<template>
<div class="ruler-wrapper">
<lollipop-marker v-for="item in markers" :key="item.value" :value="item.value" :color="item.color"
:is-hovered="hoveredValue === item.value" :is-dimmed="hoveredValue !== null && hoveredValue !== item.value"
:style="{
left: `${calculatePosition(item.value)}px`,
zIndex: calculateZIndex(item.value, hoveredValue)
}" @mouseenter="handleMouseEnter(item.value)" @mouseleave="handleMouseLeave" />
<div class="rainbow-ruler" :style="{ width: `${width}px` }" ref="rulerRef"></div>
<div class="scale-container" :style="{ width: `${width}px` }">
<div v-for="value in scaleValues" :key="value" class="scale-item"
:style="{ left: `${calculatePosition(value)}px` }">
{{ value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useColorGradient } from './composables/useColorGradient'
import { useLollipopMarkers } from './composables/useLollipopMarkers'
import LollipopMarker from './components/LollipopMarker.vue'
const props = defineProps({
width: { type: Number, default: 800 },
minValue: { type: Number, default: -90 },
maxValue: { type: Number, default: -30 },
step: { type: Number, default: 5 },
markerList: {
type: Array,
default: () => [
{ value: -85 },
{ value: -80 },
{ value: -75 },
{ value: -75 },
{ value: -70 },
{ value: -65 },
{ value: -61 },
{ value: -60 },
{ value: -55 },
{ value: -50 },
{ value: -45 },
{ value: -40 },
{ value: -35 }
]
}
})
const rulerRef = ref(null)
const hoveredValue = ref(null)
const { getColorAtPosition } = useColorGradient(props.width)
const { markers, updateMarkerColors } = useLollipopMarkers(props, rulerRef, getColorAtPosition)
const scaleValues = computed(() => {
const values = []
for (let i = props.minValue; i <= props.maxValue; i += props.step) {
values.push(i)
}
return values
})
const calculatePosition = (value) => {
const range = props.maxValue - props.minValue
return ((value - props.minValue) / range) * props.width
}
const calculateZIndex = (value, hovered) => {
const baseZIndex = Math.floor(((value - props.minValue) / (props.maxValue - props.minValue)) * 100) + 1
return value === hovered ? 1000 : baseZIndex
}
const handleMouseEnter = (value) => hoveredValue.value = value
const handleMouseLeave = () => hoveredValue.value = null
onMounted(() => {
updateMarkerColors()
})
watch(() => props.markerList, updateMarkerColors, { deep: true })
</script>
<style scoped>
.ruler-wrapper {
position: relative;
}
.rainbow-ruler {
height: 10px;
background: linear-gradient(to right,
red 0%,
orange 16.66%,
yellow 33.33%,
green 50%,
cyan 66.66%,
blue 83.33%,
violet 100%);
}
.scale-container {
position: relative;
height: 30px;
}
.scale-item {
position: absolute;
top: 10px;
transform: translateX(-50%);
font-size: 14px;
}
.marker.lollipop {
position: absolute;
bottom: 30px;
transform: translateX(-50%);
transition: left 0.3s ease, opacity 0.3s ease, transform 0.2s ease;
}
.lollipop-stick {
width: 2px;
height: 10px;
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
transition: background-color 0.3s ease, filter 0.3s ease;
}
.lollipop-head {
width: 24px;
height: 24px;
border-radius: 50%;
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
transition: background-color 0.3s ease, filter 0.3s ease;
}
.marker.lollipop.is-hover {
transform: translateX(-50%) scale(1.1);
}
.marker.lollipop.is-hover .lollipop-stick,
.marker.lollipop.is-hover .lollipop-head {
filter: brightness(1.2);
}
.marker.lollipop.is-dimmed .lollipop-stick,
.marker.lollipop.is-dimmed .lollipop-head {
filter: grayscale(0.8) opacity(0.4);
}
</style>
<template>
<div class="marker lollipop" :class="{
'is-hover': isHovered,
'is-dimmed': isDimmed
}">
<div class="lollipop-stick" :style="{ backgroundColor: color }"></div>
<div class="lollipop-head" :style="{ backgroundColor: color }"></div>
</div>
</template>
<script setup>
defineProps({
value: Number,
color: String,
isHovered: Boolean,
isDimmed: Boolean
})
</script>
import { computed } from 'vue'
export const useLollipopMarkers = (props, rulerRef, getColorAtPosition) => {
const calculatePosition = (value) => {
const range = props.maxValue - props.minValue
return ((value - props.minValue) / range) * props.width
}
const markers = computed(() => {
return props.markerList.map(item => ({
...item,
color: getColorAtPosition(rulerRef.value, calculatePosition(item.value))
}))
})
const updateMarkerColors = () => {
if (rulerRef.value) {
markers.value = props.markerList.map(item => ({
...item,
color: getColorAtPosition(rulerRef.value, calculatePosition(item.value))
}))
}
}
return { markers, updateMarkerColors }
}
export const useColorGradient = (width) => {
const createGradient = (ctx) => {
const gradient = ctx.createLinearGradient(0, 0, width, 0)
gradient.addColorStop(0, 'red')
gradient.addColorStop(0.1666, 'orange')
gradient.addColorStop(0.3333, 'yellow')
gradient.addColorStop(0.5, 'green')
gradient.addColorStop(0.6666, 'cyan')
gradient.addColorStop(0.8333, 'blue')
gradient.addColorStop(1, 'violet')
return gradient
}
const getColorAtPosition = (element, x) => {
if (!element) return ''
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = width
canvas.height = 1
ctx.fillStyle = createGradient(ctx)
ctx.fillRect(0, 0, width, 1)
const pixelData = ctx.getImageData(x, 0, 1, 1).data
return `rgb(${pixelData[0]}, ${pixelData[1]}, ${pixelData[2]})`
}
return { getColorAtPosition }
}
.ruler-wrapper {
position: relative;
}
.rainbow-ruler {
height: 10px;
background: linear-gradient(
to right,
red 0%,
orange 16.66%,
yellow 33.33%,
green 50%,
cyan 66.66%,
blue 83.33%,
violet 100%
);
}
.scale-container {
position: relative;
height: 30px;
}
.scale-item {
position: absolute;
top: 10px;
transform: translateX(-50%);
font-size: 14px;
}
.marker.lollipop {
position: absolute;
bottom: 10px;
transform: translateX(-50%);
transition: left 0.3s ease, opacity 0.3s ease, transform 0.2s ease;
}
.lollipop-stick {
width: 2px;
height: 10px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
transition: background-color 0.3s ease, filter 0.3s ease;
}
.lollipop-head {
width: 24px;
height: 24px;
border-radius: 50%;
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
transition: background-color 0.3s ease, filter 0.3s ease;
}
.marker.lollipop.is-hover {
transform: translateX(-50%) scale(1.1);
}
.marker.lollipop.is-hover .lollipop-stick,
.marker.lollipop.is-hover .lollipop-head {
filter: brightness(1.2);
}
.marker.lollipop.is-dimmed .lollipop-stick,
.marker.lollipop.is-dimmed .lollipop-head {
filter: grayscale(0.8) opacity(0.4);
}
<template>
<div class="ruler-wrapper">
<a-tooltip v-for="(item, index) in markers" :key="item.value">
<template #title>
值: {{ item.value }}<br>
序号: {{ index + 1 }}
</template>
<div class="marker lollipop" :class="{
'is-hover': hoveredValue === item.value, // 当前悬停的标记
'is-dimmed': hoveredValue !== null && hoveredValue !== item.value // 非悬停标记变暗
}" :style="{
left: `${calculatePosition(item.value)}px`, // 根据值计算水平位置
zIndex: calculateZIndex(item.value, hoveredValue), // 控制层级,确保悬停项在最上层
color: item.color // 添加color属性,用于继承
}" @mouseenter="handleMouseEnter(item.value)" @mouseleave="handleMouseLeave">
<div class="lollipop-stick" :style="{ backgroundColor: item.color }"></div>
<div class="lollipop-head">
<img :src="item.imageUrl" :alt="'image-' + item.value" class="lollipop-image" />
</div>
</div>
</a-tooltip>
<div class="rainbow-ruler" :style="{ width: `${width}px` }" ref="rulerRef"></div>
<div class="scale-container" :style="{ width: `${width}px` }">
<div v-for="value in scaleValues" :key="value" class="scale-item"
:style="{ left: `${calculatePosition(value)}px` }">
{{ value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Tooltip as ATooltip } from 'ant-design-vue'
const props = defineProps({
width: { type: Number, default: 800 },
minValue: { type: Number, default: -90 },
maxValue: { type: Number, default: -30 },
step: { type: Number, default: 5 },
markerList: {
type: Array,
default: () => [
{ value: -85, imageUrl: 'https://picsum.photos/16?random=1' },
{ value: -80, imageUrl: 'https://picsum.photos/16?random=2' },
{ value: -75, imageUrl: 'https://picsum.photos/16?random=3' },
{ value: -75, imageUrl: 'https://picsum.photos/16?random=4' },
{ value: -70, imageUrl: 'https://picsum.photos/16?random=5' },
{ value: -65, imageUrl: 'https://picsum.photos/16?random=6' },
{ value: -61, imageUrl: 'https://picsum.photos/16?random=7' },
{ value: -60, imageUrl: 'https://picsum.photos/16?random=8' },
{ value: -55, imageUrl: 'https://picsum.photos/16?random=9' },
{ value: -50, imageUrl: 'https://picsum.photos/16?random=10' },
{ value: -45, imageUrl: 'https://picsum.photos/16?random=11' },
{ value: -40, imageUrl: 'https://picsum.photos/16?random=12' },
{ value: -35, imageUrl: 'https://picsum.photos/16?random=13' }
]
}
})
const rulerRef = ref(null)
const hoveredValue = ref(null)
const markers = ref([])
const scaleValues = computed(() => {
const values = []
for (let i = props.minValue; i <= props.maxValue; i += props.step) {
values.push(i)
}
return values
})
const calculatePosition = (value) => {
const range = props.maxValue - props.minValue
return ((value - props.minValue) / range) * props.width
}
const calculateZIndex = (value, hovered) => {
const baseZIndex = Math.floor(((value - props.minValue) / (props.maxValue - props.minValue)) * 100) + 1
return value === hovered ? 1000 : baseZIndex
}
const updateMarkerColors = () => {
if (!rulerRef.value) return
markers.value = props.markerList.map(item => ({
...item,
color: getColorAtPosition(rulerRef.value, calculatePosition(item.value))
}))
}
const handleMouseEnter = (value) => hoveredValue.value = value
const handleMouseLeave = () => hoveredValue.value = null
onMounted(() => {
updateMarkerColors()
})
watch(() => props.markerList, updateMarkerColors, { deep: true })
function createGradient(ctx) {
const gradient = ctx.createLinearGradient(0, 0, props.width, 0)
gradient.addColorStop(0, 'red')
gradient.addColorStop(0.1666, 'orange')
gradient.addColorStop(0.3333, 'yellow')
gradient.addColorStop(0.5, 'green')
gradient.addColorStop(0.6666, 'cyan')
gradient.addColorStop(0.8333, 'blue')
gradient.addColorStop(1, 'violet')
return gradient
}
function getColorAtPosition(element, x) {
if (!element) return ''
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = props.width
canvas.height = 1
ctx.fillStyle = createGradient(ctx)
ctx.fillRect(0, 0, props.width, 1)
const pixelData = ctx.getImageData(x, 0, 1, 1).data
return `rgb(${pixelData[0]}, ${pixelData[1]}, ${pixelData[2]})`
}
</script>
<style scoped>
.ruler-wrapper {
position: relative;
width: v-bind(width + 'px');
}
.rainbow-ruler {
height: 2px;
background: linear-gradient(to right,
red 0%,
orange 16.66%,
yellow 33.33%,
green 50%,
cyan 66.66%,
blue 83.33%,
violet 100%);
}
.scale-container {
position: relative;
height: 16px;
}
.scale-item {
position: absolute;
top: 3px;
transform: translateX(-50%);
font-size: 10px;
}
.marker.lollipop {
position: absolute;
bottom: 18px;
transform: translateX(-50%);
transition: all 0.3s ease;
}
.lollipop-stick {
width: 2px;
height: 10px;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
transition: background-color 0.3s ease, filter 0.3s ease;
}
.lollipop-head {
width: 24px;
height: 24px;
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
background-color: transparent !important;
}
.lollipop-head::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid currentColor;
border-radius: 50%;
}
.lollipop-image {
width: 16px;
height: 16px;
object-fit: contain;
z-index: 1;
transition: all 0.3s ease;
}
.marker.lollipop.is-hover {
transform: translateX(-50%) scale(1.1);
z-index: 100;
}
.marker.lollipop.is-dimmed {
opacity: 0.4;
filter: grayscale(0.8);
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
margin-bottom: 8px;
z-index: 1000;
}
.tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 4px;
border-style: solid;
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
}
</style>