|
|
@@ -0,0 +1,489 @@
|
|
|
+<template>
|
|
|
+ <div class="echart_content">
|
|
|
+ <div class="is_show_all" v-if="showCheckBox">
|
|
|
+ <el-checkbox v-model="checkAll" :indeterminate="isIndeterminate" @change="ChangeCheckAll">
|
|
|
+ 显示全部
|
|
|
+ </el-checkbox>
|
|
|
+ </div>
|
|
|
+ <div class="echart_type">
|
|
|
+ <div class="chart_icon_item" :class="chartType === 'line_chart' ? 'line_chart_cur' : 'line_chart'"
|
|
|
+ @click="ChangeChartType('line_chart')">
|
|
|
+ 折线图
|
|
|
+ </div>
|
|
|
+ <div class="chart_icon_item" :class="chartType === 'vertical_bar' ? 'vertical_bar_cur' : 'vertical_bar'
|
|
|
+ " @click="ChangeChartType('vertical_bar')">
|
|
|
+ 柱状图
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div ref="line_chart" class="chart_box"></div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
|
|
+import { throttle } from "lodash-es";
|
|
|
+import * as echarts from "echarts";
|
|
|
+import type { ECharts } from "echarts";
|
|
|
+
|
|
|
+// 类型定义
|
|
|
+type ChartType = "line_chart" | "vertical_bar";
|
|
|
+
|
|
|
+interface LegendSelected {
|
|
|
+ [key: string]: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+// Props 定义
|
|
|
+const props = defineProps({
|
|
|
+ gridRight: { type: Number, default: 0 },
|
|
|
+ showCheckBox: { type: Boolean, default: true },
|
|
|
+ extraText: { type: Boolean, default: true },
|
|
|
+ datax: {
|
|
|
+ type: Array<string>,
|
|
|
+ default: () => [
|
|
|
+ "G10",
|
|
|
+ "G9",
|
|
|
+ "G8",
|
|
|
+ "G7",
|
|
|
+ "G6",
|
|
|
+ "G5",
|
|
|
+ "G4",
|
|
|
+ "G3",
|
|
|
+ "G2",
|
|
|
+ "G1",
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ datay: {
|
|
|
+ type: Array<Array<number>>,
|
|
|
+ default: () => [
|
|
|
+ [12, 5, 8, 10, 15, 7, 9, 11, 13, 10],
|
|
|
+ [6, 14, 9, 10, 12, 8, 7, 11, 15, 8],
|
|
|
+ [5, 10, 15, 7, 9, 12, 8, 13, 14, 7],
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ tooltipData: { type: Array<any>, default: () => [] },
|
|
|
+ colors: { type: Array<string>, default: () => [] },
|
|
|
+ unit: { type: String, default: "" },
|
|
|
+ title: { type: Array<string>, default: () => [] },
|
|
|
+ isSetMarkNumber: { type: Boolean, default: false },
|
|
|
+ markNumber: { type: Array<number>, default: () => [65] },
|
|
|
+ isSmooth: { type: Boolean, default: false },
|
|
|
+ showMarkLine: { type: Boolean, default: false },
|
|
|
+ showBackground: { type: Boolean, default: true },
|
|
|
+ isShowLabel: { type: Boolean, default: true },
|
|
|
+ showMarkPoint: { type: Boolean, default: false },
|
|
|
+ legendList: { type: Array<string>, default: () => [] },
|
|
|
+ hideOverlap: { type: Boolean, default: false },
|
|
|
+});
|
|
|
+
|
|
|
+// 响应式数据
|
|
|
+const line_chart = ref<HTMLElement | null>(null);
|
|
|
+let echartInstance: ECharts | null = null;
|
|
|
+
|
|
|
+const checkAll = ref<boolean>(true);
|
|
|
+const isIndeterminate = ref<boolean>(false);
|
|
|
+const legendSelected = ref<LegendSelected>({});
|
|
|
+const legenSelectList = ref<string[]>([]);
|
|
|
+const legenAllList = ref<string[]>([...props.title]);
|
|
|
+const chartType = ref<ChartType>("line_chart");
|
|
|
+const dataZoomEnd = ref<number>(0);
|
|
|
+const dataZoomNum = ref<number>(5);
|
|
|
+
|
|
|
+// 监听数据变化
|
|
|
+watch(
|
|
|
+ () => props.datay,
|
|
|
+ () => {
|
|
|
+ LoadEchart();
|
|
|
+ },
|
|
|
+ { deep: true },
|
|
|
+);
|
|
|
+
|
|
|
+// 辅助线配置
|
|
|
+const markLine = (index: number) => {
|
|
|
+ if (!props.showMarkLine) return {};
|
|
|
+ return {
|
|
|
+ data: [
|
|
|
+ {
|
|
|
+ type: "average",
|
|
|
+ name: "平均值",
|
|
|
+ yAxis: props.isSetMarkNumber ? props.markNumber[index] : null,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ label: {
|
|
|
+ position: "end",
|
|
|
+ formatter: (params: any) =>
|
|
|
+ props.isSetMarkNumber ? props.markNumber[index] : params.value,
|
|
|
+ },
|
|
|
+ lineStyle: { type: "dashed", color: props.colors[index] },
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// 切换图表类型
|
|
|
+const ChangeChartType = (val: ChartType) => {
|
|
|
+ chartType.value = val;
|
|
|
+ LoadEchart();
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化图表
|
|
|
+const LoadEchart = () => {
|
|
|
+ if (!line_chart.value) return;
|
|
|
+ if (echartInstance) {
|
|
|
+ echartInstance.dispose();
|
|
|
+ echartInstance = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ echartInstance = echarts.init(line_chart.value, { devicePixelRatio: 2 });
|
|
|
+
|
|
|
+ // 颜色补齐
|
|
|
+ let customColors = [...props.colors];
|
|
|
+ const colorsList = [
|
|
|
+ "#5470C6",
|
|
|
+ "#91CC75",
|
|
|
+ "#FAC858",
|
|
|
+ "#EE6666",
|
|
|
+ "#73C0DE",
|
|
|
+ "#3BA272",
|
|
|
+ "#FC8452",
|
|
|
+ "#9A60B4",
|
|
|
+ "#EA7CCC",
|
|
|
+ "#66C",
|
|
|
+ "#C33",
|
|
|
+ "#FC0",
|
|
|
+ "#6C0",
|
|
|
+ "#09C",
|
|
|
+ ];
|
|
|
+ while (customColors.length < props.title.length)
|
|
|
+ customColors = customColors.concat(colorsList);
|
|
|
+
|
|
|
+ // 图例选中状态
|
|
|
+ legenAllList.value = [...props.title];
|
|
|
+ legendSelected.value = props.title.reduce((acc, dim) => {
|
|
|
+ acc[dim] = false;
|
|
|
+ return acc;
|
|
|
+ }, {} as LegendSelected);
|
|
|
+
|
|
|
+ legenSelectList.value.forEach((item) => {
|
|
|
+ legendSelected.value[item] = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ checkAll.value = legenSelectList.value.length === legenAllList.value.length;
|
|
|
+ isIndeterminate.value =
|
|
|
+ legenSelectList.value.length > 0 &&
|
|
|
+ legenSelectList.value.length < legenAllList.value.length;
|
|
|
+
|
|
|
+ // 计算宽度与滚动条
|
|
|
+ const numSeries = Object.keys(legendSelected.value).filter(
|
|
|
+ (k) => legendSelected.value[k],
|
|
|
+ ).length;
|
|
|
+ const totalWidth = line_chart.value.clientWidth || 0;
|
|
|
+ let isShowDataZoom = false;
|
|
|
+ const barMinWidth = 30;
|
|
|
+ dataZoomNum.value = Math.floor((totalWidth - 60) / (barMinWidth * numSeries));
|
|
|
+ const echartWidth = (props.datax.length - 1) * barMinWidth * numSeries;
|
|
|
+ let singleSeriesWidth =
|
|
|
+ ((totalWidth - 60) / (props.datax.length - 1)) * numSeries - 60;
|
|
|
+ dataZoomEnd.value = Math.floor(
|
|
|
+ (100 / (props.datax.length - 1)) * dataZoomNum.value,
|
|
|
+ );
|
|
|
+ if (echartWidth > totalWidth) isShowDataZoom = true;
|
|
|
+ if (isShowDataZoom) singleSeriesWidth = 25 * legenSelectList.value.length;
|
|
|
+
|
|
|
+ // 滚动条配置
|
|
|
+ const dataZoomConfig = isShowDataZoom
|
|
|
+ ? {
|
|
|
+ start: 0,
|
|
|
+ end: dataZoomEnd.value,
|
|
|
+ type: "slider",
|
|
|
+ show: true,
|
|
|
+ borderColor: "transparent",
|
|
|
+ xAxisIndex: [0],
|
|
|
+ height: 8,
|
|
|
+ left: 20,
|
|
|
+ right: 20,
|
|
|
+ bottom: 0,
|
|
|
+ fillerColor: "transparent",
|
|
|
+ zoomLock: true,
|
|
|
+ handleSize: "0",
|
|
|
+ backgroundColor: "transparent",
|
|
|
+ showDataShadow: false,
|
|
|
+ showDetail: false,
|
|
|
+ filterMode: "filter",
|
|
|
+ }
|
|
|
+ : [];
|
|
|
+
|
|
|
+ const legendWidth = totalWidth - 270;
|
|
|
+ const extraText = props.extraText ? "占比" : "";
|
|
|
+
|
|
|
+ // 柱状图配置
|
|
|
+ const barOption: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: "axis",
|
|
|
+ axisPointer: { type: "line" },
|
|
|
+ renderMode: "html",
|
|
|
+ confine: true,
|
|
|
+ extraCssText:
|
|
|
+ "border-radius:4px; padding:5px; white-space:normal; max-width:400px;",
|
|
|
+ enterable: true,
|
|
|
+ formatter: (params: any) => {
|
|
|
+ let tip = `<div class='tooltip_content'><div class='tooltip_title'>${params[0]?.name}</div>`;
|
|
|
+ params.forEach((item: any) => {
|
|
|
+ const val = props.tooltipData.length
|
|
|
+ ? props.tooltipData[item.seriesIndex]?.[item.dataIndex] || ""
|
|
|
+ : `${item.value}${props.unit}`;
|
|
|
+ tip += `<div class='tooltip_student'>
|
|
|
+ <div class='tooltip_rect_icon' style='background:${item.color}'></div>
|
|
|
+ <div class='tooltip_student_name'>${item.seriesName}${extraText}:${val}</div>
|
|
|
+ </div>`;
|
|
|
+ });
|
|
|
+ return tip + "</div>";
|
|
|
+ },
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ show: true,
|
|
|
+ top: '6px',
|
|
|
+ left: props.showCheckBox ? "110px" : "0px",
|
|
|
+ width: legendWidth + "px",
|
|
|
+ itemGap: 20,
|
|
|
+ itemHeight: 10,
|
|
|
+ itemWidth: 25,
|
|
|
+ data: props.title,
|
|
|
+ textStyle: { fontSize: 12, color: "#333" },
|
|
|
+ selected: legendSelected.value,
|
|
|
+ type: "scroll",
|
|
|
+ pageButtonPosition: "end",
|
|
|
+ orient: "horizontal",
|
|
|
+ },
|
|
|
+ dataZoom: dataZoomConfig,
|
|
|
+ grid: { left: 30, right: 20, top: 50, bottom: 0, containLabel: true },
|
|
|
+ xAxis: {
|
|
|
+ type: "category",
|
|
|
+ data: props.datax,
|
|
|
+ boundaryGap: true,
|
|
|
+ axisTick: { alignWithLabel: true },
|
|
|
+ axisLabel: {
|
|
|
+ interval: 0,
|
|
|
+ rotate: singleSeriesWidth < 80 ? 45 : 0,
|
|
|
+ textStyle: { fontSize: 14, color: "#333" },
|
|
|
+ formatter: (val: string) => {
|
|
|
+ const w = val.length * 10;
|
|
|
+ if (w > singleSeriesWidth) return val.slice(0, 6) + "...";
|
|
|
+ return val;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: "value",
|
|
|
+ axisLabel: { formatter: `{value}${props.unit}`, fontSize: 14 },
|
|
|
+ splitLine: { lineStyle: { color: "#E4E7ED" } },
|
|
|
+ splitArea: { show: true, areaStyle: { color: ["#fafafa", "#fff"] } },
|
|
|
+ },
|
|
|
+ series: props.datay.map((data, i) => ({
|
|
|
+ type: "bar",
|
|
|
+ name: props.title[i],
|
|
|
+ barGap: 0,
|
|
|
+ barCategoryGap: "30%",
|
|
|
+ barMinWidth: 20,
|
|
|
+ barMaxWidth: 50,
|
|
|
+ itemStyle: { color: customColors[i] },
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: "top",
|
|
|
+ fontSize: 12,
|
|
|
+ formatter: (p: any) => (p.value === 0 ? "" : `${p.value}人`),
|
|
|
+ },
|
|
|
+ markLine: markLine(i),
|
|
|
+ data,
|
|
|
+ })),
|
|
|
+ };
|
|
|
+
|
|
|
+ // 折线图配置
|
|
|
+ const lineOption: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: "axis",
|
|
|
+ axisPointer: { type: "line" },
|
|
|
+ renderMode: "html",
|
|
|
+ confine: true,
|
|
|
+ extraCssText:
|
|
|
+ "border-radius:4px; padding:5px; white-space:normal; max-width:400px;",
|
|
|
+ enterable: true,
|
|
|
+ formatter: (params: any) => {
|
|
|
+ let tip = `<div class='tooltip_content'><div class='tooltip_title'>${params[0]?.name}</div>`;
|
|
|
+ params.forEach((item: any) => {
|
|
|
+ const val = props.tooltipData.length
|
|
|
+ ? props.tooltipData[item.seriesIndex]?.[item.dataIndex] || ""
|
|
|
+ : `${item.value}${props.unit}`;
|
|
|
+ tip += `<div class='tooltip_student'>
|
|
|
+ <div class='tooltip_line_icon' style='background:${item.color}'></div>
|
|
|
+ <div class='tooltip_student_name'>${item.seriesName}${extraText}:${val}</div>
|
|
|
+ </div>`;
|
|
|
+ });
|
|
|
+ return tip + "</div>";
|
|
|
+ },
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ show: true,
|
|
|
+ top: '6px',
|
|
|
+ left: props.showCheckBox ? "110px" : "0px",
|
|
|
+ width: legendWidth + "px",
|
|
|
+ itemGap: 20,
|
|
|
+ itemHeight: 10,
|
|
|
+ itemWidth: 25,
|
|
|
+ data: props.title,
|
|
|
+ textStyle: { fontSize: 12, color: "#333" },
|
|
|
+ selected: legendSelected.value,
|
|
|
+ type: "scroll",
|
|
|
+ pageButtonPosition: "end",
|
|
|
+ orient: "horizontal",
|
|
|
+ },
|
|
|
+ grid: { left: 30, right: 20, top: 50, bottom: 0, containLabel: true },
|
|
|
+ xAxis: {
|
|
|
+ type: "category",
|
|
|
+ data: props.datax,
|
|
|
+ boundaryGap: true,
|
|
|
+ axisTick: { alignWithLabel: true },
|
|
|
+ axisLabel: {
|
|
|
+ interval: 0,
|
|
|
+ hideOverlap: props.hideOverlap,
|
|
|
+ rotate: totalWidth / props.datax.length < 80 ? 45 : 0,
|
|
|
+ textStyle: { fontSize: 14, color: "#333" },
|
|
|
+ formatter: (val: string) => {
|
|
|
+ const w = val.length * 10;
|
|
|
+ if (w > singleSeriesWidth) return val.slice(0, 6) + "...";
|
|
|
+ return val;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: "value",
|
|
|
+ axisLabel: { formatter: `{value}${props.unit}`, fontSize: 14 },
|
|
|
+ splitLine: { lineStyle: { color: "#E4E7ED" } },
|
|
|
+ splitArea: { show: true, areaStyle: { color: ["#fafafa", "#fff"] } },
|
|
|
+ },
|
|
|
+ series: props.datay.map((data, i) => ({
|
|
|
+ type: "line",
|
|
|
+ name: props.title[i],
|
|
|
+ symbol: "emptyCircle",
|
|
|
+ showSymbol: true,
|
|
|
+ symbolSize: 8,
|
|
|
+ smooth: props.isSmooth,
|
|
|
+ label: { show: false },
|
|
|
+ markLine: markLine(i),
|
|
|
+ markPoint: props.showMarkPoint
|
|
|
+ ? { data: [{ type: "max" }, { type: "min" }] }
|
|
|
+ : undefined,
|
|
|
+ itemStyle: { color: customColors[i] },
|
|
|
+ lineStyle: { width: 4 },
|
|
|
+ data,
|
|
|
+ })),
|
|
|
+ };
|
|
|
+
|
|
|
+ // 渲染
|
|
|
+ chartType.value === "vertical_bar"
|
|
|
+ ? echartInstance.setOption(barOption)
|
|
|
+ : echartInstance.setOption(lineOption);
|
|
|
+
|
|
|
+ echartInstance.on("legendselectchanged", HandleLegendSelectChanged);
|
|
|
+};
|
|
|
+
|
|
|
+// 图例切换
|
|
|
+const HandleLegendSelectChanged = (params: any) => {
|
|
|
+ const sel = params.selected;
|
|
|
+ legenSelectList.value = legenAllList.value.filter((item) => sel[item]);
|
|
|
+ LoadEchart();
|
|
|
+};
|
|
|
+
|
|
|
+// 全选
|
|
|
+const ChangeCheckAll = (value: boolean) => {
|
|
|
+ if (value) {
|
|
|
+ legenSelectList.value = [...legenAllList.value];
|
|
|
+ isIndeterminate.value = false;
|
|
|
+ } else {
|
|
|
+ legenSelectList.value = [];
|
|
|
+ }
|
|
|
+ LoadEchart();
|
|
|
+};
|
|
|
+
|
|
|
+// 窗口缩放
|
|
|
+const HandleResize = throttle(async () => {
|
|
|
+ await nextTick();
|
|
|
+ LoadEchart();
|
|
|
+}, 500);
|
|
|
+
|
|
|
+// 生命周期
|
|
|
+onMounted(() => {
|
|
|
+ if (props.legendList.length) legenSelectList.value = [...props.legendList];
|
|
|
+ LoadEchart();
|
|
|
+ window.addEventListener("resize", HandleResize);
|
|
|
+});
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener("resize", HandleResize);
|
|
|
+ echartInstance?.dispose();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.echart_content {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+ margin: auto;
|
|
|
+ min-height: 360px;
|
|
|
+ height: auto;
|
|
|
+
|
|
|
+ .is_show_all {
|
|
|
+ position: absolute;
|
|
|
+ top: 2px;
|
|
|
+ left: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .chart_box {
|
|
|
+ width: 100%;
|
|
|
+ height: 400px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .echart_type {
|
|
|
+ position: absolute;
|
|
|
+ right: 0;
|
|
|
+ top: 3px;
|
|
|
+ z-index: 7;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 12px;
|
|
|
+
|
|
|
+ .chart_icon_item {
|
|
|
+ font-weight: 400;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #909399;
|
|
|
+ line-height: 16px;
|
|
|
+ background-repeat: no-repeat;
|
|
|
+ background-size: 16px 16px;
|
|
|
+ background-position: 0 50%;
|
|
|
+ text-indent: 20px;
|
|
|
+ text-align: right;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ //折线图
|
|
|
+ &.line_chart {
|
|
|
+ background-image: url("@/assets/chart/line_chart.webp");
|
|
|
+ }
|
|
|
+
|
|
|
+ &.line_chart_cur {
|
|
|
+ font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #2e64fa;
|
|
|
+ background-image: url("@/assets/chart/line_chart_cur.webp");
|
|
|
+ }
|
|
|
+
|
|
|
+ &.vertical_bar {
|
|
|
+ background-image: url("@/assets/chart/vertical_bar.webp");
|
|
|
+ }
|
|
|
+
|
|
|
+ &.vertical_bar_cur {
|
|
|
+ font-weight: 500;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #2e64fa;
|
|
|
+ background-image: url("@/assets/chart/vertical_bar_cur.webp");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|