line-chart.vue 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225
  1. <template>
  2. <view class="chart-container">
  3. <!-- Legend 图例 - 顶部 -->
  4. <view v-if="mergedLegendConfig.enabled && mergedLegendConfig.position === 'top'" class="legend legend-top">
  5. <view
  6. :key="index"
  7. v-for="(dataset, index) in chartData.datasets"
  8. :class="['legend-item', { 'legend-item-disabled': hiddenDatasets[index] }]"
  9. @tap="toggleDataset(index)"
  10. >
  11. <view class="legend-color" :style="{ backgroundColor: dataset.color }" />
  12. <text class="legend-label">{{ dataset.label }}</text>
  13. </view>
  14. </view>
  15. <view class="chart-wrapper" :style="{ width: width, height: height }">
  16. <!-- 加载占位符 -->
  17. <!-- <view v-if="!isInitialized" class="chart-loading">
  18. <text class="loading-text">加载中...</text>
  19. </view> -->
  20. <!-- #ifdef MP-ALIPAY -->
  21. <canvas
  22. :id="canvasId"
  23. v-show="isInitialized"
  24. :canvas-id="canvasId"
  25. class="chart"
  26. :height="canvasHeight"
  27. :style="{ width: canvasStyleWidth + 'px', height: canvasStyleHeight + 'px' }"
  28. :width="canvasWidth"
  29. @touchend="handleTouchEnd"
  30. @touchmove="handleTouchMove"
  31. @touchstart="handleTouchStart"
  32. />
  33. <!-- #endif -->
  34. <!-- #ifndef MP-ALIPAY -->
  35. <canvas
  36. :id="canvasId"
  37. v-show="isInitialized"
  38. :canvas-id="canvasId"
  39. class="chart"
  40. :style="{ width: width, height: height }"
  41. type="2d"
  42. @touchend="handleTouchEnd"
  43. @touchmove="handleTouchMove"
  44. @touchstart="handleTouchStart"
  45. />
  46. <!-- #endif -->
  47. <!-- Tooltip浮层 -->
  48. <view class="tooltip" :style="{...tooltipStyle}">
  49. <view class="tooltip-label">{{ tooltipData.label }}</view>
  50. <view
  51. :key="index"
  52. v-for="(dataset, index) in chartData.datasets"
  53. class="tooltip-item"
  54. >
  55. <view class="tooltip-dot" :style="{ backgroundColor: dataset.color }" />
  56. <text style="white-space: nowrap;">{{ dataset.label }}: {{ formatTooltipValue(tooltipData.values[index]) }}</text>
  57. </view>
  58. </view>
  59. <!-- 缩放控制按钮 -->
  60. <view v-if="showZoomControls && scale !== 1" class="zoom-controls">
  61. <button class="reset-btn" @tap="resetZoom">重置缩放</button>
  62. </view>
  63. </view>
  64. <!-- Legend 图例 - 底部 -->
  65. <view v-if="mergedLegendConfig.enabled && mergedLegendConfig.position === 'bottom'" class="legend legend-bottom">
  66. <view
  67. :key="index"
  68. v-for="(dataset, index) in chartData.datasets"
  69. :class="['legend-item', { 'legend-item-disabled': hiddenDatasets[index] }]"
  70. @tap="toggleDataset(index)"
  71. >
  72. <view class="legend-color" :style="{ backgroundColor: dataset.color }" />
  73. <text class="legend-label">{{ dataset.label }}</text>
  74. </view>
  75. </view>
  76. </view>
  77. </template>
  78. <script>
  79. // https://ext.dcloud.net.cn/plugin?id=25773
  80. export default {
  81. name: 'LineChart',
  82. props: {
  83. canvasId: {
  84. type: String,
  85. default: 'lineChart_' + new Date().getTime()
  86. },
  87. // 图表类型:'line' | 'bar'
  88. chartType: {
  89. type: String,
  90. default: 'line'
  91. },
  92. // 图表尺寸
  93. width: {
  94. type: String,
  95. default: '100%'
  96. },
  97. height: {
  98. type: String,
  99. default: '250px'
  100. },
  101. // 图表数据
  102. chartData: {
  103. type: Object,
  104. required: true,
  105. // 数据格式示例:
  106. // {
  107. // labels: ['1月', '2月', '3月', ...],
  108. // datasets: [
  109. // {
  110. // label: '产品A',
  111. // data: [30, 45, 60, ...],
  112. // color: '#FF6B9D',
  113. // gradientStart: '#FFB6C1',
  114. // gradientEnd: '#FF1493'
  115. // },
  116. // {
  117. // label: '产品B',
  118. // data: [50, 65, 80, ...],
  119. // color: '#4ECDC4',
  120. // gradientStart: '#A8E6CF',
  121. // gradientEnd: '#00CED1'
  122. // }
  123. // ]
  124. // }
  125. },
  126. // 内边距配置
  127. padding: {
  128. type: Object,
  129. default: ()=> ({
  130. top: 20,
  131. right: 20,
  132. bottom: 30,
  133. left: 50
  134. })
  135. },
  136. // 缩放配置
  137. zoomConfig: {
  138. type: Object,
  139. default: ()=> ({
  140. enabled: true, // 是否启用缩放
  141. minScale: 1, // 最小缩放比例
  142. maxScale: 10, // 最大缩放比例
  143. showControls: true // 是否显示重置按钮
  144. })
  145. },
  146. // 动画配置
  147. animationConfig: {
  148. type: Object,
  149. default: ()=> ({
  150. enabled: true, // 是否启用动画
  151. duration: 30 // 动画帧数(默认30帧,约500ms)
  152. })
  153. },
  154. // Y轴配置
  155. yAxisConfig: {
  156. type: Object,
  157. default: ()=> ({
  158. steps: 5, // Y轴刻度数量
  159. autoRange: true, // 自动计算范围
  160. min: null, // 手动设置最小值
  161. max: null, // 手动设置最大值
  162. allowNegative: true // 是否允许Y轴出现负数(默认允许)
  163. })
  164. },
  165. // X轴配置
  166. xAxisConfig: {
  167. type: Object,
  168. default: ()=> ({
  169. labelDensity: 'auto', // 标签密度:'auto' | 'sparse' | 'dense'
  170. showAllLabels: false, // 是否显示所有标签
  171. formatter: null, // 自定义格式化函数:(label, index) => string
  172. lineHeight: 14 // 多行文本的行高(像素)
  173. })
  174. },
  175. // Tooltip配置
  176. tooltipConfig: {
  177. type: Object,
  178. default: ()=> ({
  179. enabled: true, // 是否启用tooltip
  180. threshold: 60 // 触发距离阈值(px)
  181. })
  182. },
  183. // Legend配置
  184. legendConfig: {
  185. type: Object,
  186. default: ()=> ({
  187. enabled: true, // 是否显示图例
  188. position: 'bottom', // 位置:'top' | 'bottom'
  189. clickable: true // 是否可点击切换显示/隐藏
  190. })
  191. },
  192. // 柱状图配置
  193. barConfig: {
  194. type: Object,
  195. default: ()=> ({
  196. width: 8, // 固定柱宽(像素)
  197. innerGap: 2, // 柱组内间隙(像素)
  198. bottomGap: 2, // 底部与 X 轴的间隙(像素)
  199. leftGap: 2, // 与 Y 轴的间隙保护(像素)
  200. // 动画模式:'all' - 所有柱同一进度生长; 'stagger' - 按列逐步渲染
  201. animateMode: 'stagger',
  202. // 当 animateMode === 'stagger' 时,列间的延迟(进度的分数,0.0 - 1.0)
  203. staggerDelayFraction: 0.02,
  204. // 圆角:支持 number / { top, bottom } / { topLeft, topRight, bottomRight, bottomLeft }
  205. // 默认仅顶部圆角
  206. borderRadius: { top: 4, bottom: 0 }
  207. })
  208. }
  209. },
  210. data() {
  211. return {
  212. ctx: null,
  213. canvas: null,
  214. dpr: 1,
  215. chartWidth: 0,
  216. chartHeight: 0,
  217. // 动画相关
  218. animationProgress: 0,
  219. animationFrameCount: 0,
  220. // 数据点 - 改为数组以支持多折线
  221. points: [], // 每个元素是一个数据集的点数组
  222. // Tooltip
  223. showTooltip: false,
  224. currentPointIndex: -1,
  225. tooltipData: {
  226. label: '',
  227. values: [] // 改为数组以支持多折线
  228. },
  229. tooltipPosition: {
  230. x: 0,
  231. y: 0
  232. },
  233. // Canvas位置缓存
  234. canvasRect: null,
  235. wrapperRect: null,
  236. // 数据过渡 - 改为数组以支持多折线
  237. oldData: [], // 每个元素是一个数据集的旧数据数组
  238. transitionProgress: 0,
  239. isTransitioning: false,
  240. // 缩放重置动画
  241. isResettingZoom: false,
  242. resetProgress: 0,
  243. resetStartScale: 1,
  244. resetStartOffsetX: 0,
  245. resetStartOffsetY: 0,
  246. // Legend 图例
  247. hiddenDatasets: {}, // 隐藏的数据集索引 { 0: true, 1: false, ... }
  248. yAxisMin: 0,
  249. yAxisMax: 100,
  250. // 缩放和平移
  251. scale: 1,
  252. offsetX: 0,
  253. offsetY: 0,
  254. // 持久的 bar 水平偏移,用来保证首柱与 Y 轴的最小间隙(在 scale===1 时生效)
  255. barShift: 0,
  256. // 柱状图布局缓存:每个数据集对应一组矩形 { x, y, width, height, value }
  257. barRects: [],
  258. lastTouchDistance: 0,
  259. lastTouchCenter: { x: 0, y: 0 },
  260. isPinching: false,
  261. isDragging: false,
  262. lastTouchPos: { x: 0, y: 0 },
  263. touchStartPos: { x: 0, y: 0 },
  264. touchStartTime: 0,
  265. // #ifdef MP-ALIPAY
  266. canvasWidth: 750, // 初始值,避免为0导致Canvas不显示
  267. canvasHeight: 400,
  268. canvasStyleWidth: 375,
  269. canvasStyleHeight: 250,
  270. // #endif
  271. // 初始化状态
  272. isInitialized: false
  273. }
  274. },
  275. computed: {
  276. // 合并后的配置(保留默认值)
  277. mergedPadding() {
  278. return {
  279. top: 20,
  280. right: 20,
  281. bottom: 30,
  282. left: 50,
  283. ...this.padding
  284. }
  285. },
  286. mergedZoomConfig() {
  287. return {
  288. enabled: true,
  289. minScale: 1,
  290. maxScale: 10,
  291. showControls: true,
  292. ...this.zoomConfig
  293. }
  294. },
  295. mergedAnimationConfig() {
  296. return {
  297. enabled: true,
  298. duration: 30,
  299. ...this.animationConfig
  300. }
  301. },
  302. mergedYAxisConfig() {
  303. return {
  304. steps: 5,
  305. autoRange: true,
  306. min: null,
  307. max: null,
  308. allowNegative: true,
  309. ...this.yAxisConfig
  310. }
  311. },
  312. mergedXAxisConfig() {
  313. return {
  314. labelDensity: 'auto',
  315. showAllLabels: false,
  316. formatter: null,
  317. lineHeight: 14,
  318. ...this.xAxisConfig
  319. }
  320. },
  321. mergedTooltipConfig() {
  322. return {
  323. enabled: true,
  324. threshold: 60,
  325. ...this.tooltipConfig
  326. }
  327. },
  328. mergedLegendConfig() {
  329. return {
  330. enabled: true,
  331. position: 'bottom',
  332. clickable: true,
  333. ...this.legendConfig
  334. }
  335. },
  336. mergedBarConfig() {
  337. return {
  338. width: 8, // 固定柱宽(像素)
  339. innerGap: 2, // 柱组内间隙(像素)
  340. bottomGap: 2, // 底部与 X 轴的间隙(像素)
  341. leftGap: 2, // 与 Y 轴的间隙保护(像素)
  342. // 动画模式:'all' - 所有柱同一进度生长; 'stagger' - 按列逐步渲染
  343. animateMode: 'stagger',
  344. // 当 animateMode === 'stagger' 时,列间的延迟(进度的分数,0.0 - 1.0)
  345. staggerDelayFraction: 0.02,
  346. // 圆角:默认仅顶部圆角,支持 number / { top, bottom } / per-corner 对象
  347. borderRadius: { top: 4, bottom: 0 },
  348. ...this.barConfig
  349. }
  350. },
  351. showZoomControls() {
  352. return this.mergedZoomConfig.enabled && this.mergedZoomConfig.showControls
  353. },
  354. tooltipStyle() {
  355. if (!this.showTooltip) {
  356. return {
  357. opacity: 0,
  358. transform: 'translateX(-50%) scale(0.8)',
  359. pointerEvents: 'none',
  360. transition: 'none'
  361. }
  362. }
  363. let top = this.tooltipPosition.y
  364. // #ifdef MP-TOUTIAO
  365. if(top > parseInt(this.height) - 20) {
  366. top = parseInt(this.height) / 2 - 10
  367. }
  368. // #endif
  369. return {
  370. left: this.tooltipPosition.x + 'px',
  371. top: top + 'px',
  372. opacity: 1,
  373. transform: 'translateX(-50%) scale(1)'
  374. }
  375. }
  376. },
  377. watch: {
  378. chartData: {
  379. handler(newVal, oldChartData) {
  380. if (!oldChartData || !this.ctx) {
  381. return
  382. }
  383. // 保存旧数据用于过渡动画 - 支持多折线
  384. this.oldData = oldChartData.datasets.map(dataset=> [...dataset.data])
  385. // 开始过渡动画
  386. this.startDataTransition()
  387. },
  388. deep: true
  389. }
  390. },
  391. mounted() {
  392. this.initChart()
  393. // // #ifdef H5
  394. // this.initChart()
  395. // // #endif
  396. },
  397. onReady() {
  398. this.initChart()
  399. // // #ifndef H5
  400. // this.initChart()
  401. // // #endif
  402. },
  403. beforeDestroy() {
  404. // 清理资源
  405. this.isInitialized = false
  406. this.ctx = null
  407. this.canvas = null
  408. },
  409. methods: {
  410. async initChart() {
  411. try {
  412. // 重置初始化状态
  413. this.isInitialized = false
  414. // #ifdef H5
  415. this.dpr = 1
  416. // #endif
  417. // #ifndef H5
  418. const systemInfo = uni.getSystemInfoSync()
  419. this.dpr = systemInfo.pixelRatio || 1
  420. // #endif
  421. // #ifdef MP-ALIPAY
  422. await this.initAlipayCanvas()
  423. // #endif
  424. // #ifndef MP-ALIPAY
  425. await this.initWechatCanvas()
  426. // #endif
  427. // 初始化旧数据 - 支持多折线
  428. this.oldData = this.chartData.datasets.map(dataset=> [...dataset.data])
  429. // 标记为已初始化
  430. this.isInitialized = true
  431. } catch (error) {
  432. console.error('Canvas初始化失败:', error)
  433. }
  434. if (this.mergedAnimationConfig.enabled) {
  435. this.startAnimation()
  436. } else {
  437. this.animationProgress = 1
  438. this.drawChart()
  439. }
  440. },
  441. // 微信/抖音小程序Canvas初始化(type="2d")
  442. // #ifndef MP-ALIPAY
  443. initWechatCanvas() {
  444. return new Promise((resolve)=> {
  445. // 延迟执行,确保DOM已渲染
  446. // setTimeout(()=> {
  447. // 先获取容器尺寸
  448. const query1 = uni.createSelectorQuery().in(this)
  449. query1.select('.chart-wrapper').boundingClientRect((rect)=> {
  450. if (!rect) {
  451. console.error('[LineChart] 无法获取容器尺寸')
  452. this.initFallbackCanvas()
  453. resolve()
  454. return
  455. }
  456. // 计算容器宽高
  457. const containerWidth = rect.width || rect.right - rect.left || 0
  458. const containerHeight = rect.height || rect.bottom - rect.top || 250
  459. if (containerWidth === 0 || containerHeight === 0) {
  460. console.error('[LineChart] 容器尺寸为0')
  461. this.initFallbackCanvas()
  462. resolve()
  463. return
  464. }
  465. // 再获取Canvas节点
  466. const query2 = uni.createSelectorQuery().in(this)
  467. query2
  468. .select(`#${this.canvasId}`)
  469. .fields({ node: true, size: true })
  470. .exec((res)=> {
  471. if (res[0] && res[0].node) {
  472. const canvas = res[0].node
  473. // 使用容器尺寸,而不是Canvas返回的尺寸
  474. const width = containerWidth
  475. const height = containerHeight
  476. this.canvas = canvas
  477. this.ctx = canvas.getContext('2d')
  478. this.chartWidth = width
  479. this.chartHeight = height
  480. // #ifndef H5
  481. canvas.width = width * this.dpr
  482. canvas.height = height * this.dpr
  483. // #endif
  484. this.ctx.scale(this.dpr, this.dpr)
  485. // 立即清空 Canvas,避免显示旧内容
  486. this.ctx.clearRect(0, 0, width, height)
  487. this.ctx.fillStyle = '#ffffff'
  488. this.ctx.fillRect(0, 0, width, height)
  489. this.updateCanvasRect()
  490. resolve()
  491. } else {
  492. console.error('[LineChart] 无法获取Canvas节点')
  493. this.initFallbackCanvas()
  494. resolve()
  495. }
  496. })
  497. }).exec()
  498. // }, 300) // 延迟300ms,确保DOM渲染完成
  499. })
  500. },
  501. // #endif
  502. initFallbackCanvas() {
  503. const query = uni.createSelectorQuery().in(this)
  504. query
  505. .select(`#${this.canvasId}`)
  506. .boundingClientRect((data)=> {
  507. this.chartWidth = data.width
  508. this.chartHeight = data.height
  509. this.ctx = uni.createCanvasContext(this.canvasId, this)
  510. this.updateCanvasRect()
  511. })
  512. .exec()
  513. },
  514. // 支付宝小程序Canvas初始化
  515. // #ifdef MP-ALIPAY
  516. initAlipayCanvas() {
  517. return new Promise((resolve)=> {
  518. // 计算Canvas显示尺寸(逻辑像素)
  519. const query = uni.createSelectorQuery().in(this)
  520. query.select('.chart-wrapper').boundingClientRect((rect)=> {
  521. if (!rect) {
  522. console.error('无法获取chart-wrapper尺寸')
  523. resolve()
  524. return
  525. }
  526. const displayWidth = rect.width
  527. const displayHeight = rect.height
  528. // 设置Canvas样式尺寸(显示尺寸)
  529. this.canvasStyleWidth = displayWidth
  530. this.canvasStyleHeight = displayHeight
  531. // 设置Canvas物理像素尺寸(实际绘制尺寸,用于高清显示)
  532. this.canvasWidth = Math.floor(displayWidth * this.dpr)
  533. this.canvasHeight = Math.floor(displayHeight * this.dpr)
  534. // 设置绘制时使用的逻辑尺寸
  535. this.chartWidth = displayWidth
  536. this.chartHeight = displayHeight
  537. // 延迟执行,确保DOM已渲染
  538. setTimeout(()=> {
  539. this.ctx = uni.createCanvasContext(this.canvasId, this)
  540. if (!this.ctx) {
  541. console.error('Canvas上下文创建失败')
  542. resolve()
  543. return
  544. }
  545. // 缓存Canvas位置
  546. this.updateCanvasRect()
  547. resolve()
  548. }, 300)
  549. }).exec()
  550. })
  551. },
  552. // #endif
  553. updateCanvasRect() {
  554. const query = uni.createSelectorQuery().in(this)
  555. query.select(`#${this.canvasId}`).boundingClientRect((data)=> {
  556. this.canvasRect = data
  557. }).exec()
  558. query.select('.chart-wrapper').boundingClientRect((data)=> {
  559. this.wrapperRect = data
  560. }).exec()
  561. },
  562. startAnimation() {
  563. this.animationProgress = 0
  564. this.animationFrameCount = 0
  565. this.animate()
  566. },
  567. animate() {
  568. if (this.animationFrameCount < this.mergedAnimationConfig.duration) {
  569. this.animationFrameCount++
  570. this.animationProgress = this.animationFrameCount / this.mergedAnimationConfig.duration
  571. this.drawChart()
  572. setTimeout(()=> this.animate(), 16)
  573. } else {
  574. this.animationProgress = 1
  575. this.drawChart()
  576. }
  577. },
  578. startDataTransition() {
  579. this.isTransitioning = true
  580. this.transitionProgress = 0
  581. this.animateDataTransition()
  582. },
  583. animateDataTransition() {
  584. if (this.transitionProgress < 1) {
  585. this.transitionProgress += 0.1 // 加快过渡速度(从0.05改为0.1,约160ms完成)
  586. // 如果 tooltip 正在显示,更新 tooltip 数据
  587. if (this.showTooltip && this.currentPointIndex !== -1) {
  588. this.updateTooltipData(this.currentPointIndex)
  589. }
  590. this.drawChart()
  591. setTimeout(()=> this.animateDataTransition(), 16)
  592. } else {
  593. this.transitionProgress = 1
  594. this.isTransitioning = false
  595. this.oldData = this.chartData.datasets.map(dataset=> [...dataset.data])
  596. // 最后一次更新 tooltip 数据
  597. if (this.showTooltip && this.currentPointIndex !== -1) {
  598. this.updateTooltipData(this.currentPointIndex)
  599. }
  600. this.drawChart()
  601. }
  602. },
  603. easeOutQuad(t) {
  604. return t * (2 - t)
  605. },
  606. // 更新 Tooltip 数据
  607. updateTooltipData(closestIndex) {
  608. // 收集所有数据集的值 - 如果正在过渡则使用过渡数据
  609. const values = this.chartData.datasets.map((dataset, index)=> {
  610. if (this.isTransitioning) {
  611. const transitionData = this.getTransitionData(index)
  612. return transitionData[closestIndex]
  613. } else {
  614. return dataset.data[closestIndex]
  615. }
  616. })
  617. this.tooltipData = {
  618. label: this.chartData.labels[closestIndex],
  619. values: values
  620. }
  621. },
  622. // 格式化 Tooltip 显示的值
  623. formatTooltipValue(value) {
  624. if (value === undefined || value === null) {
  625. return '-'
  626. }
  627. // 四舍五入到整数
  628. // return Math.round(value)
  629. return value
  630. },
  631. // 重置缩放 - 带过渡动画
  632. resetZoom() {
  633. if (this.isResettingZoom) return
  634. // 保存当前状态
  635. this.resetStartScale = this.scale
  636. this.resetStartOffsetX = this.offsetX
  637. this.resetStartOffsetY = this.offsetY
  638. // 开始重置动画
  639. this.isResettingZoom = true
  640. this.resetProgress = 0
  641. this.animateResetZoom()
  642. },
  643. // 重置缩放动画
  644. animateResetZoom() {
  645. if (!this.isResettingZoom) return
  646. const duration = 200 // 动画时长(毫秒,加快速度)
  647. const startTime = Date.now()
  648. const animate = ()=> {
  649. const elapsed = Date.now() - startTime
  650. const progress = Math.min(elapsed / duration, 1)
  651. // 使用缓动函数
  652. const eased = this.easeOutCubic(progress)
  653. // 插值计算当前值
  654. this.scale = this.resetStartScale + (1 - this.resetStartScale) * eased
  655. this.offsetX = this.resetStartOffsetX + (0 - this.resetStartOffsetX) * eased
  656. this.offsetY = this.resetStartOffsetY + (0 - this.resetStartOffsetY) * eased
  657. // 重绘图表
  658. this.drawChart()
  659. if (progress < 1) {
  660. // 继续动画
  661. setTimeout(()=> animate(), 16) // 约60fps
  662. } else {
  663. // 动画结束
  664. this.scale = 1
  665. this.offsetX = 0
  666. this.offsetY = 0
  667. this.isResettingZoom = false
  668. this.drawChart()
  669. }
  670. }
  671. animate()
  672. },
  673. // 缓动函数 - 三次方缓出
  674. easeOutCubic(t) {
  675. return 1 - Math.pow(1 - t, 3)
  676. },
  677. // 切换数据集显示/隐藏
  678. toggleDataset(index) {
  679. if (!this.mergedLegendConfig.clickable) return
  680. // 切换隐藏状态
  681. this.$set(this.hiddenDatasets, index, !this.hiddenDatasets[index])
  682. // 重绘图表
  683. this.drawChart()
  684. },
  685. // 绘制图表
  686. drawChart() {
  687. if (!this.ctx) {
  688. return
  689. }
  690. const { chartWidth, chartHeight, chartData, animationProgress } = this
  691. const padding = this.mergedPadding
  692. // 支付宝小程序需要在每次绘制前设置缩放
  693. // #ifdef MP-ALIPAY
  694. // 先保存状态
  695. this.ctx.save()
  696. // 设置缩放以适配高分辨率
  697. this.ctx.scale(this.dpr, this.dpr)
  698. // #endif
  699. // 清空画布并绘制白色背景
  700. // #ifdef MP-ALIPAY
  701. this.ctx.fillStyle = '#ffffff'
  702. this.ctx.fillRect(0, 0, chartWidth, chartHeight)
  703. // #endif
  704. // #ifndef MP-ALIPAY
  705. this.ctx.clearRect(0, 0, chartWidth, chartHeight)
  706. this.ctx.fillStyle = '#ffffff'
  707. this.ctx.fillRect(0, 0, chartWidth, chartHeight)
  708. // #endif
  709. // 计算坐标轴区域
  710. const graphWidth = chartWidth - padding.left - padding.right
  711. const graphHeight = chartHeight - padding.top - padding.bottom
  712. // 绘制坐标轴和网格
  713. this.drawAxis(graphWidth, graphHeight, padding)
  714. // 计算数据点位置
  715. this.calculatePoints(graphWidth, graphHeight, padding)
  716. // 保存上下文状态,为绘制数据内容设置裁剪
  717. this.ctx.save()
  718. // 设置裁剪区域
  719. const clipPaddingRight = 10
  720. const clipPaddingLeft = 6
  721. const clipPaddingVertical = 10
  722. this.ctx.beginPath()
  723. this.ctx.rect(
  724. padding.left - clipPaddingLeft,
  725. padding.top - clipPaddingVertical,
  726. graphWidth + clipPaddingLeft + clipPaddingRight,
  727. graphHeight + clipPaddingVertical * 2
  728. )
  729. this.ctx.clip()
  730. // 绘制折线 - 支持多折线
  731. chartData.datasets.forEach((dataset, index)=> {
  732. // 跳过隐藏的数据集
  733. if (this.hiddenDatasets[index]) return
  734. if (this.chartType === 'bar') {
  735. // 柱状图在后面统一绘制
  736. // prepare barRects in calculateBarRects
  737. } else {
  738. if (this.points[index]) {
  739. this.drawLine(this.points[index], dataset, animationProgress)
  740. }
  741. }
  742. })
  743. // 绘制数据点 - 支持多折线
  744. chartData.datasets.forEach((dataset, index)=> {
  745. // 跳过隐藏的数据集
  746. if (this.hiddenDatasets[index]) return
  747. if (this.chartType === 'bar') {
  748. // 柱状图绘制由 drawBars 处理
  749. // ensure barRects 已计算
  750. if (!this.barRects || this.barRects.length === 0) {
  751. this.calculateBarRects(graphWidth, graphHeight, padding)
  752. }
  753. const rects = this.barRects[index] || []
  754. this.drawBars(rects, dataset, animationProgress)
  755. } else {
  756. if (this.points[index]) {
  757. this.drawPoints(this.points[index], dataset, animationProgress)
  758. }
  759. }
  760. })
  761. // 恢复上下文状态
  762. this.ctx.restore()
  763. // 如果正在显示Tooltip,绘制指示线
  764. if (this.showTooltip && this.currentPointIndex !== -1) {
  765. // 不使用额外的裁剪区域绘制 tooltip 指示,避免阴影被裁切。
  766. // drawTooltipLine 内部会限制绘制范围为图表区域。
  767. this.drawTooltipLine(this.currentPointIndex)
  768. }
  769. // 支付宝小程序需要调用draw方法
  770. // #ifdef MP-ALIPAY
  771. // 恢复之前保存的状态
  772. this.ctx.restore()
  773. if (this.ctx && this.ctx.draw) {
  774. this.ctx.draw()
  775. }
  776. // #endif
  777. },
  778. // 绘制 X 轴标签(支持换行符)
  779. drawXAxisLabel(text, x, y) {
  780. // 将文本按换行符分割
  781. const lines = String(text).split('\n')
  782. const lineHeight = this.mergedXAxisConfig.lineHeight || 14
  783. // 如果只有一行,直接绘制
  784. if (lines.length === 1) {
  785. this.ctx.fillText(text, x, y)
  786. return
  787. }
  788. // 多行文本,计算起始 Y 坐标(居中对齐)
  789. const totalHeight = lines.length * lineHeight
  790. const startY = y - (totalHeight - lineHeight) / 2
  791. // 绘制每一行
  792. lines.forEach((line, index)=> {
  793. this.ctx.fillText(line, x, startY + index * lineHeight)
  794. })
  795. },
  796. // 绘制坐标轴 - 支持多折线
  797. drawAxis(graphWidth, graphHeight, padding) {
  798. // 收集所有数据集的数据(跳过隐藏的数据集)
  799. const allData = []
  800. this.chartData.datasets.forEach((dataset, index)=> {
  801. // 跳过隐藏的数据集
  802. if (this.hiddenDatasets[index]) return
  803. const data = this.isTransitioning ? this.getTransitionData(index) : dataset.data
  804. allData.push(...data)
  805. })
  806. // 计算 Y 轴范围
  807. let minValue, maxValue
  808. // 检查是否手动设置了范围
  809. if (!this.mergedYAxisConfig.autoRange &&
  810. this.mergedYAxisConfig.min !== null &&
  811. this.mergedYAxisConfig.max !== null) {
  812. // 使用手动设置的范围
  813. minValue = this.mergedYAxisConfig.min
  814. maxValue = this.mergedYAxisConfig.max
  815. } else {
  816. // 自动计算范围
  817. if (allData.length === 0) {
  818. // 如果所有数据集都被隐藏,使用默认值
  819. minValue = 0
  820. maxValue = 100
  821. } else {
  822. const dataMin = Math.min(...allData)
  823. const dataMax = Math.max(...allData)
  824. let dataRange = dataMax - dataMin
  825. // 如果所有数据都相同(dataRange = 0),设置一个默认范围
  826. if (dataRange === 0) {
  827. // 如果数据值为0,使用默认范围 0~100
  828. if (dataMin === 0) {
  829. minValue = 0
  830. maxValue = 100
  831. } else {
  832. // 如果数据值不为0,使用数据值的 ±20%
  833. const defaultRange = Math.abs(dataMin) * 0.4
  834. minValue = Math.floor(dataMin - defaultRange)
  835. maxValue = Math.ceil(dataMax + defaultRange)
  836. }
  837. } else {
  838. // 正常情况:添加20%的padding
  839. const padding_percent = 0.2
  840. minValue = Math.floor(dataMin - dataRange * padding_percent)
  841. maxValue = Math.ceil(dataMax + dataRange * padding_percent)
  842. }
  843. // 如果配置不允许负数,且计算出的最小值为负数,则将最小值设为0
  844. if (!this.mergedYAxisConfig.allowNegative && minValue < 0) {
  845. minValue = 0
  846. }
  847. }
  848. // 如果只设置了 min,使用手动 min 和自动计算的 max
  849. if (this.mergedYAxisConfig.min !== null) {
  850. minValue = this.mergedYAxisConfig.min
  851. }
  852. // 如果只设置了 max,使用自动计算的 min 和手动 max
  853. if (this.mergedYAxisConfig.max !== null) {
  854. maxValue = this.mergedYAxisConfig.max
  855. }
  856. }
  857. this.yAxisMin = minValue
  858. this.yAxisMax = maxValue
  859. const centerX = padding.left + graphWidth / 2
  860. // 绘制Y轴网格线
  861. const ySteps = this.mergedYAxisConfig.steps
  862. const yRange = maxValue - minValue
  863. const yStepValue = yRange / ySteps
  864. const yStepHeight = graphHeight / ySteps
  865. for (let i = 0; i <= ySteps; i++) {
  866. const y = padding.top + i * yStepHeight
  867. if (i === ySteps) {
  868. this.ctx.strokeStyle = '#d0d7de'
  869. this.ctx.lineWidth = 1
  870. } else {
  871. this.ctx.strokeStyle = '#eaeef2'
  872. this.ctx.lineWidth = 0.5
  873. }
  874. this.ctx.beginPath()
  875. this.ctx.moveTo(padding.left, y)
  876. this.ctx.lineTo(padding.left + graphWidth, y)
  877. this.ctx.stroke()
  878. this.ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif'
  879. this.ctx.fillStyle = '#57606a'
  880. this.ctx.textAlign = 'right'
  881. this.ctx.textBaseline = 'middle'
  882. const labelValue = Math.round(maxValue - i * yStepValue)
  883. this.ctx.fillText(labelValue + '', padding.left - 10, y)
  884. }
  885. // 绘制X轴标签
  886. this.ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif'
  887. this.ctx.textAlign = 'center'
  888. this.ctx.textBaseline = 'top'
  889. this.ctx.fillStyle = '#57606a'
  890. const labelCount = this.chartData.labels.length
  891. let step = 1
  892. // 如果配置了 showAllLabels,显示所有标签
  893. if (this.mergedXAxisConfig.showAllLabels) {
  894. step = 1
  895. } else {
  896. // 根据 labelDensity 配置计算步长
  897. const effectiveScale = this.scale
  898. const scaledSpacing = graphWidth / (labelCount - 1) * effectiveScale
  899. // 根据缩放后的间距和配置的密度计算步长
  900. if (this.mergedXAxisConfig.labelDensity === 'sparse') {
  901. // 稀疏模式
  902. if (scaledSpacing > 80) {
  903. step = 1
  904. } else if (scaledSpacing > 50) {
  905. step = 3
  906. } else if (scaledSpacing > 30) {
  907. step = 5
  908. } else if (labelCount > 20) {
  909. step = Math.ceil(labelCount / 5)
  910. } else if (labelCount > 12) {
  911. step = Math.ceil(labelCount / 7)
  912. } else {
  913. step = 3
  914. }
  915. } else if (this.mergedXAxisConfig.labelDensity === 'dense') {
  916. // 密集模式
  917. if (scaledSpacing > 80) {
  918. step = 1
  919. } else if (scaledSpacing > 40) {
  920. step = 1
  921. } else if (scaledSpacing > 25) {
  922. step = 2
  923. } else if (labelCount > 20) {
  924. step = Math.ceil(labelCount / 12)
  925. } else if (labelCount > 12) {
  926. step = Math.ceil(labelCount / 15)
  927. } else {
  928. step = 1
  929. }
  930. } else {
  931. // auto 模式(默认)
  932. if (scaledSpacing > 80) {
  933. step = 1
  934. } else if (scaledSpacing > 50) {
  935. step = 2
  936. } else if (scaledSpacing > 30) {
  937. step = 3
  938. } else if (labelCount > 20) {
  939. step = Math.ceil(labelCount / 7)
  940. } else if (labelCount > 12) {
  941. step = Math.ceil(labelCount / 10)
  942. } else if (labelCount > 7) {
  943. step = 2
  944. }
  945. }
  946. }
  947. this.chartData.labels.forEach((label, i)=> {
  948. const baseX = padding.left + i * graphWidth / (this.chartData.labels.length - 1)
  949. const x = centerX + (baseX - centerX) * this.scale + this.offsetX
  950. if ((i % step === 0 || i === labelCount - 1) && x >= padding.left - 20 && x <= padding.left + graphWidth + 20) {
  951. // 应用自定义格式化函数
  952. let displayLabel = label
  953. if (this.mergedXAxisConfig.formatter && typeof this.mergedXAxisConfig.formatter === 'function') {
  954. displayLabel = this.mergedXAxisConfig.formatter(label, i)
  955. }
  956. // 绘制标签(支持换行符)
  957. this.drawXAxisLabel(displayLabel, x, padding.top + graphHeight + 12)
  958. }
  959. })
  960. // 绘制Y轴线
  961. this.ctx.strokeStyle = '#d0d7de'
  962. this.ctx.lineWidth = 1
  963. this.ctx.beginPath()
  964. this.ctx.moveTo(padding.left, padding.top)
  965. this.ctx.lineTo(padding.left, padding.top + graphHeight)
  966. this.ctx.stroke()
  967. },
  968. // 计算数据点位置 - 支持多折线
  969. calculatePoints(graphWidth, graphHeight, padding) {
  970. this.points = []
  971. const minValue = this.yAxisMin || 0
  972. const maxValue = this.yAxisMax || 100
  973. const yRange = maxValue - minValue
  974. const centerX = padding.left + graphWidth / 2
  975. // 为每个数据集计算点位置
  976. this.chartData.datasets.forEach((dataset, datasetIndex)=> {
  977. const data = this.isTransitioning ? this.getTransitionData(datasetIndex) : dataset.data
  978. const datasetPoints = []
  979. data.forEach((value, i)=> {
  980. const baseX = padding.left + i * graphWidth / (this.chartData.labels.length - 1)
  981. const baseY = padding.top + graphHeight - (value - minValue) / yRange * graphHeight
  982. const x = centerX + (baseX - centerX) * this.scale + this.offsetX
  983. const y = baseY
  984. datasetPoints.push({ x, y, value })
  985. })
  986. this.points.push(datasetPoints)
  987. })
  988. // 如果是柱状图,计算每个柱的矩形位置
  989. if (this.chartType === 'bar') {
  990. this.calculateBarRects(graphWidth, graphHeight, padding)
  991. }
  992. },
  993. // 计算柱状图矩形(每个数据集对应一组矩形)
  994. calculateBarRects(graphWidth, graphHeight, padding) {
  995. this.barRects = []
  996. // 如果处于缩放状态,清除持久偏移(持久偏移只在 scale===1 时生效)
  997. if (this.scale !== 1) {
  998. this.barShift = 0
  999. }
  1000. const labelsCount = this.chartData.labels.length
  1001. const minValue = this.yAxisMin || 0
  1002. const maxValue = this.yAxisMax || 100
  1003. const yRange = maxValue - minValue || 1
  1004. // 可见数据集索引(保持原始索引以便图例切换匹配)
  1005. const visibleIndices = this.chartData.datasets.map((d, i)=> i).filter(i=> !this.hiddenDatasets[i])
  1006. const visibleCount = Math.max(1, visibleIndices.length)
  1007. // 计算基准中心(与 calculatePoints 保持一致)
  1008. const centerAnchor = padding.left + graphWidth / 2
  1009. // 每个数据点在未缩放时的间距(与 calculatePoints 保持一致)
  1010. const step = graphWidth / Math.max(1, labelsCount - 1)
  1011. const groupWidth = step * 0.8
  1012. // 单个柱子在未缩放时的宽度
  1013. const baseSingleBarWidth = groupWidth / visibleCount
  1014. // 初始化每个数据集的 rect 数组
  1015. for (let i = 0; i < this.chartData.datasets.length; i++) {
  1016. this.barRects[i] = []
  1017. }
  1018. this.chartData.datasets.forEach((dataset, datasetIndex)=> {
  1019. const data = this.isTransitioning ? this.getTransitionData(datasetIndex) : dataset.data
  1020. data.forEach((value, i)=> {
  1021. // baseX 与 calculatePoints 保持一致(未缩放坐标)
  1022. const baseX = padding.left + i * step
  1023. // 固定柱宽(像素),但限制不超过 step * 0.9
  1024. let singleBarWidth = this.mergedBarConfig.width || 16
  1025. singleBarWidth = Math.min(singleBarWidth, step * 0.9)
  1026. // 组内排列:组宽由固定宽度和 innerGap 决定(组内偏移不随缩放改变)
  1027. const visibleIndex = visibleIndices.indexOf(datasetIndex)
  1028. const innerGap = this.mergedBarConfig.innerGap || 4
  1029. const totalGroupWidth = visibleCount * singleBarWidth + Math.max(0, visibleCount - 1) * innerGap
  1030. const firstOffset = -totalGroupWidth / 2 + singleBarWidth / 2
  1031. const offset = visibleIndex !== -1 ? firstOffset + visibleIndex * (singleBarWidth + innerGap) : 0
  1032. // 将组内偏移(不随缩放)加到缩放后的组中心上,保证组内柱不分开
  1033. // 同时应用持久的 barShift,以确保首柱与 Y 轴有足够间隙(在 scale===1 时)
  1034. const centerX = centerAnchor + (baseX - centerAnchor) * this.scale + offset + this.offsetX + (this.barShift || 0)
  1035. // 计算高度和Y坐标(预留底部 gap,避免与 X 轴重叠)
  1036. const bottomGap = this.mergedBarConfig.bottomGap || 6
  1037. const effectiveGraphHeight = Math.max(0, graphHeight - bottomGap)
  1038. const valueClamped = value === undefined || value === null ? 0 : value
  1039. const height = (valueClamped - minValue) / yRange * effectiveGraphHeight
  1040. const x = centerX - singleBarWidth / 2
  1041. const y = padding.top + (graphHeight - bottomGap) - height
  1042. // 存储 index 以便 tooltip 命中时能对应到 label
  1043. this.barRects[datasetIndex].push({ x, y, width: singleBarWidth, height, value: valueClamped, centerX, index: i })
  1044. })
  1045. })
  1046. // 修复首列被半隐藏的问题:始终确保最左侧柱与 Y 轴之间保留 leftGap(兼顾缩放和平移)
  1047. if (this.barRects && this.barRects.length > 0) {
  1048. let leftMost = Infinity
  1049. let maxBarWidth = 0
  1050. this.barRects.forEach((rectArr)=> {
  1051. if (!rectArr || rectArr.length === 0) return
  1052. for (let r = 0; r < rectArr.length; r++) {
  1053. const rect = rectArr[r]
  1054. if (rect && typeof rect.x === 'number') leftMost = Math.min(leftMost, rect.x)
  1055. if (rect && typeof rect.width === 'number') maxBarWidth = Math.max(maxBarWidth, rect.width)
  1056. }
  1057. })
  1058. const halfBar = Math.ceil((maxBarWidth || (this.mergedBarConfig.width || 8)) / 2)
  1059. const desiredLeft = (padding.left || 0) + (this.mergedBarConfig.leftGap || 2) + halfBar
  1060. if (leftMost !== Infinity && leftMost < desiredLeft) {
  1061. const delta = desiredLeft - leftMost
  1062. // 如果用户当前在拖拽或正在双指缩放,则不要自动调整,这可能会影响手势感受
  1063. if (this.isDragging || this.isPinching) {
  1064. // 跳过自动调整,等待手势结束后由 constrainOffset 修正
  1065. } else {
  1066. if (this.scale === 1) {
  1067. // 未缩放时,将持久偏移累加并直接平移 rects,保证下一次计算也会应用该偏移
  1068. this.barShift = (this.barShift || 0) + delta
  1069. this.barRects.forEach((rectArr)=> {
  1070. if (!rectArr) return
  1071. rectArr.forEach((rect)=> {
  1072. if (!rect) return
  1073. rect.x += delta
  1074. rect.centerX = (rect.centerX || 0) + delta
  1075. })
  1076. })
  1077. // 同步调整触摸缓存,防止手势判断异常
  1078. this.lastTouchPos = { x: (this.lastTouchPos && this.lastTouchPos.x ? this.lastTouchPos.x : 0) + delta, y: this.lastTouchPos && this.lastTouchPos.y ? this.lastTouchPos.y : 0 }
  1079. this.touchStartPos = { x: (this.touchStartPos && this.touchStartPos.x ? this.touchStartPos.x : 0) + delta, y: this.touchStartPos && this.touchStartPos.y ? this.touchStartPos.y : 0 }
  1080. } else {
  1081. // 在缩放状态下,不自动修改 offsetX,避免选中或重绘时导致跳动到边缘。
  1082. // 缩放时的平移应由用户手势控制或由外部显式调用来调整。
  1083. // 因此这里不做自动平移,仅保留 rects 当前计算值。
  1084. }
  1085. }
  1086. }
  1087. }
  1088. },
  1089. // 获取过渡中的数据 - 支持多折线
  1090. getTransitionData(datasetIndex) {
  1091. const oldDataset = this.oldData[datasetIndex] || []
  1092. const newData = this.chartData.datasets[datasetIndex].data
  1093. const progress = this.transitionProgress
  1094. const eased = this.easeOutQuad(progress)
  1095. const result = []
  1096. const minLength = Math.min(oldDataset.length, newData.length)
  1097. for (let i = 0; i < minLength; i++) {
  1098. const oldValue = oldDataset[i]
  1099. const newValue = newData[i]
  1100. result.push(oldValue + (newValue - oldValue) * eased)
  1101. }
  1102. if (newData.length > oldDataset.length) {
  1103. for (let i = oldDataset.length; i < newData.length; i++) {
  1104. const lastOldValue = oldDataset[oldDataset.length - 1]
  1105. const newValue = newData[i]
  1106. result.push(lastOldValue + (newValue - lastOldValue) * eased)
  1107. }
  1108. }
  1109. return result
  1110. },
  1111. // 绘制线条
  1112. drawLine(points, dataset, progress) {
  1113. if (points.length === 0) return
  1114. const visiblePointCount = Math.max(1, Math.ceil(points.length * progress))
  1115. const visiblePoints = points.slice(0, visiblePointCount)
  1116. if (visiblePoints.length < 2) return
  1117. this.ctx.save()
  1118. let opacity = 1
  1119. if (visiblePointCount === 1) {
  1120. const firstPointProgress = progress * points.length
  1121. opacity = Math.min(1, firstPointProgress)
  1122. }
  1123. this.ctx.strokeStyle = dataset.color
  1124. this.ctx.lineWidth = 2
  1125. this.ctx.lineCap = 'round'
  1126. this.ctx.lineJoin = 'round'
  1127. this.ctx.globalAlpha = opacity
  1128. this.drawSmoothCurve(visiblePoints)
  1129. this.ctx.restore()
  1130. },
  1131. // 绘制平滑曲线 - 使用 Catmull-Rom 样条曲线,确保曲线经过所有数据点
  1132. drawSmoothCurve(points) {
  1133. if (points.length < 2) return
  1134. this.ctx.beginPath()
  1135. this.ctx.moveTo(points[0].x, points[0].y)
  1136. if (points.length === 2) {
  1137. // 只有2个点时,绘制直线
  1138. this.ctx.lineTo(points[1].x, points[1].y)
  1139. } else {
  1140. // 3个点及以上,使用 Catmull-Rom 样条曲线
  1141. for (let i = 0; i < points.length - 1; i++) {
  1142. const p0 = points[Math.max(0, i - 1)]
  1143. const p1 = points[i]
  1144. const p2 = points[i + 1]
  1145. const p3 = points[Math.min(points.length - 1, i + 2)]
  1146. // Catmull-Rom 转贝塞尔曲线的控制点
  1147. const cp1x = p1.x + (p2.x - p0.x) / 6
  1148. const cp1y = p1.y + (p2.y - p0.y) / 6
  1149. const cp2x = p2.x - (p3.x - p1.x) / 6
  1150. const cp2y = p2.y - (p3.y - p1.y) / 6
  1151. this.ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y)
  1152. }
  1153. }
  1154. this.ctx.stroke()
  1155. },
  1156. // 绘制数据点
  1157. drawPoints(points, dataset, progress) {
  1158. const visiblePoints = Math.ceil(points.length * progress)
  1159. for (let i = 0; i < visiblePoints; i++) {
  1160. const pointProgress = Math.min(
  1161. 1,
  1162. (progress - i / points.length) * points.length * 1.5
  1163. )
  1164. if (pointProgress > 0) {
  1165. this.ctx.save()
  1166. if (i === 0) {
  1167. const firstPointProgress = progress * points.length
  1168. this.ctx.globalAlpha = Math.min(1, firstPointProgress)
  1169. }
  1170. const gradient = this.ctx.createRadialGradient(
  1171. points[i].x,
  1172. points[i].y,
  1173. 0,
  1174. points[i].x,
  1175. points[i].y,
  1176. 3 * pointProgress
  1177. )
  1178. gradient.addColorStop(0, dataset.gradientStart)
  1179. gradient.addColorStop(0.7, dataset.color)
  1180. gradient.addColorStop(1, dataset.gradientEnd)
  1181. this.ctx.fillStyle = gradient
  1182. this.ctx.beginPath()
  1183. this.ctx.arc(
  1184. points[i].x,
  1185. points[i].y,
  1186. 3 * pointProgress,
  1187. 0,
  1188. 2 * Math.PI
  1189. )
  1190. this.ctx.fill()
  1191. this.ctx.strokeStyle = '#ffffff'
  1192. this.ctx.lineWidth = 1.5
  1193. this.ctx.beginPath()
  1194. this.ctx.arc(
  1195. points[i].x,
  1196. points[i].y,
  1197. 3 * pointProgress,
  1198. 0,
  1199. 2 * Math.PI
  1200. )
  1201. this.ctx.stroke()
  1202. this.ctx.restore()
  1203. }
  1204. }
  1205. },
  1206. // 绘制Tooltip指示线 - 支持多折线
  1207. drawTooltipLine(pointIndex) {
  1208. if (this.points.length === 0) return
  1209. // 获取所有数据集在该索引处的点
  1210. const allPoints = this.points.map(datasetPoints=> datasetPoints[pointIndex]).filter(p=> p)
  1211. if (allPoints.length === 0) return
  1212. // 如果不是柱状图,绘制垂直指示线及高亮圆点
  1213. if (this.chartType !== 'bar') {
  1214. // 绘制垂直指示线(使用第一个点的x坐标)
  1215. const firstPoint = allPoints[0]
  1216. this.ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'
  1217. this.ctx.lineWidth = 1
  1218. this.ctx.setLineDash([5, 3])
  1219. this.ctx.beginPath()
  1220. this.ctx.moveTo(firstPoint.x, this.mergedPadding.top)
  1221. this.ctx.lineTo(
  1222. firstPoint.x,
  1223. this.mergedPadding.top + (this.chartHeight - this.mergedPadding.top - this.mergedPadding.bottom)
  1224. )
  1225. this.ctx.stroke()
  1226. this.ctx.setLineDash([])
  1227. // 为每个数据集绘制高亮点
  1228. allPoints.forEach((point, datasetIndex)=> {
  1229. const dataset = this.chartData.datasets[datasetIndex]
  1230. // 外圈光晕
  1231. this.ctx.strokeStyle = this.hexToRgba(dataset.color, 0.2)
  1232. this.ctx.lineWidth = 4
  1233. this.ctx.beginPath()
  1234. this.ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI)
  1235. this.ctx.stroke()
  1236. // 渐变填充
  1237. const gradient = this.ctx.createRadialGradient(
  1238. point.x, point.y, 0,
  1239. point.x, point.y, 4
  1240. )
  1241. gradient.addColorStop(0, dataset.gradientStart)
  1242. gradient.addColorStop(1, dataset.gradientEnd)
  1243. this.ctx.fillStyle = gradient
  1244. this.ctx.beginPath()
  1245. this.ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI)
  1246. this.ctx.fill()
  1247. // 白色边框
  1248. this.ctx.strokeStyle = '#ffffff'
  1249. this.ctx.lineWidth = 1.5
  1250. this.ctx.beginPath()
  1251. this.ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI)
  1252. this.ctx.stroke()
  1253. })
  1254. }
  1255. // 如果是柱状图,绘制高亮柱(半透明外圈)
  1256. if (this.chartType === 'bar') {
  1257. // 计算该组的最小/最大X用于包裹高亮
  1258. const groupRects = []
  1259. this.chartData.datasets.forEach((d, idx)=> {
  1260. if (this.hiddenDatasets[idx]) return
  1261. const rect = this.barRects[idx] && this.barRects[idx][pointIndex] || null
  1262. if (rect) groupRects.push({ rect, dataset: d, datasetIndex: idx })
  1263. })
  1264. if (groupRects.length > 0) {
  1265. const left = Math.min(...groupRects.map(g=> g.rect.x)) - 2
  1266. const right = Math.max(...groupRects.map(g=> g.rect.x + g.rect.width)) + 2
  1267. const padding = this.mergedPadding
  1268. const graphHeight = this.chartHeight - padding.top - padding.bottom
  1269. const highlightTop = padding.top
  1270. const highlightHeight = graphHeight
  1271. const highlightWidth = right - left
  1272. // 使用第一个可见数据集的颜色作为阴影色
  1273. const color = groupRects[0].dataset.color || '#000'
  1274. // 解析 borderRadius,支持 number 或对象
  1275. const cfg = this.mergedBarConfig || {}
  1276. const br = cfg.borderRadius
  1277. let hr = 4
  1278. if (typeof br === 'number') hr = Math.min(br, 12)
  1279. else if (br && typeof br === 'object') hr = Math.min(br.top || br.topLeft || 4, 12)
  1280. this.ctx.save()
  1281. this.ctx.shadowColor = this.hexToRgba(color, 0.28)
  1282. this.ctx.shadowBlur = 18
  1283. this.ctx.fillStyle = this.hexToRgba(color, 0.08)
  1284. this.ctx.beginPath()
  1285. this.drawRoundedRect(left - (cfg.leftGap || 0), highlightTop, highlightWidth + (cfg.leftGap || 0) * 2, highlightHeight, br || hr)
  1286. this.ctx.fill()
  1287. this.ctx.restore()
  1288. // 描边以增强可见性
  1289. this.ctx.strokeStyle = this.hexToRgba(color, 0.12)
  1290. this.ctx.lineWidth = 1
  1291. this.ctx.beginPath()
  1292. this.drawRoundedRect(left - (cfg.leftGap || 0), highlightTop, highlightWidth + (cfg.leftGap || 0) * 2, highlightHeight, br || hr)
  1293. this.ctx.stroke()
  1294. // 在阴影之上重绘该组柱体,保持柱体清晰
  1295. groupRects.forEach(g=> {
  1296. const r = g.rect
  1297. const ds = this.chartData.datasets[g.datasetIndex] || {}
  1298. if (!r) return
  1299. this.ctx.save()
  1300. this.ctx.beginPath()
  1301. // 使用与 drawBars 相同的 borderRadius 配置
  1302. this.drawRoundedRect(r.x, r.y, r.width, r.height, br || hr)
  1303. try {
  1304. const gfill = this.ctx.createLinearGradient(r.x, r.y, r.x, r.y + r.height)
  1305. gfill.addColorStop(0, ds.gradientStart || ds.color)
  1306. gfill.addColorStop(1, ds.gradientEnd || ds.color)
  1307. this.ctx.fillStyle = gfill
  1308. } catch (err) {
  1309. this.ctx.fillStyle = ds.color || '#2f95ff'
  1310. }
  1311. this.ctx.fill()
  1312. if (ds.borderColor) {
  1313. this.ctx.lineWidth = ds.borderWidth || 1
  1314. this.ctx.strokeStyle = ds.borderColor
  1315. this.ctx.beginPath()
  1316. this.drawRoundedRect(r.x, r.y, r.width, r.height, br || hr)
  1317. this.ctx.stroke()
  1318. }
  1319. this.ctx.restore()
  1320. })
  1321. }
  1322. }
  1323. },
  1324. // 颜色转换
  1325. hexToRgba(hex, opacity) {
  1326. const r = parseInt(hex.slice(1, 3), 16)
  1327. const g = parseInt(hex.slice(3, 5), 16)
  1328. const b = parseInt(hex.slice(5, 7), 16)
  1329. return `rgba(${r}, ${g}, ${b}, ${opacity})`
  1330. },
  1331. // 绘制圆角矩形的路径(支持单值或每角对象半径),使用当前 this.ctx
  1332. drawRoundedRect(x, y, width, height, radius) {
  1333. // radius 可以为数字或对象 { topLeft, topRight, bottomRight, bottomLeft } 或 { top, bottom }
  1334. let r = {
  1335. topLeft: 0,
  1336. topRight: 0,
  1337. bottomRight: 0,
  1338. bottomLeft: 0
  1339. }
  1340. if (typeof radius === 'number') {
  1341. r.topLeft = r.topRight = r.bottomRight = r.bottomLeft = Math.max(0, radius)
  1342. } else if (radius && typeof radius === 'object') {
  1343. if (radius.top !== undefined || radius.bottom !== undefined) {
  1344. r.topLeft = r.topRight = Math.max(0, radius.top || 0)
  1345. r.bottomLeft = r.bottomRight = Math.max(0, radius.bottom || 0)
  1346. } else {
  1347. r.topLeft = Math.max(0, radius.topLeft || 0)
  1348. r.topRight = Math.max(0, radius.topRight || 0)
  1349. r.bottomRight = Math.max(0, radius.bottomRight || 0)
  1350. r.bottomLeft = Math.max(0, radius.bottomLeft || 0)
  1351. }
  1352. }
  1353. // 限制每个半径不超过 width/2 或 height/2
  1354. const maxR = Math.min(width / 2, height / 2)
  1355. r.topLeft = Math.min(r.topLeft, maxR)
  1356. r.topRight = Math.min(r.topRight, maxR)
  1357. r.bottomRight = Math.min(r.bottomRight, maxR)
  1358. r.bottomLeft = Math.min(r.bottomLeft, maxR)
  1359. this.ctx.moveTo(x + r.topLeft, y)
  1360. this.ctx.lineTo(x + width - r.topRight, y)
  1361. this.ctx.quadraticCurveTo(x + width, y, x + width, y + r.topRight)
  1362. this.ctx.lineTo(x + width, y + height - r.bottomRight)
  1363. this.ctx.quadraticCurveTo(x + width, y + height, x + width - r.bottomRight, y + height)
  1364. this.ctx.lineTo(x + r.bottomLeft, y + height)
  1365. this.ctx.quadraticCurveTo(x, y + height, x, y + height - r.bottomLeft)
  1366. this.ctx.lineTo(x, y + r.topLeft)
  1367. this.ctx.quadraticCurveTo(x, y, x + r.topLeft, y)
  1368. this.ctx.closePath()
  1369. },
  1370. // 触摸开始
  1371. handleTouchStart(e) {
  1372. if (!this.mergedZoomConfig.enabled) {
  1373. if (this.mergedTooltipConfig.enabled) {
  1374. this.handleTooltip(e)
  1375. }
  1376. return
  1377. }
  1378. const touches = e.touches
  1379. if (touches.length === 2) {
  1380. this.isPinching = true
  1381. this.isDragging = false
  1382. this.showTooltip = false
  1383. const distance = this.getTouchDistance(touches[0], touches[1])
  1384. this.lastTouchDistance = distance
  1385. const center = this.getTouchCenter(touches[0], touches[1])
  1386. this.lastTouchCenter = center
  1387. } else if (touches.length === 1) {
  1388. this.isPinching = false
  1389. this.lastTouchPos = { x: touches[0].x, y: touches[0].y }
  1390. this.touchStartPos = { x: touches[0].x, y: touches[0].y }
  1391. this.touchStartTime = Date.now()
  1392. this.isDragging = false
  1393. if (this.scale === 1 && this.mergedTooltipConfig.enabled) {
  1394. this.handleTooltip(e)
  1395. }
  1396. }
  1397. },
  1398. // 触摸移动
  1399. handleTouchMove(e) {
  1400. if (!this.mergedZoomConfig.enabled) {
  1401. if (this.mergedTooltipConfig.enabled) {
  1402. this.handleTooltip(e)
  1403. }
  1404. return
  1405. }
  1406. const touches = e.touches
  1407. if (touches.length === 2 && this.isPinching) {
  1408. e.preventDefault && e.preventDefault()
  1409. const distance = this.getTouchDistance(touches[0], touches[1])
  1410. const center = this.getTouchCenter(touches[0], touches[1])
  1411. const scaleChange = distance / this.lastTouchDistance
  1412. let newScale = this.scale * scaleChange
  1413. newScale = Math.max(this.mergedZoomConfig.minScale, Math.min(this.mergedZoomConfig.maxScale, newScale))
  1414. const scaleRatio = newScale / this.scale
  1415. this.offsetX = this.offsetX * scaleRatio
  1416. this.offsetY = 0
  1417. this.scale = newScale
  1418. this.constrainOffset()
  1419. this.lastTouchDistance = distance
  1420. this.lastTouchCenter = center
  1421. this.drawChart()
  1422. } else if (touches.length === 1) {
  1423. const touch = touches[0]
  1424. const deltaX = Math.abs(touch.x - this.touchStartPos.x)
  1425. const deltaY = Math.abs(touch.y - this.touchStartPos.y)
  1426. const dragThreshold = 5
  1427. if (!this.isDragging && (deltaX > dragThreshold || deltaY > dragThreshold)) {
  1428. if (this.scale !== 1) {
  1429. this.isDragging = true
  1430. this.showTooltip = false
  1431. }
  1432. }
  1433. if (this.isDragging) {
  1434. e.preventDefault && e.preventDefault()
  1435. const moveX = touch.x - this.lastTouchPos.x
  1436. this.offsetX += moveX
  1437. this.constrainOffset()
  1438. this.lastTouchPos = { x: touch.x, y: touch.y }
  1439. this.drawChart()
  1440. } else {
  1441. if (this.mergedTooltipConfig.enabled) {
  1442. this.handleTooltip(e)
  1443. }
  1444. }
  1445. }
  1446. },
  1447. // 触摸结束
  1448. handleTouchEnd(e) {
  1449. const touches = e.touches
  1450. if (touches.length === 0) {
  1451. const touchEndTime = Date.now()
  1452. const touchDuration = touchEndTime - this.touchStartTime
  1453. const deltaX = Math.abs(this.lastTouchPos.x - this.touchStartPos.x)
  1454. const deltaY = Math.abs(this.lastTouchPos.y - this.touchStartPos.y)
  1455. const isClick = touchDuration < 300 && deltaX < 10 && deltaY < 10
  1456. if (this.isDragging || this.isPinching) {
  1457. this.constrainOffset()
  1458. this.drawChart()
  1459. } else if (isClick && this.mergedTooltipConfig.enabled) {
  1460. const mockEvent = {
  1461. touches: [{
  1462. x: this.touchStartPos.x,
  1463. y: this.touchStartPos.y,
  1464. clientX: this.touchStartPos.x
  1465. }]
  1466. }
  1467. this.handleTooltip(mockEvent)
  1468. }
  1469. this.isPinching = false
  1470. this.isDragging = false
  1471. } else if (touches.length === 1) {
  1472. if (this.isPinching) {
  1473. this.constrainOffset()
  1474. this.drawChart()
  1475. }
  1476. this.isPinching = false
  1477. this.lastTouchPos = { x: touches[0].x, y: touches[0].y }
  1478. }
  1479. },
  1480. // 计算两个触摸点之间的距离
  1481. getTouchDistance(touch1, touch2) {
  1482. const dx = touch2.x - touch1.x
  1483. const dy = touch2.y - touch1.y
  1484. return Math.sqrt(dx * dx + dy * dy)
  1485. },
  1486. // 计算两个触摸点的中心点
  1487. getTouchCenter(touch1, touch2) {
  1488. return {
  1489. x: (touch1.x + touch2.x) / 2,
  1490. y: (touch1.y + touch2.y) / 2
  1491. }
  1492. },
  1493. // 处理Tooltip - 支持多折线
  1494. handleTooltip(e) {
  1495. if (this.points.length === 0 || this.points[0].length === 0) {
  1496. return
  1497. }
  1498. if (!this.canvasRect || !this.wrapperRect) {
  1499. this.updateCanvasRect()
  1500. // 延迟重试
  1501. setTimeout(()=> {
  1502. if (this.canvasRect && this.wrapperRect) {
  1503. this.handleTooltip(e)
  1504. }
  1505. }, 100)
  1506. return
  1507. }
  1508. const touch = e.touches[0]
  1509. const canvasRect = this.canvasRect
  1510. const wrapperRect = this.wrapperRect
  1511. let touchCanvasX, touchCanvasY
  1512. if (this.canvas) {
  1513. // 小程序 canvas 触摸事件直接给出 canvas 坐标
  1514. touchCanvasX = touch.x
  1515. touchCanvasY = touch.y
  1516. } else {
  1517. const touchX = touch.x || touch.clientX
  1518. const touchY = touch.y || touch.clientY
  1519. touchCanvasX = touchX - canvasRect.left
  1520. touchCanvasY = touchY - canvasRect.top
  1521. }
  1522. // 当为柱状图时,使用 barRects 命中检测(优先)
  1523. if (this.chartType === 'bar') {
  1524. let foundIndex = -1
  1525. let foundCenterX = null
  1526. const labelCount = this.chartData.labels.length
  1527. for (let ds = 0; ds < this.barRects.length; ds++) {
  1528. if (this.hiddenDatasets[ds]) continue
  1529. const rects = this.barRects[ds] || []
  1530. for (let r = 0; r < rects.length; r++) {
  1531. const rect = rects[r]
  1532. if (!rect) continue
  1533. // 命中检测:在矩形内
  1534. if (touchCanvasX >= rect.x && touchCanvasX <= rect.x + rect.width && touchCanvasY >= rect.y && touchCanvasY <= rect.y + rect.height) {
  1535. foundIndex = rect.index
  1536. foundCenterX = rect.centerX
  1537. break
  1538. }
  1539. }
  1540. if (foundIndex !== -1) break
  1541. }
  1542. // 如果没有直接命中,则按X距离最近的组来触发(阈值为 mergedTooltipConfig.threshold)
  1543. if (foundIndex === -1) {
  1544. let minDist = Infinity
  1545. let closest = -1
  1546. for (let idx = 0; idx < labelCount; idx++) {
  1547. // 从第一个可见数据集获取对应 rect 作为代表
  1548. let repRect = null
  1549. for (let ds = 0; ds < this.barRects.length; ds++) {
  1550. if (this.hiddenDatasets[ds]) continue
  1551. const r = this.barRects[ds] && this.barRects[ds][idx] || null
  1552. if (r) { repRect = r; break }
  1553. }
  1554. if (!repRect) continue
  1555. const dist = Math.abs(repRect.centerX - touchCanvasX)
  1556. if (dist < minDist) { minDist = dist; closest = idx }
  1557. }
  1558. if (minDist < this.mergedTooltipConfig.threshold) {
  1559. foundIndex = closest
  1560. // find representative centerX
  1561. for (let ds = 0; ds < this.barRects.length; ds++) {
  1562. const r = this.barRects[ds] && this.barRects[ds][foundIndex] || null
  1563. if (r) { foundCenterX = r.centerX; break }
  1564. }
  1565. }
  1566. }
  1567. if (foundIndex !== -1) {
  1568. this.showTooltip = true
  1569. this.currentPointIndex = foundIndex
  1570. this.updateTooltipData(foundIndex)
  1571. // 计算 tooltip 位置:以该组中最高的柱为基准
  1572. const groupRects = []
  1573. this.chartData.datasets.forEach((d, dsIdx)=> {
  1574. if (this.hiddenDatasets[dsIdx]) return
  1575. const r = this.barRects[dsIdx] && this.barRects[dsIdx][foundIndex] || null
  1576. if (r) groupRects.push({ rect: r, dataset: d })
  1577. })
  1578. if (groupRects.length > 0) {
  1579. const topY = Math.min(...groupRects.map(g=> g.rect.y))
  1580. const repCenterX = foundCenterX || groupRects[0].rect.centerX
  1581. let tooltipX = canvasRect.left - wrapperRect.left + repCenterX
  1582. let tooltipY = canvasRect.top - wrapperRect.top + topY - 100
  1583. const tooltipWidth = 120
  1584. const tooltipHeight = 120
  1585. const wrapperWidth = wrapperRect.width
  1586. const wrapperHeight = wrapperRect.height
  1587. if (tooltipX - tooltipWidth < 0) tooltipX = tooltipWidth
  1588. else if (tooltipX + tooltipWidth > wrapperWidth) tooltipX = wrapperWidth - tooltipWidth
  1589. if (tooltipY < 0) tooltipY = topY + 20
  1590. else if (tooltipY + tooltipHeight > wrapperHeight) tooltipY = wrapperHeight - tooltipHeight
  1591. this.tooltipPosition.x = tooltipX
  1592. this.tooltipPosition.y = tooltipY
  1593. }
  1594. this.drawChart()
  1595. return
  1596. }
  1597. }
  1598. // 非柱状图或未命中的回退逻辑:使用折线的点距离判断(原有逻辑)
  1599. let minDistance = Infinity
  1600. let closestIndex = -1
  1601. // 如果没有点数据则直接返回
  1602. if (!this.points || !this.points[0]) return
  1603. this.points[0].forEach((point, i)=> {
  1604. const distance = Math.abs(point.x - touchCanvasX)
  1605. if (distance < minDistance) {
  1606. minDistance = distance
  1607. closestIndex = i
  1608. }
  1609. })
  1610. const threshold = this.mergedTooltipConfig.threshold
  1611. if (closestIndex !== -1 && minDistance < threshold) {
  1612. this.showTooltip = true
  1613. this.currentPointIndex = closestIndex
  1614. this.updateTooltipData(closestIndex)
  1615. const allYs = this.points.map(datasetPoints=> datasetPoints[closestIndex].y)
  1616. const topY = Math.min(...allYs)
  1617. const firstPoint = this.points[0][closestIndex]
  1618. let tooltipX = canvasRect.left - wrapperRect.left + firstPoint.x
  1619. let tooltipY = canvasRect.top - wrapperRect.top + topY - 100
  1620. const tooltipWidth = 120
  1621. const tooltipHeight = 120
  1622. const wrapperWidth = wrapperRect.width
  1623. const wrapperHeight = wrapperRect.height
  1624. if (tooltipX - tooltipWidth < 0) {
  1625. tooltipX = tooltipWidth
  1626. } else if (tooltipX + tooltipWidth > wrapperWidth) {
  1627. tooltipX = wrapperWidth - tooltipWidth
  1628. }
  1629. if (tooltipY < 0) {
  1630. tooltipY = topY + 20
  1631. } else if (tooltipY + tooltipHeight > wrapperHeight) {
  1632. tooltipY = wrapperHeight - tooltipHeight
  1633. }
  1634. this.tooltipPosition.x = tooltipX
  1635. this.tooltipPosition.y = tooltipY
  1636. this.drawChart()
  1637. } else {
  1638. if (this.showTooltip) {
  1639. this.showTooltip = false
  1640. this.currentPointIndex = -1
  1641. this.drawChart()
  1642. }
  1643. }
  1644. },
  1645. // 限制平移范围
  1646. constrainOffset() {
  1647. const { chartWidth } = this
  1648. const padding = this.mergedPadding
  1649. const graphWidth = chartWidth - padding.left - padding.right
  1650. if (this.scale === 1) {
  1651. this.offsetX = 0
  1652. this.offsetY = 0
  1653. return
  1654. }
  1655. const centerX = padding.left + graphWidth / 2
  1656. const maxOffsetX = (padding.left - centerX) * (1 - this.scale)
  1657. const minOffsetX = (padding.left + graphWidth - centerX) * (1 - this.scale)
  1658. this.offsetX = Math.max(minOffsetX, Math.min(maxOffsetX, this.offsetX))
  1659. this.offsetY = 0
  1660. },
  1661. // 绘制柱状图(接收某个数据集的矩形数组)
  1662. drawBars(rects, dataset, progress) {
  1663. if (!rects || rects.length === 0) return
  1664. const labelCount = this.chartData && this.chartData.labels ? this.chartData.labels.length : 0
  1665. const animateMode = this.mergedBarConfig && this.mergedBarConfig.animateMode ? this.mergedBarConfig.animateMode : 'all'
  1666. const staggerFraction = this.mergedBarConfig && this.mergedBarConfig.staggerDelayFraction ? this.mergedBarConfig.staggerDelayFraction : 0.02
  1667. const borderRadius = this.mergedBarConfig && this.mergedBarConfig.borderRadius ? this.mergedBarConfig.borderRadius : 0
  1668. const totalStagger = Math.max(0, Math.min(0.8, (labelCount - 1) * staggerFraction))
  1669. this.ctx.save()
  1670. rects.forEach((rect, i)=> {
  1671. let barProgress = Math.max(0, Math.min(1, progress))
  1672. if (animateMode === 'stagger' && typeof rect.index === 'number') {
  1673. const start = rect.index * staggerFraction
  1674. const denom = 1 - totalStagger
  1675. if (denom <= 0) {
  1676. barProgress = progress
  1677. } else {
  1678. barProgress = (progress - start) / denom
  1679. }
  1680. barProgress = Math.max(0, Math.min(1, barProgress))
  1681. // 使用缓动函数以获得更自然的生长
  1682. barProgress = this.easeOutQuad(barProgress)
  1683. } else {
  1684. barProgress = this.easeOutQuad(barProgress)
  1685. }
  1686. if (barProgress <= 0) return
  1687. // 计算动画高度和绘制 Y(底部锚点)
  1688. const baseY = rect.y + rect.height // 实际底部坐标(已考虑 bottomGap)
  1689. const animatedHeight = rect.height * barProgress
  1690. const drawY = baseY - animatedHeight
  1691. // 渐变填充(从 gradientStart 到 gradientEnd),在动画过程中逐步显示高度
  1692. try {
  1693. const g = this.ctx.createLinearGradient(rect.x, drawY, rect.x, drawY + animatedHeight)
  1694. g.addColorStop(0, dataset.gradientStart || dataset.color)
  1695. g.addColorStop(1, dataset.gradientEnd || dataset.color)
  1696. this.ctx.fillStyle = g
  1697. } catch (err) {
  1698. this.ctx.fillStyle = dataset.color
  1699. }
  1700. this.ctx.globalAlpha = 1
  1701. // 使用圆角矩形进行填充和描边
  1702. this.ctx.beginPath()
  1703. this.drawRoundedRect(rect.x, drawY, rect.width, animatedHeight, borderRadius)
  1704. this.ctx.fill()
  1705. // 描边(轻描)
  1706. this.ctx.strokeStyle = this.hexToRgba(dataset.color, 0.12)
  1707. this.ctx.lineWidth = 1
  1708. this.ctx.beginPath()
  1709. this.drawRoundedRect(rect.x, drawY, rect.width, animatedHeight, borderRadius)
  1710. this.ctx.stroke()
  1711. })
  1712. this.ctx.restore()
  1713. },
  1714. }
  1715. }
  1716. </script>
  1717. <style scoped>
  1718. /* 图表容器 */
  1719. .chart-container {
  1720. /* display: flex;
  1721. flex-direction: column;
  1722. width: 100%; */
  1723. }
  1724. .chart-wrapper {
  1725. position: relative;
  1726. background: #ffffff;
  1727. border-radius: 32rpx;
  1728. /* overflow: hidden; */
  1729. flex: 1;
  1730. /* #ifdef MP-TOUTIAO */
  1731. height: 250px;
  1732. /* #endif */
  1733. }
  1734. .chart-loading {
  1735. position: absolute;
  1736. top: 0;
  1737. left: 0;
  1738. right: 0;
  1739. bottom: 0;
  1740. display: flex;
  1741. align-items: center;
  1742. justify-content: center;
  1743. background: #ffffff;
  1744. z-index: 10;
  1745. }
  1746. .loading-text {
  1747. font-size: 28rpx;
  1748. color: #999999;
  1749. }
  1750. canvas {
  1751. display: block;
  1752. }
  1753. .tooltip {
  1754. position: absolute;
  1755. background: rgba(255, 255, 255, 0.95);
  1756. border-radius: 16rpx;
  1757. padding: 24rpx;
  1758. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
  1759. pointer-events: none;
  1760. z-index: 10;
  1761. min-width: 240rpx;
  1762. transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
  1763. transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
  1764. left 0.2s cubic-bezier(0.4, 0, 0.2, 1),
  1765. top 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  1766. will-change: transform, opacity;
  1767. }
  1768. .tooltip-label {
  1769. font-size: 24rpx;
  1770. color: #666;
  1771. margin-bottom: 16rpx;
  1772. font-weight: 500;
  1773. white-space: nowrap;
  1774. }
  1775. .tooltip-item {
  1776. display: flex;
  1777. align-items: center;
  1778. margin-bottom: 8rpx;
  1779. font-size: 26rpx;
  1780. color: #333;
  1781. white-space: nowrap;
  1782. }
  1783. .tooltip-item:last-child {
  1784. margin-bottom: 0;
  1785. }
  1786. .tooltip-dot {
  1787. width: 16rpx;
  1788. height: 16rpx;
  1789. border-radius: 50%;
  1790. margin-right: 12rpx;
  1791. }
  1792. .zoom-controls {
  1793. position: absolute;
  1794. top: 24rpx;
  1795. right: 24rpx;
  1796. z-index: 5;
  1797. animation: fadeInDown 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
  1798. }
  1799. @keyframes fadeInDown {
  1800. from {
  1801. opacity: 0;
  1802. transform: translateY(-16rpx);
  1803. }
  1804. to {
  1805. opacity: 1;
  1806. transform: translateY(0);
  1807. }
  1808. }
  1809. @keyframes fadeInSlide {
  1810. from {
  1811. opacity: 0;
  1812. transform: translateY(-20rpx);
  1813. }
  1814. to {
  1815. opacity: 1;
  1816. transform: translateY(0);
  1817. }
  1818. }
  1819. .reset-btn {
  1820. background: #ffffff;
  1821. color: #666666;
  1822. border: 2rpx solid #e0e0e0;
  1823. border-radius: 16rpx;
  1824. padding: 12rpx 28rpx;
  1825. font-size: 24rpx;
  1826. font-weight: 500;
  1827. box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.06);
  1828. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  1829. cursor: pointer;
  1830. display: flex;
  1831. align-items: center;
  1832. gap: 8rpx;
  1833. }
  1834. .reset-btn:hover {
  1835. background: #fafafa;
  1836. border-color: #d0d0d0;
  1837. box-shadow: 0 6rpx 12rpx rgba(0, 0, 0, 0.1);
  1838. color: #333333;
  1839. }
  1840. .reset-btn:active {
  1841. transform: translateY(2rpx);
  1842. box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.08);
  1843. }
  1844. /* Legend 图例样式 */
  1845. .legend {
  1846. display: flex;
  1847. align-items: center;
  1848. justify-content: center;
  1849. flex-wrap: wrap;
  1850. gap: 32rpx;
  1851. padding: 10rpx 40rpx;
  1852. background: #ffffff;
  1853. /* animation: fadeInSlide 0.3s ease-out; */
  1854. }
  1855. .legend-top {
  1856. border-radius: 32rpx 32rpx 0 0;
  1857. box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.08);
  1858. }
  1859. .legend-bottom {
  1860. border-radius: 0 0 32rpx 32rpx;
  1861. }
  1862. .legend-item {
  1863. display: flex;
  1864. align-items: center;
  1865. gap: 16rpx;
  1866. padding: 12rpx 24rpx;
  1867. border-radius: 12rpx;
  1868. cursor: pointer;
  1869. transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  1870. user-select: none;
  1871. background: rgba(0, 0, 0, 0.02);
  1872. }
  1873. .legend-item:hover {
  1874. background: rgba(0, 0, 0, 0.04);
  1875. }
  1876. .legend-item:active {
  1877. transform: scale(0.96);
  1878. background: rgba(0, 0, 0, 0.06);
  1879. }
  1880. .legend-item-disabled {
  1881. opacity: 0.35;
  1882. }
  1883. .legend-item-disabled .legend-label {
  1884. text-decoration: line-through;
  1885. }
  1886. .legend-color {
  1887. width: 20rpx;
  1888. height: 20rpx;
  1889. border-radius: 4rpx;
  1890. flex-shrink: 0;
  1891. box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.2);
  1892. }
  1893. .legend-label {
  1894. font-size: 26rpx;
  1895. font-weight: 500;
  1896. color: #333333;
  1897. white-space: nowrap;
  1898. }
  1899. </style>