Figures 6.5 and 6.6 Reduced world city network viewed various ways
figures
code
python
This page includes python code derived from the code that was used to generate these figures. Again, you will see just how troublesome the limitations of existing platforms in handling projections intelligently can be. Some of the code is a bit overwhelming, so I’ve hidden most of it by default, but you can see it by clicking ► Code to view the code blocks.
This is dataset 11 from the site linked here and shows the numbers of offices of 100 major firms that exist in a collection of 315 cities. The network we build is based on shared offices of firms, i.e. if two cities both have offices of the same firm, then we consider them related. To avoid a very dense network, we require the presence of a firm in a city to have been rated at least 4 (on a 5 point scale) in the dataset.
The two functions in the code block below enable us to apply the ‘minimum score 4’ filter to the raw data, and then using matrix multiplication to convert the table into the adjacency matrix of a graph. This is based on the idea that an incidence matrix, \(\mathbf{B}\), which the table is, can be multiplied by its transpose to yield an adjacency matrix, \(\mathbf{A}\):
\[\mathbf{A}=\mathbf{B}\mathbf{B}^{\mathrm{T}}\]
Code
def cut_table_at(table: pandas.DataFrame, x: int) -> pandas.DataFrame: tbl = table.copy() tbl[tbl[:] < x] =0# remove cases < x tbl[tbl[:] >0] =1# set all remaining to 1return tbl[list(tbl.sum(axis =1) >0)] # remove rows with no non-0 valuesdef make_network_from_incidence_table(tbl: pandas.DataFrame) -> networkx.Graph: incidence_matrix = numpy.array(tbl) adj_matrix = incidence_matrix.dot(incidence_matrix.transpose()) numpy.fill_diagonal(adj_matrix, 0) G = networkx.Graph(adj_matrix)return networkx.relabel_nodes(G, dict(zip(G.nodes(), list(tbl.index))))
The geography
Below, we use the previous functions to form the graph, and then add longitude-latitude coordinates for each city to the nodes.
Code
Gnx = make_network_from_incidence_table(cut_table_at(wcn, 4))wcn_ll = pandas.read_csv("wcn-cities-ll.csv", index_col=0)for name in Gnx.nodes(): lon = wcn_ll.loc[name]["LONGITUDE"] lat = wcn_ll.loc[name]["LATITUDE"] Gnx.nodes[name]["lat"] = lat Gnx.nodes[name]["lon"] = lon
A bad map
At this point, we can make up a naïve map (i.e. Figure 6.5a), simply connecting the end point coordinates of each edge in the graph with straight lines.
Code
city_to_city = []for e in Gnx.edges(): p1 = Point(Gnx.nodes[e[0]]["lon"], Gnx.nodes[e[0]]["lat"]) p2 = Point(Gnx.nodes[e[1]]["lon"], Gnx.nodes[e[1]]["lat"]) city_to_city.append(LineString([p1, p2]))# make up the naive links linestring datasetcity_to_city_gdf = geopandas.GeoDataFrame( geometry = geopandas.GeoSeries(city_to_city))city_to_city_gdf.crs ="+proj=longlat"moll ="+proj=moll"# make up a 'globe' polygon for the background to the world mapsglobe = Polygon( [Point(-180, y) for y in [_ /10for _ inrange(-900, 901)]] +\ [Point( 180, y) for y in [_ /10for _ inrange(900, -901, -1)]])globe_gdf = geopandas.GeoDataFrame(geometry = geopandas.GeoSeries([globe]))globe_gdf.crs ="+proj=longlat"# get countries datacountries = geopandas.read_file("ne-world.gpkg")# and make up a mapax = globe_gdf.to_crs(moll).plot(fc ="#dddddd")countries.to_crs(moll).plot(ax = ax, fc ="w", linewidth =0)city_to_city_gdf.to_crs(moll).plot(ax = ax, color ="k", alpha =0.5, linewidth =0.25)plt.axis("off")
That map is not very useful, although avoiding making this kind of nonsense map is surprisingly difficult—another consequence of the limitations of how projections are handled in contemporary platforms. Things get complicated though… you have been warned…
So, we need several functions. First, the great circle distance beween two longitude-latitude points.
Next, a function to return a great circle line (geodesic) between two points.
Code
def interpolate_between(z1: float, z2: float, steps: int) ->list[float]: fractions = [(1+ x) / steps for x inrange(steps -1)]return [z1] + [(z2 - z1) * f for f in fractions] + [z2]def get_geodesic(p1: Point, p2: Point, step_length: int=10) -> LineString: dist = get_great_circle_distance(p1, p2)# determine number of steps n_steps = math.ceil(dist / step_length) # reproject in a space where great circles are straight lines gdf = geopandas.GeoDataFrame(geometry = geopandas.GeoSeries([p1, p2])) gdf.crs ="+proj=longlat" gdf = gdf.to_crs(f"+proj=aeqd +lon_0={p1.x} +lat_0={p1.y}") np1x, np1y = gdf.geometry[0].x, gdf.geometry[0].y np2x, np2y = gdf.geometry[1].x, gdf.geometry[1].y xs = interpolate_between(np1x, np2x, n_steps) ys = interpolate_between(np1y, np2y, n_steps) ngdf = geopandas.GeoDataFrame(geometry = geopandas.GeoSeries( Point(x, y) for x, y inzip(xs, ys))) ngdf.crs =f"+proj=aeqd +lon_0={p1.x} +lat_0={p1.y}" points =list(ngdf.to_crs("+proj=longlat").geometry)return LineString((p.x, p.y) for p in points)
Next, a function that takes geodesic and breaks it at any point where there is large apparent jump between consecutive points. Here we use the naïve distance measurement based on the coordinates. This will mean the line is broken at the antimeridian.
Code
def split_at_antimeridian(geodesic: LineString) ->\ MultiLineString|MultiPoint|LineString: points = geodesic.coords# make into a series of LineStrings segments = [LineString([p1, p2]) for p1, p2 inzip(points[:-1], points[1:])] lengths = [ls.length for ls in segments] intersections = [l >1for l in lengths]ifTruein intersections: idx = intersections.index(True) coords1 = points[:idx] coords2 = points[idx+1:]iflen(coords1) >1andlen(coords2) >1:return MultiLineString([coords1, coords2])eliflen(coords1) ==1andlen(coords2) ==1:return MultiPoint(points)else:iflen(coords1) >len(coords2):return LineString(coords1)else:return LineString(coords2)else:return LineString(points)
Make geodesic datasets between the cities
Code
geodesics = [] # list of LineStrings along the geodesiccut_geodesics = [] # list of the geodesics cut at the dateline# iterate over the edgesfor e in Gnx.edges(): p1 = Point(Gnx.nodes[e[0]]["lon"], Gnx.nodes[e[0]]["lat"]) p2 = Point(Gnx.nodes[e[1]]["lon"], Gnx.nodes[e[1]]["lat"]) g = get_geodesic(p1, p2) geodesics.append(g) cut_geodesics.append(split_at_antimeridian(g))geodesics_gdf = geopandas.GeoDataFrame( geometry = geopandas.GeoSeries(geodesics))geodesics_gdf.crs ="+proj=longlat"geodesics_cut_gdf = geopandas.GeoDataFrame( geometry = geopandas.GeoSeries(cut_geodesics))geodesics_cut_gdf.crs ="+proj=longlat"
Better Mollweide maps
The first of these doesn’t quite work, because it doesn’t break the great circle links at the anti-meridian (dateline) so we get ‘parallels’ from one side of the map to the other when one crosses the anti-meridian. The second uses the geodesics that have been broken at the anti-meridian, so is ‘correct’.
Code
ax = globe_gdf.to_crs(moll).plot(fc ="#dddddd")countries.to_crs(moll).plot(ax = ax, fc ="w", linewidth =0)geodesics_gdf.to_crs(moll).plot(ax = ax, color ="k", alpha =0.5, linewidth =0.25)plt.axis("off")ax = globe_gdf.to_crs(moll).plot(fc ="#dddddd")countries.to_crs(moll).plot(ax = ax, fc ="w", linewidth =0)geodesics_cut_gdf.to_crs(moll).plot(ax = ax, color ="k", alpha =0.5, linewidth =0.25)plt.axis("off")