This commit is contained in:
2210088963 2025-07-16 14:59:50 +08:00
commit 72795efc0b
4 changed files with 614 additions and 656 deletions

132
CLAUDE.md
View File

@ -1,132 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the **青岛机场无人驾驶车辆协同云平台** (Qingdao Airport Autonomous Vehicle Collaborative Cloud Platform), a Vue 3 based web application for managing and monitoring autonomous vehicles at Qingdao Airport. The project uses RuoYi-Vue3 as the base framework.
## Development Commands
```bash
# Install dependencies
npm install
# Development server (runs on port 6580)
npm run dev
# or
yarn dev
# Build for production
npm run build:prod
# Build for staging
npm run build:stage
# Preview production build
npm run preview
```
**Note**: This project does not have configured linting, testing, or type checking scripts. Manual code review and browser testing are the primary quality assurance methods.
## Architecture Overview
### Tech Stack
- **Frontend Framework**: Vue 3.2.45 with Composition API
- **UI Library**: Element Plus 2.2.21
- **State Management**: Pinia 2.0.22
- **Router**: Vue Router 4.1.4
- **Build Tool**: Vite 3.2.3
- **Map Integration**: OpenLayers 6.15.1 with SuperMap iclient-ol
- **Real-time Communication**: WebSocket + STOMP protocol (@stomp/stompjs, sockjs-client)
### Key Features
- **Vehicle Management**: Real-time monitoring and control of autonomous vehicles
- **Map Visualization**: OpenLayers-based mapping with SuperMap integration for airport layouts
- **Real-time Data**: WebSocket connections for live vehicle tracking and status updates
- **System Administration**: User management, role-based permissions, operational logs
### Project Structure
#### Core Directories
- `src/views/platform/` - Main platform dashboard
- `src/views/car/` - Vehicle management interfaces (monitor, park, type)
- `src/components/map/` - OpenLayers map components and controls
- `src/components/car/` - Vehicle-specific UI components
- `src/api/` - API service definitions organized by domain
- `src/utils/websocket.js` - WebSocket service for real-time communication
#### Map System Architecture
The mapping system is built around OpenLayers with these key components:
**Core Map Component**: `src/components/map/OpenLayersMap.vue`
- Initializes map with EPSG:4528 projection for airport coordinates
- Integrates SuperMap tile services for base mapping
**Control Systems**:
- `VehicleAnimationSystem.vue` - Smooth vehicle movement animations with 60FPS engine
- `VehicleMovementControlRefactored.vue` - Enhanced vehicle tracking with motion prediction
- `LayerSwitcher.vue` - Dynamic layer management
- `VehicleStyleManager.vue` - Vehicle appearance and styling
**Real-time Features**:
- Motion prediction algorithms for smooth vehicle transitions
- 300ms timeout handling for connection interruptions
- Physical engine simulation based on vehicle speed and direction
### WebSocket Integration
The application uses a custom WebSocket service (`src/utils/websocket.js`) for real-time vehicle data:
- Auto-reconnection with configurable intervals
- Event-driven message handling
- Connection state management
- STOMP protocol support for structured messaging
### Development Server Configuration
The Vite development server is configured to:
- Run on port 6580 with auto-open
- Proxy API requests to backend servers:
- `/dev-api``http://10.0.0.126:8080` (田哥 server)
- Alternative: `http://10.0.0.17:8099` (昊天 server)
### Map Coordinate System
The project uses a custom projection `EPSG:4528` for airport mapping:
```javascript
proj4.defs("EPSG:4528", "+proj=tmerc +lat_0=0 +lon_0=120 +k=1 +x_0=40500000 +y_0=0 +ellps=GRS80 +units=m +no_defs");
```
Center coordinates: `[40507885.133754, 4025694.476392]`
### Component Patterns
**Global Components** (auto-registered):
- `Pagination` - Standardized pagination
- `RightToolbar` - Table operation toolbar
- `FileUpload` / `ImageUpload` - File handling
- `TreeSelect` - Hierarchical selection
- `DictTag` - Dictionary value display
**Route Structure**:
- Static routes defined in `src/router/index.js`
- Dynamic routes based on user permissions
- Layout-based routing with nested components
### Performance Optimizations
**Vehicle Animation System**:
- `requestAnimationFrame` for 60FPS smooth animations
- Distance threshold checks (0.1m) to avoid unnecessary calculations
- Motion history tracking (3 points) for trajectory prediction
- Easing functions for natural movement curves
### Known Issues & TODO
As documented in README.md:
1. Left sidebar menu state not preserved on refresh
2. First-level menu missing background color when second-level menu is selected
### API Backend Integration
- Uses axios for HTTP requests with interceptors
- JWT token authentication via js-cookie
- Centralized error handling and response formatting

View File

@ -19,6 +19,15 @@ export function exportCarRunInfo(query) {
})
}
// 批量查询车辆轨迹
export function batchQueryVehicleTrajectory(data) {
return request({
url: '/system/vehicle_location/trajectory/batch',
method: 'post',
data: data
})
}
// 根据车辆ID查询车辆运动信息列表
export function listCarRunInfoByVehicleId(vehicleId, query) {
return request({
@ -28,6 +37,42 @@ export function listCarRunInfoByVehicleId(vehicleId, query) {
})
}
// 根据车辆ID查询车辆轨迹
export function getVehicleTrajectory(vehicleId, query) {
return request({
url: '/system/vehicle_location/trajectory/' + vehicleId,
method: 'get',
params: query
})
}
// 根据车辆ID查询轨迹统计信息
export function getTrajectoryStatistics(vehicleId, query) {
return request({
url: '/system/vehicle_location/trajectory/' + vehicleId + '/statistics',
method: 'get',
params: query
})
}
// 根据车辆ID查询轨迹回放数据
export function getTrajectoryPlayback(vehicleId, query) {
return request({
url: '/system/vehicle_location/trajectory/' + vehicleId + '/playback',
method: 'get',
params: query
})
}
// 根据车辆ID查询区域内轨迹
export function getAreaTrajectory(vehicleId, query) {
return request({
url: '/system/vehicle_location/trajectory/' + vehicleId + '/area',
method: 'get',
params: query
})
}
// 根据车牌号查询车辆运动信息列表
export function listCarRunInfoByLicensePlate(licensePlate, query) {
return request({

View File

@ -1,4 +1,4 @@
<!-- TrackPlayback.vue轨迹回放页面UI还原 -->
<!-- TrackPlayback.vue轨迹回放页面 -->
<template>
<div class="track-playback-content">
<!-- 左侧搜索+列表 -->
@ -49,26 +49,52 @@
<div class="map-img-placeholder"></div>
<!-- 轨迹详情卡片左上角 -->
<div class="track-detail-panel">
<!-- 第一行 -->
<!-- 第一行标题和回放按钮两端对齐 -->
<div class="panel-header">
<span class="dot"></span>
<span class="panel-title">轨迹详情</span>
<div class="header-left">
<span class="dot"></span>
<span class="panel-title">轨迹详情</span>
</div>
<el-button size="small" type="primary" class="replay-btn" @click="handleReplay">回放</el-button>
</div>
<!-- 第二行 -->
<div class="panel-info">
<div class="info-row">
<span class="carno">{{ activeTask?.licensePlate || activeVehicleId || "未选择车辆" }}</span>
<span class="info-item">最大时速 <b>{{ trackDetails.maxSpeed }}km/h</b></span>
<span class="info-item">平均时速 <b>{{ trackDetails.averageSpeed }}km/h</b></span>
<span class="info-item">总里程 <b>{{ trackDetails.totalDistance }}km</b></span>
<span class="info-item">耗时 <b>{{ trackDetails.totalTime }}min</b></span>
<span class="info-item warn">冲突告警 <b>{{ trackDetails.warnings }}</b></span>
<span class="info-item prewarn">冲突预警 <b>{{ trackDetails.preWarnings }}</b></span>
<!-- 第二行车辆信息一行显示 -->
<div class="vehicle-stats-row">
<div class="stat-item">
<span class="stat-value">{{ licensePlate }}</span>
<span class="stat-label">车牌号</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ trackDetails.maxSpeed }}km/h</span>
<span class="stat-label">最大时速</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ trackDetails.averageSpeed }}km/h</span>
<span class="stat-label">平均时速</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ trackDetails.totalDistance }}km</span>
<span class="stat-label">行驶总里程</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ trackDetails.totalTime }}min</span>
<span class="stat-label">行驶耗时</span>
</div>
<div class="stat-item warn">
<span class="stat-value">{{ trackDetails.warnings }}</span>
<span class="stat-label">冲突告警</span>
</div>
<div class="stat-item prewarn">
<span class="stat-value">{{ trackDetails.preWarnings }}</span>
<span class="stat-label">冲突预警</span>
</div>
<!-- <div class="current-time">
<span class="time-value">13:46:53</span>
</div> -->
</div>
<!-- 第三行进度条 -->
<div class="panel-progress">
<!-- 第三行进度条和倍速 -->
<div class="progress-row">
<div class="progress-bar-wrap"
@mousemove="handleProgressHover"
@mouseleave="showTooltip = false"
@ -85,35 +111,34 @@
:style="{left: flag.percent+'%'}"
:title="flag.label"
>
<svg width="14" height="18" viewBox="0 0 14 18">
<polygon points="2,2 12,5 2,8" fill="#E34D59"/>
<rect x="1" y="2" width="2" height="14" fill="#E34D59"/>
</svg>
<div class="flag-icon"></div>
</div>
<!-- 鼠标悬浮提示 -->
<div v-if="showTooltip" class="progress-tooltip" :style="{left: tooltipLeft+'px'}">
{{ tooltipTime }}
</div>
</div>
<div class="progress-bottom">
<span class="start-time">{{ trackDetails.startTime || "未开始" }}</span>
<div class="speed-select">
<el-dropdown @command="setSpeed">
<span class="el-dropdown-link">
{{ speed }}x <i class="el-icon-arrow-down"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="1">1x</el-dropdown-item>
<el-dropdown-item command="2">2x</el-dropdown-item>
<el-dropdown-item command="4">4x</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<span class="end-time">{{ trackDetails.endTime || "未结束" }}</span>
<div class="speed-select">
<el-dropdown @command="setSpeed">
<span class="el-dropdown-link">
{{ speed }}x <i class="el-icon-arrow-down"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="1">1x</el-dropdown-item>
<el-dropdown-item command="2">2x</el-dropdown-item>
<el-dropdown-item command="4">4x</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 第四行开始和结束时间 -->
<div class="time-row">
<span class="start-time">开始时间 {{ trackDetails.startTime || "2025-05-01 12:09:49" }}</span>
<span class="end-time">结束时间 {{ trackDetails.endTime || "2025-05-01 14:09:49" }}</span>
</div>
</div>
</div>
</div>
@ -129,7 +154,8 @@ import {
listCarRunInfoByVehicleId,
listCarRunInfoByLicensePlate,
getLatestLocationByVehicleId,
getLatestLocationByLicensePlate
getLatestLocationByLicensePlate,
batchQueryVehicleTrajectory
} from "@/api/monitor/carRunInfo";
import { ElMessage } from "element-plus";
@ -174,6 +200,7 @@ const props = defineProps({
//
const trackDetails = ref({
licensePlate: "",
maxSpeed: "0",
averageSpeed: "0",
totalDistance: "0",
@ -226,16 +253,19 @@ async function getRunInfoList() {
//
const avgSpeed = (vehicleData.reduce((sum, point) => sum + point.speed, 0) / vehicleData.length).toFixed(2);
// licensePlate
const licensePlate = firstPoint.licensePlate || `车辆${vehicleId}`;
tasksList.push({
id: parseInt(vehicleId),
no: vehicleId,
name: `车辆${firstPoint.licensePlate}轨迹`,
name: `车辆${licensePlate}轨迹`,
time: `${firstPoint.timestamp}--${lastPoint.timestamp}`,
start: `经度${firstPoint.longitude},纬度${firstPoint.latitude}`,
end: `经度${lastPoint.longitude},纬度${lastPoint.latitude}`,
status: "已完成",
speed: `${avgSpeed}km/h`,
licensePlate: firstPoint.licensePlate,
licensePlate: licensePlate,
points: vehicleData
});
});
@ -245,6 +275,8 @@ async function getRunInfoList() {
//
if (tasks.value.length > 0) {
activeId.value = tasks.value[0].id;
//
selectTask(tasks.value[0]);
}
} else {
ElMessage.error(response.msg || '获取车辆运动信息列表失败');
@ -257,40 +289,88 @@ async function getRunInfoList() {
}
}
// ID
async function getTrackDetailByVehicleId(vehicleId) {
if (!vehicleId) return;
try {
const response = await listCarRunInfoByVehicleId(vehicleId, {});
if (response.code === 200) {
const trackData = response.data || [];
// ...
console.log('车辆轨迹数据:', trackData);
//
// taskspoints
processTrackPoints(trackData);
} else {
ElMessage.error(response.msg || '获取车辆轨迹详情失败');
}
} catch (error) {
console.error('获取车辆轨迹详情异常', error);
}
}
//
async function getLatestLocation(licensePlate) {
if (!licensePlate) return;
//
async function getBatchTrajectoryPlayback(vehicleIds, timeRange) {
if (!vehicleIds || vehicleIds.length === 0) return;
loading.value = true;
try {
const response = await getLatestLocationByLicensePlate(licensePlate);
const requestData = {
vehicleIds: vehicleIds,
startTime: timeRange?.[0] || '',
endTime: timeRange?.[1] || '',
pageNum: 1,
pageSize: 1000 //
};
const response = await batchQueryVehicleTrajectory(requestData);
console.log('批量轨迹查询结果:', response);
if (response.code === 200) {
const locationData = response.data;
// ...
console.log('车辆最新位置:', locationData);
const trajectoryData = response.data || response.rows || [];
// ID
const groupedTrajectories = {};
trajectoryData.forEach(point => {
if (!groupedTrajectories[point.vehicleId]) {
groupedTrajectories[point.vehicleId] = [];
}
groupedTrajectories[point.vehicleId].push(point);
});
//
const updatedTasks = [];
Object.keys(groupedTrajectories).forEach(vehicleId => {
const points = groupedTrajectories[vehicleId];
points.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
const avgSpeed = (points.reduce((sum, p) => sum + (p.speed || 0), 0) / points.length).toFixed(2);
// licensePlate
const licensePlate = firstPoint.licensePlate || `车辆${vehicleId}`;
updatedTasks.push({
id: parseInt(vehicleId),
no: vehicleId,
name: `车辆${licensePlate}轨迹回放`,
time: `${firstPoint.timestamp}--${lastPoint.timestamp}`,
start: `经度${firstPoint.longitude},纬度${firstPoint.latitude}`,
end: `经度${lastPoint.longitude},纬度${lastPoint.latitude}`,
status: "回放就绪",
speed: `${avgSpeed}km/h`,
licensePlate: licensePlate,
points: points,
isPlayback: true //
});
});
//
if (updatedTasks.length > 0) {
tasks.value = updatedTasks;
//
if (tasks.value.length > 0) {
activeId.value = tasks.value[0].id;
selectTask(tasks.value[0]);
}
ElMessage.success(`成功加载${updatedTasks.length}个车辆的轨迹回放数据`);
} else {
ElMessage.warning('未找到轨迹回放数据');
}
} else {
ElMessage.error(response.msg || '批量查询车辆轨迹失败');
}
} catch (error) {
console.error('获取车辆最新位置异常', error);
console.error('批量查询车辆轨迹异常', error);
ElMessage.error('批量查询车辆轨迹异常');
} finally {
loading.value = false;
}
}
@ -306,12 +386,29 @@ const filteredTasks = computed(() => {
const activeTask = computed(() => tasks.value.find((t) => t.id === activeId.value));
//
const licensePlate = computed(() => {
if (activeTask.value?.licensePlate) {
return activeTask.value.licensePlate;
} else if (trackDetails.value.licensePlate) {
return trackDetails.value.licensePlate;
} else if (props.vehicle?.licensePlate) {
return props.vehicle.licensePlate;
}
return "暂无车牌号";
});
function selectTask(item) {
activeId.value = item.id;
//
if (item.points && item.points.length > 0) {
processTrackPoints(item.points);
//
if (item.licensePlate) {
trackDetails.value.licensePlate = item.licensePlate;
}
}
}
@ -320,6 +417,14 @@ function processTrackPoints(points) {
//
points.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
//
const firstPoint = points[0];
const currentLicensePlate = trackDetails.value.licensePlate; //
if (firstPoint && firstPoint.licensePlate) {
trackDetails.value.licensePlate = firstPoint.licensePlate;
}
//
const maxSpeed = Math.max(...points.map(p => p.speed)).toFixed(2);
@ -345,8 +450,9 @@ function processTrackPoints(points) {
const endTime = new Date(points[points.length-1].timestamp);
const totalMinutes = Math.round((endTime - startTime) / (60 * 1000));
//
//
trackDetails.value = {
licensePlate: firstPoint?.licensePlate || currentLicensePlate || "暂无车牌号", // 使
maxSpeed: maxSpeed,
averageSpeed: avgSpeed,
totalDistance: distanceKm,
@ -373,30 +479,50 @@ function processTrackPoints(points) {
//
function handleReplay() {
//
const activeTask = tasks.value.find(t => t.id === activeId.value);
const currentTask = tasks.value.find(t => t.id === activeId.value);
if (activeTask) {
if (activeTask.points && activeTask.points.length > 0) {
if (currentTask) {
if (currentTask.points && currentTask.points.length > 0) {
//
processTrackPoints(activeTask.points);
processTrackPoints(currentTask.points);
ElMessage.success('开始轨迹回放');
} else {
// ID
getTrackDetailByVehicleId(activeTask.id);
// 使
const vehicleIds = [currentTask.id.toString()];
getBatchTrajectoryPlayback(vehicleIds, queryParams.value.timeRange);
}
} else if (queryParams.value.vehicleId) {
// 使ID
getTrackDetailByVehicleId(queryParams.value.vehicleId);
// 使ID
const vehicleIds = [queryParams.value.vehicleId];
getBatchTrajectoryPlayback(vehicleIds, queryParams.value.timeRange);
} else if (props.vehicle?.carId) {
// 使ID
getTrackDetailByVehicleId(props.vehicle.carId);
// 使ID
const vehicleIds = [props.vehicle.carId];
getBatchTrajectoryPlayback(vehicleIds, queryParams.value.timeRange);
} else {
//
getRunInfoList();
ElMessage.info('请先选择一个车辆任务');
//
if (tasks.value.length > 0) {
const vehicleIds = tasks.value.map(task => task.id.toString());
getBatchTrajectoryPlayback(vehicleIds, queryParams.value.timeRange);
} else {
//
getRunInfoList();
ElMessage.info('请先选择一个车辆任务');
}
}
}
//
function loadAllTrajectoryPlayback() {
if (tasks.value.length === 0) {
ElMessage.warning('暂无车辆数据');
return;
}
const vehicleIds = tasks.value.map(task => task.id.toString());
getBatchTrajectoryPlayback(vehicleIds, queryParams.value.timeRange);
}
//
async function handleExport() {
try {
@ -466,9 +592,28 @@ function setSpeed(val) {
//
onMounted(() => {
getRunInfoList();
// getLatestLocation("B579");
//
if (props.vehicle) {
if (props.vehicle.licensePlate) {
trackDetails.value.licensePlate = props.vehicle.licensePlate;
}
//
trackDetails.value = {
licensePlate: props.vehicle.licensePlate || "暂无车牌号",
maxSpeed: "0",
averageSpeed: "0",
totalDistance: "0",
totalTime: "0",
warnings: "0",
preWarnings: "0",
startTime: "",
endTime: ""
};
}
getRunInfoList();
});
</script>
@ -616,126 +761,239 @@ onMounted(() => {
.panel-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
.dot {
width: 10px;
height: 10px;
background: #347ae2;
border-radius: 50%;
display: inline-block;
}
.panel-title {
font-size: 16px;
font-weight: bold;
color: #4ea1ff;
margin-right: 12px;
}
.replay-btn {
margin-left: auto;
}
}
.panel-info {
margin-bottom: 10px;
.info-row {
justify-content: space-between;
margin-bottom: 12px;
.header-left {
display: flex;
flex-wrap: wrap;
gap: 18px;
align-items: center;
font-size: 14px;
.carno {
color: #4ea1ff;
font-weight: bold;
font-size: 15px;
gap: 10px;
.dot {
width: 10px;
height: 10px;
background: #347ae2;
border-radius: 50%;
display: inline-block;
}
.info-item {
color: #b0b8c9;
b { color: #fff; font-weight: 500; }
&.warn b { color: #e34d59; }
&.prewarn b { color: #f7b500; }
.panel-title {
font-size: 16px;
font-weight: bold;
color: #4ea1ff;
}
}
.replay-btn {
//
}
}
//
.vehicle-stats-row {
display: flex;
align-items: flex-start;
gap: 20px;
margin-bottom: 12px;
.vehicle-info {
display: flex;
flex-direction: column;
align-items: flex-start;
// min-width: 80px;
padding:3px 10px;
.vehicle-number {
font-size: 18px;
font-weight: bold;
color: #fff;
line-height: 1.2;
margin-bottom: 2px;
}
.vehicle-label {
font-size: 12px;
color: #8a8e99;
line-height: 1;
}
}
.stat-item {
display: flex;
flex-direction: column;
align-items: flex-start;
// min-width: 60px;
.stat-value {
font-size: 16px;
font-weight: bold;
color: #fff;
line-height: 1.2;
margin-bottom: 2px;
}
.stat-label {
font-size: 12px;
color: #8a8e99;
line-height: 1;
}
&.warn .stat-value {
color: #e34d59;
}
&.prewarn .stat-value {
color: #f7b500;
}
}
.current-time {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(52, 122, 226, 0.15);
border-radius: 6px;
padding: 8px 12px;
min-width: 80px;
margin-left: auto;
.time-value {
font-size: 16px;
font-weight: bold;
color: #4ea1ff;
line-height: 1;
}
}
}
.panel-progress {
margin-top: 8px;
//
.progress-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 8px;
.progress-bar-wrap {
position: relative;
height: 8px;
background: transparent;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
flex: 1;
}
.progress-bar-bg {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #343744;
border-radius: 4px;
}
.progress-bar-fg {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg, #347ae2, #4ea1ff);
border-radius: 4px;
height: 8px;
z-index: 1;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: #fff;
border: 3px solid #347ae2;
border-radius: 50%;
z-index: 2;
cursor: pointer;
box-shadow: 0 2px 8px rgba(52,122,226,0.12);
}
.progress-flag {
position: absolute;
top: -14px;
z-index: 3;
width: 14px;
height: 18px;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: auto;
}
.progress-tooltip {
position: absolute;
top: -32px;
background: #23263a;
color: #fff;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(52,122,226,0.10);
z-index: 10;
pointer-events: none;
}
.progress-bottom {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #b0b8c9;
.speed-select {
margin: 0 16px;
.el-dropdown-link {
color: #4ea1ff;
cursor: pointer;
font-weight: bold;
}
}
.start-time, .end-time {
font-size: 13px;
color: #b0b8c9;
.speed-select {
.el-dropdown-link {
color: #4ea1ff;
cursor: pointer;
font-weight: bold;
font-size: 14px;
}
}
}
//
.time-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
color: #b0b8c9;
.start-time, .end-time {
font-size: 13px;
color: #b0b8c9;
}
}
//
.progress-bar-bg {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
background: #343744;
border-radius: 4px;
}
.progress-bar-fg {
position: absolute;
left: 0; top: 0; bottom: 0;
background: linear-gradient(90deg, #347ae2, #4ea1ff);
border-radius: 4px;
height: 8px;
z-index: 1;
}
.progress-thumb {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
background: #fff;
border: 3px solid #347ae2;
border-radius: 50%;
z-index: 2;
cursor: pointer;
box-shadow: 0 2px 8px rgba(52,122,226,0.12);
}
.progress-flag {
position: absolute;
top: -16px;
z-index: 3;
width: 16px;
height: 20px;
display: flex;
align-items: flex-end;
justify-content: center;
pointer-events: auto;
.flag-icon {
position: relative;
width: 2px;
height: 18px;
background: #e34d59;
//
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 18px;
background: #e34d59;
}
//
&::after {
content: '';
position: absolute;
left: 2px;
top: 2px;
width: 0;
height: 0;
border-left: 10px solid #e34d59;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
//
clip-path: polygon(0 0, 80% 0, 70% 50%, 80% 100%, 0 100%);
}
}
}
.progress-tooltip {
position: absolute;
top: -32px;
background: #23263a;
color: #fff;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(52,122,226,0.10);
z-index: 10;
pointer-events: none;
}
}
</style>

View File

@ -22,21 +22,15 @@ const vehicleMotionHistory = ref({}); // 存储车辆移动历史
const PREDICTION_THRESHOLD = 50; // 100ms50ms
const MAX_PREDICTION_TIME = 15000; // 10000ms15000ms
//
const ACCELERATION = 0.15; //
const DECELERATION = 0.25; //
const INERTIA_FACTOR = 0.99; // 0.9850.99
const PREDICTION_STRENGTH = 3; // 43
const SPEED_SMOOTHING = 0.98; // 0.970.98
const POSITION_SMOOTHING = 0.06; // 0.080.06
const MIN_MOVE_THRESHOLD = 0.000005; // 0.000010.000005
const CONTINUOUS_MOVEMENT = true; //
const MIN_SPEED = 0.5; // 1.00.5
const PREDICTION_DECAY_RATE = 0.9995; // 0.9990.9995
const PATH_PREDICTION_ENABLED = true; //
const PATH_PREDICTION_POINTS = 15; // 2015
const CONTINUOUS_MOVEMENT_THRESHOLD = 200; // 300ms200ms
const POSITION_UPDATE_THRESHOLD = 3; // 53
// -
const SPEED_SMOOTHING = 0.3; //
const POSITION_SMOOTHING = 1.0; // 1.0
const MIN_MOVE_THRESHOLD = 0.0000001; //
const CONTINUOUS_MOVEMENT = true; //
const MIN_SPEED = 1.0; //
const PREDICTION_DECAY_RATE = 0.99; //
const CONTINUOUS_MOVEMENT_THRESHOLD = 50; //
const INTERPOLATION_STEPS = 60; // 60
// -
function easeInOutQuad(t) {
@ -78,7 +72,7 @@ function startAnimationLoop() {
animationFrameId.value = requestAnimationFrame(animate);
}
//
// -
function updateVehicleAnimations(deltaTime) {
if (!props.vehicleSource || !props.map) return;
@ -91,176 +85,91 @@ function updateVehicleAnimations(deltaTime) {
const animData = vehicleAnimations.value[id];
if (!animData) return;
// 使
//
ensureContinuousMovement(animData, vehicle, currentTime, deltaTime);
const timeSinceLastUpdate = currentTime - animData.lastUpdated;
// 1.
let targetPosition = [...animData.targetPosition];
// -
//
if (animData.currentSpeed === undefined) {
animData.currentSpeed = animData.speed;
} else {
// 使
const speedSmoothingFactor = vehicle.speedViolation ? 0.98 : SPEED_SMOOTHING;
animData.currentSpeed = animData.currentSpeed * speedSmoothingFactor +
animData.speed * (1 - speedSmoothingFactor);
animData.currentSpeed = animData.currentSpeed * SPEED_SMOOTHING +
animData.speed * (1 - SPEED_SMOOTHING);
}
let currentSpeed = animData.currentSpeed;
let currentSpeed = Math.max(animData.currentSpeed, MIN_SPEED);
//
if (timeSinceLastUpdate > PREDICTION_THRESHOLD && animData.predictionVector) {
//
const predictTime = Math.min(timeSinceLastUpdate, MAX_PREDICTION_TIME);
const predictFactor = (predictTime - PREDICTION_THRESHOLD) / 1000 * 0.6; // 0.80.6
//
const speedFactor = Math.min(1.0, currentSpeed / 30); // 1.2/251.0/30
//
const predictedPosition = [
animData.targetPosition[0] + animData.predictionVector[0] * predictFactor * speedFactor,
animData.targetPosition[1] + animData.predictionVector[1] * predictFactor * speedFactor
];
//
targetPosition = predictedPosition;
//
if (predictTime > 2000) {
//
const slowdownFactor = Math.max(0.6, 1 - (predictTime - 2000) / 15000); // 0.5/100000.6/15000
currentSpeed = Math.max(currentSpeed * slowdownFactor, MIN_SPEED); // 0
}
}
// 2.
const dx = targetPosition[0] - animData.position[0];
const dy = targetPosition[1] - animData.position[1];
//
const dx = animData.targetPosition[0] - animData.position[0];
const dy = animData.targetPosition[1] - animData.position[1];
const distance = Math.sqrt(dx * dx + dy * dy);
//
if (distance < MIN_MOVE_THRESHOLD && !CONTINUOUS_MOVEMENT) {
return;
}
//
const idealDistance = (currentSpeed * 1000 / 3600) * (deltaTime / 1000);
// 3.
if (distance > MIN_MOVE_THRESHOLD || CONTINUOUS_MOVEMENT) {
//
const idealDistance = (currentSpeed * 1000 / 3600) * (deltaTime / 1000);
//
let moveRatio;
// 使
const positionSmoothingFactor = vehicle.speedViolation ? POSITION_SMOOTHING * 0.7 : POSITION_SMOOTHING;
if (distance < idealDistance * 1.2) { // 1.51.2
// 使
moveRatio = Math.min(positionSmoothingFactor * 1.2, idealDistance / distance); // 1.51.2
moveRatio = easeOutQuint(moveRatio);
// -
let moveDistance;
//
if (distance > 0.0001) {
//
if (distance > idealDistance) {
//
moveDistance = idealDistance;
} else {
// 使
moveRatio = Math.min(positionSmoothingFactor, idealDistance / distance);
moveRatio = easeOutCubic(moveRatio);
// 使
// 16ms60fps
moveDistance = Math.max(distance * 0.5, idealDistance * 0.3);
}
} else {
// 使
moveDistance = idealDistance * 0.8;
}
//
if (distance > 0.0001) {
const moveRatio = Math.min(moveDistance / distance, 1.0);
//
animData.position[0] += dx * moveRatio;
animData.position[1] += dy * moveRatio;
} else if (animData.predictionVector) {
// 使
const predLen = Math.sqrt(
animData.predictionVector[0] * animData.predictionVector[0] +
animData.predictionVector[1] * animData.predictionVector[1]
);
if (predLen > 0) {
animData.position[0] += (animData.predictionVector[0] / predLen) * moveDistance;
animData.position[1] += (animData.predictionVector[1] / predLen) * moveDistance;
}
}
//
const feature = props.vehicleSource.getFeatureById(id);
if (feature) {
feature.getGeometry().setCoordinates(animData.position);
//
if (props.vehicles[id]) {
props.vehicles[id].position = animData.position;
}
//
// 使
if (distance < MIN_MOVE_THRESHOLD && animData.predictionVector) {
// 使使
const minMoveAmount = (currentSpeed * 1000 / 3600) * (deltaTime / 1000) * 0.05; // 0.10.05
//
if (props.getVehicleStyle) {
const currentHeading = vehicle ? vehicle.heading : 0;
//
const predLen = Math.sqrt(
animData.predictionVector[0] * animData.predictionVector[0] +
animData.predictionVector[1] * animData.predictionVector[1]
);
if (predLen > 0) {
const normPredX = animData.predictionVector[0] / predLen;
const normPredY = animData.predictionVector[1] / predLen;
//
animData.position[0] += normPredX * minMoveAmount;
animData.position[1] += normPredY * minMoveAmount;
//
animData.lastDx = normPredX * minMoveAmount;
animData.lastDy = normPredY * minMoveAmount;
}
}
//
else {
if (animData.lastDx !== undefined && animData.lastDy !== undefined) {
// 使
const inertiaFactor = vehicle.speedViolation ? INERTIA_FACTOR * 1.05 : INERTIA_FACTOR; // 1.11.05
const inertiaX = animData.lastDx * inertiaFactor;
const inertiaY = animData.lastDy * inertiaFactor;
//
const newDx = dx * moveRatio;
const newDy = dy * moveRatio;
//
const inertiaWeight = Math.min(0.75, currentSpeed / 80) * // 0.85/700.75/80
Math.min(0.9, distance / 10); // 1.0/80.9/10
//
const nextX = animData.position[0] + newDx * (1 - inertiaWeight) + inertiaX * inertiaWeight;
const nextY = animData.position[1] + newDy * (1 - inertiaWeight) + inertiaY * inertiaWeight;
//
animData.position[0] = nextX;
animData.position[1] = nextY;
//
animData.lastDx = nextX - animData.position[0];
animData.lastDy = nextY - animData.position[1];
} else {
//
animData.position[0] += dx * moveRatio;
animData.position[1] += dy * moveRatio;
//
animData.lastDx = dx * moveRatio;
animData.lastDy = dy * moveRatio;
}
}
//
const feature = props.vehicleSource.getFeatureById(id);
if (feature) {
feature.getGeometry().setCoordinates(animData.position);
//
if (props.vehicles[id]) {
props.vehicles[id].position = animData.position;
}
// heading
if (feature && props.getVehicleStyle) {
// heading
const vehicle = props.vehicles[id];
const currentHeading = vehicle ? vehicle.heading : 0;
// heading
if (!animData.lastHeading || Math.abs(animData.lastHeading - currentHeading) > 1) {
feature.setStyle(props.getVehicleStyle(id, currentSpeed, currentHeading));
animData.lastHeading = currentHeading;
}
if (!animData.lastHeading || Math.abs(animData.lastHeading - currentHeading) > 1) {
feature.setStyle(props.getVehicleStyle(id, currentSpeed, currentHeading));
animData.lastHeading = currentHeading;
}
}
}
//
//
animData.currentSpeed = currentSpeed;
//
//
updateVehiclePathHistory(id, animData, currentTime);
});
}
@ -282,8 +191,8 @@ function updateVehiclePathHistory(id, animData, currentTime) {
speed: animData.currentSpeed
});
//
if (animData.pathHistory.length > PATH_PREDICTION_POINTS + 4) {
// 10
if (animData.pathHistory.length > 10) {
animData.pathHistory.shift();
}
@ -291,92 +200,46 @@ function updateVehiclePathHistory(id, animData, currentTime) {
}
}
// 使
// 使 -
function ensureContinuousMovement(animData, vehicle, currentTime, deltaTime) {
// 使
const timeSinceLastUpdate = currentTime - animData.lastUpdated;
// 0
//
if (timeSinceLastUpdate > CONTINUOUS_MOVEMENT_THRESHOLD && animData.currentSpeed > 0) {
// 使
if (PATH_PREDICTION_ENABLED && animData.pathHistory && animData.pathHistory.length >= 3) {
const predictedPath = predictFuturePath(animData);
if (predictedPath) {
//
if (!animData.predictionVector) {
animData.predictionVector = predictedPath.vector;
} else {
// 使
animData.predictionVector = [
animData.predictionVector[0] * 0.8 + predictedPath.vector[0] * 0.2, // 0.7/0.30.8/0.2
animData.predictionVector[1] * 0.8 + predictedPath.vector[1] * 0.2
];
}
// 使
animData.targetPosition = [
animData.targetPosition[0] * 0.8 + predictedPath.position[0] * 0.2, // 0.7/0.30.8/0.2
animData.targetPosition[1] * 0.8 + predictedPath.position[1] * 0.2
];
}
}
//
else if (!animData.predictionVector ||
(Math.abs(animData.predictionVector[0]) < 0.001 && Math.abs(animData.predictionVector[1]) < 0.001)) {
// 使
//
if (!animData.predictionVector) {
// 使
if (animData.pathHistory && animData.pathHistory.length >= 2) {
const latestPoints = animData.pathHistory.slice(-2);
const dx = latestPoints[1].position[0] - latestPoints[0].position[0];
const dy = latestPoints[1].position[1] - latestPoints[0].position[1];
const len = Math.sqrt(dx*dx + dy*dy);
const latest = animData.pathHistory[animData.pathHistory.length - 1];
const previous = animData.pathHistory[animData.pathHistory.length - 2];
if (len > 0.001) {
const normalizedDx = dx / len;
const normalizedDy = dy / len;
const predX = normalizedDx * animData.currentSpeed * 0.18;
const predY = normalizedDy * animData.currentSpeed * 0.18;
animData.predictionVector = [predX, predY];
//
animData.targetPosition = [
animData.position[0] + predX * 15,
animData.position[1] + predY * 15
];
const dx = latest.position[0] - previous.position[0];
const dy = latest.position[1] - previous.position[1];
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
//
animData.predictionVector = [dx / len, dy / len];
} else {
// 使
animData.predictionVector = [0, animData.currentSpeed * 0.18];
animData.targetPosition = [
animData.position[0],
animData.position[1] + animData.currentSpeed * 0.18 * 15
];
//
animData.predictionVector = [0, 1];
}
} else {
// 使
animData.predictionVector = [0, animData.currentSpeed * 0.18];
animData.targetPosition = [
animData.position[0],
animData.position[1] + animData.currentSpeed * 0.18 * 15
];
//
animData.predictionVector = [0, 1];
}
} else {
//
// 使
animData.predictionVector = [
animData.predictionVector[0] * PREDICTION_DECAY_RATE,
animData.predictionVector[1] * PREDICTION_DECAY_RATE
];
//
animData.targetPosition = [
animData.position[0] + animData.predictionVector[0] * 15, // 1215
animData.position[1] + animData.predictionVector[1] * 15
];
}
//
if (!animData.lastSpeedReduction || currentTime - animData.lastSpeedReduction > 1000) {
animData.speed = Math.max(animData.speed * 0.98, MIN_SPEED); // 0.970.98
//
const moveDistance = (animData.currentSpeed * 1000 / 3600) * 1.0; // 1
animData.targetPosition = [
animData.position[0] + animData.predictionVector[0] * moveDistance,
animData.position[1] + animData.predictionVector[1] * moveDistance
];
//
if (!animData.lastSpeedReduction || currentTime - animData.lastSpeedReduction > 2000) {
animData.speed = Math.max(animData.speed * 0.95, MIN_SPEED);
animData.lastSpeedReduction = currentTime;
}
}
@ -481,124 +344,48 @@ function initVehicleAnimation(id, coordinates, heading, speed) {
vehicleMotionHistory.value[id] = [];
}
//
// -
function updateVehicleAnimationTarget(id, coordinates, heading, speed) {
const animData = vehicleAnimations.value[id] || {};
const now = Date.now();
//
vehicleMotionHistory.value[id] = vehicleMotionHistory.value[id] || [];
vehicleMotionHistory.value[id].push({
time: now,
position: coordinates,
heading: heading, // heading
speed: speed
});
//
if (vehicleMotionHistory.value[id].length > 10) { // 810
vehicleMotionHistory.value[id].shift();
}
// - 使
//
let predictionVector = [0, 0];
if (vehicleMotionHistory.value[id].length >= 2) {
// - 使
const history = vehicleMotionHistory.value[id];
const latest = history[history.length - 1];
const previous = history[history.length - 2];
//
if (animData.position) {
const dx = coordinates[0] - animData.position[0];
const dy = coordinates[1] - animData.position[1];
const distance = Math.sqrt(dx * dx + dy * dy);
const dt = Math.max(1, latest.time - previous.time);
const dx = (latest.position[0] - previous.position[0]);
const dy = (latest.position[1] - previous.position[1]);
// -
const baseSpeed = Math.sqrt(dx*dx + dy*dy) * (1000 / dt); //
const normalizedDx = baseSpeed > 0 ? dx / baseSpeed : 0;
const normalizedDy = baseSpeed > 0 ? dy / baseSpeed : 0;
//
predictionVector = [
normalizedDx * (PREDICTION_STRENGTH * 0.9) * baseSpeed,
normalizedDy * (PREDICTION_STRENGTH * 0.9) * baseSpeed
];
// 使
if (vehicleMotionHistory.value[id].length >= 3) {
// 使
let totalWeight = 0;
let weightedDx = 0;
let weightedDy = 0;
// 使
for (let i = history.length - 2; i >= 0 && i >= history.length - 7; i--) { // 57
const curr = history[i+1];
const prev = history[i];
const pointDt = Math.max(1, curr.time - prev.time);
const pointDx = curr.position[0] - prev.position[0];
const pointDy = curr.position[1] - prev.position[1];
// 使
const weight = Math.pow(0.85, (history.length - i - 1));
totalWeight += weight;
weightedDx += pointDx * weight;
weightedDy += pointDy * weight;
}
if (totalWeight > 0) {
//
weightedDx /= totalWeight;
weightedDy /= totalWeight;
//
predictionVector = [
predictionVector[0] * 0.6 + weightedDx * PREDICTION_STRENGTH * 0.4, // 0.7/0.30.6/0.4
predictionVector[1] * 0.6 + weightedDy * PREDICTION_STRENGTH * 0.4
];
}
}
// 使线
const speedFactor = Math.min(1.8, Math.pow(speed / 15, 0.8)); // 使使线
predictionVector = [
predictionVector[0] * speedFactor,
predictionVector[1] * speedFactor
];
//
if (speed < 2) {
predictionVector = [
predictionVector[0] * 0.4, // 0.30.4
predictionVector[1] * 0.4
];
if (distance > 0.0001) {
//
predictionVector = [dx / distance, dy / distance];
}
}
//
//
if (!animData.speedHistory) {
animData.speedHistory = [speed, speed, speed, speed]; //
animData.speedHistory = [speed, speed, speed];
} else {
animData.speedHistory.push(speed);
if (animData.speedHistory.length > 4) { // 34
if (animData.speedHistory.length > 3) {
animData.speedHistory.shift();
}
}
//
//
const smoothedSpeed = animData.speedHistory.reduce((sum, s) => sum + s, 0) /
animData.speedHistory.length;
// -
//
vehicleAnimations.value[id] = {
...animData,
targetPosition: coordinates,
targetHeading: heading, // heading
heading: animData.heading || heading, // heading使heading
targetHeading: heading,
heading: animData.heading || heading,
position: animData.position || coordinates,
speed: smoothedSpeed, // 使
speed: smoothedSpeed,
lastUpdated: now,
predictionVector,
speedHistory: animData.speedHistory