road-traffic-viewer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,881 @@
1
+ var I = Object.defineProperty;
2
+ var H = (l, e, s) => e in l ? I(l, e, { enumerable: !0, configurable: !0, writable: !0, value: s }) : l[e] = s;
3
+ var h = (l, e, s) => H(l, typeof e != "symbol" ? e + "" : e, s);
4
+ import { defineComponent as D, ref as U, shallowRef as F, computed as y, onMounted as A, onUnmounted as O, watch as L, openBlock as P, createElementBlock as C, normalizeStyle as b } from "vue";
5
+ const B = [
6
+ { key: 3, color: "#ff4d6a" },
7
+ // 严重拥堵
8
+ { key: 2, color: "#ff9500" },
9
+ // 拥堵
10
+ { key: 1, color: "#00bfff" },
11
+ // 缓行
12
+ { key: 0, color: "#00ff88" }
13
+ // 畅通
14
+ ], k = /^[kK](\d+)(?:\.(\d+))?$/, W = /^\d+(\.\d+)?$/;
15
+ function T(l) {
16
+ if (typeof l == "number")
17
+ return Math.round(l);
18
+ const e = l.trim();
19
+ if (W.test(e))
20
+ return Math.round(parseFloat(e));
21
+ const t = e.replace("+", ".").match(k);
22
+ if (t) {
23
+ const a = parseInt(t[1], 10), n = t[2] || "0";
24
+ let r = 0;
25
+ if (n.length > 0) {
26
+ const o = n.padEnd(3, "0").substring(0, 3);
27
+ r = parseInt(o, 10);
28
+ }
29
+ return a * 1e3 + r;
30
+ }
31
+ const i = parseFloat(e);
32
+ return isNaN(i) ? (console.warn(`[pile-utils] 无法解析桩号: "${l}",已当作 0 处理`), 0) : Math.round(i);
33
+ }
34
+ function V(l) {
35
+ const e = Math.round(l), s = Math.floor(e / 1e3), t = e % 1e3;
36
+ if (t === 0)
37
+ return `K${s}`;
38
+ const i = t.toString().padStart(3, "0");
39
+ return `K${s}+${i}`;
40
+ }
41
+ function fe(l) {
42
+ return `K${Math.floor(Math.round(l) / 1e3)}`;
43
+ }
44
+ const z = "#141a28", Y = "#1e2a3e", N = "#2a3a55", q = "#3a5070", $ = "#1a2535", G = "#ffb020", X = "#0a0f1a", K = "#3a5070", J = "#2a3a55", j = "#ffffff", Q = "#00ff88", Z = 28, x = 3, ee = 6, te = 16, se = 12, ie = 1e3, ne = 1e3, re = 1, ae = -1;
45
+ class S {
46
+ /**
47
+ * 构造函数
48
+ *
49
+ * @param container - 容器 DOM 元素
50
+ * @param config - 道路渲染配置
51
+ */
52
+ constructor(e, s) {
53
+ // ---- DOM 相关 ----
54
+ /** 容器 DOM 元素 */
55
+ h(this, "container");
56
+ /** Canvas 元素 */
57
+ h(this, "canvas");
58
+ /** 2D 渲染上下文 */
59
+ h(this, "ctx");
60
+ // ---- 配置 ----
61
+ /** 用户传入的原始配置(已补全默认值) */
62
+ h(this, "config");
63
+ /** 内部渲染度量参数(从配置计算得出) */
64
+ h(this, "metrics", null);
65
+ // ---- 数据缓存 ----
66
+ /** 车辆数据缓存 */
67
+ h(this, "cachedVehicles", []);
68
+ /** 路段流量数据缓存 */
69
+ h(this, "cachedTrafficFlows", []);
70
+ // ---- 渲染节流 ----
71
+ /** 是否有待处理的渲染请求 */
72
+ h(this, "renderPending", !1);
73
+ /** 设备像素比 */
74
+ h(this, "dpr", 1);
75
+ // ---- ResizeObserver ----
76
+ /** 监听容器尺寸变化 */
77
+ h(this, "resizeObserver", null);
78
+ // ---- 销毁标记 ----
79
+ /** 是否已销毁 */
80
+ h(this, "destroyed", !1);
81
+ // ---- 代码表缓存 ----
82
+ /** 流量状态 key → 颜色映射表(从配置数组构建,加速查找) */
83
+ h(this, "statusColorMap", /* @__PURE__ */ new Map());
84
+ // ---- SVG 图片缓存 ----
85
+ /** 已加载的 SVG Image 对象缓存(按 URL 去重) */
86
+ h(this, "svgImageCache", /* @__PURE__ */ new Map());
87
+ /** 道路底图 Image 对象 */
88
+ h(this, "roadBgImage", null);
89
+ /** 道路底图加载中标记 */
90
+ h(this, "roadBgImageLoading", !1);
91
+ // ---- 视口拖拽 ----
92
+ /** 当前视口左边缘对应的路线米数 */
93
+ h(this, "viewOffsetMeters", 0);
94
+ /** 鼠标拖拽中标记 */
95
+ h(this, "isDragging", !1);
96
+ /** 拖拽起始鼠标 X */
97
+ h(this, "dragStartX", 0);
98
+ /** 拖拽起始 viewOffset */
99
+ h(this, "dragStartOffset", 0);
100
+ /** 鼠标移动/抬起绑定的 this */
101
+ h(this, "_boundMouseMove", null);
102
+ h(this, "_boundMouseUp", null);
103
+ /**
104
+ * 窗口大小变化时的处理函数(箭头函数保持 this 绑定)
105
+ */
106
+ h(this, "_onWindowResize", () => {
107
+ this.destroyed || (this._resize(), this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender())));
108
+ });
109
+ // ==========================================================================
110
+ // 私有方法:鼠标拖拽平移
111
+ // ==========================================================================
112
+ /**
113
+ * 鼠标按下:开始拖拽平移视口
114
+ */
115
+ h(this, "_onMouseDown", (e) => {
116
+ if (this.destroyed || !this.metrics) return;
117
+ e.preventDefault(), this.isDragging = !0, this.dragStartX = e.clientX, this.dragStartOffset = this.viewOffsetMeters, this.canvas.style.cursor = "grabbing";
118
+ const s = (i) => this._onMouseMove(i), t = (i) => this._onMouseUp(i);
119
+ this._boundMouseMove = s, this._boundMouseUp = t, window.addEventListener("mousemove", s), window.addEventListener("mouseup", t);
120
+ });
121
+ /**
122
+ * 鼠标移动:更新视口偏移
123
+ */
124
+ h(this, "_onMouseMove", (e) => {
125
+ if (!this.isDragging || !this.metrics) return;
126
+ const s = this.metrics, i = (e.clientX - this.dragStartX) / s.canvasWidthPx * s.visibleMeters;
127
+ let a = this.dragStartOffset - i;
128
+ const n = s.effectiveStartM, r = Math.max(n, s.effectiveEndM - s.visibleMeters);
129
+ a = Math.max(n, Math.min(a, r)), this.viewOffsetMeters = a, s.viewOffsetMeters = a, this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender()));
130
+ });
131
+ /**
132
+ * 鼠标抬起:结束拖拽
133
+ */
134
+ h(this, "_onMouseUp", (e) => {
135
+ this.isDragging && (this.isDragging = !1, this.canvas.style.cursor = "grab", this._boundMouseMove && (window.removeEventListener("mousemove", this._boundMouseMove), this._boundMouseMove = null), this._boundMouseUp && (window.removeEventListener("mouseup", this._boundMouseUp), this._boundMouseUp = null));
136
+ });
137
+ if (!e)
138
+ throw new Error("[RoadTrafficCore] container 不能为空");
139
+ this.container = e, this.dpr = window.devicePixelRatio || 1, this.config = this._applyDefaults(s), this._buildStatusColorMap(), this.canvas = document.createElement("canvas"), this.canvas.style.display = "block", this.ctx = this.canvas.getContext("2d"), e.innerHTML = "", e.appendChild(this.canvas), this._resize(), this._drawEmptyRoad(), typeof ResizeObserver < "u" && (this.resizeObserver = new ResizeObserver(() => {
140
+ this._resize(), this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender()));
141
+ }), this.resizeObserver.observe(e)), window.addEventListener("resize", this._onWindowResize), this.canvas.addEventListener("mousedown", this._onMouseDown.bind(this)), this.canvas.style.cursor = "grab";
142
+ }
143
+ // ==========================================================================
144
+ // 公开方法
145
+ // ==========================================================================
146
+ /**
147
+ * 重绘道路(手动触发完整渲染)
148
+ *
149
+ * 可用于窗口 resize 后手动刷新,或首次挂载后确保道路可见。
150
+ * 内部调用 _resize() 重新计算尺寸并绘制空道路背景。
151
+ */
152
+ render() {
153
+ this.destroyed || (this._resize(), this._drawEmptyRoad());
154
+ }
155
+ /**
156
+ * 更新车辆数据
157
+ *
158
+ * 不立即渲染,而是缓存数据并通过 requestAnimationFrame 在下一帧渲染。
159
+ * 多次连续调用只会在下一帧执行一次渲染,实现自动节流。
160
+ *
161
+ * @param vehicles - 车辆数据数组
162
+ */
163
+ updateVehicles(e) {
164
+ this.destroyed || (this.cachedVehicles = e || [], this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender())));
165
+ }
166
+ /**
167
+ * 更新路段流量数据
168
+ *
169
+ * 与 updateVehicles 类似,缓存数据并通过 RAF 在下一帧渲染。
170
+ *
171
+ * @param flows - 路段流量数据数组
172
+ */
173
+ updateTrafficFlows(e) {
174
+ this.destroyed || (this.cachedTrafficFlows = e || [], this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender())));
175
+ }
176
+ /**
177
+ * 同时更新车辆和路段流量数据
178
+ *
179
+ * @param vehicles - 车辆数据数组
180
+ * @param flows - 路段流量数据数组
181
+ */
182
+ updateAll(e, s) {
183
+ this.destroyed || (this.cachedVehicles = e || [], this.cachedTrafficFlows = s || [], this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender())));
184
+ }
185
+ /**
186
+ * 获取 Canvas 元素(供外部进行截图等操作)
187
+ */
188
+ getCanvas() {
189
+ return this.canvas;
190
+ }
191
+ /**
192
+ * 获取当前渲染度量参数
193
+ */
194
+ getMetrics() {
195
+ return this.metrics;
196
+ }
197
+ /**
198
+ * 更新配置(部分更新)
199
+ *
200
+ * 传入的字段会合并到现有配置中,触发重新计算度量并重绘。
201
+ *
202
+ * @param partial - 部分配置对象
203
+ */
204
+ updateConfig(e) {
205
+ this.destroyed || (this.config = { ...this.config, ...e }, e.trafficStatusColors && this._buildStatusColorMap(), this._resize(), this.renderPending || (this.renderPending = !0, requestAnimationFrame(() => this._doRender())));
206
+ }
207
+ /**
208
+ * 销毁实例
209
+ *
210
+ * 移除事件监听、清理 DOM、释放引用。
211
+ */
212
+ destroy() {
213
+ this.destroyed || (this.destroyed = !0, this.renderPending = !1, this.resizeObserver && (this.resizeObserver.disconnect(), this.resizeObserver = null), window.removeEventListener("resize", this._onWindowResize), this._boundMouseMove && (window.removeEventListener("mousemove", this._boundMouseMove), this._boundMouseMove = null), this._boundMouseUp && (window.removeEventListener("mouseup", this._boundMouseUp), this._boundMouseUp = null), this.container && (this.container.innerHTML = ""), this.cachedVehicles = [], this.cachedTrafficFlows = []);
214
+ }
215
+ // ==========================================================================
216
+ // 私有方法:配置处理
217
+ // ==========================================================================
218
+ /**
219
+ * 补全用户配置的默认值
220
+ *
221
+ * 用户未传入的字段使用预设默认值填充,返回完整的配置对象。
222
+ */
223
+ _applyDefaults(e) {
224
+ return {
225
+ // 桩号/路线
226
+ startPile: e.startPile,
227
+ endPile: e.endPile,
228
+ paddingStartMeters: e.paddingStartMeters ?? 0,
229
+ paddingEndMeters: e.paddingEndMeters ?? 0,
230
+ segmentIntervalMeters: e.segmentIntervalMeters ?? ie,
231
+ // 比例尺
232
+ scale: e.scale ?? ne,
233
+ // 画布尺寸
234
+ canvasWidth: e.canvasWidth ?? "100%",
235
+ canvasHeight: e.canvasHeight ?? "auto",
236
+ // 方向定义
237
+ directionDown: e.directionDown ?? re,
238
+ directionUp: e.directionUp ?? ae,
239
+ // 车道配置
240
+ lanesDown: e.lanesDown ?? 1,
241
+ lanesUp: e.lanesUp ?? 1,
242
+ laneHeight: e.laneHeight ?? Z,
243
+ laneLineColor: e.laneLineColor ?? j,
244
+ roadPadding: e.roadPadding ?? x,
245
+ medianHeight: e.medianHeight ?? ee,
246
+ // 颜色配置
247
+ roadBg: e.roadBg ?? z,
248
+ roadShoulder: e.roadShoulder ?? Y,
249
+ roadLine: e.roadLine ?? N,
250
+ roadEdge: e.roadEdge ?? q,
251
+ medianBg: e.medianBg ?? $,
252
+ medianLineColor: e.medianLineColor ?? G,
253
+ canvasBg: e.canvasBg ?? X,
254
+ markerColor: e.markerColor ?? K,
255
+ laneLabelColor: e.laneLabelColor ?? J,
256
+ // 车辆默认颜色
257
+ defaultCarColor: e.defaultCarColor ?? Q,
258
+ // 流量状态颜色
259
+ trafficStatusColors: e.trafficStatusColors ?? B,
260
+ // 车辆尺寸
261
+ carLength: e.carLength ?? te,
262
+ carWidth: e.carWidth ?? se,
263
+ // 车辆 SVG 图片
264
+ carSvgUrl: e.carSvgUrl ?? "",
265
+ // 道路底图
266
+ roadBgImage: e.roadBgImage ?? "",
267
+ // 车辆标签显示
268
+ showCarSpeed: e.showCarSpeed ?? !0,
269
+ showCarId: e.showCarId ?? !0
270
+ };
271
+ }
272
+ /**
273
+ * 从 trafficStatusColors 数组构建 key→color 映射表
274
+ *
275
+ * 使用 Map 加速渲染时的颜色查找。
276
+ */
277
+ _buildStatusColorMap() {
278
+ this.statusColorMap.clear();
279
+ const e = this.config.trafficStatusColors;
280
+ for (let s = 0; s < e.length; s++)
281
+ this.statusColorMap.set(e[s].key, e[s].color);
282
+ }
283
+ // ==========================================================================
284
+ // 私有方法:度量计算
285
+ // ==========================================================================
286
+ /**
287
+ * 计算内部渲染度量参数
288
+ *
289
+ * 从配置中解析桩号、计算道路总长度、画布尺寸等。
290
+ * 如果配置无效(如 startPile >= endPile),返回 null。
291
+ */
292
+ _computeMetrics() {
293
+ const e = this.config, s = T(e.startPile), t = T(e.endPile), i = s - e.paddingStartMeters, a = t + e.paddingEndMeters, n = a - i, r = n;
294
+ if (r <= 0)
295
+ return console.warn("[RoadTrafficCore] 路线长度无效 (<=0),请检查 startPile/endPile"), null;
296
+ const o = e.scale, d = this.container.clientWidth;
297
+ let c;
298
+ const f = e.canvasWidth;
299
+ if (typeof f == "string" && f.endsWith("%")) {
300
+ const m = parseFloat(f) / 100;
301
+ c = d * m;
302
+ } else typeof f == "number" ? c = f : c = d;
303
+ const g = Math.min(o, r), p = e.roadPadding + e.lanesUp * e.laneHeight, w = e.roadPadding + e.lanesDown * e.laneHeight, v = p + e.medianHeight + w;
304
+ let u;
305
+ const _ = e.canvasHeight;
306
+ if (typeof _ == "string" && _.endsWith("%")) {
307
+ const m = this.container.clientHeight, E = parseFloat(_) / 100;
308
+ u = m * E;
309
+ } else typeof _ == "number" ? u = _ : u = v + 40;
310
+ const M = (u - v) / 2;
311
+ return c < 10 && (c = 10), u < 10 && (u = 10), {
312
+ totalMeters: r,
313
+ effectiveStartM: i,
314
+ effectiveEndM: a,
315
+ effectiveLengthM: n,
316
+ scale: o,
317
+ visibleMeters: g,
318
+ viewOffsetMeters: this.viewOffsetMeters,
319
+ canvasWidthPx: c,
320
+ canvasHeightPx: u,
321
+ laneHeight: e.laneHeight,
322
+ roadPadding: e.roadPadding,
323
+ medianHeight: e.medianHeight,
324
+ lanesDown: e.lanesDown,
325
+ lanesUp: e.lanesUp,
326
+ roadTotalHeight: v,
327
+ roadY: M,
328
+ directionDown: e.directionDown,
329
+ directionUp: e.directionUp
330
+ };
331
+ }
332
+ /**
333
+ * 计算桩号分段列表
334
+ *
335
+ * 从 effectiveStartM 到 effectiveEndM 按 segmentIntervalMeters 间隔
336
+ * 生成每个分段的信息(标签文本、米数、画布 X 坐标)。
337
+ */
338
+ _computeSegments() {
339
+ const e = this.metrics, s = this.config.segmentIntervalMeters, t = Math.ceil(e.effectiveStartM / s) * s, i = [];
340
+ for (let a = t; a <= e.effectiveEndM; a += s) {
341
+ const n = (a - e.viewOffsetMeters) / e.visibleMeters * e.canvasWidthPx;
342
+ i.push({
343
+ label: V(a),
344
+ meters: a,
345
+ x: n
346
+ });
347
+ }
348
+ return i;
349
+ }
350
+ // ==========================================================================
351
+ // 私有方法:尺寸管理
352
+ // ==========================================================================
353
+ /**
354
+ * 重新计算尺寸并设置 Canvas 宽高
355
+ *
356
+ * 处理 DPR(设备像素比)以确保高清渲染。
357
+ */
358
+ _resize() {
359
+ if (this.metrics = this._computeMetrics(), !this.metrics) return;
360
+ const e = this.metrics.effectiveStartM, s = Math.max(
361
+ e,
362
+ this.metrics.effectiveEndM - this.metrics.visibleMeters
363
+ );
364
+ this.viewOffsetMeters = Math.max(e, Math.min(this.viewOffsetMeters, s)), this.metrics.viewOffsetMeters = this.viewOffsetMeters;
365
+ const t = this.metrics, i = this.dpr;
366
+ this.canvas.width = t.canvasWidthPx * i, this.canvas.height = t.canvasHeightPx * i, this.canvas.style.width = t.canvasWidthPx + "px", this.canvas.style.height = t.canvasHeightPx + "px", this.ctx.setTransform(i, 0, 0, i, 0, 0);
367
+ }
368
+ // ==========================================================================
369
+ // 私有方法:主渲染循环
370
+ // ==========================================================================
371
+ /**
372
+ * 执行实际渲染(由 requestAnimationFrame 调用)
373
+ *
374
+ * 从缓存读取车辆/流量数据,绘制到 Canvas。
375
+ */
376
+ _doRender() {
377
+ if (this.renderPending = !1, this.destroyed || !this.metrics) return;
378
+ const e = this.metrics, s = this.ctx, t = e.canvasWidthPx, i = e.canvasHeightPx;
379
+ s.clearRect(0, 0, t, i), this._drawCanvasBg(s, t, i), this._drawRoad(s, t, i, e), this._drawTrafficFlowColors(s, t, e), this._drawMedian(s, t, e), this._drawLaneLines(s, t, e), this._drawPileMarkers(s, t, e), this._drawLaneLabels(s, e), this._drawVehicles(s, t, e);
380
+ }
381
+ /**
382
+ * 绘制空道路(无数据时的初始渲染)
383
+ */
384
+ _drawEmptyRoad() {
385
+ if (!this.metrics) return;
386
+ const e = this.metrics, s = this.ctx, t = e.canvasWidthPx, i = e.canvasHeightPx;
387
+ s.clearRect(0, 0, t, i), this._drawCanvasBg(s, t, i), this._drawRoad(s, t, i, e), this._drawMedian(s, t, e), this._drawLaneLines(s, t, e), this._drawPileMarkers(s, t, e), this._drawLaneLabels(s, e);
388
+ }
389
+ // ==========================================================================
390
+ // 私有方法:画布背景
391
+ // ==========================================================================
392
+ /**
393
+ * 绘制画布背景色
394
+ */
395
+ _drawCanvasBg(e, s, t) {
396
+ e.fillStyle = this.config.canvasBg, e.fillRect(0, 0, s, t);
397
+ }
398
+ // ==========================================================================
399
+ // 私有方法:道路背景
400
+ // ==========================================================================
401
+ /**
402
+ * 绘制道路主体背景和路肩
403
+ *
404
+ * 布局结构(从上到下):
405
+ * 画布背景
406
+ * 上路肩
407
+ * 上行车道区域(多车道)—— direction=-1,桩号由小变大(左→右)
408
+ * ═══ 中央隔离带 + 双黄线 ═══
409
+ * 下行车道区域(多车道)—— direction=1,桩号由大变小(右→左)
410
+ * 下路肩
411
+ * 画布背景
412
+ */
413
+ _drawRoad(e, s, t, i) {
414
+ const a = i.roadY, n = i.roadPadding + i.lanesUp * i.laneHeight, r = i.roadY + n + i.medianHeight, o = i.roadPadding + i.lanesDown * i.laneHeight, d = this._getRoadBgImage();
415
+ d ? e.drawImage(d, 0, i.roadY, s, i.roadTotalHeight) : (e.fillStyle = this.config.roadBg, e.fillRect(0, i.roadY, s, i.roadTotalHeight)), e.fillStyle = this.config.roadShoulder, e.fillRect(0, a, s, i.roadPadding), e.fillRect(0, r + o - i.roadPadding, s, i.roadPadding);
416
+ }
417
+ // ==========================================================================
418
+ // 私有方法:中央隔离带
419
+ // ==========================================================================
420
+ /**
421
+ * 绘制中央隔离带和双黄线
422
+ *
423
+ * 隔离带位于上行车道和下行车道之间。
424
+ * 双黄线绘制在隔离带正中。
425
+ */
426
+ _drawMedian(e, s, t) {
427
+ const i = t.roadPadding + t.lanesUp * t.laneHeight, a = t.roadY + i;
428
+ e.fillStyle = this.config.medianBg, e.fillRect(0, a, s, t.medianHeight);
429
+ const n = this.config.medianLineColor, r = 2, o = 3, d = a + t.medianHeight / 2;
430
+ e.strokeStyle = n, e.lineWidth = r, e.beginPath(), e.moveTo(0, d - o / 2 - r / 2), e.lineTo(s, d - o / 2 - r / 2), e.stroke(), e.beginPath(), e.moveTo(0, d + o / 2 + r / 2), e.lineTo(s, d + o / 2 + r / 2), e.stroke();
431
+ }
432
+ // ==========================================================================
433
+ // 私有方法:车道间隔虚线
434
+ // ==========================================================================
435
+ /**
436
+ * 绘制车道间的间隔虚线
437
+ *
438
+ * 每两个相邻车道之间绘制一条白色虚线。
439
+ * 下行和上行方向分别绘制。
440
+ */
441
+ _drawLaneLines(e, s, t) {
442
+ const i = t.roadY + t.roadPadding, a = t.lanesUp * t.laneHeight, n = t.roadY + t.roadPadding + a + t.medianHeight + t.roadPadding;
443
+ e.strokeStyle = this.config.laneLineColor, e.lineWidth = 1;
444
+ const d = [10, 8];
445
+ for (let c = 1; c < t.lanesUp; c++) {
446
+ const f = i + c * t.laneHeight;
447
+ this._drawDashedLine(e, 0, f, s, f, d);
448
+ }
449
+ for (let c = 1; c < t.lanesDown; c++) {
450
+ const f = n + c * t.laneHeight;
451
+ this._drawDashedLine(e, 0, f, s, f, d);
452
+ }
453
+ }
454
+ /**
455
+ * 绘制一条水平虚线
456
+ *
457
+ * @param ctx - 2D 上下文
458
+ * @param x1, y1 - 起点
459
+ * @param x2, y2 - 终点
460
+ * @param pattern - [实线段长度, 空白段长度]
461
+ */
462
+ _drawDashedLine(e, s, t, i, a, n) {
463
+ const r = i - s, o = a - t, d = Math.sqrt(r * r + o * o), c = n[0], f = n[1], g = c + f;
464
+ let p = 0;
465
+ for (; p < d; ) {
466
+ const w = p, v = Math.min(p + c, d), u = w / d, _ = v / d;
467
+ e.beginPath(), e.moveTo(s + r * u, t + o * u), e.lineTo(s + r * _, t + o * _), e.stroke(), p += g;
468
+ }
469
+ }
470
+ // ==========================================================================
471
+ // 私有方法:路段流量颜色覆盖
472
+ // ==========================================================================
473
+ /**
474
+ * 根据路段流量数据在道路上绘制颜色覆盖
475
+ *
476
+ * 对于每个 TrafficFlow 条目,找到其起止桩号对应的画布 X 坐标范围,
477
+ * 在该范围内的车道背景上填充对应拥堵状态的颜色。
478
+ *
479
+ * 覆盖绘制在道路底色之上、车道线和车辆之下。
480
+ */
481
+ _drawTrafficFlowColors(e, s, t) {
482
+ if (this.cachedTrafficFlows.length === 0) return;
483
+ const i = t.roadY + t.roadPadding, a = t.lanesUp * t.laneHeight, n = t.roadY + t.roadPadding + a + t.medianHeight, r = t.roadPadding + t.lanesDown * t.laneHeight;
484
+ for (let o = 0; o < this.cachedTrafficFlows.length; o++) {
485
+ const d = this.cachedTrafficFlows[o], c = T(d.startPile), f = T(d.endPile);
486
+ if (f <= c) continue;
487
+ const g = Math.max(c, t.effectiveStartM), p = Math.min(f, t.effectiveEndM);
488
+ if (p <= g) continue;
489
+ const w = (g - t.viewOffsetMeters) / t.visibleMeters * s, v = (p - t.viewOffsetMeters) / t.visibleMeters * s, u = typeof d.status == "string" ? parseInt(d.status, 10) : d.status, _ = this._getStatusColor(u);
490
+ if (!_) continue;
491
+ const M = d.direction === this.config.directionUp, m = d.direction === this.config.directionDown;
492
+ e.fillStyle = _, M ? e.fillRect(w, i, v - w, a) : m && e.fillRect(w, n, v - w, r);
493
+ }
494
+ }
495
+ /**
496
+ * 根据流量状态值获取对应颜色
497
+ *
498
+ * @param status - 状态值 0/1/2/3
499
+ * @returns 颜色字符串,未匹配到时返回 undefined
500
+ */
501
+ _getStatusColor(e) {
502
+ return this.statusColorMap.get(e);
503
+ }
504
+ // ==========================================================================
505
+ // 私有方法:桩号标尺
506
+ // ==========================================================================
507
+ /**
508
+ * 绘制桩号标记和刻度线
509
+ *
510
+ * 在道路上方绘制桩号文本和对应的刻度线。
511
+ * 桩号文本整公里显示 "K112",非整公里显示 "K112+500"。
512
+ */
513
+ _drawPileMarkers(e, s, t) {
514
+ const i = this._computeSegments();
515
+ e.font = '9px "JetBrains Mono", monospace', e.fillStyle = this.config.markerColor;
516
+ for (let a = 0; a < i.length; a++) {
517
+ const n = i[a], r = n.x;
518
+ r >= 0 && r <= s && (e.fillStyle = this.config.markerColor, e.fillRect(r, t.roadY - 6, 1, 5), e.fillStyle = this.config.markerColor, e.textAlign = "center", e.fillText(n.label, r, t.roadY - 9));
519
+ }
520
+ e.textAlign = "start";
521
+ }
522
+ // ==========================================================================
523
+ // 私有方法:车道标签
524
+ // ==========================================================================
525
+ /**
526
+ * 绘制车道方向标签(上行/下行)
527
+ *
528
+ * 在画布左侧标注方向文字。
529
+ */
530
+ _drawLaneLabels(e, s) {
531
+ const t = s.roadPadding + s.lanesDown * s.laneHeight, i = s.roadY + t + s.medianHeight;
532
+ e.font = '8px "JetBrains Mono", monospace', e.fillStyle = this.config.laneLabelColor;
533
+ const a = s.roadY + t / 2 + 3;
534
+ e.fillText("上行", 6, a);
535
+ const n = i + s.roadPadding + s.lanesUp * s.laneHeight / 2 + 3;
536
+ e.fillText("下行", 6, n);
537
+ }
538
+ // ==========================================================================
539
+ // 私有方法:车辆绘制
540
+ // ==========================================================================
541
+ /**
542
+ * 绘制所有车辆
543
+ *
544
+ * 遍历缓存的车辆数组,将每台车按其 position 和 lane 渲染到对应车道。
545
+ * 过滤超出有效范围的车辆。
546
+ */
547
+ _drawVehicles(e, s, t) {
548
+ if (this.cachedVehicles.length === 0) return;
549
+ const i = this.cachedVehicles, a = t.visibleMeters > 0 ? s / t.visibleMeters : 0;
550
+ for (let n = 0; n < i.length; n++) {
551
+ const r = i[n], o = T(r.position), d = t.viewOffsetMeters + t.visibleMeters;
552
+ if (o < t.viewOffsetMeters - 50 || o > d + 50)
553
+ continue;
554
+ const f = r.direction === this.config.directionUp ? "up" : "down", g = r.lane ?? 1, p = f === "down" ? t.lanesDown : t.lanesUp, w = Math.max(1, Math.min(g, p)), v = this._computeLaneY(t, f, w), u = (o - t.viewOffsetMeters) * a, _ = r.carColor || this.config.defaultCarColor, M = r.carSvgUrl || this.config.carSvgUrl || null;
555
+ this._drawSingleCar(e, u, v, _, r, M);
556
+ }
557
+ }
558
+ /**
559
+ * 计算指定车道中心的 Y 坐标
560
+ *
561
+ * @param m - 渲染度量
562
+ * @param side - 上行/下行
563
+ * @param laneNum - 车道编号(1-based,1 为最靠近隔离带的车道)
564
+ * @returns 该车道中心的 Y 坐标(像素)
565
+ */
566
+ _computeLaneY(e, s, t) {
567
+ if (s === "up") {
568
+ const i = e.roadY + e.roadPadding, a = t - 1;
569
+ return i + (e.lanesUp - 1 - a) * e.laneHeight + e.laneHeight / 2;
570
+ } else {
571
+ const i = e.roadPadding + e.lanesUp * e.laneHeight, a = e.roadY + i + e.medianHeight + e.roadPadding, n = t - 1;
572
+ return a + n * e.laneHeight + e.laneHeight / 2;
573
+ }
574
+ }
575
+ /**
576
+ * 绘制单台车辆(14 层精细渲染)
577
+ *
578
+ * 渲染层级(参考原 JS 工具类):
579
+ * 1. 车身阴影
580
+ * 2. 车身主体(使用车辆颜色)
581
+ * 3. 车顶(深色窄矩形)
582
+ * 4. 前挡风玻璃
583
+ * 5. 后挡风玻璃
584
+ * 6. 前车灯
585
+ * 7. 尾灯
586
+ * 8. 车身轮廓线
587
+ * 9. 速度标签(车辆上方)
588
+ * 10. 车辆 ID 标签(车辆下方)
589
+ */
590
+ _drawSingleCar(e, s, t, i, a, n) {
591
+ const r = this.config.carLength, o = this.config.carWidth;
592
+ if (e.save(), e.translate(s, t), a.direction === this.config.directionUp && e.scale(-1, 1), n) {
593
+ const v = this._loadSvgImage(n);
594
+ if (v) {
595
+ e.drawImage(v, -r / 2, -o / 2, r, o), e.restore(), this._drawCarLabels(e, s, t, o, i, a);
596
+ return;
597
+ }
598
+ }
599
+ const d = this._hexToRgb(i), c = this._rgbToHex(
600
+ Math.floor(d[0] * 0.6),
601
+ Math.floor(d[1] * 0.6),
602
+ Math.floor(d[2] * 0.6)
603
+ );
604
+ e.fillStyle = "rgba(0,0,0,0.25)", this._drawCarPath(e, 1.5, 1.5, r, o), e.fill(), e.fillStyle = i, this._drawCarPath(e, 0, 0, r, o), e.fill(), e.fillStyle = c, this._drawCarPath(e, 0, 0, r * 0.45, o * 0.7), e.fill(), e.fillStyle = "rgba(150,200,255,0.5)";
605
+ const f = r * 0.12, g = o * 0.6, p = -r * 0.18;
606
+ this._drawRoundRect(e, p, -g / 2, f, g, 2), e.fillStyle = "rgba(150,200,255,0.35)";
607
+ const w = r * 0.12;
608
+ this._drawRoundRect(e, w, -g / 2, f * 0.8, g, 2), e.fillStyle = "rgba(255,255,200,0.8)", e.beginPath(), e.ellipse(-r / 2 + 2, -o / 2 + 2.5, 2.5, 1.8, 0, 0, Math.PI * 2), e.fill(), e.beginPath(), e.ellipse(-r / 2 + 2, o / 2 - 2.5, 2.5, 1.8, 0, 0, Math.PI * 2), e.fill(), e.fillStyle = "rgba(255,30,30,0.8)", e.beginPath(), e.ellipse(r / 2 - 2, -o / 2 + 2.5, 2, 1.5, 0, 0, Math.PI * 2), e.fill(), e.beginPath(), e.ellipse(r / 2 - 2, o / 2 - 2.5, 2, 1.5, 0, 0, Math.PI * 2), e.fill(), e.strokeStyle = "rgba(0,0,0,0.3)", e.lineWidth = 0.8, this._drawCarPath(e, 0, 0, r, o), e.stroke(), e.restore(), this._drawCarLabels(e, s, t, o, i, a);
609
+ }
610
+ /**
611
+ * 绘制车辆速度标签和 ID 标签
612
+ */
613
+ _drawCarLabels(e, s, t, i, a, n) {
614
+ if (this.config.showCarSpeed && n.speed != null) {
615
+ const r = Math.abs(n.speed);
616
+ e.font = 'bold 7px "JetBrains Mono", monospace', e.fillStyle = "rgba(255,255,255,0.7)", e.textAlign = "center", e.fillText(r.toFixed(0), s, t - i / 2 - 4);
617
+ }
618
+ this.config.showCarId && (e.font = "6px sans-serif", e.fillStyle = a, e.fillText("#" + String(n.id), s, t + i / 2 + 8)), e.textAlign = "start";
619
+ }
620
+ /**
621
+ * 加载 SVG 图片(带缓存)
622
+ *
623
+ * 同步返回已缓存的图片,异步加载未缓存的 URL。
624
+ * 首次加载时返回 null,后续帧生效。
625
+ */
626
+ _loadSvgImage(e) {
627
+ return this._loadImage(e, this.svgImageCache);
628
+ }
629
+ /**
630
+ * 获取道路底图 Image 对象
631
+ *
632
+ * 同步返回已缓存的底图,异步加载首次传入的 URL。
633
+ */
634
+ _getRoadBgImage() {
635
+ const e = this.config.roadBgImage;
636
+ if (!e) return null;
637
+ if (this.roadBgImage) return this.roadBgImage;
638
+ if (this.roadBgImageLoading) return null;
639
+ this.roadBgImageLoading = !0;
640
+ const s = new Image();
641
+ return s.onload = () => {
642
+ this.roadBgImage = s, this.roadBgImageLoading = !1, !this.destroyed && !this.renderPending && (this.renderPending = !0, requestAnimationFrame(() => this._doRender()));
643
+ }, s.onerror = () => {
644
+ this.roadBgImageLoading = !1, console.warn("[RoadTrafficCore] 道路底图加载失败");
645
+ }, s.src = this._resolveSvgSrc(e), null;
646
+ }
647
+ /**
648
+ * 通用图片加载器(带缓存)
649
+ */
650
+ _loadImage(e, s) {
651
+ const t = s.get(e);
652
+ if (t) return t;
653
+ const i = this._resolveSvgSrc(e), a = new Image();
654
+ return a.onload = () => {
655
+ s.set(e, a), !this.destroyed && !this.renderPending && (this.renderPending = !0, requestAnimationFrame(() => this._doRender()));
656
+ }, a.onerror = () => {
657
+ console.warn("[RoadTrafficCore] SVG 图片加载失败:", i.substring(0, 80) + "...");
658
+ }, a.src = i, null;
659
+ }
660
+ /**
661
+ * 解析 SVG 源:自动将原始 SVG 字符串转为 data URI
662
+ */
663
+ _resolveSvgSrc(e) {
664
+ const s = e.trim();
665
+ return s.startsWith("<svg") || s.startsWith("<?xml") ? "data:image/svg+xml," + encodeURIComponent(s) : s;
666
+ }
667
+ /**
668
+ * 创建俯视图车型轮廓路径
669
+ *
670
+ * 车型形状:车头圆角较大(前弧),车尾圆角较小(后弧)
671
+ * 参考原 JS 工具类 trajectory visualizer 的设计。
672
+ *
673
+ * @param ctx - 2D 上下文
674
+ * @param ox, oy - 偏移量
675
+ * @param length - 车长
676
+ * @param width - 车宽
677
+ */
678
+ _drawCarPath(e, s, t, i, a) {
679
+ const n = i / 2, r = a / 2, o = Math.min(n * 0.4, 5), d = Math.min(n * 0.25, 3);
680
+ e.beginPath(), e.moveTo(s - n + o, t - r), e.lineTo(s + n - d, t - r), e.quadraticCurveTo(s + n, t - r, s + n, t - r + d), e.lineTo(s + n, t + r - d), e.quadraticCurveTo(s + n, t + r, s + n - d, t + r), e.lineTo(s - n + o, t + r), e.quadraticCurveTo(s - n, t + r, s - n, t + r - o), e.lineTo(s - n, t - r + o), e.quadraticCurveTo(s - n, t - r, s - n + o, t - r), e.closePath();
681
+ }
682
+ /**
683
+ * 绘制圆角矩形路径并填充
684
+ *
685
+ * @param ctx - 2D 上下文
686
+ * @param x, y - 矩形左上角
687
+ * @param w, h - 宽高
688
+ * @param r - 圆角半径
689
+ */
690
+ _drawRoundRect(e, s, t, i, a, n) {
691
+ e.beginPath(), e.moveTo(s + n, t), e.lineTo(s + i - n, t), e.quadraticCurveTo(s + i, t, s + i, t + n), e.lineTo(s + i, t + a - n), e.quadraticCurveTo(s + i, t + a, s + i - n, t + a), e.lineTo(s + n, t + a), e.quadraticCurveTo(s, t + a, s, t + a - n), e.lineTo(s, t + n), e.quadraticCurveTo(s, t, s + n, t), e.closePath(), e.fill();
692
+ }
693
+ // ==========================================================================
694
+ // 私有方法:颜色工具
695
+ // ==========================================================================
696
+ /**
697
+ * 十六进制颜色字符串 → RGB 数组
698
+ *
699
+ * @param hex - 十六进制颜色(如 "#ff4d6a" 或 "ff4d6a")
700
+ * @returns [R, G, B] 数组,每个分量 0-255
701
+ */
702
+ _hexToRgb(e) {
703
+ return e = e.replace("#", ""), [
704
+ parseInt(e.substring(0, 2), 16) || 0,
705
+ parseInt(e.substring(2, 4), 16) || 0,
706
+ parseInt(e.substring(4, 6), 16) || 0
707
+ ];
708
+ }
709
+ /**
710
+ * RGB 分量 → 十六进制颜色字符串
711
+ *
712
+ * @param r, g, b - RGB 分量,每个 0-255
713
+ * @returns 十六进制颜色(如 "#ff4d6a")
714
+ */
715
+ _rgbToHex(e, s, t) {
716
+ return "#" + ((1 << 24) + (e << 16) + (s << 8) + t).toString(16).slice(1);
717
+ }
718
+ }
719
+ const oe = /* @__PURE__ */ D({
720
+ __name: "RoadTrafficViewer",
721
+ props: {
722
+ config: {},
723
+ vehicles: {},
724
+ trafficFlows: {}
725
+ },
726
+ emits: ["ready"],
727
+ setup(l, { expose: e, emit: s }) {
728
+ const t = l, i = s, a = U(null), n = F(null), r = y(() => {
729
+ const o = {
730
+ position: "relative",
731
+ overflow: "hidden"
732
+ }, d = t.config.canvasWidth, c = t.config.canvasHeight;
733
+ return d !== void 0 ? o.width = typeof d == "number" ? `${d}px` : d : o.width = "100%", c !== void 0 && (o.height = typeof c == "number" ? `${c}px` : c), o;
734
+ });
735
+ return A(() => {
736
+ if (!a.value) {
737
+ console.warn("[RoadTrafficViewer] 容器 DOM 未找到");
738
+ return;
739
+ }
740
+ const o = new S(a.value, t.config);
741
+ n.value = o, o.render(), t.vehicles && t.vehicles.length > 0 && o.updateVehicles(t.vehicles), t.trafficFlows && t.trafficFlows.length > 0 && o.updateTrafficFlows(t.trafficFlows), i("ready", o);
742
+ }), O(() => {
743
+ n.value && (n.value.destroy(), n.value = null);
744
+ }), L(
745
+ () => t.vehicles,
746
+ (o) => {
747
+ n.value && o && n.value.updateVehicles(o);
748
+ },
749
+ { deep: !0 }
750
+ ), L(
751
+ () => t.trafficFlows,
752
+ (o) => {
753
+ n.value && n.value.updateTrafficFlows(o ?? []);
754
+ },
755
+ { deep: !0 }
756
+ ), e({
757
+ /** 核心渲染实例 */
758
+ core: n,
759
+ /** 容器 DOM 引用 */
760
+ containerRef: a
761
+ }), (o, d) => (P(), C("div", {
762
+ ref_key: "containerRef",
763
+ ref: a,
764
+ class: "road-traffic-viewer",
765
+ style: b(r.value)
766
+ }, null, 4));
767
+ }
768
+ }), R = (l, e) => {
769
+ const s = l.__vccOpts || l;
770
+ for (const [t, i] of e)
771
+ s[t] = i;
772
+ return s;
773
+ }, ue = /* @__PURE__ */ R(oe, [["__scopeId", "data-v-c2f245be"]]), de = {
774
+ name: "RoadTrafficViewerV2",
775
+ props: {
776
+ /**
777
+ * 道路渲染配置
778
+ *
779
+ * 至少需要提供 startPile 和 endPile
780
+ */
781
+ config: {
782
+ type: Object,
783
+ required: !0
784
+ },
785
+ /**
786
+ * 车辆数据集
787
+ *
788
+ * 变化时自动触发渲染更新
789
+ */
790
+ vehicles: {
791
+ type: Array,
792
+ default: function() {
793
+ return [];
794
+ }
795
+ },
796
+ /**
797
+ * 路段流量数据集
798
+ *
799
+ * 变化时自动触发路段颜色更新
800
+ */
801
+ trafficFlows: {
802
+ type: Array,
803
+ default: function() {
804
+ return [];
805
+ }
806
+ }
807
+ },
808
+ emits: ["ready"],
809
+ data() {
810
+ return {
811
+ /** 核心渲染实例 */
812
+ coreInstance: null
813
+ };
814
+ },
815
+ computed: {
816
+ /**
817
+ * 根据 config 中的 canvasWidth/canvasHeight 计算容器样式
818
+ *
819
+ * 支持 px 数值和百分比字符串
820
+ */
821
+ containerStyle() {
822
+ var l = {
823
+ position: "relative",
824
+ overflow: "hidden"
825
+ }, e = this.config.canvasWidth, s = this.config.canvasHeight;
826
+ return e !== void 0 ? l.width = typeof e == "number" ? e + "px" : e : l.width = "100%", s !== void 0 && (l.height = typeof s == "number" ? s + "px" : s), l;
827
+ }
828
+ },
829
+ watch: {
830
+ /**
831
+ * 监听 vehicles 数据变化
832
+ *
833
+ * 使用 deep: true 检测数组内部变化。
834
+ * 对于超大车辆数组(>1000),建议父组件中使用不可变替换来触发更新。
835
+ */
836
+ vehicles: {
837
+ handler: function(l) {
838
+ this.coreInstance && l && this.coreInstance.updateVehicles(l);
839
+ },
840
+ deep: !0
841
+ },
842
+ /**
843
+ * 监听 trafficFlows 数据变化
844
+ */
845
+ trafficFlows: {
846
+ handler: function(l) {
847
+ this.coreInstance && this.coreInstance.updateTrafficFlows(l || []);
848
+ },
849
+ deep: !0
850
+ }
851
+ },
852
+ mounted() {
853
+ var l = this.$refs.container;
854
+ if (!l) {
855
+ console.warn("[RoadTrafficViewerV2] 容器 DOM 未找到");
856
+ return;
857
+ }
858
+ var e = new S(l, this.config);
859
+ this.coreInstance = e, e.render(), this.vehicles && this.vehicles.length > 0 && e.updateVehicles(this.vehicles), this.trafficFlows && this.trafficFlows.length > 0 && e.updateTrafficFlows(this.trafficFlows), this.$emit("ready", e);
860
+ },
861
+ beforeDestroy() {
862
+ this.coreInstance && (this.coreInstance.destroy(), this.coreInstance = null);
863
+ }
864
+ };
865
+ function le(l, e, s, t, i, a) {
866
+ return P(), C("div", {
867
+ ref: "container",
868
+ class: "road-traffic-viewer-v2",
869
+ style: b(a.containerStyle)
870
+ }, null, 4);
871
+ }
872
+ const ge = /* @__PURE__ */ R(de, [["render", le], ["__scopeId", "data-v-51c2f40a"]]);
873
+ export {
874
+ B as DEFAULT_TRAFFIC_STATUS_COLORS,
875
+ S as RoadTrafficCore,
876
+ ue as RoadTrafficViewer,
877
+ ge as RoadTrafficViewerV2,
878
+ V as formatPile,
879
+ fe as formatPileShort,
880
+ T as parsePile
881
+ };