module NSWTopo::Labels

Constants

CENTRELINE_FRACTION
DEBUG_PARAMS
DEFAULTS
DEFAULT_SAMPLE
INSET
Label
PROPERTIES
TRANSFORMS

Public Instance Methods

add(layer) click to toggle source
# File lib/nswtopo/layer/labels.rb, line 78
def add(layer)
  category_params, base_params = layer.params.fetch("labels", {}).partition do |key, value|
    Hash === value
  end.map(&:to_h)
  collate = base_params.delete "collate"
  @params.store layer.name, base_params if base_params.any?
  category_params.each do |category, params|
    categories = Array(category).map do |category|
      [layer.name, category].join(?\s)
    end
    @params.store categories, params
  end

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

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

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

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

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

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

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

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

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

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

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

          when "trim"
            next feature unless GeoJSON::MultiLineString === feature
            distance = Float(arg) * @map.scale / 1000.0
            feature.coordinates = feature.coordinates.map do |linestring|
              linestring.trim distance
            end.reject(&:empty?)
            feature.empty? ? [] : feature
          end
        end
      rescue ArgumentError
        raise "invalid label transform: %s: %s" % [transform, [arg, *args].join(?,)]
      end.each do |feature|
        feature.properties = case feature
        when GeoJSON::MultiPoint      then point_attributes
        when GeoJSON::MultiLineString then line_attributes
        when GeoJSON::MultiPolygon    then line_attributes
        end
      end.yield_self do |features|
        GeoJSON::Collection.new(@map.projection, features).explode.extend(LabelFeatures)
      end.tap do |collection|
        collection.text, collection.layer_name = text, layer.name
      end
    end.yield_self do |collections|
      next collections unless collate
      collections.group_by(&:text).map do |text, collections|
        collections.inject(&:merge!)
      end
    end.each do |collection|
      label_features << collection
    end
  end
end
add_fence(feature, buffer) click to toggle source
# File lib/nswtopo/layer/labels.rb, line 56
def add_fence(feature, buffer)
  index = fences.length
  case feature
  when GeoJSON::Point
    [[feature.coordinates.yield_self(&to_mm)] * 2]
  when GeoJSON::LineString
    feature.coordinates.map(&to_mm).segments
  when GeoJSON::Polygon
    feature.coordinates.flat_map { |ring| ring.map(&to_mm).segments }
  end.each do |segment|
    fences << Fence.new(segment, buffer: buffer, index: index)
  end
end
drawing_features() click to toggle source
# File lib/nswtopo/layer/labels.rb, line 275
def drawing_features
  fence_index = RTree.load(fences, &:bounds)
  labelling_hull = @map.bounding_box(mm: -INSET).coordinates.first.map(&to_mm)
  debug, debug_features = Config["debug"], []
  @params = DEBUG_PARAMS.deep_merge @params if debug

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  labels.map do |label|
    label.elements.map do |element|
      [element, label.categories]
    end
  end.flatten(1).tap do |result|
    result.concat debug_features if debug
  end
end
fences() click to toggle source
# File lib/nswtopo/layer/labels.rb, line 52
def fences
  @fences ||= []
end
label_features() click to toggle source
# File lib/nswtopo/layer/labels.rb, line 70
def label_features
  @label_features ||= []
end