|
@@ -1,50 +1,62 @@
|
|
|
<template>
|
|
<template>
|
|
|
- <div
|
|
|
|
|
- :style="{
|
|
|
|
|
- ...styleMap?.mainStyle
|
|
|
|
|
- }"
|
|
|
|
|
- class="w-full h-full box-border overflow-hidden relative"
|
|
|
|
|
- >
|
|
|
|
|
- <svg
|
|
|
|
|
- :viewBox="`0 0 ${width} ${height}`"
|
|
|
|
|
- preserveAspectRatio="xMinYMin meet"
|
|
|
|
|
- class="w-full h-full"
|
|
|
|
|
- xmlns="http://www.w3.org/2000/svg"
|
|
|
|
|
- :style="{ transform: `rotate(${props.rotate}deg)` }"
|
|
|
|
|
- >
|
|
|
|
|
- <!-- 背景条:绘制完整的起始到结束角度 -->
|
|
|
|
|
- <path
|
|
|
|
|
- :d="bgPath"
|
|
|
|
|
- fill="none"
|
|
|
|
|
- :stroke="styles?.mainStyle?.curve?.color || '#eeeeee'"
|
|
|
|
|
- :stroke-width="styles?.mainStyle?.curve?.width || 1"
|
|
|
|
|
- :stroke-linecap="styles?.mainStyle?.curve?.radius ? 'round' : 'butt'"
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
- <!-- 进度值条 -->
|
|
|
|
|
- <path
|
|
|
|
|
- :d="valuePath"
|
|
|
|
|
- fill="none"
|
|
|
|
|
- :stroke="styles?.indicatorStyle?.curve?.color || '#eeeeee'"
|
|
|
|
|
- :stroke-width="styles?.indicatorStyle?.curve?.width || 1"
|
|
|
|
|
- :stroke-linecap="styles?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
- <!-- 进度圆点 -->
|
|
|
|
|
- <circle
|
|
|
|
|
- v-if="dotPos"
|
|
|
|
|
- :cx="dotPos.x"
|
|
|
|
|
- :cy="dotPos.y"
|
|
|
|
|
- :r="styles?.knobStyle?.padding?.left || 5"
|
|
|
|
|
- :fill="styles?.knobStyle?.backgroundColor || '#2092f5'"
|
|
|
|
|
- />
|
|
|
|
|
- </svg>
|
|
|
|
|
|
|
+ <div :style="{
|
|
|
|
|
+ ...styleMap?.mainStyle
|
|
|
|
|
+ }" class="relative w-full h-full box-border overflow-hidden relative">
|
|
|
|
|
+
|
|
|
|
|
+ <ImageBg v-if="styleMap?.mainStyle?.imageSrc" :src="styleMap?.mainStyle?.imageSrc"
|
|
|
|
|
+ :imageStyle="styleMap?.mainStyle?.imageStyle" />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="absolute inset-0 w-full h-full" :style="{ transform: `rotate(${props.rotate}deg)` }">
|
|
|
|
|
+ <svg :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="xMidYMid meet" class="w-full h-full block"
|
|
|
|
|
+ xmlns="http://www.w3.org/2000/svg">
|
|
|
|
|
+ <defs>
|
|
|
|
|
+ <!-- 背景条图片 pattern:保持图像原始尺寸,居中显示 -->
|
|
|
|
|
+ <pattern v-if="styleMap?.mainStyle?.curve?.imageSrc" :id="`${arcId}-bg-pattern`" patternUnits="userSpaceOnUse"
|
|
|
|
|
+ :width="width" :height="height" x="0" y="0">
|
|
|
|
|
+ <image :href="styleMap.mainStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
|
|
|
|
|
+ </pattern>
|
|
|
|
|
+ <!-- 进度值条图片 pattern:保持图像原始尺寸,居中显示 -->
|
|
|
|
|
+ <pattern v-if="styleMap?.indicatorStyle?.curve?.imageSrc" :id="`${arcId}-indicator-pattern`"
|
|
|
|
|
+ patternUnits="userSpaceOnUse" :width="width" :height="height" x="0" y="0">
|
|
|
|
|
+ <image :href="styleMap.indicatorStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
|
|
|
|
|
+ </pattern>
|
|
|
|
|
+ </defs>
|
|
|
|
|
+ <!-- 背景条:绘制完整的起始到结束角度 -->
|
|
|
|
|
+ <path :d="bgPath" fill="none"
|
|
|
|
|
+ :stroke="styleMap?.mainStyle?.curve?.imageSrc ? `url(#${arcId}-bg-pattern)` : (styleMap?.mainStyle?.curve?.color || '#eeeeee')"
|
|
|
|
|
+ :stroke-width="styleMap?.mainStyle?.curve?.width ?? 1"
|
|
|
|
|
+ :stroke-linecap="styleMap?.mainStyle?.curve?.radius ? 'round' : 'butt'"
|
|
|
|
|
+ :opacity="styleMap?.mainStyle?.curve?.opacity" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 进度值条 -->
|
|
|
|
|
+ <path :d="valuePath" fill="none"
|
|
|
|
|
+ :stroke="styleMap?.indicatorStyle?.curve?.imageSrc ? `url(#${arcId}-indicator-pattern)` : (styleMap?.indicatorStyle?.curve?.color || '#2092f5')"
|
|
|
|
|
+ :stroke-width="styleMap?.indicatorStyle?.curve?.width ?? 1"
|
|
|
|
|
+ :stroke-linecap="styleMap?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
|
|
|
|
|
+ :opacity="styleMap?.indicatorStyle?.curve?.opacity" />
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 进度圆点:无背景图时用实心圆 -->
|
|
|
|
|
+ <circle v-if="dotPos && !styleMap?.knobStyle?.imageSrc" :cx="dotPos.x" :cy="dotPos.y" :r="knobRadius.radius"
|
|
|
|
|
+ :fill="styleMap?.knobStyle?.backgroundColor || '#2092f5'" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 进度圆点背景图:与 SVG 同坐标系,用 ImageBg 统一实现 -->
|
|
|
|
|
+ <div v-if="dotPos && styleMap?.knobStyle?.imageSrc" class="absolute overflow-hidden pointer-events-none"
|
|
|
|
|
+ :style="knobOverlayStyle">
|
|
|
|
|
+ <ImageBg :src="styleMap.knobStyle.imageSrc" :imageStyle="styleMap.knobStyle.imageStyle" />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
-import { computed } from 'vue'
|
|
|
|
|
|
|
+import { computed, ref } from 'vue'
|
|
|
import { useWidgetStyle } from '../hooks/useWidgetStyle'
|
|
import { useWidgetStyle } from '../hooks/useWidgetStyle'
|
|
|
|
|
+import { useProjectStore } from '@/store/modules/project';
|
|
|
|
|
+
|
|
|
|
|
+import ImageBg from '../ImageBg.vue';
|
|
|
|
|
+
|
|
|
|
|
+const arcId = ref('arc-' + Math.random().toString(36).slice(2))
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
const props = defineProps<{
|
|
|
width: number
|
|
width: number
|
|
@@ -59,8 +71,14 @@ const props = defineProps<{
|
|
|
angleEnd: number
|
|
angleEnd: number
|
|
|
value: number
|
|
value: number
|
|
|
rotate: number
|
|
rotate: number
|
|
|
|
|
+ // 偏移开关
|
|
|
|
|
+ rotateOffset?: boolean
|
|
|
|
|
+ // 偏移量(度数)
|
|
|
|
|
+ rotateOffsetValue?: number
|
|
|
}>()
|
|
}>()
|
|
|
|
|
|
|
|
|
|
+const projectStore = useProjectStore()
|
|
|
|
|
+
|
|
|
const styleMap = useWidgetStyle({
|
|
const styleMap = useWidgetStyle({
|
|
|
widget: 'lv_arc',
|
|
widget: 'lv_arc',
|
|
|
props
|
|
props
|
|
@@ -72,6 +90,36 @@ const cx = computed(() => {
|
|
|
return min / 2
|
|
return min / 2
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+/** 弧线半径:与背景/进度条一致,留出边距避免贴边 */
|
|
|
|
|
+const trackRadius = computed(() => {
|
|
|
|
|
+ const { width, height } = props
|
|
|
|
|
+ const min = Math.min(width, height)
|
|
|
|
|
+ const curveWidth = styleMap.value?.mainStyle?.curve?.width ?? 12
|
|
|
|
|
+ return Math.max(4, min / 2 - curveWidth / 2 - knobRadius.value.padding)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/** 从 schema 的 styles 中解析 knob 的 padding.left 作为圆点半径 */
|
|
|
|
|
+const knobRadius = computed(() => {
|
|
|
|
|
+ const defaultIndicatorStyle = projectStore.globalStyle
|
|
|
|
|
+ ?.find((item) => item.widget === 'lv_arc')
|
|
|
|
|
+ ?.part?.find((item) => item.partName === 'indicator')?.defaultStyle
|
|
|
|
|
+
|
|
|
|
|
+ const defaultKnobStyle = projectStore.globalStyle
|
|
|
|
|
+ ?.find((item) => item.widget === 'lv_arc')
|
|
|
|
|
+ ?.part?.find((item) => item.partName === 'knob')?.defaultStyle
|
|
|
|
|
+
|
|
|
|
|
+ const indicatorStyle = props.styles?.find((s: any) => s.part?.name === 'indicator' && s.part.state === props.state)
|
|
|
|
|
+ const knobStyle = props.styles?.find((s: any) => s.part?.name === 'knob' && s.part.state === props.state)
|
|
|
|
|
+ const padding = knobStyle?.padding?.left ?? defaultKnobStyle?.padding?.left
|
|
|
|
|
+ const r = indicatorStyle?.curve?.width ?? defaultIndicatorStyle?.curve?.width ?? 12
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ radius: r / 2 + padding,
|
|
|
|
|
+ padding: padding,
|
|
|
|
|
+ width: r
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 极坐标转直角坐标
|
|
* 极坐标转直角坐标
|
|
|
* SVG 角度习惯:0度在右侧(3点钟),需调整使0度在上方(12点钟)
|
|
* SVG 角度习惯:0度在右侧(3点钟),需调整使0度在上方(12点钟)
|
|
@@ -93,17 +141,17 @@ function polarToCartesian(
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 生成 SVG 弧线路径指令
|
|
* 生成 SVG 弧线路径指令
|
|
|
|
|
+ * 始终从 startAngle 顺时针画到 endAngle,得到“近整圆、缺口在底部”的环(图2效果)
|
|
|
*/
|
|
*/
|
|
|
function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
|
|
function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
|
|
|
- // 如果起始角度大于结束角度,交换位置(取决于业务逻辑,这里默认顺时针绘制)
|
|
|
|
|
- const isReversed = startAngle > endAngle
|
|
|
|
|
- const start = polarToCartesian(x, y, radius, isReversed ? endAngle : startAngle)
|
|
|
|
|
- const end = polarToCartesian(x, y, radius, isReversed ? startAngle : endAngle)
|
|
|
|
|
-
|
|
|
|
|
- const diff = Math.abs(endAngle - startAngle)
|
|
|
|
|
- const largeArcFlag = diff <= 180 ? '0' : '1'
|
|
|
|
|
- // sweep-flag: 1 为顺时针
|
|
|
|
|
- const sweepFlag = isReversed ? '0' : '1'
|
|
|
|
|
|
|
+ const start = polarToCartesian(x, y, radius, startAngle)
|
|
|
|
|
+ const end = polarToCartesian(x, y, radius, endAngle)
|
|
|
|
|
+ // 顺时针扫过的角度(0~360)
|
|
|
|
|
+ let clockwiseSpan = (endAngle - startAngle) % 360
|
|
|
|
|
+ if (clockwiseSpan <= 0) clockwiseSpan += 360
|
|
|
|
|
+ if (clockwiseSpan === 0) clockwiseSpan = 360
|
|
|
|
|
+ const largeArcFlag = clockwiseSpan > 180 ? '1' : '0'
|
|
|
|
|
+ const sweepFlag = '1' // 顺时针
|
|
|
|
|
|
|
|
return [
|
|
return [
|
|
|
'M',
|
|
'M',
|
|
@@ -122,61 +170,90 @@ function describeArc(x: number, y: number, radius: number, startAngle: number, e
|
|
|
|
|
|
|
|
// 1. 背景路径
|
|
// 1. 背景路径
|
|
|
const bgPath = computed(() => {
|
|
const bgPath = computed(() => {
|
|
|
- const { width, height } = props
|
|
|
|
|
- const min = Math.min(width, height)
|
|
|
|
|
- const r = min / 2
|
|
|
|
|
- return describeArc(r, r, r - 10, props.angleStart, props.angleEnd)
|
|
|
|
|
|
|
+ const r = trackRadius.value
|
|
|
|
|
+ return describeArc(cx.value, cx.value, r, props.angleStart, props.angleEnd)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// 2. 进度计算逻辑
|
|
|
|
|
|
|
+// 顺时针从 angleStart 到 angleEnd 的弧度跨度(0~360)
|
|
|
|
|
+const clockwiseSpan = computed(() => {
|
|
|
|
|
+ const { angleStart, angleEnd } = props
|
|
|
|
|
+ let s = (angleEnd - angleStart) % 360
|
|
|
|
|
+ if (s <= 0) s += 360
|
|
|
|
|
+ return s === 0 ? 360 : s
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 2. 进度计算逻辑(与 describeArc 的顺时针方向一致)
|
|
|
const progressData = computed(() => {
|
|
const progressData = computed(() => {
|
|
|
const { value, rangeStart, rangeEnd, angleStart, angleEnd, mode } = props
|
|
const { value, rangeStart, rangeEnd, angleStart, angleEnd, mode } = props
|
|
|
|
|
|
|
|
- // 基础百分比计算 (0-1)
|
|
|
|
|
- let ratio = (value - rangeStart) / (rangeEnd - rangeStart)
|
|
|
|
|
- ratio = Math.max(0, Math.min(1, ratio)) // 限制在 0-1
|
|
|
|
|
|
|
+ const rangeDiff = rangeEnd - rangeStart
|
|
|
|
|
+ const ratio =
|
|
|
|
|
+ Math.abs(rangeDiff) < 1e-9
|
|
|
|
|
+ ? 0
|
|
|
|
|
+ : Math.max(0, Math.min(1, (value - rangeStart) / rangeDiff))
|
|
|
|
|
|
|
|
|
|
+ const span = clockwiseSpan.value
|
|
|
let startA = angleStart
|
|
let startA = angleStart
|
|
|
let endA = angleEnd
|
|
let endA = angleEnd
|
|
|
|
|
|
|
|
if (mode === 'normal') {
|
|
if (mode === 'normal') {
|
|
|
- endA = angleStart + (angleEnd - angleStart) * ratio
|
|
|
|
|
|
|
+ // 从 angleStart 顺时针填充 span * ratio
|
|
|
|
|
+ endA = angleStart + span * ratio
|
|
|
} else if (mode === 'reverse') {
|
|
} else if (mode === 'reverse') {
|
|
|
- // 从终点往回走
|
|
|
|
|
- startA = angleEnd - (angleEnd - angleStart) * ratio
|
|
|
|
|
|
|
+ startA = angleEnd - span * ratio
|
|
|
endA = angleEnd
|
|
endA = angleEnd
|
|
|
} else if (mode === 'symmetrical') {
|
|
} else if (mode === 'symmetrical') {
|
|
|
- // 对称模式:通常 rangeStart 到 rangeEnd 的中间值是起点
|
|
|
|
|
- const midAngle = (angleStart + angleEnd) / 2
|
|
|
|
|
|
|
+ const midAngle = angleStart + span / 2
|
|
|
const midValue = (rangeStart + rangeEnd) / 2
|
|
const midValue = (rangeStart + rangeEnd) / 2
|
|
|
|
|
+ const rangeToEnd = rangeEnd - midValue
|
|
|
|
|
+ const rangeToStart = midValue - rangeStart
|
|
|
|
|
|
|
|
- if (value >= midValue) {
|
|
|
|
|
|
|
+ if (value >= midValue && Math.abs(rangeToEnd) >= 1e-9) {
|
|
|
startA = midAngle
|
|
startA = midAngle
|
|
|
- const halfRatio = (value - midValue) / (rangeEnd - midValue)
|
|
|
|
|
- endA = midAngle + (angleEnd - midAngle) * halfRatio
|
|
|
|
|
|
|
+ const halfRatio = Math.max(0, Math.min(1, (value - midValue) / rangeToEnd))
|
|
|
|
|
+ endA = midAngle + (span / 2) * halfRatio
|
|
|
|
|
+ } else if (value < midValue && Math.abs(rangeToStart) >= 1e-9) {
|
|
|
|
|
+ endA = midAngle
|
|
|
|
|
+ const halfRatio = Math.max(0, Math.min(1, (midValue - value) / rangeToStart))
|
|
|
|
|
+ startA = midAngle - (span / 2) * halfRatio
|
|
|
} else {
|
|
} else {
|
|
|
|
|
+ startA = midAngle
|
|
|
endA = midAngle
|
|
endA = midAngle
|
|
|
- const halfRatio = (midValue - value) / (midValue - rangeStart)
|
|
|
|
|
- startA = midAngle - (midAngle - angleStart) * halfRatio
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return { startA, endA }
|
|
return { startA, endA }
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// 3. 值路径
|
|
|
|
|
|
|
+// 3. 值路径(与背景弧同半径)
|
|
|
const valuePath = computed(() => {
|
|
const valuePath = computed(() => {
|
|
|
const { startA, endA } = progressData.value
|
|
const { startA, endA } = progressData.value
|
|
|
if (startA === endA) return ''
|
|
if (startA === endA) return ''
|
|
|
- return describeArc(cx.value, cx.value, cx.value, startA, endA)
|
|
|
|
|
|
|
+ return describeArc(cx.value, cx.value, trackRadius.value, startA, endA)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
-// 4. 圆点位置
|
|
|
|
|
|
|
+// 4. 圆点位置(与弧同半径)
|
|
|
const dotPos = computed(() => {
|
|
const dotPos = computed(() => {
|
|
|
- // const { endA } = progressData.value
|
|
|
|
|
- // 如果是 reverse 模式,圆点可能应该在 startA(取决于视觉习惯)
|
|
|
|
|
- // 这里默认跟随进度的“活动端”
|
|
|
|
|
- const angle = props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
|
|
|
|
|
- return polarToCartesian(cx.value, cx.value, cx.value, angle)
|
|
|
|
|
|
|
+ // 原始结束角:normal / symmetrical 用 endA,reverse 用 startA
|
|
|
|
|
+ const baseAngle =
|
|
|
|
|
+ props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
|
|
|
|
|
+ // 仅对“结束值原点”做偏移,不整体偏移整条进度弧
|
|
|
|
|
+ const offset = props.rotateOffset ? props.rotateOffsetValue ?? 0 : 0
|
|
|
|
|
+ const angle = baseAngle + offset
|
|
|
|
|
+ return polarToCartesian(cx.value, cx.value, trackRadius.value, angle)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/** 进度圆点 overlay 的定位样式(与 viewBox 比例一致,用 ImageBg 时使用) */
|
|
|
|
|
+const knobOverlayStyle = computed(() => {
|
|
|
|
|
+ if (!dotPos.value || !props.width || !props.height) return {}
|
|
|
|
|
+ const { x, y } = dotPos.value
|
|
|
|
|
+ const r = knobRadius.value.radius
|
|
|
|
|
+ return {
|
|
|
|
|
+ left: `${((x - r) / props.width) * 100}%`,
|
|
|
|
|
+ top: `${((y - r) / props.height) * 100}%`,
|
|
|
|
|
+ width: `${(r * 2 / props.width) * 100}%`,
|
|
|
|
|
+ height: `${(r * 2 / props.height) * 100}%`,
|
|
|
|
|
+ borderRadius: '50%'
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
</script>
|
|
</script>
|