|
| 1 | +module Mapforge |
| 2 | + # Turns a polyline into something that can be walked by distance. It measures the line once and |
| 3 | + # then answers both directions: where a given distance along it lands, and how far along a given |
| 4 | + # coordinate lies. The train map needs both, because a station is a coordinate that has to become |
| 5 | + # a kilometre mark and a train is a kilometre mark that has to become a coordinate. |
| 6 | + class LineIndex |
| 7 | + # Spherical, so point.distance answers in great-circle meters. The default cartesian factory |
| 8 | + # would answer in degrees, which are only a fixed length along the equator. |
| 9 | + FACTORY = RGeo::Geographic.spherical_factory |
| 10 | + |
| 11 | + # Length of the whole line in meters |
| 12 | + attr_reader :length |
| 13 | + |
| 14 | + # @cumulative[i] holds the distance from the start of the line to vertex i, one entry per |
| 15 | + # coordinate, so its last entry is the length of the line. Measuring every segment up front |
| 16 | + # turns each later lookup into a walk over a sorted array instead of more trigonometry. |
| 17 | + def initialize(coordinates) |
| 18 | + @coords = coordinates |
| 19 | + @cumulative = [ 0.0 ] |
| 20 | + coordinates.each_cons(2) do |a, b| |
| 21 | + @cumulative << @cumulative.last + FACTORY.point(*a).distance(FACTORY.point(*b)) |
| 22 | + end |
| 23 | + @length = @cumulative.last |
| 24 | + @cursor = 0 |
| 25 | + end |
| 26 | + |
| 27 | + # The [lon, lat] at `meters` from the start, interpolated inside the segment it falls into. |
| 28 | + # Anything past the end wraps around, so a looping animation keeps going. |
| 29 | + # |
| 30 | + # Distances are expected in ascending order (wrapping at the end of the line), so the |
| 31 | + # segment cursor only ever moves forward: it carries on from the segment the last call left |
| 32 | + # off at rather than searching the table again, and rewinds only when a distance arrives |
| 33 | + # behind it, which is what a wrap looks like. |
| 34 | + def position_at(meters) |
| 35 | + meters %= @length |
| 36 | + @cursor = 0 if @cumulative[@cursor] > meters |
| 37 | + @cursor += 1 while @cursor < @coords.size - 2 && @cumulative[@cursor + 1] <= meters |
| 38 | + a, b = @coords[@cursor], @coords[@cursor + 1] |
| 39 | + segment = @cumulative[@cursor + 1] - @cumulative[@cursor] |
| 40 | + t = segment.zero? ? 0.0 : (meters - @cumulative[@cursor]) / segment |
| 41 | + [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t ] |
| 42 | + end |
| 43 | + |
| 44 | + # The other direction: how many meters from the start the given coordinate lies. Every vertex |
| 45 | + # is measured against it, which is affordable because this runs once per station at setup, |
| 46 | + # unlike position_at, which runs once per train per tick. |
| 47 | + # |
| 48 | + # ponytail: snaps to the nearest line vertex, not the nearest point on the segment. Fine for |
| 49 | + # dense geometry (OSM rail is metres apart); project onto the segment if that ever gets coarse. |
| 50 | + def distance_at(coordinate) |
| 51 | + point = FACTORY.point(*coordinate) |
| 52 | + @cumulative[(0...@coords.size).min_by { |i| point.distance(FACTORY.point(*@coords[i])) }] |
| 53 | + end |
| 54 | + end |
| 55 | +end |
0 commit comments