lib/nswtopo/layer/labels.rb in nswtopo-3.0.1 vs lib/nswtopo/layer/labels.rb in nswtopo-3.1
- old
+ new
@@ -1,14 +1,19 @@
# Based on:
# Fast Point-Feature Label Placement Algorithm for Real Time Screen Maps
# (Missae Yamamoto, Gilberto Camara, Luiz Antonio Nogueira Lorena)
-require_relative 'labels/barrier'
+require_relative 'labels/barriers'
+require_relative 'labels/convex_hull'
+require_relative 'labels/convex_hulls'
+require_relative 'labels/label'
module NSWTopo
module Labels
- include Vector, Log
+ using Helpers
+ include VectorRender, Log
+
CENTRELINE_FRACTION = 0.35
DEFAULT_SAMPLE = 5
INSET = 1
LABEL_ATTRIBUTES = %w[
@@ -90,19 +95,21 @@
stroke: hsl(300,100%,50%)
stroke-width: 0.2
YAML
def barriers
- @barriers ||= []
+ @barriers ||= Barriers.new
end
def label_features
@label_features ||= []
end
- module LabelFeatures
- attr_accessor :text, :dual, :layer_name
+ def conflicts
+ @conflicts ||= Hash.new do |conflicts, label|
+ conflicts[label] = Set[]
+ end
end
extend Forwardable
delegate :<< => :barriers
@@ -141,11 +148,11 @@
end.each do |categories, features|
transforms = params_for(categories).slice(*LABEL_TRANSFORMS)
attributes, point_attributes, line_attributes = [nil, "point", "line"].map do |extra_category|
categories | Set[*extra_category]
end.map do |categories|
- params_for(categories).slice(*LABEL_ATTRIBUTES).merge("categories" => categories)
+ params_for(categories).slice(*LABEL_ATTRIBUTES).merge(categories: categories)
end
features.map do |feature|
log_update "collecting labels: %s: feature %i of %i" % [layer.name, feature_count += 1, feature_total]
text = feature["label"]
@@ -155,10 +162,11 @@
else Array(text).map(&:to_s).map(&:strip).join(?\s)
end
dual = feature["dual"]
text.upcase! if String === text && attributes["upcase"]
dual.upcase! if String === dual && attributes["upcase"]
+ feature_attributes = { text: text, dual: dual, layer_name: layer.name }
transforms.inject([feature]) do |features, (transform, (arg, *args))|
next features unless arg
opts, args = args.partition do |arg|
Hash === arg
@@ -210,36 +218,34 @@
when "minimum-area"
area = Float(arg)
case feature
when GeoJSON::MultiLineString
- feature.coordinates = feature.coordinates.reject do |linestring|
- linestring.first == linestring.last && linestring.signed_area.abs < area
+ feature.reject_linestrings do |linestring|
+ linestring.closed? && linestring.signed_area.abs < area
end
when GeoJSON::MultiPolygon
- feature.coordinates = feature.coordinates.reject do |rings|
- rings.sum(&:signed_area) < area
+ feature.reject_polygons do |polygon|
+ polygon.area < area
end
+ else
+ feature
end
- feature.empty? ? [] : feature
when "minimum-length"
next feature unless GeoJSON::MultiLineString === feature
distance = Float(arg)
- feature.coordinates = feature.coordinates.reject do |linestring|
+ feature.reject_linestrings do |linestring|
linestring.path_length < distance
end
- feature.empty? ? [] : feature
when "minimum-hole", "remove-holes"
+ next feature unless GeoJSON::MultiPolygon === feature
area = Float(arg).abs 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
+ feature.remove_holes do |ring|
+ area ? (-area...0) === ring.signed_area : true
+ end
when "remove"
remove = [arg, *args].any? do |value|
case value
when true then true
@@ -251,326 +257,204 @@
remove ? [] : feature
when "keep-largest"
case feature
when GeoJSON::MultiLineString
- feature.coordinates = [feature.explode.max_by(&:length).coordinates]
+ feature.max_by(&:path_length).multi
when GeoJSON::MultiPolygon
- feature.coordinates = [feature.explode.max_by(&:area).coordinates]
+ feature.max_by(&:area).multi
+ else
+ feature
end
- feature
when "trim"
next feature unless GeoJSON::MultiLineString === feature
distance = Float(arg)
- feature.coordinates = feature.coordinates.map do |linestring|
- linestring.trim distance
- end.reject(&:empty?)
- feature.empty? ? [] : feature
+ feature.trim distance
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.reject(&:empty?).map do |feature|
+ case feature
+ when GeoJSON::MultiPoint then feature.with_properties(**feature_attributes, **point_attributes)
+ when GeoJSON::MultiLineString then feature.with_properties(**feature_attributes, **line_attributes)
+ when GeoJSON::MultiPolygon then feature.with_properties(**feature_attributes, **line_attributes)
end
- end.then do |features|
- GeoJSON::Collection.new(projection: @map.neatline.projection, features: features).explode.extend(LabelFeatures)
- end.tap do |collection|
- collection.text, collection.dual, collection.layer_name = text, dual, layer.name
- end
- end.then do |collections|
- next collections unless label_params["collate"]
- collections.group_by(&:text).map do |text, collections|
- collections.inject(&:merge!)
- end
- end.each do |collection|
- label_features << collection
+ end.flat_map(&:explode)
+ end.then do |features|
+ next features unless label_params["collate"]
+ features.flatten.group_by do |feature|
+ feature[:text]
+ end.values
+ end.each do |features|
+ label_features << features
end
end
end
- class Label
- def initialize(collection, label_index, feature_index, barrier_count, priority, hulls, attributes, elements, along = nil, fixed = nil)
- @label_index, @feature_index, @indices = label_index, feature_index, [label_index, feature_index]
- @collection, @barrier_count, @priority, @hulls, @attributes, @elements, @along, @fixed = collection, barrier_count, priority, hulls, attributes, elements, along, fixed
- @ordinal = [@barrier_count, @priority]
- @conflicts = Set.new
- end
-
- extend Forwardable
- delegate %i[text dual layer_name] => :@collection
- delegate %i[[] dig] => :@attributes
-
- attr_reader :label_index, :feature_index, :indices
- attr_reader :barrier_count, :hulls, :elements, :along, :fixed, :conflicts
- attr_accessor :priority, :ordinal
-
- def point?
- @along.nil?
- end
-
- def barriers?
- @barrier_count > 0
- end
-
- def optional?
- @attributes["optional"]
- end
-
- def coexists_with?(other)
- Array(@attributes["coexist"]).include? other.layer_name
- end
-
- def <=>(other)
- self.ordinal <=> other.ordinal
- end
-
- alias hash object_id
- alias eql? equal?
-
- def bounds
- @hulls.flatten(1).transpose.map(&:minmax)
- end
-
- def self.overlaps?(label1, label2, buffer:)
- return false if label1 == label2
- [label1, label2].map(&:hulls).inject(&:product).any? do |hulls|
- hulls.overlap?(buffer)
- end
- end
-
- def self.overlaps(labels, &block)
- Enumerator.new do |yielder|
- next unless labels.any?(&block)
- index = RTree.load(labels, &:bounds)
- index.each do |bounds, label|
- next unless buffer = yield(label)
- index.search(bounds, buffer: buffer).with_object(label).select do |other, label|
- overlaps? label, other, buffer: buffer
- end.inject(yielder, &:<<)
- end
- end
- end
+ def map_contains?(label)
+ @labelling_neatline ||= @map.neatline(mm: -INSET).first
+ @labelling_neatline.contains? label.hull
end
- def labelling_hull
- # TODO: doesn't account for map insets, need to replace with generalised check for non-covex @map.neatline
- @labelling_hull ||= @map.neatline(mm: -INSET).coordinates.first.transpose.map(&:minmax).inject(&:product).values_at(0,2,3,1,0)
- end
+ def point_candidates(feature)
+ margin = feature["margin"]
+ line_height = feature["line-height"]
+ font_size = feature["font-size"]
+ text = feature[:text]
- def barrier_segments
- @barrier_segments ||= barriers.flat_map(&:segments).then do |segments|
- RTree.load(segments, &:bounds)
- end
- end
-
- def point_candidates(collection, label_index, feature_index, feature)
- attributes = feature.properties
- margin = attributes["margin"]
- line_height = attributes["line-height"]
- font_size = attributes["font-size"]
-
point = feature.coordinates
- lines = Font.in_two collection.text, attributes
- lines = [[collection.text, Font.glyph_length(collection.text, attributes)]] if lines.map(&:first).map(&:length).min == 1
+ lines = Font.in_two text, feature.properties
+ lines = [[text, Font.glyph_length(text, feature.properties)]] if lines.map(&:first).map(&:length).min == 1
height = lines.map { font_size }.inject { |total| total + line_height }
- # if attributes["shield"]
+ # if feature["shield"]
# width += SHIELD_X * font_size
# height += SHIELD_Y * font_size
# end
- [*attributes["position"] || "over"].map do |position|
+ [*feature["position"] || "over"].map do |position|
dx = position =~ /right$/ ? 1 : position =~ /left$/ ? -1 : 0
dy = position =~ /^below/ ? 1 : position =~ /^above/ ? -1 : 0
next dx, dy, dx * dy == 0 ? 1 : 0.6
end.uniq.map.with_index do |(dx, dy, f), position_index|
- text_elements, hulls = lines.map.with_index do |(line, text_length), index|
- anchor = point.dup
- anchor[0] += dx * (f * margin + 0.5 * text_length)
- anchor[1] += dy * (f * margin + 0.5 * height)
- anchor[1] += (index - 0.5) * 0.5 * height unless lines.one?
+ text_elements, baselines = lines.map.with_index do |(line, text_length), index|
+ offset_x = dx * (f * margin + 0.5 * text_length)
+ offset_y = dy * (f * margin + 0.5 * height)
+ offset_y += (index - 0.5) * 0.5 * height unless lines.one?
+ anchor = point + Vector[offset_x, offset_y]
text_element = REXML::Element.new("text")
text_element.add_attribute "transform", "translate(%s)" % POINT % anchor
text_element.add_attribute "text-anchor", "middle"
text_element.add_attribute "textLength", VALUE % text_length
text_element.add_attribute "y", VALUE % (CENTRELINE_FRACTION * font_size)
text_element.add_text line
- hull = [text_length, font_size].zip(anchor).map do |size, origin|
- [origin - 0.5 * size, origin + 0.5 * size]
- end.inject(&:product).values_at(0,2,3,1)
+ offset = Vector[0.5 * text_length, 0]
+ baseline = GeoJSON::LineString.new [anchor - offset, anchor + offset]
- next text_element, hull
+ next text_element, baseline
end.transpose
- next unless hulls.all? do |hull|
- labelling_hull.surrounds? hull
- end
-
- bounds = hulls.flatten(1).transpose.map(&:minmax)
- barrier_count = barrier_segments.search(bounds).with_object Set[] do |segment, barriers|
- next if barriers === segment.barrier
- hulls.any? do |hull|
- barriers << segment.barrier if segment.conflicts_with? hull
- end
- end.size
- priority = [position_index, feature_index]
- Label.new collection, label_index, feature_index, barrier_count, priority, hulls, attributes, text_elements
- end.compact.reject do |candidate|
+ priority = [position_index, feature[:feature_index]]
+ Label.new baselines.inject(&:+), feature, priority, text_elements, &barriers
+ end.reject do |candidate|
candidate.optional? && candidate.barriers?
+ end.select do |candidate|
+ map_contains? candidate
end.tap do |candidates|
candidates.combination(2).each do |candidate1, candidate2|
- candidate1.conflicts << candidate2
- candidate2.conflicts << candidate1
+ conflicts[candidate1] << candidate2
+ conflicts[candidate2] << candidate1
end
end
end
- def line_string_candidates(collection, label_index, feature_index, feature)
- closed = feature.coordinates.first == feature.coordinates.last
- pairs = closed ? :ring : :segments
- data = feature.coordinates
+ def line_string_candidates(feature)
+ orientation = feature["orientation"]
+ max_turn = feature["max-turn"] * Math::PI / 180
+ min_radius = feature["min-radius"]
+ max_angle = feature["max-angle"] * Math::PI / 180
+ curved = feature["curved"]
+ sample = feature["sample"]
+ font_size = feature["font-size"]
+ text = feature[:text]
- attributes = feature.properties
- 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"]
- font_size = attributes["font-size"]
+ closed = feature.closed?
- text_length = case collection.text
- when REXML::Element then data.path_length
- when String then Font.glyph_length collection.text, attributes
+ text_length = case text
+ when REXML::Element then feature.path_length
+ when String then Font.glyph_length text, feature.properties
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]
+ points, deltas, angles, avoid = feature.coordinates.each_cons(2).flat_map do |p0, p1|
+ next [p0] if REXML::Element === text
+ distance = (p1 - p0).norm
+ next [p0] if curved && distance >= text_length
+ (0...1).step(sample/distance).map do |fraction|
+ p0 * (1 - fraction) + p1 * fraction
+ end
+ end.then do |points|
+ if closed
+ p0, p2 = points.last, points.first
+ points.unshift(p0).push(p2)
else
- steps = (distance / sample).ceil
- memo += steps.times.map do |step|
- segment.along(step.to_f / steps)
- end
+ points.push(feature.coordinates.last).unshift(nil).push(nil)
end
- end
- points << data.last unless closed
+ end.each_cons(3).map do |p0, p1, p2|
+ next p1, 0, 0, false unless p0
+ next p1, (p1 - p0).norm, 0, false unless p2
+ o01, o12, o20 = p1 - p0, p2 - p1, p0 - p2
+ l01, l12, l20 = o01.norm, o12.norm, o20.norm
+ h01, h12 = o01 / l01, o12 / l12
+ angle = Math::atan2 h01.cross(h12), h01.dot(h12)
+ semiperimeter = (l01 + l12 + l20) / 2
+ area_squared = [0, semiperimeter * (semiperimeter - l01) * (semiperimeter - l12) * (semiperimeter - l20)].max
+ curvature = 4 * Math::sqrt(area_squared) / (l01 * l12 * l20)
+ avoid = angle.abs > max_angle || min_radius * (curvature || 0) > 1
+ next p1, l01, angle, avoid
+ end.transpose
- segments = points.send(pairs)
- vectors = segments.map(&:diff)
- distances = vectors.map(&:norm)
-
- cumulative = distances.inject([0]) do |memo, distance|
- memo << memo.last + distance
+ total, distances = deltas.inject([0, []]) do |(total, distances), delta|
+ next total += delta, distances << total
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)
+ start = points.length.times
+ stop = closed ? points.length.times.cycle : points.length.times
+ indices = [stop.next]
- 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)
+ Enumerator.produce do
+ while indices.length > 1 && deltas.values_at(*indices).drop(1).sum >= text_length do
+ start.next
+ indices.shift
+ end
+ until indices.length > 1 && deltas.values_at(*indices).drop(1).sum >= text_length do
+ indices.push stop.next
+ end
- dont_use = angles.zip(curvatures).map do |angle, curvature|
- angle.abs > max_angle || min_radius * curvature > 1
- end
+ interior = indices.values_at(1...-1)
+ angle_sum, angle_sum_min, angle_sum_max, angle_square_sum = interior.inject [0, 0, 0, 0] do |(sum, min, max, square_sum), index|
+ next sum += angles[index], [min, sum].min, [max, sum].max, square_sum + angles[index]**2
+ end
- squared_angles = angles.map { |angle| angle * angle }
+ redo if angle_sum_max - angle_sum_min > max_turn
+ redo if curved && indices.length < 3
+ redo if avoid.values_at(*interior).any?
- barrier_overlaps = Hash.new do |overlaps, label_segment|
- bounds = label_segment.transpose.map(&:minmax)
- buffer = 0.5 * font_size
- overlaps[label_segment] = barrier_segments.search(bounds, buffer: buffer).select do |barrier_segment|
- barrier_segment.conflicts_with?(label_segment, buffer: buffer)
- end.inject Set[] do |barriers, segment|
- barriers.add segment.barrier
+ baseline = GeoJSON::LineString.new(points.values_at *indices).crop(text_length).then do |baseline|
+ case orientation
+ when "uphill", "anticlockwise" then true
+ when "downhill", "clockwise" then false
+ else baseline.coordinates.values_at(0, -1).map(&:x).inject(&:<=)
+ end ? baseline : baseline.reverse
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.first, indices.last)
- 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)
-
- barrier_count = baseline.segments.each.with_object Set[] do |segment, barriers|
- barriers.merge barrier_overlaps[segment]
- end.size
- priority = [total_squared_curvature, (total - 2 * along).abs / total.to_f]
-
- baseline.reverse! unless case orientation
- when "uphill", "anticlockwise" then true
- when "downhill", "clockwise" then false
- else baseline.values_at(0, -1).map(&:first).inject(&:<=)
+ along = distances.values_at(indices.first, indices.last).then do |d0, d1|
+ (d0 + ((d1 - d0) % total) / 2) % total
end
+ priority = [angle_square_sum, (total - 2 * along).abs / total]
- 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_id = [@name, "path", *feature.values_at(:layer_name, :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
+ path_element.add_attributes "id" => path_id, "d" => baseline.svg_path_data, "pathLength" => VALUE % text_length
text_element = REXML::Element.new("text")
- case collection.text
+ case text
when REXML::Element
fixed = true
- text_element.add_element collection.text, "href" => "#%s" % path_id
+ text_element.add_element text, "href" => "#%s" % path_id
when String
text_path = text_element.add_element "textPath", "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)
+ text_path.add_element("tspan", "dy" => VALUE % (CENTRELINE_FRACTION * font_size)).add_text(text)
end
- Label.new collection, label_index, feature_index, barrier_count, priority, [hull], attributes, [text_element, path_element], along, fixed
- end.compact.reject do |candidate|
+
+ Label.new baseline, feature, priority, [text_element, path_element], along: along, fixed: fixed, &barriers
+ end.reject do |candidate|
candidate.optional? && candidate.barriers?
+ end.select do |candidate|
+ map_contains? candidate
end.then do |candidates|
neighbours = Hash.new do |hash, candidate|
hash[candidate] = Set[]
end
candidates.each.with_index do |candidate1, index1|
@@ -589,143 +473,136 @@
candidates.sort.each.with_object Array[] do |candidate, sampled|
next if removed === candidate
removed.merge neighbours[candidate]
sampled << candidate
end.tap do |candidates|
- next unless separation = attributes.dig("separation", "along")
+ next unless separation = feature.dig("separation", "along")
separation += text_length
sorted = candidates.sort_by(&:along)
sorted.each.with_index do |candidate1, index1|
index2 = index1
loop do
index2 = (index2 + 1) % candidates.length
break if index2 == (closed ? index1 : 0)
candidate2 = sorted[index2]
offset = candidate2.along - candidate1.along
break unless offset % total < separation || (closed && -offset % total < separation)
- candidate2.conflicts << candidate1
- candidate1.conflicts << candidate2
+ conflicts[candidate2] << candidate1
+ conflicts[candidate1] << candidate2
end
end
end
end
end
def label_candidates(&debug)
- label_features.flat_map.with_index do |collection, label_index|
+ label_features.flat_map.with_index do |features, label_index|
log_update "compositing %s: feature %i of %i" % [@name, label_index + 1, label_features.length]
- collection.each do |feature|
- font_size = feature.properties["font-size"]
- feature.properties.slice(*FONT_SCALED_ATTRIBUTES).each do |key, value|
- feature.properties[key] = value.to_i * font_size * 0.01 if /^\d+%$/ === value
+ features.map do |feature|
+ font_size = feature["font-size"]
+ properties = feature.slice(*FONT_SCALED_ATTRIBUTES).transform_values do |value|
+ /^\d+%$/ === value ? value.to_i * font_size * 0.01 : value
+ end.then do |scaled_properties|
+ feature.except(*FONT_SCALED_ATTRIBUTES).merge(scaled_properties)
end
+ feature.with_properties(**properties)
end.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
+ feature.rings.explode
end
end.tap do |features|
features.each.with_object("feature", &debug) if Config["debug"]
- end.flat_map.with_index do |feature, feature_index|
+ end.map.with_index do |feature, feature_index|
+ feature.add_properties label_index: label_index, feature_index: feature_index
+ end.flat_map do |feature|
case feature
when GeoJSON::Point
- point_candidates(collection, label_index, feature_index, feature)
+ point_candidates(feature)
when GeoJSON::LineString
- line_string_candidates(collection, label_index, feature_index, feature)
+ line_string_candidates(feature)
end
end.tap do |candidates|
candidates.reject!(&:point?) unless candidates.all?(&:point?)
end.sort.each.with_index do |candidate, index|
- candidate.priority = index
+ candidate.priority.replace [index]
end
end.tap do |candidates|
- log_update "compositing %s: chosing label positions" % @name
+ log_update "compositing %s: choosing label positions" % @name
if Config["debug"]
- candidates.flat_map(&:hulls).each.with_object("candidate", &debug)
+ candidates.flat_map(&:explode).map do |ring|
+ GeoJSON::LineString.new [*ring, ring.first]
+ end.each.with_object("candidate", &debug)
candidates.clear
end
Enumerator.new do |yielder|
# separation/self: minimum distance between a label and another label for the same feature
candidates.group_by do |label|
label.label_index
end.values.each do |group|
Label.overlaps(group) do |label|
- label.dig("separation", "self")
- end.inject(yielder, &:<<)
+ label.separation["self"]
+ end.each(&yielder)
end
# separation/same: minimum distance between a label and another label with the same text
candidates.group_by do |label|
[label.layer_name, label.text]
end.values.each do |group|
Label.overlaps(group) do |label|
- label.dig("separation", "same")
- end.inject(yielder, &:<<)
+ label.separation["same"]
+ end.each(&yielder)
end
candidates.group_by do |candidate|
candidate.layer_name
end.each do |layer_name, group|
- index = RTree.load(group, &:bounds)
-
# separation/other: minimum distance between a label and another label from the same layer
- index.each do |bounds, label|
- next unless buffer = label.dig("separation", "other")
- index.search(bounds, buffer: buffer).with_object(label).select do |other, label|
- Label.overlaps? label, other, buffer: buffer
- end.inject(yielder, &:<<)
- end
+ Label.overlaps(group) do |label|
+ label.separation["other"]
+ end.each(&yielder)
# separation/<layer>: minimum distance between a label and any label from <layer>
- candidates.each do |label|
- next unless buffer = label.dig("separation", layer_name)
- index.search(label.bounds, buffer: buffer).with_object(label).select do |other, label|
- Label.overlaps? label, other, buffer: buffer
- end.inject(yielder, &:<<)
- end
+ Label.overlaps(group, candidates) do |label|
+ label.separation[layer_name]
+ end.each(&yielder)
end
# separation/dual: minimum distance between any two dual labels
candidates.select(&:dual).group_by do |label|
[label.layer_name, Set[label.text, label.dual]]
end.values.each do |group|
Label.overlaps(group) do |label|
- label.dig("separation", "dual")
- end.inject(yielder, &:<<)
+ label.separation["dual"]
+ end.each(&yielder)
end
# separation/all: minimum distance between a label and *any* other label
Label.overlaps(candidates) do |label|
# default of zero prevents any two labels overlapping
- label.dig("separation", "all") || 0
+ label.separation["all"] || 0
end.reject do |label1, label2|
label1.coexists_with?(label2) ||
label2.coexists_with?(label1)
- end.inject(yielder, &:<<)
+ end.each(&yielder)
end.each do |label1, label2|
- label1.conflicts << label2
- label2.conflicts << label1
+ conflicts[label1] << label2
+ conflicts[label2] << label1
end
end
end
def drawing_features
debug_features = []
candidates = label_candidates do |feature, category|
debug_features << [feature, Set["debug", category]]
end
- conflicts = candidates.map do |candidate|
- [candidate, candidate.conflicts.dup]
- end.to_h
-
ordered, unlabeled = AVLTree.new, Hash.new(true)
remaining = candidates.to_set.classify(&:label_index)
Enumerator.produce do |label|
if label
@@ -751,11 +628,11 @@
conflict_count = conflicts[candidate].each.with_object Set[] do |other, indices|
indices << other.label_index
end.delete(candidate.label_index).size
conflict_count += candidate.barrier_count
- unsafe = candidate.conflicts.classify(&:label_index).any? do |label_index, conflicts|
+ unsafe = conflicts[candidate].classify(&:label_index).any? do |label_index, conflicts|
next false unless unlabeled[label_index]
others = remaining[label_index].reject(&:optional?)
others.any? && others.all?(conflicts)
end
@@ -768,11 +645,11 @@
candidate.priority # better quality candidates
]
unless candidate.ordinal == ordinal
ordered.delete candidate
- candidate.ordinal = ordinal
+ candidate.ordinal.replace ordinal
ordered.insert candidate
end
end
ordered.first or raise StopIteration
@@ -780,15 +657,15 @@
grouped = candidates.group_by(&:indices)
5.times do
labels.select(&:point?).each do |label|
labels.delete label
labels << grouped[label.indices].min_by do |candidate|
- [(labels & candidate.conflicts - Set[label]).count, candidate.priority]
+ [(labels & conflicts[candidate] - Set[label]).count, candidate.priority]
end
end
end
end.flat_map do |label|
- label.elements.map.with_object(label["categories"]).entries
+ label.elements.map.with_object(label.categories).entries
end.tap do |result|
next unless debug_features.any?
@params = DEBUG_PARAMS.deep_merge @params
result.concat debug_features
end