uni-charts.vue
14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
<template>
<view class="uni-charts">
<canvas
v-if="type !== 'map'"
class="charts-canvas"
:style="{width: width + 'px', height: height + 'px'}"
canvas-id="uni-charts"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
></canvas>
<view v-else class="map-container" :style="{width: width + 'px', height: height + 'px'}">
<slot name="map"></slot>
</view>
</view>
</template>
<script>
export default {
name: 'uniCharts',
props: {
type: {
type: String,
default: 'line'
},
data: {
type: Array,
default () {
return []
}
},
categories: {
type: Array,
default () {
return []
}
},
option: {
type: Object,
default () {
return {
yAxis: {
tickCount: 5 // 默认5个刻度,可通过option自定义,无上限
}
}
}
},
width: {
type: [Number, String],
default: 375
},
height: {
type: [Number, String],
default: 200
}
},
data () {
return {
ctx: null,
chartData: {},
touchInfo: {}
}
},
watch: {
data: {
deep: true,
handler () {
this.initChart()
}
},
categories: {
deep: true,
handler () {
this.initChart()
}
},
option: {
deep: true,
handler () {
this.initChart()
}
}
},
mounted () {
this.initChart()
},
methods: {
initChart () {
if (this.type === 'kline') {
this.drawKline()
} else if (this.type === 'line') {
this.drawLine()
}
},
// 核心:自适应计算Y轴整数刻度(无任何数值限制)
calculateYAxisTicks (minVal, maxVal, tickCount = 5) {
// 1. 确保是整数,且最大值>最小值(无数值范围限制)
minVal = parseInt(minVal, 10)
maxVal = parseInt(maxVal, 10)
// 边界处理:极值相等时自动扩展范围(避免刻度重复)
if (maxVal <= minVal) {
maxVal = minVal + tickCount // 扩展范围,保证刻度不重复
}
// 2. 动态计算整数步长(完全自适应,无固定值限制)
const totalRange = maxVal - minVal
let step = Math.ceil(totalRange / (tickCount - 1)) // 关键:按刻度数算步长
step = step < 1 ? 1 : step // 步长至少为1,保证整数刻度
// 3. 生成连续不重复的整数刻度(无数值上限)
const ticks = []
for (let i = 0; i < tickCount; i++) {
ticks.push(minVal + i * step)
}
// 4. 确保最大值能覆盖数据(无限制)
if (ticks[ticks.length - 1] < maxVal) {
ticks.push(maxVal)
}
return {
ticks: ticks, // 最终刻度数组(纯整数、不重复、无限制)
min: minVal,
max: Math.max(...ticks),
step: step
}
},
// 折线图绘制(无限制版)
drawLine () {
if (!this.data.length || !this.categories.length) return;
const ctx = uni.createCanvasContext('uni-charts', this)
this.ctx = ctx
const { width, height } = this
const {
grid = {},
xAxis = {},
yAxis = {},
legend = {},
color = ['#25AF69', '#B34C17']
} = this.option
// 清空画布
ctx.clearRect(0, 0, width, height)
// Grid布局(自适应,无固定值)
const gridTop = grid.top ? (typeof grid.top === 'string' ? parseFloat(grid.top) / 100 * height : grid.top) : 60
const gridLeft = grid.left ? (typeof grid.left === 'string' ? parseFloat(grid.left) / 100 * width : grid.left) : 70
const gridRight = grid.right ? (typeof grid.right === 'string' ? parseFloat(grid.right) / 100 * width : grid.right) : 20
const gridBottom = grid.bottom ? (typeof grid.bottom === 'string' ? parseFloat(grid.bottom) / 100 * height : grid.bottom) : 50
const drawWidth = width - gridLeft - gridRight
const drawHeight = height - gridTop - gridBottom
// 收集所有Y轴数据(无数值限制)
let allValues = []
this.data.forEach(series => {
allValues = allValues.concat(series.data)
})
if (allValues.length === 0) return;
// 基础极值(完全按数据来,无任何限制)
const rawMaxVal = Math.max(...allValues)
const rawMinVal = Math.min(...allValues)
// 用户可通过yAxis.min/max自定义极值,否则用数据极值(无限制)
const maxVal = yAxis.max !== undefined ? yAxis.max : rawMaxVal
const minVal = yAxis.min !== undefined ? yAxis.min : rawMinVal
// 刻度数量自定义(默认5个,可传任意数,无上限)
const tickCount = yAxis.tickCount || 5
// 核心:计算无限制、不重复的整数刻度
const yAxisConfig = this.calculateYAxisTicks(minVal, maxVal, tickCount)
const { ticks: yTicks, min: alignedMinVal, max: alignedMaxVal } = yAxisConfig
const valRange = alignedMaxVal - alignedMinVal || 1
// X轴步长(自适应)
const xStep = drawWidth / (this.categories.length - 1 || 1)
// X轴标签防拥挤(自适应,无限制)
const minLabelWidth = 30
const maxShowLabels = Math.floor(drawWidth / minLabelWidth)
const labelInterval = maxShowLabels < this.categories.length
? Math.ceil(this.categories.length / maxShowLabels)
: 1
// 绘制网格线 + Y轴整数刻度(无数值限制)
ctx.setStrokeStyle(yAxis.splitLine?.lineStyle?.color || '#f5f5f7')
ctx.setLineWidth(1)
yTicks.forEach((tickVal, i) => {
const y = gridTop + drawHeight - (i * drawHeight / (yTicks.length - 1))
// 绘制网格线
ctx.beginPath()
ctx.moveTo(gridLeft, y)
ctx.lineTo(width - gridRight, y)
ctx.stroke()
// Y轴显示纯整数(无小数点、无任何数值限制)
ctx.setFillStyle(yAxis.axisLabel?.color || '#666')
ctx.setFontSize(yAxis.axisLabel?.fontSize || 12)
const valText = tickVal.toString() // 纯整数,直接转字符串
const textWidth = ctx.measureText(valText).width
ctx.fillText(valText, gridLeft - 10 - textWidth, y + 5)
})
// 绘制X轴标签(自适应)
ctx.setFillStyle(xAxis.axisLabel?.color || '#666')
ctx.setFontSize(xAxis.axisLabel?.fontSize || 12)
this.categories.forEach((text, index) => {
if (index % labelInterval === 0) {
const x = gridLeft + index * xStep
let labelText = text
if (text.length > 6) {
labelText = text.slice(0, 6) + '...'
}
const textLines = labelText.split('\n')
textLines.forEach((line, lineIdx) => {
const textWidth = ctx.measureText(line).width
ctx.fillText(line, x - textWidth / 2, height - gridBottom + 20 + (lineIdx * 12))
})
}
})
// 绘制图例(核心修复:颜色块和文字垂直居中对齐)
if (legend.show) {
// 获取图例文字大小(统一基准)
const legendFontSize = legend.textStyle?.fontSize || 12
ctx.setFontSize(legendFontSize)
// 定义颜色块尺寸(和文字高度匹配)
const legendBlockSize = legendFontSize * 0.8 // 颜色块大小 = 文字大小的80%,视觉更协调
const legendBlockMargin = 5 // 颜色块和文字的间距
let legendX = gridLeft + 10
// 计算垂直居中基准线
const legendYBase = gridTop - 30
this.data.forEach((series, idx) => {
let seriesName = series.name || `系列${idx + 1}`
if (seriesName.length > 8) {
seriesName = seriesName.slice(0, 8) + '...'
}
const textWidth = ctx.measureText(seriesName).width
// 核心:计算颜色块和文字的垂直居中位置
// 颜色块Y坐标 = 基准线 - 颜色块高度/2(居中)
const legendBlockY = legendYBase - legendBlockSize / 2
// 文字Y坐标 = 基准线 + 文字高度/4(canvas文字居中的黄金比例)
const legendTextY = legendYBase + legendFontSize / 4
// 绘制颜色块(居中对齐)
ctx.setFillStyle(series.color || color[idx % color.length])
ctx.fillRect(legendX, legendBlockY, legendBlockSize, legendBlockSize)
// 绘制文字(和颜色块垂直居中)
ctx.setFillStyle('#666')
ctx.fillText(seriesName, legendX + legendBlockSize + legendBlockMargin, legendTextY)
// 更新下一个图例的X坐标(包含间距)
legendX += legendBlockSize + legendBlockMargin + textWidth + 10
})
}
// 绘制折线(纯直线,无数值限制)
this.data.forEach((series, seriesIdx) => {
const seriesColor = series.color || color[seriesIdx % color.length]
ctx.setStrokeStyle(seriesColor)
ctx.setLineWidth(2)
ctx.beginPath()
series.data.forEach((value, index) => {
const x = gridLeft + index * xStep
// 自适应计算Y坐标(无数值限制)
const y = gridTop + drawHeight - ((value - alignedMinVal) / valRange) * drawHeight
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
})
ctx.draw()
},
// K线图绘制(同步无限制逻辑 + 图例对齐修复)
drawKline () {
const ctx = uni.createCanvasContext('uni-charts', this)
this.ctx = ctx
const { width, height } = this
const { grid = {}, xAxis = {}, yAxis = {}, color = ['#B34C17', '#25AF69'] } = this.option
ctx.clearRect(0, 0, width, height)
// Grid布局(自适应)
const gridTop = grid.top ? (typeof grid.top === 'string' ? parseFloat(grid.top) / 100 * height : grid.top) : 50
const gridLeft = grid.left ? (typeof grid.left === 'string' ? parseFloat(grid.left) / 100 * width : grid.left) : 70
const gridRight = grid.right ? (typeof grid.right === 'string' ? parseFloat(grid.right) / 100 * width : grid.right) : 20
const gridBottom = grid.bottom ? (typeof grid.bottom === 'string' ? parseFloat(grid.bottom) / 100 * height : grid.bottom) : 40
const drawWidth = width - gridLeft - gridRight
const drawHeight = height - gridTop - gridBottom
// 收集所有Y轴数据(无限制)
let allValues = []
this.data.forEach(item => {
allValues = allValues.concat([item.open, item.high, item.low, item.close])
})
if (allValues.length === 0) return;
// 基础极值(无限制)
const rawMaxVal = Math.max(...allValues)
const rawMinVal = Math.min(...allValues)
const maxVal = yAxis.max !== undefined ? yAxis.max : rawMaxVal
const minVal = yAxis.min !== undefined ? yAxis.min : rawMinVal
const tickCount = yAxis.tickCount || 5
// 核心:无限制整数刻度计算
const yAxisConfig = this.calculateYAxisTicks(minVal, maxVal, tickCount)
const { ticks: yTicks, min: alignedMinVal, max: alignedMaxVal } = yAxisConfig
const valRange = alignedMaxVal - alignedMinVal || 1
const xStep = drawWidth / (this.data.length || 1)
// 绘制网格线和Y轴刻度(无限制)
ctx.setStrokeStyle('#f5f5f7')
ctx.setLineWidth(1)
yTicks.forEach((tickVal, i) => {
const y = gridTop + drawHeight - (i * drawHeight / (yTicks.length - 1))
ctx.beginPath()
ctx.moveTo(gridLeft, y)
ctx.lineTo(width - gridRight, y)
ctx.stroke()
// 纯整数显示(无限制)
ctx.setFillStyle(yAxis.axisLabel?.color || '#666')
ctx.setFontSize(yAxis.axisLabel?.fontSize || 12)
const valText = tickVal.toString()
const textWidth = ctx.measureText(valText).width
ctx.fillText(valText, gridLeft - 10 - textWidth, y + 5)
})
// X轴标签(自适应)
ctx.setFillStyle(xAxis.axisLabel?.color || '#666')
ctx.setFontSize(xAxis.axisLabel?.fontSize || 12)
const minLabelWidth = 30
const maxShowLabels = Math.floor(drawWidth / minLabelWidth)
const labelInterval = maxShowLabels < this.categories.length
? Math.ceil(this.categories.length / maxShowLabels)
: 1
this.categories.forEach((text, index) => {
if (index % labelInterval === 0) {
const x = gridLeft + index * xStep + xStep / 2
let labelText = text
if (text.length > 6) labelText = text.slice(0, 6) + '...'
ctx.fillText(labelText, x - ctx.measureText(labelText).width / 2, height - gridBottom + 20)
}
})
// 绘制K线(无数值限制)
this.data.forEach((item, index) => {
const { open, high, low, close } = item
const x = gridLeft + index * xStep + xStep / 2
// 自适应坐标计算
const yOpen = gridTop + drawHeight - ((open - alignedMinVal) / valRange) * drawHeight
const yClose = gridTop + drawHeight - ((close - alignedMinVal) / valRange) * drawHeight
const yHigh = gridTop + drawHeight - ((high - alignedMinVal) / valRange) * drawHeight
const yLow = gridTop + drawHeight - ((low - alignedMinVal) / valRange) * drawHeight
// 绘制高低线
ctx.setStrokeStyle(close >= open ? color[0] : color[1])
ctx.setLineWidth(1)
ctx.beginPath()
ctx.moveTo(x, yHigh)
ctx.lineTo(x, yLow)
ctx.stroke()
// 绘制实体
const rectWidth = xStep / 3
ctx.setFillStyle(close >= open ? color[0] : color[1])
const rectY = Math.min(yOpen, yClose)
const rectHeight = Math.abs(yClose - yOpen) || 2
ctx.fillRect(x - rectWidth / 2, rectY, rectWidth, rectHeight)
})
ctx.draw()
},
touchStart (e) {
this.touchInfo = {
x: e.changedTouches[0].x,
y: e.changedTouches[0].y,
time: Date.now()
}
},
touchMove (e) {
this.touchInfo.x = e.changedTouches[0].x
this.touchInfo.y = e.changedTouches[0].y
},
touchEnd (e) {
// 可扩展交互逻辑,无限制
}
}
}
</script>
<style scoped>
.uni-charts {
position: relative;
}
.charts-canvas {
display: block;
}
.map-container {
position: relative;
}
</style>