|
|
@@ -0,0 +1,331 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ :style="{
|
|
|
+ ...styleMap?.mainStyle
|
|
|
+ }"
|
|
|
+ class="relative w-full h-full box-border overflow-hidden"
|
|
|
+ >
|
|
|
+ <ImageBg
|
|
|
+ v-if="styleMap?.mainStyle?.imageSrc"
|
|
|
+ :src="styleMap?.mainStyle?.imageSrc"
|
|
|
+ :imageStyle="styleMap?.mainStyle?.imageStyle"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div class="absolute inset-0 w-full h-full">
|
|
|
+ <svg
|
|
|
+ :viewBox="`0 0 ${width} ${height}`"
|
|
|
+ preserveAspectRatio="xMidYMid meet"
|
|
|
+ class="w-full h-full block"
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ >
|
|
|
+ <!-- 网格线 -->
|
|
|
+ <g v-if="hasGrid">
|
|
|
+ <line
|
|
|
+ v-for="(line, index) in verticalGridLines"
|
|
|
+ :key="'v-' + index"
|
|
|
+ :x1="line.x1"
|
|
|
+ :y1="line.y1"
|
|
|
+ :x2="line.x2"
|
|
|
+ :y2="line.y2"
|
|
|
+ :stroke="gridLineColor"
|
|
|
+ :stroke-width="gridLineWidth"
|
|
|
+ :stroke-dasharray="gridDashArray"
|
|
|
+ :opacity="gridLineOpacity"
|
|
|
+ />
|
|
|
+ <line
|
|
|
+ v-for="(line, index) in horizontalGridLines"
|
|
|
+ :key="'h-' + index"
|
|
|
+ :x1="line.x1"
|
|
|
+ :y1="line.y1"
|
|
|
+ :x2="line.x2"
|
|
|
+ :y2="line.y2"
|
|
|
+ :stroke="gridLineColor"
|
|
|
+ :stroke-width="gridLineWidth"
|
|
|
+ :stroke-dasharray="gridDashArray"
|
|
|
+ :opacity="gridLineOpacity"
|
|
|
+ />
|
|
|
+ </g>
|
|
|
+
|
|
|
+ <!-- 折线图 -->
|
|
|
+ <g v-if="chart_type === 'line'">
|
|
|
+ <path
|
|
|
+ v-for="serie in lineSeries"
|
|
|
+ :key="`line-${serie.key}`"
|
|
|
+ :d="serie.path"
|
|
|
+ fill="none"
|
|
|
+ :stroke="serie.color"
|
|
|
+ :stroke-width="2"
|
|
|
+ />
|
|
|
+ <g v-if="!hide_line_points">
|
|
|
+ <circle
|
|
|
+ v-for="point in linePoints"
|
|
|
+ :key="point.key"
|
|
|
+ :cx="point.x"
|
|
|
+ :cy="point.y"
|
|
|
+ r="3"
|
|
|
+ :fill="point.color"
|
|
|
+ />
|
|
|
+ </g>
|
|
|
+ </g>
|
|
|
+
|
|
|
+ <!-- 柱状图 -->
|
|
|
+ <g v-else-if="chart_type === 'bar'">
|
|
|
+ <rect
|
|
|
+ v-for="bar in barSeries"
|
|
|
+ :key="bar.key"
|
|
|
+ :x="bar.x"
|
|
|
+ :y="bar.y"
|
|
|
+ :width="bar.width"
|
|
|
+ :height="bar.height"
|
|
|
+ :fill="bar.color"
|
|
|
+ />
|
|
|
+ </g>
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed } from 'vue'
|
|
|
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
|
|
|
+
|
|
|
+import ImageBg from '../ImageBg.vue'
|
|
|
+import type { DataSection } from './Config.vue'
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ styles: any
|
|
|
+ state?: string
|
|
|
+ part?: string
|
|
|
+ column: number
|
|
|
+ row: number
|
|
|
+ chart_type: 'line' | 'bar' | 'none'
|
|
|
+ hide_line_points: boolean
|
|
|
+ axis_y: {
|
|
|
+ min: number
|
|
|
+ max: number
|
|
|
+ }
|
|
|
+ open_axis_y2: boolean
|
|
|
+ axis_y2?: {
|
|
|
+ min: number
|
|
|
+ max: number
|
|
|
+ }
|
|
|
+ chart_data: DataSection[]
|
|
|
+}>()
|
|
|
+
|
|
|
+const styleMap = useWidgetStyle({
|
|
|
+ widget: 'lv_chart',
|
|
|
+ props
|
|
|
+})
|
|
|
+
|
|
|
+// 主样式配置(获取折线虚线样式)
|
|
|
+const mainStyleConfig = computed(() =>
|
|
|
+ props.styles?.find(
|
|
|
+ (s: any) =>
|
|
|
+ s.part?.name === 'main' && (s.part?.state === props.state || s.part?.state === 'default')
|
|
|
+ )
|
|
|
+)
|
|
|
+
|
|
|
+const gridLineWidth = computed(() => styleMap.value?.mainStyle?.line?.width ?? 1)
|
|
|
+const gridLineColor = computed(() => styleMap.value?.mainStyle?.line?.color ?? '#e0e0e0')
|
|
|
+const gridLineOpacity = computed(() => styleMap.value?.mainStyle?.line?.opacity ?? 1)
|
|
|
+
|
|
|
+const gridDashArray = computed(() => {
|
|
|
+ const dashWidth = mainStyleConfig.value?.line?.dashWidth ?? 0
|
|
|
+ const dashGap = mainStyleConfig.value?.line?.dashGap ?? 0
|
|
|
+ if (dashWidth > 0 || dashGap > 0) {
|
|
|
+ return `${dashWidth} ${dashGap}`
|
|
|
+ }
|
|
|
+ return undefined
|
|
|
+})
|
|
|
+
|
|
|
+// 安全内边距,模拟 LVGL chart 的安全区域,避免图形/网格贴边框
|
|
|
+const padding = 8
|
|
|
+
|
|
|
+const innerLeft = computed(() => padding)
|
|
|
+const innerRight = computed(() => Math.max(padding, props.width - padding))
|
|
|
+const innerTop = computed(() => padding)
|
|
|
+const innerBottom = computed(() => Math.max(padding, props.height - padding))
|
|
|
+
|
|
|
+const innerWidth = computed(() => Math.max(0, innerRight.value - innerLeft.value))
|
|
|
+const innerHeight = computed(() => Math.max(0, innerBottom.value - innerTop.value))
|
|
|
+
|
|
|
+const hasGrid = computed(() => (props.column ?? 0) > 0 || (props.row ?? 0) > 0)
|
|
|
+
|
|
|
+// 网格线
|
|
|
+const verticalGridLines = computed(() => {
|
|
|
+ const count = Math.max(0, props.column ?? 0)
|
|
|
+ const lines: { x1: number; y1: number; x2: number; y2: number }[] = []
|
|
|
+ if (!innerWidth.value || count <= 0) return lines
|
|
|
+
|
|
|
+ // 从安全内边距开始计算:
|
|
|
+ // column = 4 时,i = 0..3,第一条在左侧安全边距,最后一条在右侧安全边距,中间等分
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const t = count === 1 ? 0.5 : i / (count - 1)
|
|
|
+ const x = innerLeft.value + t * innerWidth.value
|
|
|
+ lines.push({
|
|
|
+ x1: x,
|
|
|
+ y1: 0,
|
|
|
+ x2: x,
|
|
|
+ y2: props.height
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return lines.length >= 2 ? lines : []
|
|
|
+})
|
|
|
+
|
|
|
+const horizontalGridLines = computed(() => {
|
|
|
+ const count = Math.max(0, props.row ?? 0)
|
|
|
+ const lines: { x1: number; y1: number; x2: number; y2: number }[] = []
|
|
|
+ if (!innerHeight.value || count <= 0) return lines
|
|
|
+
|
|
|
+ // 从安全内边距开始计算:
|
|
|
+ // row = 4 时,i = 0..3,第一条在上侧安全边距,最后一条在下侧安全边距,中间等分
|
|
|
+ for (let i = 0; i < count; i++) {
|
|
|
+ const t = count === 1 ? 0.5 : i / (count - 1)
|
|
|
+ const y = innerTop.value + t * innerHeight.value
|
|
|
+ lines.push({
|
|
|
+ x1: 0,
|
|
|
+ y1: y,
|
|
|
+ x2: props.width,
|
|
|
+ y2: y
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return lines.length >= 2 ? lines : []
|
|
|
+})
|
|
|
+
|
|
|
+type AxisKey = 'y' | 'y2'
|
|
|
+
|
|
|
+const getAxisRange = (axis: AxisKey) => {
|
|
|
+ const cfg = axis === 'y2' && props.open_axis_y2 ? props.axis_y2 || props.axis_y : props.axis_y
|
|
|
+ let min = Number(cfg?.min ?? 0)
|
|
|
+ let max = Number(cfg?.max ?? 100)
|
|
|
+
|
|
|
+ if (min === max) {
|
|
|
+ const delta = Math.abs(min || 1)
|
|
|
+ min -= delta
|
|
|
+ max += delta
|
|
|
+ }
|
|
|
+
|
|
|
+ if (min > max) {
|
|
|
+ const temp = min
|
|
|
+ min = max
|
|
|
+ max = temp
|
|
|
+ }
|
|
|
+
|
|
|
+ return { min, max }
|
|
|
+}
|
|
|
+
|
|
|
+const mapValueToY = (value: number, axis: AxisKey) => {
|
|
|
+ const { min, max } = getAxisRange(axis)
|
|
|
+ const span = max - min
|
|
|
+ if (!innerHeight.value || span === 0) {
|
|
|
+ return (innerTop.value + innerBottom.value) / 2
|
|
|
+ }
|
|
|
+ const t = (value - min) / span
|
|
|
+ const clamped = Math.max(0, Math.min(1, t))
|
|
|
+ return innerBottom.value - clamped * innerHeight.value
|
|
|
+}
|
|
|
+
|
|
|
+const mapIndexToX = (index: number, total: number) => {
|
|
|
+ if (!innerWidth.value) {
|
|
|
+ return (innerLeft.value + innerRight.value) / 2
|
|
|
+ }
|
|
|
+ if (total <= 1) {
|
|
|
+ return innerLeft.value + innerWidth.value / 2
|
|
|
+ }
|
|
|
+ const t = index / (total - 1)
|
|
|
+ return innerLeft.value + t * innerWidth.value
|
|
|
+}
|
|
|
+
|
|
|
+const seriesList = computed(() => (props.chart_data || []) as DataSection[])
|
|
|
+
|
|
|
+// 折线图数据
|
|
|
+const lineSeries = computed(() => {
|
|
|
+ return seriesList.value.map((serie, index) => {
|
|
|
+ const total = Array.isArray(serie.points) ? serie.points.length : 0
|
|
|
+ const axis: AxisKey = serie.chart_axis === 'y2' ? 'y2' : 'y'
|
|
|
+
|
|
|
+ if (!total || !innerWidth.value || !innerHeight.value) {
|
|
|
+ return {
|
|
|
+ key: serie.name || index,
|
|
|
+ color: serie.color || '#2092f5ff',
|
|
|
+ path: '',
|
|
|
+ points: [] as { x: number; y: number; color: string; key: string }[]
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const points = serie.points.map((v, idx) => {
|
|
|
+ const x = mapIndexToX(idx, total)
|
|
|
+ const y = mapValueToY(v, axis)
|
|
|
+ return {
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ color: serie.color || '#2092f5ff',
|
|
|
+ key: `${serie.name || index}-${idx}`
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: serie.name || index,
|
|
|
+ color: serie.color || '#2092f5ff',
|
|
|
+ path,
|
|
|
+ points
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+const linePoints = computed(() => lineSeries.value.flatMap((serie) => serie.points))
|
|
|
+
|
|
|
+// 柱状图数据
|
|
|
+const barSeries = computed(() => {
|
|
|
+ const series = seriesList.value.filter((s) => Array.isArray(s.points) && s.points.length > 0)
|
|
|
+ const seriesCount = series.length
|
|
|
+ if (!seriesCount || !innerWidth.value || !innerHeight.value) return []
|
|
|
+
|
|
|
+ const maxPointCount = series.reduce((max, s) => Math.max(max, s.points.length), 0)
|
|
|
+ if (!maxPointCount) return []
|
|
|
+
|
|
|
+ const groupSpace = innerWidth.value / maxPointCount
|
|
|
+ const groupWidth = groupSpace * 0.7
|
|
|
+ const barWidth = groupWidth / seriesCount
|
|
|
+ const offsetInGroup = (groupSpace - groupWidth) / 2
|
|
|
+
|
|
|
+ const bars: {
|
|
|
+ key: string
|
|
|
+ x: number
|
|
|
+ y: number
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ color: string
|
|
|
+ }[] = []
|
|
|
+
|
|
|
+ for (let i = 0; i < maxPointCount; i++) {
|
|
|
+ const groupStart = innerLeft.value + i * groupSpace + offsetInGroup
|
|
|
+
|
|
|
+ series.forEach((serie, sIndex) => {
|
|
|
+ const v = serie.points[i]
|
|
|
+ if (typeof v !== 'number') return
|
|
|
+ const axis: AxisKey = serie.chart_axis === 'y2' ? 'y2' : 'y'
|
|
|
+
|
|
|
+ const yValue = mapValueToY(v, axis)
|
|
|
+ const baseY = mapValueToY(getAxisRange(axis).min, axis)
|
|
|
+ const top = Math.min(yValue, baseY)
|
|
|
+ const bottom = Math.max(yValue, baseY)
|
|
|
+
|
|
|
+ bars.push({
|
|
|
+ key: `${serie.name || sIndex}-${i}`,
|
|
|
+ x: groupStart + sIndex * barWidth,
|
|
|
+ y: top,
|
|
|
+ width: barWidth,
|
|
|
+ height: Math.max(1, bottom - top),
|
|
|
+ color: serie.color || '#2092f5ff'
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return bars
|
|
|
+})
|
|
|
+</script>
|