feat RTK轨迹绘制
This commit is contained in:
@@ -1,23 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 固定比例尺绘制:轨迹变长只加高画布/白卡,图案不缩小
|
||||
*/
|
||||
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||
import { createMockRtkStream } from '@/mock/rtkTrack'
|
||||
|
||||
const { hasCurrentProject } = useCurrentProject()
|
||||
const instance = getCurrentInstance()?.proxy
|
||||
|
||||
const CANVAS_ID = 'rtkTrackCanvas'
|
||||
const PADDING = 28
|
||||
const ROLLER_WIDTH = 14
|
||||
const MIN_H = 160
|
||||
|
||||
const canvasHeight = ref(MIN_H)
|
||||
const trail: MockRtkPoint[] = []
|
||||
let finished = false
|
||||
let cssWidth = 0
|
||||
let cssHeight = MIN_H
|
||||
let ready = false
|
||||
let initTries = 0
|
||||
let stream = createMockRtkStream({ intervalMs: 80, loop: true, rows: 80, pointsPerRow: 40 })
|
||||
|
||||
/** 固定东西向范围与比例尺(米/像素),避免越画越小 */
|
||||
let fixedMinLon = 0
|
||||
let fixedMaxLon = 0
|
||||
let fixedMaxLat = 0 // 北端固定(起点一侧)
|
||||
let pxPerMeter = 1
|
||||
|
||||
function goToRtk() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/rtk/index',
|
||||
uni.navigateTo({ url: '/pages/rtk/index' })
|
||||
}
|
||||
|
||||
function compactionColor(value: number) {
|
||||
const t = Math.min(1, Math.max(0, (value - 75) / 25))
|
||||
const r = Math.round(200 - t * 90)
|
||||
const g = Math.round(230 - t * 40)
|
||||
const b = Math.round(170 - t * 70)
|
||||
return `rgba(${r}, ${g}, ${b}, 0.75)`
|
||||
}
|
||||
|
||||
function metersPerDeg(lat: number) {
|
||||
return {
|
||||
lat: 111320,
|
||||
lon: 111320 * Math.cos((lat * Math.PI) / 180),
|
||||
}
|
||||
}
|
||||
|
||||
/** 用完整假路径定死东西跨度与比例尺;高度只随已画南北跨度增加 */
|
||||
function initFixedScale(allPoints: MockRtkPoint[]) {
|
||||
if (!allPoints.length || cssWidth <= 0)
|
||||
return
|
||||
|
||||
let minLat = Infinity
|
||||
let maxLat = -Infinity
|
||||
let minLon = Infinity
|
||||
let maxLon = -Infinity
|
||||
for (const p of allPoints) {
|
||||
minLat = Math.min(minLat, p.latitude)
|
||||
maxLat = Math.max(maxLat, p.latitude)
|
||||
minLon = Math.min(minLon, p.longitude)
|
||||
maxLon = Math.max(maxLon, p.longitude)
|
||||
}
|
||||
const padLon = (maxLon - minLon) * 0.08 || 0.00001
|
||||
fixedMinLon = minLon - padLon
|
||||
fixedMaxLon = maxLon + padLon
|
||||
fixedMaxLat = maxLat + ((maxLat - minLat) * 0.08 || 0.00001)
|
||||
|
||||
const midLat = (minLat + maxLat) / 2
|
||||
const { lon: mLon } = metersPerDeg(midLat)
|
||||
const lonSpanM = Math.max((fixedMaxLon - fixedMinLon) * mLon, 1e-3)
|
||||
pxPerMeter = (cssWidth - PADDING * 2) / lonSpanM
|
||||
}
|
||||
|
||||
function trailMinLat() {
|
||||
let minLat = fixedMaxLat
|
||||
for (const p of trail)
|
||||
minLat = Math.min(minLat, p.latitude)
|
||||
const pad = 0.00001
|
||||
return minLat - pad
|
||||
}
|
||||
|
||||
/** 按已绘制南北跨度撑高(比例尺不变) */
|
||||
function updateHeightFromTrail() {
|
||||
if (cssWidth <= 0 || !trail.length || pxPerMeter <= 0)
|
||||
return
|
||||
|
||||
const minLat = trailMinLat()
|
||||
const { lat: mLat } = metersPerDeg((fixedMaxLat + minLat) / 2)
|
||||
const latSpanM = Math.max((fixedMaxLat - minLat) * mLat, 0)
|
||||
const next = Math.max(MIN_H, Math.round(PADDING * 2 + latSpanM * pxPerMeter))
|
||||
|
||||
if (next !== cssHeight) {
|
||||
cssHeight = next
|
||||
canvasHeight.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function toXY(point: MockRtkPoint) {
|
||||
const midLat = (fixedMaxLat + point.latitude) / 2
|
||||
const { lat: mLat, lon: mLon } = metersPerDeg(midLat)
|
||||
return {
|
||||
x: PADDING + (point.longitude - fixedMinLon) * mLon * pxPerMeter,
|
||||
y: PADDING + (fixedMaxLat - point.latitude) * mLat * pxPerMeter,
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!ready || cssWidth <= 0)
|
||||
return
|
||||
|
||||
const ctx = uni.createCanvasContext(CANVAS_ID, instance as any)
|
||||
const path = trail
|
||||
|
||||
ctx.setFillStyle('#ffffff')
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight)
|
||||
|
||||
if (!path.length) {
|
||||
ctx.draw()
|
||||
return
|
||||
}
|
||||
|
||||
const coords = path.map(p => toXY(p))
|
||||
|
||||
ctx.setLineCap('round')
|
||||
ctx.setLineJoin('round')
|
||||
ctx.setLineWidth(ROLLER_WIDTH)
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
ctx.beginPath()
|
||||
ctx.setStrokeStyle(compactionColor(path[i].compaction))
|
||||
ctx.moveTo(coords[i - 1].x, coords[i - 1].y)
|
||||
ctx.lineTo(coords[i].x, coords[i].y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.setLineWidth(2.5)
|
||||
ctx.setStrokeStyle('#2563eb')
|
||||
coords.forEach((c, i) => {
|
||||
if (i === 0)
|
||||
ctx.moveTo(c.x, c.y)
|
||||
else
|
||||
ctx.lineTo(c.x, c.y)
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
const start = coords[0]
|
||||
ctx.beginPath()
|
||||
ctx.setFillStyle('#22c55e')
|
||||
ctx.arc(start.x, start.y, 10, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.setFillStyle('#166534')
|
||||
ctx.setFontSize(10)
|
||||
ctx.setTextAlign('center')
|
||||
ctx.fillText('起点', start.x, start.y - 14)
|
||||
|
||||
const last = coords[coords.length - 1]
|
||||
const cur = path[path.length - 1]
|
||||
if (finished) {
|
||||
ctx.beginPath()
|
||||
ctx.setFillStyle('#ef4444')
|
||||
ctx.moveTo(last.x, last.y - 14)
|
||||
ctx.lineTo(last.x + 10, last.y)
|
||||
ctx.lineTo(last.x, last.y + 4)
|
||||
ctx.lineTo(last.x - 10, last.y)
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
ctx.setFillStyle('#991b1b')
|
||||
ctx.fillText('结束', last.x, last.y + 18)
|
||||
}
|
||||
else {
|
||||
ctx.beginPath()
|
||||
ctx.setFillStyle('#f97316')
|
||||
ctx.arc(last.x, last.y, 7, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
const heading = (cur.heading * Math.PI) / 180
|
||||
ctx.beginPath()
|
||||
ctx.setStrokeStyle('#ea580c')
|
||||
ctx.setLineWidth(2)
|
||||
ctx.moveTo(last.x, last.y)
|
||||
ctx.lineTo(last.x + Math.sin(heading) * 12, last.y - Math.cos(heading) * 12)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
function measureAndReady(onReady?: () => void) {
|
||||
uni.createSelectorQuery()
|
||||
.in(instance as any)
|
||||
.select('.rtk-canvas')
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
const w = Number(box?.width) || 0
|
||||
if (w <= 0) {
|
||||
initTries += 1
|
||||
if (initTries < 20)
|
||||
setTimeout(() => measureAndReady(onReady), 50)
|
||||
return
|
||||
}
|
||||
|
||||
cssWidth = w
|
||||
cssHeight = MIN_H
|
||||
canvasHeight.value = MIN_H
|
||||
ready = true
|
||||
onReady?.()
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
stream.stop()
|
||||
stream = createMockRtkStream({ intervalMs: 80, loop: true, rows: 80, pointsPerRow: 40 })
|
||||
trail.length = 0
|
||||
finished = false
|
||||
initFixedScale(stream.points)
|
||||
cssHeight = MIN_H
|
||||
canvasHeight.value = MIN_H
|
||||
draw()
|
||||
|
||||
stream.start((point, index) => {
|
||||
trail.push(point)
|
||||
finished = index >= stream.points.length - 1
|
||||
updateHeightFromTrail()
|
||||
nextTick(() => draw())
|
||||
})
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
stream.stop()
|
||||
trail.length = 0
|
||||
finished = false
|
||||
cssHeight = MIN_H
|
||||
canvasHeight.value = MIN_H
|
||||
if (ready)
|
||||
draw()
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (!hasCurrentProject.value) {
|
||||
stopStream()
|
||||
return
|
||||
}
|
||||
initTries = 0
|
||||
measureAndReady(() => startStream())
|
||||
}
|
||||
|
||||
watch(hasCurrentProject, () => nextTick(() => boot()))
|
||||
onMounted(() => nextTick(() => boot()))
|
||||
onUnmounted(() => stream.stop())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="rtk-canvas" @click="goToRtk">
|
||||
<view class="canvas-placeholder">
|
||||
<view class="rtk-canvas" :style="{ height: `${canvasHeight}px` }" @click="goToRtk">
|
||||
<canvas
|
||||
v-if="hasCurrentProject"
|
||||
:id="CANVAS_ID"
|
||||
:canvas-id="CANVAS_ID"
|
||||
class="track-canvas"
|
||||
:style="{ width: '100%', height: `${canvasHeight}px` }"
|
||||
/>
|
||||
<view v-else class="canvas-placeholder">
|
||||
<text class="placeholder-text">
|
||||
{{ hasCurrentProject ? '轨迹动画区域' : '--' }}
|
||||
</text>
|
||||
<text v-if="hasCurrentProject" class="placeholder-hint">
|
||||
(Canvas 绘制行驶轨迹 + 压实热力图)
|
||||
--
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -25,30 +273,30 @@ function goToRtk() {
|
||||
|
||||
<style scoped>
|
||||
.rtk-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
background: #f0fdf4;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.placeholder-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
// RTK轨迹实时监测 + 压实热力图 - 父组件(组合头部 + 画布 + 底部)
|
||||
// RTK轨迹实时监测:整卡高度随画布内容撑开(不要 min-height:100% 锁死视口)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="card">
|
||||
<!-- 状态头部 -->
|
||||
<work-rtkHeader />
|
||||
|
||||
<!-- 轨迹画布 -->
|
||||
<work-rtkCanvas />
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<work-rtkFooter />
|
||||
<work-rtkHeader class="card-header" />
|
||||
<work-rtkCanvas class="card-canvas" />
|
||||
<work-rtkFooter class="card-footer" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
/* 高度完全跟内容走,轨迹变长整卡变高 */
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.card-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-canvas {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import formattedDate from './time'
|
||||
|
||||
export { createMockRtkStream, generateSerpentineTrack } from './rtkTrack'
|
||||
export type { MockRtkPoint, MockRtkStream, MockRtkStreamOptions } from './rtkTrack'
|
||||
|
||||
export const mockData = {
|
||||
time: formattedDate,
|
||||
message: 'Hello, this is mock data!',
|
||||
|
||||
171
src/mock/rtkTrack.ts
Normal file
171
src/mock/rtkTrack.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* RTK 轨迹假数据:蛇形碾压路径 + 持续推送
|
||||
* 字段:经纬度、航向角(度,0=北)、压实度
|
||||
*/
|
||||
|
||||
export interface MockRtkPoint {
|
||||
latitude: number
|
||||
longitude: number
|
||||
heading: number
|
||||
compaction: number
|
||||
}
|
||||
|
||||
export interface MockRtkStreamOptions {
|
||||
/** 推送间隔 ms,默认 120 */
|
||||
intervalMs?: number
|
||||
/** 结束后是否循环,默认 true */
|
||||
loop?: boolean
|
||||
/** 横向行数,默认 80(约 10 倍于早期 8 行) */
|
||||
rows?: number
|
||||
/** 每行采样点数,默认 40 */
|
||||
pointsPerRow?: number
|
||||
}
|
||||
|
||||
export interface MockRtkStream {
|
||||
/** 完整路径(用于画布定界) */
|
||||
readonly points: MockRtkPoint[]
|
||||
start: (onPoint: (point: MockRtkPoint, index: number) => void) => void
|
||||
stop: () => void
|
||||
reset: () => void
|
||||
getIndex: () => number
|
||||
}
|
||||
|
||||
/** 航向归一化到 [0, 360) */
|
||||
function normalizeHeading(deg: number) {
|
||||
return ((deg % 360) + 360) % 360
|
||||
}
|
||||
|
||||
function makeCompaction(seed: number) {
|
||||
const wave = Math.sin(seed / 4) * 4
|
||||
return Number(Math.min(98, Math.max(78, 88 + wave + (Math.random() - 0.5) * 3)).toFixed(1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 行末三角掉头:行末 → 外侧顶点 → 下行起点
|
||||
* 航向先转 60°(到顶点),再转 120°(合计 180° 衔接下行)
|
||||
*/
|
||||
function pushUTurnTriangle(
|
||||
points: MockRtkPoint[],
|
||||
startLat: number,
|
||||
startLon: number,
|
||||
fromHeading: number,
|
||||
latStep: number,
|
||||
eastward: boolean,
|
||||
seedRef: { value: number },
|
||||
) {
|
||||
const nextLat = startLat + latStep
|
||||
const midLat = startLat + latStep / 2
|
||||
// 外侧突出量 ≈ 半个行距(换算到经度),形成尖角
|
||||
const tipLatOffset = Math.abs(latStep) / 2
|
||||
const metersPerDegLat = 111320
|
||||
const metersPerDegLon = 111320 * Math.cos((startLat * Math.PI) / 180)
|
||||
const tipLonOffset = (tipLatOffset * metersPerDegLat) / metersPerDegLon
|
||||
const tipLon = startLon + (eastward ? tipLonOffset : -tipLonOffset)
|
||||
|
||||
const turnSign = eastward ? 1 : -1
|
||||
|
||||
// 1) 外侧顶点:航向 +60° / -60°
|
||||
points.push({
|
||||
latitude: Number(midLat.toFixed(7)),
|
||||
longitude: Number(tipLon.toFixed(7)),
|
||||
heading: normalizeHeading(fromHeading + 60 * turnSign),
|
||||
compaction: makeCompaction(seedRef.value++),
|
||||
})
|
||||
|
||||
// 2) 下行起点:航向转满 180°
|
||||
points.push({
|
||||
latitude: Number(nextLat.toFixed(7)),
|
||||
longitude: Number(startLon.toFixed(7)),
|
||||
heading: normalizeHeading(fromHeading + 180 * turnSign),
|
||||
compaction: makeCompaction(seedRef.value++),
|
||||
})
|
||||
}
|
||||
|
||||
/** 生成类似示意图的左右蛇形碾压路径 */
|
||||
export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): MockRtkPoint[] {
|
||||
const rows = options.rows ?? 80
|
||||
const pointsPerRow = options.pointsPerRow ?? 40
|
||||
|
||||
// 以雄安附近坐标为基准,量级约几十米行程
|
||||
const originLat = 39.0425
|
||||
const originLon = 115.8620
|
||||
const lonStep = 0.00012 // ~10m 东向
|
||||
const latStep = -0.000045 // ~5m 南向(行间距)
|
||||
|
||||
const points: MockRtkPoint[] = []
|
||||
const seedRef = { value: 0 }
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const eastward = row % 2 === 0
|
||||
const heading = eastward ? 90 : 270
|
||||
const lat = originLat + row * latStep
|
||||
|
||||
for (let col = 0; col < pointsPerRow; col++) {
|
||||
const t = eastward ? col : (pointsPerRow - 1 - col)
|
||||
const lon = originLon + t * lonStep
|
||||
points.push({
|
||||
latitude: Number(lat.toFixed(7)),
|
||||
longitude: Number(lon.toFixed(7)),
|
||||
heading,
|
||||
compaction: makeCompaction(seedRef.value++),
|
||||
})
|
||||
}
|
||||
|
||||
// 行末三角掉头(外凸尖角)
|
||||
if (row < rows - 1) {
|
||||
const endT = eastward ? (pointsPerRow - 1) : 0
|
||||
const endLon = originLon + endT * lonStep
|
||||
pushUTurnTriangle(points, lat, endLon, heading, latStep, eastward, seedRef)
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
/** 持续推送假轨迹点 */
|
||||
export function createMockRtkStream(options: MockRtkStreamOptions = {}): MockRtkStream {
|
||||
const points = generateSerpentineTrack(options)
|
||||
const intervalMs = options.intervalMs ?? 120
|
||||
const loop = options.loop ?? true
|
||||
|
||||
let index = 0
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stop() {
|
||||
if (timer != null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop()
|
||||
index = 0
|
||||
}
|
||||
|
||||
function start(onPoint: (point: MockRtkPoint, index: number) => void) {
|
||||
stop()
|
||||
timer = setInterval(() => {
|
||||
if (index >= points.length) {
|
||||
if (loop) {
|
||||
index = 0
|
||||
}
|
||||
else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
const point = points[index]
|
||||
onPoint(point, index)
|
||||
index += 1
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
return {
|
||||
points,
|
||||
start,
|
||||
stop,
|
||||
reset,
|
||||
getIndex: () => index,
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,12 @@ definePage({
|
||||
padding-top: 72px;
|
||||
}
|
||||
|
||||
/* 双列布局 */
|
||||
/* 双列:最小一屏,内容变高时整页跟着撑开可滚动 */
|
||||
.two-column {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
height: calc(100vh - 84px);
|
||||
min-height: calc(100vh - 84px);
|
||||
}
|
||||
|
||||
.column-left {
|
||||
@@ -52,11 +53,16 @@ definePage({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
}
|
||||
|
||||
.column-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 右侧白卡随轨迹变高,不锁成一屏高 */
|
||||
height: auto;
|
||||
align-self: stretch;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user