|
|
@@ -11,7 +11,7 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, onMounted, watch } from 'vue'
|
|
|
+import { ref, onMounted, watch, computed, getCurrentInstance } from 'vue'
|
|
|
|
|
|
interface Props {
|
|
|
type: 'bar' | 'line' | 'pie' | 'ring'
|
|
|
@@ -23,11 +23,13 @@ interface Props {
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
height: 200,
|
|
|
- canvasId: 'chart-canvas',
|
|
|
+ canvasId: '',
|
|
|
title: '',
|
|
|
})
|
|
|
|
|
|
-const colors = ['#4f8ef7', '#34c759', '#ff9500', '#ff3b30', '#5856d6', '#ff2d55', '#5ac8fa', '#af52de']
|
|
|
+const colors = ['#368f6f', '#4f8ef7', '#ff9500', '#8e8e93', '#5856d6', '#ff2d55', '#5ac8fa', '#af52de']
|
|
|
+
|
|
|
+const canvasId = computed(() => props.canvasId || `chart-canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
|
|
|
|
let ctx: UniApp.CanvasContext | null = null
|
|
|
|
|
|
@@ -40,36 +42,64 @@ watch(() => props.data, () => {
|
|
|
}, { deep: true })
|
|
|
|
|
|
function drawChart() {
|
|
|
- ctx = uni.createCanvasContext(props.canvasId)
|
|
|
- if (!ctx) return
|
|
|
+ const instance = getCurrentInstance()
|
|
|
+ if (!instance) return
|
|
|
|
|
|
- const { windowWidth } = uni.getSystemInfoSync()
|
|
|
- const canvasWidth = windowWidth - 32 // 减去padding
|
|
|
- const canvasHeight = props.height
|
|
|
+ const query = uni.createSelectorQuery().in(instance.proxy)
|
|
|
+ query.select('.chart-container').boundingClientRect((rect: any) => {
|
|
|
+ const windowWidth = uni.getSystemInfoSync().windowWidth
|
|
|
+ const pageMaxWidth = 430
|
|
|
+ const rectWidth = rect ? rect.width : windowWidth - 64
|
|
|
+ const cssWidth = Math.min(rectWidth, pageMaxWidth - 64, windowWidth - 64)
|
|
|
+ const cssHeight = props.height
|
|
|
|
|
|
- ctx.clearRect(0, 0, canvasWidth, canvasHeight)
|
|
|
+ ctx = uni.createCanvasContext(canvasId.value)
|
|
|
+ if (!ctx) return
|
|
|
|
|
|
- if (props.type === 'pie' || props.type === 'ring') {
|
|
|
- drawPie(canvasWidth, canvasHeight)
|
|
|
- } else if (props.type === 'bar') {
|
|
|
- drawBar(canvasWidth, canvasHeight)
|
|
|
- } else if (props.type === 'line') {
|
|
|
- drawLine(canvasWidth, canvasHeight)
|
|
|
- }
|
|
|
+ ctx.clearRect(0, 0, cssWidth, cssHeight)
|
|
|
+
|
|
|
+ if (!props.data || props.data.length === 0) {
|
|
|
+ drawEmpty(cssWidth, cssHeight)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (props.type === 'pie' || props.type === 'ring') {
|
|
|
+ drawPie(cssWidth, cssHeight)
|
|
|
+ } else if (props.type === 'bar') {
|
|
|
+ drawBar(cssWidth, cssHeight)
|
|
|
+ } else if (props.type === 'line') {
|
|
|
+ drawLine(cssWidth, cssHeight)
|
|
|
+ }
|
|
|
|
|
|
+ ctx.draw()
|
|
|
+ })
|
|
|
+ query.exec()
|
|
|
+}
|
|
|
+
|
|
|
+function drawEmpty(width: number, height: number) {
|
|
|
+ if (!ctx) return
|
|
|
+ ctx.setFontSize(14)
|
|
|
+ ctx.setFillStyle('#9ca3af')
|
|
|
+ ctx.setTextAlign('center')
|
|
|
+ ctx.fillText('暂无数据', width / 2, height / 2)
|
|
|
ctx.draw()
|
|
|
}
|
|
|
|
|
|
function drawPie(width: number, height: number) {
|
|
|
if (!ctx) return
|
|
|
const total = props.data.reduce((sum, item) => sum + item.value, 0)
|
|
|
- let startAngle = -Math.PI / 2
|
|
|
+ const isRing = props.type === 'ring'
|
|
|
+ const padding = 16
|
|
|
+ const legendHeight = 28
|
|
|
+ const chartHeight = height - legendHeight
|
|
|
const centerX = width / 2
|
|
|
- const centerY = height / 2
|
|
|
- const radius = Math.min(centerX, centerY) - 40
|
|
|
+ const centerY = chartHeight / 2
|
|
|
+ const radius = Math.min(centerX, centerY) - padding
|
|
|
+
|
|
|
+ let startAngle = -Math.PI / 2
|
|
|
|
|
|
props.data.forEach((item, index) => {
|
|
|
- const angle = (item.value / total) * Math.PI * 2
|
|
|
+ const angle = total === 0 ? 0 : (item.value / total) * Math.PI * 2
|
|
|
const endAngle = startAngle + angle
|
|
|
const color = item.color || colors[index % colors.length]
|
|
|
|
|
|
@@ -80,117 +110,226 @@ function drawPie(width: number, height: number) {
|
|
|
ctx!.setFillStyle(color)
|
|
|
ctx!.fill()
|
|
|
|
|
|
- // 标签
|
|
|
- const midAngle = startAngle + angle / 2
|
|
|
- const labelX = centerX + Math.cos(midAngle) * (radius + 15)
|
|
|
- const labelY = centerY + Math.sin(midAngle) * (radius + 15)
|
|
|
- ctx!.setFontSize(10)
|
|
|
- ctx!.setFillStyle('#666')
|
|
|
- ctx!.fillText(`${item.label}`, labelX - 15, labelY)
|
|
|
- ctx!.fillText(`${item.value}`, labelX - 5, labelY + 12)
|
|
|
-
|
|
|
startAngle = endAngle
|
|
|
})
|
|
|
|
|
|
- // 如果是 ring 类型,绘制中心圆
|
|
|
- if (props.type === 'ring') {
|
|
|
+ if (isRing) {
|
|
|
ctx!.beginPath()
|
|
|
- ctx!.arc(centerX, centerY, radius * 0.6, 0, Math.PI * 2)
|
|
|
- ctx!.setFillStyle('#fff')
|
|
|
+ ctx!.arc(centerX, centerY, radius * 0.55, 0, Math.PI * 2)
|
|
|
+ ctx!.setFillStyle('rgba(255, 255, 255, 0.85)')
|
|
|
ctx!.fill()
|
|
|
- ctx!.setFontSize(14)
|
|
|
- ctx!.setFillStyle('#333')
|
|
|
+
|
|
|
+ ctx!.setFontSize(12)
|
|
|
+ ctx!.setFillStyle('#6b7280')
|
|
|
ctx!.setTextAlign('center')
|
|
|
- ctx!.fillText('总计', centerX, centerY - 5)
|
|
|
- ctx!.fillText(String(total), centerX, centerY + 15)
|
|
|
+ ctx!.fillText('总计', centerX, centerY - 6)
|
|
|
+
|
|
|
+ ctx!.setFontSize(18)
|
|
|
+ ctx!.setFillStyle('#1f2937')
|
|
|
+ ctx!.setTextAlign('center')
|
|
|
+ ctx!.fillText(String(total), centerX, centerY + 16)
|
|
|
}
|
|
|
+
|
|
|
+ // 图例
|
|
|
+ drawLegend(width, height - legendHeight + 10)
|
|
|
+}
|
|
|
+
|
|
|
+function drawLegend(width: number, y: number) {
|
|
|
+ if (!ctx) return
|
|
|
+ const total = props.data.reduce((sum, item) => sum + item.value, 0)
|
|
|
+ const itemWidth = width / props.data.length
|
|
|
+
|
|
|
+ props.data.forEach((item, index) => {
|
|
|
+ const color = item.color || colors[index % colors.length]
|
|
|
+ const x = index * itemWidth + itemWidth / 2
|
|
|
+
|
|
|
+ ctx!.setFillStyle(color)
|
|
|
+ ctx!.fillRect(x - 28, y, 8, 8)
|
|
|
+
|
|
|
+ ctx!.setFontSize(10)
|
|
|
+ ctx!.setFillStyle('#6b7280')
|
|
|
+ ctx!.setTextAlign('left')
|
|
|
+ ctx!.fillText(item.label, x - 16, y + 8)
|
|
|
+
|
|
|
+ ctx!.setFontSize(10)
|
|
|
+ ctx!.setFillStyle('#1f2937')
|
|
|
+ ctx!.fillText(String(item.value), x + 12, y + 8)
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
function drawBar(width: number, height: number) {
|
|
|
if (!ctx) return
|
|
|
- const padding = 30
|
|
|
- const chartHeight = height - padding * 2
|
|
|
- const chartWidth = width - padding * 2
|
|
|
+ const padding = { top: 24, right: 16, bottom: 36, left: 40 }
|
|
|
+ const chartHeight = height - padding.top - padding.bottom
|
|
|
+ const chartWidth = width - padding.left - padding.right
|
|
|
const maxValue = Math.max(...props.data.map(d => d.value))
|
|
|
- const barWidth = (chartWidth / props.data.length) * 0.6
|
|
|
- const gap = (chartWidth / props.data.length) * 0.4
|
|
|
+ const safeMax = maxValue <= 0 ? 1 : maxValue * 1.1
|
|
|
+ const barWidth = (chartWidth / props.data.length) * 0.55
|
|
|
+ const gap = (chartWidth / props.data.length) * 0.45
|
|
|
+
|
|
|
+ // 网格线
|
|
|
+ ctx.setStrokeStyle('rgba(164, 216, 152, 0.25)')
|
|
|
+ ctx.setLineWidth(1)
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const y = padding.top + (chartHeight / 4) * i
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(padding.left, y)
|
|
|
+ ctx.lineTo(width - padding.right, y)
|
|
|
+ ctx.stroke()
|
|
|
+ }
|
|
|
|
|
|
- // 绘制坐标轴
|
|
|
- ctx.setStrokeStyle('#e0e0e0')
|
|
|
+ // 坐标轴
|
|
|
+ ctx.setStrokeStyle('#d1d5db')
|
|
|
ctx.beginPath()
|
|
|
- ctx.moveTo(padding, padding)
|
|
|
- ctx.lineTo(padding, height - padding)
|
|
|
- ctx.lineTo(width - padding, height - padding)
|
|
|
+ ctx.moveTo(padding.left, padding.top)
|
|
|
+ ctx.lineTo(padding.left, height - padding.bottom)
|
|
|
+ ctx.lineTo(width - padding.right, height - padding.bottom)
|
|
|
ctx.stroke()
|
|
|
|
|
|
- // 绘制柱状图
|
|
|
+ // Y 轴刻度
|
|
|
+ ctx.setFontSize(10)
|
|
|
+ ctx.setFillStyle('#9ca3af')
|
|
|
+ ctx.setTextAlign('right')
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const value = Math.round((safeMax / 4) * (4 - i))
|
|
|
+ const y = padding.top + (chartHeight / 4) * i + 4
|
|
|
+ ctx.fillText(String(value), padding.left - 6, y)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 柱状图
|
|
|
props.data.forEach((item, index) => {
|
|
|
- const x = padding + gap / 2 + index * (barWidth + gap)
|
|
|
- const barHeight = (item.value / maxValue) * chartHeight
|
|
|
- const y = height - padding - barHeight
|
|
|
+ const x = padding.left + gap / 2 + index * (barWidth + gap)
|
|
|
+ const barHeight = (item.value / safeMax) * chartHeight
|
|
|
+ const y = height - padding.bottom - barHeight
|
|
|
const color = item.color || colors[index % colors.length]
|
|
|
|
|
|
ctx!.setFillStyle(color)
|
|
|
ctx!.fillRect(x, y, barWidth, barHeight)
|
|
|
|
|
|
+ // 圆角顶部
|
|
|
+ ctx!.beginPath()
|
|
|
+ ctx!.arc(x + barWidth / 2, y, barWidth / 2, Math.PI, 0)
|
|
|
+ ctx!.setFillStyle(color)
|
|
|
+ ctx!.fill()
|
|
|
+
|
|
|
// 数值
|
|
|
- ctx!.setFontSize(10)
|
|
|
- ctx!.setFillStyle('#333')
|
|
|
- ctx!.fillText(String(item.value), x + barWidth / 2 - 5, y - 5)
|
|
|
+ if (item.value > 0) {
|
|
|
+ ctx!.setFontSize(10)
|
|
|
+ ctx!.setFillStyle('#1f2937')
|
|
|
+ ctx!.setTextAlign('center')
|
|
|
+ ctx!.fillText(String(item.value), x + barWidth / 2, y - 6)
|
|
|
+ }
|
|
|
|
|
|
// 标签
|
|
|
ctx!.setFontSize(10)
|
|
|
- ctx!.setFillStyle('#666')
|
|
|
- ctx!.fillText(item.label, x, height - padding + 15)
|
|
|
+ ctx!.setFillStyle('#6b7280')
|
|
|
+ ctx!.setTextAlign('center')
|
|
|
+ ctx!.fillText(item.label, x + barWidth / 2, height - padding.bottom + 14)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
function drawLine(width: number, height: number) {
|
|
|
if (!ctx) return
|
|
|
- const padding = 30
|
|
|
- const chartHeight = height - padding * 2
|
|
|
- const chartWidth = width - padding * 2
|
|
|
+ const padding = { top: 24, right: 16, bottom: 36, left: 40 }
|
|
|
+ const chartHeight = height - padding.top - padding.bottom
|
|
|
+ const chartWidth = width - padding.left - padding.right
|
|
|
const maxValue = Math.max(...props.data.map(d => d.value))
|
|
|
- const pointGap = chartWidth / (props.data.length - 1)
|
|
|
+ const allZero = maxValue <= 0
|
|
|
+ const safeMax = allZero ? 1 : maxValue * 1.1
|
|
|
+ const pointGap = props.data.length > 1 ? chartWidth / (props.data.length - 1) : chartWidth / 2
|
|
|
+
|
|
|
+ // 网格线
|
|
|
+ ctx.setStrokeStyle('rgba(164, 216, 152, 0.25)')
|
|
|
+ ctx.setLineWidth(1)
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const y = padding.top + (chartHeight / 4) * i
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(padding.left, y)
|
|
|
+ ctx.lineTo(width - padding.right, y)
|
|
|
+ ctx.stroke()
|
|
|
+ }
|
|
|
|
|
|
- // 绘制坐标轴
|
|
|
- ctx.setStrokeStyle('#e0e0e0')
|
|
|
+ // 坐标轴
|
|
|
+ ctx.setStrokeStyle('#d1d5db')
|
|
|
ctx.beginPath()
|
|
|
- ctx.moveTo(padding, padding)
|
|
|
- ctx.lineTo(padding, height - padding)
|
|
|
- ctx.lineTo(width - padding, height - padding)
|
|
|
+ ctx.moveTo(padding.left, padding.top)
|
|
|
+ ctx.lineTo(padding.left, height - padding.bottom)
|
|
|
+ ctx.lineTo(width - padding.right, height - padding.bottom)
|
|
|
ctx.stroke()
|
|
|
|
|
|
- // 绘制线条
|
|
|
- ctx.setStrokeStyle('#4f8ef7')
|
|
|
- ctx.setLineWidth(2)
|
|
|
- ctx.beginPath()
|
|
|
+ // Y 轴刻度
|
|
|
+ ctx.setFontSize(10)
|
|
|
+ ctx.setFillStyle('#9ca3af')
|
|
|
+ ctx.setTextAlign('right')
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const value = Math.round((safeMax / 4) * (4 - i))
|
|
|
+ const y = padding.top + (chartHeight / 4) * i + 4
|
|
|
+ ctx.fillText(String(value), padding.left - 6, y)
|
|
|
+ }
|
|
|
|
|
|
const points: { x: number; y: number }[] = []
|
|
|
props.data.forEach((item, index) => {
|
|
|
- const x = padding + index * pointGap
|
|
|
- const y = height - padding - (item.value / maxValue) * chartHeight
|
|
|
+ const x = padding.left + index * pointGap
|
|
|
+ const y = height - padding.bottom - (item.value / safeMax) * chartHeight
|
|
|
points.push({ x, y })
|
|
|
- if (index === 0) {
|
|
|
- ctx!.moveTo(x, y)
|
|
|
- } else {
|
|
|
- ctx!.lineTo(x, y)
|
|
|
- }
|
|
|
})
|
|
|
- ctx.stroke()
|
|
|
|
|
|
- // 绘制数据点和数值
|
|
|
- points.forEach((point, index) => {
|
|
|
- ctx!.beginPath()
|
|
|
- ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
|
|
|
- ctx!.setFillStyle('#4f8ef7')
|
|
|
- ctx!.fill()
|
|
|
+ if (allZero) {
|
|
|
+ ctx.setFontSize(12)
|
|
|
+ ctx.setFillStyle('#9ca3af')
|
|
|
+ ctx.setTextAlign('center')
|
|
|
+ ctx.fillText('暂无数据', width / 2, height / 2)
|
|
|
+ } else {
|
|
|
+ // 填充面积
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(points[0].x, height - padding.bottom)
|
|
|
+ points.forEach((point) => {
|
|
|
+ ctx!.lineTo(point.x, point.y)
|
|
|
+ })
|
|
|
+ ctx.lineTo(points[points.length - 1].x, height - padding.bottom)
|
|
|
+ ctx.closePath()
|
|
|
+ ctx.setFillStyle('rgba(54, 143, 111, 0.12)')
|
|
|
+ ctx.fill()
|
|
|
+
|
|
|
+ // 绘制线条
|
|
|
+ ctx.setStrokeStyle('#368f6f')
|
|
|
+ ctx.setLineWidth(2)
|
|
|
+ ctx.beginPath()
|
|
|
+ points.forEach((point, index) => {
|
|
|
+ if (index === 0) {
|
|
|
+ ctx!.moveTo(point.x, point.y)
|
|
|
+ } else {
|
|
|
+ ctx!.lineTo(point.x, point.y)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ ctx.stroke()
|
|
|
+
|
|
|
+ // 数据点和数值
|
|
|
+ points.forEach((point, index) => {
|
|
|
+ ctx!.beginPath()
|
|
|
+ ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
|
|
|
+ ctx!.setFillStyle('#ffffff')
|
|
|
+ ctx!.fill()
|
|
|
+
|
|
|
+ ctx!.beginPath()
|
|
|
+ ctx!.arc(point.x, point.y, 4, 0, Math.PI * 2)
|
|
|
+ ctx!.setStrokeStyle('#368f6f')
|
|
|
+ ctx!.setLineWidth(2)
|
|
|
+ ctx!.stroke()
|
|
|
+
|
|
|
+ ctx!.setFontSize(10)
|
|
|
+ ctx!.setFillStyle('#1f2937')
|
|
|
+ ctx!.setTextAlign('center')
|
|
|
+ ctx!.fillText(String(props.data[index].value), point.x, point.y - 10)
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
+ // X 轴标签
|
|
|
+ points.forEach((point, index) => {
|
|
|
ctx!.setFontSize(10)
|
|
|
- ctx!.setFillStyle('#333')
|
|
|
- ctx!.fillText(String(props.data[index].value), point.x - 5, point.y - 10)
|
|
|
- ctx!.setFillStyle('#666')
|
|
|
- ctx!.fillText(props.data[index].label, point.x - 10, height - padding + 15)
|
|
|
+ ctx!.setFillStyle('#6b7280')
|
|
|
+ ctx!.setTextAlign('center')
|
|
|
+ ctx!.fillText(props.data[index].label, point.x, height - padding.bottom + 14)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -204,3 +343,9 @@ function touchMove(e: any) {
|
|
|
// 可以添加滑动交互
|
|
|
}
|
|
|
</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.chart-container {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+</style>
|