@katlux/block-charts 0.1.0-beta.73 → 0.1.1-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -0
- package/dist/runtime/components/KAreaChart/KAreaChart.d.vue.ts +2 -0
- package/dist/runtime/components/KAreaChart/KAreaChart.vue +271 -69
- package/dist/runtime/components/KAreaChart/KAreaChart.vue.d.ts +2 -0
- package/dist/runtime/components/KBarChart/KBarChart.d.vue.ts +3 -1
- package/dist/runtime/components/KBarChart/KBarChart.vue +323 -125
- package/dist/runtime/components/KBarChart/KBarChart.vue.d.ts +3 -1
- package/dist/runtime/components/KHeatMap/KHeatMap.d.vue.ts +1 -1
- package/dist/runtime/components/KHeatMap/KHeatMap.vue +56 -21
- package/dist/runtime/components/KHeatMap/KHeatMap.vue.d.ts +1 -1
- package/dist/runtime/components/KLineChart/KLineChart.d.vue.ts +2 -0
- package/dist/runtime/components/KLineChart/KLineChart.vue +179 -48
- package/dist/runtime/components/KLineChart/KLineChart.vue.d.ts +2 -0
- package/dist/runtime/components/KPieChart/KPieChart.vue +32 -29
- package/dist/runtime/components/KScatterChart/KScatterChart.d.vue.ts +2 -0
- package/dist/runtime/components/KScatterChart/KScatterChart.vue +161 -49
- package/dist/runtime/components/KScatterChart/KScatterChart.vue.d.ts +2 -0
- package/dist/runtime/composables/chartUtils.d.ts +8 -0
- package/dist/runtime/composables/chartUtils.js +58 -0
- package/dist/runtime/composables/useChartAnimation.js +7 -1
- package/dist/runtime/composables/useChartAxes.d.ts +4 -2
- package/dist/runtime/composables/useChartAxes.js +32 -13
- package/dist/runtime/composables/useChartCanvas.d.ts +1 -0
- package/dist/runtime/composables/useChartCanvas.js +51 -18
- package/dist/runtime/composables/useChartData.d.ts +13 -7
- package/dist/runtime/composables/useChartData.js +99 -30
- package/dist/runtime/composables/useChartExport.d.ts +1 -1
- package/dist/runtime/composables/useChartExport.js +26 -3
- package/dist/runtime/composables/useChartHitTest.d.ts +2 -2
- package/dist/runtime/composables/useChartHitTest.js +7 -5
- package/dist/runtime/composables/useChartViewport.d.ts +3 -0
- package/dist/runtime/composables/useChartViewport.js +30 -7
- package/package.json +8 -3
|
@@ -9,14 +9,13 @@
|
|
|
9
9
|
canvas(ref="canvasRef" v-show="!isLoading")
|
|
10
10
|
svg.k-chart-svg(v-show="!isLoading" ref="svgRef" :width="width" :height="height")
|
|
11
11
|
g.k-chart-hits
|
|
12
|
-
// Hit detection
|
|
12
|
+
// Hit detection handled via angle calculation in onMouseMove
|
|
13
13
|
|
|
14
14
|
// Dynamic Interaction Layer
|
|
15
15
|
g.k-chart-interaction(v-if="hoveredIndex !== -1 && hoveredSeg")
|
|
16
16
|
path.k-chart-hit-proxy(
|
|
17
17
|
:d="hoveredSeg.d"
|
|
18
18
|
fill="transparent"
|
|
19
|
-
style="cursor: pointer"
|
|
20
19
|
@click="emit('click-point', hoveredSeg.item, hoveredIndex)"
|
|
21
20
|
)
|
|
22
21
|
path(
|
|
@@ -26,18 +25,19 @@
|
|
|
26
25
|
stroke-width="3"
|
|
27
26
|
pointer-events="none"
|
|
28
27
|
)
|
|
29
|
-
.k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="
|
|
28
|
+
.k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="tooltipStyle")
|
|
30
29
|
slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
|
|
31
30
|
span {{ tooltipState.content }}
|
|
32
31
|
</template>
|
|
33
32
|
|
|
34
33
|
<script setup>
|
|
35
|
-
import { ref, watch, computed, onMounted, nextTick } from "vue";
|
|
34
|
+
import { ref, watch, computed, onMounted, nextTick, isRef } from "vue";
|
|
36
35
|
import { useChartCanvas } from "../../composables/useChartCanvas";
|
|
37
36
|
import { useChartSvg } from "../../composables/useChartSvg";
|
|
38
37
|
import { useChartData } from "../../composables/useChartData";
|
|
39
38
|
import { useChartAnimation, easeOut } from "../../composables/useChartAnimation";
|
|
40
39
|
import { useChartExport } from "../../composables/useChartExport";
|
|
40
|
+
import { cleanNumber } from "../../composables/chartUtils";
|
|
41
41
|
const props = defineProps({
|
|
42
42
|
dataProvider: { type: null, required: false },
|
|
43
43
|
labelField: { type: String, required: false, default: "label" },
|
|
@@ -53,17 +53,25 @@ const emit = defineEmits(["click-point", "hover-point"]);
|
|
|
53
53
|
const containerRef = ref(null);
|
|
54
54
|
const canvasRef = ref(null);
|
|
55
55
|
const svgRef = ref(null);
|
|
56
|
-
const { ctx, width, height, clear, setupCanvas } = useChartCanvas();
|
|
56
|
+
const { ctx, width, height, resizeTick, clear, setupCanvas } = useChartCanvas();
|
|
57
57
|
const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg();
|
|
58
58
|
const dataRef = computed(() => props.dataProvider);
|
|
59
|
-
const { items } = useChartData(dataRef);
|
|
59
|
+
const { items, loading: dataLoading } = useChartData(dataRef);
|
|
60
60
|
const { progress, animate } = useChartAnimation();
|
|
61
61
|
const { exportPng, exportSvg: exportSvgFile } = useChartExport();
|
|
62
62
|
const isLoading = computed(() => {
|
|
63
|
-
|
|
63
|
+
const dp = props.dataProvider;
|
|
64
|
+
if (!dp) return dataLoading.value;
|
|
65
|
+
const rawLoading = dp.loading !== void 0 ? isRef(dp.loading) ? dp.loading.value : dp.loading?.value ?? dp.loading : false;
|
|
66
|
+
const rawInitial = dp.initialLoad !== void 0 ? isRef(dp.initialLoad) ? dp.initialLoad.value : dp.initialLoad?.value ?? dp.initialLoad : false;
|
|
67
|
+
return Boolean(dataLoading.value || rawLoading || rawInitial);
|
|
64
68
|
});
|
|
69
|
+
const tooltipStyle = computed(() => ({
|
|
70
|
+
left: `${tooltipState.value.x}px`,
|
|
71
|
+
top: `${tooltipState.value.y}px`
|
|
72
|
+
}));
|
|
65
73
|
const hitSegments = ref([]);
|
|
66
|
-
const total = computed(() => items.value.reduce((s, r) => s +
|
|
74
|
+
const total = computed(() => items.value.reduce((s, r) => s + Math.max(0, cleanNumber(r[props.valueField])), 0));
|
|
67
75
|
const hoveredSeg = computed(() => {
|
|
68
76
|
if (hoveredIndex.value === -1) return null;
|
|
69
77
|
return hitSegments.value[hoveredIndex.value];
|
|
@@ -88,7 +96,7 @@ const draw = () => {
|
|
|
88
96
|
if (!ctx.value) return;
|
|
89
97
|
clear(props.backgroundColor);
|
|
90
98
|
const c = ctx.value;
|
|
91
|
-
const p = easeOut(progress.value);
|
|
99
|
+
const p = props.animated ? easeOut(progress.value) : 1;
|
|
92
100
|
const segs = [];
|
|
93
101
|
const cx = width.value / 2;
|
|
94
102
|
const cy = props.showLegend ? (height.value - 50) / 2 : height.value / 2;
|
|
@@ -96,7 +104,8 @@ const draw = () => {
|
|
|
96
104
|
let startAngle = -Math.PI / 2;
|
|
97
105
|
const tot = total.value || 1;
|
|
98
106
|
items.value.forEach((item, i) => {
|
|
99
|
-
const
|
|
107
|
+
const val = Math.max(0, cleanNumber(item[props.valueField]));
|
|
108
|
+
const slice = val / tot * Math.PI * 2 * p;
|
|
100
109
|
const endAngle = startAngle + slice;
|
|
101
110
|
const color = props.colors[i % props.colors.length];
|
|
102
111
|
const isHovered = hoveredIndex.value === i;
|
|
@@ -113,13 +122,13 @@ const draw = () => {
|
|
|
113
122
|
const lr = r * 0.7;
|
|
114
123
|
const lx = cx + lr * Math.cos(midAngle);
|
|
115
124
|
const ly = cy + lr * Math.sin(midAngle);
|
|
116
|
-
if (p > 0.5) {
|
|
125
|
+
if (p > 0.5 && val > 0) {
|
|
117
126
|
c.save();
|
|
118
127
|
c.fillStyle = "#fff";
|
|
119
128
|
c.font = "bold 11px system-ui, sans-serif";
|
|
120
129
|
c.textAlign = "center";
|
|
121
130
|
c.textBaseline = "middle";
|
|
122
|
-
const pct = (
|
|
131
|
+
const pct = (val / tot * 100).toFixed(0) + "%";
|
|
123
132
|
c.fillText(pct, lx, ly);
|
|
124
133
|
c.restore();
|
|
125
134
|
}
|
|
@@ -128,41 +137,32 @@ const draw = () => {
|
|
|
128
137
|
cx: cx + r * 0.75 * Math.cos(startAngle + slice / 2),
|
|
129
138
|
cy: cy + r * 0.75 * Math.sin(startAngle + slice / 2),
|
|
130
139
|
item,
|
|
131
|
-
label: `${item[props.labelField]}: ${
|
|
140
|
+
label: `${item[props.labelField]}: ${val}`,
|
|
132
141
|
startAngle,
|
|
133
142
|
endAngle
|
|
134
143
|
});
|
|
135
144
|
startAngle = endAngle;
|
|
136
145
|
});
|
|
137
146
|
if (props.donut) {
|
|
147
|
+
const ri = r * 0.5;
|
|
138
148
|
c.save();
|
|
139
149
|
c.beginPath();
|
|
140
|
-
c.arc(cx, cy,
|
|
141
|
-
c.fillStyle = "#
|
|
142
|
-
c.globalCompositeOperation = "destination-out";
|
|
150
|
+
c.arc(cx, cy, ri, 0, Math.PI * 2);
|
|
151
|
+
c.fillStyle = props.backgroundColor || "#ffffff";
|
|
143
152
|
c.fill();
|
|
144
|
-
c.globalCompositeOperation = "source-over";
|
|
145
153
|
c.restore();
|
|
146
154
|
}
|
|
147
155
|
hitSegments.value = segs;
|
|
148
156
|
if (props.showLegend) {
|
|
149
157
|
drawLegend(c);
|
|
150
158
|
}
|
|
151
|
-
if (props.title) {
|
|
152
|
-
c.save();
|
|
153
|
-
c.fillStyle = "rgba(120,120,140,0.9)";
|
|
154
|
-
c.font = "bold 16px system-ui, sans-serif";
|
|
155
|
-
c.textAlign = "center";
|
|
156
|
-
c.fillText(props.title, width.value / 2, 30);
|
|
157
|
-
c.restore();
|
|
158
|
-
}
|
|
159
159
|
};
|
|
160
160
|
const drawLegend = (c) => {
|
|
161
161
|
c.save();
|
|
162
162
|
c.font = "11px system-ui, sans-serif";
|
|
163
163
|
c.textAlign = "left";
|
|
164
164
|
c.textBaseline = "middle";
|
|
165
|
-
const keys = items.value.map((
|
|
165
|
+
const keys = items.value.map((r) => String(r[props.labelField]));
|
|
166
166
|
let xOffset = 20;
|
|
167
167
|
let yOffset = height.value - 25;
|
|
168
168
|
keys.forEach((key, i) => {
|
|
@@ -180,10 +180,12 @@ const drawLegend = (c) => {
|
|
|
180
180
|
});
|
|
181
181
|
c.restore();
|
|
182
182
|
};
|
|
183
|
-
watch(
|
|
183
|
+
watch(progress, draw);
|
|
184
|
+
watch([hoveredIndex, resizeTick, width, height, () => props.donut], draw);
|
|
184
185
|
watch(items, () => {
|
|
185
186
|
if (props.animated) animate();
|
|
186
|
-
|
|
187
|
+
else draw();
|
|
188
|
+
});
|
|
187
189
|
onMounted(async () => {
|
|
188
190
|
await nextTick();
|
|
189
191
|
if (containerRef.value && canvasRef.value) {
|
|
@@ -225,7 +227,7 @@ const onMouseLeave = () => {
|
|
|
225
227
|
};
|
|
226
228
|
defineExpose({
|
|
227
229
|
exportPng: () => canvasRef.value && exportPng(canvasRef.value, "pie-chart.png"),
|
|
228
|
-
exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "pie-chart.svg")
|
|
230
|
+
exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "pie-chart.svg", canvasRef.value)
|
|
229
231
|
});
|
|
230
232
|
</script>
|
|
231
233
|
|
|
@@ -233,6 +235,7 @@ defineExpose({
|
|
|
233
235
|
.k-chart-wrapper {
|
|
234
236
|
position: relative;
|
|
235
237
|
user-select: none;
|
|
238
|
+
cursor: default;
|
|
236
239
|
}
|
|
237
240
|
.k-chart-wrapper .k-chart-inner {
|
|
238
241
|
width: 100%;
|
|
@@ -7,6 +7,7 @@ type __VLS_Props = {
|
|
|
7
7
|
labelField?: string;
|
|
8
8
|
animated?: boolean;
|
|
9
9
|
zoomable?: boolean;
|
|
10
|
+
wheelNavigation?: boolean;
|
|
10
11
|
maxZoom?: number;
|
|
11
12
|
colors?: string[];
|
|
12
13
|
backgroundColor?: string;
|
|
@@ -42,6 +43,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {
|
|
|
42
43
|
seriesField: string;
|
|
43
44
|
animated: boolean;
|
|
44
45
|
zoomable: boolean;
|
|
46
|
+
wheelNavigation: boolean;
|
|
45
47
|
maxZoom: number;
|
|
46
48
|
colors: string[];
|
|
47
49
|
backgroundColor: string;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
<template lang="pug">
|
|
2
2
|
.k-chart-wrapper(
|
|
3
3
|
:class="{ 'k-chart--grabbing': isDragging, 'k-chart--pannable': canPan }"
|
|
4
|
-
|
|
5
|
-
@wheel.prevent="onWheel"
|
|
4
|
+
@wheel="onWheel"
|
|
6
5
|
@mousedown="onMouseDown"
|
|
7
6
|
@mousemove="onMouseMove"
|
|
8
7
|
@mouseup="onMouseUp"
|
|
@@ -17,7 +16,7 @@
|
|
|
17
16
|
canvas(ref="canvasRef" v-show="!isLoading")
|
|
18
17
|
svg.k-chart-svg(v-show="!isLoading" ref="svgRef" :width="width" :height="height")
|
|
19
18
|
defs
|
|
20
|
-
clipPath(id="plot-clip")
|
|
19
|
+
clipPath(:id="'plot-clip-' + clipId")
|
|
21
20
|
rect(
|
|
22
21
|
:x="axes.plotLeft"
|
|
23
22
|
:y="axes.plotTop"
|
|
@@ -25,16 +24,14 @@
|
|
|
25
24
|
:height="axes.plotHeight.value"
|
|
26
25
|
)
|
|
27
26
|
g.k-chart-hits
|
|
28
|
-
// No circles here! Distance-based hit testing now.
|
|
29
27
|
|
|
30
28
|
// Dynamic Interaction Layer
|
|
31
|
-
g.k-chart-interaction(v-if="hoveredIndex !== -1 && !isLoading" clip-path="url(#plot-clip)")
|
|
29
|
+
g.k-chart-interaction(v-if="hoveredIndex !== -1 && !isLoading && hoveredPoint" :clip-path="'url(#plot-clip-' + clipId + ')'")
|
|
32
30
|
circle.k-chart-hit-proxy(
|
|
33
31
|
:cx="hoveredPoint.x"
|
|
34
32
|
:cy="hoveredPoint.y"
|
|
35
33
|
:r="20"
|
|
36
34
|
fill="transparent"
|
|
37
|
-
style="cursor: pointer"
|
|
38
35
|
@click="emit('click-point', hoveredPoint.item, hoveredPoint.index)"
|
|
39
36
|
)
|
|
40
37
|
circle(
|
|
@@ -46,21 +43,23 @@
|
|
|
46
43
|
stroke-width="2"
|
|
47
44
|
pointer-events="none"
|
|
48
45
|
)
|
|
49
|
-
.k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="
|
|
46
|
+
.k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="tooltipStyle")
|
|
50
47
|
slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
|
|
51
48
|
span {{ tooltipState.content }}
|
|
52
49
|
</template>
|
|
53
50
|
|
|
54
51
|
<script setup>
|
|
55
|
-
import { ref, watch, computed, onMounted, nextTick } from "vue";
|
|
52
|
+
import { ref, watch, computed, onMounted, nextTick, useId, isRef } from "vue";
|
|
56
53
|
import { useChartCanvas } from "../../composables/useChartCanvas";
|
|
57
54
|
import { useChartSvg } from "../../composables/useChartSvg";
|
|
58
55
|
import { useChartData } from "../../composables/useChartData";
|
|
59
|
-
import { useChartAxes } from "../../composables/useChartAxes";
|
|
56
|
+
import { useChartAxes, parseValue } from "../../composables/useChartAxes";
|
|
60
57
|
import { useChartViewport } from "../../composables/useChartViewport";
|
|
61
58
|
import { useChartAnimation, easeOut } from "../../composables/useChartAnimation";
|
|
62
59
|
import { useChartExport } from "../../composables/useChartExport";
|
|
63
60
|
import { useChartHitTest } from "../../composables/useChartHitTest";
|
|
61
|
+
import { cleanNumber, safeMax } from "../../composables/chartUtils";
|
|
62
|
+
const clipId = useId();
|
|
64
63
|
const props = defineProps({
|
|
65
64
|
dataProvider: { type: null, required: false },
|
|
66
65
|
xField: { type: String, required: false, default: "x" },
|
|
@@ -70,6 +69,7 @@ const props = defineProps({
|
|
|
70
69
|
labelField: { type: String, required: false, default: "label" },
|
|
71
70
|
animated: { type: Boolean, required: false, default: true },
|
|
72
71
|
zoomable: { type: Boolean, required: false, default: true },
|
|
72
|
+
wheelNavigation: { type: Boolean, required: false, default: true },
|
|
73
73
|
maxZoom: { type: Number, required: false, default: 20 },
|
|
74
74
|
colors: { type: Array, required: false, default: () => ["#6366f1", "#22d3ee", "#f59e0b", "#10b981", "#f43f5e", "#a855f7", "#14b8a6", "#fb923c"] },
|
|
75
75
|
backgroundColor: { type: String, required: false, default: "#ffffff" },
|
|
@@ -81,19 +81,35 @@ const emit = defineEmits(["click-point", "hover-point", "zoom-change", "pan-chan
|
|
|
81
81
|
const containerRef = ref(null);
|
|
82
82
|
const canvasRef = ref(null);
|
|
83
83
|
const svgRef = ref(null);
|
|
84
|
-
const { ctx, width, height, clear, setupCanvas } = useChartCanvas();
|
|
84
|
+
const { ctx, width, height, resizeTick, clear, setupCanvas } = useChartCanvas();
|
|
85
85
|
const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg();
|
|
86
86
|
const dataRef = computed(() => props.dataProvider);
|
|
87
|
-
const { items } = useChartData(dataRef);
|
|
87
|
+
const { items, loading: dataLoading } = useChartData(dataRef);
|
|
88
88
|
const { progress, animate } = useChartAnimation();
|
|
89
89
|
const { exportPng, exportSvg: exportSvgFile } = useChartExport();
|
|
90
90
|
const isLoading = computed(() => {
|
|
91
|
-
|
|
91
|
+
const dp = props.dataProvider;
|
|
92
|
+
if (!dp) return dataLoading.value;
|
|
93
|
+
const rawLoading = dp.loading !== void 0 ? isRef(dp.loading) ? dp.loading.value : dp.loading?.value ?? dp.loading : false;
|
|
94
|
+
const rawInitial = dp.initialLoad !== void 0 ? isRef(dp.initialLoad) ? dp.initialLoad.value : dp.initialLoad?.value ?? dp.initialLoad : false;
|
|
95
|
+
return Boolean(dataLoading.value || rawLoading || rawInitial);
|
|
92
96
|
});
|
|
97
|
+
const tooltipStyle = computed(() => ({
|
|
98
|
+
left: `${tooltipState.value.x}px`,
|
|
99
|
+
top: `${tooltipState.value.y}px`
|
|
100
|
+
}));
|
|
93
101
|
const maxZoomRef = computed(() => props.maxZoom);
|
|
94
102
|
const plotWidthRef = computed(() => Math.max(0, width.value - 75));
|
|
95
103
|
const plotLeftRef = computed(() => 55);
|
|
96
|
-
const
|
|
104
|
+
const zoomableRef = computed(() => props.zoomable);
|
|
105
|
+
const wheelNavRef = computed(() => props.wheelNavigation);
|
|
106
|
+
const viewport = useChartViewport({
|
|
107
|
+
maxZoom: maxZoomRef,
|
|
108
|
+
plotWidth: plotWidthRef,
|
|
109
|
+
plotLeft: plotLeftRef,
|
|
110
|
+
zoomable: zoomableRef,
|
|
111
|
+
wheelNavigation: wheelNavRef
|
|
112
|
+
});
|
|
97
113
|
const { findNearestEuclidean } = useChartHitTest();
|
|
98
114
|
const {
|
|
99
115
|
scale,
|
|
@@ -111,29 +127,95 @@ const {
|
|
|
111
127
|
} = viewport;
|
|
112
128
|
const xFieldRef = computed(() => props.xField);
|
|
113
129
|
const yFieldRef = computed(() => props.yField);
|
|
114
|
-
const
|
|
115
|
-
|
|
130
|
+
const xAxisType = computed(() => {
|
|
131
|
+
if (!items.value.length) return "numeric";
|
|
132
|
+
const sample = items.value.find((r) => r?.[props.xField] !== void 0 && r?.[props.xField] !== null)?.[props.xField];
|
|
133
|
+
if (sample === void 0 || sample === null) return "numeric";
|
|
134
|
+
if (typeof sample === "number") return "numeric";
|
|
135
|
+
if (typeof sample === "string") {
|
|
136
|
+
const cleaned = cleanNumber(sample, NaN);
|
|
137
|
+
if (Number.isFinite(cleaned)) return "numeric";
|
|
138
|
+
const ts = Date.parse(sample);
|
|
139
|
+
if (!isNaN(ts) && !/^\d+$/.test(sample.trim()) && /[-/T:]|[A-Za-z]/.test(sample)) return "datetime";
|
|
140
|
+
return "category";
|
|
141
|
+
}
|
|
142
|
+
return "numeric";
|
|
143
|
+
});
|
|
144
|
+
const isAllMidnight = computed(() => {
|
|
145
|
+
if (xAxisType.value !== "datetime" || !items.value.length) return false;
|
|
146
|
+
const checkCount = Math.min(items.value.length, 100);
|
|
147
|
+
for (let i = 0; i < checkCount; i++) {
|
|
148
|
+
const ts = parseValue(items.value[i][props.xField]);
|
|
149
|
+
const d = new Date(ts);
|
|
150
|
+
if (d.getHours() !== 0 || d.getMinutes() !== 0) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
const categoryList = computed(() => {
|
|
155
|
+
if (xAxisType.value !== "category") return [];
|
|
156
|
+
return [...new Set(items.value.map((r) => String(r[props.xField])))];
|
|
157
|
+
});
|
|
158
|
+
const categoryIndexMap = computed(() => {
|
|
159
|
+
const map = {};
|
|
160
|
+
categoryList.value.forEach((cat, i) => {
|
|
161
|
+
map[cat] = i;
|
|
162
|
+
});
|
|
163
|
+
return map;
|
|
164
|
+
});
|
|
165
|
+
const resolveX = (item, index = 0) => {
|
|
166
|
+
if (!item) return index;
|
|
167
|
+
const raw = item[props.xField];
|
|
168
|
+
if (xAxisType.value === "category") return categoryIndexMap.value[String(raw)] ?? index;
|
|
169
|
+
return parseValue(raw, index);
|
|
170
|
+
};
|
|
171
|
+
const axes = useChartAxes({
|
|
172
|
+
items,
|
|
173
|
+
xField: xFieldRef,
|
|
174
|
+
yField: yFieldRef,
|
|
175
|
+
width,
|
|
176
|
+
height,
|
|
177
|
+
scale,
|
|
178
|
+
panOffset,
|
|
179
|
+
padding: { top: 20, right: 20, bottom: 40, left: 55 },
|
|
180
|
+
xValueGetter: (r, i) => resolveX(r, i)
|
|
181
|
+
});
|
|
182
|
+
const maxSize = computed(() => safeMax(items.value.map((r) => cleanNumber(r[props.sizeField] ?? 1, 1)), 1));
|
|
116
183
|
const seriesMap = computed(() => {
|
|
117
184
|
const map = /* @__PURE__ */ new Map();
|
|
118
|
-
|
|
185
|
+
const maxS = maxSize.value || 1;
|
|
186
|
+
items.value.forEach((item, idx) => {
|
|
119
187
|
const key = String(item[props.seriesField] ?? "default");
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
188
|
+
let list = map.get(key);
|
|
189
|
+
if (!list) {
|
|
190
|
+
list = [];
|
|
191
|
+
map.set(key, list);
|
|
192
|
+
}
|
|
193
|
+
const sizeRaw = cleanNumber(item[props.sizeField] ?? 1, 1);
|
|
194
|
+
const r = 4 + sizeRaw / maxS * 16;
|
|
195
|
+
list.push({
|
|
196
|
+
item,
|
|
197
|
+
index: idx,
|
|
198
|
+
xVal: resolveX(item, idx),
|
|
199
|
+
yVal: parseValue(item[props.yField]),
|
|
200
|
+
sizeRaw,
|
|
201
|
+
r
|
|
202
|
+
});
|
|
203
|
+
});
|
|
123
204
|
return map;
|
|
124
205
|
});
|
|
125
206
|
const hoveredPoint = computed(() => {
|
|
126
207
|
if (hoveredIndex.value === -1) return null;
|
|
127
208
|
const item = items.value[hoveredIndex.value];
|
|
128
|
-
|
|
129
|
-
const
|
|
209
|
+
if (!item) return null;
|
|
210
|
+
const sizeRaw = cleanNumber(item[props.sizeField] ?? 1, 1);
|
|
211
|
+
const r = 4 + sizeRaw / (maxSize.value || 1) * 16;
|
|
130
212
|
const seriesKey = String(item[props.seriesField] ?? "default");
|
|
131
213
|
const seriesKeys = Array.from(seriesMap.value.keys());
|
|
132
214
|
const seriesIdx = seriesKeys.indexOf(seriesKey);
|
|
133
215
|
const color = props.colors[seriesIdx % props.colors.length];
|
|
134
216
|
return {
|
|
135
|
-
x: axes.toX(
|
|
136
|
-
y: axes.toY(
|
|
217
|
+
x: axes.toX(resolveX(item, hoveredIndex.value)),
|
|
218
|
+
y: axes.toY(parseValue(item[props.yField])),
|
|
137
219
|
r,
|
|
138
220
|
color,
|
|
139
221
|
item,
|
|
@@ -144,7 +226,7 @@ const draw = () => {
|
|
|
144
226
|
if (!ctx.value) return;
|
|
145
227
|
clear(props.backgroundColor);
|
|
146
228
|
const c = ctx.value;
|
|
147
|
-
const p = easeOut(progress.value);
|
|
229
|
+
const p = props.animated ? easeOut(progress.value) : 1;
|
|
148
230
|
c.save();
|
|
149
231
|
c.strokeStyle = "rgba(100,100,120,0.1)";
|
|
150
232
|
c.lineWidth = 1;
|
|
@@ -162,15 +244,8 @@ const draw = () => {
|
|
|
162
244
|
c.lineTo(x, axes.plotTop + axes.plotHeight.value);
|
|
163
245
|
c.stroke();
|
|
164
246
|
}
|
|
165
|
-
c.
|
|
166
|
-
c.
|
|
167
|
-
c.moveTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value);
|
|
168
|
-
c.lineTo(axes.plotLeft + axes.plotWidth.value, axes.plotTop + axes.plotHeight.value);
|
|
169
|
-
c.stroke();
|
|
170
|
-
c.beginPath();
|
|
171
|
-
c.moveTo(axes.plotLeft, axes.plotTop);
|
|
172
|
-
c.lineTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value);
|
|
173
|
-
c.stroke();
|
|
247
|
+
c.restore();
|
|
248
|
+
c.save();
|
|
174
249
|
c.fillStyle = "rgba(150,150,170,0.8)";
|
|
175
250
|
c.font = "11px system-ui, sans-serif";
|
|
176
251
|
c.textAlign = "right";
|
|
@@ -179,12 +254,42 @@ const draw = () => {
|
|
|
179
254
|
c.fillText(val.toFixed(1), axes.plotLeft - 8, axes.toY(val) + 4);
|
|
180
255
|
}
|
|
181
256
|
c.textAlign = "center";
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
257
|
+
if (xAxisType.value === "category") {
|
|
258
|
+
const count = categoryList.value.length;
|
|
259
|
+
if (count > 0) {
|
|
260
|
+
const xSteps = Math.min(10, count - 1);
|
|
261
|
+
const stepSize = xSteps > 0 ? (count - 1) / xSteps : 1;
|
|
262
|
+
for (let i = 0; i <= xSteps; i++) {
|
|
263
|
+
const idx = Math.round(i * stepSize);
|
|
264
|
+
const x = axes.toX(idx);
|
|
265
|
+
const label = categoryList.value[idx] ?? "";
|
|
266
|
+
c.fillText(label, x, axes.plotTop + axes.plotHeight.value + 16);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
for (let i = 0; i <= 5; i++) {
|
|
271
|
+
const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / 5);
|
|
272
|
+
let label;
|
|
273
|
+
if (xAxisType.value === "datetime") {
|
|
274
|
+
label = isAllMidnight.value ? new Date(val).toLocaleDateString([], { month: "short", day: "numeric" }) : new Date(val).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
275
|
+
} else {
|
|
276
|
+
label = val.toFixed(1);
|
|
277
|
+
}
|
|
278
|
+
c.fillText(label, axes.toX(val), axes.plotTop + axes.plotHeight.value + 16);
|
|
279
|
+
}
|
|
185
280
|
}
|
|
281
|
+
c.strokeStyle = "rgba(150,150,170,0.5)";
|
|
282
|
+
c.beginPath();
|
|
283
|
+
c.moveTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value);
|
|
284
|
+
c.lineTo(axes.plotLeft + axes.plotWidth.value, axes.plotTop + axes.plotHeight.value);
|
|
285
|
+
c.stroke();
|
|
286
|
+
c.beginPath();
|
|
287
|
+
c.moveTo(axes.plotLeft, axes.plotTop);
|
|
288
|
+
c.lineTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value);
|
|
289
|
+
c.stroke();
|
|
186
290
|
c.fillStyle = "rgba(120,120,140,0.8)";
|
|
187
291
|
if (props.xAxisTitle) {
|
|
292
|
+
c.textAlign = "center";
|
|
188
293
|
c.fillText(props.xAxisTitle, axes.plotLeft + axes.plotWidth.value / 2, axes.plotTop + axes.plotHeight.value + 34);
|
|
189
294
|
}
|
|
190
295
|
if (props.yAxisTitle) {
|
|
@@ -201,14 +306,13 @@ const draw = () => {
|
|
|
201
306
|
c.rect(axes.plotLeft, axes.plotTop, axes.plotWidth.value, axes.plotHeight.value);
|
|
202
307
|
c.clip();
|
|
203
308
|
let seriesIdx = 0;
|
|
204
|
-
for (const [,
|
|
309
|
+
for (const [, points] of seriesMap.value) {
|
|
205
310
|
const color = props.colors[seriesIdx % props.colors.length];
|
|
206
|
-
|
|
207
|
-
const x = axes.toX(
|
|
208
|
-
const y = axes.toY(
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
const isHovered = items.value[hoveredIndex.value] === item;
|
|
311
|
+
points.forEach((pt) => {
|
|
312
|
+
const x = axes.toX(pt.xVal);
|
|
313
|
+
const y = axes.toY(pt.yVal);
|
|
314
|
+
const r = pt.r;
|
|
315
|
+
const isHovered = items.value[hoveredIndex.value] === pt.item;
|
|
212
316
|
const alpha = p;
|
|
213
317
|
c.save();
|
|
214
318
|
c.globalAlpha = alpha * (isHovered ? 1 : 0.75);
|
|
@@ -251,16 +355,17 @@ const drawLegend = (c) => {
|
|
|
251
355
|
}
|
|
252
356
|
c.restore();
|
|
253
357
|
};
|
|
254
|
-
watch(
|
|
358
|
+
watch(progress, draw);
|
|
359
|
+
watch([scale, panOffset, hoveredIndex, resizeTick, width, height], draw);
|
|
255
360
|
watch(items, () => {
|
|
256
361
|
if (props.animated) animate();
|
|
257
|
-
|
|
362
|
+
else draw();
|
|
363
|
+
});
|
|
258
364
|
watch(scale, (v) => emit("zoom-change", v));
|
|
259
|
-
watch(panOffset, (
|
|
365
|
+
watch(() => [panOffset.value.x, panOffset.value.y], ([x, y]) => emit("pan-change", { x, y }));
|
|
260
366
|
onMounted(async () => {
|
|
261
367
|
await nextTick();
|
|
262
368
|
if (containerRef.value && canvasRef.value) {
|
|
263
|
-
containerRef.value.style.position = "relative";
|
|
264
369
|
setupCanvas(canvasRef.value);
|
|
265
370
|
if (props.animated) animate();
|
|
266
371
|
else draw();
|
|
@@ -277,7 +382,7 @@ const onMouseMove = (e) => {
|
|
|
277
382
|
if (!rect) return;
|
|
278
383
|
const mx = e.clientX - rect.left;
|
|
279
384
|
const my = e.clientY - rect.top;
|
|
280
|
-
const nearest = findNearestEuclidean(items.value, mx, my, axes, props.xField, props.yField, 20);
|
|
385
|
+
const nearest = findNearestEuclidean(items.value, mx, my, axes, props.xField, props.yField, 20, (item, i) => resolveX(item, i));
|
|
281
386
|
if (nearest) {
|
|
282
387
|
setHovered(nearest.index);
|
|
283
388
|
showTooltip(mx + 10, my - 10, `${nearest.item[props.xField]}: ${nearest.item[props.yField]}`, nearest.item, nearest.index);
|
|
@@ -297,7 +402,7 @@ defineExpose({
|
|
|
297
402
|
zoomOut: viewport.zoomOut,
|
|
298
403
|
resetZoom: viewport.resetZoom,
|
|
299
404
|
exportPng: () => canvasRef.value && exportPng(canvasRef.value, "scatter-chart.png"),
|
|
300
|
-
exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "scatter-chart.svg")
|
|
405
|
+
exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "scatter-chart.svg", canvasRef.value)
|
|
301
406
|
});
|
|
302
407
|
</script>
|
|
303
408
|
|
|
@@ -305,6 +410,13 @@ defineExpose({
|
|
|
305
410
|
.k-chart-wrapper {
|
|
306
411
|
position: relative;
|
|
307
412
|
user-select: none;
|
|
413
|
+
cursor: default;
|
|
414
|
+
}
|
|
415
|
+
.k-chart-wrapper.k-chart--pannable {
|
|
416
|
+
cursor: grab;
|
|
417
|
+
}
|
|
418
|
+
.k-chart-wrapper.k-chart--grabbing {
|
|
419
|
+
cursor: grabbing;
|
|
308
420
|
}
|
|
309
421
|
.k-chart-wrapper .k-chart-controls {
|
|
310
422
|
position: absolute;
|
|
@@ -7,6 +7,7 @@ type __VLS_Props = {
|
|
|
7
7
|
labelField?: string;
|
|
8
8
|
animated?: boolean;
|
|
9
9
|
zoomable?: boolean;
|
|
10
|
+
wheelNavigation?: boolean;
|
|
10
11
|
maxZoom?: number;
|
|
11
12
|
colors?: string[];
|
|
12
13
|
backgroundColor?: string;
|
|
@@ -42,6 +43,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {
|
|
|
42
43
|
seriesField: string;
|
|
43
44
|
animated: boolean;
|
|
44
45
|
zoomable: boolean;
|
|
46
|
+
wheelNavigation: boolean;
|
|
45
47
|
maxZoom: number;
|
|
46
48
|
colors: string[];
|
|
47
49
|
backgroundColor: string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust numerical utilities for Katlux chart engine.
|
|
3
|
+
* Prevents call stack overflow on large datasets (100k+ points)
|
|
4
|
+
* and safely normalizes unparsed or localized number strings.
|
|
5
|
+
*/
|
|
6
|
+
export declare function cleanNumber(val: any, fallback?: number): number;
|
|
7
|
+
export declare function safeMax(arr: number[], fallback?: number): number;
|
|
8
|
+
export declare function safeMin(arr: number[], fallback?: number): number;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function cleanNumber(val, fallback = 0) {
|
|
2
|
+
if (val === null || val === void 0) return fallback;
|
|
3
|
+
if (typeof val === "number") {
|
|
4
|
+
return Number.isFinite(val) ? val : fallback;
|
|
5
|
+
}
|
|
6
|
+
if (typeof val === "string") {
|
|
7
|
+
let s = val.trim();
|
|
8
|
+
if (!s) return fallback;
|
|
9
|
+
s = s.replace(/\s+/g, "");
|
|
10
|
+
if (s.includes(".") && s.includes(",")) {
|
|
11
|
+
if (s.lastIndexOf(",") > s.lastIndexOf(".")) {
|
|
12
|
+
s = s.replace(/\./g, "").replace(",", ".");
|
|
13
|
+
} else {
|
|
14
|
+
s = s.replace(/,/g, "");
|
|
15
|
+
}
|
|
16
|
+
} else if (s.includes(",")) {
|
|
17
|
+
const parts = s.split(",");
|
|
18
|
+
if (parts.length > 2 || parts.length === 2 && parts[1].length === 3) {
|
|
19
|
+
s = s.replace(/,/g, "");
|
|
20
|
+
} else {
|
|
21
|
+
s = s.replace(",", ".");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const num = Number(s);
|
|
25
|
+
return Number.isFinite(num) ? num : fallback;
|
|
26
|
+
}
|
|
27
|
+
return fallback;
|
|
28
|
+
}
|
|
29
|
+
export function safeMax(arr, fallback = 0) {
|
|
30
|
+
if (!arr || arr.length === 0) return fallback;
|
|
31
|
+
let max = -Infinity;
|
|
32
|
+
let found = false;
|
|
33
|
+
for (let i = 0; i < arr.length; i++) {
|
|
34
|
+
const v = arr[i];
|
|
35
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
36
|
+
if (!found || v > max) {
|
|
37
|
+
max = v;
|
|
38
|
+
found = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return found ? max : fallback;
|
|
43
|
+
}
|
|
44
|
+
export function safeMin(arr, fallback = 0) {
|
|
45
|
+
if (!arr || arr.length === 0) return fallback;
|
|
46
|
+
let min = Infinity;
|
|
47
|
+
let found = false;
|
|
48
|
+
for (let i = 0; i < arr.length; i++) {
|
|
49
|
+
const v = arr[i];
|
|
50
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
51
|
+
if (!found || v < min) {
|
|
52
|
+
min = v;
|
|
53
|
+
found = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found ? min : fallback;
|
|
58
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ref } from "vue";
|
|
1
|
+
import { ref, onScopeDispose } from "vue";
|
|
2
2
|
export function useChartAnimation(duration = 600) {
|
|
3
3
|
const progress = ref(0);
|
|
4
4
|
let rafId = null;
|
|
@@ -25,6 +25,12 @@ export function useChartAnimation(duration = 600) {
|
|
|
25
25
|
rafId = null;
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
|
+
try {
|
|
29
|
+
onScopeDispose(() => {
|
|
30
|
+
stop();
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
28
34
|
return { progress, animate, stop };
|
|
29
35
|
}
|
|
30
36
|
export function easeOut(t) {
|