| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225 |
- <template>
- <view class="chart-container">
- <!-- Legend 图例 - 顶部 -->
- <view v-if="mergedLegendConfig.enabled && mergedLegendConfig.position === 'top'" class="legend legend-top">
- <view
- :key="index"
- v-for="(dataset, index) in chartData.datasets"
- :class="['legend-item', { 'legend-item-disabled': hiddenDatasets[index] }]"
- @tap="toggleDataset(index)"
- >
- <view class="legend-color" :style="{ backgroundColor: dataset.color }" />
- <text class="legend-label">{{ dataset.label }}</text>
- </view>
- </view>
- <view class="chart-wrapper" :style="{ width: width, height: height }">
- <!-- 加载占位符 -->
- <!-- <view v-if="!isInitialized" class="chart-loading">
- <text class="loading-text">加载中...</text>
- </view> -->
- <!-- #ifdef MP-ALIPAY -->
- <canvas
- :id="canvasId"
- v-show="isInitialized"
- :canvas-id="canvasId"
- class="chart"
- :height="canvasHeight"
- :style="{ width: canvasStyleWidth + 'px', height: canvasStyleHeight + 'px' }"
- :width="canvasWidth"
- @touchend="handleTouchEnd"
- @touchmove="handleTouchMove"
- @touchstart="handleTouchStart"
- />
- <!-- #endif -->
- <!-- #ifndef MP-ALIPAY -->
- <canvas
- :id="canvasId"
- v-show="isInitialized"
- :canvas-id="canvasId"
- class="chart"
- :style="{ width: width, height: height }"
- type="2d"
- @touchend="handleTouchEnd"
- @touchmove="handleTouchMove"
- @touchstart="handleTouchStart"
- />
- <!-- #endif -->
- <!-- Tooltip浮层 -->
- <view class="tooltip" :style="{...tooltipStyle}">
- <view class="tooltip-label">{{ tooltipData.label }}</view>
- <view
- :key="index"
- v-for="(dataset, index) in chartData.datasets"
- class="tooltip-item"
- >
- <view class="tooltip-dot" :style="{ backgroundColor: dataset.color }" />
- <text style="white-space: nowrap;">{{ dataset.label }}: {{ formatTooltipValue(tooltipData.values[index]) }}</text>
- </view>
- </view>
- <!-- 缩放控制按钮 -->
- <view v-if="showZoomControls && scale !== 1" class="zoom-controls">
- <button class="reset-btn" @tap="resetZoom">重置缩放</button>
- </view>
- </view>
- <!-- Legend 图例 - 底部 -->
- <view v-if="mergedLegendConfig.enabled && mergedLegendConfig.position === 'bottom'" class="legend legend-bottom">
- <view
- :key="index"
- v-for="(dataset, index) in chartData.datasets"
- :class="['legend-item', { 'legend-item-disabled': hiddenDatasets[index] }]"
- @tap="toggleDataset(index)"
- >
- <view class="legend-color" :style="{ backgroundColor: dataset.color }" />
- <text class="legend-label">{{ dataset.label }}</text>
- </view>
- </view>
- </view>
- </template>
- <script>
- // https://ext.dcloud.net.cn/plugin?id=25773
- export default {
- name: 'LineChart',
- props: {
- canvasId: {
- type: String,
- default: 'lineChart_' + new Date().getTime()
- },
-
- // 图表类型:'line' | 'bar'
- chartType: {
- type: String,
- default: 'line'
- },
- // 图表尺寸
- width: {
- type: String,
- default: '100%'
- },
- height: {
- type: String,
- default: '250px'
- },
- // 图表数据
- chartData: {
- type: Object,
- required: true,
- // 数据格式示例:
- // {
- // labels: ['1月', '2月', '3月', ...],
- // datasets: [
- // {
- // label: '产品A',
- // data: [30, 45, 60, ...],
- // color: '#FF6B9D',
- // gradientStart: '#FFB6C1',
- // gradientEnd: '#FF1493'
- // },
- // {
- // label: '产品B',
- // data: [50, 65, 80, ...],
- // color: '#4ECDC4',
- // gradientStart: '#A8E6CF',
- // gradientEnd: '#00CED1'
- // }
- // ]
- // }
- },
- // 内边距配置
- padding: {
- type: Object,
- default: ()=> ({
- top: 20,
- right: 20,
- bottom: 30,
- left: 50
- })
- },
- // 缩放配置
- zoomConfig: {
- type: Object,
- default: ()=> ({
- enabled: true, // 是否启用缩放
- minScale: 1, // 最小缩放比例
- maxScale: 10, // 最大缩放比例
- showControls: true // 是否显示重置按钮
- })
- },
- // 动画配置
- animationConfig: {
- type: Object,
- default: ()=> ({
- enabled: true, // 是否启用动画
- duration: 30 // 动画帧数(默认30帧,约500ms)
- })
- },
- // Y轴配置
- yAxisConfig: {
- type: Object,
- default: ()=> ({
- steps: 5, // Y轴刻度数量
- autoRange: true, // 自动计算范围
- min: null, // 手动设置最小值
- max: null, // 手动设置最大值
- allowNegative: true // 是否允许Y轴出现负数(默认允许)
- })
- },
- // X轴配置
- xAxisConfig: {
- type: Object,
- default: ()=> ({
- labelDensity: 'auto', // 标签密度:'auto' | 'sparse' | 'dense'
- showAllLabels: false, // 是否显示所有标签
- formatter: null, // 自定义格式化函数:(label, index) => string
- lineHeight: 14 // 多行文本的行高(像素)
- })
- },
- // Tooltip配置
- tooltipConfig: {
- type: Object,
- default: ()=> ({
- enabled: true, // 是否启用tooltip
- threshold: 60 // 触发距离阈值(px)
- })
- },
- // Legend配置
- legendConfig: {
- type: Object,
- default: ()=> ({
- enabled: true, // 是否显示图例
- position: 'bottom', // 位置:'top' | 'bottom'
- clickable: true // 是否可点击切换显示/隐藏
- })
- },
- // 柱状图配置
- barConfig: {
- type: Object,
- default: ()=> ({
- width: 8, // 固定柱宽(像素)
- innerGap: 2, // 柱组内间隙(像素)
- bottomGap: 2, // 底部与 X 轴的间隙(像素)
- leftGap: 2, // 与 Y 轴的间隙保护(像素)
- // 动画模式:'all' - 所有柱同一进度生长; 'stagger' - 按列逐步渲染
- animateMode: 'stagger',
- // 当 animateMode === 'stagger' 时,列间的延迟(进度的分数,0.0 - 1.0)
- staggerDelayFraction: 0.02,
- // 圆角:支持 number / { top, bottom } / { topLeft, topRight, bottomRight, bottomLeft }
- // 默认仅顶部圆角
- borderRadius: { top: 4, bottom: 0 }
- })
- }
- },
- data() {
- return {
- ctx: null,
- canvas: null,
- dpr: 1,
- chartWidth: 0,
- chartHeight: 0,
- // 动画相关
- animationProgress: 0,
- animationFrameCount: 0,
- // 数据点 - 改为数组以支持多折线
- points: [], // 每个元素是一个数据集的点数组
- // Tooltip
- showTooltip: false,
- currentPointIndex: -1,
- tooltipData: {
- label: '',
- values: [] // 改为数组以支持多折线
- },
- tooltipPosition: {
- x: 0,
- y: 0
- },
- // Canvas位置缓存
- canvasRect: null,
- wrapperRect: null,
- // 数据过渡 - 改为数组以支持多折线
- oldData: [], // 每个元素是一个数据集的旧数据数组
- transitionProgress: 0,
- isTransitioning: false,
- // 缩放重置动画
- isResettingZoom: false,
- resetProgress: 0,
- resetStartScale: 1,
- resetStartOffsetX: 0,
- resetStartOffsetY: 0,
- // Legend 图例
- hiddenDatasets: {}, // 隐藏的数据集索引 { 0: true, 1: false, ... }
- yAxisMin: 0,
- yAxisMax: 100,
- // 缩放和平移
- scale: 1,
- offsetX: 0,
- offsetY: 0,
- // 持久的 bar 水平偏移,用来保证首柱与 Y 轴的最小间隙(在 scale===1 时生效)
- barShift: 0,
- // 柱状图布局缓存:每个数据集对应一组矩形 { x, y, width, height, value }
- barRects: [],
- lastTouchDistance: 0,
- lastTouchCenter: { x: 0, y: 0 },
- isPinching: false,
- isDragging: false,
- lastTouchPos: { x: 0, y: 0 },
- touchStartPos: { x: 0, y: 0 },
- touchStartTime: 0,
- // #ifdef MP-ALIPAY
- canvasWidth: 750, // 初始值,避免为0导致Canvas不显示
- canvasHeight: 400,
- canvasStyleWidth: 375,
- canvasStyleHeight: 250,
- // #endif
- // 初始化状态
- isInitialized: false
- }
- },
- computed: {
- // 合并后的配置(保留默认值)
- mergedPadding() {
- return {
- top: 20,
- right: 20,
- bottom: 30,
- left: 50,
- ...this.padding
- }
- },
- mergedZoomConfig() {
- return {
- enabled: true,
- minScale: 1,
- maxScale: 10,
- showControls: true,
- ...this.zoomConfig
- }
- },
- mergedAnimationConfig() {
- return {
- enabled: true,
- duration: 30,
- ...this.animationConfig
- }
- },
- mergedYAxisConfig() {
- return {
- steps: 5,
- autoRange: true,
- min: null,
- max: null,
- allowNegative: true,
- ...this.yAxisConfig
- }
- },
- mergedXAxisConfig() {
- return {
- labelDensity: 'auto',
- showAllLabels: false,
- formatter: null,
- lineHeight: 14,
- ...this.xAxisConfig
- }
- },
- mergedTooltipConfig() {
- return {
- enabled: true,
- threshold: 60,
- ...this.tooltipConfig
- }
- },
- mergedLegendConfig() {
- return {
- enabled: true,
- position: 'bottom',
- clickable: true,
- ...this.legendConfig
- }
- },
- mergedBarConfig() {
- return {
- width: 8, // 固定柱宽(像素)
- innerGap: 2, // 柱组内间隙(像素)
- bottomGap: 2, // 底部与 X 轴的间隙(像素)
- leftGap: 2, // 与 Y 轴的间隙保护(像素)
- // 动画模式:'all' - 所有柱同一进度生长; 'stagger' - 按列逐步渲染
- animateMode: 'stagger',
- // 当 animateMode === 'stagger' 时,列间的延迟(进度的分数,0.0 - 1.0)
- staggerDelayFraction: 0.02,
- // 圆角:默认仅顶部圆角,支持 number / { top, bottom } / per-corner 对象
- borderRadius: { top: 4, bottom: 0 },
- ...this.barConfig
- }
- },
- showZoomControls() {
- return this.mergedZoomConfig.enabled && this.mergedZoomConfig.showControls
- },
- tooltipStyle() {
- if (!this.showTooltip) {
- return {
- opacity: 0,
- transform: 'translateX(-50%) scale(0.8)',
- pointerEvents: 'none',
- transition: 'none'
- }
- }
- let top = this.tooltipPosition.y
- // #ifdef MP-TOUTIAO
- if(top > parseInt(this.height) - 20) {
- top = parseInt(this.height) / 2 - 10
- }
- // #endif
- return {
- left: this.tooltipPosition.x + 'px',
- top: top + 'px',
- opacity: 1,
- transform: 'translateX(-50%) scale(1)'
- }
- }
- },
- watch: {
- chartData: {
- handler(newVal, oldChartData) {
- if (!oldChartData || !this.ctx) {
- return
- }
- // 保存旧数据用于过渡动画 - 支持多折线
- this.oldData = oldChartData.datasets.map(dataset=> [...dataset.data])
- // 开始过渡动画
- this.startDataTransition()
- },
- deep: true
- }
- },
- mounted() {
- this.initChart()
- // // #ifdef H5
- // this.initChart()
- // // #endif
- },
- onReady() {
- this.initChart()
- // // #ifndef H5
- // this.initChart()
- // // #endif
- },
- beforeDestroy() {
- // 清理资源
- this.isInitialized = false
- this.ctx = null
- this.canvas = null
- },
- methods: {
- async initChart() {
- try {
- // 重置初始化状态
- this.isInitialized = false
- // #ifdef H5
- this.dpr = 1
- // #endif
- // #ifndef H5
- const systemInfo = uni.getSystemInfoSync()
- this.dpr = systemInfo.pixelRatio || 1
- // #endif
- // #ifdef MP-ALIPAY
- await this.initAlipayCanvas()
- // #endif
- // #ifndef MP-ALIPAY
- await this.initWechatCanvas()
- // #endif
- // 初始化旧数据 - 支持多折线
- this.oldData = this.chartData.datasets.map(dataset=> [...dataset.data])
- // 标记为已初始化
- this.isInitialized = true
- } catch (error) {
- console.error('Canvas初始化失败:', error)
- }
- if (this.mergedAnimationConfig.enabled) {
- this.startAnimation()
- } else {
- this.animationProgress = 1
- this.drawChart()
- }
- },
- // 微信/抖音小程序Canvas初始化(type="2d")
- // #ifndef MP-ALIPAY
- initWechatCanvas() {
- return new Promise((resolve)=> {
- // 延迟执行,确保DOM已渲染
- // setTimeout(()=> {
- // 先获取容器尺寸
- const query1 = uni.createSelectorQuery().in(this)
- query1.select('.chart-wrapper').boundingClientRect((rect)=> {
- if (!rect) {
- console.error('[LineChart] 无法获取容器尺寸')
- this.initFallbackCanvas()
- resolve()
- return
- }
- // 计算容器宽高
- const containerWidth = rect.width || rect.right - rect.left || 0
- const containerHeight = rect.height || rect.bottom - rect.top || 250
- if (containerWidth === 0 || containerHeight === 0) {
- console.error('[LineChart] 容器尺寸为0')
- this.initFallbackCanvas()
- resolve()
- return
- }
- // 再获取Canvas节点
- const query2 = uni.createSelectorQuery().in(this)
- query2
- .select(`#${this.canvasId}`)
- .fields({ node: true, size: true })
- .exec((res)=> {
- if (res[0] && res[0].node) {
- const canvas = res[0].node
- // 使用容器尺寸,而不是Canvas返回的尺寸
- const width = containerWidth
- const height = containerHeight
- this.canvas = canvas
- this.ctx = canvas.getContext('2d')
- this.chartWidth = width
- this.chartHeight = height
- // #ifndef H5
- canvas.width = width * this.dpr
- canvas.height = height * this.dpr
- // #endif
- this.ctx.scale(this.dpr, this.dpr)
- // 立即清空 Canvas,避免显示旧内容
- this.ctx.clearRect(0, 0, width, height)
- this.ctx.fillStyle = '#ffffff'
- this.ctx.fillRect(0, 0, width, height)
- this.updateCanvasRect()
- resolve()
- } else {
- console.error('[LineChart] 无法获取Canvas节点')
- this.initFallbackCanvas()
- resolve()
- }
- })
- }).exec()
- // }, 300) // 延迟300ms,确保DOM渲染完成
- })
- },
- // #endif
- initFallbackCanvas() {
- const query = uni.createSelectorQuery().in(this)
- query
- .select(`#${this.canvasId}`)
- .boundingClientRect((data)=> {
- this.chartWidth = data.width
- this.chartHeight = data.height
- this.ctx = uni.createCanvasContext(this.canvasId, this)
- this.updateCanvasRect()
- })
- .exec()
- },
- // 支付宝小程序Canvas初始化
- // #ifdef MP-ALIPAY
- initAlipayCanvas() {
- return new Promise((resolve)=> {
- // 计算Canvas显示尺寸(逻辑像素)
- const query = uni.createSelectorQuery().in(this)
- query.select('.chart-wrapper').boundingClientRect((rect)=> {
- if (!rect) {
- console.error('无法获取chart-wrapper尺寸')
- resolve()
- return
- }
- const displayWidth = rect.width
- const displayHeight = rect.height
- // 设置Canvas样式尺寸(显示尺寸)
- this.canvasStyleWidth = displayWidth
- this.canvasStyleHeight = displayHeight
- // 设置Canvas物理像素尺寸(实际绘制尺寸,用于高清显示)
- this.canvasWidth = Math.floor(displayWidth * this.dpr)
- this.canvasHeight = Math.floor(displayHeight * this.dpr)
- // 设置绘制时使用的逻辑尺寸
- this.chartWidth = displayWidth
- this.chartHeight = displayHeight
- // 延迟执行,确保DOM已渲染
- setTimeout(()=> {
- this.ctx = uni.createCanvasContext(this.canvasId, this)
- if (!this.ctx) {
- console.error('Canvas上下文创建失败')
- resolve()
- return
- }
- // 缓存Canvas位置
- this.updateCanvasRect()
- resolve()
- }, 300)
- }).exec()
- })
- },
- // #endif
- updateCanvasRect() {
- const query = uni.createSelectorQuery().in(this)
- query.select(`#${this.canvasId}`).boundingClientRect((data)=> {
- this.canvasRect = data
- }).exec()
- query.select('.chart-wrapper').boundingClientRect((data)=> {
- this.wrapperRect = data
- }).exec()
- },
- startAnimation() {
- this.animationProgress = 0
- this.animationFrameCount = 0
- this.animate()
- },
- animate() {
- if (this.animationFrameCount < this.mergedAnimationConfig.duration) {
- this.animationFrameCount++
- this.animationProgress = this.animationFrameCount / this.mergedAnimationConfig.duration
- this.drawChart()
- setTimeout(()=> this.animate(), 16)
- } else {
- this.animationProgress = 1
- this.drawChart()
- }
- },
- startDataTransition() {
- this.isTransitioning = true
- this.transitionProgress = 0
- this.animateDataTransition()
- },
- animateDataTransition() {
- if (this.transitionProgress < 1) {
- this.transitionProgress += 0.1 // 加快过渡速度(从0.05改为0.1,约160ms完成)
- // 如果 tooltip 正在显示,更新 tooltip 数据
- if (this.showTooltip && this.currentPointIndex !== -1) {
- this.updateTooltipData(this.currentPointIndex)
- }
- this.drawChart()
- setTimeout(()=> this.animateDataTransition(), 16)
- } else {
- this.transitionProgress = 1
- this.isTransitioning = false
- this.oldData = this.chartData.datasets.map(dataset=> [...dataset.data])
- // 最后一次更新 tooltip 数据
- if (this.showTooltip && this.currentPointIndex !== -1) {
- this.updateTooltipData(this.currentPointIndex)
- }
- this.drawChart()
- }
- },
- easeOutQuad(t) {
- return t * (2 - t)
- },
- // 更新 Tooltip 数据
- updateTooltipData(closestIndex) {
- // 收集所有数据集的值 - 如果正在过渡则使用过渡数据
- const values = this.chartData.datasets.map((dataset, index)=> {
- if (this.isTransitioning) {
- const transitionData = this.getTransitionData(index)
- return transitionData[closestIndex]
- } else {
- return dataset.data[closestIndex]
- }
- })
- this.tooltipData = {
- label: this.chartData.labels[closestIndex],
- values: values
- }
- },
- // 格式化 Tooltip 显示的值
- formatTooltipValue(value) {
- if (value === undefined || value === null) {
- return '-'
- }
- // 四舍五入到整数
- // return Math.round(value)
- return value
- },
- // 重置缩放 - 带过渡动画
- resetZoom() {
- if (this.isResettingZoom) return
- // 保存当前状态
- this.resetStartScale = this.scale
- this.resetStartOffsetX = this.offsetX
- this.resetStartOffsetY = this.offsetY
- // 开始重置动画
- this.isResettingZoom = true
- this.resetProgress = 0
- this.animateResetZoom()
- },
- // 重置缩放动画
- animateResetZoom() {
- if (!this.isResettingZoom) return
- const duration = 200 // 动画时长(毫秒,加快速度)
- const startTime = Date.now()
- const animate = ()=> {
- const elapsed = Date.now() - startTime
- const progress = Math.min(elapsed / duration, 1)
- // 使用缓动函数
- const eased = this.easeOutCubic(progress)
- // 插值计算当前值
- this.scale = this.resetStartScale + (1 - this.resetStartScale) * eased
- this.offsetX = this.resetStartOffsetX + (0 - this.resetStartOffsetX) * eased
- this.offsetY = this.resetStartOffsetY + (0 - this.resetStartOffsetY) * eased
- // 重绘图表
- this.drawChart()
- if (progress < 1) {
- // 继续动画
- setTimeout(()=> animate(), 16) // 约60fps
- } else {
- // 动画结束
- this.scale = 1
- this.offsetX = 0
- this.offsetY = 0
- this.isResettingZoom = false
- this.drawChart()
- }
- }
- animate()
- },
- // 缓动函数 - 三次方缓出
- easeOutCubic(t) {
- return 1 - Math.pow(1 - t, 3)
- },
- // 切换数据集显示/隐藏
- toggleDataset(index) {
- if (!this.mergedLegendConfig.clickable) return
- // 切换隐藏状态
- this.$set(this.hiddenDatasets, index, !this.hiddenDatasets[index])
- // 重绘图表
- this.drawChart()
- },
- // 绘制图表
- drawChart() {
- if (!this.ctx) {
- return
- }
- const { chartWidth, chartHeight, chartData, animationProgress } = this
- const padding = this.mergedPadding
- // 支付宝小程序需要在每次绘制前设置缩放
- // #ifdef MP-ALIPAY
- // 先保存状态
- this.ctx.save()
- // 设置缩放以适配高分辨率
- this.ctx.scale(this.dpr, this.dpr)
- // #endif
- // 清空画布并绘制白色背景
- // #ifdef MP-ALIPAY
- this.ctx.fillStyle = '#ffffff'
- this.ctx.fillRect(0, 0, chartWidth, chartHeight)
- // #endif
- // #ifndef MP-ALIPAY
- this.ctx.clearRect(0, 0, chartWidth, chartHeight)
- this.ctx.fillStyle = '#ffffff'
- this.ctx.fillRect(0, 0, chartWidth, chartHeight)
- // #endif
- // 计算坐标轴区域
- const graphWidth = chartWidth - padding.left - padding.right
- const graphHeight = chartHeight - padding.top - padding.bottom
- // 绘制坐标轴和网格
- this.drawAxis(graphWidth, graphHeight, padding)
- // 计算数据点位置
- this.calculatePoints(graphWidth, graphHeight, padding)
- // 保存上下文状态,为绘制数据内容设置裁剪
- this.ctx.save()
- // 设置裁剪区域
- const clipPaddingRight = 10
- const clipPaddingLeft = 6
- const clipPaddingVertical = 10
- this.ctx.beginPath()
- this.ctx.rect(
- padding.left - clipPaddingLeft,
- padding.top - clipPaddingVertical,
- graphWidth + clipPaddingLeft + clipPaddingRight,
- graphHeight + clipPaddingVertical * 2
- )
- this.ctx.clip()
- // 绘制折线 - 支持多折线
- chartData.datasets.forEach((dataset, index)=> {
- // 跳过隐藏的数据集
- if (this.hiddenDatasets[index]) return
- if (this.chartType === 'bar') {
- // 柱状图在后面统一绘制
- // prepare barRects in calculateBarRects
- } else {
- if (this.points[index]) {
- this.drawLine(this.points[index], dataset, animationProgress)
- }
- }
- })
- // 绘制数据点 - 支持多折线
- chartData.datasets.forEach((dataset, index)=> {
- // 跳过隐藏的数据集
- if (this.hiddenDatasets[index]) return
- if (this.chartType === 'bar') {
- // 柱状图绘制由 drawBars 处理
- // ensure barRects 已计算
- if (!this.barRects || this.barRects.length === 0) {
- this.calculateBarRects(graphWidth, graphHeight, padding)
- }
- const rects = this.barRects[index] || []
- this.drawBars(rects, dataset, animationProgress)
- } else {
- if (this.points[index]) {
- this.drawPoints(this.points[index], dataset, animationProgress)
- }
- }
- })
- // 恢复上下文状态
- this.ctx.restore()
- // 如果正在显示Tooltip,绘制指示线
- if (this.showTooltip && this.currentPointIndex !== -1) {
- // 不使用额外的裁剪区域绘制 tooltip 指示,避免阴影被裁切。
- // drawTooltipLine 内部会限制绘制范围为图表区域。
- this.drawTooltipLine(this.currentPointIndex)
- }
- // 支付宝小程序需要调用draw方法
- // #ifdef MP-ALIPAY
- // 恢复之前保存的状态
- this.ctx.restore()
- if (this.ctx && this.ctx.draw) {
- this.ctx.draw()
- }
- // #endif
- },
- // 绘制 X 轴标签(支持换行符)
- drawXAxisLabel(text, x, y) {
- // 将文本按换行符分割
- const lines = String(text).split('\n')
- const lineHeight = this.mergedXAxisConfig.lineHeight || 14
- // 如果只有一行,直接绘制
- if (lines.length === 1) {
- this.ctx.fillText(text, x, y)
- return
- }
- // 多行文本,计算起始 Y 坐标(居中对齐)
- const totalHeight = lines.length * lineHeight
- const startY = y - (totalHeight - lineHeight) / 2
- // 绘制每一行
- lines.forEach((line, index)=> {
- this.ctx.fillText(line, x, startY + index * lineHeight)
- })
- },
- // 绘制坐标轴 - 支持多折线
- drawAxis(graphWidth, graphHeight, padding) {
- // 收集所有数据集的数据(跳过隐藏的数据集)
- const allData = []
- this.chartData.datasets.forEach((dataset, index)=> {
- // 跳过隐藏的数据集
- if (this.hiddenDatasets[index]) return
- const data = this.isTransitioning ? this.getTransitionData(index) : dataset.data
- allData.push(...data)
- })
- // 计算 Y 轴范围
- let minValue, maxValue
- // 检查是否手动设置了范围
- if (!this.mergedYAxisConfig.autoRange &&
- this.mergedYAxisConfig.min !== null &&
- this.mergedYAxisConfig.max !== null) {
- // 使用手动设置的范围
- minValue = this.mergedYAxisConfig.min
- maxValue = this.mergedYAxisConfig.max
- } else {
- // 自动计算范围
- if (allData.length === 0) {
- // 如果所有数据集都被隐藏,使用默认值
- minValue = 0
- maxValue = 100
- } else {
- const dataMin = Math.min(...allData)
- const dataMax = Math.max(...allData)
- let dataRange = dataMax - dataMin
- // 如果所有数据都相同(dataRange = 0),设置一个默认范围
- if (dataRange === 0) {
- // 如果数据值为0,使用默认范围 0~100
- if (dataMin === 0) {
- minValue = 0
- maxValue = 100
- } else {
- // 如果数据值不为0,使用数据值的 ±20%
- const defaultRange = Math.abs(dataMin) * 0.4
- minValue = Math.floor(dataMin - defaultRange)
- maxValue = Math.ceil(dataMax + defaultRange)
- }
- } else {
- // 正常情况:添加20%的padding
- const padding_percent = 0.2
- minValue = Math.floor(dataMin - dataRange * padding_percent)
- maxValue = Math.ceil(dataMax + dataRange * padding_percent)
- }
- // 如果配置不允许负数,且计算出的最小值为负数,则将最小值设为0
- if (!this.mergedYAxisConfig.allowNegative && minValue < 0) {
- minValue = 0
- }
- }
- // 如果只设置了 min,使用手动 min 和自动计算的 max
- if (this.mergedYAxisConfig.min !== null) {
- minValue = this.mergedYAxisConfig.min
- }
- // 如果只设置了 max,使用自动计算的 min 和手动 max
- if (this.mergedYAxisConfig.max !== null) {
- maxValue = this.mergedYAxisConfig.max
- }
- }
- this.yAxisMin = minValue
- this.yAxisMax = maxValue
- const centerX = padding.left + graphWidth / 2
- // 绘制Y轴网格线
- const ySteps = this.mergedYAxisConfig.steps
- const yRange = maxValue - minValue
- const yStepValue = yRange / ySteps
- const yStepHeight = graphHeight / ySteps
- for (let i = 0; i <= ySteps; i++) {
- const y = padding.top + i * yStepHeight
- if (i === ySteps) {
- this.ctx.strokeStyle = '#d0d7de'
- this.ctx.lineWidth = 1
- } else {
- this.ctx.strokeStyle = '#eaeef2'
- this.ctx.lineWidth = 0.5
- }
- this.ctx.beginPath()
- this.ctx.moveTo(padding.left, y)
- this.ctx.lineTo(padding.left + graphWidth, y)
- this.ctx.stroke()
- this.ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif'
- this.ctx.fillStyle = '#57606a'
- this.ctx.textAlign = 'right'
- this.ctx.textBaseline = 'middle'
- const labelValue = Math.round(maxValue - i * yStepValue)
- this.ctx.fillText(labelValue + '', padding.left - 10, y)
- }
- // 绘制X轴标签
- this.ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif'
- this.ctx.textAlign = 'center'
- this.ctx.textBaseline = 'top'
- this.ctx.fillStyle = '#57606a'
- const labelCount = this.chartData.labels.length
- let step = 1
- // 如果配置了 showAllLabels,显示所有标签
- if (this.mergedXAxisConfig.showAllLabels) {
- step = 1
- } else {
- // 根据 labelDensity 配置计算步长
- const effectiveScale = this.scale
- const scaledSpacing = graphWidth / (labelCount - 1) * effectiveScale
- // 根据缩放后的间距和配置的密度计算步长
- if (this.mergedXAxisConfig.labelDensity === 'sparse') {
- // 稀疏模式
- if (scaledSpacing > 80) {
- step = 1
- } else if (scaledSpacing > 50) {
- step = 3
- } else if (scaledSpacing > 30) {
- step = 5
- } else if (labelCount > 20) {
- step = Math.ceil(labelCount / 5)
- } else if (labelCount > 12) {
- step = Math.ceil(labelCount / 7)
- } else {
- step = 3
- }
- } else if (this.mergedXAxisConfig.labelDensity === 'dense') {
- // 密集模式
- if (scaledSpacing > 80) {
- step = 1
- } else if (scaledSpacing > 40) {
- step = 1
- } else if (scaledSpacing > 25) {
- step = 2
- } else if (labelCount > 20) {
- step = Math.ceil(labelCount / 12)
- } else if (labelCount > 12) {
- step = Math.ceil(labelCount / 15)
- } else {
- step = 1
- }
- } else {
- // auto 模式(默认)
- if (scaledSpacing > 80) {
- step = 1
- } else if (scaledSpacing > 50) {
- step = 2
- } else if (scaledSpacing > 30) {
- step = 3
- } else if (labelCount > 20) {
- step = Math.ceil(labelCount / 7)
- } else if (labelCount > 12) {
- step = Math.ceil(labelCount / 10)
- } else if (labelCount > 7) {
- step = 2
- }
- }
- }
- this.chartData.labels.forEach((label, i)=> {
- const baseX = padding.left + i * graphWidth / (this.chartData.labels.length - 1)
- const x = centerX + (baseX - centerX) * this.scale + this.offsetX
- if ((i % step === 0 || i === labelCount - 1) && x >= padding.left - 20 && x <= padding.left + graphWidth + 20) {
- // 应用自定义格式化函数
- let displayLabel = label
- if (this.mergedXAxisConfig.formatter && typeof this.mergedXAxisConfig.formatter === 'function') {
- displayLabel = this.mergedXAxisConfig.formatter(label, i)
- }
- // 绘制标签(支持换行符)
- this.drawXAxisLabel(displayLabel, x, padding.top + graphHeight + 12)
- }
- })
- // 绘制Y轴线
- this.ctx.strokeStyle = '#d0d7de'
- this.ctx.lineWidth = 1
- this.ctx.beginPath()
- this.ctx.moveTo(padding.left, padding.top)
- this.ctx.lineTo(padding.left, padding.top + graphHeight)
- this.ctx.stroke()
- },
- // 计算数据点位置 - 支持多折线
- calculatePoints(graphWidth, graphHeight, padding) {
- this.points = []
- const minValue = this.yAxisMin || 0
- const maxValue = this.yAxisMax || 100
- const yRange = maxValue - minValue
- const centerX = padding.left + graphWidth / 2
- // 为每个数据集计算点位置
- this.chartData.datasets.forEach((dataset, datasetIndex)=> {
- const data = this.isTransitioning ? this.getTransitionData(datasetIndex) : dataset.data
- const datasetPoints = []
- data.forEach((value, i)=> {
- const baseX = padding.left + i * graphWidth / (this.chartData.labels.length - 1)
- const baseY = padding.top + graphHeight - (value - minValue) / yRange * graphHeight
- const x = centerX + (baseX - centerX) * this.scale + this.offsetX
- const y = baseY
- datasetPoints.push({ x, y, value })
- })
- this.points.push(datasetPoints)
- })
- // 如果是柱状图,计算每个柱的矩形位置
- if (this.chartType === 'bar') {
- this.calculateBarRects(graphWidth, graphHeight, padding)
- }
- },
- // 计算柱状图矩形(每个数据集对应一组矩形)
- calculateBarRects(graphWidth, graphHeight, padding) {
- this.barRects = []
- // 如果处于缩放状态,清除持久偏移(持久偏移只在 scale===1 时生效)
- if (this.scale !== 1) {
- this.barShift = 0
- }
- const labelsCount = this.chartData.labels.length
- const minValue = this.yAxisMin || 0
- const maxValue = this.yAxisMax || 100
- const yRange = maxValue - minValue || 1
- // 可见数据集索引(保持原始索引以便图例切换匹配)
- const visibleIndices = this.chartData.datasets.map((d, i)=> i).filter(i=> !this.hiddenDatasets[i])
- const visibleCount = Math.max(1, visibleIndices.length)
- // 计算基准中心(与 calculatePoints 保持一致)
- const centerAnchor = padding.left + graphWidth / 2
- // 每个数据点在未缩放时的间距(与 calculatePoints 保持一致)
- const step = graphWidth / Math.max(1, labelsCount - 1)
- const groupWidth = step * 0.8
- // 单个柱子在未缩放时的宽度
- const baseSingleBarWidth = groupWidth / visibleCount
- // 初始化每个数据集的 rect 数组
- for (let i = 0; i < this.chartData.datasets.length; i++) {
- this.barRects[i] = []
- }
- this.chartData.datasets.forEach((dataset, datasetIndex)=> {
- const data = this.isTransitioning ? this.getTransitionData(datasetIndex) : dataset.data
- data.forEach((value, i)=> {
- // baseX 与 calculatePoints 保持一致(未缩放坐标)
- const baseX = padding.left + i * step
- // 固定柱宽(像素),但限制不超过 step * 0.9
- let singleBarWidth = this.mergedBarConfig.width || 16
- singleBarWidth = Math.min(singleBarWidth, step * 0.9)
- // 组内排列:组宽由固定宽度和 innerGap 决定(组内偏移不随缩放改变)
- const visibleIndex = visibleIndices.indexOf(datasetIndex)
- const innerGap = this.mergedBarConfig.innerGap || 4
- const totalGroupWidth = visibleCount * singleBarWidth + Math.max(0, visibleCount - 1) * innerGap
- const firstOffset = -totalGroupWidth / 2 + singleBarWidth / 2
- const offset = visibleIndex !== -1 ? firstOffset + visibleIndex * (singleBarWidth + innerGap) : 0
- // 将组内偏移(不随缩放)加到缩放后的组中心上,保证组内柱不分开
- // 同时应用持久的 barShift,以确保首柱与 Y 轴有足够间隙(在 scale===1 时)
- const centerX = centerAnchor + (baseX - centerAnchor) * this.scale + offset + this.offsetX + (this.barShift || 0)
- // 计算高度和Y坐标(预留底部 gap,避免与 X 轴重叠)
- const bottomGap = this.mergedBarConfig.bottomGap || 6
- const effectiveGraphHeight = Math.max(0, graphHeight - bottomGap)
- const valueClamped = value === undefined || value === null ? 0 : value
- const height = (valueClamped - minValue) / yRange * effectiveGraphHeight
- const x = centerX - singleBarWidth / 2
- const y = padding.top + (graphHeight - bottomGap) - height
- // 存储 index 以便 tooltip 命中时能对应到 label
- this.barRects[datasetIndex].push({ x, y, width: singleBarWidth, height, value: valueClamped, centerX, index: i })
- })
- })
- // 修复首列被半隐藏的问题:始终确保最左侧柱与 Y 轴之间保留 leftGap(兼顾缩放和平移)
- if (this.barRects && this.barRects.length > 0) {
- let leftMost = Infinity
- let maxBarWidth = 0
- this.barRects.forEach((rectArr)=> {
- if (!rectArr || rectArr.length === 0) return
- for (let r = 0; r < rectArr.length; r++) {
- const rect = rectArr[r]
- if (rect && typeof rect.x === 'number') leftMost = Math.min(leftMost, rect.x)
- if (rect && typeof rect.width === 'number') maxBarWidth = Math.max(maxBarWidth, rect.width)
- }
- })
- const halfBar = Math.ceil((maxBarWidth || (this.mergedBarConfig.width || 8)) / 2)
- const desiredLeft = (padding.left || 0) + (this.mergedBarConfig.leftGap || 2) + halfBar
- if (leftMost !== Infinity && leftMost < desiredLeft) {
- const delta = desiredLeft - leftMost
- // 如果用户当前在拖拽或正在双指缩放,则不要自动调整,这可能会影响手势感受
- if (this.isDragging || this.isPinching) {
- // 跳过自动调整,等待手势结束后由 constrainOffset 修正
- } else {
- if (this.scale === 1) {
- // 未缩放时,将持久偏移累加并直接平移 rects,保证下一次计算也会应用该偏移
- this.barShift = (this.barShift || 0) + delta
- this.barRects.forEach((rectArr)=> {
- if (!rectArr) return
- rectArr.forEach((rect)=> {
- if (!rect) return
- rect.x += delta
- rect.centerX = (rect.centerX || 0) + delta
- })
- })
- // 同步调整触摸缓存,防止手势判断异常
- this.lastTouchPos = { x: (this.lastTouchPos && this.lastTouchPos.x ? this.lastTouchPos.x : 0) + delta, y: this.lastTouchPos && this.lastTouchPos.y ? this.lastTouchPos.y : 0 }
- this.touchStartPos = { x: (this.touchStartPos && this.touchStartPos.x ? this.touchStartPos.x : 0) + delta, y: this.touchStartPos && this.touchStartPos.y ? this.touchStartPos.y : 0 }
- } else {
- // 在缩放状态下,不自动修改 offsetX,避免选中或重绘时导致跳动到边缘。
- // 缩放时的平移应由用户手势控制或由外部显式调用来调整。
- // 因此这里不做自动平移,仅保留 rects 当前计算值。
- }
- }
- }
- }
- },
- // 获取过渡中的数据 - 支持多折线
- getTransitionData(datasetIndex) {
- const oldDataset = this.oldData[datasetIndex] || []
- const newData = this.chartData.datasets[datasetIndex].data
- const progress = this.transitionProgress
- const eased = this.easeOutQuad(progress)
- const result = []
- const minLength = Math.min(oldDataset.length, newData.length)
- for (let i = 0; i < minLength; i++) {
- const oldValue = oldDataset[i]
- const newValue = newData[i]
- result.push(oldValue + (newValue - oldValue) * eased)
- }
- if (newData.length > oldDataset.length) {
- for (let i = oldDataset.length; i < newData.length; i++) {
- const lastOldValue = oldDataset[oldDataset.length - 1]
- const newValue = newData[i]
- result.push(lastOldValue + (newValue - lastOldValue) * eased)
- }
- }
- return result
- },
- // 绘制线条
- drawLine(points, dataset, progress) {
- if (points.length === 0) return
- const visiblePointCount = Math.max(1, Math.ceil(points.length * progress))
- const visiblePoints = points.slice(0, visiblePointCount)
- if (visiblePoints.length < 2) return
- this.ctx.save()
- let opacity = 1
- if (visiblePointCount === 1) {
- const firstPointProgress = progress * points.length
- opacity = Math.min(1, firstPointProgress)
- }
- this.ctx.strokeStyle = dataset.color
- this.ctx.lineWidth = 2
- this.ctx.lineCap = 'round'
- this.ctx.lineJoin = 'round'
- this.ctx.globalAlpha = opacity
- this.drawSmoothCurve(visiblePoints)
- this.ctx.restore()
- },
- // 绘制平滑曲线 - 使用 Catmull-Rom 样条曲线,确保曲线经过所有数据点
- drawSmoothCurve(points) {
- if (points.length < 2) return
- this.ctx.beginPath()
- this.ctx.moveTo(points[0].x, points[0].y)
- if (points.length === 2) {
- // 只有2个点时,绘制直线
- this.ctx.lineTo(points[1].x, points[1].y)
- } else {
- // 3个点及以上,使用 Catmull-Rom 样条曲线
- for (let i = 0; i < points.length - 1; i++) {
- const p0 = points[Math.max(0, i - 1)]
- const p1 = points[i]
- const p2 = points[i + 1]
- const p3 = points[Math.min(points.length - 1, i + 2)]
- // Catmull-Rom 转贝塞尔曲线的控制点
- const cp1x = p1.x + (p2.x - p0.x) / 6
- const cp1y = p1.y + (p2.y - p0.y) / 6
- const cp2x = p2.x - (p3.x - p1.x) / 6
- const cp2y = p2.y - (p3.y - p1.y) / 6
- this.ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y)
- }
- }
- this.ctx.stroke()
- },
- // 绘制数据点
- drawPoints(points, dataset, progress) {
- const visiblePoints = Math.ceil(points.length * progress)
- for (let i = 0; i < visiblePoints; i++) {
- const pointProgress = Math.min(
- 1,
- (progress - i / points.length) * points.length * 1.5
- )
- if (pointProgress > 0) {
- this.ctx.save()
- if (i === 0) {
- const firstPointProgress = progress * points.length
- this.ctx.globalAlpha = Math.min(1, firstPointProgress)
- }
- const gradient = this.ctx.createRadialGradient(
- points[i].x,
- points[i].y,
- 0,
- points[i].x,
- points[i].y,
- 3 * pointProgress
- )
- gradient.addColorStop(0, dataset.gradientStart)
- gradient.addColorStop(0.7, dataset.color)
- gradient.addColorStop(1, dataset.gradientEnd)
- this.ctx.fillStyle = gradient
- this.ctx.beginPath()
- this.ctx.arc(
- points[i].x,
- points[i].y,
- 3 * pointProgress,
- 0,
- 2 * Math.PI
- )
- this.ctx.fill()
- this.ctx.strokeStyle = '#ffffff'
- this.ctx.lineWidth = 1.5
- this.ctx.beginPath()
- this.ctx.arc(
- points[i].x,
- points[i].y,
- 3 * pointProgress,
- 0,
- 2 * Math.PI
- )
- this.ctx.stroke()
- this.ctx.restore()
- }
- }
- },
- // 绘制Tooltip指示线 - 支持多折线
- drawTooltipLine(pointIndex) {
- if (this.points.length === 0) return
- // 获取所有数据集在该索引处的点
- const allPoints = this.points.map(datasetPoints=> datasetPoints[pointIndex]).filter(p=> p)
- if (allPoints.length === 0) return
- // 如果不是柱状图,绘制垂直指示线及高亮圆点
- if (this.chartType !== 'bar') {
- // 绘制垂直指示线(使用第一个点的x坐标)
- const firstPoint = allPoints[0]
- this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'
- this.ctx.lineWidth = 1
- this.ctx.setLineDash([5, 3])
- this.ctx.beginPath()
- this.ctx.moveTo(firstPoint.x, this.mergedPadding.top)
- this.ctx.lineTo(
- firstPoint.x,
- this.mergedPadding.top + (this.chartHeight - this.mergedPadding.top - this.mergedPadding.bottom)
- )
- this.ctx.stroke()
- this.ctx.setLineDash([])
- // 为每个数据集绘制高亮点
- allPoints.forEach((point, datasetIndex)=> {
- const dataset = this.chartData.datasets[datasetIndex]
- // 外圈光晕
- this.ctx.strokeStyle = this.hexToRgba(dataset.color, 0.2)
- this.ctx.lineWidth = 4
- this.ctx.beginPath()
- this.ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI)
- this.ctx.stroke()
- // 渐变填充
- const gradient = this.ctx.createRadialGradient(
- point.x, point.y, 0,
- point.x, point.y, 4
- )
- gradient.addColorStop(0, dataset.gradientStart)
- gradient.addColorStop(1, dataset.gradientEnd)
- this.ctx.fillStyle = gradient
- this.ctx.beginPath()
- this.ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI)
- this.ctx.fill()
- // 白色边框
- this.ctx.strokeStyle = '#ffffff'
- this.ctx.lineWidth = 1.5
- this.ctx.beginPath()
- this.ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI)
- this.ctx.stroke()
- })
- }
- // 如果是柱状图,绘制高亮柱(半透明外圈)
- if (this.chartType === 'bar') {
- // 计算该组的最小/最大X用于包裹高亮
- const groupRects = []
- this.chartData.datasets.forEach((d, idx)=> {
- if (this.hiddenDatasets[idx]) return
- const rect = this.barRects[idx] && this.barRects[idx][pointIndex] || null
- if (rect) groupRects.push({ rect, dataset: d, datasetIndex: idx })
- })
- if (groupRects.length > 0) {
- const left = Math.min(...groupRects.map(g=> g.rect.x)) - 2
- const right = Math.max(...groupRects.map(g=> g.rect.x + g.rect.width)) + 2
- const padding = this.mergedPadding
- const graphHeight = this.chartHeight - padding.top - padding.bottom
- const highlightTop = padding.top
- const highlightHeight = graphHeight
- const highlightWidth = right - left
- // 使用第一个可见数据集的颜色作为阴影色
- const color = groupRects[0].dataset.color || '#000'
- // 解析 borderRadius,支持 number 或对象
- const cfg = this.mergedBarConfig || {}
- const br = cfg.borderRadius
- let hr = 4
- if (typeof br === 'number') hr = Math.min(br, 12)
- else if (br && typeof br === 'object') hr = Math.min(br.top || br.topLeft || 4, 12)
- this.ctx.save()
- this.ctx.shadowColor = this.hexToRgba(color, 0.28)
- this.ctx.shadowBlur = 18
- this.ctx.fillStyle = this.hexToRgba(color, 0.08)
- this.ctx.beginPath()
- this.drawRoundedRect(left - (cfg.leftGap || 0), highlightTop, highlightWidth + (cfg.leftGap || 0) * 2, highlightHeight, br || hr)
- this.ctx.fill()
- this.ctx.restore()
- // 描边以增强可见性
- this.ctx.strokeStyle = this.hexToRgba(color, 0.12)
- this.ctx.lineWidth = 1
- this.ctx.beginPath()
- this.drawRoundedRect(left - (cfg.leftGap || 0), highlightTop, highlightWidth + (cfg.leftGap || 0) * 2, highlightHeight, br || hr)
- this.ctx.stroke()
- // 在阴影之上重绘该组柱体,保持柱体清晰
- groupRects.forEach(g=> {
- const r = g.rect
- const ds = this.chartData.datasets[g.datasetIndex] || {}
- if (!r) return
- this.ctx.save()
- this.ctx.beginPath()
- // 使用与 drawBars 相同的 borderRadius 配置
- this.drawRoundedRect(r.x, r.y, r.width, r.height, br || hr)
- try {
- const gfill = this.ctx.createLinearGradient(r.x, r.y, r.x, r.y + r.height)
- gfill.addColorStop(0, ds.gradientStart || ds.color)
- gfill.addColorStop(1, ds.gradientEnd || ds.color)
- this.ctx.fillStyle = gfill
- } catch (err) {
- this.ctx.fillStyle = ds.color || '#2f95ff'
- }
- this.ctx.fill()
- if (ds.borderColor) {
- this.ctx.lineWidth = ds.borderWidth || 1
- this.ctx.strokeStyle = ds.borderColor
- this.ctx.beginPath()
- this.drawRoundedRect(r.x, r.y, r.width, r.height, br || hr)
- this.ctx.stroke()
- }
- this.ctx.restore()
- })
- }
- }
- },
- // 颜色转换
- hexToRgba(hex, opacity) {
- const r = parseInt(hex.slice(1, 3), 16)
- const g = parseInt(hex.slice(3, 5), 16)
- const b = parseInt(hex.slice(5, 7), 16)
- return `rgba(${r}, ${g}, ${b}, ${opacity})`
- },
- // 绘制圆角矩形的路径(支持单值或每角对象半径),使用当前 this.ctx
- drawRoundedRect(x, y, width, height, radius) {
- // radius 可以为数字或对象 { topLeft, topRight, bottomRight, bottomLeft } 或 { top, bottom }
- let r = {
- topLeft: 0,
- topRight: 0,
- bottomRight: 0,
- bottomLeft: 0
- }
- if (typeof radius === 'number') {
- r.topLeft = r.topRight = r.bottomRight = r.bottomLeft = Math.max(0, radius)
- } else if (radius && typeof radius === 'object') {
- if (radius.top !== undefined || radius.bottom !== undefined) {
- r.topLeft = r.topRight = Math.max(0, radius.top || 0)
- r.bottomLeft = r.bottomRight = Math.max(0, radius.bottom || 0)
- } else {
- r.topLeft = Math.max(0, radius.topLeft || 0)
- r.topRight = Math.max(0, radius.topRight || 0)
- r.bottomRight = Math.max(0, radius.bottomRight || 0)
- r.bottomLeft = Math.max(0, radius.bottomLeft || 0)
- }
- }
- // 限制每个半径不超过 width/2 或 height/2
- const maxR = Math.min(width / 2, height / 2)
- r.topLeft = Math.min(r.topLeft, maxR)
- r.topRight = Math.min(r.topRight, maxR)
- r.bottomRight = Math.min(r.bottomRight, maxR)
- r.bottomLeft = Math.min(r.bottomLeft, maxR)
- this.ctx.moveTo(x + r.topLeft, y)
- this.ctx.lineTo(x + width - r.topRight, y)
- this.ctx.quadraticCurveTo(x + width, y, x + width, y + r.topRight)
- this.ctx.lineTo(x + width, y + height - r.bottomRight)
- this.ctx.quadraticCurveTo(x + width, y + height, x + width - r.bottomRight, y + height)
- this.ctx.lineTo(x + r.bottomLeft, y + height)
- this.ctx.quadraticCurveTo(x, y + height, x, y + height - r.bottomLeft)
- this.ctx.lineTo(x, y + r.topLeft)
- this.ctx.quadraticCurveTo(x, y, x + r.topLeft, y)
- this.ctx.closePath()
- },
- // 触摸开始
- handleTouchStart(e) {
- if (!this.mergedZoomConfig.enabled) {
- if (this.mergedTooltipConfig.enabled) {
- this.handleTooltip(e)
- }
- return
- }
- const touches = e.touches
- if (touches.length === 2) {
- this.isPinching = true
- this.isDragging = false
- this.showTooltip = false
- const distance = this.getTouchDistance(touches[0], touches[1])
- this.lastTouchDistance = distance
- const center = this.getTouchCenter(touches[0], touches[1])
- this.lastTouchCenter = center
- } else if (touches.length === 1) {
- this.isPinching = false
- this.lastTouchPos = { x: touches[0].x, y: touches[0].y }
- this.touchStartPos = { x: touches[0].x, y: touches[0].y }
- this.touchStartTime = Date.now()
- this.isDragging = false
- if (this.scale === 1 && this.mergedTooltipConfig.enabled) {
- this.handleTooltip(e)
- }
- }
- },
- // 触摸移动
- handleTouchMove(e) {
- if (!this.mergedZoomConfig.enabled) {
- if (this.mergedTooltipConfig.enabled) {
- this.handleTooltip(e)
- }
- return
- }
- const touches = e.touches
- if (touches.length === 2 && this.isPinching) {
- e.preventDefault && e.preventDefault()
- const distance = this.getTouchDistance(touches[0], touches[1])
- const center = this.getTouchCenter(touches[0], touches[1])
- const scaleChange = distance / this.lastTouchDistance
- let newScale = this.scale * scaleChange
- newScale = Math.max(this.mergedZoomConfig.minScale, Math.min(this.mergedZoomConfig.maxScale, newScale))
- const scaleRatio = newScale / this.scale
- this.offsetX = this.offsetX * scaleRatio
- this.offsetY = 0
- this.scale = newScale
- this.constrainOffset()
- this.lastTouchDistance = distance
- this.lastTouchCenter = center
- this.drawChart()
- } else if (touches.length === 1) {
- const touch = touches[0]
- const deltaX = Math.abs(touch.x - this.touchStartPos.x)
- const deltaY = Math.abs(touch.y - this.touchStartPos.y)
- const dragThreshold = 5
- if (!this.isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) {
- if (this.scale !== 1) {
- this.isDragging = true
- this.showTooltip = false
- }
- }
- if (this.isDragging) {
- e.preventDefault && e.preventDefault()
- const moveX = touch.x - this.lastTouchPos.x
- this.offsetX += moveX
- this.constrainOffset()
- this.lastTouchPos = { x: touch.x, y: touch.y }
- this.drawChart()
- } else {
- if (this.mergedTooltipConfig.enabled) {
- this.handleTooltip(e)
- }
- }
- }
- },
- // 触摸结束
- handleTouchEnd(e) {
- const touches = e.touches
- if (touches.length === 0) {
- const touchEndTime = Date.now()
- const touchDuration = touchEndTime - this.touchStartTime
- const deltaX = Math.abs(this.lastTouchPos.x - this.touchStartPos.x)
- const deltaY = Math.abs(this.lastTouchPos.y - this.touchStartPos.y)
- const isClick = touchDuration < 300 && deltaX < 10 && deltaY < 10
- if (this.isDragging || this.isPinching) {
- this.constrainOffset()
- this.drawChart()
- } else if (isClick && this.mergedTooltipConfig.enabled) {
- const mockEvent = {
- touches: [{
- x: this.touchStartPos.x,
- y: this.touchStartPos.y,
- clientX: this.touchStartPos.x
- }]
- }
- this.handleTooltip(mockEvent)
- }
- this.isPinching = false
- this.isDragging = false
- } else if (touches.length === 1) {
- if (this.isPinching) {
- this.constrainOffset()
- this.drawChart()
- }
- this.isPinching = false
- this.lastTouchPos = { x: touches[0].x, y: touches[0].y }
- }
- },
- // 计算两个触摸点之间的距离
- getTouchDistance(touch1, touch2) {
- const dx = touch2.x - touch1.x
- const dy = touch2.y - touch1.y
- return Math.sqrt(dx * dx + dy * dy)
- },
- // 计算两个触摸点的中心点
- getTouchCenter(touch1, touch2) {
- return {
- x: (touch1.x + touch2.x) / 2,
- y: (touch1.y + touch2.y) / 2
- }
- },
- // 处理Tooltip - 支持多折线
- handleTooltip(e) {
- if (this.points.length === 0 || this.points[0].length === 0) {
- return
- }
- if (!this.canvasRect || !this.wrapperRect) {
- this.updateCanvasRect()
- // 延迟重试
- setTimeout(()=> {
- if (this.canvasRect && this.wrapperRect) {
- this.handleTooltip(e)
- }
- }, 100)
- return
- }
- const touch = e.touches[0]
- const canvasRect = this.canvasRect
- const wrapperRect = this.wrapperRect
- let touchCanvasX, touchCanvasY
- if (this.canvas) {
- // 小程序 canvas 触摸事件直接给出 canvas 坐标
- touchCanvasX = touch.x
- touchCanvasY = touch.y
- } else {
- const touchX = touch.x || touch.clientX
- const touchY = touch.y || touch.clientY
- touchCanvasX = touchX - canvasRect.left
- touchCanvasY = touchY - canvasRect.top
- }
- // 当为柱状图时,使用 barRects 命中检测(优先)
- if (this.chartType === 'bar') {
- let foundIndex = -1
- let foundCenterX = null
- const labelCount = this.chartData.labels.length
- for (let ds = 0; ds < this.barRects.length; ds++) {
- if (this.hiddenDatasets[ds]) continue
- const rects = this.barRects[ds] || []
- for (let r = 0; r < rects.length; r++) {
- const rect = rects[r]
- if (!rect) continue
- // 命中检测:在矩形内
- if (touchCanvasX >= rect.x && touchCanvasX <= rect.x + rect.width && touchCanvasY >= rect.y && touchCanvasY <= rect.y + rect.height) {
- foundIndex = rect.index
- foundCenterX = rect.centerX
- break
- }
- }
- if (foundIndex !== -1) break
- }
- // 如果没有直接命中,则按X距离最近的组来触发(阈值为 mergedTooltipConfig.threshold)
- if (foundIndex === -1) {
- let minDist = Infinity
- let closest = -1
- for (let idx = 0; idx < labelCount; idx++) {
- // 从第一个可见数据集获取对应 rect 作为代表
- let repRect = null
- for (let ds = 0; ds < this.barRects.length; ds++) {
- if (this.hiddenDatasets[ds]) continue
- const r = this.barRects[ds] && this.barRects[ds][idx] || null
- if (r) { repRect = r; break }
- }
- if (!repRect) continue
- const dist = Math.abs(repRect.centerX - touchCanvasX)
- if (dist < minDist) { minDist = dist; closest = idx }
- }
- if (minDist < this.mergedTooltipConfig.threshold) {
- foundIndex = closest
- // find representative centerX
- for (let ds = 0; ds < this.barRects.length; ds++) {
- const r = this.barRects[ds] && this.barRects[ds][foundIndex] || null
- if (r) { foundCenterX = r.centerX; break }
- }
- }
- }
- if (foundIndex !== -1) {
- this.showTooltip = true
- this.currentPointIndex = foundIndex
- this.updateTooltipData(foundIndex)
- // 计算 tooltip 位置:以该组中最高的柱为基准
- const groupRects = []
- this.chartData.datasets.forEach((d, dsIdx)=> {
- if (this.hiddenDatasets[dsIdx]) return
- const r = this.barRects[dsIdx] && this.barRects[dsIdx][foundIndex] || null
- if (r) groupRects.push({ rect: r, dataset: d })
- })
- if (groupRects.length > 0) {
- const topY = Math.min(...groupRects.map(g=> g.rect.y))
- const repCenterX = foundCenterX || groupRects[0].rect.centerX
- let tooltipX = canvasRect.left - wrapperRect.left + repCenterX
- let tooltipY = canvasRect.top - wrapperRect.top + topY - 100
- const tooltipWidth = 120
- const tooltipHeight = 120
- const wrapperWidth = wrapperRect.width
- const wrapperHeight = wrapperRect.height
- if (tooltipX - tooltipWidth < 0) tooltipX = tooltipWidth
- else if (tooltipX + tooltipWidth > wrapperWidth) tooltipX = wrapperWidth - tooltipWidth
- if (tooltipY < 0) tooltipY = topY + 20
- else if (tooltipY + tooltipHeight > wrapperHeight) tooltipY = wrapperHeight - tooltipHeight
- this.tooltipPosition.x = tooltipX
- this.tooltipPosition.y = tooltipY
- }
- this.drawChart()
- return
- }
- }
- // 非柱状图或未命中的回退逻辑:使用折线的点距离判断(原有逻辑)
- let minDistance = Infinity
- let closestIndex = -1
- // 如果没有点数据则直接返回
- if (!this.points || !this.points[0]) return
- this.points[0].forEach((point, i)=> {
- const distance = Math.abs(point.x - touchCanvasX)
- if (distance < minDistance) {
- minDistance = distance
- closestIndex = i
- }
- })
- const threshold = this.mergedTooltipConfig.threshold
- if (closestIndex !== -1 && minDistance < threshold) {
- this.showTooltip = true
- this.currentPointIndex = closestIndex
- this.updateTooltipData(closestIndex)
- const allYs = this.points.map(datasetPoints=> datasetPoints[closestIndex].y)
- const topY = Math.min(...allYs)
- const firstPoint = this.points[0][closestIndex]
- let tooltipX = canvasRect.left - wrapperRect.left + firstPoint.x
- let tooltipY = canvasRect.top - wrapperRect.top + topY - 100
- const tooltipWidth = 120
- const tooltipHeight = 120
- const wrapperWidth = wrapperRect.width
- const wrapperHeight = wrapperRect.height
- if (tooltipX - tooltipWidth < 0) {
- tooltipX = tooltipWidth
- } else if (tooltipX + tooltipWidth > wrapperWidth) {
- tooltipX = wrapperWidth - tooltipWidth
- }
- if (tooltipY < 0) {
- tooltipY = topY + 20
- } else if (tooltipY + tooltipHeight > wrapperHeight) {
- tooltipY = wrapperHeight - tooltipHeight
- }
- this.tooltipPosition.x = tooltipX
- this.tooltipPosition.y = tooltipY
- this.drawChart()
- } else {
- if (this.showTooltip) {
- this.showTooltip = false
- this.currentPointIndex = -1
- this.drawChart()
- }
- }
- },
- // 限制平移范围
- constrainOffset() {
- const { chartWidth } = this
- const padding = this.mergedPadding
- const graphWidth = chartWidth - padding.left - padding.right
- if (this.scale === 1) {
- this.offsetX = 0
- this.offsetY = 0
- return
- }
- const centerX = padding.left + graphWidth / 2
- const maxOffsetX = (padding.left - centerX) * (1 - this.scale)
- const minOffsetX = (padding.left + graphWidth - centerX) * (1 - this.scale)
- this.offsetX = Math.max(minOffsetX, Math.min(maxOffsetX, this.offsetX))
- this.offsetY = 0
- },
- // 绘制柱状图(接收某个数据集的矩形数组)
- drawBars(rects, dataset, progress) {
- if (!rects || rects.length === 0) return
- const labelCount = this.chartData && this.chartData.labels ? this.chartData.labels.length : 0
- const animateMode = this.mergedBarConfig && this.mergedBarConfig.animateMode ? this.mergedBarConfig.animateMode : 'all'
- const staggerFraction = this.mergedBarConfig && this.mergedBarConfig.staggerDelayFraction ? this.mergedBarConfig.staggerDelayFraction : 0.02
- const borderRadius = this.mergedBarConfig && this.mergedBarConfig.borderRadius ? this.mergedBarConfig.borderRadius : 0
- const totalStagger = Math.max(0, Math.min(0.8, (labelCount - 1) * staggerFraction))
- this.ctx.save()
- rects.forEach((rect, i)=> {
- let barProgress = Math.max(0, Math.min(1, progress))
- if (animateMode === 'stagger' && typeof rect.index === 'number') {
- const start = rect.index * staggerFraction
- const denom = 1 - totalStagger
- if (denom <= 0) {
- barProgress = progress
- } else {
- barProgress = (progress - start) / denom
- }
- barProgress = Math.max(0, Math.min(1, barProgress))
- // 使用缓动函数以获得更自然的生长
- barProgress = this.easeOutQuad(barProgress)
- } else {
- barProgress = this.easeOutQuad(barProgress)
- }
- if (barProgress <= 0) return
- // 计算动画高度和绘制 Y(底部锚点)
- const baseY = rect.y + rect.height // 实际底部坐标(已考虑 bottomGap)
- const animatedHeight = rect.height * barProgress
- const drawY = baseY - animatedHeight
- // 渐变填充(从 gradientStart 到 gradientEnd),在动画过程中逐步显示高度
- try {
- const g = this.ctx.createLinearGradient(rect.x, drawY, rect.x, drawY + animatedHeight)
- g.addColorStop(0, dataset.gradientStart || dataset.color)
- g.addColorStop(1, dataset.gradientEnd || dataset.color)
- this.ctx.fillStyle = g
- } catch (err) {
- this.ctx.fillStyle = dataset.color
- }
- this.ctx.globalAlpha = 1
- // 使用圆角矩形进行填充和描边
- this.ctx.beginPath()
- this.drawRoundedRect(rect.x, drawY, rect.width, animatedHeight, borderRadius)
- this.ctx.fill()
- // 描边(轻描)
- this.ctx.strokeStyle = this.hexToRgba(dataset.color, 0.12)
- this.ctx.lineWidth = 1
- this.ctx.beginPath()
- this.drawRoundedRect(rect.x, drawY, rect.width, animatedHeight, borderRadius)
- this.ctx.stroke()
- })
- this.ctx.restore()
- },
- }
- }
- </script>
- <style scoped>
- /* 图表容器 */
- .chart-container {
- /* display: flex;
- flex-direction: column;
- width: 100%; */
- }
- .chart-wrapper {
- position: relative;
- background: #ffffff;
- border-radius: 32rpx;
- /* overflow: hidden; */
- flex: 1;
- /* #ifdef MP-TOUTIAO */
- height: 250px;
- /* #endif */
- }
- .chart-loading {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: #ffffff;
- z-index: 10;
- }
- .loading-text {
- font-size: 28rpx;
- color: #999999;
- }
- canvas {
- display: block;
- }
- .tooltip {
- position: absolute;
- background: rgba(255, 255, 255, 0.95);
- border-radius: 16rpx;
- padding: 24rpx;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
- pointer-events: none;
- z-index: 10;
- min-width: 240rpx;
- transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
- transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
- left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
- top 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- will-change: transform, opacity;
- }
- .tooltip-label {
- font-size: 24rpx;
- color: #666;
- margin-bottom: 16rpx;
- font-weight: 500;
- white-space: nowrap;
- }
- .tooltip-item {
- display: flex;
- align-items: center;
- margin-bottom: 8rpx;
- font-size: 26rpx;
- color: #333;
- white-space: nowrap;
- }
- .tooltip-item:last-child {
- margin-bottom: 0;
- }
- .tooltip-dot {
- width: 16rpx;
- height: 16rpx;
- border-radius: 50%;
- margin-right: 12rpx;
- }
- .zoom-controls {
- position: absolute;
- top: 24rpx;
- right: 24rpx;
- z-index: 5;
- animation: fadeInDown 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
- }
- @keyframes fadeInDown {
- from {
- opacity: 0;
- transform: translateY(-16rpx);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- @keyframes fadeInSlide {
- from {
- opacity: 0;
- transform: translateY(-20rpx);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
- }
- .reset-btn {
- background: #ffffff;
- color: #666666;
- border: 2rpx solid #e0e0e0;
- border-radius: 16rpx;
- padding: 12rpx 28rpx;
- font-size: 24rpx;
- font-weight: 500;
- box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.06);
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8rpx;
- }
- .reset-btn:hover {
- background: #fafafa;
- border-color: #d0d0d0;
- box-shadow: 0 6rpx 12rpx rgba(0, 0, 0, 0.1);
- color: #333333;
- }
- .reset-btn:active {
- transform: translateY(2rpx);
- box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.08);
- }
- /* Legend 图例样式 */
- .legend {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-wrap: wrap;
- gap: 32rpx;
- padding: 10rpx 40rpx;
- background: #ffffff;
- /* animation: fadeInSlide 0.3s ease-out; */
- }
- .legend-top {
- border-radius: 32rpx 32rpx 0 0;
- box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.08);
- }
- .legend-bottom {
- border-radius: 0 0 32rpx 32rpx;
- }
- .legend-item {
- display: flex;
- align-items: center;
- gap: 16rpx;
- padding: 12rpx 24rpx;
- border-radius: 12rpx;
- cursor: pointer;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
- user-select: none;
- background: rgba(0, 0, 0, 0.02);
- }
- .legend-item:hover {
- background: rgba(0, 0, 0, 0.04);
- }
- .legend-item:active {
- transform: scale(0.96);
- background: rgba(0, 0, 0, 0.06);
- }
- .legend-item-disabled {
- opacity: 0.35;
- }
- .legend-item-disabled .legend-label {
- text-decoration: line-through;
- }
- .legend-color {
- width: 20rpx;
- height: 20rpx;
- border-radius: 4rpx;
- flex-shrink: 0;
- box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
- }
- .legend-label {
- font-size: 26rpx;
- font-weight: 500;
- color: #333333;
- white-space: nowrap;
- }
- </style>
|