@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.
Files changed (35) hide show
  1. package/dist/module.d.mts +3 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -0
  4. package/dist/runtime/components/KAreaChart/KAreaChart.d.vue.ts +2 -0
  5. package/dist/runtime/components/KAreaChart/KAreaChart.vue +271 -69
  6. package/dist/runtime/components/KAreaChart/KAreaChart.vue.d.ts +2 -0
  7. package/dist/runtime/components/KBarChart/KBarChart.d.vue.ts +3 -1
  8. package/dist/runtime/components/KBarChart/KBarChart.vue +323 -125
  9. package/dist/runtime/components/KBarChart/KBarChart.vue.d.ts +3 -1
  10. package/dist/runtime/components/KHeatMap/KHeatMap.d.vue.ts +1 -1
  11. package/dist/runtime/components/KHeatMap/KHeatMap.vue +56 -21
  12. package/dist/runtime/components/KHeatMap/KHeatMap.vue.d.ts +1 -1
  13. package/dist/runtime/components/KLineChart/KLineChart.d.vue.ts +2 -0
  14. package/dist/runtime/components/KLineChart/KLineChart.vue +179 -48
  15. package/dist/runtime/components/KLineChart/KLineChart.vue.d.ts +2 -0
  16. package/dist/runtime/components/KPieChart/KPieChart.vue +32 -29
  17. package/dist/runtime/components/KScatterChart/KScatterChart.d.vue.ts +2 -0
  18. package/dist/runtime/components/KScatterChart/KScatterChart.vue +161 -49
  19. package/dist/runtime/components/KScatterChart/KScatterChart.vue.d.ts +2 -0
  20. package/dist/runtime/composables/chartUtils.d.ts +8 -0
  21. package/dist/runtime/composables/chartUtils.js +58 -0
  22. package/dist/runtime/composables/useChartAnimation.js +7 -1
  23. package/dist/runtime/composables/useChartAxes.d.ts +4 -2
  24. package/dist/runtime/composables/useChartAxes.js +32 -13
  25. package/dist/runtime/composables/useChartCanvas.d.ts +1 -0
  26. package/dist/runtime/composables/useChartCanvas.js +51 -18
  27. package/dist/runtime/composables/useChartData.d.ts +13 -7
  28. package/dist/runtime/composables/useChartData.js +99 -30
  29. package/dist/runtime/composables/useChartExport.d.ts +1 -1
  30. package/dist/runtime/composables/useChartExport.js +26 -3
  31. package/dist/runtime/composables/useChartHitTest.d.ts +2 -2
  32. package/dist/runtime/composables/useChartHitTest.js +7 -5
  33. package/dist/runtime/composables/useChartViewport.d.ts +3 -0
  34. package/dist/runtime/composables/useChartViewport.js +30 -7
  35. 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 now handled via angle calculation in onMouseMove
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="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
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
- return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
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 + Number(r[props.valueField]), 0));
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 slice = Number(item[props.valueField]) / tot * Math.PI * 2 * p;
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 = (Number(item[props.valueField]) / tot * 100).toFixed(0) + "%";
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]}: ${item[props.valueField]}`,
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, r * 0.5, 0, Math.PI * 2);
141
- c.fillStyle = "#000";
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((it) => String(it[props.labelField]));
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([items, hoveredIndex, progress, () => props.donut], draw, { deep: true });
183
+ watch(progress, draw);
184
+ watch([hoveredIndex, resizeTick, width, height, () => props.donut], draw);
184
185
  watch(items, () => {
185
186
  if (props.animated) animate();
186
- }, { deep: true });
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
- :style="{ cursor: isDragging ? 'grabbing' : canPan ? 'grab' : 'default' }"
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="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
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
- return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
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 viewport = useChartViewport({ maxZoom: maxZoomRef, plotWidth: plotWidthRef, plotLeft: plotLeftRef });
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 axes = useChartAxes({ items, xField: xFieldRef, yField: yFieldRef, width, height, scale, panOffset, padding: { top: 20, right: 20, bottom: 40, left: 55 } });
115
- const maxSize = computed(() => Math.max(...items.value.map((r) => Number(r[props.sizeField] ?? 1)), 1));
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
- for (const item of items.value) {
185
+ const maxS = maxSize.value || 1;
186
+ items.value.forEach((item, idx) => {
119
187
  const key = String(item[props.seriesField] ?? "default");
120
- if (!map.has(key)) map.set(key, []);
121
- map.get(key).push(item);
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
- const sizeRaw = Number(item[props.sizeField] ?? 1);
129
- const r = 4 + sizeRaw / maxSize.value * 16;
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(Number(item[props.xField])),
136
- y: axes.toY(Number(item[props.yField])),
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.strokeStyle = "rgba(150,150,170,0.5)";
166
- c.beginPath();
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
- for (let i = 0; i <= 5; i++) {
183
- const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / 5);
184
- c.fillText(val.toFixed(1), axes.toX(val), axes.plotTop + axes.plotHeight.value + 16);
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 [, seriesItems] of seriesMap.value) {
309
+ for (const [, points] of seriesMap.value) {
205
310
  const color = props.colors[seriesIdx % props.colors.length];
206
- seriesItems.forEach((item) => {
207
- const x = axes.toX(Number(item[props.xField]));
208
- const y = axes.toY(Number(item[props.yField]));
209
- const sizeRaw = Number(item[props.sizeField] ?? 1);
210
- const r = 4 + sizeRaw / maxSize.value * 16;
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([items, scale, panOffset, hoveredIndex, progress], draw, { deep: true });
358
+ watch(progress, draw);
359
+ watch([scale, panOffset, hoveredIndex, resizeTick, width, height], draw);
255
360
  watch(items, () => {
256
361
  if (props.animated) animate();
257
- }, { deep: true });
362
+ else draw();
363
+ });
258
364
  watch(scale, (v) => emit("zoom-change", v));
259
- watch(panOffset, (v) => emit("pan-change", v), { deep: true });
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) {