|
|
@@ -0,0 +1,533 @@
|
|
|
+<template>
|
|
|
+ <div class="relative w-full h-full overflow-visible">
|
|
|
+ <div class="absolute inset-0 flex items-center justify-center overflow-visible">
|
|
|
+ <div :style="faceStyle" class="relative box-border">
|
|
|
+ <div
|
|
|
+ v-if="mainImageSrc"
|
|
|
+ class="absolute inset-0 flex items-center justify-center overflow-hidden pointer-events-none"
|
|
|
+ :style="{ opacity: mainImageOpacity }"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ v-if="!mainImageUseMask"
|
|
|
+ :src="mainImageSrc"
|
|
|
+ class="block max-w-none max-h-none"
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ <ImageBg v-else :src="mainImageSrc" :imageStyle="mainImageStyle" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <svg
|
|
|
+ :viewBox="`0 0 ${faceSize} ${faceSize}`"
|
|
|
+ class="block w-full h-full overflow-visible"
|
|
|
+ xmlns="http://www.w3.org/2000/svg"
|
|
|
+ >
|
|
|
+ <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 labels"
|
|
|
+ :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="hand in lineHands"
|
|
|
+ :key="hand.key"
|
|
|
+ :x1="hand.x1"
|
|
|
+ :y1="hand.y1"
|
|
|
+ :x2="hand.x2"
|
|
|
+ :y2="hand.y2"
|
|
|
+ :stroke="hand.stroke"
|
|
|
+ :stroke-width="hand.strokeWidth"
|
|
|
+ stroke-linecap="round"
|
|
|
+ />
|
|
|
+ </svg>
|
|
|
+
|
|
|
+ <div
|
|
|
+ v-for="hand in imageHands"
|
|
|
+ :key="hand.key"
|
|
|
+ class="absolute pointer-events-none"
|
|
|
+ :style="hand.style"
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ v-if="!hand.useMask"
|
|
|
+ :src="hand.src"
|
|
|
+ class="block w-full h-full"
|
|
|
+ :style="{ opacity: hand.opacity }"
|
|
|
+ alt=""
|
|
|
+ />
|
|
|
+ <ImageBg v-else :src="hand.src" :imageStyle="hand.imageStyle" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </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'
|
|
|
+
|
|
|
+type PointerType = 'image' | 'line'
|
|
|
+
|
|
|
+type PointerImage = {
|
|
|
+ image: string
|
|
|
+ alpha: number
|
|
|
+ recolor: string
|
|
|
+ size: {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ }
|
|
|
+ center: {
|
|
|
+ x: number
|
|
|
+ y: number
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type PointerLine = {
|
|
|
+ color: string
|
|
|
+ size: {
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+type HandConfig = {
|
|
|
+ time: number
|
|
|
+ type: PointerType
|
|
|
+ image: PointerImage
|
|
|
+ line: PointerLine
|
|
|
+}
|
|
|
+
|
|
|
+type TickLine = {
|
|
|
+ key: string
|
|
|
+ x1: number
|
|
|
+ y1: number
|
|
|
+ x2: number
|
|
|
+ y2: number
|
|
|
+ stroke: string
|
|
|
+ strokeWidth: number
|
|
|
+ linecap: 'round' | 'butt'
|
|
|
+}
|
|
|
+
|
|
|
+type LineHand = {
|
|
|
+ key: string
|
|
|
+ x1: number
|
|
|
+ y1: number
|
|
|
+ x2: number
|
|
|
+ y2: number
|
|
|
+ stroke: string
|
|
|
+ strokeWidth: number
|
|
|
+}
|
|
|
+
|
|
|
+type ImageHand = {
|
|
|
+ key: string
|
|
|
+ src: string
|
|
|
+ useMask: boolean
|
|
|
+ opacity: number
|
|
|
+ imageStyle: CSSProperties
|
|
|
+ style: CSSProperties
|
|
|
+}
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ width: number
|
|
|
+ height: number
|
|
|
+ styles: any[]
|
|
|
+ part?: string
|
|
|
+ state?: string
|
|
|
+ enableText: boolean
|
|
|
+ totalTicks: number
|
|
|
+ majorTickInterval: number
|
|
|
+ hourHand: HandConfig
|
|
|
+ minuteHand: HandConfig
|
|
|
+ secondHand: HandConfig
|
|
|
+}>()
|
|
|
+
|
|
|
+const projectStore = useProjectStore()
|
|
|
+
|
|
|
+const styleMap = useWidgetStyle({
|
|
|
+ widget: 'analog_clock',
|
|
|
+ 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 faceSize = computed(() => Math.max(0, Math.min(props.width || 0, props.height || 0)))
|
|
|
+const center = computed(() => faceSize.value / 2)
|
|
|
+const radius = computed(() => faceSize.value / 2)
|
|
|
+const lineWidthLimit = computed(() => Math.max(0, radius.value))
|
|
|
+
|
|
|
+const minorTickLength = computed(() =>
|
|
|
+ Math.max(0, Number(itemsStyleConfig.value?.other?.length ?? 6))
|
|
|
+)
|
|
|
+const majorTickLength = computed(() =>
|
|
|
+ Math.max(0, Number(indicatorStyleConfig.value?.other?.length ?? 10))
|
|
|
+)
|
|
|
+
|
|
|
+const minorTickWidth = computed(() =>
|
|
|
+ Math.max(0, Math.min(Number(styleMap.value?.itemsStyle?.line?.width ?? 2), lineWidthLimit.value))
|
|
|
+)
|
|
|
+const majorTickWidth = computed(() =>
|
|
|
+ Math.max(
|
|
|
+ 0,
|
|
|
+ Math.min(Number(styleMap.value?.indicatorStyle?.line?.width ?? 2), lineWidthLimit.value)
|
|
|
+ )
|
|
|
+)
|
|
|
+
|
|
|
+const minorTickColor = computed(() =>
|
|
|
+ String(styleMap.value?.itemsStyle?.line?.color ?? '#212121FF')
|
|
|
+)
|
|
|
+const majorTickColor = computed(() =>
|
|
|
+ String(styleMap.value?.indicatorStyle?.line?.color ?? '#212121FF')
|
|
|
+)
|
|
|
+const minorTickLinecap = computed(() =>
|
|
|
+ styleMap.value?.itemsStyle?.line?.radius ? ('round' as const) : ('butt' as const)
|
|
|
+)
|
|
|
+const majorTickLinecap = computed(() =>
|
|
|
+ styleMap.value?.indicatorStyle?.line?.radius ? ('round' as const) : ('butt' as const)
|
|
|
+)
|
|
|
+
|
|
|
+const labelColor = computed(() => String(styleMap.value?.indicatorStyle?.color ?? '#212121FF'))
|
|
|
+const labelFontSize = computed(() =>
|
|
|
+ Number.parseFloat(String(styleMap.value?.indicatorStyle?.fontSize ?? 14))
|
|
|
+)
|
|
|
+const labelFontFamily = computed(() =>
|
|
|
+ String(styleMap.value?.indicatorStyle?.fontFamily ?? 'sans-serif')
|
|
|
+)
|
|
|
+const labelLetterSpacing = computed(() =>
|
|
|
+ String(styleMap.value?.indicatorStyle?.letterSpacing ?? '0px')
|
|
|
+)
|
|
|
+const labelDecoration = computed(() =>
|
|
|
+ String(styleMap.value?.indicatorStyle?.textDecoration ?? 'none')
|
|
|
+)
|
|
|
+
|
|
|
+const uniqueTickCount = computed(() => {
|
|
|
+ const total = Math.max(0, Math.floor(Number(props.totalTicks) || 0))
|
|
|
+ if (total <= 1) return total
|
|
|
+ return total - 1
|
|
|
+})
|
|
|
+
|
|
|
+const normalizedMajorTickInterval = computed(() =>
|
|
|
+ Math.max(0, Math.floor(Number(props.majorTickInterval) || 0))
|
|
|
+)
|
|
|
+
|
|
|
+const hasIncompleteLeadingMajor = computed(() => {
|
|
|
+ if (normalizedMajorTickInterval.value <= 0) return 0
|
|
|
+ return uniqueTickCount.value % normalizedMajorTickInterval.value
|
|
|
+})
|
|
|
+
|
|
|
+const tickStepAngle = computed(() => {
|
|
|
+ if (uniqueTickCount.value <= 0) return 0
|
|
|
+ return 360 / uniqueTickCount.value
|
|
|
+})
|
|
|
+
|
|
|
+const outerTickRadius = computed(() => {
|
|
|
+ const maxLineWidth = Math.max(minorTickWidth.value, majorTickWidth.value)
|
|
|
+ return Math.max(0, radius.value - maxLineWidth / 2 - 2)
|
|
|
+})
|
|
|
+
|
|
|
+const polarPoint = (distance: number, angleDeg: number) => {
|
|
|
+ const rad = (angleDeg * Math.PI) / 180
|
|
|
+ return {
|
|
|
+ x: center.value + Math.cos(rad) * distance,
|
|
|
+ y: center.value + Math.sin(rad) * distance
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const tickMeta = computed(() => {
|
|
|
+ const list: { index: number; angle: number; isMajor: boolean }[] = []
|
|
|
+ for (let index = 0; index < uniqueTickCount.value; index++) {
|
|
|
+ list.push({
|
|
|
+ index,
|
|
|
+ angle: -90 + tickStepAngle.value * index,
|
|
|
+ isMajor:
|
|
|
+ normalizedMajorTickInterval.value > 0 &&
|
|
|
+ index % normalizedMajorTickInterval.value === 0 &&
|
|
|
+ !(hasIncompleteLeadingMajor.value > 0 && index === 0)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return list
|
|
|
+})
|
|
|
+
|
|
|
+const createTickLine = (
|
|
|
+ index: number,
|
|
|
+ angle: number,
|
|
|
+ length: number,
|
|
|
+ width: number,
|
|
|
+ stroke: string,
|
|
|
+ linecap: 'round' | 'butt'
|
|
|
+): TickLine => {
|
|
|
+ const start = polarPoint(outerTickRadius.value, angle)
|
|
|
+ const end = polarPoint(Math.max(0, outerTickRadius.value - length), angle)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `tick-${index}`,
|
|
|
+ x1: start.x,
|
|
|
+ y1: start.y,
|
|
|
+ x2: end.x,
|
|
|
+ y2: end.y,
|
|
|
+ stroke,
|
|
|
+ strokeWidth: width,
|
|
|
+ linecap
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const majorTicks = computed(() => {
|
|
|
+ if (majorTickWidth.value <= 0 || majorTickLength.value <= 0) return []
|
|
|
+
|
|
|
+ return tickMeta.value
|
|
|
+ .filter((item) => item.isMajor)
|
|
|
+ .map((item) =>
|
|
|
+ createTickLine(
|
|
|
+ item.index,
|
|
|
+ item.angle,
|
|
|
+ majorTickLength.value,
|
|
|
+ majorTickWidth.value,
|
|
|
+ majorTickColor.value,
|
|
|
+ majorTickLinecap.value
|
|
|
+ )
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const minorTicks = computed(() => {
|
|
|
+ if (minorTickWidth.value <= 0 || minorTickLength.value <= 0) return []
|
|
|
+
|
|
|
+ return tickMeta.value
|
|
|
+ .filter((item) => !item.isMajor)
|
|
|
+ .map((item) =>
|
|
|
+ createTickLine(
|
|
|
+ item.index,
|
|
|
+ item.angle,
|
|
|
+ minorTickLength.value,
|
|
|
+ minorTickWidth.value,
|
|
|
+ minorTickColor.value,
|
|
|
+ minorTickLinecap.value
|
|
|
+ )
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const labels = computed(() => {
|
|
|
+ if (!props.enableText || normalizedMajorTickInterval.value <= 0) return []
|
|
|
+
|
|
|
+ const labelRadius = Math.max(
|
|
|
+ 0,
|
|
|
+ outerTickRadius.value - majorTickLength.value - labelFontSize.value * 0.9 - 0
|
|
|
+ )
|
|
|
+
|
|
|
+ const majorLabelTicks = tickMeta.value.filter((item) => item.isMajor)
|
|
|
+ const labelCount = majorLabelTicks.length
|
|
|
+
|
|
|
+ if (labelCount <= 0) return []
|
|
|
+
|
|
|
+ return majorLabelTicks.map((item, index) => {
|
|
|
+ const point = polarPoint(labelRadius, item.angle)
|
|
|
+ const text =
|
|
|
+ hasIncompleteLeadingMajor.value > 0
|
|
|
+ ? String(index + 1)
|
|
|
+ : index === 0
|
|
|
+ ? String(labelCount)
|
|
|
+ : String(index)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: `label-${item.index}`,
|
|
|
+ x: point.x,
|
|
|
+ y: point.y,
|
|
|
+ text,
|
|
|
+ fill: labelColor.value,
|
|
|
+ fontSize: labelFontSize.value,
|
|
|
+ fontFamily: labelFontFamily.value,
|
|
|
+ letterSpacing: labelLetterSpacing.value,
|
|
|
+ decoration: labelDecoration.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 mainImageSrc = computed(() => String(styleMap.value?.mainStyle?.imageSrc ?? ''))
|
|
|
+const mainImageStyle = computed(
|
|
|
+ () =>
|
|
|
+ ({
|
|
|
+ backgroundColor: styleMap.value?.mainStyle?.imageStyle?.backgroundColor,
|
|
|
+ opacity: styleMap.value?.mainStyle?.imageStyle?.opacity
|
|
|
+ }) as CSSProperties
|
|
|
+)
|
|
|
+const mainImageOpacity = computed(() => Number(mainImageStyle.value.opacity ?? 1))
|
|
|
+const mainImageUseMask = computed(() => {
|
|
|
+ return !isTransparentColor(String(mainImageStyle.value.backgroundColor ?? ''))
|
|
|
+})
|
|
|
+
|
|
|
+const faceStyle = computed(() => {
|
|
|
+ const mainStyle = styleMap.value?.mainStyle || {}
|
|
|
+ const {
|
|
|
+ imageSrc: _imageSrc,
|
|
|
+ imageStyle: _imageStyle,
|
|
|
+ line: _line,
|
|
|
+ curve: _curve,
|
|
|
+ ...style
|
|
|
+ } = mainStyle
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...style,
|
|
|
+ width: `${faceSize.value}px`,
|
|
|
+ height: `${faceSize.value}px`,
|
|
|
+ borderRadius: '50%',
|
|
|
+ overflow: 'hidden'
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const getHourAngle = (value: number) => ((value % 12) / 12) * 360 - 90
|
|
|
+const getMinuteSecondAngle = (value: number) => ((value % 60) / 60) * 360 - 90
|
|
|
+const getHandTime = (hand: HandConfig) =>
|
|
|
+ hand.time ?? (hand.type === 'image' ? (hand as any)?.image?.time : (hand as any)?.line?.time) ?? 0
|
|
|
+
|
|
|
+const createLineHand = (key: string, hand: HandConfig, angle: number) => {
|
|
|
+ if (hand.type !== 'line') return null
|
|
|
+
|
|
|
+ const strokeWidth = Math.max(
|
|
|
+ 0,
|
|
|
+ Math.min(Number(hand.line?.size?.width ?? 0), lineWidthLimit.value)
|
|
|
+ )
|
|
|
+ const length = Math.max(0, Number(hand.line?.size?.height ?? 0))
|
|
|
+ const point = polarPoint(length, angle)
|
|
|
+
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ x1: center.value,
|
|
|
+ y1: center.value,
|
|
|
+ x2: point.x,
|
|
|
+ y2: point.y,
|
|
|
+ stroke: String(hand.line?.color ?? '#212121FF'),
|
|
|
+ strokeWidth
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const isLineHand = (hand: LineHand | null): hand is LineHand => !!hand
|
|
|
+
|
|
|
+const lineHands = computed(() => {
|
|
|
+ const result = [
|
|
|
+ createLineHand('hour-line', props.hourHand, getHourAngle(Number(getHandTime(props.hourHand)))),
|
|
|
+ createLineHand(
|
|
|
+ 'minute-line',
|
|
|
+ props.minuteHand,
|
|
|
+ getMinuteSecondAngle(Number(getHandTime(props.minuteHand)))
|
|
|
+ ),
|
|
|
+ createLineHand(
|
|
|
+ 'second-line',
|
|
|
+ props.secondHand,
|
|
|
+ getMinuteSecondAngle(Number(getHandTime(props.secondHand)))
|
|
|
+ )
|
|
|
+ ]
|
|
|
+
|
|
|
+ return result.filter(isLineHand)
|
|
|
+})
|
|
|
+
|
|
|
+const createImageHand = (key: string, hand: HandConfig, angle: number) => {
|
|
|
+ if (hand.type !== 'image') return null
|
|
|
+
|
|
|
+ const src = getImageSrc(hand.image?.image)
|
|
|
+ if (!src) return null
|
|
|
+
|
|
|
+ const width = Math.max(1, Number(hand.image?.size?.width ?? 1))
|
|
|
+ const height = Math.max(1, Number(hand.image?.size?.height ?? 1))
|
|
|
+ const centerX = Number(hand.image?.center?.x ?? 0)
|
|
|
+ const centerY = Number(hand.image?.center?.y ?? 0)
|
|
|
+ const recolor = String(hand.image?.recolor ?? '#00000000')
|
|
|
+ const opacity = Math.max(0, Math.min(1, Number(hand.image?.alpha ?? 255) / 255))
|
|
|
+
|
|
|
+ return {
|
|
|
+ key,
|
|
|
+ src,
|
|
|
+ useMask: !isTransparentColor(recolor),
|
|
|
+ opacity,
|
|
|
+ imageStyle: {
|
|
|
+ backgroundColor: recolor,
|
|
|
+ opacity
|
|
|
+ } as CSSProperties,
|
|
|
+ style: {
|
|
|
+ left: `${center.value - centerX}px`,
|
|
|
+ top: `${center.value - centerY}px`,
|
|
|
+ width: `${width}px`,
|
|
|
+ height: `${height}px`,
|
|
|
+ transform: `rotate(${angle}deg)`,
|
|
|
+ transformOrigin: `${centerX}px ${centerY}px`
|
|
|
+ } as CSSProperties
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const isImageHand = (hand: ImageHand | null): hand is ImageHand => !!hand
|
|
|
+
|
|
|
+const imageHands = computed(() => {
|
|
|
+ const result = [
|
|
|
+ createImageHand(
|
|
|
+ 'hour-image',
|
|
|
+ props.hourHand,
|
|
|
+ getHourAngle(Number(getHandTime(props.hourHand)))
|
|
|
+ ),
|
|
|
+ createImageHand(
|
|
|
+ 'minute-image',
|
|
|
+ props.minuteHand,
|
|
|
+ getMinuteSecondAngle(Number(getHandTime(props.minuteHand)))
|
|
|
+ ),
|
|
|
+ createImageHand(
|
|
|
+ 'second-image',
|
|
|
+ props.secondHand,
|
|
|
+ getMinuteSecondAngle(Number(getHandTime(props.secondHand)))
|
|
|
+ )
|
|
|
+ ]
|
|
|
+
|
|
|
+ return result.filter(isImageHand)
|
|
|
+})
|
|
|
+</script>
|