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']
|
DeviceCard: typeof import('./components/deviceCard.vue')['default']
|
||||||
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
|
DeviceInfo: typeof import('./components/deviceInfo.vue')['default']
|
||||||
FullscreenNav: typeof import('./components/FullscreenNav.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']
|
HomeConstruction: typeof import('./components/home/construction.vue')['default']
|
||||||
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
|
HomeHomeNav: typeof import('./components/home/homeNav.vue')['default']
|
||||||
HomeList: typeof import('./components/home/list.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 { useCurrentProject } from '@/composables/useCurrentProject'
|
||||||
import { createMockRtkStream } from '@/mock/rtkTrack'
|
import { createMockRtkStream } from '@/mock/rtkTrack'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** canvas-id,同页多实例时需唯一 */
|
||||||
|
canvasId?: string
|
||||||
|
/** 是否点击跳转全屏 RTK 页(全屏页内应关闭) */
|
||||||
|
navigateOnClick?: boolean
|
||||||
|
}>(), {
|
||||||
|
canvasId: 'rtkTrackCanvas',
|
||||||
|
navigateOnClick: true,
|
||||||
|
})
|
||||||
|
|
||||||
const { hasCurrentProject } = useCurrentProject()
|
const { hasCurrentProject } = useCurrentProject()
|
||||||
const instance = getCurrentInstance()?.proxy
|
const instance = getCurrentInstance()?.proxy
|
||||||
|
|
||||||
const CANVAS_ID = 'rtkTrackCanvas'
|
const CANVAS_ID = props.canvasId
|
||||||
const PADDING = 28
|
const PADDING = 28
|
||||||
const ROLLER_WIDTH = 14
|
const ROLLER_WIDTH = 14
|
||||||
const MIN_H = 160
|
const MIN_H = 160
|
||||||
@@ -31,6 +41,8 @@ let fixedMaxLat = 0 // 北端固定(起点一侧)
|
|||||||
let pxPerMeter = 1
|
let pxPerMeter = 1
|
||||||
|
|
||||||
function goToRtk() {
|
function goToRtk() {
|
||||||
|
if (!props.navigateOnClick)
|
||||||
|
return
|
||||||
uni.navigateTo({ url: '/pages/rtk/index' })
|
uni.navigateTo({ url: '/pages/rtk/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* RTK 轨迹假数据:蛇形碾压路径 + 持续推送
|
* RTK 轨迹假数据:蛇形碾压路径 + 持续推送
|
||||||
* 字段:经纬度、航向角(度,0=北)、压实度
|
* 字段:经纬度、航向、压实度、速度、温度、碾压遍数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MockRtkPoint {
|
export interface MockRtkPoint {
|
||||||
latitude: number
|
latitude: number // 纬度
|
||||||
longitude: number
|
longitude: number // 经度
|
||||||
heading: number
|
heading: number // 航向角(度,0=北,顺时针)
|
||||||
compaction: number
|
compaction: number // 压实度 %
|
||||||
|
speed: number // 压实速度 km/h
|
||||||
|
temperature: number // 温度 ℃
|
||||||
|
passCount: number // 碾压遍数
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockRtkStreamOptions {
|
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))
|
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° 衔接下行)
|
* 航向先转 60°(到顶点),再转 120°(合计 180° 衔接下行)
|
||||||
@@ -55,30 +87,25 @@ function pushUTurnTriangle(
|
|||||||
) {
|
) {
|
||||||
const nextLat = startLat + latStep
|
const nextLat = startLat + latStep
|
||||||
const midLat = startLat + latStep / 2
|
const midLat = startLat + latStep / 2
|
||||||
// 外侧突出量 ≈ 半个行距(换算到经度),形成尖角
|
|
||||||
const tipLatOffset = Math.abs(latStep) / 2
|
const tipLatOffset = Math.abs(latStep) / 2
|
||||||
const metersPerDegLat = 111320
|
const metersPerDegLat = 111320
|
||||||
const metersPerDegLon = 111320 * Math.cos((startLat * Math.PI) / 180)
|
const metersPerDegLon = 111320 * Math.cos((startLat * Math.PI) / 180)
|
||||||
const tipLonOffset = (tipLatOffset * metersPerDegLat) / metersPerDegLon
|
const tipLonOffset = (tipLatOffset * metersPerDegLat) / metersPerDegLon
|
||||||
const tipLon = startLon + (eastward ? tipLonOffset : -tipLonOffset)
|
const tipLon = startLon + (eastward ? tipLonOffset : -tipLonOffset)
|
||||||
|
|
||||||
const turnSign = eastward ? 1 : -1
|
const turnSign = eastward ? 1 : -1
|
||||||
|
|
||||||
// 1) 外侧顶点:航向 +60° / -60°
|
points.push(buildPoint(
|
||||||
points.push({
|
midLat,
|
||||||
latitude: Number(midLat.toFixed(7)),
|
tipLon,
|
||||||
longitude: Number(tipLon.toFixed(7)),
|
normalizeHeading(fromHeading + 60 * turnSign),
|
||||||
heading: normalizeHeading(fromHeading + 60 * turnSign),
|
seedRef.value++,
|
||||||
compaction: makeCompaction(seedRef.value++),
|
))
|
||||||
})
|
points.push(buildPoint(
|
||||||
|
nextLat,
|
||||||
// 2) 下行起点:航向转满 180°
|
startLon,
|
||||||
points.push({
|
normalizeHeading(fromHeading + 180 * turnSign),
|
||||||
latitude: Number(nextLat.toFixed(7)),
|
seedRef.value++,
|
||||||
longitude: Number(startLon.toFixed(7)),
|
))
|
||||||
heading: normalizeHeading(fromHeading + 180 * turnSign),
|
|
||||||
compaction: makeCompaction(seedRef.value++),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 生成类似示意图的左右蛇形碾压路径 */
|
/** 生成类似示意图的左右蛇形碾压路径 */
|
||||||
@@ -86,11 +113,10 @@ export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): Moc
|
|||||||
const rows = options.rows ?? 80
|
const rows = options.rows ?? 80
|
||||||
const pointsPerRow = options.pointsPerRow ?? 40
|
const pointsPerRow = options.pointsPerRow ?? 40
|
||||||
|
|
||||||
// 以雄安附近坐标为基准,量级约几十米行程
|
|
||||||
const originLat = 39.0425
|
const originLat = 39.0425
|
||||||
const originLon = 115.8620
|
const originLon = 115.8620
|
||||||
const lonStep = 0.00012 // ~10m 东向
|
const lonStep = 0.00012
|
||||||
const latStep = -0.000045 // ~5m 南向(行间距)
|
const latStep = -0.000045
|
||||||
|
|
||||||
const points: MockRtkPoint[] = []
|
const points: MockRtkPoint[] = []
|
||||||
const seedRef = { value: 0 }
|
const seedRef = { value: 0 }
|
||||||
@@ -103,15 +129,9 @@ export function generateSerpentineTrack(options: MockRtkStreamOptions = {}): Moc
|
|||||||
for (let col = 0; col < pointsPerRow; col++) {
|
for (let col = 0; col < pointsPerRow; col++) {
|
||||||
const t = eastward ? col : (pointsPerRow - 1 - col)
|
const t = eastward ? col : (pointsPerRow - 1 - col)
|
||||||
const lon = originLon + t * lonStep
|
const lon = originLon + t * lonStep
|
||||||
points.push({
|
points.push(buildPoint(lat, lon, heading, seedRef.value++))
|
||||||
latitude: Number(lat.toFixed(7)),
|
|
||||||
longitude: Number(lon.toFixed(7)),
|
|
||||||
heading,
|
|
||||||
compaction: makeCompaction(seedRef.value++),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行末三角掉头(外凸尖角)
|
|
||||||
if (row < rows - 1) {
|
if (row < rows - 1) {
|
||||||
const endT = eastward ? (pointsPerRow - 1) : 0
|
const endT = eastward ? (pointsPerRow - 1) : 0
|
||||||
const endLon = originLon + endT * lonStep
|
const endLon = originLon + endT * lonStep
|
||||||
|
|||||||
@@ -1,23 +1,66 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { HeatmapLayerKey } from '@/components/heatmap/heatUtils'
|
||||||
|
import type { MockRtkPoint } from '@/mock/rtkTrack'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
layout: false,
|
layout: false,
|
||||||
style: {
|
style: {
|
||||||
navigationStyle: 'custom',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page" @tap="onBlankTap">
|
||||||
<!-- 顶部导航 -->
|
|
||||||
<FullscreenNav />
|
<FullscreenNav />
|
||||||
|
|
||||||
<!-- 占位内容 -->
|
<scroll-view class="content" scroll-y>
|
||||||
<view class="content">
|
<view class="card" @tap.stop>
|
||||||
<text class="placeholder-text">
|
<view class="toolbar">
|
||||||
压实热力图
|
<text class="tip">
|
||||||
</text>
|
点击地图查看点位详情
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,19 +68,38 @@ definePage({
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #EAEFF9;
|
background-color: #EAEFF9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
padding-top: 140px;
|
padding-top: 140px;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.card {
|
||||||
font-size: 18px;
|
background: #fff;
|
||||||
color: #999;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
definePage({
|
definePage({
|
||||||
layout: false,
|
layout: false,
|
||||||
style: {
|
style: {
|
||||||
// navigationBarTitleText: 'RTK轨迹',
|
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -10,15 +9,17 @@ definePage({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page">
|
<view class="page">
|
||||||
<!-- 顶部导航 -->
|
|
||||||
<FullscreenNav />
|
<FullscreenNav />
|
||||||
|
|
||||||
<!-- 占位内容 -->
|
<!-- 放大展示与作业页相同的 RTK 轨迹绘制 -->
|
||||||
<view class="content">
|
<scroll-view class="content" scroll-y>
|
||||||
<text class="placeholder-text">
|
<view class="canvas-card">
|
||||||
RTK轨迹
|
<work-rtkCanvas
|
||||||
</text>
|
canvas-id="rtkTrackCanvasFull"
|
||||||
</view>
|
:navigate-on-click="false"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,19 +27,24 @@ definePage({
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: #EAEFF9;
|
background-color: #EAEFF9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 16px;
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
padding-top: 140px;
|
padding-top: 140px;
|
||||||
display: flex;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: calc(100vh - 120px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder-text {
|
.canvas-card {
|
||||||
font-size: 18px;
|
background: #fff;
|
||||||
color: #999;
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
/* 全屏宽度绘制 → 比例尺更大,视觉上放大 */
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user