Continuing our deep-dive HOWTO about building Map::Tube
maps, we describe the structure of a Map::Tube
map file, extend the map for the first tram line, and use a plugin to graph the network.
The first post in this series introduced us to Map::Tube
. There, we built the fundamental structure of the Map::Tube::Hannover
module and created the basic map file for the Hannover tram network. This time, we’ll look at a map file’s structure and extend the network. At the end, we’ll visualise a graph of the railway network we’ve created so far.
Structural understanding
Now that we’ve created a basic map, our goal is to understand the structure of Map::Tube
maps a bit more. This way we won’t trip up when extending our map to a more full network.
As a reminder, the map file we have at present looks like this:
{
"name" : "Hannover",
"lines" : {
"line" : [
{
"id" : "L1",
"name" : "Linie 1"
}
]
},
"stations" : {
"station" : [
{
"id" : "H1",
"name" : "Hauptbahnhof",
"line" : "L1",
"link" : "H2"
},
{
"id" : "H2",
"name" : "Langenhagen",
"line" : "L1",
"link" : "H1"
}
]
}
}
Let’s consider each of the elements in this map.
Each map can have a name. This is a string providing a human-readable name of the map, specified by the name
attribute. Even though a map doesn’t necessarily have to have a name, it’s very handy to have, hence I’ve included it here.
Each map has one lines
attribute which contains a line
array containing the individual lines in the network. There should be at least one line in the map. Each line has to have an ID (specified by the id
attribute) and a name (specified by the name
attribute).
A valid map must also have at least two stations on one line. We define stations in the stations
attribute. It contains a station
array of individual stations. A given station must have an ID (given by the id
attribute) and a name (given by the name
attribute). We must assign a station to at least one of the lines specified in the lines
attribute. It should also link to at least one other station by specifying the relevant ID in its link
attribute.
There are more things that a map file can contain. For instance, a line can have a color
attribute to specify its colour. Also, stations can link to other stations indirectly by using the other_link
attribute. This way we can represent connections via something like a tunnel, passageway or escalator.
For all the gory details, check out the formal requirements for maps section of the Map::Tube::Cookbook
documentation.
Extending the network
Where to from here? Well, Linie 1
needs more stations to better reflect the situation in real life. Also, the network needs more lines as well as connections between those lines, again reflecting reality better. Fortunately, these are things we can test, so we’ll expand the test suite as we go along.
We’d best get on with it then!
Walking the line
We currently only have two stations in our network. That’s not enough! To flesh things out a bit, let’s add some more stations along Linie 1
. To be specific, let’s add the other terminal station on that line, Sarstedt
, as well as stations between Hauptbahnhof
and the respective terminal stations. I’ve decided to choose Kabelkamp
on the north side and Laatzen
on the south side.
One thing that we’re going to have to be careful with is giving each station an ID. How are we going to do that in some kind of systematic way? The first thing I thought of was to go left to right across the network. By this, I mean that the station furthest in the west along the given line is what I shall consider to be the first station along that line. This decision is arbitrary, but it should be good enough for our purposes.
Although it’s not clear from the Üstra network plan,1 it turns out that Langenhagen
is further west than Sarstedt
. So, I chose Langenhagen
to be the first station along that line. Because this is also the first line in the network, Langenhagen
has the honour of having the first station ID in the map file.
Thus, for Linie 1
, we have these stations, their respective new labels, and their links:
Station | ID | Links |
---|---|---|
Langenhagen | H1 | H2 |
Kabelkamp | H2 | H1,H3 |
Hauptbahnhof | H3 | H2,H4 |
Laatzen | H4 | H3,H5 |
Sarstedt | H5 | H4 |
Our goal for now is to have these stations connected along Linie 1
with their respective IDs and links. We’ll achieve this goal in small steps so that we’re not changing too much in one go.
Note also that I’m doing all this work by hand. This allows us to see how all the pieces fit together, which is one main goal of this HOWTO. In reality, however, a railway network is much more complex and with many more stations. Thus, to create the full network, one would need an automated way to extract line and station data from e.g. OpenStreetMap. We would then collect this information and export it in the form that Map::Tube
needs. But, that’s not important right now, so we’ll continue adding stations and lines manually.
Each station in our current network describes the connection it has to other stations via its link
attribute. Stations at the ends of the line only link to one other station because that’s what it means for a station to be at the end of a line. The remaining stations link to two stations each, connecting one end of the line to the other like the proverbial string of pearls. In general, one can have many links at a given station, especially if many lines cross at a given station. Right now we don’t need this extra complexity and will keep things simple.
Let’s kick-start the implementation of the full list of stations for Linie 1
by adding the station Sarstedt
. To get the ball rolling, we’ll start (as usual) with a test.
What we want to check is that there is a route from Langenhagen
to Sarstedt
and that stations along that route match our expectations. How do we go about doing this? Again, the Map::Tube
framework comes to our rescue. It provides the ok_map_routes()
assertion in the Test::Map::Tube
module which we can use to check our route. Also, the docs help again, by providing a simple route-checking example.
Before we add this test, let’s remove some code duplication in our test suite. Note that currently, we’re creating a Map::Tube::Hannover
object twice in the tests. Really, we only need to do that once. Let’s instantiate a single object and pass that to our test functions.
Assign a variable called $hannover
to the instantiated Map::Tube::Hannover
object before the ok_map*()
functions, like so:
my $hannover = Map::Tube::Hannover->new;
Then replace Map::Tube::Hannover->new
in the calls to the ok_map*()
functions with the new variable:
ok_map($hannover);
ok_map_functions($hannover);
Our test file (t/map-tube-hannover.t
) now looks like this:
use strict;
use warnings;
use Test::More;
use Map::Tube::Hannover;
use Test::Map::Tube;
my $hannover = Map::Tube::Hannover->new;
ok_map($hannover);
ok_map_functions($hannover);
done_testing();
Running this test with prove
, we find that the tests still pass.
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. ok
All tests successful.
Files=1, Tests=2, 0 wallclock secs ( 0.04 usr 0.00 sys + 0.48 cusr 0.05 csys = 0.57 CPU)
Result: PASS
Great! That’s worthy of a commit:
$ git commit -m "Extract repeated object instantiation into variable in tests" t/map-tube-hannover.t
[main f9005d1] Extract repeated object instantiation into variable in tests
1 file changed, 4 insertions(+), 2 deletions(-)
Now we’re ready to check the route from Langenhagen
to Sarstedt
via Hauptbahnhof
. We start by defining an array of strings containing route descriptions:
my @routes = (
"Route 1|Langenhagen|Sarstedt|Langenhagen,Hauptbahnhof,Sarstedt",
);
Although this array only contains one element, we’ll be extending it later, so using a plural name is ok in this situation. Also, the ok_map_routes()
test function requires an array reference as one of its arguments, hence creating an array now is sensible forward-thinking.
We check the route by calling ok_map_routes()
like so:
ok_map_routes($hannover, \@routes);
The complete test file is now:
use strict;
use warnings;
use Test::More;
use Map::Tube::Hannover;
use Test::Map::Tube;
my $hannover = Map::Tube::Hannover->new;
ok_map($hannover);
ok_map_functions($hannover);
my @routes = (
"Route 1|Langenhagen|Sarstedt|Langenhagen,Hauptbahnhof,Sarstedt",
);
ok_map_routes($hannover, \@routes);
done_testing();
We don’t expect the tests to pass. Even so, what feedback do they give us?
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. 1/? Map::Tube::get_node_by_name(): ERROR: Invalid Station Name [Sarstedt]. (status: 101) file /home/cochrane/perl5/perlbrew/perls/perl-5.38.3/lib/site_perl/5.38.3/Map/Tube.pm on line 897
# Tests were run but no plan was declared and done_testing() was not seen.
# Looks like your test exited with 255 just after 2.
t/map-tube-hannover.t .. Dubious, test returned 255 (wstat 65280, 0xff00)
All 2 subtests passed
Test Summary Report
-------------------
t/map-tube-hannover.t (Wstat: 65280 (exited 255) Tests: 2 Failed: 0)
Non-zero exit status: 255
Parse errors: No plan found in TAP output
Files=1, Tests=2, 1 wallclock secs ( 0.03 usr 0.01 sys + 0.47 cusr 0.05 csys = 0.56 CPU)
Result: FAIL
Ok, so Map::Tube::Hannover
doesn’t know about the station Sarstedt
. Let’s update the map file and add a station entry for Sarstedt
, linking it to Hauptbahnhof
at this step. We’ll also relabel Langenhagen
and Hauptbahnhof
and reorder the entries within the map file so that Langenhagen
is at the top and Sarstedt
at the bottom, thus matching the north-south direction of this line. Remember that the links I’m using right now aren’t those that we want in the end: this is merely a step along that path.
Our list of stations now looks like this:
"stations" : {
"station" : [
{
"id" : "H1",
"name" : "Langenhagen",
"line" : "L1",
"link" : "H3"
},
{
"id" : "H3",
"name" : "Hauptbahnhof",
"line" : "L1",
"link" : "H1,H5"
},
{
"id" : "H5",
"name" : "Sarstedt",
"line" : "L1",
"link" : "H3"
}
]
}
Testing this change:
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. ok
All tests successful.
Files=1, Tests=3, 1 wallclock secs ( 0.06 usr 0.00 sys + 0.49 cusr 0.05 csys = 0.60 CPU)
Result: PASS
Great! We’ve got a working route from Langenhagen
to Sarstedt
! Let’s add in the stations that we left out in the last step.
Update the routes test to this:
my @routes = (
"Route 1|Langenhagen|Sarstedt|Langenhagen,Kabelkamp,Hauptbahnhof,Laatzen,Sarstedt",
);
ok_map_routes($hannover, \@routes);
And check what the test output tells us:
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. 1/? Map::Tube::get_node_by_name(): ERROR: Invalid Station Name [Kabelkamp]. (status: 101) file /home/cochrane/perl5/perlbrew/perls/perl-5.38.3/lib/site_perl/5.38.3/Test/Map/Tube.pm on line 1434
# Tests were run but no plan was declared and done_testing() was not seen.
# Looks like your test exited with 255 just after 2.
t/map-tube-hannover.t .. Dubious, test returned 255 (wstat 65280, 0xff00)
All 2 subtests passed
Test Summary Report
-------------------
t/map-tube-hannover.t (Wstat: 65280 (exited 255) Tests: 2 Failed: 0)
Non-zero exit status: 255
Parse errors: No plan found in TAP output
Files=1, Tests=2, 0 wallclock secs ( 0.03 usr 0.00 sys + 0.50 cusr 0.04 csys = 0.57 CPU)
Result: FAIL
Ok, the station Kabelkamp
is missing. Adding its entry to the map file after Langenhagen
, and updating the links for the Langenhagen
and Hauptbahnhof
stations, we now have this stations list:
"stations" : {
"station" : [
{
"id" : "H1",
"name" : "Langenhagen",
"line" : "L1",
"link" : "H2"
},
{
"id" : "H2",
"name" : "Kabelkamp",
"line" : "L1",
"link" : "H1,H3"
},
{
"id" : "H3",
"name" : "Hauptbahnhof",
"line" : "L1",
"link" : "H2,H5"
},
{
"id" : "H5",
"name" : "Sarstedt",
"line" : "L1",
"link" : "H3"
}
]
}
Re-running the tests, we get:
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. 1/? Map::Tube::get_node_by_name(): ERROR: Invalid Station Name [Laatzen]. (status: 101) file /home/cochrane/perl5/perlbrew/perls/perl-5.38.3/lib/site_perl/5.38.3/Test/Map/Tube.pm on line 1434
# Tests were run but no plan was declared and done_testing() was not seen.
# Looks like your test exited with 255 just after 2.
t/map-tube-hannover.t .. Dubious, test returned 255 (wstat 65280, 0xff00)
All 2 subtests passed
Test Summary Report
-------------------
t/map-tube-hannover.t (Wstat: 65280 (exited 255) Tests: 2 Failed: 0)
Non-zero exit status: 255
Parse errors: No plan found in TAP output
Files=1, Tests=2, 0 wallclock secs ( 0.03 usr 0.00 sys + 0.48 cusr 0.05 csys = 0.56 CPU)
Result: FAIL
The tests are still failing, but we’re getting the failure that we expect to see. In other words, we expect to see the error about Kabelkamp
disappear but expect to see an error about Laatzen
. This is because we haven’t added Laatzen
yet. Adding the station entry for Laatzen
and fixing up the links in the stations list, we get:
"stations" : {
"station" : [
{
"id" : "H1",
"name" : "Langenhagen",
"line" : "L1",
"link" : "H2"
},
{
"id" : "H2",
"name" : "Kabelkamp",
"line" : "L1",
"link" : "H1,H3"
},
{
"id" : "H3",
"name" : "Hauptbahnhof",
"line" : "L1",
"link" : "H2,H4"
},
{
"id" : "H4",
"name" : "Laatzen",
"line" : "L1",
"link" : "H3,H5"
},
{
"id" : "H5",
"name" : "Sarstedt",
"line" : "L1",
"link" : "H4"
}
]
}
You’ll find that the tests now pass:
$ prove -lr t/map-tube-hannover.t
t/map-tube-hannover.t .. ok
All tests successful.
Files=1, Tests=3, 0 wallclock secs ( 0.03 usr 0.00 sys + 0.51 cusr 0.03 csys = 0.57 CPU)
Result: PASS
Yay! 🎉
That’s worth another commit:
$ git commit -m "Extend list of stations on Linie 1" share/hannover-map.json t/map-tube-hannover.t
[main a742db3] Extend list of stations on Linie 1
2 files changed, 27 insertions(+), 3 deletions(-)
Seeing the bigger picture
To visualise what our map looks like, we can use the Map::Tube::Plugin::Graph
plugin. Let’s install the plugin and see what it does:
$ cpanm Map::Tube::Plugin::Graph
Note that you might need to install Graphviz before installing the plugin, e.g.:
$ sudo apt install graphviz
We’re going to create a small program to convert our Map::Tube
map into a PNG image of a Graphviz graph. To keep things nice and tidy, let’s create a bin/
directory to keep our program in:
$ mkdir bin
Now, with your favourite editor, open a file called bin/map2image.pl
and enter into it the following code:
use strict;
use warnings;
use lib qw(lib);
use Map::Tube::Hannover;
my $hannover = Map::Tube::Hannover->new;
my $map_name = $hannover->name;
open(my $map_image, ">", "$map_name.png")
or die "ERROR: Can't open $map_name.png: $!";
binmode($map_image);
print $map_image $hannover->as_png;
close($map_image);
# vim: expandtab shiftwidth=4
In this program, we specify the location of the lib
directory explicitly. This saves us from having to use -I lib
when invoking perl
on the command line. We then import our Map::Tube::Hannover
module and instantiate a new Map::Tube::Hannover
object. We also save the map’s name for later use as part of the output image filename.
Then we open the output file and barf if something went wrong. Since the output image is a PNG, we need to set the output mode to binary. After that, we print the output of the Map::Tube::Plugin::Graph
plugin’s as_png()
method2 to file and close the file.
Running our new program like so:
$ perl bin/map2image.pl
produces an image file called Hannover.png
in the base project directory. Opening this image in an image viewer, you should output similar to this:
Nice!
It’s fairly obvious from the input map file that our network is a straight line. Even so, it’s nice to see this in an image rather than having to deduce it from only the map file’s structure.
It’ll be handy having such a tool around when developing the map further, so let’s add it to the repository and commit that change:
$ git commit -m "Add program to convert map into a PNG image"
[main bb709e8] Add program to convert map into a PNG image
1 file changed, 17 insertions(+)
create mode 100644 bin/map2image.pl
Wrapping up
We didn’t do as much this time, but that’s OK. We put in a lot of work in the previous post getting everything up and running, so taking it easy for a bit will let us catch our breath. Still, we weren’t mucking around. We used test-driven development to extend the tram network map for Hannover and wrote a small program to visualise it. We’re making steady progress toward our goal: a working Map::Tube
map that we can use to find our way from station to station.
The next post in the series will describe how to add more lines to the network as well as how to use colour to tell them apart. Until then, keep cool till after school!
You’ll need to follow that link and then download the PDF for “Netzplan U”. A direct link would get outdated very quickly, so I thought it best only to mention how to get the right info. The “U” in “Netzplan U” stands for U-Bahn: i.e. the “underground” tram network. I use quotes around “underground” here because only the very centre of the network is underground; the rest is overground. This is why I use the English term “tram” rather than “subway”, because “tram” fits reality so much better. Also, I think there’s a certain class of train geek which takes exception at calling such a railway network an U-Bahn. ↩
As far as I can tell, the
as_png()
method gets monkey patched onto theMap::Tube
role, hence why we can call it from ourMap::Tube::Hannover
object. ↩
Top comments (0)