Skip to content

Commit 613a3ba

Browse files
committed
line + position methods
1 parent 49396ea commit 613a3ba

3 files changed

Lines changed: 97 additions & 4 deletions

File tree

app/lib/mapforge/line_index.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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

lib/tasks/animation.rake

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ namespace :animation do
77
speed = (args[:speed] || 10).to_f # meters/second
88
interval = 2 # seconds between updates, the browser interpolates in between
99

10-
walker = Mapforge::PathWalker.new(line.coordinates(include_height: false))
11-
abort "Line #{line.id} has zero length" if walker.length.zero?
12-
puts "Line length: #{walker.length.round} m, speed: #{speed} m/s"
10+
line_index = Mapforge::LineIndex.new(line.coordinates(include_height: false))
11+
abort "Line #{line.id} has zero length" if line_index.length.zero?
12+
puts "Line length: #{line_index.length.round} m, speed: #{speed} m/s"
1313

1414
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1515
tick = 0
1616
loop do
17-
coordinates = walker.position_at(speed * interval * tick)
17+
coordinates = line_index.position_at(speed * interval * tick)
1818
point.update(geometry: { "type" => "Point", "coordinates" => coordinates })
1919
tick += 1
2020
sleep [ started + tick * interval - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0 ].max
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
require "rails_helper"
2+
3+
RSpec.describe Mapforge::LineIndex do
4+
subject(:index) { described_class.new(coordinates) }
5+
6+
# unevenly spaced: a cluster of short segments, then one long straight
7+
let(:coordinates) { [ [ 13.0, 52.0 ], [ 13.0001, 52.0 ], [ 13.0002, 52.0 ], [ 13.01, 52.0 ] ] }
8+
9+
def meters_between(a, b)
10+
factory = RGeo::Geographic.spherical_factory
11+
factory.point(*a).distance(factory.point(*b))
12+
end
13+
14+
it "moves the same distance for every equal step, regardless of vertex spacing" do
15+
step = index.length / 20
16+
positions = (0..19).map { |i| index.position_at(step * i) }
17+
distances = positions.each_cons(2).map { |a, b| meters_between(a, b) }
18+
19+
expect(distances).to all(be_within(0.01).of(step))
20+
end
21+
22+
it "returns the line vertices at their cumulative distances" do
23+
expect(index.position_at(0)).to eq(coordinates.first)
24+
expect(meters_between(index.position_at(meters_between(*coordinates.first(2))), coordinates[1]))
25+
.to be_within(0.01).of(0)
26+
end
27+
28+
it "maps a coordinate back to its cumulative distance along the line" do
29+
expect(index.distance_at(coordinates.first)).to eq(0)
30+
expect(index.distance_at(coordinates[1])).to be_within(0.01).of(meters_between(*coordinates.first(2)))
31+
expect(index.distance_at(coordinates.last)).to be_within(0.01).of(index.length)
32+
end
33+
34+
it "wraps around at the end of the line" do
35+
expect(meters_between(index.position_at(index.length), coordinates.first)).to be_within(0.01).of(0)
36+
expect(index.position_at(index.length + 25)).to eq(index.position_at(25))
37+
end
38+
end

0 commit comments

Comments
 (0)