fix color
@@ -26,10 +26,10 @@ INPUT_FILE = '01_distance.xlsx'
|
||||
OUTPUT_FILE = '02_pairing.xlsx'
|
||||
|
||||
# 约束参数
|
||||
L_MAX = 50 # 距离阈值 (英里)
|
||||
MU_SUM_MAX = 450 # 需求和上限
|
||||
CV_MAX = 0.5 # 变异系数上限
|
||||
Q = 400 # 卡车容量
|
||||
L_MAX = 30 # 距离阈值 (英里)
|
||||
MU_SUM_MAX = 350 # 需求和上限
|
||||
CV_MAX = 0.3 # 变异系数上限
|
||||
Q = 350 # 卡车容量
|
||||
|
||||
# 价值函数权重
|
||||
ALPHA = 1.0 # 容量利用率权重
|
||||
|
||||
@@ -16,17 +16,66 @@ Task 3 - Step 7: 敏感性分析 (Refined)
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from scipy import stats
|
||||
import matplotlib.pyplot as plt
|
||||
import warnings
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# 设置绘图风格
|
||||
plt.style.use('seaborn-v0_8-whitegrid')
|
||||
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans'] # 适配中文
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
# Matplotlib cache dirs(避免 home 目录不可写导致的警告)
|
||||
_mpl_config_dir = Path(tempfile.gettempdir()) / "mm_task3_mplconfig"
|
||||
_xdg_cache_dir = Path(tempfile.gettempdir()) / "mm_task3_xdg_cache"
|
||||
_mpl_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
_xdg_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.setdefault("MPLCONFIGDIR", str(_mpl_config_dir))
|
||||
os.environ.setdefault("XDG_CACHE_HOME", str(_xdg_cache_dir))
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from cycler import cycler
|
||||
|
||||
# 设置绘图风格(低饱和度 Morandi 色系)
|
||||
MORANDI = {
|
||||
"bg": "#F5F2ED",
|
||||
"grid": "#D7D1C7",
|
||||
"text": "#3F3F3F",
|
||||
"muted_blue": "#8FA3A7",
|
||||
"muted_blue_dark": "#6E858A",
|
||||
"sage": "#AEB8A6",
|
||||
"terracotta": "#B07A6A",
|
||||
"warm_gray": "#8C857A",
|
||||
}
|
||||
|
||||
plt.rcParams.update(
|
||||
{
|
||||
"figure.facecolor": MORANDI["bg"],
|
||||
"axes.facecolor": MORANDI["bg"],
|
||||
"savefig.facecolor": MORANDI["bg"],
|
||||
"axes.edgecolor": MORANDI["grid"],
|
||||
"axes.labelcolor": MORANDI["text"],
|
||||
"xtick.color": MORANDI["text"],
|
||||
"ytick.color": MORANDI["text"],
|
||||
"text.color": MORANDI["text"],
|
||||
"grid.color": MORANDI["grid"],
|
||||
"grid.alpha": 0.55,
|
||||
"axes.grid": True,
|
||||
"axes.prop_cycle": cycler(
|
||||
"color",
|
||||
[
|
||||
MORANDI["muted_blue"],
|
||||
MORANDI["sage"],
|
||||
MORANDI["terracotta"],
|
||||
MORANDI["warm_gray"],
|
||||
],
|
||||
),
|
||||
"legend.frameon": True,
|
||||
"legend.framealpha": 0.9,
|
||||
"legend.facecolor": MORANDI["bg"],
|
||||
"legend.edgecolor": MORANDI["grid"],
|
||||
"font.sans-serif": ["Arial Unicode MS", "SimHei", "DejaVu Sans"],
|
||||
"axes.unicode_minus": False,
|
||||
}
|
||||
)
|
||||
|
||||
# ============================================
|
||||
# 基础参数和函数 (保持不变)
|
||||
@@ -333,16 +382,38 @@ for i, param in enumerate(params_list):
|
||||
data = df_results[df_results['param'] == param].sort_values('value')
|
||||
|
||||
# 绘制 E1/E2 - 左轴
|
||||
line1, = ax.plot(data['value'], data['E1'], 'b-', linewidth=1.6, label='Expected Service (E1)')
|
||||
line2, = ax.plot(data['value'], data['E2'], 'g-', linewidth=1.6, alpha=0.9, label='Quality-weighted (E2)')
|
||||
line1, = ax.plot(
|
||||
data['value'],
|
||||
data['E1'],
|
||||
linestyle='-',
|
||||
color=MORANDI["muted_blue_dark"],
|
||||
linewidth=1.8,
|
||||
label='Expected Service (E1)',
|
||||
)
|
||||
line2, = ax.plot(
|
||||
data['value'],
|
||||
data['E2'],
|
||||
linestyle='-',
|
||||
color=MORANDI["sage"],
|
||||
linewidth=1.8,
|
||||
alpha=0.95,
|
||||
label='Quality-weighted (E2)',
|
||||
)
|
||||
ax.set_xlabel(param_labels[param][0])
|
||||
ax.set_ylabel('Service (E1/E2)')
|
||||
|
||||
# 绘制 R1 (风险) - 右轴
|
||||
ax2 = ax.twinx()
|
||||
line3, = ax2.plot(data['value'], data['R1'], 'r--', linewidth=1.6, label='Shortfall Risk (R1)')
|
||||
ax2.set_ylabel('Risk Probability (R1)', color='r')
|
||||
ax2.tick_params(axis='y', labelcolor='r')
|
||||
line3, = ax2.plot(
|
||||
data['value'],
|
||||
data['R1'],
|
||||
linestyle='--',
|
||||
color=MORANDI["terracotta"],
|
||||
linewidth=1.8,
|
||||
label='Shortfall Risk (R1)',
|
||||
)
|
||||
ax2.set_ylabel('Risk Probability (R1)', color=MORANDI["terracotta"])
|
||||
ax2.tick_params(axis='y', labelcolor=MORANDI["terracotta"])
|
||||
|
||||
# 添加基准线
|
||||
if param == 'merge_ratio':
|
||||
@@ -354,7 +425,7 @@ for i, param in enumerate(params_list):
|
||||
elif param == 'cv_max':
|
||||
base_val = BASE_CV_MAX
|
||||
|
||||
ax.axvline(x=base_val, color='gray', linestyle=':', alpha=0.5)
|
||||
ax.axvline(x=base_val, color=MORANDI["warm_gray"], linestyle=':', alpha=0.65)
|
||||
|
||||
# 图例
|
||||
lines = [line1, line2, line3]
|
||||
@@ -362,7 +433,7 @@ for i, param in enumerate(params_list):
|
||||
ax.legend(lines, labels, loc='best', frameon=True)
|
||||
|
||||
ax.set_title(f'Effect of {param_labels[param][0]}')
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.grid(True, alpha=0.55)
|
||||
|
||||
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
|
||||
plt.savefig(OUTPUT_FIG, dpi=300)
|
||||
|
||||
@@ -19,6 +19,7 @@ Task 3 - Step 8: 可视化(Fig.1/2/4/5)
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import os
|
||||
@@ -38,11 +39,62 @@ import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib import colors as mcolors
|
||||
from cycler import cycler
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
MORANDI = {
|
||||
"bg": "#F5F2ED",
|
||||
"grid": "#D7D1C7",
|
||||
"text": "#3F3F3F",
|
||||
"muted_blue": "#8FA3A7",
|
||||
"muted_blue_dark": "#6E858A",
|
||||
"sage": "#AEB8A6",
|
||||
"clay": "#C6AA9B",
|
||||
"rose": "#D2B8B3",
|
||||
"warm_gray": "#8C857A",
|
||||
"terracotta": "#B07A6A",
|
||||
}
|
||||
|
||||
|
||||
def _apply_morandi_style() -> None:
|
||||
plt.rcParams.update(
|
||||
{
|
||||
"figure.facecolor": MORANDI["bg"],
|
||||
"axes.facecolor": MORANDI["bg"],
|
||||
"savefig.facecolor": MORANDI["bg"],
|
||||
"axes.edgecolor": MORANDI["grid"],
|
||||
"axes.labelcolor": MORANDI["text"],
|
||||
"xtick.color": MORANDI["text"],
|
||||
"ytick.color": MORANDI["text"],
|
||||
"text.color": MORANDI["text"],
|
||||
"grid.color": MORANDI["grid"],
|
||||
"grid.alpha": 0.55,
|
||||
"axes.grid": True,
|
||||
"axes.titleweight": "semibold",
|
||||
"axes.titlesize": 11,
|
||||
"axes.labelsize": 10,
|
||||
"legend.frameon": True,
|
||||
"legend.framealpha": 0.9,
|
||||
"legend.facecolor": MORANDI["bg"],
|
||||
"legend.edgecolor": MORANDI["grid"],
|
||||
"axes.prop_cycle": cycler(
|
||||
"color",
|
||||
[
|
||||
MORANDI["muted_blue"],
|
||||
MORANDI["sage"],
|
||||
MORANDI["clay"],
|
||||
MORANDI["rose"],
|
||||
MORANDI["warm_gray"],
|
||||
],
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Paths:
|
||||
distance_xlsx: Path
|
||||
@@ -76,6 +128,15 @@ def _pair_key(a: int, b: int) -> tuple[int, int]:
|
||||
|
||||
|
||||
def fig1_pairing_map(paths: Paths) -> Path:
|
||||
_apply_morandi_style()
|
||||
|
||||
# Warmer + higher contrast for Fig.1 specifically (clearer on paper/screens)
|
||||
fig1_colors = {
|
||||
"paired": "#e76f51",
|
||||
"unpaired": "#6c757d",
|
||||
"link": "#bc6c25",
|
||||
}
|
||||
|
||||
sites = pd.read_excel(paths.distance_xlsx, sheet_name="sites")
|
||||
pairs = pd.read_excel(paths.pairing_xlsx, sheet_name="selected_pairs")
|
||||
|
||||
@@ -97,9 +158,9 @@ def fig1_pairing_map(paths: Paths) -> Path:
|
||||
ax.plot(
|
||||
[si["lon"], sj["lon"]],
|
||||
[si["lat"], sj["lat"]],
|
||||
color="#2c7fb8",
|
||||
alpha=0.35,
|
||||
linewidth=1.0,
|
||||
color=fig1_colors["link"],
|
||||
alpha=0.5,
|
||||
linewidth=1.2,
|
||||
zorder=1,
|
||||
)
|
||||
|
||||
@@ -110,8 +171,8 @@ def fig1_pairing_map(paths: Paths) -> Path:
|
||||
paired["lon"],
|
||||
paired["lat"],
|
||||
s=18,
|
||||
color="#d95f0e",
|
||||
alpha=0.85,
|
||||
color=fig1_colors["paired"],
|
||||
alpha=0.9,
|
||||
label=f"Paired sites (n={len(paired)})",
|
||||
zorder=2,
|
||||
)
|
||||
@@ -120,7 +181,7 @@ def fig1_pairing_map(paths: Paths) -> Path:
|
||||
unpaired["lon"],
|
||||
unpaired["lat"],
|
||||
s=22,
|
||||
color="#636363",
|
||||
color=fig1_colors["unpaired"],
|
||||
alpha=0.9,
|
||||
marker="x",
|
||||
label=f"Unpaired sites (n={len(unpaired)})",
|
||||
@@ -130,7 +191,7 @@ def fig1_pairing_map(paths: Paths) -> Path:
|
||||
ax.set_title("Fig.1 Pairing map (sites + selected 34 links)")
|
||||
ax.set_xlabel("Longitude")
|
||||
ax.set_ylabel("Latitude")
|
||||
ax.grid(True, alpha=0.2)
|
||||
ax.grid(True, alpha=0.55)
|
||||
ax.legend(loc="best", frameon=True)
|
||||
|
||||
out = paths.figures_dir / "fig1_pairing_map.png"
|
||||
@@ -140,7 +201,58 @@ def fig1_pairing_map(paths: Paths) -> Path:
|
||||
return out
|
||||
|
||||
|
||||
def export_fig1_points_js(paths: Paths) -> Path:
|
||||
sites = pd.read_excel(paths.distance_xlsx, sheet_name="sites").copy()
|
||||
pairs = pd.read_excel(paths.pairing_xlsx, sheet_name="selected_pairs").copy()
|
||||
|
||||
sites["site_id"] = sites["site_id"].astype(int)
|
||||
sites["k"] = sites["k"].astype(int)
|
||||
|
||||
paired_ids = set(pairs["site_i_id"].astype(int)).union(
|
||||
set(pairs["site_j_id"].astype(int))
|
||||
)
|
||||
|
||||
points = []
|
||||
for _, r in sites.iterrows():
|
||||
points.append(
|
||||
{
|
||||
"site_id": int(r["site_id"]),
|
||||
"site_name": str(r["site_name"]),
|
||||
"lat": float(r["lat"]),
|
||||
"lng": float(r["lon"]),
|
||||
"mu": float(r["mu"]),
|
||||
"sigma": float(r["sigma"]),
|
||||
"k": int(r["k"]),
|
||||
"is_paired": int(r["site_id"]) in paired_ids,
|
||||
}
|
||||
)
|
||||
|
||||
links = []
|
||||
for _, r in pairs.iterrows():
|
||||
links.append(
|
||||
{
|
||||
"site_i_id": int(r["site_i_id"]),
|
||||
"site_j_id": int(r["site_j_id"]),
|
||||
"site_i_name": str(r["site_i_name"]),
|
||||
"site_j_name": str(r["site_j_name"]),
|
||||
"distance": float(r["distance"]),
|
||||
}
|
||||
)
|
||||
|
||||
out = BASE_DIR / "fig1_points.js"
|
||||
payload = (
|
||||
"// Auto-generated from `task3/01_distance.xlsx` (sites) + `task3/02_pairing.xlsx` (selected_pairs)\n"
|
||||
"// Usage: open `task3/fig1_carto.html` in a browser (same directory must contain this file).\n"
|
||||
f"window.FIG1_POINTS = {json.dumps(points, ensure_ascii=False, separators=(',', ':'))};\n"
|
||||
f"window.FIG1_LINKS = {json.dumps(links, ensure_ascii=False, separators=(',', ':'))};\n"
|
||||
)
|
||||
out.write_text(payload, encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
def fig2_allocation_scatter(paths: Paths) -> Path:
|
||||
_apply_morandi_style()
|
||||
|
||||
df = pd.read_excel(paths.allocation_xlsx, sheet_name="allocation")
|
||||
|
||||
q_ratio = df["q_ratio"].to_numpy(dtype=float)
|
||||
@@ -157,15 +269,22 @@ def fig2_allocation_scatter(paths: Paths) -> Path:
|
||||
]
|
||||
|
||||
for ax, (title, x) in zip(axes, panels):
|
||||
ax.scatter(x, q_ratio, s=22, alpha=0.75, color="#3182bd", edgecolor="none")
|
||||
ax.scatter(
|
||||
x,
|
||||
q_ratio,
|
||||
s=22,
|
||||
alpha=0.8,
|
||||
color=MORANDI["muted_blue"],
|
||||
edgecolor="none",
|
||||
)
|
||||
if np.isfinite(x).all() and np.isfinite(q_ratio).all() and len(x) >= 2:
|
||||
coef = np.polyfit(x, q_ratio, 1)
|
||||
xx = np.linspace(float(np.min(x)), float(np.max(x)), 100)
|
||||
yy = coef[0] * xx + coef[1]
|
||||
ax.plot(xx, yy, color="#de2d26", linewidth=2.0, alpha=0.9)
|
||||
ax.plot(xx, yy, color=MORANDI["terracotta"], linewidth=2.0, alpha=0.95)
|
||||
ax.set_title(title)
|
||||
ax.set_xlabel("x")
|
||||
ax.grid(True, alpha=0.2)
|
||||
ax.grid(True, alpha=0.55)
|
||||
|
||||
axes[0].set_ylabel("$q^*/Q$")
|
||||
fig.suptitle("Fig.2 Allocation strategy scatter (34 pairs)", y=1.02)
|
||||
@@ -178,6 +297,8 @@ def fig2_allocation_scatter(paths: Paths) -> Path:
|
||||
|
||||
|
||||
def fig4_calendar_heatmap(paths: Paths) -> Path:
|
||||
_apply_morandi_style()
|
||||
|
||||
sites = pd.read_excel(paths.distance_xlsx, sheet_name="sites")[["site_id", "mu"]]
|
||||
sites["site_id"] = sites["site_id"].astype(int)
|
||||
mu_by_id = dict(zip(sites["site_id"], sites["mu"]))
|
||||
@@ -212,7 +333,17 @@ def fig4_calendar_heatmap(paths: Paths) -> Path:
|
||||
expected[day - 1, slot - 1] = pair_E.get(_pair_key(i_id, j_id), np.nan)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 2.6), dpi=200)
|
||||
im = ax.imshow(expected.T, aspect="auto", cmap="viridis")
|
||||
cmap = mcolors.LinearSegmentedColormap.from_list(
|
||||
"morandi",
|
||||
[
|
||||
MORANDI["bg"],
|
||||
"#E7E1D7",
|
||||
MORANDI["sage"],
|
||||
MORANDI["muted_blue"],
|
||||
"#5F7478",
|
||||
],
|
||||
)
|
||||
im = ax.imshow(expected.T, aspect="auto", cmap=cmap)
|
||||
ax.set_yticks([0, 1], labels=["Slot 1", "Slot 2"])
|
||||
ax.set_xlabel("Day of year")
|
||||
ax.set_title("Fig.4 Calendar heatmap (expected service per slot)")
|
||||
@@ -233,6 +364,8 @@ def fig4_calendar_heatmap(paths: Paths) -> Path:
|
||||
|
||||
|
||||
def fig5_risk_distribution(paths: Paths) -> Path:
|
||||
_apply_morandi_style()
|
||||
|
||||
risk = pd.read_excel(paths.evaluate_xlsx, sheet_name="pair_risk").copy()
|
||||
p = risk["shortfall_prob_either"].to_numpy(dtype=float)
|
||||
p = p[np.isfinite(p)]
|
||||
@@ -240,21 +373,36 @@ def fig5_risk_distribution(paths: Paths) -> Path:
|
||||
fig, axes = plt.subplots(1, 2, figsize=(11, 3.8), dpi=200)
|
||||
|
||||
ax = axes[0]
|
||||
ax.hist(p, bins=10, color="#3182bd", alpha=0.85, edgecolor="white")
|
||||
ax.axvline(float(np.mean(p)), color="#de2d26", linewidth=2.0, label=f"mean={np.mean(p):.3f}")
|
||||
ax.hist(p, bins=10, color=MORANDI["sage"], alpha=0.9, edgecolor=MORANDI["bg"])
|
||||
ax.axvline(
|
||||
float(np.mean(p)),
|
||||
color=MORANDI["terracotta"],
|
||||
linewidth=2.2,
|
||||
label=f"mean={np.mean(p):.3f}",
|
||||
)
|
||||
ax.set_title("Histogram")
|
||||
ax.set_xlabel("Shortfall probability (either site)")
|
||||
ax.set_ylabel("Count")
|
||||
ax.grid(True, alpha=0.2)
|
||||
ax.grid(True, alpha=0.55)
|
||||
ax.legend(loc="best", frameon=True)
|
||||
|
||||
ax = axes[1]
|
||||
p_sorted = np.sort(p)[::-1]
|
||||
ax.plot(np.arange(1, len(p_sorted) + 1), p_sorted, marker="o", markersize=3.5, linewidth=1.5)
|
||||
ax.plot(
|
||||
np.arange(1, len(p_sorted) + 1),
|
||||
p_sorted,
|
||||
marker="o",
|
||||
markersize=3.2,
|
||||
linewidth=1.6,
|
||||
color=MORANDI["muted_blue_dark"],
|
||||
markerfacecolor=MORANDI["muted_blue"],
|
||||
markeredgecolor=MORANDI["bg"],
|
||||
markeredgewidth=0.6,
|
||||
)
|
||||
ax.set_title("Sorted by risk (descending)")
|
||||
ax.set_xlabel("Pair rank")
|
||||
ax.set_ylabel("Shortfall probability")
|
||||
ax.grid(True, alpha=0.2)
|
||||
ax.grid(True, alpha=0.55)
|
||||
|
||||
fig.suptitle("Fig.5 Risk distribution across 34 pairs", y=1.02)
|
||||
fig.tight_layout()
|
||||
@@ -282,6 +430,8 @@ def main() -> None:
|
||||
|
||||
out1 = fig1_pairing_map(paths)
|
||||
print(f"Saved: {out1.relative_to(BASE_DIR)}")
|
||||
out1_js = export_fig1_points_js(paths)
|
||||
print(f"Saved: {out1_js.relative_to(BASE_DIR)}")
|
||||
out2 = fig2_allocation_scatter(paths)
|
||||
print(f"Saved: {out2.relative_to(BASE_DIR)}")
|
||||
out4 = fig4_calendar_heatmap(paths)
|
||||
|
||||
@@ -643,6 +643,8 @@ $$R_1 = P(S_i / D_i < 0.8 \text{ 或 } S_j / D_j < 0.8)$$
|
||||
|
||||
```
|
||||
task3/
|
||||
├── fig1_carto.html # 交互地图(CartoDB 底图)
|
||||
├── fig1_points.js # ✅ 自动生成(08_visualize.py)
|
||||
├── 08_visualize.py # 可视化脚本
|
||||
└── figures/
|
||||
├── fig1_pairing_map.png
|
||||
@@ -656,6 +658,8 @@ task3/
|
||||
|
||||

|
||||
|
||||
交互版:用浏览器打开 `task3/fig1_carto.html`(同目录需有 `task3/fig1_points.js`;需联网加载 CartoDB 底图)。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
175
task3/fig1_carto.html
Normal file
@@ -0,0 +1,175 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Task 3 Fig.1 (CartoDB + Leaflet)</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
crossorigin=""
|
||||
/>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#map {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.legend {
|
||||
background: rgba(255, 249, 240, 0.95);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.15);
|
||||
font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
border: 1px solid rgba(188, 108, 37, 0.25);
|
||||
}
|
||||
.legend .title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.legend .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legend .swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.legend .muted {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.legend .line {
|
||||
height: 2px;
|
||||
background: rgba(188, 108, 37, 0.75);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||
<script src="./fig1_points.js"></script>
|
||||
<script>
|
||||
const points = window.FIG1_POINTS || [];
|
||||
const links = window.FIG1_LINKS || [];
|
||||
if (!points.length) {
|
||||
alert("FIG1_POINTS 为空:请先运行 task3/08_visualize.py 生成 fig1_points.js,或确认该文件与本页面同目录。");
|
||||
}
|
||||
|
||||
const map = L.map("map", { preferCanvas: true });
|
||||
|
||||
// CartoDB basemap (Positron)
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
|
||||
subdomains: "abcd",
|
||||
maxZoom: 20,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
}).addTo(map);
|
||||
|
||||
const byId = new Map(points.map((p) => [p.site_id, p]));
|
||||
const muValues = points.map((p) => p.mu);
|
||||
const muMin = Math.min(...muValues);
|
||||
const muMax = Math.max(...muValues);
|
||||
|
||||
const COLORS = {
|
||||
// warmer + higher contrast
|
||||
paired: "#e76f51",
|
||||
unpaired: "#6c757d",
|
||||
link: "#bc6c25",
|
||||
};
|
||||
|
||||
function clamp01(x) {
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
function radiusForMu(mu) {
|
||||
const t = clamp01((mu - muMin) / (muMax - muMin || 1));
|
||||
return lerp(5, 20, t);
|
||||
}
|
||||
function colorForPaired(isPaired) {
|
||||
return isPaired ? COLORS.paired : COLORS.unpaired;
|
||||
}
|
||||
|
||||
const siteLayer = L.featureGroup();
|
||||
for (const p of points) {
|
||||
const marker = L.circleMarker([p.lat, p.lng], {
|
||||
radius: radiusForMu(p.mu),
|
||||
color: "rgba(255,255,255,0.96)",
|
||||
weight: 1.35,
|
||||
fillColor: colorForPaired(p.is_paired),
|
||||
fillOpacity: 0.92,
|
||||
});
|
||||
marker.bindPopup(
|
||||
`<div style="font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">\n` +
|
||||
`<div style="font-weight:700;margin-bottom:6px;">${p.site_id}. ${p.site_name}</div>` +
|
||||
`<div><b>mu</b>: ${p.mu.toFixed(1)}</div>` +
|
||||
`<div><b>sigma</b>: ${p.sigma.toFixed(1)}</div>` +
|
||||
`<div><b>k</b>: ${p.k}</div>` +
|
||||
`<div><b>paired</b>: ${p.is_paired ? "yes" : "no"}</div>` +
|
||||
`</div>`
|
||||
);
|
||||
siteLayer.addLayer(marker);
|
||||
}
|
||||
siteLayer.addTo(map);
|
||||
|
||||
const linkLayer = L.featureGroup();
|
||||
for (const e of links) {
|
||||
const a = byId.get(e.site_i_id);
|
||||
const b = byId.get(e.site_j_id);
|
||||
if (!a || !b) continue;
|
||||
const line = L.polyline(
|
||||
[
|
||||
[a.lat, a.lng],
|
||||
[b.lat, b.lng],
|
||||
],
|
||||
{
|
||||
color: COLORS.link,
|
||||
weight: 2.6,
|
||||
opacity: 0.5,
|
||||
}
|
||||
);
|
||||
line.bindPopup(
|
||||
`<div style="font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">\n` +
|
||||
`<div style="font-weight:700;margin-bottom:6px;">Pair</div>` +
|
||||
`<div>${e.site_i_id}. ${e.site_i_name}</div>` +
|
||||
`<div>${e.site_j_id}. ${e.site_j_name}</div>` +
|
||||
`<div><b>distance</b>: ${e.distance.toFixed(2)} mi</div>` +
|
||||
`</div>`
|
||||
);
|
||||
linkLayer.addLayer(line);
|
||||
}
|
||||
linkLayer.addTo(map);
|
||||
|
||||
const all = L.featureGroup([siteLayer, linkLayer]);
|
||||
map.fitBounds(all.getBounds().pad(0.12));
|
||||
|
||||
const legend = L.control({ position: "bottomleft" });
|
||||
legend.onAdd = function () {
|
||||
const pairedCount = points.filter((p) => p.is_paired).length;
|
||||
const div = L.DomUtil.create("div", "legend");
|
||||
div.innerHTML =
|
||||
`<div class="title">Task 3 Fig.1 配对地图(交互)</div>` +
|
||||
`<div class="row"><span><span class="swatch" style="background:${COLORS.paired}"></span>已配对站点</span><span>${pairedCount}</span></div>` +
|
||||
`<div class="row"><span><span class="swatch" style="background:${COLORS.unpaired}"></span>未配对站点</span><span>${points.length - pairedCount}</span></div>` +
|
||||
`<div class="muted">点大小: mu(线性缩放)</div>` +
|
||||
`<div class="line"></div>` +
|
||||
`<div class="muted">连线: 34条配对(点击查看距离)</div>`;
|
||||
return div;
|
||||
};
|
||||
legend.addTo(map);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
4
task3/fig1_points.js
Normal file
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 692 KiB After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 92 KiB |