@katlux/block-charts 0.1.0-beta.8 → 0.1.1-alpha.101

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 (34) hide show
  1. package/LICENSE +200 -0
  2. package/dist/module.d.mts +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +2 -0
  5. package/dist/runtime/components/KAreaChart/KAreaChart.d.vue.ts +2 -0
  6. package/dist/runtime/components/KAreaChart/KAreaChart.vue +271 -69
  7. package/dist/runtime/components/KAreaChart/KAreaChart.vue.d.ts +2 -0
  8. package/dist/runtime/components/KBarChart/KBarChart.d.vue.ts +2 -0
  9. package/dist/runtime/components/KBarChart/KBarChart.vue +323 -125
  10. package/dist/runtime/components/KBarChart/KBarChart.vue.d.ts +2 -0
  11. package/dist/runtime/components/KHeatMap/KHeatMap.vue +56 -21
  12. package/dist/runtime/components/KLineChart/KLineChart.d.vue.ts +2 -0
  13. package/dist/runtime/components/KLineChart/KLineChart.vue +179 -48
  14. package/dist/runtime/components/KLineChart/KLineChart.vue.d.ts +2 -0
  15. package/dist/runtime/components/KPieChart/KPieChart.vue +32 -29
  16. package/dist/runtime/components/KScatterChart/KScatterChart.d.vue.ts +3 -1
  17. package/dist/runtime/components/KScatterChart/KScatterChart.vue +161 -49
  18. package/dist/runtime/components/KScatterChart/KScatterChart.vue.d.ts +3 -1
  19. package/dist/runtime/composables/chartUtils.d.ts +8 -0
  20. package/dist/runtime/composables/chartUtils.js +58 -0
  21. package/dist/runtime/composables/useChartAnimation.js +7 -1
  22. package/dist/runtime/composables/useChartAxes.d.ts +4 -2
  23. package/dist/runtime/composables/useChartAxes.js +32 -13
  24. package/dist/runtime/composables/useChartCanvas.d.ts +1 -0
  25. package/dist/runtime/composables/useChartCanvas.js +51 -18
  26. package/dist/runtime/composables/useChartData.d.ts +13 -7
  27. package/dist/runtime/composables/useChartData.js +99 -30
  28. package/dist/runtime/composables/useChartExport.d.ts +1 -1
  29. package/dist/runtime/composables/useChartExport.js +26 -3
  30. package/dist/runtime/composables/useChartHitTest.d.ts +2 -2
  31. package/dist/runtime/composables/useChartHitTest.js +7 -5
  32. package/dist/runtime/composables/useChartViewport.d.ts +3 -0
  33. package/dist/runtime/composables/useChartViewport.js +30 -7
  34. package/package.json +12 -7
@@ -21,7 +21,6 @@
21
21
  :width="hoveredCell.w"
22
22
  :height="hoveredCell.h"
23
23
  fill="transparent"
24
- style="cursor: pointer"
25
24
  @click="emit('click-point', hoveredCell.item, hoveredIndex)"
26
25
  )
27
26
  rect(
@@ -37,17 +36,18 @@
37
36
  )
38
37
 
39
38
  // Labels remain in SVG for sharpness and easy styling
40
- .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
39
+ .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="tooltipStyle")
41
40
  slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
42
41
  span {{ tooltipState.content }}
43
42
  </template>
44
43
 
45
44
  <script setup>
46
- import { ref, watch, computed, onMounted, nextTick } from "vue";
45
+ import { ref, watch, computed, onMounted, nextTick, isRef } from "vue";
47
46
  import { useChartSvg } from "../../composables/useChartSvg";
48
47
  import { useChartData } from "../../composables/useChartData";
49
48
  import { useChartCanvas } from "../../composables/useChartCanvas";
50
49
  import { useChartExport } from "../../composables/useChartExport";
50
+ import { cleanNumber, safeMax, safeMin } from "../../composables/chartUtils";
51
51
  const props = defineProps({
52
52
  dataProvider: { type: null, required: false },
53
53
  rowField: { type: String, required: false, default: "row" },
@@ -65,31 +65,57 @@ const emit = defineEmits(["click-point", "hover-point"]);
65
65
  const containerRef = ref(null);
66
66
  const canvasRef = ref(null);
67
67
  const svgRef = ref(null);
68
- const { ctx, width, height, clear, setupCanvas } = useChartCanvas();
68
+ const { ctx, width, height, resizeTick, clear, setupCanvas } = useChartCanvas();
69
69
  const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg();
70
70
  const dataRef = computed(() => props.dataProvider);
71
- const { items } = useChartData(dataRef);
71
+ const { items, loading: dataLoading } = useChartData(dataRef);
72
72
  const { exportPng, exportSvg: exportSvgFile } = useChartExport();
73
73
  const isLoading = computed(() => {
74
- return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
74
+ const dp = props.dataProvider;
75
+ if (!dp) return dataLoading.value;
76
+ const rawLoading = dp.loading !== void 0 ? isRef(dp.loading) ? dp.loading.value : dp.loading?.value ?? dp.loading : false;
77
+ const rawInitial = dp.initialLoad !== void 0 ? isRef(dp.initialLoad) ? dp.initialLoad.value : dp.initialLoad?.value ?? dp.initialLoad : false;
78
+ return Boolean(dataLoading.value || rawLoading || rawInitial);
75
79
  });
80
+ const tooltipStyle = computed(() => ({
81
+ left: `${tooltipState.value.x}px`,
82
+ top: `${tooltipState.value.y}px`
83
+ }));
76
84
  const padLeft = 80;
77
85
  const padTop = 30;
78
86
  const padRight = 20;
79
87
  const padBottom = 20;
80
88
  const rows = computed(() => [...new Set(items.value.map((r) => String(r[props.rowField])))]);
81
89
  const cols = computed(() => [...new Set(items.value.map((r) => String(r[props.columnField])))]);
90
+ const rowIndexMap = computed(() => {
91
+ const map = /* @__PURE__ */ new Map();
92
+ rows.value.forEach((r, i) => map.set(r, i));
93
+ return map;
94
+ });
95
+ const colIndexMap = computed(() => {
96
+ const map = /* @__PURE__ */ new Map();
97
+ cols.value.forEach((c, i) => map.set(c, i));
98
+ return map;
99
+ });
82
100
  const cellW = computed(() => Math.max(8, (width.value - padLeft - padRight) / (cols.value.length || 1)));
83
101
  const cellH = computed(() => Math.max(8, (height.value - padTop - padBottom) / (rows.value.length || 1)));
84
- const maxVal = computed(() => Math.max(...items.value.map((r) => Number(r[props.valueField])), 1));
102
+ const maxVal = computed(() => safeMax(items.value.map((r) => cleanNumber(r[props.valueField])), 1));
103
+ const minVal = computed(() => safeMin(items.value.map((r) => cleanNumber(r[props.valueField])), 0));
85
104
  const hoveredCell = computed(() => {
86
105
  if (hoveredIndex.value === -1) return null;
87
106
  return renderedCells.value[hoveredIndex.value];
88
107
  });
89
- const minVal = computed(() => Math.min(...items.value.map((r) => Number(r[props.valueField])), 0));
108
+ const parseHexColor = (colorStr, fallback) => {
109
+ let hex = (colorStr || fallback).replace("#", "").trim();
110
+ if (hex.length === 3) {
111
+ hex = hex.split("").map((c) => c + c).join("");
112
+ }
113
+ const num = parseInt(hex.padEnd(6, "0").slice(0, 6), 16);
114
+ return isNaN(num) ? parseInt(fallback.replace("#", ""), 16) : num;
115
+ };
90
116
  const lerp = (t) => {
91
- const low = parseInt((props.colorLow || "#1e1e2e").replace("#", ""), 16);
92
- const high = parseInt((props.colorHigh || "#6366f1").replace("#", ""), 16);
117
+ const low = parseHexColor(props.colorLow, "1e1e2e");
118
+ const high = parseHexColor(props.colorHigh, "6366f1");
93
119
  const lr = low >> 16 & 255;
94
120
  const lg = low >> 8 & 255;
95
121
  const lb = low & 255;
@@ -104,9 +130,10 @@ const lerp = (t) => {
104
130
  const renderedCells = computed(() => {
105
131
  const range = maxVal.value - minVal.value || 1;
106
132
  return items.value.map((item) => {
107
- const ri = rows.value.indexOf(String(item[props.rowField]));
108
- const ci = cols.value.indexOf(String(item[props.columnField]));
109
- const t = (Number(item[props.valueField]) - minVal.value) / range;
133
+ const ri = rowIndexMap.value.get(String(item[props.rowField])) ?? 0;
134
+ const ci = colIndexMap.value.get(String(item[props.columnField])) ?? 0;
135
+ const val = cleanNumber(item[props.valueField]);
136
+ const t = Math.max(0, Math.min(1, (val - minVal.value) / range));
110
137
  return {
111
138
  x: padLeft + ci * cellW.value,
112
139
  y: padTop + ri * cellH.value,
@@ -114,7 +141,7 @@ const renderedCells = computed(() => {
114
141
  h: cellH.value - 1,
115
142
  color: lerp(t),
116
143
  item,
117
- label: `${item[props.rowField]} \xD7 ${item[props.columnField]}: ${item[props.valueField]}`
144
+ label: `${item[props.rowField]} \xD7 ${item[props.columnField]}: ${val}`
118
145
  };
119
146
  });
120
147
  });
@@ -124,12 +151,9 @@ const draw = () => {
124
151
  const c = ctx.value;
125
152
  drawAxes(c);
126
153
  renderedCells.value.forEach((cell, i) => {
127
- const isHovered = hoveredIndex.value === i;
128
154
  c.fillStyle = cell.color;
129
- c.beginPath();
130
- c.rect(cell.x, cell.y, cell.w, cell.h);
131
- c.fill();
132
- if (isHovered) {
155
+ c.fillRect(cell.x, cell.y, cell.w, cell.h);
156
+ if (hoveredIndex.value === i) {
133
157
  c.strokeStyle = "#fff";
134
158
  c.lineWidth = 2;
135
159
  c.strokeRect(cell.x + 1, cell.y + 1, cell.w - 2, cell.h - 2);
@@ -185,7 +209,18 @@ const drawLegend = (c) => {
185
209
  c.fillText(maxVal.value.toString(), x + gradW, y + gradH + 10);
186
210
  c.restore();
187
211
  };
188
- watch([renderedCells, hoveredIndex, width, height], draw);
212
+ watch([renderedCells, hoveredIndex, width, height, resizeTick], draw);
213
+ watch(items, draw);
214
+ watch(isLoading, (val) => {
215
+ if (!val) {
216
+ nextTick(() => {
217
+ if (canvasRef.value) {
218
+ setupCanvas(canvasRef.value);
219
+ draw();
220
+ }
221
+ });
222
+ }
223
+ });
189
224
  onMounted(async () => {
190
225
  await nextTick();
191
226
  if (containerRef.value && canvasRef.value) {
@@ -217,7 +252,7 @@ const onMouseLeave = () => {
217
252
  };
218
253
  defineExpose({
219
254
  exportPng: () => canvasRef.value && exportPng(canvasRef.value, "heatmap.png"),
220
- exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "heatmap.svg")
255
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "heatmap.svg", canvasRef.value)
221
256
  });
222
257
  </script>
223
258
 
@@ -5,6 +5,7 @@ type __VLS_Props = {
5
5
  seriesField?: string;
6
6
  animated?: boolean;
7
7
  zoomable?: boolean;
8
+ wheelNavigation?: boolean;
8
9
  maxZoom?: number;
9
10
  colors?: string[];
10
11
  backgroundColor?: string;
@@ -40,6 +41,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {
40
41
  seriesField: string;
41
42
  animated: boolean;
42
43
  zoomable: boolean;
44
+ wheelNavigation: boolean;
43
45
  maxZoom: number;
44
46
  colors: string[];
45
47
  backgroundColor: string;
@@ -2,8 +2,7 @@
2
2
  .k-chart-wrapper(
3
3
  ref="wrapperRef"
4
4
  :class="{ 'k-chart--grabbing': isDragging, 'k-chart--pannable': canPan }"
5
- :style="{ cursor: isDragging ? 'grabbing' : canPan ? 'grab' : 'default' }"
6
- @wheel.prevent="onWheel"
5
+ @wheel="onWheel"
7
6
  @mousedown="onMouseDown"
8
7
  @mousemove="onMouseMove"
9
8
  @mouseup="onMouseUp"
@@ -23,7 +22,7 @@
23
22
  :height="height"
24
23
  )
25
24
  defs
26
- clipPath(id="plot-clip")
25
+ clipPath(:id="'plot-clip-' + clipId")
27
26
  rect(
28
27
  :x="axes.plotLeft"
29
28
  :y="axes.plotTop"
@@ -31,7 +30,7 @@
31
30
  :height="axes.plotHeight.value"
32
31
  )
33
32
  // Dynamic Interaction Layer: Repositions one set of circles to the nearest point
34
- g.k-chart-interaction(v-if="hoveredIndex !== -1" clip-path="url(#plot-clip)")
33
+ g.k-chart-interaction(v-if="hoveredIndex !== -1" :clip-path="'url(#plot-clip-' + clipId + ')'")
35
34
  circle.k-chart-hit-proxy(
36
35
  v-for="(pt, i) in hoveredPoints"
37
36
  :key="i"
@@ -39,7 +38,6 @@
39
38
  :cy="pt.y"
40
39
  :r="10"
41
40
  fill="transparent"
42
- style="cursor: pointer"
43
41
  @click="emit('click-point', pt.item, pt.index)"
44
42
  )
45
43
  // Visible indicators
@@ -57,14 +55,14 @@
57
55
  // Tooltip
58
56
  .k-chart-tooltip(
59
57
  v-if="tooltipState.visible && !isLoading"
60
- :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }"
58
+ :style="tooltipStyle"
61
59
  )
62
60
  slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
63
61
  span {{ tooltipState.content }}
64
62
  </template>
65
63
 
66
64
  <script setup>
67
- import { ref, watch, computed, onMounted, onUnmounted, nextTick } from "vue";
65
+ import { ref, watch, computed, onMounted, onUnmounted, nextTick, useId, isRef } from "vue";
68
66
  import { useChartCanvas } from "../../composables/useChartCanvas";
69
67
  import { useChartSvg } from "../../composables/useChartSvg";
70
68
  import { useChartData } from "../../composables/useChartData";
@@ -73,6 +71,8 @@ import { useChartViewport } from "../../composables/useChartViewport";
73
71
  import { useChartAnimation, easeOut } from "../../composables/useChartAnimation";
74
72
  import { useChartExport } from "../../composables/useChartExport";
75
73
  import { useChartHitTest } from "../../composables/useChartHitTest";
74
+ import { cleanNumber } from "../../composables/chartUtils";
75
+ const clipId = useId();
76
76
  const props = defineProps({
77
77
  dataProvider: { type: null, required: false },
78
78
  xField: { type: String, required: false, default: "x" },
@@ -80,6 +80,7 @@ const props = defineProps({
80
80
  seriesField: { type: String, required: false, default: "series" },
81
81
  animated: { type: Boolean, required: false, default: true },
82
82
  zoomable: { type: Boolean, required: false, default: true },
83
+ wheelNavigation: { type: Boolean, required: false, default: true },
83
84
  maxZoom: { type: Number, required: false, default: 10 },
84
85
  colors: { type: Array, required: false, default: () => ["#6366f1", "#22d3ee", "#f59e0b", "#10b981", "#f43f5e", "#a855f7", "#14b8a6", "#fb923c"] },
85
86
  backgroundColor: { type: String, required: false, default: "#ffffff" },
@@ -92,19 +93,35 @@ const wrapperRef = ref(null);
92
93
  const containerRef = ref(null);
93
94
  const canvasRef = ref(null);
94
95
  const svgRef = ref(null);
95
- const { ctx, width, height, clear, setupCanvas } = useChartCanvas();
96
+ const { ctx, width, height, resizeTick, clear, setupCanvas } = useChartCanvas();
96
97
  const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg();
97
98
  const dataRef = computed(() => props.dataProvider);
98
- const { items, loading } = useChartData(dataRef);
99
+ const { items, loading: dataLoading } = useChartData(dataRef);
99
100
  const { progress, animate } = useChartAnimation();
100
101
  const { exportPng, exportSvg: exportSvgFile } = useChartExport();
101
102
  const isLoading = computed(() => {
102
- return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
103
+ const dp = props.dataProvider;
104
+ if (!dp) return dataLoading.value;
105
+ const rawLoading = dp.loading !== void 0 ? isRef(dp.loading) ? dp.loading.value : dp.loading?.value ?? dp.loading : false;
106
+ const rawInitial = dp.initialLoad !== void 0 ? isRef(dp.initialLoad) ? dp.initialLoad.value : dp.initialLoad?.value ?? dp.initialLoad : false;
107
+ return Boolean(dataLoading.value || rawLoading || rawInitial);
103
108
  });
109
+ const tooltipStyle = computed(() => ({
110
+ left: `${tooltipState.value.x}px`,
111
+ top: `${tooltipState.value.y}px`
112
+ }));
104
113
  const maxZoomRef = computed(() => props.maxZoom);
105
114
  const plotWidthRef = computed(() => Math.max(0, width.value - 75));
106
115
  const plotLeftRef = computed(() => 55);
107
- const viewport = useChartViewport({ maxZoom: maxZoomRef, plotWidth: plotWidthRef, plotLeft: plotLeftRef });
116
+ const zoomableRef = computed(() => props.zoomable);
117
+ const wheelNavRef = computed(() => props.wheelNavigation);
118
+ const viewport = useChartViewport({
119
+ maxZoom: maxZoomRef,
120
+ plotWidth: plotWidthRef,
121
+ plotLeft: plotLeftRef,
122
+ zoomable: zoomableRef,
123
+ wheelNavigation: wheelNavRef
124
+ });
108
125
  const { findNearestX } = useChartHitTest();
109
126
  const {
110
127
  scale,
@@ -120,8 +137,56 @@ const {
120
137
  onMouseUp,
121
138
  onMouseLeave: onViewportMouseLeave
122
139
  } = viewport;
123
- const xFieldRef = computed(() => props.xField);
124
- const yFieldRef = computed(() => props.yField);
140
+ const xAxisType = computed(() => {
141
+ if (!items.value.length) return "linear";
142
+ const sample = items.value.find((r) => r?.[props.xField] !== void 0 && r?.[props.xField] !== null)?.[props.xField];
143
+ if (sample === void 0 || sample === null) return "linear";
144
+ if (typeof sample === "number") return "linear";
145
+ if (typeof sample === "string") {
146
+ const cleaned = cleanNumber(sample, NaN);
147
+ if (Number.isFinite(cleaned)) return "linear";
148
+ const parsed = Date.parse(sample);
149
+ if (!isNaN(parsed) && !/^\d+$/.test(sample.trim()) && /[-/T:]|[A-Za-z]/.test(sample)) return "datetime";
150
+ return "category";
151
+ }
152
+ return "linear";
153
+ });
154
+ const isAllMidnight = computed(() => {
155
+ if (xAxisType.value !== "datetime" || !items.value.length) return false;
156
+ const checkCount = Math.min(items.value.length, 100);
157
+ for (let i = 0; i < checkCount; i++) {
158
+ const ts = parseValue(items.value[i][props.xField]);
159
+ const d = new Date(ts);
160
+ if (d.getHours() !== 0 || d.getMinutes() !== 0) return false;
161
+ }
162
+ return true;
163
+ });
164
+ const categoryList = computed(() => {
165
+ if (xAxisType.value !== "category") return [];
166
+ const seen = /* @__PURE__ */ new Set();
167
+ const list = [];
168
+ for (const item of items.value) {
169
+ const val = String(item[props.xField] ?? "");
170
+ if (!seen.has(val)) {
171
+ seen.add(val);
172
+ list.push(val);
173
+ }
174
+ }
175
+ return list;
176
+ });
177
+ const categoryIndexMap = computed(() => {
178
+ const map = {};
179
+ categoryList.value.forEach((cat, i) => {
180
+ map[cat] = i;
181
+ });
182
+ return map;
183
+ });
184
+ const resolveX = (item, index = 0) => {
185
+ if (!item) return index;
186
+ const raw = item[props.xField];
187
+ if (xAxisType.value === "category") return categoryIndexMap.value[String(raw)] ?? index;
188
+ return parseValue(raw, index);
189
+ };
125
190
  const seriesMap = computed(() => {
126
191
  const map = /* @__PURE__ */ new Map();
127
192
  for (const item of items.value) {
@@ -131,6 +196,24 @@ const seriesMap = computed(() => {
131
196
  }
132
197
  return map;
133
198
  });
199
+ const sortedSeriesMap = computed(() => {
200
+ const map = /* @__PURE__ */ new Map();
201
+ const itemIndexMap = /* @__PURE__ */ new Map();
202
+ for (let i = 0; i < items.value.length; i++) {
203
+ itemIndexMap.set(items.value[i], i);
204
+ }
205
+ for (const [key, seriesItems] of seriesMap.value) {
206
+ const enriched = seriesItems.map((item, idx) => ({
207
+ item,
208
+ index: itemIndexMap.get(item) ?? idx,
209
+ xVal: resolveX(item, idx)
210
+ })).sort((a, b) => a.xVal - b.xVal);
211
+ map.set(key, enriched);
212
+ }
213
+ return map;
214
+ });
215
+ const xFieldRef = computed(() => props.xField);
216
+ const yFieldRef = computed(() => props.yField);
134
217
  const axes = useChartAxes({
135
218
  items,
136
219
  xField: xFieldRef,
@@ -139,13 +222,14 @@ const axes = useChartAxes({
139
222
  height,
140
223
  scale,
141
224
  panOffset,
142
- padding: { top: 20, right: 20, bottom: 40, left: 55 }
225
+ padding: { top: 20, right: 20, bottom: 40, left: 55 },
226
+ xValueGetter: (r, i) => resolveX(r, i)
143
227
  });
144
228
  const draw = () => {
145
229
  if (!ctx.value) return;
146
230
  clear(props.backgroundColor);
147
231
  const c = ctx.value;
148
- const p = easeOut(progress.value);
232
+ const p = props.animated ? easeOut(progress.value) : 1;
149
233
  drawGrid(c);
150
234
  drawAxes(c);
151
235
  c.save();
@@ -153,9 +237,9 @@ const draw = () => {
153
237
  c.rect(axes.plotLeft, axes.plotTop, axes.plotWidth.value, axes.plotHeight.value);
154
238
  c.clip();
155
239
  let seriesIdx = 0;
156
- for (const [, seriesItems] of seriesMap.value) {
240
+ for (const [, seriesData] of sortedSeriesMap.value) {
157
241
  const color = props.colors[seriesIdx % props.colors.length];
158
- drawLine(c, seriesItems, color, p);
242
+ drawLine(c, seriesData, color, p);
159
243
  seriesIdx++;
160
244
  }
161
245
  c.restore();
@@ -220,13 +304,31 @@ const drawAxes = (c) => {
220
304
  c.fillText(val.toFixed(1), axes.plotLeft - 8, y + 4);
221
305
  }
222
306
  c.textAlign = "center";
223
- const xSteps = 5;
224
- for (let i = 0; i <= xSteps; i++) {
225
- const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / xSteps);
226
- const x = axes.toX(val);
227
- let label = val.toFixed(1);
228
- if (val > 1e12) label = new Date(val).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
229
- c.fillText(label, x, axes.plotTop + axes.plotHeight.value + 16);
307
+ if (xAxisType.value === "category") {
308
+ const count = categoryList.value.length;
309
+ if (count > 0) {
310
+ const xSteps = Math.min(10, count - 1);
311
+ const stepSize = xSteps > 0 ? (count - 1) / xSteps : 1;
312
+ for (let i = 0; i <= xSteps; i++) {
313
+ const idx = Math.round(i * stepSize);
314
+ const x = axes.toX(idx);
315
+ const label = categoryList.value[idx] ?? "";
316
+ c.fillText(label, x, axes.plotTop + axes.plotHeight.value + 16);
317
+ }
318
+ }
319
+ } else {
320
+ const xSteps = 5;
321
+ for (let i = 0; i <= xSteps; i++) {
322
+ const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / xSteps);
323
+ const x = axes.toX(val);
324
+ let label;
325
+ if (xAxisType.value === "datetime") {
326
+ label = isAllMidnight.value ? new Date(val).toLocaleDateString([], { month: "short", day: "numeric" }) : new Date(val).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
327
+ } else {
328
+ label = val.toFixed(1);
329
+ }
330
+ c.fillText(label, x, axes.plotTop + axes.plotHeight.value + 16);
331
+ }
230
332
  }
231
333
  c.beginPath();
232
334
  c.moveTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value);
@@ -253,8 +355,7 @@ const drawAxes = (c) => {
253
355
  };
254
356
  const drawLine = (c, data, color, progress2) => {
255
357
  if (!data.length) return;
256
- const sorted = [...data].sort((a, b) => parseValue(a[props.xField]) - parseValue(b[props.xField]));
257
- const totalPts = sorted.length;
358
+ const totalPts = data.length;
258
359
  const drawCount = Math.max(1, Math.floor(totalPts * progress2));
259
360
  c.save();
260
361
  c.strokeStyle = color;
@@ -262,18 +363,20 @@ const drawLine = (c, data, color, progress2) => {
262
363
  c.lineJoin = "round";
263
364
  c.lineCap = "round";
264
365
  c.beginPath();
265
- sorted.slice(0, drawCount).forEach((item, i) => {
266
- const x = axes.toX(parseValue(item[props.xField]));
267
- const y = axes.toY(parseValue(item[props.yField]));
366
+ for (let i = 0; i < drawCount; i++) {
367
+ const pt = data[i];
368
+ const x = axes.toX(pt.xVal);
369
+ const y = axes.toY(parseValue(pt.item[props.yField]));
268
370
  if (i === 0) c.moveTo(x, y);
269
371
  else c.lineTo(x, y);
270
- });
372
+ }
271
373
  c.stroke();
374
+ const currentHoveredIdx = hoveredIndex.value;
272
375
  for (let i = 0; i < drawCount; i++) {
273
- const item = sorted[i];
274
- const x = axes.toX(parseValue(item[props.xField]));
275
- const y = axes.toY(parseValue(item[props.yField]));
276
- const isHovered = hoveredIndex.value === items.value.indexOf(item);
376
+ const pt = data[i];
377
+ const x = axes.toX(pt.xVal);
378
+ const y = axes.toY(parseValue(pt.item[props.yField]));
379
+ const isHovered = currentHoveredIdx !== -1 && pt.index === currentHoveredIdx;
277
380
  c.beginPath();
278
381
  c.arc(x, y, isHovered ? 5 : 3, 0, Math.PI * 2);
279
382
  c.fillStyle = isHovered ? "#fff" : color;
@@ -284,12 +387,14 @@ const drawLine = (c, data, color, progress2) => {
284
387
  }
285
388
  c.restore();
286
389
  };
287
- watch([items, scale, panOffset, hoveredIndex, progress], draw, { deep: true });
390
+ watch(progress, draw);
391
+ watch([scale, panOffset, resizeTick, width, height, hoveredIndex], draw);
288
392
  watch(items, () => {
289
393
  if (props.animated) animate();
290
- }, { deep: true });
394
+ else draw();
395
+ });
291
396
  watch(scale, (v) => emit("zoom-change", v));
292
- watch(panOffset, (v) => emit("pan-change", v), { deep: true });
397
+ watch(() => [panOffset.value.x, panOffset.value.y], ([x, y]) => emit("pan-change", { x, y }));
293
398
  onMounted(async () => {
294
399
  await nextTick();
295
400
  if (containerRef.value && canvasRef.value) {
@@ -298,22 +403,28 @@ onMounted(async () => {
298
403
  else draw();
299
404
  }
300
405
  });
406
+ watch(() => items.value.length, (newLen) => {
407
+ if (hoveredIndex.value >= newLen) {
408
+ setHovered(-1);
409
+ }
410
+ });
301
411
  const hoveredPoints = computed(() => {
302
- if (hoveredIndex.value === -1) return [];
412
+ if (hoveredIndex.value === -1 || hoveredIndex.value >= items.value.length) return [];
303
413
  const item = items.value[hoveredIndex.value];
304
- const xVal = parseValue(item[props.xField]);
414
+ if (!item) return [];
415
+ const xVal = resolveX(item);
305
416
  const xPx = axes.toX(xVal);
306
417
  const pts = [];
307
418
  let seriesIdx = 0;
308
- for (const [key, seriesItems] of seriesMap.value) {
309
- const seriesItem = seriesItems.find((it) => parseValue(it[props.xField]) === xVal);
310
- if (seriesItem) {
419
+ for (const [, seriesList] of sortedSeriesMap.value) {
420
+ const found = seriesList.find((pt) => pt.xVal === xVal);
421
+ if (found) {
311
422
  pts.push({
312
423
  x: xPx,
313
- y: axes.toY(parseValue(seriesItem[props.yField])),
424
+ y: axes.toY(parseValue(found.item[props.yField])),
314
425
  color: props.colors[seriesIdx % props.colors.length],
315
- item: seriesItem,
316
- index: items.value.indexOf(seriesItem)
426
+ item: found.item,
427
+ index: found.index
317
428
  });
318
429
  }
319
430
  seriesIdx++;
@@ -331,11 +442,24 @@ const onMouseMove = (e) => {
331
442
  if (!rect) return;
332
443
  const mouseX = e.clientX - rect.left;
333
444
  const mouseY = e.clientY - rect.top;
334
- const nearest = findNearestX(items.value, mouseX, props.xField, axes);
445
+ if (mouseX < axes.plotLeft || mouseX > axes.plotLeft + axes.plotWidth.value || mouseY < axes.plotTop || mouseY > axes.plotTop + axes.plotHeight.value) {
446
+ setHovered(-1);
447
+ hideTooltip();
448
+ return;
449
+ }
450
+ const nearest = findNearestX(items.value, mouseX, props.xField, axes, (r, i) => resolveX(r, i));
335
451
  if (nearest) {
336
452
  setHovered(nearest.index);
337
- showTooltip(mouseX + 10, mouseY - 20, `${nearest.item[props.xField]}: ${nearest.item[props.yField]}`, nearest.item, nearest.index);
338
453
  emit("hover-point", nearest.item, nearest.index);
454
+ const xPx = axes.toX(resolveX(nearest.item, nearest.index));
455
+ const yPx = axes.toY(parseValue(nearest.item[props.yField]));
456
+ showTooltip(
457
+ xPx,
458
+ yPx - 10,
459
+ `${props.xField}: ${nearest.item[props.xField]} | ${props.yField}: ${nearest.item[props.yField]}`,
460
+ nearest.item,
461
+ nearest.index
462
+ );
339
463
  } else {
340
464
  setHovered(-1);
341
465
  hideTooltip();
@@ -351,7 +475,7 @@ defineExpose({
351
475
  zoomOut: viewport.zoomOut,
352
476
  resetZoom: viewport.resetZoom,
353
477
  exportPng: () => canvasRef.value && exportPng(canvasRef.value, "line-chart.png"),
354
- exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "line-chart.svg")
478
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "line-chart.svg", canvasRef.value)
355
479
  });
356
480
  </script>
357
481
 
@@ -359,6 +483,13 @@ defineExpose({
359
483
  .k-chart-wrapper {
360
484
  position: relative;
361
485
  user-select: none;
486
+ cursor: default;
487
+ }
488
+ .k-chart-wrapper.k-chart--pannable {
489
+ cursor: grab;
490
+ }
491
+ .k-chart-wrapper.k-chart--grabbing {
492
+ cursor: grabbing;
362
493
  }
363
494
  .k-chart-wrapper .k-chart-controls {
364
495
  position: absolute;
@@ -5,6 +5,7 @@ type __VLS_Props = {
5
5
  seriesField?: string;
6
6
  animated?: boolean;
7
7
  zoomable?: boolean;
8
+ wheelNavigation?: boolean;
8
9
  maxZoom?: number;
9
10
  colors?: string[];
10
11
  backgroundColor?: string;
@@ -40,6 +41,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {
40
41
  seriesField: string;
41
42
  animated: boolean;
42
43
  zoomable: boolean;
44
+ wheelNavigation: boolean;
43
45
  maxZoom: number;
44
46
  colors: string[];
45
47
  backgroundColor: string;