feat(data-tools): 新增入库类型选择功能并优化数据工具界面
- 在补数任务面板中添加入库类型单选按钮组,支持 MySQL 和 InfluxDB - 更新 AddData 接口定义,添加 StorageType 相关类型和选项接口 - 修改补数 API 请求逻辑,根据入库类型动态调整接口路径前缀 - 重构台账设备表单,统一使用装置网络参数作为 MAC 和 NDID 的单一数据源 - 优化台账线路表单,仅当存在 ID 时才设置 lineId 字段,避免空值传递 - 添加入库类型列表获取接口和相关数据处理逻辑 - 更新台账字典代码常量,新增终端型号字典码 - 优化台账树节点添加逻辑,增加前置条件验证和禁用原因提示 - 添加 InfluxDB 配置文件到额外资源目录 - 更新稳定数据分析视图,优化台账树数据结构处理和样式布局 - 完善 API 调试契约检查,确保设备和线路数据映射正确性 - 优化趋势查询性能,禁用全局加载状态提升用户体验
This commit is contained in:
@@ -6,6 +6,10 @@ $primary-color: var(--el-color-primary);
|
||||
--cn-color-phase-a: #daa520;
|
||||
--cn-color-phase-b: #2e8b57;
|
||||
--cn-color-phase-c: #a52a2a;
|
||||
--cn-color-phase-t: #000000;
|
||||
--cn-color-phase-ab: #daa520;
|
||||
--cn-color-phase-bc: #2e8b57;
|
||||
--cn-color-phase-ca: #a52a2a;
|
||||
|
||||
/* 波形状态与常用业务颜色 */
|
||||
--cn-color-run: #20b2aa;
|
||||
|
||||
39
frontend/src/utils/phaseColors.ts
Normal file
39
frontend/src/utils/phaseColors.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const readThemeColor = (name: string, fallback: string) => {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
export const phaseColorFallbackMap: Record<string, string> = {
|
||||
A: '#daa520',
|
||||
B: '#2e8b57',
|
||||
C: '#a52a2a',
|
||||
T: '#000000',
|
||||
AB: '#daa520',
|
||||
BC: '#2e8b57',
|
||||
CA: '#a52a2a'
|
||||
}
|
||||
|
||||
const phaseColorVariableMap: Record<string, string> = {
|
||||
A: '--cn-color-phase-a',
|
||||
B: '--cn-color-phase-b',
|
||||
C: '--cn-color-phase-c',
|
||||
T: '--cn-color-phase-t',
|
||||
AB: '--cn-color-phase-ab',
|
||||
BC: '--cn-color-phase-bc',
|
||||
CA: '--cn-color-phase-ca'
|
||||
}
|
||||
|
||||
export const resolvePhaseThemeColor = (phase: string) => {
|
||||
const normalizedPhase = String(phase || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const variableName = phaseColorVariableMap[normalizedPhase]
|
||||
const fallback = phaseColorFallbackMap[normalizedPhase]
|
||||
|
||||
return variableName && fallback ? readThemeColor(variableName, fallback) : ''
|
||||
}
|
||||
|
||||
export const getDefaultPhaseThemeColors = () => ['A', 'B', 'C'].map(resolvePhaseThemeColor)
|
||||
@@ -2,6 +2,7 @@
|
||||
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': collapsed }">
|
||||
<el-button
|
||||
class="indicator-toggle"
|
||||
type="primary"
|
||||
:icon="collapsed ? ArrowLeft : ArrowRight"
|
||||
circle
|
||||
@click="emit('update:collapsed', !collapsed)"
|
||||
@@ -61,7 +62,7 @@ const emit = defineEmits<{
|
||||
.indicator-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: -18px;
|
||||
left: -28px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +1,67 @@
|
||||
<template>
|
||||
<section class="card steady-tree-card">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">台账监测点</span>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||
<section class="card steady-tree-card" :class="{ 'is-collapsed': collapsed }">
|
||||
<div v-show="collapsed" class="collapsed-panel">
|
||||
<el-tooltip content="展开设备树" placement="right">
|
||||
<el-button class="panel-toggle" type="primary" :icon="ArrowRight" circle @click="emit('toggle')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
:model-value="keyword"
|
||||
clearable
|
||||
placeholder="搜索工程、项目、设备、监测点"
|
||||
@update:model-value="handleKeywordChange"
|
||||
/>
|
||||
<div v-show="!collapsed" class="expanded-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">设备树</span>
|
||||
<el-tooltip content="收缩设备树" placement="top">
|
||||
<el-button class="panel-toggle" type="primary" :icon="ArrowLeft" circle @click="emit('toggle')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scrollbar">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="ledger-tree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-main">
|
||||
<el-icon :class="['node-icon', `is-level-${normalizeLedgerLevel(data.level)}`]">
|
||||
<component :is="resolveLedgerIcon(data.level)" />
|
||||
</el-icon>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
</span>
|
||||
<span class="node-count">
|
||||
<template v-if="shouldShowLedgerCount(data)">
|
||||
{{ resolveLedgerCountText(data) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
<div class="tree-search-row">
|
||||
<el-input
|
||||
:model-value="keyword"
|
||||
clearable
|
||||
placeholder="搜索工程、项目、设备、监测点"
|
||||
@update:model-value="handleKeywordChange"
|
||||
></el-input>
|
||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="tree-scrollbar">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
class="ledger-tree"
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:default-checked-keys="defaultCheckedKeys"
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="tree-node">
|
||||
<span class="node-main">
|
||||
<el-icon :class="['node-icon', `is-level-${normalizeLedgerLevel(data.level)}`]">
|
||||
<component :is="resolveLedgerIcon(data.level)" />
|
||||
</el-icon>
|
||||
<span class="node-name">{{ data.name }}</span>
|
||||
</span>
|
||||
<span class="node-count">
|
||||
<template v-if="shouldShowLedgerCount(data)">
|
||||
{{ resolveLedgerCountText(data) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-tree>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, ArrowRight, Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
|
||||
import type { TreeInstance } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
@@ -61,12 +74,14 @@ const props = defineProps<{
|
||||
loading: boolean
|
||||
keyword: string
|
||||
defaultCheckedKeys: string[]
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
search: [value: string]
|
||||
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
const treeRef = ref<TreeInstance>()
|
||||
@@ -134,6 +149,46 @@ watch(
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.steady-tree-card:not(.is-collapsed) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.steady-tree-card.is-collapsed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.collapsed-panel,
|
||||
.expanded-panel {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.collapsed-panel {
|
||||
display: flex;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expanded-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.tree-node {
|
||||
display: flex;
|
||||
@@ -148,6 +203,21 @@ watch(
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.tree-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tree-search-row :deep(.el-input) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,23 +1,79 @@
|
||||
<template>
|
||||
<section class="card trend-chart-panel" v-loading="loading">
|
||||
<div v-if="trendResult" class="panel-header">
|
||||
<span class="panel-meta">
|
||||
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }} 点
|
||||
</span>
|
||||
<div class="panel-header">
|
||||
<span v-if="trendResult" class="panel-meta">{{ trendResult.displayPointCount || 0 }} 点</span>
|
||||
<span v-else class="panel-meta">趋势图</span>
|
||||
|
||||
<div class="trend-tool-groups">
|
||||
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
|
||||
<el-tooltip
|
||||
v-for="item in group.items"
|
||||
:key="item.action"
|
||||
:content="getTrendToolTooltip(item)"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
|
||||
:icon="item.icon"
|
||||
:disabled="isTrendToolDisabled(item.action)"
|
||||
circle
|
||||
@click="handleTrendToolAction(item.action)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSeries" class="chart-body">
|
||||
<LineChart :options="chartOptions" />
|
||||
<div v-if="hasSeries" ref="chartExportTargetRef" class="chart-list steady-trend-export-target">
|
||||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||||
<div class="chart-body">
|
||||
<LineChart :options="group.options" :group="group.group" @chart-data-zoom="handleChartDataZoom" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||
|
||||
<el-dialog v-model="fullscreenVisible" title="趋势图全屏展示" fullscreen append-to-body destroy-on-close>
|
||||
<div v-if="hasSeries" class="fullscreen-chart-body">
|
||||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||||
<div class="chart-body">
|
||||
<LineChart
|
||||
:options="group.options"
|
||||
:group="group.group"
|
||||
@chart-data-zoom="handleChartDataZoom"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||
</el-dialog>
|
||||
|
||||
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ArrowDownBold,
|
||||
ArrowLeftBold,
|
||||
ArrowRightBold,
|
||||
ArrowUpBold,
|
||||
Crop,
|
||||
DataAnalysis,
|
||||
FullScreen,
|
||||
Mouse,
|
||||
Picture,
|
||||
Pointer,
|
||||
RefreshLeft
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import LineChart from '@/components/echarts/line/index.vue'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import { buildSteadyTrendChartOptions } from '../utils/trendOptions'
|
||||
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendChartPanel'
|
||||
@@ -28,8 +84,235 @@ const props = defineProps<{
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
||||
type SteadyTrendToolAction =
|
||||
| 'x-zoom-in'
|
||||
| 'x-zoom-out'
|
||||
| 'y-zoom-in'
|
||||
| 'y-zoom-out'
|
||||
| 'box-zoom'
|
||||
| 'wheel-zoom'
|
||||
| 'reset'
|
||||
| 'pan'
|
||||
| 'fullscreen'
|
||||
| 'download-image'
|
||||
| 'query-data'
|
||||
|
||||
const trendToolGroups: Array<{
|
||||
key: string
|
||||
items: Array<{
|
||||
action: SteadyTrendToolAction
|
||||
label: string
|
||||
icon: Component
|
||||
}>
|
||||
}> = [
|
||||
{
|
||||
key: 'viewport',
|
||||
items: [
|
||||
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse },
|
||||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||
{ action: 'pan', label: '平移', icon: Pointer }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'view',
|
||||
items: [
|
||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
||||
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 10 }
|
||||
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||
const trendYZoomScale = ref(1)
|
||||
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
||||
const wheelZoomEnabled = ref(false)
|
||||
const fullscreenVisible = ref(false)
|
||||
const dataTableVisible = ref(false)
|
||||
const chartExportTargetRef = ref<HTMLElement>()
|
||||
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
||||
const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResult?.series || []))
|
||||
const chartGroups = computed(() =>
|
||||
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
||||
activeTool: activeTrendInteractionMode.value,
|
||||
wheelZoomEnabled: wheelZoomEnabled.value,
|
||||
yZoomScale: trendYZoomScale.value
|
||||
})
|
||||
)
|
||||
const canPanTrendChart = computed(() => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
|
||||
return hasSeries.value && (start > 0 || end < 100)
|
||||
})
|
||||
const isDefaultTrendXZoomRange = computed(() => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
|
||||
return start === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.start && end === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.end
|
||||
})
|
||||
const canResetTrendChart = computed(() => {
|
||||
const changedYZoom = trendYZoomScale.value !== 1
|
||||
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
||||
const changedWheelZoom = wheelZoomEnabled.value
|
||||
|
||||
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
||||
})
|
||||
|
||||
const resetTrendToolState = () => {
|
||||
trendXZoomRange.value = { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
||||
trendYZoomScale.value = 1
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
wheelZoomEnabled.value = false
|
||||
}
|
||||
|
||||
const zoomTrendXAxis = (ratio: number) => {
|
||||
const { start, end } = trendXZoomRange.value
|
||||
const center = (start + end) / 2
|
||||
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
|
||||
const nextStart = clampPercent(center - nextWidth / 2)
|
||||
const nextEnd = clampPercent(center + nextWidth / 2)
|
||||
|
||||
if (nextStart === 0) {
|
||||
trendXZoomRange.value = { start: 0, end: nextWidth }
|
||||
return
|
||||
}
|
||||
|
||||
if (nextEnd === 100) {
|
||||
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
|
||||
return
|
||||
}
|
||||
|
||||
trendXZoomRange.value = { start: nextStart, end: nextEnd }
|
||||
}
|
||||
|
||||
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
||||
if (action === 'fullscreen') return fullscreenVisible.value
|
||||
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
||||
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
||||
if (action === 'query-data') return false
|
||||
if (!hasSeries.value) return true
|
||||
if (action === 'pan') return !canPanTrendChart.value
|
||||
if (action === 'reset') return !canResetTrendChart.value
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: string }) => {
|
||||
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
||||
return '请先放大 X 轴或框选局部区域后再平移'
|
||||
}
|
||||
|
||||
return item.label
|
||||
}
|
||||
|
||||
const downloadSteadyTrendImage = async () => {
|
||||
await nextTick()
|
||||
|
||||
const targetElement = chartExportTargetRef.value
|
||||
|
||||
if (!targetElement) {
|
||||
ElMessage.warning('暂无可下载的趋势图')
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = await html2canvas(targetElement, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: window.devicePixelRatio || 1,
|
||||
useCORS: true
|
||||
})
|
||||
const imageUrl = canvas.toDataURL('image/png')
|
||||
const exportFile = document.createElement('a')
|
||||
|
||||
exportFile.style.display = 'none'
|
||||
exportFile.download = `steady-trend-${Date.now()}.png`
|
||||
exportFile.href = imageUrl
|
||||
document.body.appendChild(exportFile)
|
||||
exportFile.click()
|
||||
document.body.removeChild(exportFile)
|
||||
|
||||
ElMessage.success('趋势图图片下载成功')
|
||||
}
|
||||
|
||||
const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
||||
if (isTrendToolDisabled(action)) return
|
||||
|
||||
switch (action) {
|
||||
case 'x-zoom-in':
|
||||
zoomTrendXAxis(0.8)
|
||||
break
|
||||
case 'x-zoom-out':
|
||||
zoomTrendXAxis(1.25)
|
||||
break
|
||||
case 'y-zoom-in':
|
||||
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
|
||||
break
|
||||
case 'y-zoom-out':
|
||||
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
|
||||
break
|
||||
case 'box-zoom':
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
|
||||
break
|
||||
case 'wheel-zoom':
|
||||
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
||||
break
|
||||
case 'pan':
|
||||
if (!canPanTrendChart.value) {
|
||||
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
break
|
||||
}
|
||||
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
|
||||
break
|
||||
case 'reset':
|
||||
resetTrendToolState()
|
||||
break
|
||||
case 'fullscreen':
|
||||
fullscreenVisible.value = true
|
||||
break
|
||||
case 'download-image':
|
||||
await downloadSteadyTrendImage()
|
||||
break
|
||||
case 'query-data':
|
||||
if (!hasSeries.value) {
|
||||
ElMessage.warning('请先查询趋势数据')
|
||||
break
|
||||
}
|
||||
dataTableVisible.value = true
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||
trendXZoomRange.value = {
|
||||
start: clampPercent(value.start),
|
||||
end: clampPercent(value.end)
|
||||
}
|
||||
|
||||
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
||||
activeTrendInteractionMode.value = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.trendResult,
|
||||
() => {
|
||||
// 新查询结果应回到完整时间范围,避免沿用上一批数据的局部缩放窗口。
|
||||
resetTrendToolState()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -46,8 +329,8 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -56,9 +339,64 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.trend-tool-groups {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-tool-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trend-tool-group + .trend-tool-group {
|
||||
padding-left: 8px;
|
||||
border-left: 1px dashed var(--el-border-color);
|
||||
}
|
||||
|
||||
.trend-tool-group :deep(.el-button.is-circle) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.chart-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fullscreen-chart-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: calc(100vh - 96px);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.chart-group {
|
||||
display: flex;
|
||||
flex: 1 0 240px;
|
||||
flex-direction: column;
|
||||
min-height: 220px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--cn-color-canvas-bg);
|
||||
}
|
||||
|
||||
.chart-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visibleProxy"
|
||||
class="steady-trend-data-dialog"
|
||||
title="数据查询"
|
||||
width="86vw"
|
||||
top="7vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="table-main card steady-trend-data-table">
|
||||
<div class="table-header">
|
||||
<div class="header-button-lf">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Download"
|
||||
plain
|
||||
:loading="downloading"
|
||||
:disabled="!tableModel.timeValues.length"
|
||||
@click="downloadSteadyTrendData"
|
||||
>
|
||||
下载数据
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-button-ri"></div>
|
||||
</div>
|
||||
|
||||
<el-table :data="pagedRows" border stripe height="100%">
|
||||
<el-table-column prop="time" label="时间" min-width="170" fixed="left" align="center" />
|
||||
<el-table-column
|
||||
v-for="lineGroup in tableModel.lineGroups"
|
||||
:key="lineGroup.key"
|
||||
:label="lineGroup.label"
|
||||
align="center"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="indicatorGroup in lineGroup.indicatorGroups"
|
||||
:key="indicatorGroup.key"
|
||||
:label="indicatorGroup.label"
|
||||
align="center"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in indicatorGroup.columns"
|
||||
:key="column.prop"
|
||||
:prop="column.prop"
|
||||
:label="column.label"
|
||||
min-width="110"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row[column.prop] ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="table-footer">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[500, 1000, 2000, 5000]"
|
||||
:total="tableModel.timeValues.length"
|
||||
@size-change="currentPage = 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import {
|
||||
buildSteadyTrendExcelHtml,
|
||||
buildSteadyTrendTableModel,
|
||||
buildSteadyTrendTableRows,
|
||||
createEmptySteadyTrendTableModel
|
||||
} from '../utils/trendTable'
|
||||
|
||||
defineOptions({
|
||||
name: 'SteadyTrendDataTableDialog'
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
trendResult: SteadyDataView.SteadyTrendQueryResult | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(500)
|
||||
const downloading = ref(false)
|
||||
const tableModel = shallowRef(createEmptySteadyTrendTableModel())
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
const pagedRows = computed(() =>
|
||||
buildSteadyTrendTableRows(tableModel.value, (currentPage.value - 1) * pageSize.value, pageSize.value)
|
||||
)
|
||||
|
||||
const downloadSteadyTrendData = async () => {
|
||||
if (!tableModel.value.timeValues.length || !tableModel.value.columns.length) {
|
||||
ElMessage.warning('暂无可下载的数据')
|
||||
return
|
||||
}
|
||||
|
||||
downloading.value = true
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const excelContent = buildSteadyTrendExcelHtml(tableModel.value)
|
||||
const blob = new Blob([excelContent], { type: 'application/vnd.ms-excel;charset=utf-8;' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const exportFile = document.createElement('a')
|
||||
|
||||
exportFile.style.display = 'none'
|
||||
exportFile.download = `steady-trend-data-${Date.now()}.xls`
|
||||
exportFile.href = blobUrl
|
||||
document.body.appendChild(exportFile)
|
||||
exportFile.click()
|
||||
document.body.removeChild(exportFile)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
|
||||
ElMessage.success('数据下载成功')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
visible => {
|
||||
if (visible) {
|
||||
tableModel.value = buildSteadyTrendTableModel(props.trendResult?.series || [])
|
||||
currentPage.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
// 弹窗关闭后释放当前页表格模型,避免大数据量结果长期占用额外内存。
|
||||
tableModel.value = createEmptySteadyTrendTableModel()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.steady-trend-data-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 70vh;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-button-lf,
|
||||
.header-button-ri {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-button-ri {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.steady-trend-data-table :deep(.el-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.steady-trend-data-table :deep(.el-table__inner-wrapper) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -14,12 +14,9 @@
|
||||
<div class="toolbar-field">
|
||||
<span class="toolbar-field__label">统计:</span>
|
||||
<el-select
|
||||
:model-value="modelValue.statTypes"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:model-value="modelValue.statType"
|
||||
placeholder="选择统计类型"
|
||||
@update:model-value="updateField('statTypes', $event)"
|
||||
@update:model-value="updateField('statType', $event)"
|
||||
>
|
||||
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||
</el-select>
|
||||
@@ -118,7 +115,7 @@ const handleTimeBaseDateChange = (value: Date) => {
|
||||
<style scoped lang="scss">
|
||||
.trend-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||
grid-template-columns: minmax(360px, 1.35fr) repeat(3, minmax(0, 1fr)) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
@@ -152,6 +149,16 @@ const handleTimeBaseDateChange = (value: Date) => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trend-toolbar__time :deep(.time-period-search__unit) {
|
||||
width: 72px;
|
||||
flex: 0 0 72px;
|
||||
}
|
||||
|
||||
.trend-toolbar__time :deep(.time-period-search__picker) {
|
||||
width: 136px;
|
||||
flex: 0 0 136px;
|
||||
}
|
||||
|
||||
.harmonic-select {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div class="steady-trend-layout">
|
||||
<div class="steady-trend-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsedProxy }">
|
||||
<aside class="selector-column">
|
||||
<SteadyLedgerTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||
@refresh="emit('refreshLedger')"
|
||||
@search="emit('ledgerSearch', $event)"
|
||||
@change="emit('ledgerChange', $event)"
|
||||
/>
|
||||
<div class="ledger-panel-body">
|
||||
<SteadyLedgerTree
|
||||
:key="selectorResetKey"
|
||||
:tree-data="ledgerTree"
|
||||
:loading="loading.ledger"
|
||||
:keyword="ledgerKeyword"
|
||||
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||
:collapsed="ledgerPanelCollapsedProxy"
|
||||
@refresh="emit('refreshLedger')"
|
||||
@search="emit('ledgerSearch', $event)"
|
||||
@change="emit('ledgerChange', $event)"
|
||||
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="trend-main">
|
||||
@@ -67,12 +71,14 @@ const props = defineProps<{
|
||||
ledgerKeyword: string
|
||||
defaultLedgerCheckedKeys: string[]
|
||||
defaultIndicatorCheckedKeys: string[]
|
||||
ledgerPanelCollapsed: boolean
|
||||
indicatorPanelCollapsed: boolean
|
||||
selectorResetKey: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:trendForm': [value: SteadyTrendFormState]
|
||||
'update:ledgerPanelCollapsed': [value: boolean]
|
||||
'update:indicatorPanelCollapsed': [value: boolean]
|
||||
refreshLedger: []
|
||||
ledgerSearch: [value: string]
|
||||
@@ -88,6 +94,11 @@ const trendFormProxy = computed({
|
||||
set: value => emit('update:trendForm', value)
|
||||
})
|
||||
|
||||
const ledgerPanelCollapsedProxy = computed({
|
||||
get: () => props.ledgerPanelCollapsed,
|
||||
set: value => emit('update:ledgerPanelCollapsed', value)
|
||||
})
|
||||
|
||||
const indicatorPanelCollapsedProxy = computed({
|
||||
get: () => props.indicatorPanelCollapsed,
|
||||
set: value => emit('update:indicatorPanelCollapsed', value)
|
||||
@@ -104,12 +115,37 @@ const indicatorPanelCollapsedProxy = computed({
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.selector-column {
|
||||
display: grid;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed .selector-column {
|
||||
z-index: 4;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body {
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.steady-trend-layout.is-ledger-collapsed .ledger-panel-body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ledger-panel-body :deep(.steady-tree-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trend-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -131,7 +167,7 @@ const indicatorPanelCollapsedProxy = computed({
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.steady-trend-layout {
|
||||
.steady-trend-layout:not(.is-ledger-collapsed) {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const trendOptionsFile = path.join(currentDir, '..', 'utils', 'trendOptions.ts')
|
||||
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['steady y-axis upper padding uses 1.15', /const\s+STEADY_AXIS_EXPAND_RATIO\s*=\s*1\.15/],
|
||||
['steady y-axis lower padding uses 0.85', /const\s+STEADY_AXIS_SHRINK_RATIO\s*=\s*0\.85/],
|
||||
[
|
||||
'steady integer ranges still enter readable-axis normalization',
|
||||
/rawInterval\s*>=\s*STEADY_AXIS_SMALL_INTERVAL_THRESHOLD/,
|
||||
true
|
||||
],
|
||||
['steady y-axis keeps readable interval normalization', /getReadableAxisInterval\(axisRange\s*\/\s*currentSplitCount\)/],
|
||||
['steady y-axis keeps min label visible', /showMinLabel:\s*true/],
|
||||
['steady y-axis keeps max label visible', /showMaxLabel:\s*true/]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
|
||||
const exists = pattern.test(trendOptionsSource)
|
||||
return shouldBeMissing ? exists : !exists
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steady axis range contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steady axis range contract check passed')
|
||||
@@ -0,0 +1,329 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewDir = path.join(currentDir, '..')
|
||||
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
|
||||
|
||||
const toolbarSource = read('components/SteadyTrendToolbar.vue')
|
||||
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||
const trendPayloadSource = read('utils/trendPayload.ts')
|
||||
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||
const selectionRulesSource = read('utils/selectionRules.ts')
|
||||
const sharedPhaseColorSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'utils', 'phaseColors.ts'), 'utf8')
|
||||
const sharedStyleSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'styles', 'var.scss'), 'utf8')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'toolbar uses a single active stat type',
|
||||
/:model-value="modelValue\.statType"[\s\S]*@update:model-value="updateField\('statType', \$event\)/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'toolbar no longer allows multiple stat types in one query',
|
||||
/<el-select(?![\s\S]*multiple[\s\S]*placeholder="[^"]*统计类型")[\s\S]*placeholder="[^"]*统计类型"/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'trend payload keeps API statTypes array but only sends active stat',
|
||||
/statTypes:\s*\[formState\.statType\]/,
|
||||
trendPayloadSource
|
||||
],
|
||||
[
|
||||
'selection rules cap monitoring points at six',
|
||||
/MAX_SELECTED_LINE_COUNT\s*=\s*6[\s\S]*lineIds\.length\s*>\s*MAX_SELECTED_LINE_COUNT/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'selection rules cap indicators at six',
|
||||
/MAX_SELECTED_INDICATOR_COUNT\s*=\s*6[\s\S]*indicators\.length\s*>\s*MAX_SELECTED_INDICATOR_COUNT/,
|
||||
selectionRulesSource
|
||||
],
|
||||
[
|
||||
'chart panel renders a list of grouped charts',
|
||||
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel syncs grouped charts through LineChart group and dataZoom events',
|
||||
/<LineChart[\s\S]*:options="group\.options"[\s\S]*:group="group\.group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel only shows trend point count without bucket prefix',
|
||||
/trendResult\.displayPointCount\s*\|\|\s*0[\s\S]*点/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel does not render bucket slash prefix before point count',
|
||||
/^(?![\s\S]*trendResult\.bucket[\s\S]*\/[\s\S]*trendResult\.displayPointCount)[\s\S]*$/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel renders waveform-style trend tool groups',
|
||||
/trend-tool-groups[\s\S]*trendToolGroups[\s\S]*handleTrendToolAction/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel keeps trend toolbar next to point count on the left',
|
||||
/\.panel-header\s*\{[\s\S]*justify-content:\s*flex-start/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel exposes core trend toolbar actions except marker and data export',
|
||||
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel exposes a wheel zoom toggle action',
|
||||
/wheel-zoom/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel defaults wheel zoom disabled',
|
||||
/const\s+wheelZoomEnabled\s*=\s*ref\(false\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel passes wheel zoom state into grouped options',
|
||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel defaults to first tenth of the x range',
|
||||
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*10\s*\}/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel stores shared x zoom range from the default range',
|
||||
/const trendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>\(\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel compares reset state against the default x zoom range',
|
||||
/const isDefaultTrendXZoomRange[\s\S]*DEFAULT_STEADY_TREND_X_ZOOM_RANGE[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel stores y zoom scale and active chart tool',
|
||||
/const trendYZoomScale\s*=\s*ref\(1\)[\s\S]*const activeTrendInteractionMode\s*=\s*ref<SteadyTrendInteractionMode>\('none'\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel passes active chart tool and y zoom scale into grouped options',
|
||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*activeTool:\s*activeTrendInteractionMode\.value[\s\S]*yZoomScale:\s*trendYZoomScale\.value/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel resets x zoom range to the default first tenth when trend result changes',
|
||||
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel resets toolbar state when trend result changes',
|
||||
/watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel can export the visible trend charts as an image',
|
||||
/steady-trend-export-target[\s\S]*html2canvas[\s\S]*downloadSteadyTrendImage/,
|
||||
chartPanelSource
|
||||
],
|
||||
['chart utilities expose grouped chart options', /export const buildSteadyTrendChartGroups/, trendOptionsSource],
|
||||
[
|
||||
'chart utilities carry one shared ECharts group for steady multi-chart sync',
|
||||
/group:\s*STEADY_TREND_CHART_GROUP/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart utilities accept shared x zoom range when building options',
|
||||
/buildSteadyTrendChartOptions\s*=\s*\([^)]*zoomRange:\s*SteadyTrendZoomRange/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart utilities accept active tool and y zoom options',
|
||||
/interface\s+SteadyTrendChartBuildOptions[\s\S]*activeTool\?:[\s\S]*yZoomScale\?:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options expose activeTool for LineChart interactions',
|
||||
/activeTool:\s*chartOptions\.activeTool\s*\|\|\s*'none'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options apply y zoom scale to steady y axis',
|
||||
/applySteadyYAxisZoom\([\s\S]*buildSteadyTrendAxisConfig\(values,\s*unit\)[\s\S]*chartOptions\.yZoomScale/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options bind dataZoom to shared x zoom range',
|
||||
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options accept wheel zoom option',
|
||||
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options only zoom on mouse wheel when enabled',
|
||||
/zoomOnMouseWheel:\s*chartOptions\.wheelZoomEnabled\s*===\s*true[\s\S]*moveOnMouseWheel:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options keep hover axis pointer as a vertical line',
|
||||
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options calculate visible point count from shared zoom range',
|
||||
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart series line width uses visible point count after x zoom',
|
||||
/width:\s*resolveSteadyTrendLineWidth\(\s*resolveSteadyTrendVisiblePointCount\(series\.points\?\.length\s*\|\|\s*0,\s*zoomRange\)\s*\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping splits by indicator for one monitoring point',
|
||||
/lineIds\.length\s*===\s*1[\s\S]*indicatorCodes\.length\s*>\s*1[\s\S]*'indicator'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping splits by monitoring point for one indicator',
|
||||
/lineIds\.length\s*>\s*1[\s\S]*indicatorCodes\.length\s*===\s*1[\s\S]*'line'/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options keep fixed left grid for multi-chart alignment',
|
||||
/left:\s*STEADY_TREND_GRID_LEFT[\s\S]*containLabel:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options use compact top grid to reduce title legend height',
|
||||
/STEADY_TREND_GRID_TOP\s*=\s*28[\s\S]*top:\s*STEADY_TREND_GRID_TOP/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart panel does not render external chart title rows',
|
||||
/^(?![\s\S]*<div class="chart-title">)[\s\S]*$/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart options render centered group title through ECharts title',
|
||||
/title:\s*\{[\s\S]*text:\s*chartTitle[\s\S]*left:\s*'center'[\s\S]*top:\s*0/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping passes resolved title into ECharts options',
|
||||
/const\s+groupTitle\s*=\s*resolveGroupTitle\(groupSeries\)[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options explicitly show min and max y labels',
|
||||
/showMinLabel:\s*true[\s\S]*showMaxLabel:\s*true/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart y axis keeps waveform unit placement style without changing unit content',
|
||||
/nameLocation:\s*'middle'[\s\S]*nameGap:\s*42[\s\S]*nameTextStyle:/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis only keeps first and last labels like waveform',
|
||||
/buildSteadyTimeAxisLabelFormatter[\s\S]*index\s*!==\s*0\s*&&\s*index\s*!==\s*lastIndex[\s\S]*return\s*''/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis label layout follows waveform except unit display',
|
||||
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*false[\s\S]*interval:\s*0[\s\S]*margin:\s*showTimeAxis\s*\?\s*10\s*:\s*0[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeLabels\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options allow hiding the x axis for non-final grouped charts',
|
||||
/buildSteadyTrendChartOptions\s*=\s*\([^)]*showTimeAxis\s*=\s*true/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart x axis keeps labels visible but hides baseline and dense ticks',
|
||||
/axisLine:\s*\{[\s\S]*show:\s*false[\s\S]*axisLabel:\s*\{[\s\S]*show:\s*showTimeAxis[\s\S]*axisTick:\s*\{[\s\S]*show:\s*false/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart grouping only shows the x axis on the final grouped chart',
|
||||
/const\s+groupEntries\s*=\s*Array\.from\(groupMap\.entries\(\)\)[\s\S]*const\s+isLastChart\s*=\s*index\s*===\s*groupEntries\.length\s*-\s*1[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart options use waveform line width buckets including the final three widths',
|
||||
/resolveSteadyTrendLineWidth[\s\S]*200000\)\s*return\s*0\.35[\s\S]*100000\)\s*return\s*0\.45[\s\S]*50000\)\s*return\s*0\.55[\s\S]*20000\)\s*return\s*0\.65[\s\S]*10000\)\s*return\s*0\.75[\s\S]*5000\)\s*return\s*0\.9[\s\S]*2000\)\s*return\s*1[\s\S]*800\)\s*return\s*1\.1[\s\S]*200\)\s*return\s*1\.2[\s\S]*return\s*STEADY_TREND_LINE_MAX_WIDTH/,
|
||||
trendOptionsSource
|
||||
],
|
||||
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
|
||||
[
|
||||
'chart legend only uses phase name',
|
||||
/const formatSeriesName[\s\S]*return series\.phase \|\| series\.seriesKey/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'chart group title joins monitoring point and indicator with underscore',
|
||||
/\[firstSeries\.lineName[\s\S]*firstSeries\.indicatorName[\s\S]*\][\s\S]*\.filter\(Boolean\)[\s\S]*\.join\('_'\)/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis above-one upper padding uses 1.05',
|
||||
/STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE\s*=\s*1\.05/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis above-one lower padding uses 0.95',
|
||||
/STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE\s*=\s*0\.95/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact padding uses 1.015 for narrow above-one ranges',
|
||||
/STEADY_AXIS_COMPACT_EXPAND_RATIO\s*=\s*1\.015/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact padding uses 0.985 for narrow above-one ranges',
|
||||
/STEADY_AXIS_COMPACT_SHRINK_RATIO\s*=\s*0\.985/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis compact split penalty stays low',
|
||||
/STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE\s*=\s*0\.05/,
|
||||
trendOptionsSource
|
||||
],
|
||||
[
|
||||
'steady y-axis enables compact readable range for narrow above-one data',
|
||||
/shouldUseCompactReadableAxisRange[\s\S]*maxAbs\s*>\s*1[\s\S]*STEADY_AXIS_COMPACT_RANGE_RATIO/,
|
||||
trendOptionsSource
|
||||
],
|
||||
['shared phase colors read the global T phase theme variable', /T:\s*'--cn-color-phase-t'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep T phase black fallback', /T:\s*'#000000'/, sharedPhaseColorSource],
|
||||
['shared phase colors map AB line voltage to phase A color', /AB:\s*'--cn-color-phase-ab'/, sharedPhaseColorSource],
|
||||
['shared phase colors map BC line voltage to phase B color', /BC:\s*'--cn-color-phase-bc'/, sharedPhaseColorSource],
|
||||
['shared phase colors map CA line voltage to phase C color', /CA:\s*'--cn-color-phase-ca'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep AB fallback aligned with phase A', /AB:\s*'#daa520'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep BC fallback aligned with phase B', /BC:\s*'#2e8b57'/, sharedPhaseColorSource],
|
||||
['shared phase colors keep CA fallback aligned with phase C', /CA:\s*'#a52a2a'/, sharedPhaseColorSource],
|
||||
['global style defines AB line voltage color variable', /--cn-color-phase-ab:\s*#daa520/, sharedStyleSource],
|
||||
['global style defines BC line voltage color variable', /--cn-color-phase-bc:\s*#2e8b57/, sharedStyleSource],
|
||||
['global style defines CA line voltage color variable', /--cn-color-phase-ca:\s*#a52a2a/, sharedStyleSource]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView chart display contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView chart display contract passed')
|
||||
@@ -0,0 +1,96 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewDir = path.join(currentDir, '..')
|
||||
const read = file => {
|
||||
const targetFile = path.join(viewDir, file)
|
||||
|
||||
return fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : ''
|
||||
}
|
||||
|
||||
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||
const dialogSource = read('components/SteadyTrendDataTableDialog.vue')
|
||||
const tableUtilSource = read('utils/trendTable.ts')
|
||||
|
||||
const expectations = [
|
||||
[
|
||||
'chart panel exposes data query action in the existing trend tool groups',
|
||||
/trendToolGroups[\s\S]*query-data[\s\S]*handleTrendToolAction/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel opens data table dialog from current trend result',
|
||||
/SteadyTrendDataTableDialog[\s\S]*v-model="dataTableVisible"[\s\S]*:trend-result="trendResult"/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'chart panel warns before opening data table without series data',
|
||||
/ElMessage\.warning\('请先查询趋势数据'\)/,
|
||||
chartPanelSource
|
||||
],
|
||||
[
|
||||
'data table dialog follows dictdata table-main card table-header structure',
|
||||
/class="table-main card steady-trend-data-table"[\s\S]*class="table-header"[\s\S]*class="header-button-lf"[\s\S]*class="header-button-ri"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog provides full Excel download action on the left header',
|
||||
/class="header-button-lf"[\s\S]*type="primary"[\s\S]*:icon="Download"[\s\S]*plain[\s\S]*downloadSteadyTrendData/,
|
||||
dialogSource
|
||||
],
|
||||
['data table dialog no longer renders refresh button', /:icon="Refresh"|refreshKey/, dialogSource, true],
|
||||
[
|
||||
'data table dialog renders fixed common time column',
|
||||
/<el-table-column\s+prop="time"[\s\S]*fixed="left"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog renders monitoring point then indicator then phase columns',
|
||||
/v-for="lineGroup in tableModel\.lineGroups"[\s\S]*v-for="indicatorGroup in lineGroup\.indicatorGroups"[\s\S]*v-for="column in indicatorGroup\.columns"/,
|
||||
dialogSource
|
||||
],
|
||||
[
|
||||
'data table dialog renders paged rows instead of full model rows',
|
||||
/:data="pagedRows"[\s\S]*<el-pagination[\s\S]*v-model:current-page="currentPage"[\s\S]*v-model:page-size="pageSize"/,
|
||||
dialogSource
|
||||
],
|
||||
['data table dialog defaults to 500 rows per page', /const\s+pageSize\s*=\s*ref\(500\)/, dialogSource],
|
||||
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
|
||||
[
|
||||
'trend table utility carries indicator units into labels',
|
||||
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*(\$\{unit\})/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility groups columns by monitoring point before indicator',
|
||||
/lineGroupMap[\s\S]*indicatorGroupMap[\s\S]*columns\.push/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility aligns rows by shared sorted time values without building all rows eagerly',
|
||||
/timeValues:\s*Array\.from\(timeSet\)[\s\S]*\.sort\(\)[\s\S]*buildSteadyTrendTableRows/,
|
||||
tableUtilSource
|
||||
],
|
||||
[
|
||||
'trend table utility builds full Excel html export',
|
||||
/colspan="\$\{lineColspan\}"[\s\S]*rowspan="3"[\s\S]*buildSteadyTrendTableRows\(model,\s*0,\s*model\.timeValues\.length\)[\s\S]*buildSteadyTrendExcelHtml/,
|
||||
tableUtilSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, source, forbidden]) =>
|
||||
forbidden ? pattern.test(source) : !pattern.test(source)
|
||||
)
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView data table contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView data table contract passed')
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const componentDir = path.join(currentDir, '..', 'components')
|
||||
|
||||
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const ledgerTreeSource = read('SteadyLedgerTree.vue')
|
||||
const workbenchSource = read('SteadyTrendWorkbench.vue')
|
||||
|
||||
const expectations = [
|
||||
['ledger panel title is renamed to device tree', /<span class="panel-title">设备树<\/span>/, ledgerTreeSource],
|
||||
['ledger panel no longer renders old title', !/台账监测点/.test(ledgerTreeSource), ledgerTreeSource],
|
||||
[
|
||||
'ledger refresh button is placed in the search row after the input',
|
||||
/<div class="tree-search-row">[\s\S]*<el-input[\s\S]*<\/el-input>[\s\S]*<el-button[\s\S]*@click="emit\('refresh'\)"[\s\S]*<\/div>/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
['ledger panel receives collapsed state', /collapsed:\s*boolean/, ledgerTreeSource],
|
||||
['ledger panel emits toggle event', /toggle:\s*\[\]/, ledgerTreeSource],
|
||||
['ledger panel renders internal collapse or expand button', /@click="emit\('toggle'\)"/, ledgerTreeSource],
|
||||
[
|
||||
'ledger collapse buttons use primary theme color',
|
||||
/class="panel-toggle"[\s\S]*type="primary"[\s\S]*@click="emit\('toggle'\)"/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
['workbench passes collapsed state to ledger tree', /:collapsed="ledgerPanelCollapsedProxy"/, workbenchSource],
|
||||
['workbench wires ledger tree toggle event', /@toggle="emit\('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy\)"/, workbenchSource],
|
||||
['workbench no longer renders external ledger toggle button', !/class="ledger-toggle"/.test(workbenchSource), workbenchSource],
|
||||
['workbench collapsed ledger column does not reserve button width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||
['workbench selector column passes full height to the ledger card', /\.selector-column\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||
['workbench ledger panel body passes full height to the ledger card', /\.ledger-panel-body\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||
[
|
||||
'workbench selector column no longer reserves an external toggle column',
|
||||
!/grid-template-columns:\s*minmax\(0,\s*1fr\)\s+36px/.test(workbenchSource),
|
||||
workbenchSource
|
||||
],
|
||||
[
|
||||
'ledger collapsed state only floats the expand button',
|
||||
/\.steady-tree-card\.is-collapsed[\s\S]*position:\s*absolute[\s\S]*width:\s*36px[\s\S]*height:\s*36px/,
|
||||
ledgerTreeSource
|
||||
],
|
||||
[
|
||||
'expanded ledger panel remains in normal layout',
|
||||
/\.steady-tree-card:not\(\.is-collapsed\)[\s\S]*height:\s*100%/,
|
||||
ledgerTreeSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, expectation, source]) => {
|
||||
return expectation instanceof RegExp ? !expectation.test(source) : !expectation
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('steadyDataView ledger panel layout contract failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('steadyDataView ledger panel layout contract passed')
|
||||
@@ -41,11 +41,7 @@ const expectations = [
|
||||
/defaultIndicatorCheckedKeys\.value\s*=/,
|
||||
pageSource
|
||||
],
|
||||
[
|
||||
'ledger tree receives default checked keys',
|
||||
/defaultCheckedKeys/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
['ledger tree receives default checked keys', /defaultCheckedKeys/, read('SteadyLedgerTree.vue')],
|
||||
[
|
||||
'ledger tree renders level icons',
|
||||
/<component\s+:is="resolveLedgerIcon\(data\.level\)"/,
|
||||
@@ -56,10 +52,11 @@ const expectations = [
|
||||
/const\s+ledgerIcons[\s\S]*0:[\s\S]*1:[\s\S]*2:[\s\S]*3:/,
|
||||
read('SteadyLedgerTree.vue')
|
||||
],
|
||||
['indicator tree receives default checked keys', /defaultCheckedKeys/, read('SteadyIndicatorTree.vue')],
|
||||
[
|
||||
'indicator tree receives default checked keys',
|
||||
/defaultCheckedKeys/,
|
||||
read('SteadyIndicatorTree.vue')
|
||||
'selection rules dedupe leaf indicators collected from checked parents and children',
|
||||
/seenIndicatorKeys[\s\S]*node\.indicatorCode[\s\S]*seenIndicatorKeys\.has[\s\S]*seenIndicatorKeys\.add/,
|
||||
selectionRulesSource
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ const componentSource = fs.existsSync(componentDir)
|
||||
const readComponent = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||
const toolbarSource = readComponent('SteadyTrendToolbar.vue')
|
||||
const chartPanelSource = readComponent('SteadyTrendChartPanel.vue')
|
||||
const workbenchSource = readComponent('SteadyTrendWorkbench.vue')
|
||||
const floatingPanelSource = readComponent('SteadyIndicatorFloatingPanel.vue')
|
||||
const ledgerTreeSource = readComponent('SteadyLedgerTree.vue')
|
||||
const indicatorTreeSource = readComponent('SteadyIndicatorTree.vue')
|
||||
const viewSource = `${source}\n${componentSource}`
|
||||
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||
@@ -62,15 +64,40 @@ const requiredPatterns = [
|
||||
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
|
||||
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource],
|
||||
[
|
||||
'trend toolbar reserves four evenly distributed search columns',
|
||||
/grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)\s*auto/,
|
||||
'trend toolbar gives the time selector a wider first column',
|
||||
/grid-template-columns:\s*minmax\(360px,\s*1\.35fr\)\s+repeat\(3,\s*minmax\(0,\s*1fr\)\)\s*auto/,
|
||||
toolbarSource
|
||||
],
|
||||
['trend toolbar keeps actions after four search columns', /grid-column:\s*5/, toolbarSource],
|
||||
[
|
||||
'trend toolbar widens the shared time period unit selector',
|
||||
/\.trend-toolbar__time\s*:deep\(\.time-period-search__unit\)[\s\S]*width:\s*72px[\s\S]*flex:\s*0 0 72px/,
|
||||
toolbarSource
|
||||
],
|
||||
[
|
||||
'trend toolbar widens the shared time period date picker',
|
||||
/\.trend-toolbar__time\s*:deep\(\.time-period-search__picker\)[\s\S]*width:\s*136px[\s\S]*flex:\s*0 0 136px/,
|
||||
toolbarSource
|
||||
],
|
||||
['floating indicator panel expanded width is reduced', /width:\s*300px/, floatingPanelSource],
|
||||
['floating indicator collapsed state keeps icon only', /width:\s*0/, floatingPanelSource],
|
||||
['floating indicator body is hidden when collapsed', /\.indicator-floating-panel\.is-collapsed\s+\.indicator-panel-body/, floatingPanelSource],
|
||||
['indicator tree header separates title and refresh icon', /justify-content:\s*flex-start/, indicatorTreeSource]
|
||||
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
|
||||
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
|
||||
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
|
||||
['indicator tree header separates title and refresh icon', /justify-content:\s*flex-start/, indicatorTreeSource],
|
||||
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
|
||||
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],
|
||||
['workbench exposes collapsed ledger panel model', /ledgerPanelCollapsed[\s\S]*update:ledgerPanelCollapsed/, workbenchSource],
|
||||
['workbench applies collapsed ledger layout class', /is-ledger-collapsed/, workbenchSource],
|
||||
['ledger panel stays in normal layout instead of floating', /\.selector-column[\s\S]*position:\s*relative/, workbenchSource],
|
||||
['collapsed ledger panel does not reserve trigger column width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||
[
|
||||
'collapsed ledger panel allows only the expand button to float',
|
||||
/\.steady-trend-layout\.is-ledger-collapsed\s+\.selector-column[\s\S]*overflow:\s*visible/,
|
||||
workbenchSource
|
||||
]
|
||||
]
|
||||
|
||||
const failures = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="table-box steady-data-view-page">
|
||||
<SteadyTrendWorkbench
|
||||
v-model:trend-form="trendForm"
|
||||
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||
v-model:indicator-panel-collapsed="indicatorPanelCollapsed"
|
||||
:ledger-tree="ledgerTree"
|
||||
:indicator-tree="indicatorTree"
|
||||
@@ -52,6 +53,7 @@ const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||
const trendResult = ref<SteadyDataView.SteadyTrendQueryResult | null>(null)
|
||||
const trendForm = ref(defaultTrendFormState())
|
||||
const ledgerKeyword = ref('')
|
||||
const ledgerPanelCollapsed = ref(false)
|
||||
const indicatorPanelCollapsed = ref(false)
|
||||
const selectorResetKey = ref(0)
|
||||
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||
@@ -133,10 +135,12 @@ const resetTrendState = () => {
|
||||
}
|
||||
|
||||
const handleQueryTrend = async () => {
|
||||
indicatorPanelCollapsed.value = true
|
||||
|
||||
const selectionError = validateTrendSelection({
|
||||
lineIds: lineIds.value,
|
||||
indicators: selectedIndicators.value,
|
||||
statTypes: trendForm.value.statTypes,
|
||||
statType: trendForm.value.statType,
|
||||
harmonicOrders: trendForm.value.harmonicOrders
|
||||
})
|
||||
if (selectionError) {
|
||||
@@ -167,13 +171,9 @@ watch(
|
||||
|
||||
trendForm.value = {
|
||||
...trendForm.value,
|
||||
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
|
||||
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
|
||||
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
|
||||
}
|
||||
|
||||
if (!trendForm.value.statTypes.length) {
|
||||
trendForm.value.statTypes = availableStats.slice(0, 1)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
export const MAX_TREND_SERIES_COUNT = 24
|
||||
export const MAX_SELECTED_LINE_COUNT = 6
|
||||
export const MAX_SELECTED_INDICATOR_COUNT = 6
|
||||
export const MAX_HARMONIC_ORDER_COUNT = 6
|
||||
|
||||
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
|
||||
@@ -25,6 +27,7 @@ export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[])
|
||||
|
||||
export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||
const indicators: SteadyDataView.SteadyIndicatorNode[] = []
|
||||
const seenIndicatorKeys = new Set<string>()
|
||||
|
||||
const collect = (node: SteadyDataView.SteadyIndicatorNode) => {
|
||||
if (node.children?.length) {
|
||||
@@ -32,9 +35,11 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
|
||||
return
|
||||
}
|
||||
|
||||
if (node.indicatorCode) {
|
||||
indicators.push(node)
|
||||
}
|
||||
const indicatorKey = node.indicatorCode || node.treeKey || node.id
|
||||
if (!indicatorKey || seenIndicatorKeys.has(indicatorKey)) return
|
||||
|
||||
seenIndicatorKeys.add(indicatorKey)
|
||||
indicators.push(node)
|
||||
}
|
||||
|
||||
nodes.forEach(collect)
|
||||
@@ -92,7 +97,7 @@ export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicator
|
||||
export const estimateTrendSeriesCount = (
|
||||
lineIds: string[],
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[],
|
||||
statType: SteadyDataView.SteadyTrendStatType,
|
||||
harmonicOrders: number[]
|
||||
) => {
|
||||
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
|
||||
@@ -101,14 +106,11 @@ export const estimateTrendSeriesCount = (
|
||||
const phaseCount = indicator.phaseCodes?.length || 1
|
||||
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
|
||||
|
||||
return count + lineIds.length * phaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
|
||||
return count + lineIds.length * phaseCount * (statType ? 1 : 0) * fieldCount * harmonicMultiplier
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export const validateHarmonicOrders = (
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||
harmonicOrders: number[]
|
||||
) => {
|
||||
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
|
||||
if (!hasHarmonicIndicator(indicators)) return ''
|
||||
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
|
||||
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
|
||||
@@ -119,20 +121,22 @@ export const validateHarmonicOrders = (
|
||||
export const validateTrendSelection = (params: {
|
||||
lineIds: string[]
|
||||
indicators: SteadyDataView.SteadyIndicatorNode[]
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
harmonicOrders: number[]
|
||||
}) => {
|
||||
const { lineIds, indicators, statTypes, harmonicOrders } = params
|
||||
const { lineIds, indicators, statType, harmonicOrders } = params
|
||||
|
||||
if (!lineIds.length) return '请选择监测点'
|
||||
if (!indicators.length) return '请选择趋势指标'
|
||||
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
|
||||
if (!statTypes.length) return '请选择统计类型'
|
||||
if (lineIds.length > MAX_SELECTED_LINE_COUNT) return '监测点最多选择 6 个'
|
||||
if (indicators.length > MAX_SELECTED_INDICATOR_COUNT) return '趋势指标最多选择 6 个'
|
||||
if (!statType) return '请选择统计类型'
|
||||
|
||||
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
|
||||
if (harmonicError) return harmonicError
|
||||
|
||||
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statTypes, harmonicOrders)
|
||||
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statType, harmonicOrders)
|
||||
if (seriesCount > MAX_TREND_SERIES_COUNT) {
|
||||
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,47 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
import { resolvePhaseThemeColor } from '@/utils/phaseColors'
|
||||
|
||||
const trendColors = [
|
||||
export interface SteadyTrendChartGroup {
|
||||
key: string
|
||||
title: string
|
||||
group: string
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SteadyTrendZoomRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type SteadyTrendActiveTool = 'none' | 'box-zoom' | 'pan'
|
||||
|
||||
export interface SteadyTrendChartBuildOptions {
|
||||
activeTool?: SteadyTrendActiveTool
|
||||
wheelZoomEnabled?: boolean
|
||||
yZoomScale?: number
|
||||
}
|
||||
|
||||
const STEADY_TREND_CHART_GROUP = 'steady-trend-chart-sync'
|
||||
const STEADY_AXIS_EXPAND_RATIO = 1.15
|
||||
const STEADY_AXIS_SHRINK_RATIO = 0.85
|
||||
const STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE = 1.05
|
||||
const STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE = 0.95
|
||||
const STEADY_AXIS_COMPACT_EXPAND_RATIO = 1.015
|
||||
const STEADY_AXIS_COMPACT_SHRINK_RATIO = 0.985
|
||||
const STEADY_AXIS_COMPACT_RANGE_RATIO = 0.25
|
||||
const STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE = 0.05
|
||||
const STEADY_AXIS_BALANCED_RATIO = 0.9
|
||||
const STEADY_AXIS_DEFAULT_SPLIT_COUNT = 4
|
||||
const STEADY_AXIS_EXTRA_SPLIT_SCORE = 0.25
|
||||
const STEADY_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
|
||||
const STEADY_TREND_GRID_LEFT = '64px'
|
||||
const STEADY_TREND_GRID_RIGHT = '28px'
|
||||
const STEADY_TREND_GRID_TOP = 28
|
||||
const STEADY_TREND_LINE_MAX_WIDTH = 1.3
|
||||
const STEADY_AXIS_TEXT_COLOR = 'var(--el-text-color-regular)'
|
||||
const STEADY_AXIS_LINE_COLOR = 'var(--el-border-color)'
|
||||
|
||||
const fallbackTrendColors = [
|
||||
'var(--el-color-primary)',
|
||||
'#00a870',
|
||||
'#e6a23c',
|
||||
@@ -11,23 +52,497 @@ const trendColors = [
|
||||
'#f97316'
|
||||
]
|
||||
|
||||
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||
return [series.lineName, series.indicatorName || series.seriesName, series.phase, series.statType]
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
interface ReadableAxisRangeOptions {
|
||||
preferCompact?: boolean
|
||||
compactMin?: number
|
||||
compactMax?: number
|
||||
}
|
||||
|
||||
export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||
const getUniqueValues = (
|
||||
seriesList: SteadyDataView.SteadyTrendSeries[],
|
||||
getValue: (series: SteadyDataView.SteadyTrendSeries) => string
|
||||
) => {
|
||||
return Array.from(new Set(seriesList.map(getValue).filter(Boolean)))
|
||||
}
|
||||
|
||||
const resolveGroupMode = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||
const lineIds = getUniqueValues(seriesList, series => series.lineId)
|
||||
const indicatorCodes = getUniqueValues(seriesList, series => series.indicatorCode)
|
||||
|
||||
if (lineIds.length === 1 && indicatorCodes.length > 1) return 'indicator'
|
||||
if (lineIds.length > 1 && indicatorCodes.length === 1) return 'line'
|
||||
|
||||
return 'single'
|
||||
}
|
||||
|
||||
const resolveGroupKey = (series: SteadyDataView.SteadyTrendSeries, groupMode: string) => {
|
||||
if (groupMode === 'indicator') return series.indicatorCode || series.seriesKey
|
||||
if (groupMode === 'line') return series.lineId || series.seriesKey
|
||||
|
||||
return 'steady-trend'
|
||||
}
|
||||
|
||||
const resolveGroupTitle = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||
const firstSeries = seriesList[0]
|
||||
if (!firstSeries) return '趋势图'
|
||||
|
||||
return (
|
||||
[firstSeries.lineName || firstSeries.lineId, firstSeries.indicatorName || firstSeries.seriesName]
|
||||
.filter(Boolean)
|
||||
.join('_') || '趋势图'
|
||||
)
|
||||
}
|
||||
|
||||
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||
return series.phase || series.seriesKey
|
||||
}
|
||||
|
||||
const getAxisPrecision = (step: number) => {
|
||||
const absStep = Math.abs(step)
|
||||
|
||||
if (!Number.isFinite(absStep) || absStep >= 1) return 0
|
||||
|
||||
const stepText = `${absStep}`
|
||||
if (stepText.includes('e-')) {
|
||||
return Number(stepText.split('e-')[1] || 0)
|
||||
}
|
||||
|
||||
return Math.min(stepText.split('.')[1]?.length || 0, 4)
|
||||
}
|
||||
|
||||
const getAxisBoundaryPrecision = (axisMin: number, axisMax: number, interval: number) => {
|
||||
const boundaryPrecision = Math.max(Math.abs(axisMin), Math.abs(axisMax)) < 1 ? 2 : 0
|
||||
|
||||
return Math.max(getAxisPrecision(interval), boundaryPrecision)
|
||||
}
|
||||
|
||||
const normalizeAxisValue = (value: number, precision: number) => {
|
||||
const factor = 10 ** precision
|
||||
const normalizedValue = Math.round(value * factor) / factor
|
||||
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
||||
}
|
||||
|
||||
const getReadableAxisInterval = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return 1
|
||||
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
const normalizedValue = value / magnitude
|
||||
const step = STEADY_AXIS_READABLE_INTERVAL_STEPS.find(item => normalizedValue <= item) || 10
|
||||
|
||||
return step * magnitude
|
||||
}
|
||||
|
||||
const roundAxisValueUp = (value: number) => {
|
||||
const absValue = Math.abs(value)
|
||||
|
||||
if (!Number.isFinite(absValue) || absValue === 0) return 0
|
||||
|
||||
const magnitude = 10 ** Math.floor(Math.log10(absValue))
|
||||
const step = magnitude >= 10 ? magnitude / 10 : magnitude
|
||||
const precision = getAxisPrecision(step)
|
||||
|
||||
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
|
||||
}
|
||||
|
||||
const formatAxisLabel = (value: number, precision: number) => {
|
||||
if (!Number.isFinite(value)) return ''
|
||||
return `${normalizeAxisValue(value, precision)}`
|
||||
}
|
||||
|
||||
const buildSteadyTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||
const lastIndex = timeLabels.length - 1
|
||||
|
||||
return (value: string | number, index: number) => {
|
||||
if (index !== 0 && index !== lastIndex) return ''
|
||||
return `${value}`
|
||||
}
|
||||
}
|
||||
|
||||
const getReadableAxisIntervalCandidates = (value: number) => {
|
||||
return Array.from(new Set([getReadableAxisInterval(value), getReadableAxisInterval(value / 2)])).filter(
|
||||
item => Number.isFinite(item) && item > 0
|
||||
)
|
||||
}
|
||||
|
||||
const buildReadableAxisRangeCandidate = (
|
||||
readableMin: number,
|
||||
readableMax: number,
|
||||
interval: number,
|
||||
currentSplitCount: number,
|
||||
baseSplitCount: number,
|
||||
coverMin: number,
|
||||
coverMax: number,
|
||||
scoreRange: number,
|
||||
extraSplitScore = STEADY_AXIS_EXTRA_SPLIT_SCORE
|
||||
) => {
|
||||
const precision = getAxisPrecision(interval)
|
||||
const normalizedMin = normalizeAxisValue(readableMin, precision)
|
||||
const normalizedMax = normalizeAxisValue(readableMax, precision)
|
||||
const normalizedInterval = normalizeAxisValue(interval, precision)
|
||||
const candidateRange = normalizedMax - normalizedMin
|
||||
const epsilon = Math.max(Math.abs(normalizedInterval), 1) * 1e-10
|
||||
|
||||
if (normalizedMin - coverMin > epsilon || coverMax - normalizedMax > epsilon || candidateRange <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extraSplitCost = Math.max(currentSplitCount - baseSplitCount, 0) * extraSplitScore
|
||||
const wasteRatio = scoreRange > 0 ? Math.max((candidateRange - scoreRange) / scoreRange, 0) : 0
|
||||
|
||||
return {
|
||||
axisMin: normalizedMin,
|
||||
axisMax: normalizedMax,
|
||||
interval: normalizedInterval,
|
||||
splitCount: currentSplitCount,
|
||||
score: getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCenteredReadableAxisRange = (
|
||||
coverMin: number,
|
||||
coverMax: number,
|
||||
interval: number,
|
||||
currentSplitCount: number,
|
||||
baseSplitCount: number,
|
||||
scoreRange: number,
|
||||
extraSplitScore = STEADY_AXIS_EXTRA_SPLIT_SCORE
|
||||
) => {
|
||||
const candidateRange = interval * currentSplitCount
|
||||
const coverRange = coverMax - coverMin
|
||||
|
||||
if (!Number.isFinite(candidateRange) || candidateRange < coverRange) return null
|
||||
|
||||
const center = (coverMin + coverMax) / 2
|
||||
let readableMin = Math.floor((center - candidateRange / 2) / interval) * interval
|
||||
let readableMax = readableMin + candidateRange
|
||||
|
||||
if (readableMin > coverMin) {
|
||||
const shiftCount = Math.ceil((readableMin - coverMin) / interval)
|
||||
readableMin -= shiftCount * interval
|
||||
readableMax -= shiftCount * interval
|
||||
}
|
||||
|
||||
if (readableMax < coverMax) {
|
||||
const shiftCount = Math.ceil((coverMax - readableMax) / interval)
|
||||
readableMin += shiftCount * interval
|
||||
readableMax += shiftCount * interval
|
||||
}
|
||||
|
||||
return buildReadableAxisRangeCandidate(
|
||||
readableMin,
|
||||
readableMax,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
baseSplitCount,
|
||||
coverMin,
|
||||
coverMax,
|
||||
scoreRange,
|
||||
extraSplitScore
|
||||
)
|
||||
}
|
||||
|
||||
const resolveReadableAxisRange = (
|
||||
axisMin: number,
|
||||
axisMax: number,
|
||||
splitCount: number,
|
||||
options: ReadableAxisRangeOptions = {}
|
||||
) => {
|
||||
const axisRange = axisMax - axisMin
|
||||
const rawInterval = axisRange / splitCount
|
||||
|
||||
if (!Number.isFinite(axisRange) || axisRange <= 0) {
|
||||
return {
|
||||
axisMin,
|
||||
axisMax,
|
||||
interval: rawInterval,
|
||||
splitCount
|
||||
}
|
||||
}
|
||||
|
||||
const splitCountCandidates =
|
||||
splitCount >= STEADY_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
|
||||
const compactMin = Number(options.compactMin)
|
||||
const compactMax = Number(options.compactMax)
|
||||
const canUseCompact =
|
||||
options.preferCompact && Number.isFinite(compactMin) && Number.isFinite(compactMax) && compactMax > compactMin
|
||||
const scoreRange = canUseCompact ? compactMax - compactMin : axisRange
|
||||
const candidates = splitCountCandidates.reduce<
|
||||
Array<{
|
||||
axisMin: number
|
||||
axisMax: number
|
||||
interval: number
|
||||
splitCount: number
|
||||
score: number
|
||||
}>
|
||||
>((result, currentSplitCount) => {
|
||||
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
|
||||
let readableMin = axisMin
|
||||
let readableMax = axisMax
|
||||
|
||||
for (let index = 0; index < STEADY_AXIS_READABLE_INTERVAL_STEPS.length; index += 1) {
|
||||
readableMin = Math.floor(axisMin / interval) * interval
|
||||
readableMax = readableMin + interval * currentSplitCount
|
||||
|
||||
if (readableMax < axisMax) {
|
||||
readableMax = Math.ceil(axisMax / interval) * interval
|
||||
readableMin = readableMax - interval * currentSplitCount
|
||||
}
|
||||
|
||||
if (readableMin <= axisMin && readableMax >= axisMax) break
|
||||
|
||||
interval = getReadableAxisInterval(interval * 1.01)
|
||||
}
|
||||
|
||||
const candidate = buildReadableAxisRangeCandidate(
|
||||
readableMin,
|
||||
readableMax,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
splitCount,
|
||||
axisMin,
|
||||
axisMax,
|
||||
scoreRange
|
||||
)
|
||||
if (candidate) result.push(candidate)
|
||||
|
||||
return result
|
||||
}, [])
|
||||
|
||||
if (canUseCompact) {
|
||||
const compactSplitCountCandidates = [splitCount + 2, splitCount + 3, splitCount + 4]
|
||||
|
||||
compactSplitCountCandidates.forEach(currentSplitCount => {
|
||||
getReadableAxisIntervalCandidates(axisRange / currentSplitCount).forEach(interval => {
|
||||
const candidate = resolveCenteredReadableAxisRange(
|
||||
compactMin,
|
||||
compactMax,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
splitCount,
|
||||
scoreRange,
|
||||
STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE
|
||||
)
|
||||
if (candidate) candidates.push(candidate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return candidates.reduce(
|
||||
(currentBest, candidate) => (candidate.score < currentBest.score ? candidate : currentBest),
|
||||
{
|
||||
axisMin,
|
||||
axisMax,
|
||||
interval: rawInterval,
|
||||
splitCount,
|
||||
score: Number.POSITIVE_INFINITY
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
|
||||
if (value === 0) return 0
|
||||
|
||||
const useAboveOneRatio = Math.abs(value) > 1
|
||||
const expandRatio = useAboveOneRatio ? STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE : STEADY_AXIS_EXPAND_RATIO
|
||||
const shrinkRatio = useAboveOneRatio ? STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE : STEADY_AXIS_SHRINK_RATIO
|
||||
const ratio = isMin ? (value < 0 ? expandRatio : shrinkRatio) : value > 0 ? expandRatio : shrinkRatio
|
||||
|
||||
return value * ratio
|
||||
}
|
||||
|
||||
const resolveCompactAxisBoundary = (value: number, isMin: boolean) => {
|
||||
if (value === 0) return 0
|
||||
|
||||
const ratio = isMin
|
||||
? value < 0
|
||||
? STEADY_AXIS_COMPACT_EXPAND_RATIO
|
||||
: STEADY_AXIS_COMPACT_SHRINK_RATIO
|
||||
: value > 0
|
||||
? STEADY_AXIS_COMPACT_EXPAND_RATIO
|
||||
: STEADY_AXIS_COMPACT_SHRINK_RATIO
|
||||
|
||||
return value * ratio
|
||||
}
|
||||
|
||||
const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
|
||||
if (min >= 0 || max <= 0) return false
|
||||
|
||||
const minAbs = Math.abs(min)
|
||||
const maxAbs = Math.abs(max)
|
||||
const smallerAbs = Math.min(minAbs, maxAbs)
|
||||
const largerAbs = Math.max(minAbs, maxAbs)
|
||||
|
||||
return largerAbs > 0 && smallerAbs / largerAbs >= STEADY_AXIS_BALANCED_RATIO
|
||||
}
|
||||
|
||||
const shouldUseCompactReadableAxisRange = (min: number, max: number) => {
|
||||
const dataRange = max - min
|
||||
const maxAbs = Math.max(Math.abs(min), Math.abs(max))
|
||||
|
||||
return maxAbs > 1 && dataRange > 0 && dataRange / maxAbs <= STEADY_AXIS_COMPACT_RANGE_RATIO
|
||||
}
|
||||
|
||||
const buildSteadyTrendAxisConfig = (values: number[], unit = '') => {
|
||||
if (!values.length) {
|
||||
return {
|
||||
type: 'value',
|
||||
name: unit
|
||||
}
|
||||
}
|
||||
|
||||
const min = Math.min(...values)
|
||||
const max = Math.max(...values)
|
||||
let axisMin = resolveExpandedAxisBoundary(min, true)
|
||||
let axisMax = resolveExpandedAxisBoundary(max, false)
|
||||
|
||||
if (shouldUseBalancedAxisBoundary(min, max)) {
|
||||
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
|
||||
axisMin = -axisBoundary
|
||||
axisMax = axisBoundary
|
||||
}
|
||||
|
||||
if (axisMin === axisMax) {
|
||||
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * STEADY_AXIS_EXPAND_RATIO
|
||||
axisMin -= fallbackBoundary
|
||||
axisMax += fallbackBoundary
|
||||
}
|
||||
|
||||
const useCompactReadableAxisRange = shouldUseCompactReadableAxisRange(min, max)
|
||||
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, STEADY_AXIS_DEFAULT_SPLIT_COUNT, {
|
||||
preferCompact: useCompactReadableAxisRange,
|
||||
compactMin: useCompactReadableAxisRange ? resolveCompactAxisBoundary(min, true) : undefined,
|
||||
compactMax: useCompactReadableAxisRange ? resolveCompactAxisBoundary(max, false) : undefined
|
||||
})
|
||||
const precision = getAxisBoundaryPrecision(
|
||||
readableAxisRange.axisMin,
|
||||
readableAxisRange.axisMax,
|
||||
readableAxisRange.interval
|
||||
)
|
||||
const normalizedInterval = normalizeAxisValue(readableAxisRange.interval, precision)
|
||||
|
||||
return {
|
||||
type: 'value',
|
||||
name: unit,
|
||||
nameLocation: 'middle',
|
||||
nameGap: 42,
|
||||
nameTextStyle: {
|
||||
color: STEADY_AXIS_TEXT_COLOR,
|
||||
fontSize: 12,
|
||||
align: 'center'
|
||||
},
|
||||
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
|
||||
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
|
||||
interval: normalizedInterval,
|
||||
minInterval: normalizedInterval,
|
||||
maxInterval: normalizedInterval,
|
||||
splitNumber: readableAxisRange.splitCount,
|
||||
axisLabel: {
|
||||
showMinLabel: true,
|
||||
showMaxLabel: true,
|
||||
hideOverlap: true,
|
||||
formatter: (value: number) => formatAxisLabel(value, precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applySteadyYAxisZoom = (yAxisConfig: Record<string, unknown>, yZoomScale = 1) => {
|
||||
const axisMin = Number(yAxisConfig.min)
|
||||
const axisMax = Number(yAxisConfig.max)
|
||||
const scale = Number(yZoomScale)
|
||||
|
||||
if (!Number.isFinite(axisMin) || !Number.isFinite(axisMax) || axisMin === axisMax || scale === 1) {
|
||||
return yAxisConfig
|
||||
}
|
||||
|
||||
const center = (axisMin + axisMax) / 2
|
||||
const halfRange = ((axisMax - axisMin) * scale) / 2
|
||||
const nextMin = center - halfRange
|
||||
const nextMax = center + halfRange
|
||||
const splitNumber = Math.max(Math.round(Number(yAxisConfig.splitNumber) || STEADY_AXIS_DEFAULT_SPLIT_COUNT), 1)
|
||||
const readableAxisRange = resolveReadableAxisRange(nextMin, nextMax, splitNumber)
|
||||
const precision = getAxisBoundaryPrecision(
|
||||
readableAxisRange.axisMin,
|
||||
readableAxisRange.axisMax,
|
||||
readableAxisRange.interval
|
||||
)
|
||||
const normalizedInterval = normalizeAxisValue(readableAxisRange.interval, precision)
|
||||
|
||||
return {
|
||||
...yAxisConfig,
|
||||
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
|
||||
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
|
||||
interval: normalizedInterval,
|
||||
minInterval: normalizedInterval,
|
||||
maxInterval: normalizedInterval,
|
||||
splitNumber: readableAxisRange.splitCount
|
||||
}
|
||||
}
|
||||
|
||||
const resolveSteadyTrendLineWidth = (pointCount: number) => {
|
||||
if (pointCount >= 200000) return 0.35
|
||||
if (pointCount >= 100000) return 0.45
|
||||
if (pointCount >= 50000) return 0.55
|
||||
if (pointCount >= 20000) return 0.65
|
||||
if (pointCount >= 10000) return 0.75
|
||||
if (pointCount >= 5000) return 0.9
|
||||
if (pointCount >= 2000) return 1
|
||||
if (pointCount >= 800) return 1.1
|
||||
if (pointCount >= 200) return 1.2
|
||||
|
||||
return STEADY_TREND_LINE_MAX_WIDTH
|
||||
}
|
||||
|
||||
const resolveSteadyTrendVisiblePointCount = (pointCount: number, zoomRange: SteadyTrendZoomRange) => {
|
||||
const visibleRatio = Math.max((zoomRange.end - zoomRange.start) / 100, 0)
|
||||
|
||||
return Math.ceil(pointCount * visibleRatio)
|
||||
}
|
||||
|
||||
const resolveSeriesColor = (series: SteadyDataView.SteadyTrendSeries, index: number) => {
|
||||
const phaseColor = resolvePhaseThemeColor(series.phase || '')
|
||||
|
||||
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
|
||||
}
|
||||
|
||||
export const buildSteadyTrendChartOptions = (
|
||||
seriesList: SteadyDataView.SteadyTrendSeries[],
|
||||
zoomRange: SteadyTrendZoomRange,
|
||||
showTimeAxis = true,
|
||||
chartOptions: SteadyTrendChartBuildOptions = {},
|
||||
chartTitle = ''
|
||||
): Record<string, unknown> => {
|
||||
const timeLabels = Array.from(
|
||||
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
|
||||
).sort()
|
||||
const values = seriesList.flatMap(series =>
|
||||
(series.points || [])
|
||||
.map(point => point.value)
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||
)
|
||||
const unit = seriesList.find(series => series.unit)?.unit || ''
|
||||
|
||||
return {
|
||||
activeTool: chartOptions.activeTool || 'none',
|
||||
title: {
|
||||
text: ''
|
||||
text: chartTitle,
|
||||
left: 'center',
|
||||
top: 0,
|
||||
textStyle: {
|
||||
color: '#303133',
|
||||
fontSize: 13,
|
||||
fontWeight: 600
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'line',
|
||||
snap: true,
|
||||
lineStyle: {
|
||||
color: 'rgba(24, 144, 255, 0.55)',
|
||||
width: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
type: 'scroll',
|
||||
@@ -35,26 +550,56 @@ export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTr
|
||||
right: 12
|
||||
},
|
||||
grid: {
|
||||
top: 48,
|
||||
left: 64,
|
||||
right: 28,
|
||||
bottom: 48,
|
||||
top: STEADY_TREND_GRID_TOP,
|
||||
left: STEADY_TREND_GRID_LEFT,
|
||||
right: STEADY_TREND_GRID_RIGHT,
|
||||
bottom: showTimeAxis ? 40 : 8,
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: timeLabels,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
boundaryGap: false,
|
||||
axisLine: {
|
||||
show: false,
|
||||
onZero: false,
|
||||
lineStyle: {
|
||||
color: STEADY_AXIS_LINE_COLOR
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
formatter: '{value}'
|
||||
show: showTimeAxis,
|
||||
hideOverlap: false,
|
||||
interval: 0,
|
||||
margin: showTimeAxis ? 10 : 0,
|
||||
color: STEADY_AXIS_TEXT_COLOR,
|
||||
formatter: buildSteadyTimeAxisLabelFormatter(timeLabels)
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
lineStyle: {
|
||||
color: STEADY_AXIS_LINE_COLOR
|
||||
}
|
||||
}
|
||||
},
|
||||
color: trendColors,
|
||||
series: seriesList.map(series => {
|
||||
yAxis: applySteadyYAxisZoom(buildSteadyTrendAxisConfig(values, unit), chartOptions.yZoomScale),
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
start: zoomRange.start,
|
||||
end: zoomRange.end,
|
||||
zoomOnMouseWheel: chartOptions.wheelZoomEnabled === true,
|
||||
moveOnMouseWheel: false
|
||||
},
|
||||
{
|
||||
start: zoomRange.start,
|
||||
height: 13,
|
||||
bottom: '20px',
|
||||
end: zoomRange.end
|
||||
}
|
||||
],
|
||||
color: seriesList.map(resolveSeriesColor),
|
||||
series: seriesList.map((series, index) => {
|
||||
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
|
||||
|
||||
return {
|
||||
@@ -64,9 +609,47 @@ export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTr
|
||||
connectNulls: false,
|
||||
data: timeLabels.map(time => pointMap.get(time) ?? null),
|
||||
lineStyle: {
|
||||
width: 1.2
|
||||
width: resolveSteadyTrendLineWidth(
|
||||
resolveSteadyTrendVisiblePointCount(series.points?.length || 0, zoomRange)
|
||||
),
|
||||
color: resolveSeriesColor(series, index)
|
||||
},
|
||||
itemStyle: {
|
||||
color: resolveSeriesColor(series, index)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSteadyTrendChartGroups = (
|
||||
seriesList: SteadyDataView.SteadyTrendSeries[],
|
||||
zoomRange: SteadyTrendZoomRange,
|
||||
chartOptions: SteadyTrendChartBuildOptions = {}
|
||||
): SteadyTrendChartGroup[] => {
|
||||
if (!seriesList.length) return []
|
||||
|
||||
const groupMode = resolveGroupMode(seriesList)
|
||||
const groupMap = new Map<string, SteadyDataView.SteadyTrendSeries[]>()
|
||||
|
||||
seriesList.forEach(series => {
|
||||
const key = resolveGroupKey(series, groupMode)
|
||||
const currentGroup = groupMap.get(key) || []
|
||||
currentGroup.push(series)
|
||||
groupMap.set(key, currentGroup)
|
||||
})
|
||||
|
||||
const groupEntries = Array.from(groupMap.entries())
|
||||
|
||||
return groupEntries.map(([key, groupSeries], index) => {
|
||||
const isLastChart = index === groupEntries.length - 1
|
||||
const groupTitle = resolveGroupTitle(groupSeries)
|
||||
|
||||
return {
|
||||
key,
|
||||
title: groupTitle,
|
||||
group: STEADY_TREND_CHART_GROUP,
|
||||
options: buildSteadyTrendChartOptions(groupSeries, zoomRange, isLastChart, chartOptions, groupTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface SteadyTrendFormState {
|
||||
timeRange: string[]
|
||||
timeUnit: TimePeriodUnit
|
||||
timeBaseDate: Date
|
||||
statTypes: SteadyDataView.SteadyTrendStatType[]
|
||||
statType: SteadyDataView.SteadyTrendStatType
|
||||
qualityFlag?: number
|
||||
harmonicOrders: number[]
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
|
||||
timeRange: buildTimePeriodRange('month', baseDate),
|
||||
timeUnit: 'month',
|
||||
timeBaseDate: baseDate,
|
||||
statTypes: ['AVG'],
|
||||
statType: 'AVG',
|
||||
qualityFlag: 0,
|
||||
harmonicOrders: []
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export const buildSteadyTrendQueryPayload = (
|
||||
return {
|
||||
lineIds,
|
||||
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
|
||||
statTypes: formState.statTypes,
|
||||
statTypes: [formState.statType],
|
||||
timeStart: formatSteadyTrendQueryTime(formState.timeRange[0] || ''),
|
||||
timeEnd: formatSteadyTrendQueryTime(formState.timeRange[1] || ''),
|
||||
qualityFlag: formState.qualityFlag,
|
||||
|
||||
223
frontend/src/views/steady/steadyDataView/utils/trendTable.ts
Normal file
223
frontend/src/views/steady/steadyDataView/utils/trendTable.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||
|
||||
export interface SteadyTrendTableColumn {
|
||||
prop: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SteadyTrendTableIndicatorGroup {
|
||||
key: string
|
||||
label: string
|
||||
columns: SteadyTrendTableColumn[]
|
||||
}
|
||||
|
||||
export interface SteadyTrendTableLineGroup {
|
||||
key: string
|
||||
label: string
|
||||
indicatorGroups: SteadyTrendTableIndicatorGroup[]
|
||||
}
|
||||
|
||||
export interface SteadyTrendTableModel {
|
||||
lineGroups: SteadyTrendTableLineGroup[]
|
||||
columns: SteadyTrendTableColumn[]
|
||||
timeValues: string[]
|
||||
pointValueMap: Map<string, Map<string, number | null>>
|
||||
}
|
||||
|
||||
export const createEmptySteadyTrendTableModel = (): SteadyTrendTableModel => ({
|
||||
lineGroups: [],
|
||||
columns: [],
|
||||
timeValues: [],
|
||||
pointValueMap: new Map()
|
||||
})
|
||||
|
||||
const resolvePhaseLabel = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||
const phase = String(series.phase || '').trim()
|
||||
|
||||
if (!phase) return series.seriesName || series.seriesKey
|
||||
if (phase.endsWith('相')) return phase
|
||||
|
||||
return `${phase}相`
|
||||
}
|
||||
|
||||
const resolveLineLabel = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||
return series.lineName || series.lineId || '监测点'
|
||||
}
|
||||
|
||||
const resolveIndicatorLabel = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||
const indicatorName = series.indicatorName || series.seriesName || series.indicatorCode || '指标'
|
||||
const unit = String(series.unit || '').trim()
|
||||
|
||||
return unit ? `${indicatorName}(${unit})` : indicatorName
|
||||
}
|
||||
|
||||
const resolveColumnProp = (series: SteadyDataView.SteadyTrendSeries, index: number) => {
|
||||
return (
|
||||
series.seriesKey || `${series.lineId || 'line'}_${series.indicatorCode || 'indicator'}_${series.phase || index}`
|
||||
)
|
||||
}
|
||||
|
||||
export const buildSteadyTrendTableModel = (seriesList: SteadyDataView.SteadyTrendSeries[]): SteadyTrendTableModel => {
|
||||
const lineGroupMap = new Map<
|
||||
string,
|
||||
SteadyTrendTableLineGroup & {
|
||||
indicatorGroupMap: Map<string, SteadyTrendTableIndicatorGroup>
|
||||
}
|
||||
>()
|
||||
const columns: SteadyTrendTableColumn[] = []
|
||||
const pointValueMap = new Map<string, Map<string, number | null>>()
|
||||
const timeSet = new Set<string>()
|
||||
|
||||
seriesList.forEach((series, index) => {
|
||||
const lineKey = series.lineId || series.lineName || `line-${index}`
|
||||
const indicatorKey = `${lineKey}__${series.indicatorCode || series.indicatorName || series.seriesName || index}`
|
||||
const columnProp = resolveColumnProp(series, index)
|
||||
|
||||
if (!lineGroupMap.has(lineKey)) {
|
||||
lineGroupMap.set(lineKey, {
|
||||
key: lineKey,
|
||||
label: resolveLineLabel(series),
|
||||
indicatorGroups: [],
|
||||
indicatorGroupMap: new Map()
|
||||
})
|
||||
}
|
||||
|
||||
const lineGroup = lineGroupMap.get(lineKey)!
|
||||
|
||||
if (!lineGroup.indicatorGroupMap.has(indicatorKey)) {
|
||||
const indicatorGroup = {
|
||||
key: indicatorKey,
|
||||
label: resolveIndicatorLabel(series),
|
||||
columns: []
|
||||
}
|
||||
|
||||
lineGroup.indicatorGroupMap.set(indicatorKey, indicatorGroup)
|
||||
lineGroup.indicatorGroups.push(indicatorGroup)
|
||||
}
|
||||
|
||||
const indicatorGroup = lineGroup.indicatorGroupMap.get(indicatorKey)!
|
||||
const column = {
|
||||
prop: columnProp,
|
||||
label: resolvePhaseLabel(series)
|
||||
}
|
||||
|
||||
indicatorGroup.columns.push(column)
|
||||
columns.push(column)
|
||||
|
||||
const seriesPointMap = new Map<string, number | null>()
|
||||
;(series.points || []).forEach(point => {
|
||||
if (!point.time) return
|
||||
timeSet.add(point.time)
|
||||
seriesPointMap.set(point.time, point.value)
|
||||
})
|
||||
pointValueMap.set(columnProp, seriesPointMap)
|
||||
})
|
||||
|
||||
const lineGroups = Array.from(lineGroupMap.values()).map(group => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
indicatorGroups: group.indicatorGroups
|
||||
}))
|
||||
|
||||
return {
|
||||
lineGroups,
|
||||
columns,
|
||||
timeValues: Array.from(timeSet).sort(),
|
||||
pointValueMap
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSteadyTrendTableRows = (
|
||||
model: SteadyTrendTableModel,
|
||||
startIndex: number,
|
||||
pageSize: number
|
||||
): Array<Record<string, number | string | null>> => {
|
||||
return model.timeValues.slice(startIndex, startIndex + pageSize).map(timeValue => {
|
||||
const row: Record<string, number | string | null> = {
|
||||
time: timeValue
|
||||
}
|
||||
|
||||
model.columns.forEach(column => {
|
||||
if (model.pointValueMap.get(column.prop)?.has(timeValue)) {
|
||||
row[column.prop] = model.pointValueMap.get(column.prop)?.get(timeValue) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
const escapeHtml = (value: unknown) => {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
const countLineColumns = (lineGroup: SteadyTrendTableLineGroup) => {
|
||||
return lineGroup.indicatorGroups.reduce((total, indicatorGroup) => total + indicatorGroup.columns.length, 0)
|
||||
}
|
||||
|
||||
const buildExcelHeaderRows = (model: SteadyTrendTableModel) => {
|
||||
const lineCells = model.lineGroups
|
||||
.map(lineGroup => {
|
||||
const lineColspan = countLineColumns(lineGroup)
|
||||
|
||||
return `<th colspan="${lineColspan}">${escapeHtml(lineGroup.label)}</th>`
|
||||
})
|
||||
.join('')
|
||||
const indicatorCells = model.lineGroups
|
||||
.flatMap(lineGroup =>
|
||||
lineGroup.indicatorGroups.map(
|
||||
indicatorGroup =>
|
||||
`<th colspan="${indicatorGroup.columns.length}">${escapeHtml(indicatorGroup.label)}</th>`
|
||||
)
|
||||
)
|
||||
.join('')
|
||||
const phaseCells = model.columns.map(column => `<th>${escapeHtml(column.label)}</th>`).join('')
|
||||
|
||||
return [
|
||||
`<tr><th rowspan="3">时间</th>${lineCells}</tr>`,
|
||||
`<tr>${indicatorCells}</tr>`,
|
||||
`<tr>${phaseCells}</tr>`
|
||||
].join('')
|
||||
}
|
||||
|
||||
const buildExcelBodyRows = (model: SteadyTrendTableModel) => {
|
||||
return buildSteadyTrendTableRows(model, 0, model.timeValues.length)
|
||||
.map(row => {
|
||||
const valueCells = model.columns.map(column => `<td>${escapeHtml(row[column.prop] ?? '-')}</td>`).join('')
|
||||
|
||||
return `<tr><td>${escapeHtml(row.time)}</td>${valueCells}</tr>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
export const buildSteadyTrendExcelHtml = (model: SteadyTrendTableModel) => {
|
||||
return [
|
||||
'<!DOCTYPE html>',
|
||||
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">',
|
||||
'<head>',
|
||||
'<meta charset="UTF-8" />',
|
||||
'<style>',
|
||||
'table{border-collapse:collapse;}',
|
||||
'th,td{border:1px solid #999;padding:4px 8px;white-space:nowrap;}',
|
||||
'th{background:#f5f7fa;font-weight:700;text-align:center;}',
|
||||
'td{text-align:center;}',
|
||||
'</style>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<table>',
|
||||
'<thead>',
|
||||
buildExcelHeaderRows(model),
|
||||
'</thead>',
|
||||
'<tbody>',
|
||||
buildExcelBodyRows(model),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</body>',
|
||||
'</html>'
|
||||
].join('')
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewFile = path.join(currentDir, '..', 'index.vue')
|
||||
const viewSource = fs.readFileSync(viewFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['waveform y-axis upper padding uses 1.15', /const\s+TREND_AXIS_EXPAND_RATIO\s*=\s*1\.15/],
|
||||
['waveform y-axis lower padding uses 0.85', /const\s+TREND_AXIS_SHRINK_RATIO\s*=\s*0\.85/],
|
||||
['waveform y-axis above-one upper padding uses 1.05', /const\s+TREND_AXIS_EXPAND_RATIO_ABOVE_ONE\s*=\s*1\.05/],
|
||||
['waveform y-axis above-one lower padding uses 0.95', /const\s+TREND_AXIS_SHRINK_RATIO_ABOVE_ONE\s*=\s*0\.95/],
|
||||
[
|
||||
'waveform y-axis switches padding by absolute boundary value',
|
||||
/Math\.abs\(value\)\s*>\s*1[\s\S]*TREND_AXIS_EXPAND_RATIO_ABOVE_ONE[\s\S]*TREND_AXIS_SHRINK_RATIO_ABOVE_ONE/
|
||||
],
|
||||
['waveform y-axis compact padding uses 1.015 for narrow above-one ranges', /TREND_AXIS_COMPACT_EXPAND_RATIO\s*=\s*1\.015/],
|
||||
['waveform y-axis compact padding uses 0.985 for narrow above-one ranges', /TREND_AXIS_COMPACT_SHRINK_RATIO\s*=\s*0\.985/],
|
||||
['waveform y-axis compact split penalty stays low', /TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE\s*=\s*0\.05/],
|
||||
[
|
||||
'waveform y-axis enables compact readable range for narrow above-one data',
|
||||
/shouldUseCompactReadableAxisRange[\s\S]*maxAbs\s*>\s*1[\s\S]*TREND_AXIS_COMPACT_RANGE_RATIO/
|
||||
],
|
||||
[
|
||||
'waveform integer ranges still enter readable-axis normalization',
|
||||
/rawInterval\s*>=\s*TREND_AXIS_SMALL_INTERVAL_THRESHOLD/,
|
||||
true
|
||||
],
|
||||
['waveform y-axis keeps readable interval normalization', /getReadableAxisInterval\(axisRange\s*\/\s*currentSplitCount\)/],
|
||||
[
|
||||
'waveform y-axis keeps upper boundary aligned to interval and split count',
|
||||
/normalizedRange\s*=\s*normalizeAxisValue\(normalizedInterval\s*\*\s*currentSplitCount,\s*precision\)[\s\S]*normalizedMin\s*\+\s*normalizedRange/
|
||||
],
|
||||
['waveform y-axis keeps min label visible', /showMinLabel:\s*true/],
|
||||
['waveform y-axis keeps max label visible', /showMaxLabel:\s*true/]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
|
||||
const exists = pattern.test(viewSource)
|
||||
return shouldBeMissing ? exists : !exists
|
||||
})
|
||||
|
||||
if (failures.length) {
|
||||
console.error('waveform axis range contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('waveform axis range contract check passed')
|
||||
@@ -0,0 +1,32 @@
|
||||
/* eslint-env node */
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
const viewFile = path.join(currentDir, '..', 'index.vue')
|
||||
const viewSource = fs.readFileSync(viewFile, 'utf8')
|
||||
|
||||
const expectations = [
|
||||
['waveform trend max line width is 1.3', /const\s+TREND_LINE_MAX_WIDTH\s*=\s*1\.3/],
|
||||
[
|
||||
'waveform trend line width uses visible point buckets including the final three widths',
|
||||
/resolveTrendLineWidth[\s\S]*200000\)\s*return\s*0\.35[\s\S]*100000\)\s*return\s*0\.45[\s\S]*50000\)\s*return\s*0\.55[\s\S]*20000\)\s*return\s*0\.65[\s\S]*10000\)\s*return\s*0\.75[\s\S]*5000\)\s*return\s*0\.9[\s\S]*2000\)\s*return\s*1[\s\S]*800\)\s*return\s*1\.1[\s\S]*200\)\s*return\s*1\.2[\s\S]*return\s*TREND_LINE_MAX_WIDTH/
|
||||
],
|
||||
[
|
||||
'waveform trend series applies line width from visible point count',
|
||||
/width:\s*resolveTrendLineWidth\(visiblePointCount\)/
|
||||
]
|
||||
]
|
||||
|
||||
const failures = expectations.filter(([, pattern]) => !pattern.test(viewSource))
|
||||
|
||||
if (failures.length) {
|
||||
console.error('waveform line width contract check failed:')
|
||||
for (const [name] of failures) {
|
||||
console.error(`- ${name}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('waveform line width contract check passed')
|
||||
@@ -348,12 +348,17 @@ const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean
|
||||
reset: !canResetTrendChart.value
|
||||
}))
|
||||
|
||||
const TREND_AXIS_EXPAND_RATIO = 1.2
|
||||
const TREND_AXIS_SHRINK_RATIO = 0.8
|
||||
const TREND_AXIS_EXPAND_RATIO = 1.15
|
||||
const TREND_AXIS_SHRINK_RATIO = 0.85
|
||||
const TREND_AXIS_EXPAND_RATIO_ABOVE_ONE = 1.05
|
||||
const TREND_AXIS_SHRINK_RATIO_ABOVE_ONE = 0.95
|
||||
const TREND_AXIS_COMPACT_EXPAND_RATIO = 1.015
|
||||
const TREND_AXIS_COMPACT_SHRINK_RATIO = 0.985
|
||||
const TREND_AXIS_COMPACT_RANGE_RATIO = 0.25
|
||||
const TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE = 0.05
|
||||
const TREND_AXIS_BALANCED_RATIO = 0.9
|
||||
const TREND_AXIS_DEFAULT_SPLIT_COUNT = 4
|
||||
const TREND_AXIS_COMPACT_SPLIT_COUNT = 2
|
||||
const TREND_AXIS_SMALL_INTERVAL_THRESHOLD = 1
|
||||
const TREND_AXIS_EXTRA_SPLIT_SCORE = 0.25
|
||||
const TREND_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
|
||||
const TREND_GRID_TOP = '6px'
|
||||
@@ -363,7 +368,13 @@ const TREND_GRID_BOTTOM = {
|
||||
withTimeAxis: '34px',
|
||||
withoutTimeAxis: '6px'
|
||||
}
|
||||
const TREND_LINE_MAX_WIDTH = 1.6
|
||||
const TREND_LINE_MAX_WIDTH = 1.3
|
||||
|
||||
interface ReadableAxisRangeOptions {
|
||||
preferCompact?: boolean
|
||||
compactMin?: number
|
||||
compactMax?: number
|
||||
}
|
||||
|
||||
const getAxisPrecision = (step: number) => {
|
||||
const absStep = Math.abs(step)
|
||||
@@ -435,8 +446,8 @@ const resolveTrendLineWidth = (pointCount: number) => {
|
||||
if (pointCount >= 10000) return 0.75
|
||||
if (pointCount >= 5000) return 0.9
|
||||
if (pointCount >= 2000) return 1
|
||||
if (pointCount >= 800) return 1.2
|
||||
if (pointCount >= 200) return 1.4
|
||||
if (pointCount >= 800) return 1.1
|
||||
if (pointCount >= 200) return 1.2
|
||||
|
||||
return TREND_LINE_MAX_WIDTH
|
||||
}
|
||||
@@ -510,26 +521,113 @@ const applyYAxisZoom = (yAxisConfig: Record<string, unknown>) => {
|
||||
const halfRange = ((axisMax - axisMin) * scale) / 2
|
||||
const nextMin = center - halfRange
|
||||
const nextMax = center + halfRange
|
||||
const splitNumber = Number(yAxisConfig.splitNumber) || TREND_AXIS_DEFAULT_SPLIT_COUNT
|
||||
const interval = (nextMax - nextMin) / splitNumber
|
||||
const precision = getAxisBoundaryPrecision(nextMin, nextMax, interval)
|
||||
const normalizedInterval = normalizeAxisValue(interval, precision)
|
||||
const splitNumber = Math.max(Math.round(Number(yAxisConfig.splitNumber) || TREND_AXIS_DEFAULT_SPLIT_COUNT), 1)
|
||||
const readableAxisRange = resolveReadableAxisRange(nextMin, nextMax, splitNumber)
|
||||
const precision = getAxisBoundaryPrecision(
|
||||
readableAxisRange.axisMin,
|
||||
readableAxisRange.axisMax,
|
||||
readableAxisRange.interval
|
||||
)
|
||||
const normalizedInterval = normalizeAxisValue(readableAxisRange.interval, precision)
|
||||
|
||||
return {
|
||||
...yAxisConfig,
|
||||
min: normalizeAxisValue(nextMin, precision),
|
||||
max: normalizeAxisValue(nextMax, precision),
|
||||
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
|
||||
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
|
||||
interval: normalizedInterval,
|
||||
minInterval: normalizedInterval,
|
||||
maxInterval: normalizedInterval
|
||||
maxInterval: normalizedInterval,
|
||||
splitNumber: readableAxisRange.splitCount
|
||||
}
|
||||
}
|
||||
|
||||
const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount: number) => {
|
||||
const getReadableAxisIntervalCandidates = (value: number) => {
|
||||
return Array.from(new Set([getReadableAxisInterval(value), getReadableAxisInterval(value / 2)])).filter(
|
||||
item => Number.isFinite(item) && item > 0
|
||||
)
|
||||
}
|
||||
|
||||
const buildReadableAxisRangeCandidate = (
|
||||
readableMin: number,
|
||||
interval: number,
|
||||
currentSplitCount: number,
|
||||
baseSplitCount: number,
|
||||
coverMin: number,
|
||||
coverMax: number,
|
||||
scoreRange: number,
|
||||
extraSplitScore = TREND_AXIS_EXTRA_SPLIT_SCORE
|
||||
) => {
|
||||
const precision = getAxisPrecision(interval)
|
||||
const normalizedMin = normalizeAxisValue(readableMin, precision)
|
||||
const normalizedInterval = normalizeAxisValue(interval, precision)
|
||||
const normalizedRange = normalizeAxisValue(normalizedInterval * currentSplitCount, precision)
|
||||
const normalizedMax = normalizeAxisValue(normalizedMin + normalizedRange, precision)
|
||||
const candidateRange = normalizedMax - normalizedMin
|
||||
const epsilon = Math.max(Math.abs(normalizedInterval), 1) * 1e-10
|
||||
|
||||
if (normalizedMin - coverMin > epsilon || coverMax - normalizedMax > epsilon || candidateRange <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const extraSplitCost = Math.max(currentSplitCount - baseSplitCount, 0) * extraSplitScore
|
||||
const wasteRatio = scoreRange > 0 ? Math.max((candidateRange - scoreRange) / scoreRange, 0) : 0
|
||||
|
||||
return {
|
||||
axisMin: normalizedMin,
|
||||
axisMax: normalizedMax,
|
||||
interval: normalizedInterval,
|
||||
splitCount: currentSplitCount,
|
||||
score: getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCenteredReadableAxisRange = (
|
||||
coverMin: number,
|
||||
coverMax: number,
|
||||
interval: number,
|
||||
currentSplitCount: number,
|
||||
baseSplitCount: number,
|
||||
scoreRange: number,
|
||||
extraSplitScore = TREND_AXIS_EXTRA_SPLIT_SCORE
|
||||
) => {
|
||||
const candidateRange = interval * currentSplitCount
|
||||
const coverRange = coverMax - coverMin
|
||||
|
||||
if (!Number.isFinite(candidateRange) || candidateRange < coverRange) return null
|
||||
|
||||
const center = (coverMin + coverMax) / 2
|
||||
let readableMin = Math.floor((center - candidateRange / 2) / interval) * interval
|
||||
let readableMax = readableMin + candidateRange
|
||||
|
||||
if (readableMin > coverMin) {
|
||||
const shiftCount = Math.ceil((readableMin - coverMin) / interval)
|
||||
readableMin -= shiftCount * interval
|
||||
readableMax -= shiftCount * interval
|
||||
}
|
||||
|
||||
if (readableMax < coverMax) {
|
||||
const shiftCount = Math.ceil((coverMax - readableMax) / interval)
|
||||
readableMin += shiftCount * interval
|
||||
readableMax += shiftCount * interval
|
||||
}
|
||||
|
||||
return buildReadableAxisRangeCandidate(
|
||||
readableMin,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
baseSplitCount,
|
||||
coverMin,
|
||||
coverMax,
|
||||
scoreRange,
|
||||
extraSplitScore
|
||||
)
|
||||
}
|
||||
|
||||
const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount: number, options: ReadableAxisRangeOptions = {}) => {
|
||||
const axisRange = axisMax - axisMin
|
||||
const rawInterval = axisRange / splitCount
|
||||
|
||||
if (!Number.isFinite(axisRange) || axisRange <= 0 || rawInterval >= TREND_AXIS_SMALL_INTERVAL_THRESHOLD) {
|
||||
if (!Number.isFinite(axisRange) || axisRange <= 0) {
|
||||
return {
|
||||
axisMin,
|
||||
axisMax,
|
||||
@@ -538,12 +636,22 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
|
||||
}
|
||||
}
|
||||
|
||||
const splitCountCandidates =
|
||||
splitCount >= TREND_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
|
||||
|
||||
return splitCountCandidates.reduce(
|
||||
(currentBest, currentSplitCount) => {
|
||||
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
|
||||
const splitCountCandidates = splitCount >= TREND_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
|
||||
const compactMin = Number(options.compactMin)
|
||||
const compactMax = Number(options.compactMax)
|
||||
const canUseCompact =
|
||||
options.preferCompact && Number.isFinite(compactMin) && Number.isFinite(compactMax) && compactMax > compactMin
|
||||
const scoreRange = canUseCompact ? compactMax - compactMin : axisRange
|
||||
const candidates = splitCountCandidates.reduce<
|
||||
Array<{
|
||||
axisMin: number
|
||||
axisMax: number
|
||||
interval: number
|
||||
splitCount: number
|
||||
score: number
|
||||
}>
|
||||
>((result, currentSplitCount) => {
|
||||
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
|
||||
let readableMin = axisMin
|
||||
let readableMax = axisMax
|
||||
|
||||
@@ -561,24 +669,41 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
|
||||
interval = getReadableAxisInterval(interval * 1.01)
|
||||
}
|
||||
|
||||
const precision = getAxisPrecision(interval)
|
||||
const normalizedMin = normalizeAxisValue(readableMin, precision)
|
||||
const normalizedMax = normalizeAxisValue(readableMax, precision)
|
||||
const normalizedInterval = normalizeAxisValue(interval, precision)
|
||||
const extraSplitCost = Math.max(currentSplitCount - splitCount, 0) * TREND_AXIS_EXTRA_SPLIT_SCORE
|
||||
const wasteRatio = (normalizedMax - normalizedMin - axisRange) / axisRange
|
||||
const score = getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
|
||||
const candidate = buildReadableAxisRangeCandidate(
|
||||
readableMin,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
splitCount,
|
||||
axisMin,
|
||||
axisMax,
|
||||
scoreRange
|
||||
)
|
||||
if (candidate) result.push(candidate)
|
||||
|
||||
if (score >= currentBest.score) return currentBest
|
||||
return result
|
||||
}, [])
|
||||
|
||||
return {
|
||||
axisMin: normalizedMin,
|
||||
axisMax: normalizedMax,
|
||||
interval: normalizedInterval,
|
||||
splitCount: currentSplitCount,
|
||||
score
|
||||
}
|
||||
},
|
||||
if (canUseCompact) {
|
||||
const compactSplitCountCandidates = [splitCount + 2, splitCount + 3, splitCount + 4]
|
||||
|
||||
compactSplitCountCandidates.forEach(currentSplitCount => {
|
||||
getReadableAxisIntervalCandidates(axisRange / currentSplitCount).forEach(interval => {
|
||||
const candidate = resolveCenteredReadableAxisRange(
|
||||
compactMin,
|
||||
compactMax,
|
||||
interval,
|
||||
currentSplitCount,
|
||||
splitCount,
|
||||
scoreRange,
|
||||
TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE
|
||||
)
|
||||
if (candidate) candidates.push(candidate)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return candidates.reduce(
|
||||
(currentBest, candidate) => (candidate.score < currentBest.score ? candidate : currentBest),
|
||||
{
|
||||
axisMin,
|
||||
axisMax,
|
||||
@@ -592,13 +717,30 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
|
||||
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
|
||||
if (value === 0) return 0
|
||||
|
||||
const useAboveOneRatio = Math.abs(value) > 1
|
||||
const expandRatio = useAboveOneRatio ? TREND_AXIS_EXPAND_RATIO_ABOVE_ONE : TREND_AXIS_EXPAND_RATIO
|
||||
const shrinkRatio = useAboveOneRatio ? TREND_AXIS_SHRINK_RATIO_ABOVE_ONE : TREND_AXIS_SHRINK_RATIO
|
||||
const ratio = isMin
|
||||
? value < 0
|
||||
? TREND_AXIS_EXPAND_RATIO
|
||||
: TREND_AXIS_SHRINK_RATIO
|
||||
? expandRatio
|
||||
: shrinkRatio
|
||||
: value > 0
|
||||
? TREND_AXIS_EXPAND_RATIO
|
||||
: TREND_AXIS_SHRINK_RATIO
|
||||
? expandRatio
|
||||
: shrinkRatio
|
||||
|
||||
return value * ratio
|
||||
}
|
||||
|
||||
const resolveCompactAxisBoundary = (value: number, isMin: boolean) => {
|
||||
if (value === 0) return 0
|
||||
|
||||
const ratio = isMin
|
||||
? value < 0
|
||||
? TREND_AXIS_COMPACT_EXPAND_RATIO
|
||||
: TREND_AXIS_COMPACT_SHRINK_RATIO
|
||||
: value > 0
|
||||
? TREND_AXIS_COMPACT_EXPAND_RATIO
|
||||
: TREND_AXIS_COMPACT_SHRINK_RATIO
|
||||
|
||||
return value * ratio
|
||||
}
|
||||
@@ -618,6 +760,13 @@ const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
|
||||
return largerAbs > 0 && smallerAbs / largerAbs >= TREND_AXIS_BALANCED_RATIO
|
||||
}
|
||||
|
||||
const shouldUseCompactReadableAxisRange = (min: number, max: number) => {
|
||||
const dataRange = max - min
|
||||
const maxAbs = Math.max(Math.abs(min), Math.abs(max))
|
||||
|
||||
return maxAbs > 1 && dataRange > 0 && dataRange / maxAbs <= TREND_AXIS_COMPACT_RANGE_RATIO
|
||||
}
|
||||
|
||||
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = TREND_AXIS_DEFAULT_SPLIT_COUNT) => {
|
||||
const min = Number(trendPayload.min)
|
||||
const max = Number(trendPayload.max)
|
||||
@@ -645,7 +794,12 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
|
||||
}
|
||||
|
||||
const safeSplitCount = Math.max(Math.round(splitCount), 1)
|
||||
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount)
|
||||
const useCompactReadableAxisRange = shouldUseCompactReadableAxisRange(min, max)
|
||||
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount, {
|
||||
preferCompact: useCompactReadableAxisRange,
|
||||
compactMin: useCompactReadableAxisRange ? resolveCompactAxisBoundary(min, true) : undefined,
|
||||
compactMax: useCompactReadableAxisRange ? resolveCompactAxisBoundary(max, false) : undefined
|
||||
})
|
||||
const precision = getAxisBoundaryPrecision(
|
||||
readableAxisRange.axisMin,
|
||||
readableAxisRange.axisMax,
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
const readThemeColor = (name: string, fallback: string) => {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
import { getDefaultPhaseThemeColors, readThemeColor } from '@/utils/phaseColors'
|
||||
|
||||
const phaseColors = {
|
||||
a: readThemeColor('--cn-color-phase-a', '#daa520'),
|
||||
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
|
||||
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
|
||||
}
|
||||
|
||||
export const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
|
||||
export const defaultPhaseColors = getDefaultPhaseThemeColors()
|
||||
export const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
|
||||
export const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')
|
||||
|
||||
Reference in New Issue
Block a user