在網路科學中,許多分析專注於節點間的抽象拓撲關係,但對於運輸、物流等嵌入真實世界的網路,空間維度是不可或缺的分析要素。傳統力導向佈局等演算法雖能呈現社群結構,卻會抹去節點的地理鄰近性,使視覺化結果與現實脫節。本文將闡述關鍵的轉換方法:地理投影。此技術不僅是將經緯度數據對應至平面座標的數學過程,更是將抽象網路錨定於真實地理脈絡的橋樑。透過構建基於投影座標的位置字典,我們能強制視覺化佈局遵循地理現實,將混亂的拓撲圖轉化為具備分析價值的空間分佈圖,使航線密度、樞紐位置與區域連結等模式一目了然。

航空網路的空間視覺化與地理投影

本章節將聚焦於如何利用機場的地理空間資訊,對航空網路進行更具意義的視覺化呈現。我們將探討如何將球形的經緯度數據轉換為二維平面上的座標,以及如何利用這些地理位置資訊來生成更直觀的網路佈局。

將地理位置納入網路視覺化

  • 節點篩選與屬性賦值
    • 篩選標準
      • 程式碼片段延續了前文的節點處理流程。
      • 對於從 airport_lat_long 字典中獲取的經緯度 latlong,進行進一步的篩選。
      • 排除了經度為 0 或小於 -128.6(可能表示非美國大陸的極端值),以及緯度為 0 或小於 23.5(可能表示非美國大陸的極端值)的機場。
      • if long == 0 or long < -128.6 or lat == 0 or lat < 23.5: 這一條件用於標記需要移除的節點。
    • 屬性賦值
      • 對於通過篩選的機場節點 v,將其經緯度信息賦值給節點屬性:
        • G_air.nodes[v]['lat'] = lat
        • G_air.nodes[v]['long'] = long
    • 處理缺失數據
      • except KeyError: 塊用於處理在 airport_lat_long 字典中找不到機場代碼的情況。這些找不到的節點也會被移除。
    • 移除斷開連接的節點
      • 除了基於地理位置的篩選,我們還需要確保網路的連通性。
      • G_air.remove_node(v):在迭代過程中,如果節點不滿足條件,則將其從網路中移除。這也包括了移除那些在篩選後可能導致網路斷開的節點。
  • 初步視覺化與挑戰
    • nx.draw_networkx()
      • 當我們嘗試使用標準的 draw_networkx() 函數來視覺化航空網路時,即使隱藏節點和標籤,僅留下邊,結果通常是一個難以辨識的「毛球」(hairball)。
      • node_size=0, with_labels=False, edge_color='#666666', alpha=0.1:這些參數用於簡化視覺化,但無法解決底層的佈局問題。
    • 問題根源
      • 標準的網路佈局算法(如力導向佈局 force-directed layout)是基於節點之間的拓撲連接關係來計算位置的,它們並不考慮節點的實際地理位置。
      • 因此,即使節點之間存在實際的地理距離,這些距離在拓撲佈局中也無法得到體現,導致視覺化混亂。

利用地理位置創建自定義佈局

  • 從球體到平面:地理投影
    • 挑戰:經度 (longitude) 和緯度 (latitude) 是地球球面上的座標,而螢幕或頁面上的視覺化需要二維平面上的 x, y 座標。
    • 投影 (Projection):需要一種方法將球面的座標轉換為平面的座標。這稱為地理投影。
    • 簡單投影
      • 一種簡單但不太精確的方法是直接縮放經度,並將緯度作為 y 軸。例如,x = longitude * scale_factory = latitude * scale_factor
      • 然而,這種簡單的線性映射會嚴重扭曲靠近兩極的區域,並且無法準確反映真實的距離。
    • 更優的投影
      • Python 的 cartopy 套件:提供了多種標準的地理投影方法(如 Mercator, Robinson, Mollweide 等),並且能與 matplotlib 良好整合。這些投影能夠更準確地在二維平面上表示地球表面的地理特徵。
  • 手動創建位置字典 pos
    • pos = dict():創建一個空字典,用於儲存每個節點的 (x, y) 座標。
    • 遍歷節點
      • for v in G_air.nodes::迭代網路中的每個機場節點。
      • 獲取節點的經緯度屬性:long = G_air.nodes[v]['long']lat = G_air.nodes[v]['lat']
    • 座標轉換與縮放
      • 將經緯度轉換為螢幕上的 x, y 座標。這一步驟通常涉及:
        1. 投影轉換:根據選擇的地理投影方法,將 (lat, long) 轉換為 (x’, y’)。
        2. 縮放與平移:將投影後的座標縮放到適合螢幕顯示的範圍,並進行必要的平移,以確保網路完整顯示在圖表中。
        • 例如,一個簡單的線性投影可能類似於:
          # 簡單的線性投影 (僅為示意,cartopy 提供更優方法)
          screen_width = 15 # 假設圖表寬度
          screen_height = 15 # 假設圖表高度
          
          # 經度範圍 (-125 到 -66),緯度範圍 (24 到 49)
          # 將經度映射到 0-screen_width 的範圍
          x_coord = (long - (-125)) / (-66 - (-125)) * screen_width
          # 將緯度映射到 0-screen_height 的範圍
          y_coord = (lat - 24) / (49 - 24) * screen_height
          
          pos[v] = (x_coord, y_coord)
          
        • 使用 cartopy:更推薦的方式是利用 cartopy 創建一個地圖投影對象,然後使用該對象來轉換經緯度。
  • 視覺化繪製
    • fig = plt.figure(figsize=(15,15)):創建一個較大的圖形對象,以便容納地理投影後的網路。
    • 一旦 pos 字典填充完畢,就可以將其傳遞給 NetworkX 的繪圖函數,例如 nx.draw_networkx_nodes()nx.draw_networkx_edges(),以生成基於地理位置的網路佈局。

程式碼範例:地理投影與佈局生成

import networkx as nx
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pathlib import Path
import math

# --- 假設 G_air_spatial 已載入並處理好節點屬性 (lat, long, mass, count) 
---
# 為了程式碼的獨立性,這裡重新載入並處理部分數據
# 實際應用中,應接續前文的 G_air_spatial

# --- 重新創建模擬數據和網路 (如果前文未執行) 
---
data_dir_sim = Path('./data_simulated')
carrier_data_path = data_dir_sim / 'BTS2018' / 'carrier.csv'
airport_db_path = data_dir_partow / 'partow' / 'GlobalAirportDatabase.txt'

# 模擬創建 carrier.csv
if not carrier_data_path.exists():
    data_dir_sim.mkdir(parents=True, exist_ok=True)
    mock_data_carrier = {
        'ORIGIN_AIRPORT_ID': [10001, 10001, 10001, 10002, 10002, 10003, 10003, 10001, 10002, 10003, 10004, 10005, 10006, 10007],
        'DEST_AIRPORT_ID':   [10002, 10003, 10004, 10001, 10003, 10001, 10002, 10005, 10005, 10005, 10001, 10002, 10001, 10002],
        'PASSENGERS':        [1500, 800, 200, 1200, 900, 750, 600, 100, 150, 300, 50, 70, 1200, 1000],
        'YEAR':              [2018]*14, 'MONTH': [1]*14
    }
    df_mock_carrier = pd.DataFrame(mock_data_carrier)
    df_mock_carrier.to_csv(carrier_data_path, index=False)

# 模擬創建 GlobalAirportDatabase.txt
if not airport_db_path.exists():
    data_dir_partow = Path('./data_simulated/partow')
    data_dir_partow.mkdir(parents=True, exist_ok=True)
    mock_airport_db_content = """
1:JFK:John F. Kennedy International Airport:USA:40.639751:-73.778925
2:LAX:Los Angeles International Airport:USA:33.941556:-118.408530
3:ORD:Chicago O'Hare International Airport:USA:41.974209:-87.907321
4:ATL:Hartsfield-Jackson Atlanta International Airport:USA:33.640445:-84.427700
5:DEN:Denver International Airport:USA:39.856057:-104.673737
6:SFO:San Francisco International Airport:USA:37.619002:-122.371297
7:SEA:Seattle-Tacoma International Airport:USA:47.448981:-122.309310
8:MIA:Miami International Airport:USA:25.793199:-80.290594
9:DFW:Dallas/Fort Worth International Airport:USA:32.899811:-97.040416
10:LAS:McCarran International Airport:USA:36.084030:-115.153734
11:PHX:Phoenix Sky Harbor International Airport:USA:33.434250:-112.011590
12:IAH:George Bush Intercontinental Airport:USA:29.990163:-95.336770
13:BOS:Logan International Airport:USA:42.365599:-71.009667
14:MSP:Minneapolis–Saint Paul International Airport:USA:44.884794:-93.217711
15:PHL:Philadelphia International Airport:USA:39.874399:-75.242449
16:CLT:Charlotte Douglas International Airport:USA:35.214433:-80.947171
17:EWR:Newark Liberty International Airport:USA:40.689532:-74.174492
18:DTW:Detroit Metropolitan Airport:USA:42.211501:-83.353409
19:FLL:Fort Lauderdale–Hollywood International Airport:USA:26.074199:-80.150667
20:BWI:Baltimore/Washington International Airport:USA:39.177452:-76.668409
21:SLC:Salt Lake City International Airport:USA:40.789999:-111.979111
22:SAN:San Diego International Airport:USA:32.733819:-117.193751
23:TPA:Tampa International Airport:USA:27.977474:-82.531189
24:PDX:Portland International Airport:USA:45.589802:-122.597510
25:IAD:Washington Dulles International Airport:USA:38.953054:-77.456459
26:DCA:Ronald Reagan Washington National Airport:USA:38.851111:-77.037500
27:ANC:Ted Stevens Anchorage International Airport:USA:61.174355:-149.996333 # Alaska, will be removed
28:HNL:Daniel K. Inouye International Airport:USA:21.318694:-157.924694 # Hawaii, will be removed
"""
    with open(airport_db_path, 'w') as f:
        f.write(mock_airport_db_content.strip())

# --- 重新載入並處理數據 
---
G_air_spatial = nx.Graph()
airport_locations = {}

try:
    # 載入旅客流量
    df_carrier = pd.read_csv(carrier_data_path)
    df_carrier_2018 = df_carrier[df_carrier['YEAR'] == 2018]
    for index, row in df_carrier_2018.iterrows():
        origin = str(row['ORIGIN_AIRPORT_ID'])
        dest = str(row['DEST_AIRPORT_ID'])
        passengers = int(row['PASSENGERS'])
        if origin == dest or passengers == 0: continue
        if G_air_spatial.has_edge(origin, dest):
            G_air_spatial.edges[origin, dest]['count'] += passengers
        else:
            G_air_spatial.add_edge(origin, dest, count=passengers)
            if origin not in G_air_spatial: G_air_spatial.add_node(origin)
            if dest not in G_air_spatial: G_air_spatial.add_node(dest)
    
    # 載入地理位置
    with open(airport_db_path) as f:
        for row in f:
            columns = row.strip().split(':')
            if len(columns) >= 16:
                code = columns[1]
                country = columns[3]
                if country == 'USA':
                    try:
                        lat = float(columns[14])
                        long = float(columns[15])
                        airport_locations[code] = (lat, long)
                    except ValueError: pass
    
    # 添加地理屬性並篩選大陸機場
    nodes_to_remove = []
    for node_id in list(G_air_spatial.nodes()):
        if node_id in airport_locations:
            lat, long = airport_locations[node_id]
            if 24 <= lat <= 49 and -125 <= long <= -66:
                G_air_spatial.nodes[node_id]['lat'] = lat
                G_air_spatial.nodes[node_id]['long'] = long
            else:
                nodes_to_remove.append(node_id)
        else:
            nodes_to_remove.append(node_id)
    G_air_spatial.remove_nodes_from(nodes_to_remove)

    print(f"網路準備就緒: {G_air_spatial.number_of_nodes()} 個節點, {G_air_spatial.number_of_edges()} 條邊。")

except Exception as e:
    print(f"初始化網路時發生錯誤: {e}")

# --- 創建地理投影佈局 
---

# Haversine 公式計算球面距離 (公里)
def haversine_distance(lat1, lon1, lat2, lon2):
    R = 6371 # 地球半徑 (公里)
    lat1_rad, lon1_rad, lat2_rad, lon2_rad = map(math.radians, [lat1, lon1, lat2, lon2])
    dlon = lon2_rad - lon1_rad
    dlat = lat2_rad - lat1_rad
    a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
    return R * c

# 創建位置字典 pos,將經緯度映射到二維平面座標
pos = {}
# 設定地圖投影的範圍和比例尺
# 我們將使用一個簡單的線性投影,將經緯度映射到一個圖形區域
# 實際應用中,推薦使用 cartopy 套件進行更精確的投影

# 確定數據範圍以進行縮放
min_lat = min(data['lat'] for _, data in G_air_spatial.nodes(data=True) if 'lat' in data)
max_lat = max(data['lat'] for _, data in G_air_spatial.nodes(data=True) if 'lat' in data)
min_lon = min(data['long'] for _, data in G_air_spatial.nodes(data=True) if 'long' in data)
max_lon = max(data['long'] for _, data in G_air_spatial.nodes(data=True) if 'long' in data)

# 設定輸出圖形的尺寸 (例如,寬高相等,方便視覺化)
fig_width = 15
fig_height = 15

# 簡單的線性映射 (Mercator 投影的近似)
# 將經度映射到 x 軸,緯度映射到 y 軸
# 這裡我們將經度範圍映射到 0 到 fig_width,緯度範圍映射到 0 到 fig_height
for node_id, data in G_air_spatial.nodes(data=True):
    if 'lat' in data and 'long' in data:
        lat = data['lat']
        long = data['long']
        
        # 轉換經度到 x 座標
        # 經度範圍: min_lon 到 max_lon
        # 映射到: 0 到 fig_width
        x_coord = fig_width * (long - min_lon) / (max_lon - min_lon)
        
        # 轉換緯度到 y 座標
        # 緯度範圍: min_lat 到 max_lat
        # 映射到: 0 到 fig_height
        # 注意: 在繪圖中,y 軸通常向上增加,所以我們可能需要反轉或直接使用
        y_coord = fig_height * (lat - min_lat) / (max_lat - min_lat)
        
        pos[node_id] = (x_coord, y_coord)

print(f"已為 {len(pos)} 個節點生成地理位置佈局。")

# --- 視覺化網路,使用地理位置佈局 
---
plt.figure(figsize=(15, 15))

# 繪製邊
nx.draw_networkx_edges(
    G_air_spatial,
    pos,
    edge_color='#666666', # 灰色
    alpha=0.2,          # 半透明
    width=0.5           # 細線
)

# 繪製節點 (可以選擇性繪製,或只顯示邊)
# nx.draw_networkx_nodes(
#     G_air_spatial,
#     pos,
#     node_size=5,       # 小節點大小
#     node_color='skyblue',
#     alpha=0.8
# )

# 繪製節點標籤 (可選,可能會使圖表擁擠)
# nx.draw_networkx_labels(
#     G_air_spatial,
#     pos,
#     font_size=8,
#     font_color='black'
# )

plt.title("US Air Traffic Network with Geographic Layout", fontsize=18)
plt.xlabel("Longitude (Projected)", fontsize=14)
plt.ylabel("Latitude (Projected)", fontsize=14)

# 設定圖表範圍,以匹配我們映射的座標範圍
plt.xlim([0, fig_width])
plt.ylim([0, fig_height])

# 移除坐標軸刻度,以獲得更乾淨的地圖視覺
plt.xticks([])
plt.yticks([])

# 顯示圖表
plt.show()

print("\n視覺化分析:")
print("  - 地理位置佈局使得網路結構與實際地理空間對應。")
print("  - 可以清晰地看到美國大陸的地理輪廓,以及機場之間的航線分佈。")
print("  - 航線的密度和分佈反映了主要航空樞紐和繁忙的航線。")
print("  - 這種視覺化方式比隨機佈局更能揭示網路的空間模式。")
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

start

:航空網路的空間視覺化與地理投影;:節點篩選與屬性賦值;
note right
篩選標準:
  - 基於經緯度範圍 (美國大陸)
  - 排除經緯度為 0 或極端值
  - 排除不在大陸範圍內的機場
屬性賦值:
  - 添加 'lat', 'long' 節點屬性
處理缺失數據:
  - 移除在位置數據庫中找不到的機場
移除斷開連接節點:
  - 確保網路連通性
end note

:初步視覺化與挑戰;
note right
標準視覺化 (draw_networkx):
  - 易產生「毛球」現象
  - 佈局基於拓撲,忽略地理位置
問題根源:
  - 拓撲佈局無法體現地理距離
  - 視覺化混亂,難以解讀
end note

:利用地理位置創建自定義佈局;
note right
地理投影 (Projection):
  - 將球面經緯度轉換為平面 x, y 座標
  - 挑戰: 球面到平面的映射
簡單投影:
  - 線性縮放經度/緯度 (不精確,扭曲大)
優化投影:
  - 使用 cartopy 套件 (Mercator, Robinson 等)
  - 提供更準確的地理表示
創建位置字典 (pos):
  - 遍歷節點,獲取經緯度
  - 進行投影轉換和縮放/平移
  - 映射到螢幕座標
視覺化繪製:
  - 使用 pos 字典作為節點位置
  - 繪製邊和節點
end note

stop

@enduml

看圖說話:

此圖示總結了「航空網路的空間視覺化與地理投影」的內容,重點在於展示如何利用機場的地理位置資訊來創建有意義的網路視覺化。流程開頭首先聚焦於「節點篩選與屬性賦值」,說明了如何根據地理位置範圍篩選機場節點,並為其添加緯度和經度屬性,接著闡述了「初步視覺化與挑戰」,指出了標準視覺化方法(如力導向佈局)在處理地理空間網路時的局限性,最後詳細介紹了「利用地理位置創建自定義佈局」的過程,包括地理投影的概念、如何將經緯度轉換為平面座標,以及如何利用這些座標來生成地理空間佈局,從而實現更直觀的網路視覺化。

縱觀現代管理者面對的數據洪流,能將龐雜的網路連結從混雜不清的「毛球」狀態,轉化為具備戰略意義的清晰圖像,是區分決策品質的關鍵。航空網路的視覺化挑戰,正是此一普遍困境的精準縮影,而其解方則蘊含了深刻的管理智慧。

傳統的拓撲佈局僅揭示了「誰與誰相連」的表層關係,卻隱藏了「在哪裡發生」及「為何如此分佈」的深層結構。真正的突破,源於引入「地理投影」這一核心概念——它不僅是技術操作,更象徵一種思維框架的轉換。透過選擇正確的投影視角,我們將抽象的節點關係錨定於真實世界,從而發掘出單純的連結分析所無法企及的空間模式、樞紐地位與潛在的市場缺口。這正是將數據分析與商業地理學整合的價值所在。

展望未來,這種融合數據科學與空間智慧的能力,將從航空業擴散至供應鏈優化、零售據點規劃乃至人才生態佈局。能夠繪製並解讀自身「戰略地理圖」的組織,將在資源配置與競爭洞察上取得決定性優勢。

玄貓認為,對高階管理者而言,此案例的核心啟示並非學會特定繪圖技術,而是要培養這種「選擇正確投影」的策略性思維。這意味著在任何複雜問題面前,我們都必須主動尋找能還原事實現況、揭示底層結構的分析框架,方能真正駕馭複雜性,看見別人所忽略的機會格局。