@katlux/block-charts 0.1.1-alpha.87 → 0.1.1-alpha.89

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.
@@ -1,7 +1,6 @@
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
4
  @wheel.prevent="onWheel"
6
5
  @mousedown="onMouseDown"
7
6
  @mousemove="onMouseMove"
@@ -25,7 +24,7 @@
25
24
  :height="plotHeight.value"
26
25
  )
27
26
  g.k-chart-hits
28
- // Hit detection is now handled by calculation in onMouseMove
27
+ // Hit detection is handled by calculation in onMouseMove
29
28
 
30
29
  // Dynamic Interaction Layer
31
30
  g.k-chart-interaction(v-if="hoveredIndex !== -1 && hoveredBar" clip-path="url(#plot-clip)")
@@ -35,7 +34,6 @@
35
34
  :width="hoveredBar.w"
36
35
  :height="hoveredBar.h"
37
36
  fill="transparent"
38
- style="cursor: pointer"
39
37
  @click="emit('click-point', hoveredBar.item, hoveredIndex)"
40
38
  )
41
39
  rect(
@@ -49,7 +47,7 @@
49
47
  rx="2"
50
48
  pointer-events="none"
51
49
  )
52
- .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
50
+ .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="tooltipStyle")
53
51
  slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
54
52
  span {{ tooltipState.content }}
55
53
  </template>
@@ -62,6 +60,7 @@ import { useChartData } from "../../composables/useChartData";
62
60
  import { useChartViewport } from "../../composables/useChartViewport";
63
61
  import { useChartAnimation, easeOut } from "../../composables/useChartAnimation";
64
62
  import { useChartExport } from "../../composables/useChartExport";
63
+ import { cleanNumber, safeMax } from "../../composables/chartUtils";
65
64
  const props = defineProps({
66
65
  dataProvider: { type: null, required: false },
67
66
  categoryField: { type: String, required: false, default: "category" },
@@ -82,7 +81,7 @@ const emit = defineEmits(["click-point", "hover-point", "zoom-change", "pan-chan
82
81
  const containerRef = ref(null);
83
82
  const canvasRef = ref(null);
84
83
  const svgRef = ref(null);
85
- const { ctx, width, height, clear, setupCanvas } = useChartCanvas();
84
+ const { ctx, width, height, resizeTick, clear, setupCanvas } = useChartCanvas();
86
85
  const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg();
87
86
  const dataRef = computed(() => props.dataProvider);
88
87
  const { items } = useChartData(dataRef);
@@ -91,6 +90,10 @@ const { exportPng, exportSvg: exportSvgFile } = useChartExport();
91
90
  const isLoading = computed(() => {
92
91
  return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
93
92
  });
93
+ const tooltipStyle = computed(() => ({
94
+ left: `${tooltipState.value.x}px`,
95
+ top: `${tooltipState.value.y}px`
96
+ }));
94
97
  const maxZoomRef = computed(() => props.maxZoom);
95
98
  const plotWidthRef = computed(() => Math.max(0, width.value - 75));
96
99
  const plotLeftRef = computed(() => 55);
@@ -119,12 +122,12 @@ const groups = computed(() => [...new Set(items.value.map((r) => String(r[props.
119
122
  const maxValue = computed(() => {
120
123
  if (props.mode === "stacked") {
121
124
  const sums = categories.value.map(
122
- (cat) => items.value.filter((r) => String(r[props.categoryField]) === cat).reduce((acc, r) => acc + Math.max(0, Number(r[props.valueField])), 0)
125
+ (cat) => items.value.filter((r) => String(r[props.categoryField]) === cat).reduce((acc, r) => acc + Math.max(0, cleanNumber(r[props.valueField])), 0)
123
126
  );
124
- return Math.max(...sums, 10);
127
+ return safeMax(sums, 10);
125
128
  }
126
- const vals = items.value.map((r) => Number(r[props.valueField]));
127
- return vals.length ? Math.max(...vals, 10) : 10;
129
+ const vals = items.value.map((r) => cleanNumber(r[props.valueField]));
130
+ return safeMax(vals, 10);
128
131
  });
129
132
  const visibleCatCount = computed(() => Math.max(1, Math.ceil(categories.value.length / scale.value)));
130
133
  const panCatOffset = computed(() => {
@@ -144,117 +147,228 @@ const draw = () => {
144
147
  if (!ctx.value) return;
145
148
  clear(props.backgroundColor);
146
149
  const c = ctx.value;
147
- const p = easeOut(progress.value);
150
+ const p = props.animated ? easeOut(progress.value) : 1;
148
151
  const bars = [];
152
+ const isHorizontal = props.orientation === "horizontal";
149
153
  const catCount = visibleCategories.value.length;
150
154
  const gCount = groups.value.length || 1;
151
- const catWidth = plotWidth.value / (catCount || 1);
152
- let barWidth = catWidth * 0.7;
153
- if (props.mode === "clustered" && gCount > 1) {
154
- barWidth = catWidth * 0.8 / gCount;
155
- }
156
- c.save();
157
- c.strokeStyle = "rgba(100,100,120,0.1)";
158
- c.lineWidth = 1;
159
- c.fillStyle = "rgba(150,150,170,0.8)";
160
- c.font = "11px system-ui, sans-serif";
161
- c.textAlign = "right";
162
- for (let i = 0; i <= 5; i++) {
163
- const val = maxValue.value / 5 * i;
164
- const y = plotTop + plotHeight.value - val / maxValue.value * plotHeight.value;
155
+ if (isHorizontal) {
156
+ const catHeight = plotHeight.value / (catCount || 1);
157
+ let barHeight = catHeight * 0.7;
158
+ if (props.mode === "clustered" && gCount > 1) {
159
+ barHeight = catHeight * 0.8 / gCount;
160
+ }
161
+ c.save();
162
+ c.strokeStyle = "rgba(100,100,120,0.1)";
163
+ c.lineWidth = 1;
164
+ c.fillStyle = "rgba(150,150,170,0.8)";
165
+ c.font = "11px system-ui, sans-serif";
166
+ c.textAlign = "center";
167
+ for (let i = 0; i <= 5; i++) {
168
+ const val = maxValue.value / 5 * i;
169
+ const x = plotLeft + val / maxValue.value * plotWidth.value;
170
+ c.beginPath();
171
+ c.moveTo(x, plotTop);
172
+ c.lineTo(x, plotTop + plotHeight.value);
173
+ c.stroke();
174
+ c.fillText(val.toFixed(0), x, plotTop + plotHeight.value + 16);
175
+ }
176
+ c.strokeStyle = "rgba(100,100,120,0.05)";
177
+ for (let i = 0; i <= catCount; i++) {
178
+ const y = plotTop + i * catHeight;
179
+ c.beginPath();
180
+ c.moveTo(plotLeft, y);
181
+ c.lineTo(plotLeft + plotWidth.value, y);
182
+ c.stroke();
183
+ }
184
+ c.strokeStyle = "rgba(150,150,170,0.5)";
165
185
  c.beginPath();
166
- c.moveTo(plotLeft, y);
167
- c.lineTo(plotLeft + plotWidth.value, y);
186
+ c.moveTo(plotLeft, plotTop + plotHeight.value);
187
+ c.lineTo(plotLeft + plotWidth.value, plotTop + plotHeight.value);
168
188
  c.stroke();
169
- c.fillText(val.toFixed(0), plotLeft - 8, y + 4);
170
- }
171
- c.strokeStyle = "rgba(100,100,120,0.05)";
172
- for (let i = 0; i <= catCount; i++) {
173
- const x = plotLeft + i * catWidth;
174
189
  c.beginPath();
175
- c.moveTo(x, plotTop);
176
- c.lineTo(x, plotTop + plotHeight.value);
190
+ c.moveTo(plotLeft, plotTop);
191
+ c.lineTo(plotLeft, plotTop + plotHeight.value);
177
192
  c.stroke();
178
- }
179
- c.strokeStyle = "rgba(150,150,170,0.5)";
180
- c.beginPath();
181
- c.moveTo(plotLeft, plotTop + plotHeight.value);
182
- c.lineTo(plotLeft + plotWidth.value, plotTop + plotHeight.value);
183
- c.stroke();
184
- c.beginPath();
185
- c.moveTo(plotLeft, plotTop);
186
- c.lineTo(plotLeft, plotTop + plotHeight.value);
187
- c.stroke();
188
- c.restore();
189
- c.save();
190
- c.beginPath();
191
- c.rect(plotLeft, plotTop, plotWidth.value, plotHeight.value);
192
- c.clip();
193
- visibleCategories.value.forEach((cat, catIdx) => {
194
- const catItems = items.value.filter((r) => String(r[props.categoryField]) === cat);
195
- const catX = plotLeft + catIdx * catWidth;
196
- if (props.mode === "stacked") {
197
- let yOffset = 0;
198
- groups.value.forEach((group, gIdx) => {
199
- const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
200
- if (!item) return;
201
- const val = Number(item[props.valueField]) * p;
202
- const bh = val / maxValue.value * plotHeight.value;
203
- const bx = catX + (catWidth - barWidth) / 2;
204
- const by = plotTop + plotHeight.value - (yOffset + val) / maxValue.value * plotHeight.value;
205
- const isHovered = hoveredIndex.value === bars.length;
206
- const color = props.colors[gIdx % props.colors.length];
207
- c.fillStyle = isHovered ? color : color + "dd";
208
- c.fillRect(bx, by, barWidth, bh);
209
- c.strokeStyle = "rgba(255,255,255,0.3)";
210
- c.lineWidth = 1;
211
- c.strokeRect(bx, by, barWidth, bh);
212
- bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` });
213
- yOffset += Number(item[props.valueField]);
214
- });
215
- } else if (props.mode === "simple") {
216
- const item = catItems[0];
217
- if (item) {
218
- const isHovered = hoveredIndex.value === bars.length;
219
- const val = Number(item[props.valueField]) * p;
220
- const bh = val / maxValue.value * plotHeight.value;
221
- const bx = catX + (catWidth - barWidth) / 2;
222
- const by = plotTop + plotHeight.value - bh;
223
- const color = props.colors[0] || "#6366f1";
224
- c.fillStyle = isHovered ? color : color + "dd";
225
- c.fillRect(bx, by, barWidth, bh);
226
- bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat}: ${item[props.valueField]}` });
193
+ c.restore();
194
+ c.save();
195
+ c.beginPath();
196
+ c.rect(plotLeft, plotTop, plotWidth.value, plotHeight.value);
197
+ c.clip();
198
+ visibleCategories.value.forEach((cat, catIdx) => {
199
+ const catItems = items.value.filter((r) => String(r[props.categoryField]) === cat);
200
+ const catY = plotTop + catIdx * catHeight;
201
+ if (props.mode === "stacked") {
202
+ let xOffset = 0;
203
+ groups.value.forEach((group, gIdx) => {
204
+ const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
205
+ if (!item) return;
206
+ const val = cleanNumber(item[props.valueField]) * p;
207
+ const bw = val / maxValue.value * plotWidth.value;
208
+ const bx = plotLeft + xOffset / maxValue.value * plotWidth.value;
209
+ const by = catY + (catHeight - barHeight) / 2;
210
+ const isHovered = hoveredIndex.value === bars.length;
211
+ const color = props.colors[gIdx % props.colors.length];
212
+ c.fillStyle = isHovered ? color : color + "dd";
213
+ c.fillRect(bx, by, bw, barHeight);
214
+ c.strokeStyle = "rgba(255,255,255,0.3)";
215
+ c.lineWidth = 1;
216
+ c.strokeRect(bx, by, bw, barHeight);
217
+ bars.push({ x: bx, y: by, w: bw, h: barHeight, item, label: `${cat} (${group}): ${item[props.valueField]}` });
218
+ xOffset += cleanNumber(item[props.valueField]);
219
+ });
220
+ } else if (props.mode === "simple") {
221
+ const item = catItems[0];
222
+ if (item) {
223
+ const isHovered = hoveredIndex.value === bars.length;
224
+ const val = cleanNumber(item[props.valueField]) * p;
225
+ const bw = val / maxValue.value * plotWidth.value;
226
+ const bx = plotLeft;
227
+ const by = catY + (catHeight - barHeight) / 2;
228
+ const color = props.colors[0] || "#6366f1";
229
+ c.fillStyle = isHovered ? color : color + "dd";
230
+ c.fillRect(bx, by, bw, barHeight);
231
+ bars.push({ x: bx, y: by, w: bw, h: barHeight, item, label: `${cat}: ${item[props.valueField]}` });
232
+ }
233
+ } else {
234
+ groups.value.forEach((group, gIdx) => {
235
+ const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
236
+ if (!item) return;
237
+ const isHovered = hoveredIndex.value === bars.length;
238
+ const val = cleanNumber(item[props.valueField]) * p;
239
+ const bw = val / maxValue.value * plotWidth.value;
240
+ const gCountTotal = groups.value.length;
241
+ const totalBarsH = gCountTotal * barHeight + (gCountTotal - 1) * 2;
242
+ const startY = catY + (catHeight - totalBarsH) / 2;
243
+ const by = startY + gIdx * (barHeight + 2);
244
+ const bx = plotLeft;
245
+ const color = props.colors[gIdx % props.colors.length];
246
+ c.fillStyle = isHovered ? color : color + "dd";
247
+ c.fillRect(bx, by, bw, barHeight);
248
+ bars.push({ x: bx, y: by, w: bw, h: barHeight, item, label: `${cat} (${group}): ${item[props.valueField]}` });
249
+ });
227
250
  }
228
- } else {
229
- groups.value.forEach((group, gIdx) => {
230
- const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
231
- if (!item) return;
232
- const isHovered = hoveredIndex.value === bars.length;
233
- const val = Number(item[props.valueField]) * p;
234
- const bh = val / maxValue.value * plotHeight.value;
235
- const gCountTotal = groups.value.length;
236
- const totalBarsW = gCountTotal * barWidth + (gCountTotal - 1) * 2;
237
- const startX = catX + (catWidth - totalBarsW) / 2;
238
- const bx = startX + gIdx * (barWidth + 2);
239
- const by = plotTop + plotHeight.value - bh;
240
- const color = props.colors[gIdx % props.colors.length];
241
- c.fillStyle = isHovered ? color : color + "dd";
242
- c.fillRect(bx, by, barWidth, bh);
243
- bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` });
244
- });
251
+ });
252
+ c.restore();
253
+ c.save();
254
+ c.fillStyle = "rgba(150,150,170,0.9)";
255
+ c.font = "11px system-ui, sans-serif";
256
+ c.textAlign = "right";
257
+ visibleCategories.value.forEach((cat, catIdx) => {
258
+ const catY = plotTop + catIdx * catHeight;
259
+ const label = cat.length > 10 ? cat.slice(0, 10) + "\u2026" : cat;
260
+ c.fillText(label, plotLeft - 8, catY + catHeight / 2 + 4);
261
+ });
262
+ c.restore();
263
+ } else {
264
+ const catWidth = plotWidth.value / (catCount || 1);
265
+ let barWidth = catWidth * 0.7;
266
+ if (props.mode === "clustered" && gCount > 1) {
267
+ barWidth = catWidth * 0.8 / gCount;
245
268
  }
246
- });
247
- c.restore();
248
- c.save();
249
- c.fillStyle = "rgba(150,150,170,0.9)";
250
- c.font = "11px system-ui, sans-serif";
251
- c.textAlign = "center";
252
- visibleCategories.value.forEach((cat, catIdx) => {
253
- const catX = plotLeft + catIdx * catWidth;
254
- const label = cat.length > 10 ? cat.slice(0, 10) + "\u2026" : cat;
255
- c.fillText(label, catX + catWidth / 2, plotTop + plotHeight.value + 18);
256
- });
257
- c.restore();
269
+ c.save();
270
+ c.strokeStyle = "rgba(100,100,120,0.1)";
271
+ c.lineWidth = 1;
272
+ c.fillStyle = "rgba(150,150,170,0.8)";
273
+ c.font = "11px system-ui, sans-serif";
274
+ c.textAlign = "right";
275
+ for (let i = 0; i <= 5; i++) {
276
+ const val = maxValue.value / 5 * i;
277
+ const y = plotTop + plotHeight.value - val / maxValue.value * plotHeight.value;
278
+ c.beginPath();
279
+ c.moveTo(plotLeft, y);
280
+ c.lineTo(plotLeft + plotWidth.value, y);
281
+ c.stroke();
282
+ c.fillText(val.toFixed(0), plotLeft - 8, y + 4);
283
+ }
284
+ c.strokeStyle = "rgba(100,100,120,0.05)";
285
+ for (let i = 0; i <= catCount; i++) {
286
+ const x = plotLeft + i * catWidth;
287
+ c.beginPath();
288
+ c.moveTo(x, plotTop);
289
+ c.lineTo(x, plotTop + plotHeight.value);
290
+ c.stroke();
291
+ }
292
+ c.strokeStyle = "rgba(150,150,170,0.5)";
293
+ c.beginPath();
294
+ c.moveTo(plotLeft, plotTop + plotHeight.value);
295
+ c.lineTo(plotLeft + plotWidth.value, plotTop + plotHeight.value);
296
+ c.stroke();
297
+ c.beginPath();
298
+ c.moveTo(plotLeft, plotTop);
299
+ c.lineTo(plotLeft, plotTop + plotHeight.value);
300
+ c.stroke();
301
+ c.restore();
302
+ c.save();
303
+ c.beginPath();
304
+ c.rect(plotLeft, plotTop, plotWidth.value, plotHeight.value);
305
+ c.clip();
306
+ visibleCategories.value.forEach((cat, catIdx) => {
307
+ const catItems = items.value.filter((r) => String(r[props.categoryField]) === cat);
308
+ const catX = plotLeft + catIdx * catWidth;
309
+ if (props.mode === "stacked") {
310
+ let yOffset = 0;
311
+ groups.value.forEach((group, gIdx) => {
312
+ const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
313
+ if (!item) return;
314
+ const val = cleanNumber(item[props.valueField]) * p;
315
+ const bh = val / maxValue.value * plotHeight.value;
316
+ const bx = catX + (catWidth - barWidth) / 2;
317
+ const by = plotTop + plotHeight.value - (yOffset + val) / maxValue.value * plotHeight.value;
318
+ const isHovered = hoveredIndex.value === bars.length;
319
+ const color = props.colors[gIdx % props.colors.length];
320
+ c.fillStyle = isHovered ? color : color + "dd";
321
+ c.fillRect(bx, by, barWidth, bh);
322
+ c.strokeStyle = "rgba(255,255,255,0.3)";
323
+ c.lineWidth = 1;
324
+ c.strokeRect(bx, by, barWidth, bh);
325
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` });
326
+ yOffset += cleanNumber(item[props.valueField]);
327
+ });
328
+ } else if (props.mode === "simple") {
329
+ const item = catItems[0];
330
+ if (item) {
331
+ const isHovered = hoveredIndex.value === bars.length;
332
+ const val = cleanNumber(item[props.valueField]) * p;
333
+ const bh = val / maxValue.value * plotHeight.value;
334
+ const bx = catX + (catWidth - barWidth) / 2;
335
+ const by = plotTop + plotHeight.value - bh;
336
+ const color = props.colors[0] || "#6366f1";
337
+ c.fillStyle = isHovered ? color : color + "dd";
338
+ c.fillRect(bx, by, barWidth, bh);
339
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat}: ${item[props.valueField]}` });
340
+ }
341
+ } else {
342
+ groups.value.forEach((group, gIdx) => {
343
+ const item = catItems.find((r) => String(r[props.groupField] ?? "default") === group);
344
+ if (!item) return;
345
+ const isHovered = hoveredIndex.value === bars.length;
346
+ const val = cleanNumber(item[props.valueField]) * p;
347
+ const bh = val / maxValue.value * plotHeight.value;
348
+ const gCountTotal = groups.value.length;
349
+ const totalBarsW = gCountTotal * barWidth + (gCountTotal - 1) * 2;
350
+ const startX = catX + (catWidth - totalBarsW) / 2;
351
+ const bx = startX + gIdx * (barWidth + 2);
352
+ const by = plotTop + plotHeight.value - bh;
353
+ const color = props.colors[gIdx % props.colors.length];
354
+ c.fillStyle = isHovered ? color : color + "dd";
355
+ c.fillRect(bx, by, barWidth, bh);
356
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` });
357
+ });
358
+ }
359
+ });
360
+ c.restore();
361
+ c.save();
362
+ c.fillStyle = "rgba(150,150,170,0.9)";
363
+ c.font = "11px system-ui, sans-serif";
364
+ c.textAlign = "center";
365
+ visibleCategories.value.forEach((cat, catIdx) => {
366
+ const catX = plotLeft + catIdx * catWidth;
367
+ const label = cat.length > 10 ? cat.slice(0, 10) + "\u2026" : cat;
368
+ c.fillText(label, catX + catWidth / 2, plotTop + plotHeight.value + 18);
369
+ });
370
+ c.restore();
371
+ }
258
372
  c.save();
259
373
  c.fillStyle = "rgba(120,120,140,0.8)";
260
374
  if (props.xAxisTitle) {
@@ -291,10 +405,12 @@ const drawLegend = (c) => {
291
405
  });
292
406
  c.restore();
293
407
  };
294
- watch([items, scale, panOffset, hoveredIndex, progress, () => props.mode, () => props.orientation], draw, { deep: true });
408
+ watch(progress, draw);
409
+ watch([scale, panOffset, hoveredIndex, resizeTick, width, height, () => props.mode, () => props.orientation], draw);
295
410
  watch(items, () => {
296
411
  if (props.animated) animate();
297
- }, { deep: true });
412
+ else draw();
413
+ });
298
414
  watch(scale, (v) => emit("zoom-change", v));
299
415
  watch(panOffset, (v) => emit("pan-change", v), { deep: true });
300
416
  onMounted(async () => {
@@ -339,7 +455,7 @@ defineExpose({
339
455
  zoomOut: viewport.zoomOut,
340
456
  resetZoom: viewport.resetZoom,
341
457
  exportPng: () => canvasRef.value && exportPng(canvasRef.value, "bar-chart.png"),
342
- exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "bar-chart.svg")
458
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "bar-chart.svg", canvasRef.value)
343
459
  });
344
460
  </script>
345
461
 
@@ -347,6 +463,13 @@ defineExpose({
347
463
  .k-chart-wrapper {
348
464
  position: relative;
349
465
  user-select: none;
466
+ cursor: default;
467
+ }
468
+ .k-chart-wrapper.k-chart--pannable {
469
+ cursor: grab;
470
+ }
471
+ .k-chart-wrapper.k-chart--grabbing {
472
+ cursor: grabbing;
350
473
  }
351
474
  .k-chart-wrapper .k-chart-controls {
352
475
  position: absolute;
@@ -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,7 +36,7 @@
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>
@@ -48,6 +47,7 @@ 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" },
@@ -73,20 +73,34 @@ const { exportPng, exportSvg: exportSvgFile } = useChartExport();
73
73
  const isLoading = computed(() => {
74
74
  return props.dataProvider?.loading?.value || props.dataProvider?.initialLoad?.value || false;
75
75
  });
76
+ const tooltipStyle = computed(() => ({
77
+ left: `${tooltipState.value.x}px`,
78
+ top: `${tooltipState.value.y}px`
79
+ }));
76
80
  const padLeft = 80;
77
81
  const padTop = 30;
78
82
  const padRight = 20;
79
83
  const padBottom = 20;
80
84
  const rows = computed(() => [...new Set(items.value.map((r) => String(r[props.rowField])))]);
81
85
  const cols = computed(() => [...new Set(items.value.map((r) => String(r[props.columnField])))]);
86
+ const rowIndexMap = computed(() => {
87
+ const map = /* @__PURE__ */ new Map();
88
+ rows.value.forEach((r, i) => map.set(r, i));
89
+ return map;
90
+ });
91
+ const colIndexMap = computed(() => {
92
+ const map = /* @__PURE__ */ new Map();
93
+ cols.value.forEach((c, i) => map.set(c, i));
94
+ return map;
95
+ });
82
96
  const cellW = computed(() => Math.max(8, (width.value - padLeft - padRight) / (cols.value.length || 1)));
83
97
  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));
98
+ const maxVal = computed(() => safeMax(items.value.map((r) => cleanNumber(r[props.valueField])), 1));
99
+ const minVal = computed(() => safeMin(items.value.map((r) => cleanNumber(r[props.valueField])), 0));
85
100
  const hoveredCell = computed(() => {
86
101
  if (hoveredIndex.value === -1) return null;
87
102
  return renderedCells.value[hoveredIndex.value];
88
103
  });
89
- const minVal = computed(() => Math.min(...items.value.map((r) => Number(r[props.valueField])), 0));
90
104
  const lerp = (t) => {
91
105
  const low = parseInt((props.colorLow || "#1e1e2e").replace("#", ""), 16);
92
106
  const high = parseInt((props.colorHigh || "#6366f1").replace("#", ""), 16);
@@ -104,9 +118,10 @@ const lerp = (t) => {
104
118
  const renderedCells = computed(() => {
105
119
  const range = maxVal.value - minVal.value || 1;
106
120
  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;
121
+ const ri = rowIndexMap.value.get(String(item[props.rowField])) ?? 0;
122
+ const ci = colIndexMap.value.get(String(item[props.columnField])) ?? 0;
123
+ const val = cleanNumber(item[props.valueField]);
124
+ const t = Math.max(0, Math.min(1, (val - minVal.value) / range));
110
125
  return {
111
126
  x: padLeft + ci * cellW.value,
112
127
  y: padTop + ri * cellH.value,
@@ -114,7 +129,7 @@ const renderedCells = computed(() => {
114
129
  h: cellH.value - 1,
115
130
  color: lerp(t),
116
131
  item,
117
- label: `${item[props.rowField]} \xD7 ${item[props.columnField]}: ${item[props.valueField]}`
132
+ label: `${item[props.rowField]} \xD7 ${item[props.columnField]}: ${val}`
118
133
  };
119
134
  });
120
135
  });
@@ -124,12 +139,9 @@ const draw = () => {
124
139
  const c = ctx.value;
125
140
  drawAxes(c);
126
141
  renderedCells.value.forEach((cell, i) => {
127
- const isHovered = hoveredIndex.value === i;
128
142
  c.fillStyle = cell.color;
129
- c.beginPath();
130
- c.rect(cell.x, cell.y, cell.w, cell.h);
131
- c.fill();
132
- if (isHovered) {
143
+ c.fillRect(cell.x, cell.y, cell.w, cell.h);
144
+ if (hoveredIndex.value === i) {
133
145
  c.strokeStyle = "#fff";
134
146
  c.lineWidth = 2;
135
147
  c.strokeRect(cell.x + 1, cell.y + 1, cell.w - 2, cell.h - 2);
@@ -137,7 +149,6 @@ const draw = () => {
137
149
  });
138
150
  if (props.showLegend) drawLegend(c);
139
151
  };
140
- watch([items, renderedCells, hoveredIndex, width, height, resizeTick], draw, { deep: true });
141
152
  const drawAxes = (c) => {
142
153
  c.save();
143
154
  c.fillStyle = "rgba(120,120,140,0.8)";
@@ -186,6 +197,8 @@ const drawLegend = (c) => {
186
197
  c.fillText(maxVal.value.toString(), x + gradW, y + gradH + 10);
187
198
  c.restore();
188
199
  };
200
+ watch([renderedCells, hoveredIndex, width, height, resizeTick], draw);
201
+ watch(items, draw);
189
202
  watch(isLoading, (val) => {
190
203
  if (!val) {
191
204
  nextTick(() => {
@@ -227,7 +240,7 @@ const onMouseLeave = () => {
227
240
  };
228
241
  defineExpose({
229
242
  exportPng: () => canvasRef.value && exportPng(canvasRef.value, "heatmap.png"),
230
- exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "heatmap.svg")
243
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, "heatmap.svg", canvasRef.value)
231
244
  });
232
245
  </script>
233
246