-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathweb_map.py
More file actions
838 lines (786 loc) · 38.4 KB
/
Copy pathweb_map.py
File metadata and controls
838 lines (786 loc) · 38.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
"""
生成交互式 Leaflet 行程地图(嵌入程序 或 浏览器打开共用)。
- 底图可选: 高德(GCJ-02, 中文超清) / OSM(WGS-84, 全球含日本)。
- 换 OSM 时中国坐标自动 GCJ-02->WGS-84; 高德用原始坐标。
- 视觉: Pip-Boy / RobCo 终端风 — 辉光管绿(#3CFF6A) + 黑底 + 等宽字体 + 扫描线 + CRT 暗角。
底图通过 CSS filter(hue-rotate + saturate + brightness)做单色化, 网络线/站/标签全用磷光绿。
- 图层(顶层开关, 默认保守): showNet=关(网太密会盖行程), showMidLabels=关(ALL时一堆字挤一起), edgeFocus=开(端点显眼)
"""
from __future__ import annotations
import json
import os
import tempfile
import webbrowser
import cn_coords
# 终端配色(和主程序 main.py 同步)
PHOSPHOR = "#3CFF6A" # 辉光管绿(主色)
PHOSPHOR_DIM = "#1FB04A" # 暗绿
PHOSPHOR_BG = "#03140A" # 黑底
PHOSPHOR_AMBER = "#FFB000" # RobCo 橙(端点/警告)
PHOSPHOR_RED = "#FF7A33" # 暗红(消费/错误)
ROUTE_GREEN = "#62FF85" # 行程线高亮绿
_TILES = {
"amap": {"url": "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}",
"sub": ["1", "2", "3", "4"], "attr": "© 高德地图(终端着色)", "max": 18},
"osm": {"url": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
"sub": ["a", "b", "c"], "attr": "© OpenStreetMap(终端着色)", "max": 19},
}
_HTML = """<!doctype html>
<html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>__LEAFLET_CSS__</style>
<style>
/* ── 终端基础: 磷光绿 on 黑 ───────────────────────────────── */
html,body,#map{height:100%;margin:0;background:%BG%;
font-family:"Cascadia Mono","Consolas","Courier New","MS Gothic","Microsoft YaHei",monospace;
color:%PHOSPHOR%;}
/* 底图瓦片 filter (由 data.bw / data.crt 控制)
之前思路: 默认 grayscale, 加 hue-rotate 偏到绿 → 但用户看到的"蓝色怪线"
其实是 OSM 道路(motorway 蓝) 被 grayscale 后通过 mix-blend-multiply 深绿染 → 变深青/暗蓝
新思路: 三个模式互斥
- bw=true: 强制单色(绿磷光) — 终端风
- bw=false + crt=true: 原始 OSM 彩色 + CRT 扫描线/暗角(扫描线在 .crt-scan 容器, 不动瓦片 filter)
- bw=false + crt=false: 完全干净, 原始 OSM 彩色
默认 bw=true, crt=true(原体验)
*/
.leaflet-tile-pane{position:relative}
.leaflet-tile-pane::after{
content:""; position:absolute; inset:0; pointer-events:none;
background: linear-gradient(180deg, rgba(3,20,10,0.62) 0%, rgba(3,20,10,0.85) 100%);
mix-blend-mode: multiply;
}
/* ── 控件 / 缩放按钮: 重写为绿壳 ─────────────────────────── */
.leaflet-control-zoom a{
background:%BG% !important; color:%PHOSPHOR% !important;
border:1px solid %PHOSPHOR_DIM% !important; border-radius:0 !important;
font-weight:700; font-family:inherit;
text-shadow: 0 0 4px %PHOSPHOR%;
}
.leaflet-control-zoom a:hover{
background:%PHOSPHOR_DIM% !important; color:%BG% !important; text-shadow:none;
}
.leaflet-control-attribution{
background: rgba(3,20,10,0.7) !important; color:%PHOSPHOR_DIM% !important;
font: 10px "Cascadia Mono","Consolas",monospace !important; border:1px solid %PHOSPHOR_DIM%;
border-radius:0 !important; padding:1px 6px !important;
}
.leaflet-control-attribution a{ color:%PHOSPHOR% !important; }
/* ── 站名标签: 黑底 + 颜色描边(随行程代表色) + 辉光 + 等宽 ─── */
.stlbl{
background: rgba(3,20,10,0.92); color: %PHOSPHOR%;
border: 1px solid %PHOSPHOR_DIM%; border-radius: 0;
box-shadow: 0 0 6px rgba(60,255,106,0.45),
inset 0 0 6px rgba(60,255,106,0.10);
font: 600 11px "Cascadia Mono","Consolas","Microsoft YaHei",monospace;
padding: 1px 5px; letter-spacing: 0.2px;
text-shadow: 0 0 4px %PHOSPHOR%;
white-space: nowrap;
}
.leaflet-tooltip-right:before,.leaflet-tooltip-left:before{display:none}
/* ── 顶部信息条: 终端面板风 ─────────────────────────────── */
.panel{
position:absolute; top:10px; left:10px; z-index:500;
background: rgba(3,20,10,0.86); color:%PHOSPHOR%;
border:1px solid %PHOSPHOR_DIM%; border-radius:0; padding:6px 12px;
font: 600 12px "Cascadia Mono","Consolas",monospace;
box-shadow: 0 0 8px rgba(60,255,106,0.30);
text-shadow: 0 0 3px %PHOSPHOR%;
letter-spacing:0.5px;
}
.panel b{ color:%ROUTE_GREEN%; text-shadow: 0 0 5px %ROUTE_GREEN%; }
/* ── 弹出气泡(点击站点) ────────────────────────────────── */
.leaflet-popup-content-wrapper, .leaflet-popup-tip{
background:%BG% !important; color:%PHOSPHOR% !important;
border:1px solid %PHOSPHOR_DIM% !important; border-radius:0 !important;
box-shadow: 0 0 10px rgba(60,255,106,0.40) !important;
font-family: "Cascadia Mono","Consolas",monospace !important;
}
.leaflet-popup-content{ margin:8px 12px !important; font: 12px "Cascadia Mono","Consolas",monospace; }
.leaflet-popup-content b{ color:%ROUTE_GREEN%; text-shadow:0 0 4px %ROUTE_GREEN%; }
/* ── Leaflet 内置 zoom 控件: 隐藏 sub 控件多余的边线 ─── */
.leaflet-bar{ border: none !important; box-shadow:none !important; }
/* ── 覆盖在地图上的 CRT 效果: 扫描线 + 暗角 ─────────── */
.crt-scan{
position:absolute; inset:0; pointer-events:none; z-index:600;
background: repeating-linear-gradient(
0deg,
rgba(0,0,0,0.18) 0px, rgba(0,0,0,0.18) 1px,
transparent 1px, transparent 3px
);
mix-blend-mode: multiply;
}
.crt-vignette{
position:absolute; inset:0; pointer-events:none; z-index:601;
background: radial-gradient(ellipse at center,
rgba(0,0,0,0) 50%, rgba(0,0,0,0.55) 100%);
}
.crt-flicker{
position:absolute; inset:0; pointer-events:none; z-index:602;
background: rgba(60,255,106,0.04);
opacity:0;
}
.map-wake{
position:absolute; inset:0; z-index:603; pointer-events:none; opacity:0;
background:linear-gradient(180deg,rgba(3,20,10,.42),rgba(3,20,10,.05));
}
.map-wake::after{
content:""; position:absolute; left:0; right:0; top:-2px; height:2px;
background:%PHOSPHOR%; box-shadow:0 0 10px %PHOSPHOR%; opacity:0;
}
body.fx-map-wake .map-wake{animation:map-wake-fade 220ms ease-out 1;}
body.fx-map-wake .map-wake::after{animation:map-wake-scan 220ms ease-out 1;}
body.fx-map-wake .crt-flicker{animation:crt-wake 150ms steps(2,end) 1;}
@keyframes map-wake-fade{0%{opacity:.8}100%{opacity:0}}
@keyframes map-wake-scan{0%{transform:translateY(0);opacity:.9}100%{transform:translateY(100vh);opacity:0}}
@keyframes crt-wake{0%,100%{opacity:0}45%{opacity:.22}65%{opacity:.05}}
body.map-moving .leaflet-tooltip-pane{visibility:hidden;}
body.map-moving .route-primary{filter:none!important;}
body.mode-mid-off .mid-label{display:none!important;}
body.zoom-labels-off .mid-label{display:none!important;}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation:none!important;transition:none!important;}
.map-wake{display:none!important;}
}
</style></head>
<body><div id="map"></div><div class="map-wake" aria-hidden="true"></div>
<div class="crt-flicker"></div>
<div class="crt-vignette"></div>
<div class="crt-scan"></div>
<div class="panel" id="meta"></div>
<script>__LEAFLET_JS__</script>
<script>
const data = __DATA__;
const PHOSPHOR = "%PHOSPHOR%", PHOSPHOR_DIM = "%PHOSPHOR_DIM%",
PHOSPHOR_AMBER = "%PHOSPHOR_AMBER%", ROUTE_GREEN = "%ROUTE_GREEN%";
const SHOW_NET = !!data.showNet, SHOW_MID_LABELS = !!data.showMidLabels, EDGE_FOCUS = !!data.edgeFocus,
B_AND_W = !!data.bw, SHOW_CRT = data.crt !== false; // 默认 true(老体验不破)
const REDUCED_MOTION = !!(window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches);
const map = L.map('map', {
zoomControl:true, attributionControl:true,
zoomAnimation:!REDUCED_MOTION, fadeAnimation:false, markerZoomAnimation:false
});
const netRenderer = L.canvas({padding:0.35, tolerance:2});
/* 全局图层状态 — 暴露 setLayers() 给 Python 端调, 不重建 map 只切 CSS / layer group 显隐
注意: 切换 bw / crt 是纯 CSS 切换, 不重画; 切 net / mid / edge 需要重画(show_place 重调) */
window._mapState = { net: SHOW_NET, mid: SHOW_MID_LABELS, edge: EDGE_FOCUS, bw: B_AND_W, crt: SHOW_CRT };
window._netGroup = L.layerGroup(); // 网络底图 polyline 群
window._midLabelGroup = L.layerGroup(); // 途中站名 tooltip 群
window._edgeGroup = L.layerGroup(); // 端点强调(外圈环)群
window._routeGroup = L.layerGroup(); // 行程线群
window._markerGroup = L.layerGroup(); // 站点圆群
// 静态(默认显示)的行程 / 站点 / 端点 — 时间线播放时不会被 clearLayers 清掉
window._staticRouteGroup = L.layerGroup();
window._staticMarkerGroup = L.layerGroup();
window._staticEdgeGroup = L.layerGroup();
/* 默认显示静态组, 时间线 group 暂不挂 map (playTimeline 时再加) */
window._staticMarkerGroup.addTo(map);
window._staticRouteGroup.addTo(map);
window._staticEdgeGroup.addTo(map);
window._midLabelGroup.addTo(map);
window._netGroup.addTo(map);
/* 三个 mode 互斥: CSS class 切到 body 上, 让 filter / overlay 跟着变 */
document.body.classList.add('mode-bw-' + (B_AND_W ? 'on' : 'off'));
document.body.classList.add('mode-crt-' + (SHOW_CRT ? 'on' : 'off'));
document.body.classList.add('mode-mid-' + (SHOW_MID_LABELS ? 'on' : 'off'));
/* 暴露 setLayers 给 Python 端, 不重建 map, 只切 CSS class 和 layer group 显隐 */
window.setLayers = function(s){
if (typeof s !== 'object') return;
const before = Object.assign({}, window._mapState);
// 合并新状态
for (const k of Object.keys(s)) {
if (k in window._mapState && s[k] !== undefined) window._mapState[k] = !!s[k];
}
const cur = window._mapState;
// bw / crt: 切 CSS class
if ('bw' in s) {
document.body.classList.remove('mode-bw-on','mode-bw-off');
document.body.classList.add('mode-bw-' + (cur.bw ? 'on' : 'off'));
}
if ('crt' in s) {
document.body.classList.remove('mode-crt-on','mode-crt-off');
document.body.classList.add('mode-crt-' + (cur.crt ? 'on' : 'off'));
}
// net / mid / edge: 切 layer group 显隐(避免重画)
if ('net' in s) {
if (cur.net) { if (!map.hasLayer(window._netGroup)) window._netGroup.addTo(map); }
else { if (map.hasLayer(window._netGroup)) map.removeLayer(window._netGroup); }
}
if ('mid' in s) {
document.body.classList.remove('mode-mid-on','mode-mid-off');
document.body.classList.add('mode-mid-' + (cur.mid ? 'on' : 'off'));
}
if ('edge' in s) {
if (cur.edge) { if (!map.hasLayer(window._staticEdgeGroup)) window._staticEdgeGroup.addTo(map); }
else { if (map.hasLayer(window._staticEdgeGroup)) map.removeLayer(window._staticEdgeGroup); }
}
return before;
};
window.getLayers = function(){ return Object.assign({}, window._mapState); };
/* 时间线播放: 线延伸动画 — 像老式绘图仪一笔画出来
流程: 清空 → 隐藏静态 → 逐条 trip 推进 → 每条内部逐点延伸 → 端点弹出提示 */
window._timelineRunId = 0;
window.playTimeline = function(opts){
opts = opts || {};
const perStep = opts.perStep || 800; // 段间停顿(到端点后稍停)
const perSegMs = opts.perSegMs || 25; // 单段线延伸速度(越小越快)
const trips = (opts.trips || data.timeline || []);
if (!trips.length) return -1;
window._routeGroup.clearLayers();
window._markerGroup.clearLayers();
window._edgeGroup.clearLayers();
if (!map.hasLayer(window._routeGroup)) window._routeGroup.addTo(map);
if (!map.hasLayer(window._markerGroup)) window._markerGroup.addTo(map);
if (!map.hasLayer(window._edgeGroup)) window._edgeGroup.addTo(map);
if (map.hasLayer(window._staticRouteGroup)) map.removeLayer(window._staticRouteGroup);
if (map.hasLayer(window._staticMarkerGroup)) map.removeLayer(window._staticMarkerGroup);
if (map.hasLayer(window._staticEdgeGroup)) map.removeLayer(window._staticEdgeGroup);
window._timelinePlaying = true; // 旗标, 让 stopTimeline 中断
const runId = ++window._timelineRunId;
const isActive = () => window._timelinePlaying && window._timelineRunId === runId;
let step = 0;
function showNextTrip(){
if (!isActive()) return;
if (step >= trips.length) return;
const t = trips[step];
animateSegment(t, 0, () => {
if (!isActive()) return;
step++;
if (step < trips.length) setTimeout(showNextTrip, perStep);
});
}
// 单条 trip: 逐段画(从 stop 延伸到 stop, 一段一段推进)
function animateSegment(t, segIdx, doneCb){
const latlngs = t.latlngs;
if (segIdx >= latlngs.length - 1) { doneCb && doneCb(); return; }
const col = t.color || ROUTE_GREEN;
const stops = t.stops || [];
const a = latlngs[segIdx], b = latlngs[segIdx + 1];
const aName = stops[segIdx] || '';
const bName = stops[segIdx + 1] || '';
// 端点 marker (端点强调) — 用 L.marker + divIcon, 这样能挂 tooltip
if (segIdx === 0) {
const startIcon = L.divIcon({
className: 'st-marker start-marker',
iconSize: [14, 14], iconAnchor: [7, 7],
html: '<div style="width:14px;height:14px;border-radius:50%;background:' + PHOSPHOR_AMBER +
';border:2px solid ' + PHOSPHOR_DIM + ';box-shadow:0 0 8px ' + PHOSPHOR_AMBER +
', inset 0 0 4px rgba(0,0,0,0.5);"></div>'
});
const startM = L.marker(a, { icon: startIcon, zIndexOffset: 100 }).addTo(window._markerGroup);
L.circleMarker(a, { radius: 11, color: PHOSPHOR_AMBER, weight: 1, opacity: 0.7, fillOpacity: 0 }).addTo(window._edgeGroup);
// 起点站名 tooltip (默认不显示, 之后手动 open)
if (aName) {
startM.bindTooltip(aName, { permanent: false, direction: 'right', offset: [8, 0], className: 'stlbl' });
startM.on('tooltipopen', (ev) => {
const tip = ev.tooltip && ev.tooltip._container;
if (tip) {
tip.style.color = PHOSPHOR_AMBER;
tip.style.borderColor = PHOSPHOR_AMBER;
tip.style.boxShadow = '0 0 6px ' + PHOSPHOR_AMBER;
tip.style.textShadow = '0 0 4px ' + PHOSPHOR_AMBER;
}
});
startM.openTooltip(); // 立即弹出起点站名
}
}
// 动态 polyline: 一开始一个点(起点), 每帧把当前点推进到 segIdx+1 的某个位置
const sub = L.polyline([a], {color:col,weight:4,opacity:.95,dashArray:'8 4',
lineCap:'round',lineJoin:'round',className:'route-primary',interactive:false}).addTo(window._routeGroup);
const subThin = L.polyline([a], {color:col,weight:1.2,opacity:.7,
className:'route-base',interactive:false}).addTo(window._routeGroup);
// 头部光点: 用 L.marker 才能 setLatLngs (circleMarker 没这个方法)
const head = L.marker(a, { icon: L.divIcon({ className: 'tl-head', iconSize:[10,10], iconAnchor:[5,5], html: '<div style="width:10px;height:10px;border-radius:50%;background:'+col+';border:2px solid '+col+';box-shadow:0 0 9px '+col+', 0 0 3px '+col+';"></div>' }) }).addTo(window._routeGroup);
const STEPS = 12; // 一段内 12 帧, 越少越快
let i = 0;
function stepFrame(){
if (!isActive()) {
sub.remove(); subThin.remove(); head.remove();
if (doneCb) doneCb();
return;
}
i++;
const k = i / STEPS;
const cur = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
sub.setLatLngs([a, cur]);
subThin.setLatLngs([a, cur]);
head.setLatLng(cur);
if (i < STEPS) {
setTimeout(stepFrame, perSegMs);
} else {
// 这段画完: 移除 head 光点, 画一个站点 marker, 弹站名 tooltip
head.remove();
const isFinalStop = (segIdx === latlngs.length - 2);
const dotIcon = L.divIcon({
className: 'st-marker mid-marker',
iconSize: isFinalStop ? [14, 14] : [8, 8],
iconAnchor: isFinalStop ? [7, 7] : [4, 4],
html: '<div style="width:' + (isFinalStop ? 14 : 8) + 'px;height:' + (isFinalStop ? 14 : 8) +
'px;border-radius:50%;background:' + (isFinalStop ? PHOSPHOR_AMBER : PHOSPHOR) +
';border:1.5px solid ' + PHOSPHOR_DIM +
';box-shadow:0 0 ' + (isFinalStop ? 8 : 4) + 'px ' + (isFinalStop ? PHOSPHOR_AMBER : PHOSPHOR) +
';"></div>'
});
const bM = L.marker(b, { icon: dotIcon, zIndexOffset: isFinalStop ? 100 : 50 }).addTo(window._markerGroup);
if (isFinalStop) {
// 终点: 大圈 + 外圈环
L.circleMarker(b, { radius: 11, color: PHOSPHOR_AMBER, weight: 1, opacity: 0.7, fillOpacity: 0 }).addTo(window._edgeGroup);
}
// ★ 到 b 时, 弹站名 tooltip (永久显示 1.5s 后自动关)
if (bName) {
bM.bindTooltip(bName, { permanent: true, direction: 'right', offset: [8, 0], className: 'stlbl' });
bM.on('tooltipopen', (ev) => {
const tip = ev.tooltip && ev.tooltip._container;
if (tip) {
const tipColor = isFinalStop ? PHOSPHOR_AMBER : PHOSPHOR;
tip.style.color = tipColor;
tip.style.borderColor = tipColor;
tip.style.boxShadow = '0 0 6px ' + tipColor + ', inset 0 0 6px rgba(60,255,106,0.10)';
tip.style.textShadow = '0 0 4px ' + tipColor;
}
});
bM.openTooltip();
}
setTimeout(() => animateSegment(t, segIdx + 1, doneCb), perSegMs);
}
}
stepFrame();
}
showNextTrip();
return 0;
};
window.stopTimeline = function(){
window._timelinePlaying = false;
window._routeGroup.clearLayers();
window._timelineRunId++;
window._markerGroup.clearLayers();
window._edgeGroup.clearLayers();
if (map.hasLayer(window._routeGroup)) map.removeLayer(window._routeGroup);
if (map.hasLayer(window._markerGroup)) map.removeLayer(window._markerGroup);
if (map.hasLayer(window._edgeGroup)) map.removeLayer(window._edgeGroup);
if (!map.hasLayer(window._staticRouteGroup)) window._staticRouteGroup.addTo(map);
if (!map.hasLayer(window._staticMarkerGroup)) window._staticMarkerGroup.addTo(map);
if (window._mapState.edge && !map.hasLayer(window._staticEdgeGroup)) window._staticEdgeGroup.addTo(map);
};
const t = data.tile;
// 1x1 透明 PNG(失败瓦片用这个, 避免出现 cyan/彩色占位条)
const TRANSPARENT_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
const tileLayer = L.tileLayer(t.url, {
subdomains:t.sub, maxZoom:t.max, attribution:t.attr,
errorTileUrl: TRANSPARENT_PNG,
crossOrigin: true, // 避免 WebEngine 跨域问题
updateWhenZooming: false, // 平移/缩放时不重排, 减少瓦片闪烁
keepBuffer: 2, // 缓存 2 圈, 减少缩放后重拉
}).addTo(map);
window._viewPoints = [];
function collectViewPoints(d){
const points = [];
(d.routes||[]).forEach(r => (r.latlngs||[]).forEach(p => points.push(p)));
(d.points||[]).forEach(p => points.push([p.lat,p.lon]));
return points;
}
function updateLabelVisibility(){
document.body.classList.toggle('zoom-labels-off',map.getZoom()<11);
}
window.fitCurrent = function(animate){
if (!window._viewPoints.length) return false;
map.fitBounds(L.latLngBounds(window._viewPoints), {
padding:[80,80], maxZoom:14, animate:!!animate && !REDUCED_MOTION
});
return true;
};
let resumeGeneration = 0;
let wakeTimer = 0;
let routeAnimations = [];
function pulseRoutes(){
routeAnimations.forEach(animation=>animation.cancel());
routeAnimations = [];
if (REDUCED_MOTION || !Element.prototype.animate) return;
Array.from(document.querySelectorAll('.route-primary')).slice(0,16).forEach((el,index)=>{
const animation = el.animate([
{strokeDashoffset:'36',opacity:.28},
{strokeDashoffset:'0',opacity:.95}
], {duration:400,delay:index*18,easing:'cubic-bezier(.2,.8,.2,1)'});
routeAnimations.push(animation);
});
}
window.resumeMap = function(fit, redraw){
const generation = ++resumeGeneration;
document.body.classList.remove('fx-map-wake');
void document.body.offsetWidth;
if (!REDUCED_MOTION) document.body.classList.add('fx-map-wake');
clearTimeout(wakeTimer);
wakeTimer = setTimeout(()=>document.body.classList.remove('fx-map-wake'),260);
requestAnimationFrame(()=>requestAnimationFrame(()=>{
if (generation !== resumeGeneration) return;
map.invalidateSize({pan:false,debounceMoveend:true});
if (fit) window.fitCurrent(false);
if (redraw) tileLayer.redraw();
pulseRoutes();
updateLabelVisibility();
}));
return true;
};
window.suspendMap = function(){
resumeGeneration++;
routeAnimations.forEach(animation=>animation.cancel());
routeAnimations = [];
document.body.classList.remove('fx-map-wake','map-moving');
if (typeof stopTimeline === 'function' && window._timelinePlaying) stopTimeline();
return true;
};
let movingTimer = 0;
map.on('movestart zoomstart',()=>{
clearTimeout(movingTimer); document.body.classList.add('map-moving');
});
map.on('moveend zoomend',()=>{
clearTimeout(movingTimer);
updateLabelVisibility();
movingTimer=setTimeout(()=>requestAnimationFrame(()=>document.body.classList.remove('map-moving')),90);
});
document.addEventListener('visibilitychange',()=>{
if (document.hidden) window.suspendMap(); else window.resumeMap(false,false);
});
window._viewPoints = collectViewPoints(data);
/* 三个 mode 互斥: CSS class 切到 body 上, 让 filter / overlay 跟着变
body.mode-bw-on → 强制单色
body.mode-bw-off → 原始 OSM 彩色(可能叠加 CRT 扫描线/暗角)
body.mode-crt-on → CRT 扫描/暗角/闪烁 显示
body.mode-crt-off → CRT 容器全隐藏
默认: bw=on, crt=on
*/
const bwCss = document.createElement("style");
bwCss.textContent = `
/* 默认: bw=on, crt=on */
body.mode-bw-on .leaflet-tile-pane{
filter: grayscale(1) brightness(0.55) contrast(1.30) hue-rotate(75deg) saturate(0.4) !important;
}
body.mode-bw-on .leaflet-tile-pane::after{ display:none !important; }
body.mode-bw-off .leaflet-tile-pane::after{
background: linear-gradient(180deg, rgba(3,20,10,0.62) 0%, rgba(3,20,10,0.85) 100%);
mix-blend-mode: multiply;
}
body.mode-crt-off .crt-scan, body.mode-crt-off .crt-vignette, body.mode-crt-off .crt-flicker{
display:none !important;
}
`;
document.head.appendChild(bwCss);
/* 自定义 CSS: 注入 polyline 的辉光(纯色 line 没 shadow, 用 ::after 模拟) */
const styleEl = document.createElement("style");
styleEl.textContent = `
.route-primary{ filter:drop-shadow(0 0 3px rgba(60,255,106,.65)); }
.st-marker{ filter:drop-shadow(0 0 3px ${PHOSPHOR_DIM}); }
.edge-marker{ filter:drop-shadow(0 0 4px ${PHOSPHOR_AMBER}); }
`;
document.head.appendChild(styleEl);
/* 网络底图(其它线路): 用每条 line 自带的真实代表色, 默认关(全开会盖行程) */
if (SHOW_NET) {
(data.network||[]).forEach(n=>{
if (!n.latlngs || !n.latlngs.length) return;
const col = n.color || PHOSPHOR_DIM;
L.polyline(n.latlngs,{
color: col, weight: n.weight || 1.8,
opacity: n.opacity == null ? 0.7 : n.opacity,
lineCap: "round", lineJoin: "round",
renderer:netRenderer, interactive:false, bubblingMouseEvents:false
}).addTo(window._netGroup);
});
}
/* 行程线: 用 trip 自带的 line 真实色(JR/Metro/私铁 = 公司代表色), 没有就 ROUTE_GREEN
底层叠细实线保亮, 上层 dashArray 电流脉冲 */
(data.routes||[]).forEach((r,i)=>{ if(r.latlngs.length>1){
const col = r.color || ROUTE_GREEN;
L.polyline(r.latlngs,{
color: col, weight: r.weight || 4, opacity: 0.95,
lineCap: "round", lineJoin: "round",
dashArray: "8 4", dashOffset: "0",
className: "route-primary", interactive:false, bubblingMouseEvents:false
}).addTo(window._staticRouteGroup);
L.polyline(r.latlngs,{
color: col, weight: Math.max(1.2, (r.weight || 4) * 0.3), opacity: 0.7,
className: "route-base", interactive:false, bubblingMouseEvents:false
}).addTo(window._staticRouteGroup);
}});
/* 站点: 端点 = 磷光橙(警告/重要), 途中 = 磷光绿
端点强调: 大一点 + 一个外圈环 + 顶端类名 edge-marker(更亮辉光) */
(data.points||[]).forEach(p=>{
const edge = p.edge, name = p.name;
const fill = edge ? PHOSPHOR_AMBER : PHOSPHOR;
const stroke = PHOSPHOR_DIM;
const m=L.circleMarker([p.lat,p.lon],{
radius: edge ? 7 : 3.5,
color: stroke, weight: 1.5, fillColor: fill, fillOpacity: 1.0,
className: edge && EDGE_FOCUS ? "st-marker edge-marker" : "st-marker"
}).addTo(window._staticMarkerGroup);
/* 端点强调: 多画一个外圈环(独立 group, 跟 marker group 分开便于单控) */
if (edge && EDGE_FOCUS) {
L.circleMarker([p.lat,p.lon],{
radius:11,color:PHOSPHOR_AMBER,weight:1,opacity:.7,fillOpacity:0,
interactive:false,bubblingMouseEvents:false
}).addTo(window._staticEdgeGroup);
}
/* 标签策略: 端点(进/出站)始终有标签; 途中站默认只在 showMidLabels=true 时显(避免"中国中间站没名"又避免 ALL 模式一堆字挤一起) */
const showLabel = !!name;
if (showLabel) {
const tipColor = edge ? (p.lineColor || PHOSPHOR_AMBER) : PHOSPHOR;
m.bindTooltip(name, { permanent:true, direction:'right', className:edge?'stlbl edge-label':'stlbl mid-label', offset:[4,0] });
m.on('tooltipopen', (ev) => {
const tip = ev.tooltip && ev.tooltip._container;
if (tip) {
tip.style.color = tipColor;
tip.style.borderColor = tipColor;
tip.style.boxShadow = '0 0 6px ' + tipColor + ', inset 0 0 6px rgba(60,255,106,0.10)';
tip.style.textShadow = '0 0 4px ' + tipColor;
}
});
}
m.bindPopup('<b>'+name+'</b>'+(p.line?'<br>> '+p.line:''));
});
/* 视野: 给控件(右下角 attribution / 缩放按钮 / 顶部面板)留 padding, 不再丢下半截 */
if(window._viewPoints.length){
window.fitCurrent(false);
} else {
map.setView([34,108],4);
}
document.getElementById('meta').innerHTML =
'<b>> ' + (data.title||'ROUTE') + '</b> NODES: ' + (data.points||[]).length;
window.resumeMap(false,false);
/* 重画数据(切城市/切底图时用, 不重建 map 实例)
接收新 data 字典, 清空 4 个 static group, 按新数据重新填 polyline/marker/tooltip
返回: {n_points, n_routes, n_network} 给 Python 端看结果 */
window.reloadData = function(d){
if (!d || typeof d !== 'object') return null;
if (window._timelinePlaying && typeof stopTimeline==='function') stopTimeline();
window._mapState.net=!!d.showNet; window._mapState.mid=!!d.showMidLabels;
window._mapState.edge=d.edgeFocus!==false;
document.body.classList.remove('mode-mid-on','mode-mid-off');
document.body.classList.add('mode-mid-' + (window._mapState.mid ? 'on' : 'off'));
if (window._mapState.edge && !map.hasLayer(window._staticEdgeGroup)) window._staticEdgeGroup.addTo(map);
if (!window._mapState.edge && map.hasLayer(window._staticEdgeGroup)) map.removeLayer(window._staticEdgeGroup);
if (window._mapState.net && !map.hasLayer(window._netGroup)) window._netGroup.addTo(map);
if (!window._mapState.net && map.hasLayer(window._netGroup)) map.removeLayer(window._netGroup);
// 清空 static groups
window._staticRouteGroup.clearLayers();
window._staticMarkerGroup.clearLayers();
window._staticEdgeGroup.clearLayers();
window._netGroup.clearLayers();
window._midLabelGroup.clearLayers();
// 网络底图
if (d.showNet) {
(d.network||[]).forEach(n=>{
if (!n.latlngs || !n.latlngs.length) return;
L.polyline(n.latlngs, { color: n.color || PHOSPHOR_DIM, weight: n.weight || 1.8,
opacity: n.opacity == null ? 0.7 : n.opacity,
lineCap:'round',lineJoin:'round',renderer:netRenderer,
interactive:false,bubblingMouseEvents:false }).addTo(window._netGroup);
});
}
// 行程线
(d.routes||[]).forEach(r=>{
if (!r.latlngs || r.latlngs.length<2) return;
const col = r.color || ROUTE_GREEN;
L.polyline(r.latlngs, { color: col, weight: r.weight || 4, opacity: 0.95, dashArray: '8 4',
lineCap:'round',lineJoin:'round',className:'route-primary',interactive:false,
bubblingMouseEvents:false }).addTo(window._staticRouteGroup);
L.polyline(r.latlngs, { color:col,weight:Math.max(1.2,(r.weight||4)*0.3),
opacity:0.7,className:'route-base',interactive:false,
bubblingMouseEvents:false }).addTo(window._staticRouteGroup);
});
// 站点
(d.points||[]).forEach(p=>{
const edge = p.edge, name = p.name;
const fill = edge ? PHOSPHOR_AMBER : PHOSPHOR;
const stroke = PHOSPHOR_DIM;
const m = L.circleMarker([p.lat, p.lon], {
radius: edge ? 7 : 3.5, color: stroke, weight: 1.5, fillColor: fill, fillOpacity: 1.0,
className: edge && d.edgeFocus!==false ? "st-marker edge-marker" : "st-marker"
}).addTo(window._staticMarkerGroup);
if (edge && d.edgeFocus!==false) {
L.circleMarker([p.lat, p.lon], {
radius:11,color:PHOSPHOR_AMBER,weight:1,opacity:.7,fillOpacity:0,interactive:false
}).addTo(window._staticEdgeGroup);
}
const showLabel = !!name;
if (showLabel) {
const tipColor = edge ? (p.lineColor || PHOSPHOR_AMBER) : PHOSPHOR;
m.bindTooltip(name,{permanent:true,direction:'right',className:edge?'stlbl edge-label':'stlbl mid-label',offset:[4,0]});
m.on('tooltipopen', (ev) => {
const tip = ev.tooltip && ev.tooltip._container;
if (tip) {
tip.style.color = tipColor;
tip.style.borderColor = tipColor;
tip.style.boxShadow = '0 0 6px ' + tipColor + ', inset 0 0 6px rgba(60,255,106,0.10)';
tip.style.textShadow = '0 0 4px ' + tipColor;
}
});
}
});
window._viewPoints = collectViewPoints(d);
map.invalidateSize({pan:false,debounceMoveend:true});
window.fitCurrent(false);
window.resumeMap(false,false);
document.getElementById('meta').innerHTML =
'<b>> ' + (d.title||'ROUTE') + '</b> NODES: ' + (d.points||[]).length;
return { n_points: (d.points||[]).length, n_routes: (d.routes||[]).length, n_network: (d.network||[]).length };
};
/* 刷新视图按钮: 右上角终端风, 强制 invalidateSize + fitBounds + 刷新瓦片缓存 */
const refreshBtn = L.control({ position: 'topright' });
refreshBtn.onAdd = function(){
const d = L.DomUtil.create('div', 'refresh-ctrl');
d.innerHTML = '<button title="刷新视图" type="button">R</button>';
L.DomEvent.disableClickPropagation(d);
d.querySelector('button').onclick = function(){
window.resumeMap(true,true);
};
return d;
};
refreshBtn.addTo(map);
</script>
<style>
.refresh-ctrl button{
background:%BG% !important; color:%PHOSPHOR% !important;
border:1px solid %PHOSPHOR_DIM% !important; border-radius:0 !important;
width:30px; height:30px; font-size:18px; font-weight:700; line-height:1;
font-family: "Cascadia Mono","Consolas",monospace;
text-shadow: 0 0 4px %PHOSPHOR%; cursor: pointer;
box-shadow: 0 0 6px rgba(60,255,106,0.3);
transition:background-color 80ms linear,color 80ms linear,transform 80ms linear;
will-change:transform;
}
.refresh-ctrl button:hover{ background:%PHOSPHOR_DIM% !important; color:%BG% !important; text-shadow:none; }
.refresh-ctrl button:active{transform:translateY(1px) scale(.96);}
</style></body></html>""".replace("%BG%", PHOSPHOR_BG)\
.replace("%PHOSPHOR%", PHOSPHOR)\
.replace("%PHOSPHOR_DIM%", PHOSPHOR_DIM)\
.replace("%PHOSPHOR_AMBER%", PHOSPHOR_AMBER)\
.replace("%ROUTE_GREEN%", ROUTE_GREEN)
_LEAFLET_ASSETS = None
def _leaflet_assets():
"""Read bundled Leaflet once; map startup must not depend on a CDN."""
global _LEAFLET_ASSETS
if _LEAFLET_ASSETS is None:
base = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(base, "leaflet.css"), encoding="utf-8") as stream:
css = stream.read()
with open(os.path.join(base, "leaflet.js"), encoding="utf-8") as stream:
js = stream.read().replace("</script", "<\\/script")
_LEAFLET_ASSETS = css, js
return _LEAFLET_ASSETS
def build_html_from_data(data) -> str:
"""Render already-built map data without repeating coordinate conversion."""
css, js = _leaflet_assets()
payload = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
return (_HTML.replace("__DATA__", payload)
.replace("__LEAFLET_CSS__", css)
.replace("__LEAFLET_JS__", js))
def build_data(trips, title="", network=None, source="amap", labels_all=False,
show_net=False, show_mid_labels=False, edge_focus=True,
bw=False, crt=True, timeline=None):
"""timeline: 可选, [{color, latlngs, stops, name}], 给 JS 端 playTimeline() 逐条放.
传 None = 不用时间线播放. 传 [] = 显式空. 默认 None."""
osm = (source == "osm")
def cv(lat, lon, china):
if osm and china:
wlat, wlng = cn_coords.gcj02_to_wgs84(lon, lat)
return [round(wlat, 6), round(wlng, 6)]
return [lat, lon]
def _norm(s):
return (s or "").replace(" ", "").replace("站", "")
pts = {}
routes = []
for t in trips:
china = (t.get("country") == "中国")
path = t.get("path")
line_c = t.get("line_color") or "" # 站名标签颜色 = 行程线代表色
# 只在有"真实沿轨路径"(路由成功, 多点)时画行程线, 用线路真实代表色。
# 路由失败(只有起终点)→ 不画直线: 否则在地图上拉出一堆横跨几十公里、
# 不沿任何轨道的"直线蛛网"(用户明确要删的那种)。端点站照常显示。
if path and len(path) >= 2:
lls = [cv(p["lat"], p["lon"], china) for p in path]
routes.append({"latlngs": lls, "color": line_c,
"weight": float(t.get("weight", 4)), "freq": int(t.get("freq", 1))})
for nm, ll in ((t["in"], t["ci"]), (t["out"], t["co"])):
c = cv(ll[0], ll[1], china)
k = _norm(nm)
cur = pts.get(k)
if cur:
cur["edge"] = True; cur["label"] = True
if not cur.get("lineColor"): cur["lineColor"] = line_c
else:
pts[k] = {"lat": c[0], "lon": c[1], "name": nm, "edge": True,
"label": True, "line": t.get("line", ""), "lineColor": line_c}
# 中间站点标记: 用带站名的站序列表(t["stations"]); 没有就退回 path。
# 这样即使行程线是 OSM 几何(顶点无站名), 中途站名也照样显示。
mids = t.get("stations") or path or []
if mids and len(mids) > 2:
for p in mids[1:-1]:
nm = p.get("name", "")
if not nm:
continue
k = _norm(nm)
if k not in pts:
c = cv(p["lat"], p["lon"], china)
pts[k] = {"lat": c[0], "lon": c[1], "name": nm, "edge": False,
"label": labels_all, "line": "", "lineColor": line_c}
net = []
for ln in (network or []):
# 每条网络线按自身国别转换坐标(日本=WGS84 不转; 中国=GCJ02, OSM 时才转)。
# 之前一律按 china=True 转 → 日本网格被错误地套了中国偏移, 和高亮错开。
ln_china = ln.get("china", True)
segments = ln.get("segments")
if segments is not None:
latlngs = [[cv(p[0], p[1], ln_china) for p in segment]
for segment in segments if len(segment) >= 2]
else:
latlngs = [cv(p[0], p[1], ln_china) for p in ln["pts"]]
net.append({"color": ln["color"], "latlngs": latlngs,
"weight": float(ln.get("weight", 1.8)),
"opacity": float(ln.get("opacity", 0.7))})
return {
"title": title, "points": list(pts.values()), "routes": routes,
"network": net, "tile": _TILES.get(source, _TILES["amap"]),
# 图层开关(顶层传给 JS)
"showNet": bool(show_net),
"showMidLabels": bool(show_mid_labels),
"edgeFocus": bool(edge_focus),
"bw": bool(bw), "crt": bool(crt),
# 时间线播放(可选)
"timeline": timeline if timeline is not None else [],
}
def build_html_string(trips, title="", network=None, source="amap", labels_all=False,
show_net=False, show_mid_labels=False, edge_focus=True,
bw=False, crt=True, timeline=None) -> str:
data = build_data(trips, title, network, source, labels_all,
show_net=show_net, show_mid_labels=show_mid_labels, edge_focus=edge_focus,
bw=bw, crt=crt, timeline=timeline)
return build_html_from_data(data)
def build_html(trips, title="行程地图", network=None, source="amap", labels_all=False,
show_net=False, show_mid_labels=False, edge_focus=True,
bw=False, crt=True, timeline=None) -> str:
html = build_html_string(trips, title, network, source, labels_all,
show_net=show_net, show_mid_labels=show_mid_labels,
edge_focus=edge_focus, bw=bw, crt=crt, timeline=timeline)
path = os.path.join(tempfile.gettempdir(), "trip_map.html")
with open(path, "w", encoding="utf-8") as f:
f.write(html)
return path
def open_in_browser(trips, title="行程地图", network=None, source="amap", labels_all=True,
show_net=False, show_mid_labels=False, edge_focus=True,
bw=False, crt=True):
path = build_html(trips, title, network, source, labels_all,
show_net=show_net, show_mid_labels=show_mid_labels,
edge_focus=edge_focus, bw=bw, crt=crt)
webbrowser.open("file:///" + path.replace("\\", "/"))
return path
if __name__ == "__main__":
sample = [{"country": "中国", "in": "金沙江路", "out": "上海南站",
"ci": (31.234, 121.409), "co": (31.156, 121.425), "line": "3号线",
"path": [{"name": "金沙江路", "lat": 31.234, "lon": 121.409},
{"name": "中山公园", "lat": 31.22, "lon": 121.42},
{"name": "上海南站", "lat": 31.156, "lon": 121.425}]}]
net = [{"color": "#E3002A", "pts": [(31.1, 121.3), (31.2, 121.4), (31.3, 121.5)]}]
for src in ("amap", "osm"):
d = build_data(sample, "测试", net, src)
print(src, "points:", len(d["points"]), "routes:", len(d["routes"]),
"net:", len(d["network"]), "first_pt:", d["points"][0]["lat"], d["points"][0]["lon"],
"showNet:", d["showNet"], "showMidLabels:", d["showMidLabels"], "edgeFocus:", d["edgeFocus"])