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