|
|
@@ -0,0 +1,648 @@
|
|
|
+<template>
|
|
|
+ <div :style="styleMap?.mainStyle" class="relative w-full h-full box-border overflow-visible">
|
|
|
+ <div
|
|
|
+ v-if="mainBackgroundImageSrc"
|
|
|
+ class="absolute pointer-events-none flex items-center justify-center overflow-hidden"
|
|
|
+ :style="backgroundImageWrapStyle"
|
|
|
+ >
|
|
|
+ <ImageBg
|
|
|
+ v-if="mainBackgroundUseMask"
|
|
|
+ :src="mainBackgroundImageSrc"
|
|
|
+ :imageStyle="mainBackgroundMaskStyle"
|
|
|
+ />
|
|
|
+ <img
|
|
|
+ v-else
|
|
|
+ :src="mainBackgroundImageSrc"
|
|
|
+ alt=""
|
|
|
+ class="block max-w-none max-h-none"
|
|
|
+ :style="{ opacity: mainBackgroundOpacity }"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <svg
|
|
|
+ :viewBox="`0 0 ${width} ${height}`"
|
|
|
+ class="block w-full h-full overflow-visible"
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ >
|
|
|
+ <defs>
|
|
|
+ <pattern
|
|
|
+ v-if="mainCurveImageSrc"
|
|
|
+ :id="mainCurvePatternId"
|
|
|
+ patternUnits="userSpaceOnUse"
|
|
|
+ :x="curvePatternBox.x"
|
|
|
+ :y="curvePatternBox.y"
|
|
|
+ :width="curvePatternBox.size"
|
|
|
+ :height="curvePatternBox.size"
|
|
|
+ >
|
|
|
+ <image
|
|
|
+ :href="mainCurveImageSrc"
|
|
|
+ :x="curvePatternBox.x"
|
|
|
+ :y="curvePatternBox.y"
|
|
|
+ :width="curvePatternBox.size"
|
|
|
+ :height="curvePatternBox.size"
|
|
|
+ preserveAspectRatio="xMidYMid meet"
|
|
|
+ :opacity="mainCurveOpacity"
|
|
|
+ />
|
|
|
+ </pattern>
|
|
|
+ </defs>
|
|
|
+
|
|
|
+ <path
|
|
|
+ v-if="mainCurveWidth > 0 && mainArcPath"
|
|
|
+ :d="mainArcPath"
|
|
|
+ fill="none"
|
|
|
+ :stroke="mainCurveStroke"
|
|
|
+ :stroke-width="mainCurveWidth"
|
|
|
+ :stroke-linecap="mainCurveLinecap"
|
|
|
+ />
|
|
|
+
|
|
|
+ <path
|
|
|
+ v-for="section in sectionCurves"
|
|
|
+ :key="section.key"
|
|
|
+ :d="section.path"
|
|
|
+ fill="none"
|
|
|
+ :stroke="section.stroke"
|
|
|
+ :stroke-width="section.strokeWidth"
|
|
|
+ :stroke-linecap="mainCurveLinecap"
|
|
|
+ />
|
|
|
+
|
|
|
+ <line
|
|
|
+ v-for="tick in minorTicks"
|
|
|
+ :key="tick.key"
|
|
|
+ :x1="tick.x1"
|
|
|
+ :y1="tick.y1"
|
|
|
+ :x2="tick.x2"
|
|
|
+ :y2="tick.y2"
|
|
|
+ :stroke="tick.stroke"
|
|
|
+ :stroke-width="tick.strokeWidth"
|
|
|
+ :stroke-linecap="tick.linecap"
|
|
|
+ />
|
|
|
+
|
|
|
+ <line
|
|
|
+ v-for="tick in majorTicks"
|
|
|
+ :key="tick.key"
|
|
|
+ :x1="tick.x1"
|
|
|
+ :y1="tick.y1"
|
|
|
+ :x2="tick.x2"
|
|
|
+ :y2="tick.y2"
|
|
|
+ :stroke="tick.stroke"
|
|
|
+ :stroke-width="tick.strokeWidth"
|
|
|
+ :stroke-linecap="tick.linecap"
|
|
|
+ />
|
|
|
+
|
|
|
+ <text
|
|
|
+ v-for="label in labelItems"
|
|
|
+ :key="label.key"
|
|
|
+ :x="label.x"
|
|
|
+ :y="label.y"
|
|
|
+ text-anchor="middle"
|
|
|
+ dominant-baseline="middle"
|
|
|
+ :fill="label.fill"
|
|
|
+ :font-size="label.fontSize"
|
|
|
+ :font-family="label.fontFamily"
|
|
|
+ :letter-spacing="label.letterSpacing"
|
|
|
+ :text-decoration="label.decoration"
|
|
|
+ >
|
|
|
+ {{ label.text }}
|
|
|
+ </text>
|
|
|
+
|
|
|
+ <line
|
|
|
+ v-for="needle in lineNeedles"
|
|
|
+ :key="needle.key"
|
|
|
+ :x1="needle.x1"
|
|
|
+ :y1="needle.y1"
|
|
|
+ :x2="needle.x2"
|
|
|
+ :y2="needle.y2"
|
|
|
+ :stroke="needle.stroke"
|
|
|
+ :stroke-width="needle.strokeWidth"
|
|
|
+ :stroke-linecap="needle.linecap"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="needle in imageNeedles"
|
|
|
+ :key="needle.key"
|
|
|
+ class="absolute pointer-events-none"
|
|
|
+ :style="needle.style"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ v-if="!needle.useMask"
|
|
|
+ :src="needle.src"
|
|
|
+ class="block w-full h-full"
|
|
|
+ :style="{ opacity: needle.opacity }"
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ <ImageBg v-else :src="needle.src" :imageStyle="needle.imageStyle" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, type CSSProperties } from 'vue'
|
|
|
+import { useProjectStore } from '@/store/modules/project'
|
|
|
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
|
|
|
+import ImageBg from '../ImageBg.vue'
|
|
|
+import type { BaseMeterNeedle, BaseMeterSection } from './types'
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ id?: string
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ styles: any[]
|
|
|
+ state?: string
|
|
|
+ part?: string
|
|
|
+ mode: 'round_inner' | 'round_outer'
|
|
|
+ tick: number
|
|
|
+ mainTick: number
|
|
|
+ rangeStart: number
|
|
|
+ rangeEnd: number
|
|
|
+ angleRange: number
|
|
|
+ rotationAngle: number
|
|
|
+ enableText: boolean
|
|
|
+ labels: string
|
|
|
+ needles: BaseMeterNeedle[]
|
|
|
+ sections: BaseMeterSection[]
|
|
|
+}>()
|
|
|
+
|
|
|
+const projectStore = useProjectStore()
|
|
|
+
|
|
|
+const styleMap = useWidgetStyle({
|
|
|
+ widget: 'base_meter',
|
|
|
+ props
|
|
|
+})
|
|
|
+
|
|
|
+const getStyleConfig = (partName: string) => {
|
|
|
+ const targetState = props.part === partName ? props.state : 'default'
|
|
|
+
|
|
|
+ return (
|
|
|
+ props.styles?.find(
|
|
|
+ (item) => item.part?.name === partName && item.part?.state === targetState
|
|
|
+ ) ||
|
|
|
+ props.styles?.find((item) => item.part?.name === partName && item.part?.state === 'default')
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const indicatorStyleConfig = computed(() => getStyleConfig('indicator'))
|
|
|
+const itemsStyleConfig = computed(() => getStyleConfig('items'))
|
|
|
+
|
|
|
+const clamp = (value: number, min: number, max: number) => {
|
|
|
+ return Math.min(max, Math.max(min, value))
|
|
|
+}
|
|
|
+
|
|
|
+const isTransparentColor = (color?: string) => {
|
|
|
+ if (!color) return true
|
|
|
+ const normalized = color.trim().toLowerCase()
|
|
|
+ return normalized === '#00000000' || normalized.endsWith('00')
|
|
|
+}
|
|
|
+
|
|
|
+const getImageSrc = (imageId?: string) => {
|
|
|
+ if (!imageId) return ''
|
|
|
+ const image = projectStore.project?.resources.images.find((item) => item.id === imageId)
|
|
|
+ if (!image) return ''
|
|
|
+ return `local:///${(projectStore.projectPath + image.path).replaceAll('\\', '/')}`
|
|
|
+}
|
|
|
+
|
|
|
+const width = computed(() => Math.max(1, Number(props.width) || 1))
|
|
|
+const height = computed(() => Math.max(1, Number(props.height) || 1))
|
|
|
+const minSide = computed(() => Math.min(width.value, height.value))
|
|
|
+const center = computed(() => ({
|
|
|
+ x: width.value / 2,
|
|
|
+ y: height.value / 2
|
|
|
+}))
|
|
|
+const halfMin = computed(() => minSide.value / 2)
|
|
|
+const maxLineWidth = computed(() => Math.max(0, halfMin.value))
|
|
|
+
|
|
|
+const mainCurveWidth = computed(() =>
|
|
|
+ clamp(Number(styleMap.value?.mainStyle?.curve?.width ?? 2), 0, maxLineWidth.value)
|
|
|
+)
|
|
|
+const mainCurveColor = computed(() =>
|
|
|
+ String(styleMap.value?.mainStyle?.curve?.color ?? '#212121FF')
|
|
|
+)
|
|
|
+const mainCurveLinecap = computed(() =>
|
|
|
+ styleMap.value?.mainStyle?.curve?.radius ? ('round' as const) : ('butt' as const)
|
|
|
+)
|
|
|
+const mainCurveImageSrc = computed(() => String(styleMap.value?.mainStyle?.curve?.imageSrc ?? ''))
|
|
|
+const mainCurveOpacity = computed(() => Number(styleMap.value?.mainStyle?.curve?.opacity ?? 1))
|
|
|
+const mainCurvePatternId = computed(() => `base-meter-curve-${props.id || 'default'}`)
|
|
|
+
|
|
|
+const mainTextColor = computed(() => String(styleMap.value?.mainStyle?.color ?? '#212121FF'))
|
|
|
+const mainFontSize = computed(() =>
|
|
|
+ Number.parseFloat(String(styleMap.value?.mainStyle?.fontSize ?? '14'))
|
|
|
+)
|
|
|
+const mainFontFamily = computed(() => String(styleMap.value?.mainStyle?.fontFamily ?? 'sans-serif'))
|
|
|
+const mainLetterSpacing = computed(() => String(styleMap.value?.mainStyle?.letterSpacing ?? '0px'))
|
|
|
+const mainTextDecoration = computed(() =>
|
|
|
+ String(styleMap.value?.mainStyle?.textDecoration ?? 'none')
|
|
|
+)
|
|
|
+
|
|
|
+const mainBackgroundImageSrc = computed(() => String(styleMap.value?.mainStyle?.imageSrc ?? ''))
|
|
|
+const mainBackgroundMaskStyle = computed(
|
|
|
+ () =>
|
|
|
+ ({
|
|
|
+ backgroundColor: styleMap.value?.mainStyle?.imageStyle?.backgroundColor,
|
|
|
+ opacity: styleMap.value?.mainStyle?.imageStyle?.opacity
|
|
|
+ }) as CSSProperties
|
|
|
+)
|
|
|
+const mainBackgroundOpacity = computed(() => Number(mainBackgroundMaskStyle.value.opacity ?? 1))
|
|
|
+const mainBackgroundUseMask = computed(() => {
|
|
|
+ return !isTransparentColor(String(mainBackgroundMaskStyle.value.backgroundColor ?? ''))
|
|
|
+})
|
|
|
+const backgroundImageWrapStyle = computed(() => {
|
|
|
+ return {
|
|
|
+ left: '-100px',
|
|
|
+ top: '-100px',
|
|
|
+ width: `${width.value + 200}px`,
|
|
|
+ height: `${height.value + 200}px`
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const indicatorLength = computed(() =>
|
|
|
+ Math.max(0, Number(indicatorStyleConfig.value?.other?.length ?? 10))
|
|
|
+)
|
|
|
+const itemsLength = computed(() => Math.max(0, Number(itemsStyleConfig.value?.other?.length ?? 5)))
|
|
|
+const indicatorLineWidth = computed(() =>
|
|
|
+ clamp(Number(styleMap.value?.indicatorStyle?.line?.width ?? 2), 0, maxLineWidth.value)
|
|
|
+)
|
|
|
+const itemsLineWidth = computed(() =>
|
|
|
+ clamp(Number(styleMap.value?.itemsStyle?.line?.width ?? 2), 0, maxLineWidth.value)
|
|
|
+)
|
|
|
+const indicatorLineColor = computed(() =>
|
|
|
+ String(styleMap.value?.indicatorStyle?.line?.color ?? '#212121FF')
|
|
|
+)
|
|
|
+const itemsLineColor = computed(() =>
|
|
|
+ String(styleMap.value?.itemsStyle?.line?.color ?? '#212121FF')
|
|
|
+)
|
|
|
+const indicatorLinecap = computed(() =>
|
|
|
+ styleMap.value?.indicatorStyle?.line?.radius ? ('round' as const) : ('butt' as const)
|
|
|
+)
|
|
|
+const itemsLinecap = computed(() =>
|
|
|
+ styleMap.value?.itemsStyle?.line?.radius ? ('round' as const) : ('butt' as const)
|
|
|
+)
|
|
|
+
|
|
|
+const outerReserve = computed(() => {
|
|
|
+ if (props.mode === 'round_outer') {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+ return 8
|
|
|
+})
|
|
|
+
|
|
|
+const curveRadius = computed(() => {
|
|
|
+ const radius = halfMin.value - outerReserve.value - mainCurveWidth.value / 2
|
|
|
+ return Math.max(0, radius)
|
|
|
+})
|
|
|
+
|
|
|
+const curvePatternBox = computed(() => {
|
|
|
+ return {
|
|
|
+ x: center.value.x - minSide.value / 2,
|
|
|
+ y: center.value.y - minSide.value / 2,
|
|
|
+ size: minSide.value
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const polarPoint = (radius: number, angleDeg: number) => {
|
|
|
+ const radians = (angleDeg * Math.PI) / 180
|
|
|
+ return {
|
|
|
+ x: center.value.x + Math.cos(radians) * radius,
|
|
|
+ y: center.value.y + Math.sin(radians) * radius
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const describeArc = (radius: number, startAngle: number, sweepAngle: number) => {
|
|
|
+ if (radius <= 0 || sweepAngle <= 0) return ''
|
|
|
+
|
|
|
+ let normalizedSweep = sweepAngle % 360
|
|
|
+ if (normalizedSweep === 0) {
|
|
|
+ normalizedSweep = 359.999
|
|
|
+ }
|
|
|
+ if (normalizedSweep < 0) {
|
|
|
+ normalizedSweep += 360
|
|
|
+ }
|
|
|
+
|
|
|
+ const start = polarPoint(radius, startAngle)
|
|
|
+ const end = polarPoint(radius, startAngle + normalizedSweep)
|
|
|
+ const largeArcFlag = normalizedSweep > 180 ? '1' : '0'
|
|
|
+
|
|
|
+ return ['M', start.x, start.y, 'A', radius, radius, 0, largeArcFlag, 1, end.x, end.y].join(' ')
|
|
|
+}
|
|
|
+
|
|
|
+const mainArcPath = computed(() =>
|
|
|
+ describeArc(
|
|
|
+ curveRadius.value,
|
|
|
+ Number(props.rotationAngle) || 0,
|
|
|
+ clamp(Number(props.angleRange) || 0, 0, 360)
|
|
|
+ )
|
|
|
+)
|
|
|
+const mainCurveStroke = computed(() => {
|
|
|
+ return mainCurveImageSrc.value ? `url(#${mainCurvePatternId.value})` : mainCurveColor.value
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedSections = computed(() => {
|
|
|
+ return (props.sections || []).map((section) => {
|
|
|
+ const start = Math.min(Number(section.start) || 0, Number(section.end) || 0)
|
|
|
+ const end = Math.max(Number(section.start) || 0, Number(section.end) || 0)
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...section,
|
|
|
+ start,
|
|
|
+ end
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+const rangeMin = computed(() =>
|
|
|
+ Math.min(Number(props.rangeStart) || 0, Number(props.rangeEnd) || 0)
|
|
|
+)
|
|
|
+const rangeMax = computed(() =>
|
|
|
+ Math.max(Number(props.rangeStart) || 0, Number(props.rangeEnd) || 0)
|
|
|
+)
|
|
|
+const rangeSpan = computed(() => (Number(props.rangeEnd) || 0) - (Number(props.rangeStart) || 0))
|
|
|
+
|
|
|
+const normalizeValue = (value: number) => {
|
|
|
+ if (rangeSpan.value === 0) return 0
|
|
|
+ return clamp((value - (Number(props.rangeStart) || 0)) / rangeSpan.value, 0, 1)
|
|
|
+}
|
|
|
+
|
|
|
+const valueAt = (t: number) => {
|
|
|
+ return (Number(props.rangeStart) || 0) + rangeSpan.value * t
|
|
|
+}
|
|
|
+
|
|
|
+const angleAt = (t: number) => {
|
|
|
+ return (Number(props.rotationAngle) || 0) + clamp(Number(props.angleRange) || 0, 0, 360) * t
|
|
|
+}
|
|
|
+
|
|
|
+const valueToAngle = (value: number) => angleAt(normalizeValue(value))
|
|
|
+
|
|
|
+const getSectionForValue = (value: number) => {
|
|
|
+ return normalizedSections.value.find((section) => value >= section.start && value <= section.end)
|
|
|
+}
|
|
|
+
|
|
|
+const sectionCurves = computed(() => {
|
|
|
+ return normalizedSections.value
|
|
|
+ .map((section, index) => {
|
|
|
+ const startValue = clamp(section.start, rangeMin.value, rangeMax.value)
|
|
|
+ const endValue = clamp(section.end, rangeMin.value, rangeMax.value)
|
|
|
+ const startT = normalizeValue(startValue)
|
|
|
+ const endT = normalizeValue(endValue)
|
|
|
+ const pathStart = Math.min(startT, endT)
|
|
|
+ const pathEnd = Math.max(startT, endT)
|
|
|
+ const startAngle = angleAt(pathStart)
|
|
|
+ const path = describeArc(
|
|
|
+ curveRadius.value,
|
|
|
+ startAngle,
|
|
|
+ clamp(Number(props.angleRange) || 0, 0, 360) * (pathEnd - pathStart)
|
|
|
+ )
|
|
|
+ const strokeWidth = clamp(Number(section.curve.width) || 0, 0, 100)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `section-curve-${index}`,
|
|
|
+ path,
|
|
|
+ stroke: String(section.curve.color || '#FF00FFFF'),
|
|
|
+ strokeWidth
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((section) => section.path && section.strokeWidth > 0)
|
|
|
+})
|
|
|
+
|
|
|
+const tickCount = computed(() => Math.max(2, Math.min(1000, Math.floor(Number(props.tick) || 41))))
|
|
|
+const majorTickGap = computed(() =>
|
|
|
+ Math.max(1, Math.min(tickCount.value - 1, Math.floor(Number(props.mainTick) || 8)))
|
|
|
+)
|
|
|
+
|
|
|
+const majorIndices = computed(() => {
|
|
|
+ const set = new Set<number>()
|
|
|
+ const lastIndex = tickCount.value - 1
|
|
|
+
|
|
|
+ for (let index = 0; index <= lastIndex; index += majorTickGap.value) {
|
|
|
+ set.add(index)
|
|
|
+ }
|
|
|
+
|
|
|
+ return set
|
|
|
+})
|
|
|
+
|
|
|
+const tickPositions = computed(() => {
|
|
|
+ const list: { index: number; value: number; angle: number; isMajor: boolean }[] = []
|
|
|
+ const lastIndex = tickCount.value - 1
|
|
|
+
|
|
|
+ for (let index = 0; index < tickCount.value; index++) {
|
|
|
+ const t = lastIndex <= 0 ? 0 : index / lastIndex
|
|
|
+ list.push({
|
|
|
+ index,
|
|
|
+ value: valueAt(t),
|
|
|
+ angle: angleAt(t),
|
|
|
+ isMajor: majorIndices.value.has(index)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return list
|
|
|
+})
|
|
|
+
|
|
|
+const createTickLine = (
|
|
|
+ prefix: string,
|
|
|
+ index: number,
|
|
|
+ angle: number,
|
|
|
+ length: number,
|
|
|
+ widthValue: number,
|
|
|
+ color: string,
|
|
|
+ linecap: 'round' | 'butt'
|
|
|
+) => {
|
|
|
+ const tickStartRadius =
|
|
|
+ props.mode === 'round_outer'
|
|
|
+ ? curveRadius.value + mainCurveWidth.value / 2
|
|
|
+ : curveRadius.value - mainCurveWidth.value / 2
|
|
|
+ const tickEndRadius =
|
|
|
+ props.mode === 'round_outer' ? tickStartRadius + length : tickStartRadius - length
|
|
|
+ const start = polarPoint(Math.max(0, tickStartRadius), angle)
|
|
|
+ const end = polarPoint(Math.max(0, tickEndRadius), angle)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `${prefix}-${index}`,
|
|
|
+ x1: start.x,
|
|
|
+ y1: start.y,
|
|
|
+ x2: end.x,
|
|
|
+ y2: end.y,
|
|
|
+ stroke: color,
|
|
|
+ strokeWidth: widthValue,
|
|
|
+ linecap
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const majorTicks = computed(() => {
|
|
|
+ return tickPositions.value
|
|
|
+ .filter((tick) => tick.isMajor)
|
|
|
+ .map((tick) => {
|
|
|
+ const section = getSectionForValue(tick.value)
|
|
|
+ const color = String(section?.line?.color || indicatorLineColor.value)
|
|
|
+ const strokeWidth = clamp(
|
|
|
+ Number(section?.line?.width ?? indicatorLineWidth.value),
|
|
|
+ 0,
|
|
|
+ maxLineWidth.value
|
|
|
+ )
|
|
|
+
|
|
|
+ return createTickLine(
|
|
|
+ 'major-tick',
|
|
|
+ tick.index,
|
|
|
+ tick.angle,
|
|
|
+ indicatorLength.value,
|
|
|
+ strokeWidth,
|
|
|
+ color,
|
|
|
+ indicatorLinecap.value
|
|
|
+ )
|
|
|
+ })
|
|
|
+ .filter((tick) => tick.strokeWidth > 0)
|
|
|
+})
|
|
|
+
|
|
|
+const minorTicks = computed(() => {
|
|
|
+ return tickPositions.value
|
|
|
+ .filter((tick) => !tick.isMajor)
|
|
|
+ .map((tick) => {
|
|
|
+ const section = getSectionForValue(tick.value)
|
|
|
+ const color = String(section?.line?.color || itemsLineColor.value)
|
|
|
+ const strokeWidth = clamp(
|
|
|
+ Number(section?.line?.width ?? itemsLineWidth.value),
|
|
|
+ 0,
|
|
|
+ maxLineWidth.value
|
|
|
+ )
|
|
|
+
|
|
|
+ return createTickLine(
|
|
|
+ 'minor-tick',
|
|
|
+ tick.index,
|
|
|
+ tick.angle,
|
|
|
+ itemsLength.value,
|
|
|
+ strokeWidth,
|
|
|
+ color,
|
|
|
+ itemsLinecap.value
|
|
|
+ )
|
|
|
+ })
|
|
|
+ .filter((tick) => tick.strokeWidth > 0)
|
|
|
+})
|
|
|
+
|
|
|
+const formatLabelValue = (value: number) => {
|
|
|
+ return String(Math.round(value))
|
|
|
+}
|
|
|
+
|
|
|
+const labelTexts = computed(() => {
|
|
|
+ const raw = (props.labels || '').trim()
|
|
|
+ if (raw) {
|
|
|
+ return raw
|
|
|
+ .split(',')
|
|
|
+ .map((item) => item.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ }
|
|
|
+
|
|
|
+ return tickPositions.value
|
|
|
+ .filter((tick) => tick.isMajor)
|
|
|
+ .map((tick) => formatLabelValue(tick.value))
|
|
|
+})
|
|
|
+
|
|
|
+const labelRadius = computed(() => {
|
|
|
+ if (props.mode === 'round_outer') {
|
|
|
+ return (
|
|
|
+ curveRadius.value +
|
|
|
+ mainCurveWidth.value / 2 +
|
|
|
+ indicatorLength.value +
|
|
|
+ mainFontSize.value / 2 +
|
|
|
+ 6
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return Math.max(
|
|
|
+ 0,
|
|
|
+ curveRadius.value -
|
|
|
+ mainCurveWidth.value / 2 -
|
|
|
+ indicatorLength.value -
|
|
|
+ mainFontSize.value / 2 -
|
|
|
+ 6
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const labelItems = computed(() => {
|
|
|
+ if (!props.enableText) return []
|
|
|
+
|
|
|
+ const majorTicksOnly = tickPositions.value.filter((tick) => tick.isMajor)
|
|
|
+
|
|
|
+ return majorTicksOnly
|
|
|
+ .map((tick, index) => {
|
|
|
+ const text = labelTexts.value[index]
|
|
|
+ if (!text) return null
|
|
|
+
|
|
|
+ const point = polarPoint(labelRadius.value, tick.angle)
|
|
|
+ const section = getSectionForValue(tick.value)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `label-${tick.index}`,
|
|
|
+ x: point.x,
|
|
|
+ y: point.y,
|
|
|
+ text,
|
|
|
+ fill: String(section?.text || mainTextColor.value),
|
|
|
+ fontSize: mainFontSize.value,
|
|
|
+ fontFamily: mainFontFamily.value,
|
|
|
+ letterSpacing: mainLetterSpacing.value,
|
|
|
+ decoration: mainTextDecoration.value
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .filter((item): item is NonNullable<typeof item> => !!item)
|
|
|
+})
|
|
|
+
|
|
|
+const createLineNeedle = (needle: BaseMeterNeedle, index: number) => {
|
|
|
+ if (needle.type !== 'line') return null
|
|
|
+
|
|
|
+ const angle = valueToAngle(Number(needle.value) || 0)
|
|
|
+ const point = polarPoint(Math.max(0, Number(needle.line.size.height) || 0), angle)
|
|
|
+ const strokeWidth = clamp(Number(needle.line.size.width) || 0, 0, maxLineWidth.value)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `line-needle-${index}`,
|
|
|
+ x1: center.value.x,
|
|
|
+ y1: center.value.y,
|
|
|
+ x2: point.x,
|
|
|
+ y2: point.y,
|
|
|
+ stroke: String(needle.line.color || '#212121FF'),
|
|
|
+ strokeWidth,
|
|
|
+ linecap: needle.line.round ? ('round' as const) : ('butt' as const)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const lineNeedles = computed(() => {
|
|
|
+ return (props.needles || [])
|
|
|
+ .map((needle, index) => createLineNeedle(needle, index))
|
|
|
+ .filter((needle): needle is NonNullable<typeof needle> => !!needle && needle.strokeWidth > 0)
|
|
|
+})
|
|
|
+
|
|
|
+const createImageNeedle = (needle: BaseMeterNeedle, index: number) => {
|
|
|
+ if (needle.type !== 'image' || !needle.image.image) return null
|
|
|
+ const src = getImageSrc(needle.image.image)
|
|
|
+ if (!src) return null
|
|
|
+
|
|
|
+ const angle = valueToAngle(Number(needle.value) || 0)
|
|
|
+ const widthValue = Math.max(1, Number(needle.image.size.width) || 1)
|
|
|
+ const heightValue = Math.max(1, Number(needle.image.size.height) || 1)
|
|
|
+ const centerX = Number(needle.image.center.x) || 0
|
|
|
+ const centerY = Number(needle.image.center.y) || 0
|
|
|
+ const recolor = String(needle.image.recolor || '#00000000')
|
|
|
+ const opacity = clamp(Number(needle.image.alpha ?? 255), 0, 255) / 255
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `image-needle-${index}`,
|
|
|
+ src,
|
|
|
+ useMask: !isTransparentColor(recolor),
|
|
|
+ opacity,
|
|
|
+ imageStyle: {
|
|
|
+ backgroundColor: recolor,
|
|
|
+ opacity
|
|
|
+ } as CSSProperties,
|
|
|
+ style: {
|
|
|
+ left: `${center.value.x - centerX}px`,
|
|
|
+ top: `${center.value.y - centerY}px`,
|
|
|
+ width: `${widthValue}px`,
|
|
|
+ height: `${heightValue}px`,
|
|
|
+ transform: `rotate(${angle}deg)`,
|
|
|
+ transformOrigin: `${centerX}px ${centerY}px`
|
|
|
+ } as CSSProperties
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const imageNeedles = computed(() => {
|
|
|
+ return (props.needles || [])
|
|
|
+ .map((needle, index) => createImageNeedle(needle, index))
|
|
|
+ .filter((needle): needle is NonNullable<typeof needle> => !!needle)
|
|
|
+})
|
|
|
+</script>
|