|
|
|
@ -54,6 +54,9 @@ |
|
|
|
<div class="trajectory-toolbar"> |
|
|
|
<span class="page-inline-filter__label">{{ $t("device.trajectory.provider.label") }}</span> |
|
|
|
<el-radio-group v-model="mapProvider" size="small" @change="handleProviderChange"> |
|
|
|
<el-radio-button label="maptiler" :disabled="!hasMaptilerKey"> |
|
|
|
{{ $t("device.trajectory.provider.maptiler") }} |
|
|
|
</el-radio-button> |
|
|
|
<el-radio-button label="amap" :disabled="!hasAmapKey"> |
|
|
|
{{ $t("device.trajectory.provider.amap") }} |
|
|
|
</el-radio-button> |
|
|
|
@ -111,9 +114,17 @@ |
|
|
|
import { getDeviceTrajectory, getDeviceTrajectoryMapConfig } from "@/api/device/device"; |
|
|
|
import { loadAMap } from "@/utils/loadAMap"; |
|
|
|
import { loadGoogleMaps } from "@/utils/loadGoogleMaps"; |
|
|
|
import { loadLeaflet } from "@/utils/loadLeaflet"; |
|
|
|
|
|
|
|
const AMAP_DEFAULT_CENTER = [121.4737, 31.2304]; |
|
|
|
const GOOGLE_DEFAULT_CENTER = { lat: 31.2304, lng: 121.4737 }; |
|
|
|
const LEAFLET_DEFAULT_CENTER = [31.2304, 121.4737]; |
|
|
|
const AMAP_FALLBACK_MAX_ZOOM = 20; |
|
|
|
const GOOGLE_DETAIL_ZOOM = 21; |
|
|
|
const LEAFLET_MAX_ZOOM = 22; |
|
|
|
const LEAFLET_LINE_ARROW_MAX = 36; |
|
|
|
const LEAFLET_SHOW_LINE_ARROWS = false; |
|
|
|
const LEAFLET_TRAJECTORY_LINE_COLOR = "#1a73e8"; |
|
|
|
const CONVERT_BATCH_SIZE = 40; |
|
|
|
const MAX_TRAJECTORY_POINTS = 100; |
|
|
|
|
|
|
|
@ -143,6 +154,7 @@ export default { |
|
|
|
polyline: null, |
|
|
|
markers: [], |
|
|
|
markerInfoWindows: [], |
|
|
|
tileLayer: null, |
|
|
|
mapsApi: null, |
|
|
|
mapConfig: null, |
|
|
|
locationTimeRange: [], |
|
|
|
@ -169,6 +181,9 @@ export default { |
|
|
|
hasGoogleKey() { |
|
|
|
return !!(this.mapConfig && this.mapConfig.googleKey); |
|
|
|
}, |
|
|
|
hasMaptilerKey() { |
|
|
|
return !!(this.mapConfig && this.mapConfig.maptilerKey); |
|
|
|
}, |
|
|
|
}, |
|
|
|
methods: { |
|
|
|
async handleOpen() { |
|
|
|
@ -213,12 +228,16 @@ export default { |
|
|
|
await this.renderCurrentProviderMap(); |
|
|
|
}, |
|
|
|
resolveDefaultProvider() { |
|
|
|
if (this.hasMaptilerKey) { |
|
|
|
return "maptiler"; |
|
|
|
} |
|
|
|
if (this.hasAmapKey) { |
|
|
|
return "amap"; |
|
|
|
} |
|
|
|
if (this.hasGoogleKey) { |
|
|
|
return "google"; |
|
|
|
} |
|
|
|
|
|
|
|
return "amap"; |
|
|
|
}, |
|
|
|
initDefaultLocationTimeRange() { |
|
|
|
@ -267,7 +286,7 @@ export default { |
|
|
|
try { |
|
|
|
const response = await getDeviceTrajectoryMapConfig(); |
|
|
|
const data = response && response.data ? response.data : {}; |
|
|
|
if (!data.gaodeKey && !data.googleKey) { |
|
|
|
if (!data.gaodeKey && !data.googleKey && !data.maptilerKey) { |
|
|
|
this.loadError = this.$t("device.trajectory.message.missingMapKey"); |
|
|
|
return; |
|
|
|
} |
|
|
|
@ -294,8 +313,11 @@ export default { |
|
|
|
}; |
|
|
|
}) |
|
|
|
.filter((item) => item.latNum !== null && item.lngNum !== null); |
|
|
|
this.totalTrajectoryCount = validPoints.length; |
|
|
|
this.trajectoryPoints = validPoints.slice(-MAX_TRAJECTORY_POINTS); |
|
|
|
const sortedPoints = validPoints.slice().sort((left, right) => { |
|
|
|
return this.getTrackTimestamp(left) - this.getTrackTimestamp(right); |
|
|
|
}); |
|
|
|
this.totalTrajectoryCount = sortedPoints.length; |
|
|
|
this.trajectoryPoints = sortedPoints.slice(-MAX_TRAJECTORY_POINTS); |
|
|
|
} catch (error) { |
|
|
|
this.trajectoryPoints = []; |
|
|
|
this.totalTrajectoryCount = 0; |
|
|
|
@ -310,6 +332,13 @@ export default { |
|
|
|
} |
|
|
|
this.loadError = ""; |
|
|
|
await this.$nextTick(); |
|
|
|
if (this.mapProvider === "maptiler") { |
|
|
|
await this.ensureLeafletMap(); |
|
|
|
if (!this.loadError) { |
|
|
|
this.renderLeafletTrajectory(); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
if (this.mapProvider === "google") { |
|
|
|
await this.ensureGoogleMap(); |
|
|
|
if (!this.loadError) { |
|
|
|
@ -323,6 +352,211 @@ export default { |
|
|
|
this.renderAmapTrajectory(); |
|
|
|
} |
|
|
|
}, |
|
|
|
async ensureLeafletMap() { |
|
|
|
if (!this.hasMaptilerKey) { |
|
|
|
this.loadError = this.$t("device.trajectory.message.missingMaptilerKey"); |
|
|
|
return; |
|
|
|
} |
|
|
|
this.mapLoading = true; |
|
|
|
try { |
|
|
|
const mapsApi = await loadLeaflet(); |
|
|
|
if (!this.$refs.map) { |
|
|
|
return; |
|
|
|
} |
|
|
|
if (!this.map || this.mapVendor !== "leaflet") { |
|
|
|
this.destroyMap(); |
|
|
|
this.mapsApi = mapsApi; |
|
|
|
this.map = mapsApi.map(this.$refs.map, { |
|
|
|
center: LEAFLET_DEFAULT_CENTER, |
|
|
|
zoom: LEAFLET_MAX_ZOOM, |
|
|
|
zoomControl: true, |
|
|
|
}); |
|
|
|
this.tileLayer = mapsApi.tileLayer( |
|
|
|
"https://api.maptiler.com/maps/streets/{z}/{x}/{y}{r}.png?key=" + |
|
|
|
encodeURIComponent(this.mapConfig.maptilerKey), |
|
|
|
{ |
|
|
|
attribution: |
|
|
|
'© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>', |
|
|
|
maxZoom: LEAFLET_MAX_ZOOM, |
|
|
|
maxNativeZoom: LEAFLET_MAX_ZOOM, |
|
|
|
detectRetina: true, |
|
|
|
} |
|
|
|
); |
|
|
|
this.tileLayer.addTo(this.map); |
|
|
|
this.mapVendor = "leaflet"; |
|
|
|
} else { |
|
|
|
this.mapsApi = mapsApi; |
|
|
|
if (typeof this.map.invalidateSize === "function") { |
|
|
|
this.map.invalidateSize(); |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
|
this.loadError = error && error.message ? error.message : this.$t("device.trajectory.message.maptilerLoadFailed"); |
|
|
|
} finally { |
|
|
|
this.mapLoading = false; |
|
|
|
} |
|
|
|
}, |
|
|
|
renderLeafletTrajectory() { |
|
|
|
if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const mapPoints = this.getMapOrderedTrajectoryPoints(); |
|
|
|
if (!mapPoints.length) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
this.clearOverlays(); |
|
|
|
|
|
|
|
const path = mapPoints.map((item) => [item.latNum, item.lngNum]); |
|
|
|
this.polyline = this.createLeafletTrajectoryLayer(path); |
|
|
|
if (this.polyline) { |
|
|
|
this.polyline.addTo(this.map); |
|
|
|
} |
|
|
|
|
|
|
|
const startItem = mapPoints[0]; |
|
|
|
const endItem = mapPoints[mapPoints.length - 1]; |
|
|
|
this.markers = []; |
|
|
|
|
|
|
|
if (path.length > 1) { |
|
|
|
const startAngle = this.getLeafletDirectionAngle(path[0], path[1]); |
|
|
|
const endAngle = this.getLeafletDirectionAngle(path[path.length - 2], path[path.length - 1]); |
|
|
|
const startMarker = this.createLeafletDirectionMarker( |
|
|
|
path[0], |
|
|
|
startAngle, |
|
|
|
this.$t("device.trajectory.marker.startShort"), |
|
|
|
"#67c23a", |
|
|
|
this.$t("device.trajectory.message.startTime"), |
|
|
|
this.getPointTrackTime(startItem) |
|
|
|
); |
|
|
|
const endMarker = this.createLeafletDirectionMarker( |
|
|
|
path[path.length - 1], |
|
|
|
endAngle, |
|
|
|
this.$t("device.trajectory.marker.endShort"), |
|
|
|
"#f56c6c", |
|
|
|
this.$t("device.trajectory.message.endTime"), |
|
|
|
this.getPointTrackTime(endItem) |
|
|
|
); |
|
|
|
this.markers.push(startMarker, endMarker); |
|
|
|
if (LEAFLET_SHOW_LINE_ARROWS) { |
|
|
|
this.addLeafletLineArrows(path); |
|
|
|
} |
|
|
|
} else { |
|
|
|
const startMarker = this.mapsApi.marker(path[0], { |
|
|
|
title: this.$t("device.trajectory.marker.startShort"), |
|
|
|
}); |
|
|
|
startMarker |
|
|
|
.addTo(this.map) |
|
|
|
.bindPopup( |
|
|
|
`<b>${this.escapeHtml(this.$t("device.trajectory.message.startTime"))}</b><br/>${this.escapeHtml( |
|
|
|
this.getPointTrackTime(startItem) |
|
|
|
)}` |
|
|
|
); |
|
|
|
this.markers.push(startMarker); |
|
|
|
} |
|
|
|
|
|
|
|
const latestPoint = path[path.length - 1]; |
|
|
|
this.map.setView(latestPoint, LEAFLET_MAX_ZOOM); |
|
|
|
}, |
|
|
|
createLeafletDirectionMarker(point, angle, shortLabel, color, timeLabel, timeText) { |
|
|
|
const icon = this.mapsApi.divIcon({ |
|
|
|
className: "leaflet-trajectory-arrow", |
|
|
|
iconSize: [30, 30], |
|
|
|
iconAnchor: [15, 15], |
|
|
|
html: this.buildLeafletDirectionHtml(angle, color), |
|
|
|
}); |
|
|
|
const marker = this.mapsApi.marker(point, { |
|
|
|
icon, |
|
|
|
title: shortLabel, |
|
|
|
}); |
|
|
|
marker |
|
|
|
.addTo(this.map) |
|
|
|
.bindPopup(`<b>${this.escapeHtml(timeLabel)}</b><br/>${this.escapeHtml(timeText || "-")}`); |
|
|
|
return marker; |
|
|
|
}, |
|
|
|
addLeafletLineArrows(path) { |
|
|
|
if (!Array.isArray(path) || path.length < 2) { |
|
|
|
return; |
|
|
|
} |
|
|
|
const segmentCount = path.length - 1; |
|
|
|
const step = Math.max(1, Math.ceil(segmentCount / LEAFLET_LINE_ARROW_MAX)); |
|
|
|
for (let i = step; i < path.length; i += step) { |
|
|
|
const fromPoint = path[i - 1]; |
|
|
|
const toPoint = path[i]; |
|
|
|
const marker = this.createLeafletLineArrowMarker(fromPoint, toPoint); |
|
|
|
if (marker) { |
|
|
|
this.markers.push(marker); |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
createLeafletLineArrowMarker(fromPoint, toPoint) { |
|
|
|
if (!Array.isArray(fromPoint) || !Array.isArray(toPoint)) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
const midPoint = [ |
|
|
|
(Number(fromPoint[0]) + Number(toPoint[0])) / 2, |
|
|
|
(Number(fromPoint[1]) + Number(toPoint[1])) / 2, |
|
|
|
]; |
|
|
|
if (!Number.isFinite(midPoint[0]) || !Number.isFinite(midPoint[1])) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
const angle = this.getLeafletDirectionAngle(fromPoint, toPoint); |
|
|
|
const icon = this.mapsApi.divIcon({ |
|
|
|
className: "leaflet-trajectory-line-arrow", |
|
|
|
iconSize: [20, 20], |
|
|
|
iconAnchor: [10, 10], |
|
|
|
html: |
|
|
|
`<div style="width:20px;height:20px;display:flex;align-items:center;justify-content:center;transform:rotate(${angle}deg);">` + |
|
|
|
'<span style="display:inline-block;font-size:14px;line-height:14px;color:#1a73e8;text-shadow:0 1px 2px rgba(255,255,255,0.9);">➤</span>' + |
|
|
|
"</div>", |
|
|
|
}); |
|
|
|
const marker = this.mapsApi.marker(midPoint, { |
|
|
|
icon, |
|
|
|
interactive: false, |
|
|
|
keyboard: false, |
|
|
|
}); |
|
|
|
marker.addTo(this.map); |
|
|
|
return marker; |
|
|
|
}, |
|
|
|
getLeafletDirectionAngle(fromPoint, toPoint) { |
|
|
|
if (!Array.isArray(fromPoint) || !Array.isArray(toPoint)) { |
|
|
|
return 0; |
|
|
|
} |
|
|
|
const deltaLat = Number(toPoint[0]) - Number(fromPoint[0]); |
|
|
|
const deltaLng = Number(toPoint[1]) - Number(fromPoint[1]); |
|
|
|
if (!Number.isFinite(deltaLat) || !Number.isFinite(deltaLng) || (deltaLat === 0 && deltaLng === 0)) { |
|
|
|
return 0; |
|
|
|
} |
|
|
|
return (Math.atan2(deltaLat, deltaLng) * 180) / Math.PI; |
|
|
|
}, |
|
|
|
buildLeafletDirectionHtml(angle, color) { |
|
|
|
return ( |
|
|
|
`<div style="width:30px;height:30px;display:flex;align-items:center;justify-content:center;transform:rotate(${angle}deg);">` + |
|
|
|
`<span style="display:inline-block;font-size:20px;line-height:20px;color:${this.escapeHtml(color)};text-shadow:0 1px 4px rgba(0,0,0,0.35);">➤</span>` + |
|
|
|
"</div>" |
|
|
|
); |
|
|
|
}, |
|
|
|
createLeafletTrajectoryLayer(path) { |
|
|
|
if (!Array.isArray(path) || !path.length) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
return this.mapsApi.polyline(path, { |
|
|
|
color: LEAFLET_TRAJECTORY_LINE_COLOR, |
|
|
|
opacity: 0.95, |
|
|
|
weight: 4, |
|
|
|
lineJoin: "round", |
|
|
|
}); |
|
|
|
}, |
|
|
|
getAmapMaxZoom() { |
|
|
|
if (!this.map || typeof this.map.getZooms !== "function") { |
|
|
|
return AMAP_FALLBACK_MAX_ZOOM; |
|
|
|
} |
|
|
|
const zoomRange = this.map.getZooms(); |
|
|
|
if (!Array.isArray(zoomRange) || zoomRange.length < 2) { |
|
|
|
return AMAP_FALLBACK_MAX_ZOOM; |
|
|
|
} |
|
|
|
const maxZoom = Number(zoomRange[1]); |
|
|
|
return Number.isFinite(maxZoom) ? maxZoom : AMAP_FALLBACK_MAX_ZOOM; |
|
|
|
}, |
|
|
|
async ensureAmap() { |
|
|
|
if (!this.hasAmapKey) { |
|
|
|
this.loadError = this.$t("device.trajectory.message.missingAmapKey"); |
|
|
|
@ -515,11 +749,8 @@ export default { |
|
|
|
this.map.add(this.polyline); |
|
|
|
this.map.add(this.markers); |
|
|
|
|
|
|
|
if (path.length === 1) { |
|
|
|
this.map.setZoomAndCenter(15, startPoint); |
|
|
|
} else { |
|
|
|
this.map.setFitView([this.polyline].concat(this.markers), false, [60, 60, 60, 60]); |
|
|
|
} |
|
|
|
const detailZoom = this.getAmapMaxZoom(); |
|
|
|
this.map.setZoomAndCenter(detailZoom, endPoint); |
|
|
|
}, |
|
|
|
renderGoogleTrajectory() { |
|
|
|
if (!this.map || !this.mapsApi || !this.trajectoryPoints.length) { |
|
|
|
@ -531,8 +762,6 @@ export default { |
|
|
|
} |
|
|
|
this.clearOverlays(); |
|
|
|
const path = mapPoints.map((item) => ({ lat: item.latNum, lng: item.lngNum })); |
|
|
|
const bounds = new this.mapsApi.LatLngBounds(); |
|
|
|
path.forEach((point) => bounds.extend(point)); |
|
|
|
|
|
|
|
const polylineOptions = { |
|
|
|
path, |
|
|
|
@ -603,12 +832,8 @@ export default { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (path.length === 1) { |
|
|
|
this.map.setCenter(startPoint); |
|
|
|
this.map.setZoom(15); |
|
|
|
} else { |
|
|
|
this.map.fitBounds(bounds); |
|
|
|
} |
|
|
|
this.map.setCenter(endPoint); |
|
|
|
this.map.setZoom(GOOGLE_DETAIL_ZOOM); |
|
|
|
}, |
|
|
|
openGoogleMarkerInfoWindow(marker, title, timeText) { |
|
|
|
if (!this.mapsApi || !this.map || !marker || !this.mapsApi.InfoWindow) { |
|
|
|
@ -667,6 +892,13 @@ export default { |
|
|
|
} |
|
|
|
}); |
|
|
|
} |
|
|
|
} else if (this.mapVendor === "leaflet") { |
|
|
|
if (this.polyline && typeof this.map.removeLayer === "function") { |
|
|
|
this.map.removeLayer(this.polyline); |
|
|
|
} |
|
|
|
if (this.markers.length && typeof this.map.removeLayer === "function") { |
|
|
|
this.markers.forEach((marker) => this.map.removeLayer(marker)); |
|
|
|
} |
|
|
|
} else { |
|
|
|
if (this.polyline) { |
|
|
|
this.map.remove(this.polyline); |
|
|
|
@ -690,10 +922,13 @@ export default { |
|
|
|
} |
|
|
|
if (this.mapVendor === "amap" && typeof this.map.destroy === "function") { |
|
|
|
this.map.destroy(); |
|
|
|
} else if (this.mapVendor === "leaflet" && typeof this.map.remove === "function") { |
|
|
|
this.map.remove(); |
|
|
|
} else if (this.$refs.map) { |
|
|
|
this.$refs.map.innerHTML = ""; |
|
|
|
} |
|
|
|
this.map = null; |
|
|
|
this.tileLayer = null; |
|
|
|
if (resetVendor) { |
|
|
|
this.mapVendor = ""; |
|
|
|
this.mapsApi = null; |
|
|
|
@ -817,4 +1052,4 @@ export default { |
|
|
|
width: 100%; |
|
|
|
height: 420px; |
|
|
|
} |
|
|
|
</style> |
|
|
|
</style> |
|
|
|
|