lib/nswtopo/layer/labels.rb



# Based on:
#   Fast Point-Feature Label Placement Algorithm for Real Time Screen Maps
#   (Missae Yamamoto, Gilberto Camara, Luiz Antonio Nogueira Lorena)

require_relative 'labels/barriers'
require_relative 'labels/convex_hull'
require_relative 'labels/convex_hulls'
require_relative 'labels/label'

module NSWTopo
  module Labels
    using Helpers
    include VectorRender, Log

    CENTRELINE_FRACTION = 0.35
    DEFAULT_SAMPLE = 5
    INSET = 1

    LABEL_ATTRIBUTES = %w[
      coexist
      curved
      font-family
      font-size
      font-style
      font-variant
      font-weight
      format
      knockout
      letter-spacing
      line-height
      margin
      max-angle
      max-turn
      min-radius
      optional
      orientation
      position
      sample
      separation
      separation-all
      separation-along
      shield
      upcase
      word-spacing
    ]

    LABEL_TRANSFORMS = %w[
      buffer
      fallback
      keep-largest
      minimum-area
      minimum-hole
      minimum-length
      offset
      reduce
      remove
      remove-holes
      smooth
      trim
    ]

    LABEL_PARAMS = LABEL_ATTRIBUTES + LABEL_TRANSFORMS + SVG_ATTRIBUTES

    DEFAULTS = YAML.load <<~YAML
      knockout: true
      preserve: true
      stroke: none
      fill: black
      font-style: italic
      font-family: Arial, Helvetica, sans-serif
      font-size: 1.8
      line-height: 110%
      margin: 1.0
      max-turn: 60
      min-radius: 0
      max-angle: #{StraightSkeleton::DEFAULT_ROUNDING_ANGLE}
      sample: #{DEFAULT_SAMPLE}
    YAML

    DEBUG_PARAMS = YAML.load <<~YAML
      debug:
        dupe: ~
        fill: none
        opacity: 0.5
        knockout: false
      debug feature:
        stroke: hsl(260,100%,50%)
        stroke-width: 0.2
        symbol:
          circle:
            r: 0.3
            stroke: none
            fill: hsl(260,100%,50%)
      debug candidate:
        stroke: hsl(300,100%,50%)
        stroke-width: 0.2
    YAML

    def barriers
      @barriers ||= Barriers.new
    end

    def label_features
      @label_features ||= []
    end

    def conflicts
      @conflicts ||= Hash.new do |conflicts, label|
        conflicts[label] = Set[]
      end
    end

    extend Forwardable
    delegate :<< => :barriers

    def add(layer)
      label_params = layer.params.fetch("labels", {})
      label_params.except(*LABEL_PARAMS).select do |key, value|
        Hash === value
      end.transform_keys do |categories|
        Array(categories).map do |category|
          [layer.name, category].join(?\s)
        end
      end.then do |params|
        { layer.name => label_params }.merge(params)
      end.transform_values do |params|
        params.slice(*LABEL_PARAMS)
      end.transform_values do |params|
        # handle legacy format for separation, separation-all, separation-along
        params.each.with_object("separation" => Hash[]) do |(key, value), hash|
          case [key, value]
          in ["separation",    Hash] then hash["separation"].merge! value
          in ["separation",       *] then hash["separation"].merge! "self"  => value
          in ["separation-all",   *] then hash["separation"].merge! "other" => value
          in ["separation-along", *] then hash["separation"].merge! "along" => value
          else hash[key] = value
          end
        end
      end.then do |category_params|
        @params.merge! category_params
      end

      feature_count = feature_total = 0
      layer.labeling_features.tap do |features|
        feature_total = features.length
      end.map(&:multi).group_by do |feature|
        Set[layer.name, *feature["category"]]
      end.each do |categories, features|
        transforms = 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)
        end

        features.map do |feature|
          log_update "collecting labels: %s: feature %i of %i" % [layer.name, feature_count += 1, feature_total]
          text = feature["label"]
          text = case
          when REXML::Element === text then text
          when attributes["format"] then attributes["format"] % text
          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
            end
            opts = opts.inject({}, &:merge).transform_keys(&:to_sym)
            features.flat_map do |feature|
              case transform
              when "reduce"
                case arg
                when "skeleton"
                  feature.respond_to?(arg) ? feature.send(arg) : feature
                when "centrelines"
                  feature.respond_to?(arg) ? feature.send(arg, **opts) : feature
                when "centrepoints"
                  interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
                  feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
                when "centres"
                  interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
                  feature.respond_to?(arg) ? feature.send(arg, interval: interval, **opts) : feature
                when "centroids"
                  feature.respond_to?(arg) ? feature.send(arg) : feature
                when "samples"
                  interval = Float(opts.delete(:interval) || DEFAULT_SAMPLE)
                  feature.respond_to?(arg) ? feature.send(arg, interval) : feature
                else
                  raise "unrecognised label transform: reduce: %s" % arg
                end

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

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

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

              when "minimum-area"
                area = Float(arg)
                case feature
                when GeoJSON::MultiLineString
                  feature.reject_linestrings do |linestring|
                    linestring.closed? && linestring.signed_area.abs < area
                  end
                when GeoJSON::MultiPolygon
                  feature.reject_polygons do |polygon|
                    polygon.area < area
                  end
                else
                  feature
                end

              when "minimum-length"
                next feature unless GeoJSON::MultiLineString === feature
                distance = Float(arg)
                feature.reject_linestrings do |linestring|
                  linestring.path_length < distance
                end

              when "minimum-hole", "remove-holes"
                next feature unless GeoJSON::MultiPolygon === feature
                area = Float(arg).abs unless true == arg
                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
                  when String  then text == value
                  when Regexp  then text =~ value
                  when Numeric then text == value.to_s
                  end
                end
                remove ? [] : feature

              when "keep-largest"
                case feature
                when GeoJSON::MultiLineString
                  feature.max_by(&:path_length).multi
                when GeoJSON::MultiPolygon
                  feature.max_by(&:area).multi
                else
                  feature
                end

              when "trim"
                next feature unless GeoJSON::MultiLineString === feature
                distance = Float(arg)
                feature.trim distance
              end
            end
          rescue ArgumentError
            raise "invalid label transform: %s: %s" % [transform, [arg, *args].join(?,)]
          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.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

    def map_contains?(label)
      @labelling_neatline ||= @map.neatline(mm: -INSET).first
      @labelling_neatline.contains? label.hull
    end

    def point_candidates(feature)
      margin      = feature["margin"]
      line_height = feature["line-height"]
      font_size   = feature["font-size"]
      text        = feature[:text]

      point = feature.coordinates
      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 feature["shield"]
      #   width += SHIELD_X * font_size
      #   height += SHIELD_Y * font_size
      # end

      [*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, 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

          offset = Vector[0.5 * text_length, 0]
          baseline = GeoJSON::LineString.new [anchor - offset, anchor + offset]

          next text_element, baseline
        end.transpose

        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|
          conflicts[candidate1] << candidate2
          conflicts[candidate2] << candidate1
        end
      end
    end

    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]

      closed = feature.closed?

      text_length = case text
      when REXML::Element then feature.path_length
      when String then Font.glyph_length text, feature.properties
      end

      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
          points.push(feature.coordinates.last).unshift(nil).push(nil)
        end
      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

      total, distances = deltas.inject([0, []]) do |(total, distances), delta|
        next total += delta, distances << total
      end

      start = points.length.times
      stop = closed ? points.length.times.cycle : points.length.times
      indices = [stop.next]

      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

        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

        redo if angle_sum_max - angle_sum_min > max_turn
        redo if curved && indices.length < 3
        redo if avoid.values_at(*interior).any?

        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

        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]

        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" => baseline.svg_path_data, "pathLength" => VALUE % text_length
        text_element = REXML::Element.new("text")

        case text
        when REXML::Element
          fixed = true
          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(text)
        end

        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|
          index2 = index1
          loop do
            index2 = (index2 + 1) % candidates.length
            break if index2 == (closed ? index1 : 0)
            candidate2 = candidates[index2]
            offset = candidate2.along - candidate1.along
            break unless offset % total < sample || (closed && -offset % total < sample)
            neighbours[candidate2] << candidate1
            neighbours[candidate1] << candidate2
          end
        end
        removed = Set[]
        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 = 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)
              conflicts[candidate2] << candidate1
              conflicts[candidate1] << candidate2
            end
          end
        end
      end
    end

    def label_candidates(&debug)
      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]
        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.rings.explode
          end
        end.tap do |features|
          features.each.with_object("feature", &debug) if Config["debug"]
        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(feature)
          when GeoJSON::LineString
            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.replace [index]
        end
      end.tap do |candidates|
        log_update "compositing %s: choosing label positions" % @name

        if Config["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.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.separation["same"]
            end.each(&yielder)
          end

          candidates.group_by do |candidate|
            candidate.layer_name
          end.each do |layer_name, group|
            # separation/other: minimum distance between a label and another label from the same layer
            Label.overlaps(group) do |label|
              label.separation["other"]
            end.each(&yielder)

            # separation/<layer>: minimum distance between a label and any label from <layer>
            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.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.separation["all"] || 0
          end.reject do |label1, label2|
            label1.coexists_with?(label2) ||
            label2.coexists_with?(label1)
          end.each(&yielder)
        end.each do |label1, label2|
          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

      ordered, unlabeled = AVLTree.new, Hash.new(true)
      remaining = candidates.to_set.classify(&:label_index)

      Enumerator.produce do |label|
        if label
          removals = Set[label] | conflicts[label]
          if first = unlabeled[label.label_index]
            removals.merge remaining[label.label_index].select(&:barriers?)
            unlabeled[label.label_index] = false
          end

          removals.each do |candidate|
            remaining[candidate.label_index].delete candidate
            ordered.delete candidate
          end

          conflicts.values_at(*removals).inject(Set[], &:|).subtract(removals).each do |candidate|
            conflicts[candidate].subtract removals
          end.tap do |changed|
            changed.merge remaining[label.label_index] if first
          end
        else
          candidates
        end.each do |candidate|
          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 = 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

          ordinal = [
            candidate.fixed                  ? 0 : 1, # fixed grid-line labels
            candidate.optional?              ? 1 : 0, # non-optional candidates
            unsafe                           ? 1 : 0, # candidates which don't prevent another feature being labeled altogether
            unlabeled[candidate.label_index] ? 0 : 1, # candidates for unlabeled features
            conflict_count,                           # candidates with fewer conflicts
            candidate.priority                        # better quality candidates
          ]

          unless candidate.ordinal == ordinal
            ordered.delete candidate
            candidate.ordinal.replace ordinal
            ordered.insert candidate
          end
        end

        ordered.first or raise StopIteration
      end.to_set.tap do |labels|
        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 & conflicts[candidate] - Set[label]).count, candidate.priority]
            end
          end
        end
      end.flat_map do |label|
        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
    end
  end
end