af-mobile-client-vue3 1.5.69 → 1.5.70
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.
- package/package.json +1 -1
- package/src/api/map/trackApi.ts +94 -0
- package/src/assets/img/map/start.png +0 -0
- package/src/assets/img/map/terminus.png +0 -0
- package/src/components/data/XOlMap/index.vue +117 -1
- package/src/components/data/XOlMap/types.ts +26 -0
- package/src/utils/map/track.ts +294 -0
- package/src/utils/map/trackUtils.ts +107 -0
- package/src/views/component/XOlMapView/index.vue +10 -0
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { getConfigByNameAsync } from '@af-mobile-client-vue3/services/api/common'
|
|
2
|
+
import { post } from '@af-mobile-client-vue3/services/restTools'
|
|
3
|
+
import useUserStore from '@af-mobile-client-vue3/stores/modules/user'
|
|
4
|
+
import { trajectoryThinning } from '@af-mobile-client-vue3/utils/map/track'
|
|
5
|
+
import { trajectoryGrouping } from '@af-mobile-client-vue3/utils/map/trackUtils'
|
|
6
|
+
import dayjs from 'dayjs/esm/index'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* trajectoryThinning 是否抽稀 默认 false
|
|
10
|
+
* trajectoryCorrection 是否高德纠偏 默认 false
|
|
11
|
+
* group 是否分组 默认 false 要进行高德纠偏必须分组
|
|
12
|
+
* AMAP_KEY 高德key 用来纠偏
|
|
13
|
+
* gapMs 分组时间间隔 毫秒 默认 30分钟
|
|
14
|
+
* rdpEpsilon RDP 精度(度) 默认 0.000001
|
|
15
|
+
* dbscanEpsilon DBSCAN聚类半径(km) 默认 0.01
|
|
16
|
+
* minPuts DBSCAN聚类 最小开始聚类点数 默认 20
|
|
17
|
+
* minDistance 分组数据进行高德纠偏起始纠偏数 默认 20 不能低于20 否则就会纠偏失败,失败也算次数
|
|
18
|
+
*/
|
|
19
|
+
export interface TrajectoryParams {
|
|
20
|
+
trajectoryThinning?: boolean
|
|
21
|
+
trajectoryCorrection?: boolean
|
|
22
|
+
group?: boolean
|
|
23
|
+
AMAP_KEY?: string
|
|
24
|
+
gapMs?: number
|
|
25
|
+
rdpEpsilon?: number
|
|
26
|
+
dbscanEpsilon?: number
|
|
27
|
+
minPuts?: number
|
|
28
|
+
minDistance?: number
|
|
29
|
+
orderTime?: string
|
|
30
|
+
serviceName?: string
|
|
31
|
+
}
|
|
32
|
+
export const defaultTrajectoryParams: Required<TrajectoryParams> = {
|
|
33
|
+
trajectoryThinning: false,
|
|
34
|
+
trajectoryCorrection: false,
|
|
35
|
+
group: false,
|
|
36
|
+
AMAP_KEY: '',
|
|
37
|
+
gapMs: 1800000,
|
|
38
|
+
rdpEpsilon: 0.000001,
|
|
39
|
+
dbscanEpsilon: 0.01,
|
|
40
|
+
minPuts: 20,
|
|
41
|
+
minDistance: 20,
|
|
42
|
+
orderTime: 'f_realtime asc',
|
|
43
|
+
serviceName: 'af-linepatrol',
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
*
|
|
47
|
+
* @param userId 查询的用户id
|
|
48
|
+
* @param dateStr 查询的轨迹日期
|
|
49
|
+
* @param trajectoryParams 后台轨迹纠偏参数
|
|
50
|
+
*/
|
|
51
|
+
export async function getDeviceState(
|
|
52
|
+
userId: string = useUserStore().getLogin().id,
|
|
53
|
+
dateStr: string = dayjs().format('YYYY-MM-DD'),
|
|
54
|
+
trajectoryParams: TrajectoryParams = defaultTrajectoryParams,
|
|
55
|
+
) {
|
|
56
|
+
const condition = `1 = 1 and f_user_id = ${userId} and (f_realtime between '${dateStr} 00:00:00' and '${dateStr} 23:59:59'`
|
|
57
|
+
const param = {
|
|
58
|
+
condition,
|
|
59
|
+
dateStr,
|
|
60
|
+
userId,
|
|
61
|
+
...trajectoryParams,
|
|
62
|
+
}
|
|
63
|
+
// 获取轨迹
|
|
64
|
+
const trackPoint = await post(`/api/${trajectoryParams.serviceName}/logic/getDeviceStateLogic`, param)
|
|
65
|
+
// 轨迹数据未处理,需要对原始数据进行 处理
|
|
66
|
+
if (trackPoint.state === 'error') {
|
|
67
|
+
// 轨迹抽稀
|
|
68
|
+
if (trackPoint.points.length >= 30) {
|
|
69
|
+
// 轨迹抽稀
|
|
70
|
+
let path = trajectoryThinning(trackPoint.points)
|
|
71
|
+
// 存储 path 数据供表格使用
|
|
72
|
+
const webConfig = await getConfigByNameAsync('webConfig')
|
|
73
|
+
if (webConfig?.setting?.lesseeName === 'jinbin') {
|
|
74
|
+
path = path.filter(item => item.sp !== '0.0')
|
|
75
|
+
}
|
|
76
|
+
// 轨迹分组
|
|
77
|
+
const trajectoryGroupingResult = trajectoryGrouping(path, {})
|
|
78
|
+
console.warn('>>>> trajectoryGroupingResult', trajectoryGroupingResult)
|
|
79
|
+
return trajectoryGroupingResult
|
|
80
|
+
|
|
81
|
+
// const graspRoadCorrectRes = await graspRoadCorrect(trajectoryGroupingResult)
|
|
82
|
+
// console.warn('>>>> graspRoadCorrect', graspRoadCorrectRes)
|
|
83
|
+
// console.warn('>>>> graspRoadCorrect', graspRoadCorrectRes.value)
|
|
84
|
+
// 返回数据
|
|
85
|
+
// return JSON.parse(graspRoadCorrectRes.value)
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
console.warn('当日使用app时间较短或行程较少,不足够生成轨迹')
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (trackPoint.state === 'success') {
|
|
92
|
+
return trackPoint.points
|
|
93
|
+
}
|
|
94
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -15,11 +15,14 @@ import type {
|
|
|
15
15
|
PolygonData,
|
|
16
16
|
PolygonLayerConfig,
|
|
17
17
|
TrackData,
|
|
18
|
+
TrackGroupItem,
|
|
18
19
|
WebGLPointOptions,
|
|
19
20
|
WMSLayerConfig,
|
|
20
21
|
WMSOptions,
|
|
21
22
|
} from './types'
|
|
22
23
|
import locationIcon from '@af-mobile-client-vue3/assets/img/component/positioning.png'
|
|
24
|
+
import startIcon from '@af-mobile-client-vue3/assets/img/map/start.png'
|
|
25
|
+
import terminusIcon from '@af-mobile-client-vue3/assets/img/map/terminus.png'
|
|
23
26
|
import { getConfigByName } from '@af-mobile-client-vue3/services/api/common'
|
|
24
27
|
import { mobileUtil } from '@af-mobile-client-vue3/utils/mobileUtil'
|
|
25
28
|
import { Map, View } from 'ol'
|
|
@@ -1192,7 +1195,6 @@ function addTrackLayer(trackData: TrackData): void {
|
|
|
1192
1195
|
// 添加到地图
|
|
1193
1196
|
map.addLayer(vectorLayer)
|
|
1194
1197
|
trackLayers[trackData.id] = vectorLayer
|
|
1195
|
-
|
|
1196
1198
|
// 更新图层状态,确保 show 属性被正确设置
|
|
1197
1199
|
const trackDataWithShow = {
|
|
1198
1200
|
...trackData,
|
|
@@ -1201,6 +1203,119 @@ function addTrackLayer(trackData: TrackData): void {
|
|
|
1201
1203
|
trackLayerStatus.value.push(trackDataWithShow)
|
|
1202
1204
|
}
|
|
1203
1205
|
|
|
1206
|
+
/**
|
|
1207
|
+
* 添加分段轨迹组图层
|
|
1208
|
+
* @param trackGroups - 轨迹组数组,每组包含唯一 key 和 path
|
|
1209
|
+
* @param options - 轨迹图层配置
|
|
1210
|
+
* @param options.id - 图层ID
|
|
1211
|
+
* @param options.name - 图层名称
|
|
1212
|
+
* @param options.color - 统一颜色,控制所有轨迹段
|
|
1213
|
+
* @param options.show - 图层是否默认显示
|
|
1214
|
+
*/
|
|
1215
|
+
function addTrackGroupLayer(
|
|
1216
|
+
trackGroups: TrackGroupItem[],
|
|
1217
|
+
options: {
|
|
1218
|
+
id?: number
|
|
1219
|
+
name?: string
|
|
1220
|
+
color?: string
|
|
1221
|
+
show?: boolean
|
|
1222
|
+
} = {},
|
|
1223
|
+
): void {
|
|
1224
|
+
if (!map || !Array.isArray(trackGroups) || trackGroups.length === 0)
|
|
1225
|
+
return
|
|
1226
|
+
|
|
1227
|
+
const layerId = options.id ?? 0
|
|
1228
|
+
const layerName = options.name ?? (trackGroups.length === 1 ? trackGroups[0].key : '轨迹组')
|
|
1229
|
+
const layerColor = options.color || '#ff0000'
|
|
1230
|
+
const visible = options.show ?? true
|
|
1231
|
+
|
|
1232
|
+
const vectorSource = new VectorSource()
|
|
1233
|
+
const vectorLayer = new VectorLayer({
|
|
1234
|
+
source: vectorSource,
|
|
1235
|
+
visible,
|
|
1236
|
+
zIndex: 2,
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
trackGroups.forEach((group) => {
|
|
1240
|
+
if (!group.path || group.path.length === 0)
|
|
1241
|
+
return
|
|
1242
|
+
|
|
1243
|
+
const coordinates = group.path.map(coord => fromLonLat([coord.x, coord.y]))
|
|
1244
|
+
if (coordinates.length > 1) {
|
|
1245
|
+
const lineString = new Feature({
|
|
1246
|
+
geometry: new LineString(coordinates),
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
const lineStyle = new Style({
|
|
1250
|
+
stroke: new Stroke({
|
|
1251
|
+
color: layerColor,
|
|
1252
|
+
width: 4,
|
|
1253
|
+
}),
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
lineString.setStyle(lineStyle)
|
|
1257
|
+
vectorSource.addFeature(lineString)
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const startPoint = new Feature({
|
|
1261
|
+
geometry: new Point(coordinates[0]),
|
|
1262
|
+
properties: { segmentKey: group.key, type: 'start' },
|
|
1263
|
+
})
|
|
1264
|
+
const endPoint = new Feature({
|
|
1265
|
+
geometry: new Point(coordinates[coordinates.length - 1]),
|
|
1266
|
+
properties: { segmentKey: group.key, type: 'end' },
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
const pointStyle = (src: string, text: string) =>
|
|
1270
|
+
new Style({
|
|
1271
|
+
image: new Icon({
|
|
1272
|
+
src,
|
|
1273
|
+
anchor: [0.5, 1],
|
|
1274
|
+
scale: 0.15,
|
|
1275
|
+
crossOrigin: 'anonymous',
|
|
1276
|
+
}),
|
|
1277
|
+
text: new Text({
|
|
1278
|
+
text,
|
|
1279
|
+
offsetY: 10,
|
|
1280
|
+
font: '12px sans-serif',
|
|
1281
|
+
fill: new Fill({
|
|
1282
|
+
color: '#333',
|
|
1283
|
+
}),
|
|
1284
|
+
stroke: new Stroke({
|
|
1285
|
+
color: '#fff',
|
|
1286
|
+
width: 2,
|
|
1287
|
+
}),
|
|
1288
|
+
}),
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
// 解析时间:key格式为 "开始时间-结束时间"
|
|
1292
|
+
const [startTime, endTime] = group.key.split('-')
|
|
1293
|
+
|
|
1294
|
+
startPoint.setStyle(pointStyle(startIcon, startTime || '起点'))
|
|
1295
|
+
endPoint.setStyle(pointStyle(terminusIcon, endTime || '终点'))
|
|
1296
|
+
|
|
1297
|
+
vectorSource.addFeatures([startPoint, endPoint])
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
map.addLayer(vectorLayer)
|
|
1301
|
+
trackLayers[layerId] = vectorLayer
|
|
1302
|
+
|
|
1303
|
+
const flatTrackData = trackGroups.reduce<[number, number][]>((acc, group) => {
|
|
1304
|
+
if (group.path && group.path.length > 0) {
|
|
1305
|
+
acc.push(...group.path.map(p => [p.x, p.y] as [number, number]))
|
|
1306
|
+
}
|
|
1307
|
+
return acc
|
|
1308
|
+
}, [])
|
|
1309
|
+
|
|
1310
|
+
trackLayerStatus.value.push({
|
|
1311
|
+
id: layerId,
|
|
1312
|
+
name: layerName,
|
|
1313
|
+
trackData: flatTrackData,
|
|
1314
|
+
color: layerColor,
|
|
1315
|
+
show: visible,
|
|
1316
|
+
})
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1204
1319
|
/**
|
|
1205
1320
|
* 控制轨迹图层显示/隐藏
|
|
1206
1321
|
* @param trackId - 轨迹ID
|
|
@@ -1272,6 +1387,7 @@ defineExpose({
|
|
|
1272
1387
|
startNavigation,
|
|
1273
1388
|
stopNavigation,
|
|
1274
1389
|
addTrackLayer,
|
|
1390
|
+
addTrackGroupLayer,
|
|
1275
1391
|
setTrackLayerVisible,
|
|
1276
1392
|
handleToggleTrackLayer,
|
|
1277
1393
|
addPolygonLayer,
|
|
@@ -150,6 +150,32 @@ export interface TrackData {
|
|
|
150
150
|
show?: boolean // 是否显示
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* 轨迹点数据接口
|
|
155
|
+
*/
|
|
156
|
+
export interface TrackPoint {
|
|
157
|
+
/** 经度 */
|
|
158
|
+
x: number
|
|
159
|
+
/** 纬度 */
|
|
160
|
+
y: number
|
|
161
|
+
/** 角度 */
|
|
162
|
+
ag?: string
|
|
163
|
+
/** 时间戳 */
|
|
164
|
+
tm?: number
|
|
165
|
+
/** 速度 */
|
|
166
|
+
sp?: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 分段轨迹数据项
|
|
171
|
+
*/
|
|
172
|
+
export interface TrackGroupItem {
|
|
173
|
+
key: string
|
|
174
|
+
path: TrackPoint[]
|
|
175
|
+
color?: string
|
|
176
|
+
show?: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
/**
|
|
154
180
|
* 多边形数据接口
|
|
155
181
|
*/
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ==========================================
|
|
3
|
+
* 轨迹抽稀工具
|
|
4
|
+
* RDP(几何抽稀) + DBSCAN(停留点识别)
|
|
5
|
+
* 坐标统一使用:
|
|
6
|
+
* x -> 经度 (lon)
|
|
7
|
+
* y -> 纬度 (lat)
|
|
8
|
+
* 返回字段仅包含:x、y、ag、tm、sp
|
|
9
|
+
* ==========================================
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface Point {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
ag: any
|
|
16
|
+
tm: any
|
|
17
|
+
sp: any
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* ================= DBSCAN ================= */
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* DBSCAN 聚类算法(基于经纬度球面距离)
|
|
24
|
+
*/
|
|
25
|
+
class DBSCAN {
|
|
26
|
+
private epsilon: number
|
|
27
|
+
private minPts: number
|
|
28
|
+
private data: Point[]
|
|
29
|
+
private labels: number[]
|
|
30
|
+
private cellSize: number
|
|
31
|
+
private grid: Map<string, number[]>
|
|
32
|
+
|
|
33
|
+
constructor(epsilon: number, minPts: number, data: Point[]) {
|
|
34
|
+
this.epsilon = epsilon // km
|
|
35
|
+
this.minPts = minPts
|
|
36
|
+
this.data = data
|
|
37
|
+
this.labels = Array.from({ length: data.length }, () => 0)
|
|
38
|
+
|
|
39
|
+
// 空间网格索引(加速邻域查询)
|
|
40
|
+
this.cellSize = this.epsilon / 110.574
|
|
41
|
+
this.grid = new Map()
|
|
42
|
+
|
|
43
|
+
for (let i = 0; i < data.length; i++) {
|
|
44
|
+
const { x, y } = data[i]
|
|
45
|
+
const xIndex = Math.floor(x / this.cellSize)
|
|
46
|
+
const yIndex = Math.floor(y / this.cellSize)
|
|
47
|
+
const key = `${xIndex},${yIndex}`
|
|
48
|
+
|
|
49
|
+
if (!this.grid.has(key))
|
|
50
|
+
this.grid.set(key, [])
|
|
51
|
+
this.grid.get(key)!.push(i)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 球面距离(Haversine)
|
|
56
|
+
distance(a, b) {
|
|
57
|
+
const lat1 = a.y
|
|
58
|
+
const lon1 = a.x
|
|
59
|
+
const lat2 = b.y
|
|
60
|
+
const lon2 = b.x
|
|
61
|
+
|
|
62
|
+
const dLat = (lat2 - lat1) * Math.PI / 180
|
|
63
|
+
const dLon = (lon2 - lon1) * Math.PI / 180
|
|
64
|
+
|
|
65
|
+
const s
|
|
66
|
+
= Math.sin(dLat / 2) ** 2
|
|
67
|
+
+ Math.cos(lat1 * Math.PI / 180)
|
|
68
|
+
* Math.cos(lat2 * Math.PI / 180)
|
|
69
|
+
* Math.sin(dLon / 2) ** 2
|
|
70
|
+
|
|
71
|
+
return 6371 * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
regionQuery(index) {
|
|
75
|
+
const neighbors = []
|
|
76
|
+
const { x, y } = this.data[index]
|
|
77
|
+
const xIndex = Math.floor(x / this.cellSize)
|
|
78
|
+
const yIndex = Math.floor(y / this.cellSize)
|
|
79
|
+
|
|
80
|
+
for (let i = -1; i <= 1; i++) {
|
|
81
|
+
for (let j = -1; j <= 1; j++) {
|
|
82
|
+
const key = `${xIndex + i},${yIndex + j}`
|
|
83
|
+
const cell = this.grid.get(key)
|
|
84
|
+
if (!cell)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
for (const idx of cell) {
|
|
88
|
+
if (this.distance(this.data[index], this.data[idx]) < this.epsilon) {
|
|
89
|
+
neighbors.push(idx)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return neighbors
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
dbscan() {
|
|
98
|
+
let clusterId = 0
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
101
|
+
if (this.labels[i] !== 0)
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
const neighbors = this.regionQuery(i)
|
|
105
|
+
if (neighbors.length < this.minPts) {
|
|
106
|
+
this.labels[i] = -1
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
clusterId++
|
|
111
|
+
this.expandCluster(i, neighbors, clusterId)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return this.labels
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expandCluster(index, neighbors, clusterId) {
|
|
118
|
+
this.labels[index] = clusterId
|
|
119
|
+
let i = 0
|
|
120
|
+
|
|
121
|
+
while (i < neighbors.length) {
|
|
122
|
+
const n = neighbors[i]
|
|
123
|
+
|
|
124
|
+
if (this.labels[n] === -1) {
|
|
125
|
+
this.labels[n] = clusterId
|
|
126
|
+
}
|
|
127
|
+
else if (this.labels[n] === 0) {
|
|
128
|
+
this.labels[n] = clusterId
|
|
129
|
+
const nn = this.regionQuery(n)
|
|
130
|
+
if (nn.length >= this.minPts) {
|
|
131
|
+
neighbors.push(...nn)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
i++
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ================= RDP ================= */
|
|
140
|
+
|
|
141
|
+
function getDecimalDigits(num) {
|
|
142
|
+
const str = num.toString()
|
|
143
|
+
const match = str.match(/\.(\d+)/)
|
|
144
|
+
return match ? match[1].length : 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function perpendicularDistance(p, s, e) {
|
|
148
|
+
const x0 = p.x
|
|
149
|
+
const y0 = p.y
|
|
150
|
+
const x1 = s.x
|
|
151
|
+
const y1 = s.y
|
|
152
|
+
const x2 = e.x
|
|
153
|
+
const y2 = e.y
|
|
154
|
+
|
|
155
|
+
const num = Math.abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1)
|
|
156
|
+
const den = Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2)
|
|
157
|
+
|
|
158
|
+
return den === 0
|
|
159
|
+
? Math.hypot(x0 - x1, y0 - y1)
|
|
160
|
+
: num / den
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function rdp(points, epsilon) {
|
|
164
|
+
if (points.length < 3)
|
|
165
|
+
return points
|
|
166
|
+
|
|
167
|
+
let maxDist = 0
|
|
168
|
+
let index = 0
|
|
169
|
+
const start = points[0]
|
|
170
|
+
const end = points[points.length - 1]
|
|
171
|
+
|
|
172
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
173
|
+
const d = perpendicularDistance(points[i], start, end)
|
|
174
|
+
if (d > maxDist) {
|
|
175
|
+
maxDist = d
|
|
176
|
+
index = i
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (maxDist > epsilon) {
|
|
181
|
+
const left = rdp(points.slice(0, index + 1), epsilon)
|
|
182
|
+
const right = rdp(points.slice(index), epsilon)
|
|
183
|
+
return left.slice(0, -1).concat(right)
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
return [start, end]
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ================= 主函数 ================= */
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 轨迹抽稀主入口
|
|
194
|
+
* @param {Array} data 原始轨迹
|
|
195
|
+
* @param {number} rdpEpsilon RDP 精度(度)
|
|
196
|
+
* @param {number} dbscanEpsilon DBSCAN 半径(km)
|
|
197
|
+
* @param {number} minPts DBSCAN 最小点数
|
|
198
|
+
*/
|
|
199
|
+
export function trajectoryThinning(
|
|
200
|
+
data: any[],
|
|
201
|
+
rdpEpsilon = 0.000001,
|
|
202
|
+
dbscanEpsilon = 0.01,
|
|
203
|
+
minPts = 20,
|
|
204
|
+
): Point[] {
|
|
205
|
+
if (!Array.isArray(data) || data.length === 0)
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
// 1️⃣ 数据清洗
|
|
209
|
+
const cleaned: Point[] = data
|
|
210
|
+
.filter(p =>
|
|
211
|
+
p.x && p.y
|
|
212
|
+
&& p.ag !== undefined
|
|
213
|
+
&& p.tm !== undefined
|
|
214
|
+
&& p.sp !== undefined
|
|
215
|
+
&& getDecimalDigits(p.x) > 3
|
|
216
|
+
&& getDecimalDigits(p.y) > 3,
|
|
217
|
+
)
|
|
218
|
+
.map(p => ({
|
|
219
|
+
x: +p.x,
|
|
220
|
+
y: +p.y,
|
|
221
|
+
ag: p.ag,
|
|
222
|
+
tm: p.tm,
|
|
223
|
+
sp: p.sp,
|
|
224
|
+
}))
|
|
225
|
+
.sort((a, b) => a.tm - b.tm)
|
|
226
|
+
|
|
227
|
+
if (!cleaned.length)
|
|
228
|
+
return []
|
|
229
|
+
|
|
230
|
+
// 2️⃣ 按 tm 去重
|
|
231
|
+
const dedup: Point[] = []
|
|
232
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
233
|
+
if (!cleaned[i + 1] || cleaned[i].tm !== cleaned[i + 1].tm) {
|
|
234
|
+
dedup.push(cleaned[i])
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 3️⃣ RDP 抽稀
|
|
239
|
+
const rdpPoints: Point[] = rdp(dedup, rdpEpsilon)
|
|
240
|
+
if (!rdpPoints.length)
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
// 4️⃣ DBSCAN 聚类
|
|
244
|
+
const labels = runDbscan(dbscanEpsilon, minPts, rdpPoints)
|
|
245
|
+
|
|
246
|
+
// 5️⃣ 结果整理
|
|
247
|
+
return modifyArray(labels, rdpPoints)
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.map(p => ({
|
|
250
|
+
x: +p.x.toFixed(6),
|
|
251
|
+
y: +p.y.toFixed(6),
|
|
252
|
+
ag: p.ag,
|
|
253
|
+
tm: p.tm,
|
|
254
|
+
sp: p.sp,
|
|
255
|
+
}))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ================= 工具函数 ================= */
|
|
259
|
+
|
|
260
|
+
function runDbscan(epsilon: number, minPts: number, data: Point[]): number[] {
|
|
261
|
+
if (data.length < minPts) {
|
|
262
|
+
return Array.from({ length: data.length }, () => -1)
|
|
263
|
+
}
|
|
264
|
+
return new DBSCAN(epsilon, minPts, data).dbscan()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function modifyArray(labels: number[], points: Point[]): Point[] {
|
|
268
|
+
const groups: Record<number, { index: number, tm: number }[]> = {}
|
|
269
|
+
const result: (Point | 0)[] = Array.from({ length: points.length }, () => 0)
|
|
270
|
+
|
|
271
|
+
for (let i = 0; i < labels.length; i++) {
|
|
272
|
+
const id = labels[i]
|
|
273
|
+
if (id === -1)
|
|
274
|
+
continue
|
|
275
|
+
if (!groups[id])
|
|
276
|
+
groups[id] = []
|
|
277
|
+
groups[id].push({ index: i, tm: points[i].tm })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const group of Object.values(groups)) {
|
|
281
|
+
group.sort((a, b) => a.tm - b.tm)
|
|
282
|
+
result[group[0].index] = points[group[0].index]
|
|
283
|
+
result[group[group.length - 1].index] = points[group[group.length - 1].index]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 噪声点全部保留
|
|
287
|
+
for (let i = 0; i < labels.length; i++) {
|
|
288
|
+
if (labels[i] === -1) {
|
|
289
|
+
result[i] = points[i]
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return result.filter(Boolean) as Point[]
|
|
294
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 获取两个时间之间的时间间隔
|
|
3
|
+
* @param time1
|
|
4
|
+
* @param time2
|
|
5
|
+
* @returns {number}
|
|
6
|
+
*/
|
|
7
|
+
export function getTimeInterval(time1, time2) {
|
|
8
|
+
if (time1 == null || time2 == null)
|
|
9
|
+
return 0
|
|
10
|
+
|
|
11
|
+
let t1 = Number(time1)
|
|
12
|
+
let t2 = Number(time2)
|
|
13
|
+
|
|
14
|
+
// 秒级 → 转毫秒
|
|
15
|
+
if (t1 < 1e12)
|
|
16
|
+
t1 *= 1000
|
|
17
|
+
if (t2 < 1e12)
|
|
18
|
+
t2 *= 1000
|
|
19
|
+
|
|
20
|
+
return Math.abs(t2 - t1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 格式化时间为 HH:mm:ss
|
|
25
|
+
*/
|
|
26
|
+
export function formatTime(time) {
|
|
27
|
+
if (time == null)
|
|
28
|
+
return ''
|
|
29
|
+
|
|
30
|
+
let timestamp = Number(time)
|
|
31
|
+
if (timestamp < 1e12)
|
|
32
|
+
timestamp *= 1000
|
|
33
|
+
|
|
34
|
+
const date = new Date(timestamp)
|
|
35
|
+
const hh = String(date.getUTCHours()).padStart(2, '0')
|
|
36
|
+
const mm = String(date.getUTCMinutes()).padStart(2, '0')
|
|
37
|
+
const ss = String(date.getUTCSeconds()).padStart(2, '0')
|
|
38
|
+
|
|
39
|
+
return `${hh}:${mm}:${ss}`
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 轨迹分组
|
|
43
|
+
* @param trajectory
|
|
44
|
+
* @param {object} options
|
|
45
|
+
* @returns {*}
|
|
46
|
+
*/
|
|
47
|
+
export function trajectoryGrouping(trajectory, options) {
|
|
48
|
+
if (Array.isArray(trajectory) && trajectory.length === 0) {
|
|
49
|
+
console.warn('分组失败:无有效轨迹数据')
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
// 排序轨迹
|
|
53
|
+
const sortedPath = trajectory.sort((a, b) =>
|
|
54
|
+
new Date(a.tm).getTime() - new Date(b.tm).getTime(),
|
|
55
|
+
)
|
|
56
|
+
const result = []
|
|
57
|
+
let currentGroup = {
|
|
58
|
+
startTime: null,
|
|
59
|
+
endTime: null,
|
|
60
|
+
path: [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < sortedPath.length; i++) {
|
|
64
|
+
const point = sortedPath[i]
|
|
65
|
+
|
|
66
|
+
if (currentGroup.path.length === 0) {
|
|
67
|
+
// 初始化分组
|
|
68
|
+
currentGroup.startTime = point.tm
|
|
69
|
+
currentGroup.endTime = point.tm
|
|
70
|
+
currentGroup.path.push(point)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const prevPoint = sortedPath[i - 1]
|
|
75
|
+
const interval = getTimeInterval(prevPoint.tm, point.tm)
|
|
76
|
+
const gapMs = options?.gapMs || 1800000
|
|
77
|
+
if (interval > gapMs) {
|
|
78
|
+
// 2️⃣ 时间间隔超过阈值,结束当前分组
|
|
79
|
+
result.push(formatGroup(currentGroup))
|
|
80
|
+
|
|
81
|
+
// 新建分组
|
|
82
|
+
currentGroup = {
|
|
83
|
+
startTime: point.tm,
|
|
84
|
+
endTime: point.tm,
|
|
85
|
+
path: [point],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// 继续当前分组
|
|
90
|
+
currentGroup.endTime = point.tm
|
|
91
|
+
currentGroup.path.push(point)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3️⃣ 推入最后一组
|
|
96
|
+
if (currentGroup.path.length > 0) {
|
|
97
|
+
result.push(formatGroup(currentGroup))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
function formatGroup(group) {
|
|
103
|
+
return {
|
|
104
|
+
key: `${formatTime(group.startTime)}-${formatTime(group.endTime)}`,
|
|
105
|
+
path: group.path,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import NormalDataLayout from '@af-mobile-client-vue3/components/layout/NormalDataLayout/index.vue'
|
|
3
3
|
import { showNotify } from 'vant'
|
|
4
4
|
import { onMounted, ref } from 'vue'
|
|
5
|
+
import { getDeviceState } from '~root/src/api/map/trackApi'
|
|
5
6
|
import XOlMap from '../../../components/data/XOlMap/index.vue'
|
|
6
7
|
import { polygonTestData, trackTestData } from './testData'
|
|
7
8
|
import 'vant/lib/index.css'
|
|
@@ -273,6 +274,12 @@ function handleAddTrack() {
|
|
|
273
274
|
})
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
// 添加轨迹数组测试
|
|
278
|
+
async function handleAddTrackGroup() {
|
|
279
|
+
const trackGroup = await getDeviceState('419879409793761282', '2026-04-13')
|
|
280
|
+
mapRef.value.addTrackGroupLayer(trackGroup, { id: 10, name: '轨迹', color: '#fff' })
|
|
281
|
+
}
|
|
282
|
+
|
|
276
283
|
// 多边形处理函数
|
|
277
284
|
function handlePolygonClick(polygon: any) {
|
|
278
285
|
console.log('>>>> polygon: ', JSON.stringify(polygon))
|
|
@@ -364,6 +371,9 @@ function stopNavigation() {
|
|
|
364
371
|
<van-button type="primary" size="small" @click="handleAddTrack">
|
|
365
372
|
添加测试轨迹
|
|
366
373
|
</van-button>
|
|
374
|
+
<van-button type="primary" size="small" @click="handleAddTrackGroup">
|
|
375
|
+
添加测试轨迹数组
|
|
376
|
+
</van-button>
|
|
367
377
|
</div>
|
|
368
378
|
|
|
369
379
|
<div class="control-group">
|