feat RTK热力图绘制
This commit is contained in:
4
src/components.d.ts
vendored
4
src/components.d.ts
vendored
@@ -12,6 +12,10 @@ declare module 'vue' {
|
||||
DeviceCard: typeof import('./components/deviceCard.vue')['default']
|
||||
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
|
||||
FullscreenNav: typeof import('./components/FullscreenNav.vue')['default']
|
||||
HeatmapCanvas: typeof import('./components/heatmap/canvas.vue')['default']
|
||||
HeatmapLayerPanel: typeof import('./components/heatmap/layerPanel.vue')['default']
|
||||
HeatmapLegend: typeof import('./components/heatmap/legend.vue')['default']
|
||||
HeatmapPointDetail: typeof import('./components/heatmap/pointDetail.vue')['default']
|
||||
HomeConstruction: typeof import('./components/home/construction.vue')['default']
|
||||
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
|
||||
HomeList: typeof import('./components/home/list.vue')['default']
|
||||
|
||||
207
src/components/heatmap/canvas.vue
Normal file
207
src/components/heatmap/canvas.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeatmapLayerKey } from './heatUtils'
|
||||
/**
|
||||
* 压实热力图网格:
|
||||
* - 36 列固定;行数 = ceil(数据量 / 36),随数据渐进增加
|
||||
* - 每个数据占一格;格子间距固定一致
|
||||
* - 色阶:0–90 绿,90–95 黄,95–100 红
|
||||
*/
|
||||
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { createMockRtkStream } from '@/mock/rtkTrack'
|
||||
import { colorByScore, layerScore } from './heatUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
layer: HeatmapLayerKey
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectPoint: [point: MockRtkPoint]
|
||||
}>()
|
||||
|
||||
const instance = getCurrentInstance()?.proxy
|
||||
const CANVAS_ID = 'heatmapCanvas'
|
||||
const COLS = 36
|
||||
const GAP = 3
|
||||
const PADDING = 12
|
||||
const MIN_ROWS = 8
|
||||
const MIN_H = 200
|
||||
|
||||
const canvasHeight = ref(MIN_H)
|
||||
|
||||
interface HeatCell {
|
||||
point: MockRtkPoint
|
||||
score: number
|
||||
col: number
|
||||
row: number
|
||||
}
|
||||
|
||||
const cells: HeatCell[] = []
|
||||
let cssWidth = 0
|
||||
let cssHeight = MIN_H
|
||||
let ready = false
|
||||
let stream = createMockRtkStream({ intervalMs: 50, loop: false, rows: 40, pointsPerRow: 36 })
|
||||
|
||||
function rowCount() {
|
||||
if (!cells.length)
|
||||
return MIN_ROWS
|
||||
return Math.max(MIN_ROWS, Math.ceil(cells.length / COLS))
|
||||
}
|
||||
|
||||
function layoutMetrics() {
|
||||
const rows = rowCount()
|
||||
const innerW = Math.max(cssWidth - PADDING * 2, 1)
|
||||
const cellW = (innerW - GAP * (COLS - 1)) / COLS
|
||||
const cellH = cellW
|
||||
const contentH = PADDING * 2 + rows * cellH + GAP * Math.max(0, rows - 1)
|
||||
return { rows, cellW, cellH, contentH }
|
||||
}
|
||||
|
||||
function syncHeight() {
|
||||
if (cssWidth <= 0)
|
||||
return
|
||||
const { contentH } = layoutMetrics()
|
||||
const next = Math.max(MIN_H, Math.round(contentH))
|
||||
if (next !== cssHeight) {
|
||||
cssHeight = next
|
||||
canvasHeight.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function cellRect(col: number, row: number, cellW: number, cellH: number) {
|
||||
return {
|
||||
x: PADDING + col * (cellW + GAP),
|
||||
y: PADDING + row * (cellH + GAP),
|
||||
w: cellW,
|
||||
h: cellH,
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!ready || cssWidth <= 0)
|
||||
return
|
||||
|
||||
const ctx = uni.createCanvasContext(CANVAS_ID, instance as any)
|
||||
const { cellW, cellH } = layoutMetrics()
|
||||
|
||||
ctx.setFillStyle('#f8fafc')
|
||||
ctx.fillRect(0, 0, cssWidth, cssHeight)
|
||||
|
||||
for (const cell of cells) {
|
||||
const { x, y, w, h } = cellRect(cell.col, cell.row, cellW, cellH)
|
||||
ctx.setFillStyle(colorByScore(cell.score))
|
||||
ctx.fillRect(x, y, w, h)
|
||||
}
|
||||
|
||||
ctx.draw()
|
||||
}
|
||||
|
||||
function appendPoint(point: MockRtkPoint) {
|
||||
const index = cells.length
|
||||
cells.push({
|
||||
point,
|
||||
score: layerScore(props.layer, point),
|
||||
col: index % COLS,
|
||||
row: Math.floor(index / COLS),
|
||||
})
|
||||
syncHeight()
|
||||
nextTick(() => draw())
|
||||
}
|
||||
|
||||
function refreshScores() {
|
||||
for (const c of cells)
|
||||
c.score = layerScore(props.layer, c.point)
|
||||
draw()
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
stream.stop()
|
||||
stream = createMockRtkStream({ intervalMs: 50, loop: false, rows: 40, pointsPerRow: 36 })
|
||||
cells.length = 0
|
||||
syncHeight()
|
||||
draw()
|
||||
|
||||
stream.start((point) => {
|
||||
appendPoint(point)
|
||||
})
|
||||
}
|
||||
|
||||
function measureAndStart() {
|
||||
uni.createSelectorQuery()
|
||||
.in(instance as any)
|
||||
.select('.heatmap-canvas')
|
||||
.boundingClientRect((rect) => {
|
||||
const box = Array.isArray(rect) ? rect[0] : rect
|
||||
const w = Number(box?.width) || 0
|
||||
if (w <= 0) {
|
||||
setTimeout(() => measureAndStart(), 50)
|
||||
return
|
||||
}
|
||||
cssWidth = w
|
||||
ready = true
|
||||
startStream()
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
function onTap(e: any) {
|
||||
if (!cells.length || cssWidth <= 0)
|
||||
return
|
||||
|
||||
const x = Number(e?.detail?.x ?? e?.touches?.[0]?.x ?? 0)
|
||||
const y = Number(e?.detail?.y ?? e?.touches?.[0]?.y ?? 0)
|
||||
const { cellW, cellH } = layoutMetrics()
|
||||
|
||||
const col = Math.floor((x - PADDING) / (cellW + GAP))
|
||||
const row = Math.floor((y - PADDING) / (cellH + GAP))
|
||||
if (col < 0 || col >= COLS || row < 0)
|
||||
return
|
||||
|
||||
// 点落在 gap 上则忽略
|
||||
const localX = (x - PADDING) - col * (cellW + GAP)
|
||||
const localY = (y - PADDING) - row * (cellH + GAP)
|
||||
if (localX > cellW || localY > cellH)
|
||||
return
|
||||
|
||||
const found = cells.find(c => c.col === col && c.row === row)
|
||||
if (found)
|
||||
emit('selectPoint', found.point)
|
||||
}
|
||||
|
||||
watch(() => props.layer, () => refreshScores())
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => measureAndStart())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stream.stop()
|
||||
ready = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="heatmap-canvas" :style="{ height: `${canvasHeight}px` }" @tap="onTap">
|
||||
<canvas
|
||||
:id="CANVAS_ID"
|
||||
:canvas-id="CANVAS_ID"
|
||||
class="canvas"
|
||||
:style="{ width: '100%', height: `${canvasHeight}px` }"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.heatmap-canvas {
|
||||
width: 100%;
|
||||
/* background: #f8fafc; */
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
74
src/components/heatmap/heatUtils.ts
Normal file
74
src/components/heatmap/heatUtils.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** 热力图图层与色阶工具 */
|
||||
|
||||
export type HeatmapLayerKey = 'compaction' | 'speed' | 'temperature' | 'passCount'
|
||||
|
||||
export const HEATMAP_LAYERS: { key: HeatmapLayerKey, label: string }[] = [
|
||||
{ key: 'compaction', label: '压实度' },
|
||||
{ key: 'speed', label: '压实速度' },
|
||||
{ key: 'temperature', label: '温度' },
|
||||
{ key: 'passCount', label: '碾压遍数' },
|
||||
]
|
||||
|
||||
/** 色阶阈值:0–90 绿,90–95 黄,95–100 红 */
|
||||
export const HEAT_THRESHOLD_YELLOW = 90
|
||||
export const HEAT_THRESHOLD_RED = 95
|
||||
|
||||
export function colorByScore(score: number): string {
|
||||
if (score >= HEAT_THRESHOLD_RED)
|
||||
return '#ef4444'
|
||||
if (score >= HEAT_THRESHOLD_YELLOW)
|
||||
return '#eab308'
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
/** 各图层原始值映射到 0–100 分,统一用 90/95 分色 */
|
||||
export function layerScore(
|
||||
layer: HeatmapLayerKey,
|
||||
point: { compaction: number, speed: number, temperature: number, passCount: number },
|
||||
): number {
|
||||
switch (layer) {
|
||||
case 'compaction':
|
||||
return point.compaction
|
||||
case 'speed': {
|
||||
// 目标约 4~5 km/h 偏优,偏快偏慢降分
|
||||
const ideal = 4.2
|
||||
const diff = Math.abs(point.speed - ideal)
|
||||
return Math.max(0, Math.min(100, 100 - diff * 18))
|
||||
}
|
||||
case 'temperature': {
|
||||
// 目标约 150℃
|
||||
const ideal = 150
|
||||
const diff = Math.abs(point.temperature - ideal)
|
||||
return Math.max(0, Math.min(100, 100 - diff * 2.5))
|
||||
}
|
||||
case 'passCount':
|
||||
// 目标遍数 6
|
||||
return Math.max(0, Math.min(100, (point.passCount / 6) * 100))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function layerRawText(
|
||||
layer: HeatmapLayerKey,
|
||||
point: { compaction: number, speed: number, temperature: number, passCount: number },
|
||||
): string {
|
||||
switch (layer) {
|
||||
case 'compaction':
|
||||
return `${point.compaction}%`
|
||||
case 'speed':
|
||||
return `${point.speed}km/h`
|
||||
case 'temperature':
|
||||
return `${point.temperature}℃`
|
||||
case 'passCount':
|
||||
return `${point.passCount}遍`
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
export function headingText(heading: number): string {
|
||||
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
const idx = Math.round(heading / 45) % 8
|
||||
return `${dirs[idx]} ${heading.toFixed(1)}°`
|
||||
}
|
||||
106
src/components/heatmap/layerPanel.vue
Normal file
106
src/components/heatmap/layerPanel.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeatmapLayerKey } from './heatUtils'
|
||||
import { HEATMAP_LAYERS } from './heatUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: HeatmapLayerKey
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [key: HeatmapLayerKey]
|
||||
'update:visible': [v: boolean]
|
||||
}>()
|
||||
|
||||
function select(key: HeatmapLayerKey) {
|
||||
emit('update:modelValue', key)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
emit('update:visible', !props.visible)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="layer-wrap">
|
||||
<view class="layer-btn" @tap.stop="toggle">
|
||||
<text class="layer-btn-text">
|
||||
图层切换
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="visible" class="layer-panel" @tap.stop>
|
||||
<text class="panel-title">
|
||||
图层切换
|
||||
</text>
|
||||
<view
|
||||
v-for="item in HEATMAP_LAYERS"
|
||||
:key="item.key"
|
||||
class="layer-item"
|
||||
:class="{ active: modelValue === item.key }"
|
||||
@tap="select(item.key)"
|
||||
>
|
||||
<text class="layer-item-text">
|
||||
{{ item.label }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layer-wrap {
|
||||
position: relative;
|
||||
/* z-index: 20; */
|
||||
}
|
||||
|
||||
.layer-btn {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.layer-btn-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layer-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 10px 0;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: block;
|
||||
padding: 4px 16px 10px;
|
||||
font-size: 13px;
|
||||
/* color: #999; */
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.layer-item.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.layer-item-text {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.layer-item.active .layer-item-text {
|
||||
color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
118
src/components/heatmap/legend.vue
Normal file
118
src/components/heatmap/legend.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { HeatmapLayerKey } from './heatUtils'
|
||||
import { computed } from 'vue'
|
||||
import { HEATMAP_LAYERS } from './heatUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
layer: HeatmapLayerKey
|
||||
}>()
|
||||
|
||||
const title = computed(() => {
|
||||
const found = HEATMAP_LAYERS.find(i => i.key === props.layer)
|
||||
return `${found?.label || '压实度'}色阶`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="legend">
|
||||
<text class="legend-title">
|
||||
{{ title }}
|
||||
</text>
|
||||
<view class="bar-row">
|
||||
<view class="bar">
|
||||
<view class="seg seg-green" />
|
||||
<view class="seg seg-yellow" />
|
||||
<view class="seg seg-red" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="marks">
|
||||
<text class="mark">
|
||||
0
|
||||
</text>
|
||||
<text class="mark mark-90">
|
||||
90
|
||||
</text>
|
||||
<text class="mark mark-95">
|
||||
95
|
||||
</text>
|
||||
<text class="mark">
|
||||
100
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legend {
|
||||
padding: 12px 4px 4px;
|
||||
}
|
||||
|
||||
.legend-title {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seg {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.seg-green {
|
||||
flex: 90;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.seg-yellow {
|
||||
flex: 5;
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.seg-red {
|
||||
flex: 5;
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.marks {
|
||||
position: relative;
|
||||
height: 18px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
position: absolute;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.mark:first-child {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mark:last-child {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mark-90 {
|
||||
left: 90%;
|
||||
}
|
||||
|
||||
.mark-95 {
|
||||
left: 95%;
|
||||
}
|
||||
</style>
|
||||
169
src/components/heatmap/pointDetail.vue
Normal file
169
src/components/heatmap/pointDetail.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||
import { headingText } from './heatUtils'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
point: MockRtkPoint | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="visible && point" class="mask" @tap="emit('close')">
|
||||
<view class="card" @tap.stop>
|
||||
<view class="card-head">
|
||||
<view class="pin" />
|
||||
<view class="head-text">
|
||||
<text class="title">
|
||||
点位详情
|
||||
</text>
|
||||
<text class="sub">
|
||||
点击地图任意位置查看
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
点位坐标
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ point.longitude.toFixed(4) }}°E, {{ point.latitude.toFixed(4) }}°N
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
航向角
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ headingText(point.heading) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
压实速度
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ point.speed }}km/h
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
压实度
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ point.compaction }}%
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
温度
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ point.temperature }}℃
|
||||
</text>
|
||||
</view>
|
||||
<view class="row">
|
||||
<text class="label">
|
||||
碾压遍数
|
||||
</text>
|
||||
<text class="value">
|
||||
{{ point.passCount }}遍
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<text class="hint">
|
||||
点击空白处关闭
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.pin {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50% 50% 50% 0;
|
||||
background: #2563eb;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.head-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
text-align: right;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -7,10 +7,20 @@ import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref, watch } from
|
||||
import { useCurrentProject } from '@/composables/useCurrentProject'
|
||||
import { createMockRtkStream } from '@/mock/rtkTrack'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** canvas-id,同页多实例时需唯一 */
|
||||
canvasId?: string
|
||||
/** 是否点击跳转全屏 RTK 页(全屏页内应关闭) */
|
||||
navigateOnClick?: boolean
|
||||
}>(), {
|
||||
canvasId: 'rtkTrackCanvas',
|
||||
navigateOnClick: true,
|
||||
})
|
||||
|
||||
const { hasCurrentProject } = useCurrentProject()
|
||||
const instance = getCurrentInstance()?.proxy
|
||||
|
||||
const CANVAS_ID = 'rtkTrackCanvas'
|
||||
const CANVAS_ID = props.canvasId
|
||||
const PADDING = 28
|
||||
const ROLLER_WIDTH = 14
|
||||
const MIN_H = 160
|
||||
@@ -31,6 +41,8 @@ let fixedMaxLat = 0 // 北端固定(起点一侧)
|
||||
let pxPerMeter = 1
|
||||
|
||||
function goToRtk() {
|
||||
if (!props.navigateOnClick)
|
||||
return
|
||||
uni.navigateTo({ url: '/pages/rtk/index' })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* RTK 轨迹假数据:蛇形碾压路径 + 持续推送
|
||||
* 字段:经纬度、航向角(度,0=北)、压实度
|
||||
* 字段:经纬度、航向、压实度、速度、温度、碾压遍数
|
||||
*/
|
||||
|
||||
export interface MockRtkPoint {
|
||||
latitude: number
|
||||
longitude: number
|
||||
heading: number
|
||||
compaction: number
|
||||
latitude: number // 纬度
|
||||
longitude: number // 经度
|
||||
heading: number // 航向角(度,0=北,顺时针)
|
||||
compaction: number // 压实度 %
|
||||
speed: number // 压实速度 km/h
|
||||
temperature: number // 温度 ℃
|
||||
passCount: number // 碾压遍数
|
||||
}
|
||||
|
||||
export interface MockRtkStreamOptions {
|
||||
@@ -40,6 +43,35 @@ function makeCompaction(seed: number) {
|
||||
return Number(Math.min(98, Math.max(78, 88 + wave + (Math.random() - 0.5) * 3)).toFixed(1))
|
||||
}
|
||||
|
||||
function makeSpeed(seed: number) {
|
||||
return Number((3.2 + Math.sin(seed / 5) * 1.2 + Math.random() * 0.8).toFixed(1))
|
||||
}
|
||||
|
||||
function makeTemperature(seed: number) {
|
||||
return Number((148 + Math.sin(seed / 7) * 8 + (Math.random() - 0.5) * 6).toFixed(0))
|
||||
}
|
||||
|
||||
function makePassCount(seed: number) {
|
||||
return Math.max(1, Math.min(8, Math.round(2 + (seed % 9) / 2 + Math.random())))
|
||||
}
|
||||
|
||||
function buildPoint(
|
||||
lat: number,
|
||||
lon: number,
|
||||
heading: number,
|
||||
seed: number,
|
||||
): MockRtkPoint {
|
||||
return {
|
||||
latitude: Number(lat.toFixed(7)),
|
||||
longitude: Number(lon.toFixed(7)),
|
||||
heading,
|
||||
compaction: makeCompaction(seed),
|
||||
speed: makeSpeed(seed),
|
||||
temperature: makeTemperature(seed),
|
||||
passCount: makePassCount(seed),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 行末三角掉头:行末 → 外侧顶点 → 下行起点
|
||||
* 航向先转 60°(到顶点),再转 120°(合计 180° 衔接下行)
|
||||
@@ -55,30 +87,25 @@ function pushUTurnTriangle(
|
||||
) {
|
||||
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++),
|
||||
})
|
||||
points.push(buildPoint(
|
||||
midLat,
|
||||
tipLon,
|
||||
normalizeHeading(fromHeading + 60 * turnSign),
|
||||
seedRef.value++,
|
||||
))
|
||||
points.push(buildPoint(
|
||||
nextLat,
|
||||
startLon,
|
||||
normalizeHeading(fromHeading + 180 * turnSign),
|
||||
seedRef.value++,
|
||||
))
|
||||
}
|
||||
|
||||
/** 生成类似示意图的左右蛇形碾压路径 */
|
||||
@@ -86,11 +113,10 @@ export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): Moc
|
||||
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 lonStep = 0.00012
|
||||
const latStep = -0.000045
|
||||
|
||||
const points: MockRtkPoint[] = []
|
||||
const seedRef = { value: 0 }
|
||||
@@ -103,15 +129,9 @@ export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): Moc
|
||||
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++),
|
||||
})
|
||||
points.push(buildPoint(lat, lon, heading, seedRef.value++))
|
||||
}
|
||||
|
||||
// 行末三角掉头(外凸尖角)
|
||||
if (row < rows - 1) {
|
||||
const endT = eastward ? (pointsPerRow - 1) : 0
|
||||
const endLon = originLon + endT * lonStep
|
||||
|
||||
@@ -1,23 +1,66 @@
|
||||
<script lang="ts" setup>
|
||||
import type { HeatmapLayerKey } from '@/components/heatmap/heatUtils'
|
||||
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||
import { ref } from 'vue'
|
||||
|
||||
definePage({
|
||||
layout: false,
|
||||
style: {
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
const layer = ref<HeatmapLayerKey>('compaction')
|
||||
const layerVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const selectedPoint = ref<MockRtkPoint | null>(null)
|
||||
|
||||
function onSelectPoint(point: MockRtkPoint) {
|
||||
selectedPoint.value = point
|
||||
detailVisible.value = true
|
||||
layerVisible.value = false
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailVisible.value = false
|
||||
}
|
||||
|
||||
function onBlankTap() {
|
||||
if (layerVisible.value)
|
||||
layerVisible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部导航 -->
|
||||
<view class="page" @tap="onBlankTap">
|
||||
<FullscreenNav />
|
||||
|
||||
<!-- 占位内容 -->
|
||||
<view class="content">
|
||||
<text class="placeholder-text">
|
||||
压实热力图
|
||||
</text>
|
||||
</view>
|
||||
<scroll-view class="content" scroll-y>
|
||||
<view class="card" @tap.stop>
|
||||
<view class="toolbar">
|
||||
<text class="tip">
|
||||
点击地图查看点位详情
|
||||
</text>
|
||||
<heatmap-layerPanel
|
||||
v-model="layer"
|
||||
v-model:visible="layerVisible"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<heatmap-canvas
|
||||
:layer="layer"
|
||||
@select-point="onSelectPoint"
|
||||
/>
|
||||
|
||||
<heatmap-legend :layer="layer" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<heatmap-pointDetail
|
||||
:visible="detailVisible"
|
||||
:point="selectedPoint"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -25,19 +68,38 @@ definePage({
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: #EAEFF9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 12px 16px;
|
||||
padding-top: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
/* background: #f8fafc; */
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
definePage({
|
||||
layout: false,
|
||||
style: {
|
||||
// navigationBarTitleText: 'RTK轨迹',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
@@ -10,15 +9,17 @@ definePage({
|
||||
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 顶部导航 -->
|
||||
<FullscreenNav />
|
||||
|
||||
<!-- 占位内容 -->
|
||||
<view class="content">
|
||||
<text class="placeholder-text">
|
||||
RTK轨迹
|
||||
</text>
|
||||
</view>
|
||||
<!-- 放大展示与作业页相同的 RTK 轨迹绘制 -->
|
||||
<scroll-view class="content" scroll-y>
|
||||
<view class="canvas-card">
|
||||
<work-rtkCanvas
|
||||
canvas-id="rtkTrackCanvasFull"
|
||||
:navigate-on-click="false"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -26,19 +27,24 @@ definePage({
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background-color: #EAEFF9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 12px 16px;
|
||||
padding-top: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
.canvas-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
/* 全屏宽度绘制 → 比例尺更大,视觉上放大 */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user