Trae Assistant commited on
Commit
7a3ff56
·
0 Parent(s):

Enhance: Fix Vue delimiters, add upload/export, add default data

Browse files
Files changed (6) hide show
  1. .gitignore +4 -0
  2. Dockerfile +15 -0
  3. README.md +74 -0
  4. app.py +206 -0
  5. requirements.txt +3 -0
  6. templates/index.html +440 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv
4
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create a non-root user for security (and HF Spaces compatibility)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV PATH="/home/user/.local/bin:$PATH"
14
+
15
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Fleet Route Commander
3
+ emoji: 🚚
4
+ colorFrom: blue
5
+ colorTo: red
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 智能车队路径规划与物流优化系统 (VRP Solver)
9
+ ---
10
+
11
+ # 智能车队路径指挥官 (Fleet Route Commander)
12
+
13
+ 这是一个基于 **Clarke-Wright Savings Algorithm** 的智能物流路径规划系统(VRP Solver)。
14
+ 旨在帮助物流调度员、车队经理优化配送路线,降低运输成本,提高车辆装载率。
15
+
16
+ ## 核心功能
17
+
18
+ 1. **交互式地图编辑器**:
19
+ * 在画布上自由添加“客户点”(左键)和“仓库点”(右键)。
20
+ * 实时调整客户需求量和车辆容量限制。
21
+ * **支持数据导入/导出**:可上传 JSON 文件快速加载场景,或保存当前布局。
22
+ 2. **智能路径算法**:
23
+ * 内置 VRP 求解引擎,自动计算最优配送路线。
24
+ * 考虑车辆容量约束 (CVRP),避免超载。
25
+ * 最小化车队总行驶里程。
26
+ 3. **可视化结果**:
27
+ * 不同颜色区分不同车辆的行驶路径。
28
+ * 实时显示总里程、所需车辆数、单车负载率。
29
+
30
+ ## 技术栈
31
+
32
+ * **Backend**: Python Flask (API 服务 + 算法逻辑)
33
+ * **Frontend**: Vue.js 3 + Tailwind CSS (无构建流程,纯 CDN 极速加载)
34
+ * **Visualization**: HTML5 Canvas (高性能地图绘制)
35
+ * **Deployment**: Docker (Hugging Face Spaces 兼容)
36
+
37
+ ## 商业应用场景
38
+
39
+ * **城市配送**:外卖、快递、生鲜配送的区域调度。
40
+ * **干线物流**:多点提货/卸货的路径优化。
41
+ * **校车/班车规划**:接送站点的最优串联。
42
+ * **垃圾回收**:市政环卫车辆的清运路线设计。
43
+
44
+ ## 快速开始
45
+
46
+ ### 本地运行
47
+
48
+ ```bash
49
+ # 1. 安装依赖
50
+ pip install -r requirements.txt
51
+
52
+ # 2. 运行应用
53
+ python app.py
54
+ ```
55
+
56
+ ### Docker 运行
57
+
58
+ ```bash
59
+ docker build -t fleet-route-commander .
60
+ docker run -p 7860:7860 fleet-route-commander
61
+ ```
62
+
63
+ 打开浏览器访问 `http://localhost:7860` 即可使用。
64
+
65
+ ## 算法说明
66
+
67
+ 本项目使用 **Clarke-Wright 节约算法 (Savings Algorithm)**:
68
+ 1. 初始化:假设每位客户由单独的车辆配送。
69
+ 2. 计算节约值:评估合并两个客户到同一条路线所能减少的距离。
70
+ 3. 迭代合并:按照节约值从大到小排序,在满足容量约束的前提下合并路线。
71
+ 4. 输出:生成车辆数最少、距离最短的配送方案。
72
+
73
+ ---
74
+ *Created by Trae AI Assistant*
app.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import math
3
+ import json
4
+ import numpy as np
5
+ from flask import Flask, render_template, request, jsonify
6
+
7
+ app = Flask(__name__)
8
+
9
+ # --- VRP Solver (Clarke-Wright Savings Algorithm) ---
10
+
11
+ def calculate_distance(p1, p2):
12
+ return math.sqrt((p1['x'] - p2['x'])**2 + (p1['y'] - p2['y'])**2)
13
+
14
+ def solve_cvrp(depot, customers, vehicle_capacity):
15
+ """
16
+ Solves Capacitated Vehicle Routing Problem using Clarke-Wright Savings Algorithm.
17
+
18
+ Args:
19
+ depot: dict {'x': float, 'y': float}
20
+ customers: list of dicts [{'id': int, 'x': float, 'y': float, 'demand': int}]
21
+ vehicle_capacity: int
22
+
23
+ Returns:
24
+ list of routes, where each route is a list of customer objects (including depot start/end)
25
+ total_distance: float
26
+ """
27
+
28
+ # 1. Initialize: Each customer gets their own route [Depot -> C -> Depot]
29
+ routes = []
30
+ for c in customers:
31
+ if c['demand'] > vehicle_capacity:
32
+ # Edge case: Customer demand > vehicle capacity (impossible in standard VRP, split needed)
33
+ # For simplicity, we just overload or assume valid input.
34
+ # Let's just create a dedicated route and mark it overloaded if needed.
35
+ pass
36
+
37
+ route = {
38
+ 'nodes': [c], # Just the customer for now, we assume depot at ends
39
+ 'load': c['demand'],
40
+ 'cost': 2 * calculate_distance(depot, c)
41
+ }
42
+ routes.append(route)
43
+
44
+ # 2. Calculate Savings
45
+ savings = []
46
+ n = len(customers)
47
+ for i in range(n):
48
+ for j in range(i + 1, n):
49
+ c1 = customers[i]
50
+ c2 = customers[j]
51
+
52
+ d_0_1 = calculate_distance(depot, c1)
53
+ d_0_2 = calculate_distance(depot, c2)
54
+ d_1_2 = calculate_distance(c1, c2)
55
+
56
+ # Saving = d(0,i) + d(0,j) - d(i,j)
57
+ s = d_0_1 + d_0_2 - d_1_2
58
+ savings.append({
59
+ 'i': i, # index in customers list
60
+ 'j': j,
61
+ 's': s
62
+ })
63
+
64
+ # 3. Sort Savings Descending
65
+ savings.sort(key=lambda x: x['s'], reverse=True)
66
+
67
+ # 4. Merge Routes
68
+ # We need to map customer_index -> route_object to track where each customer is
69
+ cust_to_route = {i: routes[i] for i in range(n)}
70
+
71
+ for saving in savings:
72
+ i = saving['i']
73
+ j = saving['j']
74
+
75
+ route_i = cust_to_route[i]
76
+ route_j = cust_to_route[j]
77
+
78
+ # If already same route, skip
79
+ if route_i is route_j:
80
+ continue
81
+
82
+ # Check Capacity
83
+ if route_i['load'] + route_j['load'] > vehicle_capacity:
84
+ continue
85
+
86
+ # Check Connectivity (Can only merge if i and j are at the "ends" of their respective routes, connected to depot)
87
+ # route nodes list: [c1, c2, c3] (implicitly Depot -> c1 ... c3 -> Depot)
88
+
89
+ # Case 1: i is last in route_i, j is first in route_j
90
+ # New route: [route_i...] + [route_j...]
91
+ i_is_last = (route_i['nodes'][-1]['id'] == customers[i]['id'])
92
+ j_is_first = (route_j['nodes'][0]['id'] == customers[j]['id'])
93
+
94
+ # Case 2: i is first in route_i, j is last in route_j
95
+ # New route: [route_j...] + [route_i...]
96
+ i_is_first = (route_i['nodes'][0]['id'] == customers[i]['id'])
97
+ j_is_last = (route_j['nodes'][-1]['id'] == customers[j]['id'])
98
+
99
+ # Case 3: i is first, j is first -> Reverse route_i so i becomes last
100
+ # New route: [reversed(route_i)...] + [route_j...]
101
+
102
+ # Case 4: i is last, j is last -> Reverse route_j so j becomes first
103
+ # New route: [route_i...] + [reversed(route_j)...]
104
+
105
+ # Simplified merge logic
106
+ new_nodes = None
107
+
108
+ if i_is_last and j_is_first:
109
+ new_nodes = route_i['nodes'] + route_j['nodes']
110
+ elif i_is_first and j_is_last:
111
+ new_nodes = route_j['nodes'] + route_i['nodes']
112
+ elif i_is_first and j_is_first:
113
+ new_nodes = route_i['nodes'][::-1] + route_j['nodes'] # Reverse i
114
+ elif i_is_last and j_is_last:
115
+ new_nodes = route_i['nodes'] + route_j['nodes'][::-1] # Reverse j
116
+
117
+ if new_nodes:
118
+ # Perform Merge
119
+ new_load = route_i['load'] + route_j['load']
120
+
121
+ # Update route_i to become the merged route
122
+ route_i['nodes'] = new_nodes
123
+ route_i['load'] = new_load
124
+
125
+ # Update pointers for all nodes in the old route_j to point to route_i
126
+ # Note: We must update ALL nodes that were in route_j, not just customer j
127
+ for node in route_j['nodes']:
128
+ # Find the index of this node in the original customers list
129
+ # This is inefficient O(N), but fine for demo < 100 nodes
130
+ c_idx = next((idx for idx, c in enumerate(customers) if c['id'] == node['id']), None)
131
+ if c_idx is not None:
132
+ cust_to_route[c_idx] = route_i
133
+
134
+ # Remove route_j from active routes list (optional, but good for cleanup)
135
+ # Actually we just filter at the end
136
+ route_j['active'] = False # Mark as dead
137
+
138
+ # 5. Finalize Routes
139
+ final_routes = []
140
+ total_dist = 0
141
+
142
+ unique_routes = []
143
+ seen_ids = set()
144
+
145
+ # Filter out dead routes and duplicates
146
+ for r in routes:
147
+ if r.get('active', True):
148
+ # Check if we already processed this route object
149
+ if id(r) not in seen_ids:
150
+ seen_ids.add(id(r))
151
+ unique_routes.append(r)
152
+
153
+ for r in unique_routes:
154
+ # Construct full path: Depot -> Nodes -> Depot
155
+ full_path = [depot] + r['nodes'] + [depot]
156
+
157
+ # Calculate final distance
158
+ dist = 0
159
+ for k in range(len(full_path) - 1):
160
+ dist += calculate_distance(full_path[k], full_path[k+1])
161
+
162
+ final_routes.append({
163
+ 'path': full_path,
164
+ 'load': r['load'],
165
+ 'distance': round(dist, 2),
166
+ 'vehicle_id': len(final_routes) + 1
167
+ })
168
+ total_dist += dist
169
+
170
+ return final_routes, round(total_dist, 2)
171
+
172
+ # --- Routes ---
173
+
174
+ @app.route('/')
175
+ def index():
176
+ return render_template('index.html')
177
+
178
+ @app.route('/api/optimize', methods=['POST'])
179
+ def optimize():
180
+ try:
181
+ data = request.json
182
+ depot = data.get('depot')
183
+ customers = data.get('customers', [])
184
+ vehicle_capacity = float(data.get('vehicleCapacity', 100))
185
+
186
+ if not depot:
187
+ return jsonify({'error': 'Depot is required'}), 400
188
+
189
+ if not customers:
190
+ return jsonify({'routes': [], 'total_distance': 0}), 200
191
+
192
+ # Run Solver
193
+ routes, total_dist = solve_cvrp(depot, customers, vehicle_capacity)
194
+
195
+ return jsonify({
196
+ 'routes': routes,
197
+ 'total_distance': total_dist,
198
+ 'vehicle_count': len(routes)
199
+ })
200
+
201
+ except Exception as e:
202
+ return jsonify({'error': str(e)}), 500
203
+
204
+ if __name__ == '__main__':
205
+ port = int(os.environ.get('PORT', 7860))
206
+ app.run(host='0.0.0.0', port=port)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask==3.0.0
2
+ numpy==1.26.3
3
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>智能车队路径指挥官 (Fleet Route Commander)</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
10
+ <style>
11
+ body { font-family: 'Inter', sans-serif; }
12
+ canvas { cursor: crosshair; }
13
+ .slide-fade-enter-active { transition: all 0.3s ease-out; }
14
+ .slide-fade-leave-active { transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1); }
15
+ .slide-fade-enter-from, .slide-fade-leave-to { transform: translateX(20px); opacity: 0; }
16
+ [v-cloak] { display: none; }
17
+ </style>
18
+ </head>
19
+ <body class="bg-gray-50 text-gray-800 h-screen flex flex-col">
20
+
21
+ <div id="app" class="flex-1 flex flex-col h-full" v-cloak>
22
+ <!-- Header -->
23
+ <header class="bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center shadow-sm z-10">
24
+ <div class="flex items-center gap-3">
25
+ <div class="bg-blue-600 text-white p-2 rounded-lg">
26
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
27
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0121 18.382V7.618a1 1 0 01-.553-.894L15 7m0 13V7m0 0L9.553 4.553A1 1 0 009 7v13" />
28
+ </svg>
29
+ </div>
30
+ <div>
31
+ <h1 class="text-xl font-bold text-gray-900 tracking-tight">智能车队路径指挥官</h1>
32
+ <p class="text-xs text-gray-500">Fleet Route Commander - VRP Solver</p>
33
+ </div>
34
+ </div>
35
+ <div class="flex items-center gap-4">
36
+ <div class="text-sm text-gray-600">
37
+ <span class="inline-block w-3 h-3 bg-blue-500 rounded-sm mr-1"></span> 仓库 (右键)
38
+ <span class="inline-block w-3 h-3 bg-red-500 rounded-full ml-3 mr-1"></span> 客户 (左键)
39
+ </div>
40
+ <button @click="resetMap" class="text-sm text-gray-500 hover:text-red-600 transition-colors">
41
+ 重置地图
42
+ </button>
43
+ </div>
44
+ </header>
45
+
46
+ <!-- Main Content -->
47
+ <div class="flex-1 flex overflow-hidden">
48
+ <!-- Sidebar Controls -->
49
+ <aside class="w-80 bg-white border-r border-gray-200 flex flex-col z-20 shadow-lg">
50
+ <div class="p-5 border-b border-gray-100 space-y-4">
51
+ <div>
52
+ <label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">车辆容量限制</label>
53
+ <input type="number" v-model.number="vehicleCapacity" class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" placeholder="例如: 100">
54
+ </div>
55
+ <div>
56
+ <label class="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">默认客户需求</label>
57
+ <input type="number" v-model.number="defaultDemand" class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" placeholder="例如: 10">
58
+ </div>
59
+
60
+ <!-- Data Controls -->
61
+ <div class="flex gap-2">
62
+ <input type="file" id="fileInput" class="hidden" @change="handleFileUpload" accept=".json">
63
+ <button @click="triggerUpload" class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-semibold py-2 rounded border border-gray-300 transition-colors">
64
+ 导入数据
65
+ </button>
66
+ <button @click="exportData" class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs font-semibold py-2 rounded border border-gray-300 transition-colors">
67
+ 导出数据
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Stats / Actions -->
73
+ <div class="p-5 bg-gray-50 space-y-4">
74
+ <button @click="optimizeRoutes" :disabled="!canOptimize || isOptimizing"
75
+ class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-semibold py-2.5 rounded-lg shadow-md transition-all flex justify-center items-center gap-2">
76
+ <span v-if="isOptimizing" class="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></span>
77
+ <span v-else>开始路径规划 (Optimize)</span>
78
+ </button>
79
+
80
+ <div v-if="solution" class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm space-y-2">
81
+ <div class="flex justify-between items-center">
82
+ <span class="text-gray-500 text-sm">总里程</span>
83
+ <span class="font-bold text-gray-900 text-lg">${ solution.total_distance } km</span>
84
+ </div>
85
+ <div class="flex justify-between items-center">
86
+ <span class="text-gray-500 text-sm">所需车辆</span>
87
+ <span class="font-bold text-blue-600">${ solution.vehicle_count } 辆</span>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <!-- Route Details List -->
93
+ <div class="flex-1 overflow-y-auto p-4 space-y-3">
94
+ <h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">规划结果详情</h3>
95
+ <div v-if="!solution && customers.length > 0" class="text-center text-gray-400 py-8 text-sm">
96
+ 点击上方按钮计算最优路径
97
+ </div>
98
+ <div v-else-if="customers.length === 0" class="text-center text-gray-400 py-8 text-sm">
99
+ 请在右侧地图点击添加客户点
100
+ </div>
101
+
102
+ <transition-group name="slide-fade">
103
+ <div v-for="(route, index) in solution?.routes" :key="index"
104
+ class="bg-white p-3 rounded-lg border-l-4 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
105
+ :style="{ borderLeftColor: getRouteColor(index) }"
106
+ @mouseenter="highlightRoute(index)"
107
+ @mouseleave="highlightRoute(null)">
108
+ <div class="flex justify-between items-start mb-2">
109
+ <span class="font-bold text-gray-800 text-sm">车辆 #${ route.vehicle_id }</span>
110
+ <span class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">负载: ${ route.load }/${ vehicleCapacity }</span>
111
+ </div>
112
+ <div class="text-xs text-gray-500 line-clamp-2">
113
+ 路径: 仓库 → ${ route.path.slice(1, -1).map(n => 'C'+n.id).join(' → ') } → 仓库
114
+ </div>
115
+ <div class="mt-2 text-right text-xs text-gray-400">
116
+ 里程: ${ route.distance }
117
+ </div>
118
+ </div>
119
+ </transition-group>
120
+ </div>
121
+ </aside>
122
+
123
+ <!-- Map Area -->
124
+ <main class="flex-1 relative bg-white" ref="mapContainer">
125
+ <canvas ref="canvas" class="absolute inset-0 w-full h-full block"></canvas>
126
+
127
+ <!-- Floating Legend -->
128
+ <div class="absolute top-4 right-4 bg-white/90 backdrop-blur px-4 py-2 rounded-lg shadow-sm border border-gray-200 text-xs text-gray-500 pointer-events-none select-none">
129
+ 缩放: 滚轮 | 移动: 按住空格拖拽 (暂未实现)
130
+ </div>
131
+ </main>
132
+ </div>
133
+ </div>
134
+
135
+ <script>
136
+ const { createApp, ref, onMounted, computed, watch } = Vue;
137
+
138
+ createApp({
139
+ delimiters: ['${', '}'],
140
+ setup() {
141
+ const canvas = ref(null);
142
+ const mapContainer = ref(null);
143
+ const ctx = ref(null);
144
+
145
+ // State
146
+ const depot = ref({ x: 400, y: 300 }); // Default centerish
147
+ const customers = ref([]);
148
+ const vehicleCapacity = ref(100);
149
+ const defaultDemand = ref(10);
150
+ const solution = ref(null);
151
+ const isOptimizing = ref(false);
152
+ const hoveredRouteIndex = ref(null);
153
+
154
+ // Colors for routes
155
+ const colors = [
156
+ '#2563EB', '#DC2626', '#16A34A', '#D97706', '#9333EA',
157
+ '#0891B2', '#DB2777', '#4F46E5', '#CA8A04', '#059669'
158
+ ];
159
+
160
+ const getRouteColor = (index) => colors[index % colors.length];
161
+
162
+ const canOptimize = computed(() => customers.value.length > 0 && depot.value);
163
+
164
+ // Initialization
165
+ onMounted(() => {
166
+ initCanvas();
167
+ window.addEventListener('resize', resizeCanvas);
168
+ resizeCanvas();
169
+
170
+ // Add some demo data by default
171
+ addDemoData();
172
+ });
173
+
174
+ function initCanvas() {
175
+ const el = canvas.value;
176
+ if (!el) return;
177
+ ctx.value = el.getContext('2d');
178
+
179
+ // Event Listeners
180
+ el.addEventListener('mousedown', handleMapClick);
181
+ el.addEventListener('contextmenu', (e) => {
182
+ e.preventDefault();
183
+ handleMapClick(e, true);
184
+ });
185
+ }
186
+
187
+ function resizeCanvas() {
188
+ const container = mapContainer.value;
189
+ const el = canvas.value;
190
+ if (!container || !el) return;
191
+
192
+ el.width = container.clientWidth;
193
+ el.height = container.clientHeight;
194
+
195
+ // If depot is default and no customers, center it
196
+ if (customers.value.length === 0) {
197
+ depot.value = { x: el.width / 2, y: el.height / 2 };
198
+ }
199
+
200
+ draw();
201
+ }
202
+
203
+ function handleMapClick(e, isRightClick) {
204
+ const rect = canvas.value.getBoundingClientRect();
205
+ const x = e.clientX - rect.left;
206
+ const y = e.clientY - rect.top;
207
+
208
+ if (isRightClick) {
209
+ depot.value = { x, y };
210
+ // Clear solution when map changes
211
+ solution.value = null;
212
+ } else {
213
+ const id = customers.value.length + 1;
214
+ customers.value.push({
215
+ id,
216
+ x,
217
+ y,
218
+ demand: defaultDemand.value
219
+ });
220
+ solution.value = null;
221
+ }
222
+ draw();
223
+ }
224
+
225
+ function resetMap() {
226
+ customers.value = [];
227
+ solution.value = null;
228
+ if (canvas.value) {
229
+ depot.value = { x: canvas.value.width / 2, y: canvas.value.height / 2 };
230
+ }
231
+ draw();
232
+ }
233
+
234
+ function addDemoData() {
235
+ // Example data for quick start
236
+ if (customers.value.length > 0) return; // Don't overwrite if user already added something
237
+
238
+ // Get current center to place relative points
239
+ const cx = canvas.value ? canvas.value.width / 2 : 400;
240
+ const cy = canvas.value ? canvas.value.height / 2 : 300;
241
+
242
+ depot.value = { x: cx, y: cy };
243
+
244
+ customers.value = [
245
+ { id: 1, x: cx - 150, y: cy - 100, demand: 15 },
246
+ { id: 2, x: cx + 200, y: cy - 50, demand: 20 },
247
+ { id: 3, x: cx - 50, y: cy + 150, demand: 10 },
248
+ { id: 4, x: cx + 100, y: cy + 100, demand: 25 },
249
+ { id: 5, x: cx + 250, y: cy + 50, demand: 12 },
250
+ { id: 6, x: cx - 100, y: cy - 150, demand: 8 },
251
+ { id: 7, x: cx - 200, y: cy + 50, demand: 18 },
252
+ ];
253
+ draw();
254
+ }
255
+
256
+ function triggerUpload() {
257
+ const fileInput = document.getElementById('fileInput');
258
+ if (fileInput) fileInput.click();
259
+ }
260
+
261
+ function handleFileUpload(event) {
262
+ const file = event.target.files[0];
263
+ if (!file) return;
264
+
265
+ const reader = new FileReader();
266
+ reader.onload = (e) => {
267
+ try {
268
+ const data = JSON.parse(e.target.result);
269
+ if (data.customers) customers.value = data.customers;
270
+ if (data.depot) depot.value = data.depot;
271
+ if (data.vehicleCapacity) vehicleCapacity.value = data.vehicleCapacity;
272
+ solution.value = null; // Clear old solution
273
+ draw();
274
+ alert('数据导入成功!');
275
+ } catch (err) {
276
+ alert('文件解析失败,请确保是有效的 JSON 文件。');
277
+ console.error(err);
278
+ }
279
+ };
280
+ reader.readAsText(file);
281
+ event.target.value = ''; // Reset
282
+ }
283
+
284
+ function exportData() {
285
+ const data = {
286
+ depot: depot.value,
287
+ customers: customers.value,
288
+ vehicleCapacity: vehicleCapacity.value,
289
+ solution: solution.value
290
+ };
291
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
292
+ const url = URL.createObjectURL(blob);
293
+ const a = document.createElement('a');
294
+ a.href = url;
295
+ a.download = 'route_data.json';
296
+ a.click();
297
+ URL.revokeObjectURL(url);
298
+ }
299
+
300
+ async function optimizeRoutes() {
301
+ if (!depot.value) return;
302
+
303
+ isOptimizing.value = true;
304
+ try {
305
+ const response = await fetch('/api/optimize', {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({
309
+ depot: depot.value,
310
+ customers: customers.value,
311
+ vehicleCapacity: vehicleCapacity.value
312
+ })
313
+ });
314
+
315
+ if (!response.ok) {
316
+ throw new Error(`HTTP error! status: ${response.status}`);
317
+ }
318
+
319
+ const data = await response.json();
320
+ if (data.error) {
321
+ alert('Error: ' + data.error);
322
+ } else {
323
+ solution.value = data;
324
+ }
325
+ } catch (e) {
326
+ alert('Optimization failed: ' + e.message);
327
+ console.error(e);
328
+ } finally {
329
+ isOptimizing.value = false;
330
+ draw(); // Redraw with routes
331
+ }
332
+ }
333
+
334
+ function highlightRoute(index) {
335
+ hoveredRouteIndex.value = index;
336
+ draw();
337
+ }
338
+
339
+ function draw() {
340
+ if (!ctx.value || !canvas.value) return;
341
+ const c = ctx.value;
342
+ const w = canvas.value.width;
343
+ const h = canvas.value.height;
344
+
345
+ // Clear
346
+ c.clearRect(0, 0, w, h);
347
+
348
+ // Grid lines
349
+ c.strokeStyle = '#f3f4f6';
350
+ c.lineWidth = 1;
351
+ const gridSize = 50;
352
+ c.beginPath();
353
+ for(let x=0; x<=w; x+=gridSize) { c.moveTo(x,0); c.lineTo(x,h); }
354
+ for(let y=0; y<=h; y+=gridSize) { c.moveTo(0,y); c.lineTo(w,y); }
355
+ c.stroke();
356
+
357
+ // Draw Routes
358
+ if (solution.value && solution.value.routes) {
359
+ solution.value.routes.forEach((route, index) => {
360
+ const isHovered = hoveredRouteIndex.value === index;
361
+ const isDimmed = hoveredRouteIndex.value !== null && !isHovered;
362
+
363
+ c.beginPath();
364
+ c.strokeStyle = getRouteColor(index);
365
+ c.lineWidth = isHovered ? 4 : 2;
366
+ c.globalAlpha = isDimmed ? 0.2 : 0.8;
367
+
368
+ // Path points
369
+ const points = route.path;
370
+ if (points.length > 0) {
371
+ c.moveTo(points[0].x, points[0].y);
372
+ for (let i = 1; i < points.length; i++) {
373
+ c.lineTo(points[i].x, points[i].y);
374
+ }
375
+ }
376
+ c.stroke();
377
+ c.globalAlpha = 1.0;
378
+ });
379
+ }
380
+
381
+ // Draw Connections to Depot (Ghost lines if no solution)
382
+ if (!solution.value) {
383
+ c.strokeStyle = '#e5e7eb';
384
+ c.lineWidth = 1;
385
+ c.setLineDash([4, 4]);
386
+ c.beginPath();
387
+ customers.value.forEach(cust => {
388
+ c.moveTo(depot.value.x, depot.value.y);
389
+ c.lineTo(cust.x, cust.y);
390
+ });
391
+ c.stroke();
392
+ c.setLineDash([]);
393
+ }
394
+
395
+ // Draw Depot
396
+ if (depot.value) {
397
+ c.fillStyle = '#3B82F6'; // Blue-500
398
+ c.shadowColor = 'rgba(59, 130, 246, 0.5)';
399
+ c.shadowBlur = 10;
400
+ const size = 20;
401
+ c.fillRect(depot.value.x - size/2, depot.value.y - size/2, size, size);
402
+
403
+ c.fillStyle = 'white';
404
+ c.font = 'bold 12px Inter';
405
+ c.textAlign = 'center';
406
+ c.textBaseline = 'middle';
407
+ c.fillText('D', depot.value.x, depot.value.y);
408
+ c.shadowBlur = 0;
409
+ }
410
+
411
+ // Draw Customers
412
+ customers.value.forEach(cust => {
413
+ // Circle
414
+ c.beginPath();
415
+ c.arc(cust.x, cust.y, 8, 0, Math.PI * 2);
416
+ c.fillStyle = '#EF4444'; // Red-500
417
+ c.fill();
418
+
419
+ // Demand Label
420
+ c.fillStyle = '#1f2937';
421
+ c.font = '10px Inter';
422
+ c.textAlign = 'center';
423
+ c.fillText(cust.demand, cust.x, cust.y - 12);
424
+ });
425
+ }
426
+
427
+ return {
428
+ canvas, mapContainer,
429
+ vehicleCapacity, defaultDemand,
430
+ customers, solution,
431
+ isOptimizing, canOptimize,
432
+ resetMap, optimizeRoutes,
433
+ getRouteColor, highlightRoute,
434
+ triggerUpload, handleFileUpload, exportData // Exporting new functions
435
+ };
436
+ }
437
+ }).mount('#app');
438
+ </script>
439
+ </body>
440
+ </html>