@katlux/block-charts 0.1.0-beta.0

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.
@@ -0,0 +1,307 @@
1
+ <template lang="pug">
2
+ .k-chart-wrapper
3
+ .k-chart-inner(
4
+ ref="containerRef"
5
+ @mousemove="onMouseMove"
6
+ @mouseleave="onMouseLeave"
7
+ )
8
+ KLoader(:loading="isLoading" overlay)
9
+ canvas(ref="canvasRef" v-show="!isLoading")
10
+ svg.k-chart-svg(v-show="!isLoading" ref="svgRef" :width="width" :height="height")
11
+ g.k-chart-hits
12
+ // Hit detection now handled via angle calculation in onMouseMove
13
+
14
+ // Dynamic Interaction Layer
15
+ g.k-chart-interaction(v-if="hoveredIndex !== -1 && hoveredSeg")
16
+ path.k-chart-hit-proxy(
17
+ :d="hoveredSeg.d"
18
+ fill="transparent"
19
+ style="cursor: pointer"
20
+ @click="emit('click-point', hoveredSeg.item, hoveredIndex)"
21
+ )
22
+ path(
23
+ :d="hoveredSeg.d"
24
+ fill="transparent"
25
+ stroke="#fff"
26
+ stroke-width="3"
27
+ pointer-events="none"
28
+ )
29
+ .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
30
+ slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
31
+ span {{ tooltipState.content }}
32
+ </template>
33
+
34
+ <script lang="ts" setup>
35
+ import { ref, watch, computed, onMounted, nextTick } from 'vue'
36
+ import { useChartCanvas } from '../../composables/useChartCanvas'
37
+ import { useChartSvg } from '../../composables/useChartSvg'
38
+ import { useChartData } from '../../composables/useChartData'
39
+ import { useChartAnimation, easeOut } from '../../composables/useChartAnimation'
40
+ import { useChartExport } from '../../composables/useChartExport'
41
+
42
+ const props = withDefaults(defineProps<{
43
+ dataProvider?: any
44
+ labelField?: string
45
+ valueField?: string
46
+ donut?: boolean
47
+ animated?: boolean
48
+ colors?: string[]
49
+ backgroundColor?: string
50
+ showLegend?: boolean
51
+ title?: string
52
+ }>(), {
53
+ labelField: 'label',
54
+ valueField: 'value',
55
+ donut: false,
56
+ animated: true,
57
+ colors: () => ['#6366f1', '#22d3ee', '#f59e0b', '#10b981', '#f43f5e', '#a855f7', '#14b8a6', '#fb923c'],
58
+ backgroundColor: '#ffffff',
59
+ showLegend: true,
60
+ title: ''
61
+ })
62
+
63
+ const emit = defineEmits<{
64
+ 'click-point': [item: any, index: number]
65
+ 'hover-point': [item: any, index: number]
66
+ }>()
67
+
68
+ const containerRef = ref<HTMLElement | null>(null)
69
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
70
+ const svgRef = ref<SVGSVGElement | null>(null)
71
+
72
+ const { ctx, width, height, clear, setupCanvas } = useChartCanvas()
73
+ const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg()
74
+ const dataRef = computed(() => props.dataProvider)
75
+ const { items } = useChartData(dataRef)
76
+ const { progress, animate } = useChartAnimation()
77
+ const { exportPng, exportSvg: exportSvgFile } = useChartExport()
78
+
79
+ const isLoading = computed(() => {
80
+ return props.dataProvider?.loading?.value || (props.dataProvider as any)?.initialLoad?.value || false
81
+ })
82
+
83
+ interface ISegment { d: string; cx: number; cy: number; item: any; label: string; startAngle: number; endAngle: number }
84
+ const hitSegments = ref<ISegment[]>([])
85
+
86
+ const total = computed(() => items.value.reduce((s, r) => s + Number(r[props.valueField]), 0))
87
+
88
+ const hoveredSeg = computed(() => {
89
+ if (hoveredIndex.value === -1) return null
90
+ return hitSegments.value[hoveredIndex.value]
91
+ })
92
+
93
+ const arcPath = (cx: number, cy: number, r: number, startAngle: number, endAngle: number): string => {
94
+ const x1 = cx + r * Math.cos(startAngle)
95
+ const y1 = cy + r * Math.sin(startAngle)
96
+ const x2 = cx + r * Math.cos(endAngle)
97
+ const y2 = cy + r * Math.sin(endAngle)
98
+ const large = endAngle - startAngle > Math.PI ? 1 : 0
99
+ if (props.donut) {
100
+ const ri = r * 0.5
101
+ const xi1 = cx + ri * Math.cos(endAngle)
102
+ const yi1 = cy + ri * Math.sin(endAngle)
103
+ const xi2 = cx + ri * Math.cos(startAngle)
104
+ const yi2 = cy + ri * Math.sin(startAngle)
105
+ return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} L ${xi1} ${yi1} A ${ri} ${ri} 0 ${large} 0 ${xi2} ${yi2} Z`
106
+ }
107
+ return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`
108
+ }
109
+
110
+ const draw = () => {
111
+ if (!ctx.value) return
112
+ clear(props.backgroundColor)
113
+ const c = ctx.value
114
+ const p = easeOut(progress.value)
115
+ const segs: ISegment[] = []
116
+
117
+ const cx = width.value / 2
118
+ const cy = props.showLegend ? (height.value - 50) / 2 : height.value / 2
119
+ const r = Math.min(width.value - 40, props.showLegend ? height.value - 100 : height.value - 40) / 2
120
+
121
+ let startAngle = -Math.PI / 2
122
+ const tot = total.value || 1
123
+
124
+ items.value.forEach((item, i) => {
125
+ const slice = (Number(item[props.valueField]) / tot) * Math.PI * 2 * p
126
+ const endAngle = startAngle + slice
127
+ const color = props.colors[i % props.colors.length]
128
+ const isHovered = hoveredIndex.value === i
129
+ const rr = isHovered ? r + 8 : r
130
+
131
+ c.save()
132
+ c.beginPath()
133
+ c.moveTo(cx, cy)
134
+ c.arc(cx, cy, rr, startAngle, endAngle)
135
+ c.closePath()
136
+ c.fillStyle = color
137
+ c.fill()
138
+ c.restore()
139
+
140
+ // Label
141
+ const midAngle = startAngle + slice / 2
142
+ const lr = r * 0.7
143
+ const lx = cx + lr * Math.cos(midAngle)
144
+ const ly = cy + lr * Math.sin(midAngle)
145
+ if (p > 0.5) {
146
+ c.save()
147
+ c.fillStyle = '#fff'
148
+ c.font = 'bold 11px system-ui, sans-serif'
149
+ c.textAlign = 'center'
150
+ c.textBaseline = 'middle'
151
+ const pct = ((Number(item[props.valueField]) / tot) * 100).toFixed(0) + '%'
152
+ c.fillText(pct, lx, ly)
153
+ c.restore()
154
+ }
155
+
156
+ segs.push({
157
+ d: arcPath(cx, cy, rr, startAngle, endAngle),
158
+ cx: cx + r * 0.75 * Math.cos(startAngle + slice / 2),
159
+ cy: cy + r * 0.75 * Math.sin(startAngle + slice / 2),
160
+ item, label: `${item[props.labelField]}: ${item[props.valueField]}`,
161
+ startAngle, endAngle
162
+ })
163
+ startAngle = endAngle
164
+ })
165
+
166
+ if (props.donut) {
167
+ c.save()
168
+ c.beginPath()
169
+ c.arc(cx, cy, r * 0.5, 0, Math.PI * 2)
170
+ c.fillStyle = '#000'
171
+ c.globalCompositeOperation = 'destination-out'
172
+ c.fill()
173
+ c.globalCompositeOperation = 'source-over'
174
+ c.restore()
175
+ }
176
+
177
+ hitSegments.value = segs
178
+
179
+ if (props.showLegend) {
180
+ drawLegend(c)
181
+ }
182
+
183
+ if (props.title) {
184
+ c.save()
185
+ c.fillStyle = 'rgba(120,120,140,0.9)'
186
+ c.font = 'bold 16px system-ui, sans-serif'
187
+ c.textAlign = 'center'
188
+ c.fillText(props.title, width.value / 2, 30)
189
+ c.restore()
190
+ }
191
+ }
192
+
193
+ const drawLegend = (c: CanvasRenderingContext2D) => {
194
+ c.save()
195
+ c.font = '11px system-ui, sans-serif'
196
+ c.textAlign = 'left'
197
+ c.textBaseline = 'middle'
198
+
199
+ const keys = items.value.map(it => String(it[props.labelField]))
200
+ let xOffset = 20
201
+ let yOffset = height.value - 25
202
+
203
+ keys.forEach((key, i) => {
204
+ const color = props.colors[i % props.colors.length]
205
+ c.fillStyle = color
206
+ c.fillRect(xOffset, yOffset - 4, 10, 8)
207
+
208
+ c.fillStyle = 'rgba(150,150,170,0.9)'
209
+ c.fillText(key, xOffset + 15, yOffset)
210
+
211
+ const textWidth = c.measureText(key).width
212
+ xOffset += textWidth + 35
213
+ if (xOffset > width.value - 50) {
214
+ xOffset = 20
215
+ yOffset -= 15
216
+ }
217
+ })
218
+ c.restore()
219
+ }
220
+
221
+ watch([items, hoveredIndex, progress, () => props.donut], draw, { deep: true })
222
+ watch(items, () => { if (props.animated) animate() }, { deep: true })
223
+
224
+ onMounted(async () => {
225
+ await nextTick()
226
+ if (containerRef.value && canvasRef.value) {
227
+ setupCanvas(canvasRef.value)
228
+ if (props.animated) animate(); else draw()
229
+ }
230
+ })
231
+
232
+ const onMouseMove = (e: MouseEvent) => {
233
+ const rect = containerRef.value?.getBoundingClientRect()
234
+ if (!rect) return
235
+
236
+ const cx = width.value / 2
237
+ const cy = props.showLegend ? (height.value - 50) / 2 : height.value / 2
238
+ const r = Math.min(width.value - 40, props.showLegend ? height.value - 100 : height.value - 40) / 2
239
+
240
+ const mx = e.clientX - rect.left
241
+ const my = e.clientY - rect.top
242
+
243
+ const dx = mx - cx
244
+ const dy = my - cy
245
+ const dist = Math.sqrt(dx * dx + dy * dy)
246
+
247
+ // Check if within outer radius and (if donut) outside inner radius
248
+ const inRadius = dist <= r + 10 && (!props.donut || dist >= r * 0.5 - 5)
249
+
250
+ if (inRadius) {
251
+ let angle = Math.atan2(dy, dx)
252
+ if (angle < -Math.PI / 2) angle += Math.PI * 2
253
+
254
+ const idx = hitSegments.value.findIndex(seg => angle >= seg.startAngle && angle < seg.endAngle)
255
+ if (idx !== -1) {
256
+ const seg = hitSegments.value[idx]
257
+ setHovered(idx)
258
+ showTooltip(mx + 10, my - 10, seg.label, seg.item, idx)
259
+ emit('hover-point', seg.item, idx)
260
+ return
261
+ }
262
+ }
263
+
264
+ setHovered(-1)
265
+ hideTooltip()
266
+ }
267
+
268
+ const onMouseLeave = () => {
269
+ setHovered(-1)
270
+ hideTooltip()
271
+ }
272
+
273
+ defineExpose({
274
+ exportPng: () => canvasRef.value && exportPng(canvasRef.value, 'pie-chart.png'),
275
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, 'pie-chart.svg')
276
+ })
277
+ </script>
278
+
279
+ <style lang="scss" scoped>
280
+ .k-chart-wrapper {
281
+ position: relative; user-select: none;
282
+ .k-chart-inner {
283
+ width: 100%;
284
+ height: 400px;
285
+ position: relative;
286
+ canvas, .k-chart-svg {
287
+ position: absolute;
288
+ top: 0;
289
+ left: 0;
290
+ width: 100%;
291
+ height: 100%;
292
+ display: block;
293
+ }
294
+ .k-chart-svg {
295
+ overflow: visible;
296
+ }
297
+ }
298
+ .k-chart-svg {
299
+ pointer-events: none;
300
+ .k-chart-hit-proxy {
301
+ pointer-events: all;
302
+ cursor: pointer;
303
+ }
304
+ }
305
+ .k-chart-tooltip { position: absolute; background: var(--bg-color-elevated, #1e1e2e); color: var(--text-color-primary, #cdd6f4); border: 1px solid var(--border-color-medium, #45475a); border-radius: 8px; padding: 6px 10px; font-size: 12px; pointer-events: none; z-index: 20; white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
306
+ }
307
+ </style>
@@ -0,0 +1,375 @@
1
+ <template lang="pug">
2
+ .k-chart-wrapper(
3
+ :class="{ 'k-chart--grabbing': isDragging, 'k-chart--pannable': canPan }"
4
+ :style="{ cursor: isDragging ? 'grabbing' : canPan ? 'grab' : 'default' }"
5
+ @wheel.prevent="onWheel"
6
+ @mousedown="onMouseDown"
7
+ @mousemove="onMouseMove"
8
+ @mouseup="onMouseUp"
9
+ @mouseleave="onMouseLeave"
10
+ )
11
+ .k-chart-controls(v-if="zoomable")
12
+ KButton(size="small" @click="zoomIn" title="Zoom In") +
13
+ KButton(size="small" @click="zoomOut" title="Zoom Out") −
14
+ KButton(size="small" @click="resetZoom" title="Reset") ↺
15
+ .k-chart-inner(ref="containerRef")
16
+ KLoader(:loading="isLoading" overlay)
17
+ canvas(ref="canvasRef" v-show="!isLoading")
18
+ svg.k-chart-svg(v-show="!isLoading" ref="svgRef" :width="width" :height="height")
19
+ defs
20
+ clipPath(id="plot-clip")
21
+ rect(
22
+ :x="axes.plotLeft"
23
+ :y="axes.plotTop"
24
+ :width="axes.plotWidth.value"
25
+ :height="axes.plotHeight.value"
26
+ )
27
+ g.k-chart-hits
28
+ // No circles here! Distance-based hit testing now.
29
+
30
+ // Dynamic Interaction Layer
31
+ g.k-chart-interaction(v-if="hoveredIndex !== -1 && !isLoading" clip-path="url(#plot-clip)")
32
+ circle.k-chart-hit-proxy(
33
+ :cx="hoveredPoint.x"
34
+ :cy="hoveredPoint.y"
35
+ :r="20"
36
+ fill="transparent"
37
+ style="cursor: pointer"
38
+ @click="emit('click-point', hoveredPoint.item, hoveredPoint.index)"
39
+ )
40
+ circle(
41
+ :cx="hoveredPoint.x"
42
+ :cy="hoveredPoint.y"
43
+ :r="hoveredPoint.r + 3"
44
+ fill="transparent"
45
+ :stroke="hoveredPoint.color"
46
+ stroke-width="2"
47
+ pointer-events="none"
48
+ )
49
+ .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
50
+ slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
51
+ span {{ tooltipState.content }}
52
+ </template>
53
+
54
+ <script lang="ts" setup>
55
+ import { ref, watch, computed, onMounted, nextTick } from 'vue'
56
+ import { useChartCanvas } from '../../composables/useChartCanvas'
57
+ import { useChartSvg } from '../../composables/useChartSvg'
58
+ import { useChartData } from '../../composables/useChartData'
59
+ import { useChartAxes } from '../../composables/useChartAxes'
60
+ import { useChartViewport } from '../../composables/useChartViewport'
61
+ import { useChartAnimation, easeOut } from '../../composables/useChartAnimation'
62
+ import { useChartExport } from '../../composables/useChartExport'
63
+ import { useChartHitTest } from '../../composables/useChartHitTest'
64
+
65
+ const props = withDefaults(defineProps<{
66
+ dataProvider?: any
67
+ xField?: string
68
+ yField?: string
69
+ seriesField?: string
70
+ sizeField?: string
71
+ labelField?: string
72
+ animated?: boolean
73
+ zoomable?: boolean
74
+ maxZoom?: number
75
+ colors?: string[]
76
+ backgroundColor?: string
77
+ showLegend?: boolean
78
+ xAxisTitle?: string
79
+ yAxisTitle?: string
80
+ }>(), {
81
+ xField: 'x',
82
+ yField: 'y',
83
+ seriesField: 'series',
84
+ sizeField: 'size',
85
+ labelField: 'label',
86
+ animated: true,
87
+ zoomable: true,
88
+ maxZoom: 20,
89
+ colors: () => ['#6366f1', '#22d3ee', '#f59e0b', '#10b981', '#f43f5e', '#a855f7', '#14b8a6', '#fb923c'],
90
+ backgroundColor: '#ffffff',
91
+ showLegend: true,
92
+ xAxisTitle: '',
93
+ yAxisTitle: ''
94
+ })
95
+
96
+ const emit = defineEmits<{
97
+ 'click-point': [item: any, index: number]
98
+ 'hover-point': [item: any, index: number]
99
+ 'zoom-change': [scale: number]
100
+ 'pan-change': [offset: { x: number; y: number }]
101
+ }>()
102
+
103
+ const containerRef = ref<HTMLElement | null>(null)
104
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
105
+ const svgRef = ref<SVGSVGElement | null>(null)
106
+
107
+ const { ctx, width, height, clear, setupCanvas } = useChartCanvas()
108
+ const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg()
109
+ const dataRef = computed(() => props.dataProvider)
110
+ const { items } = useChartData(dataRef)
111
+ const { progress, animate } = useChartAnimation()
112
+ const { exportPng, exportSvg: exportSvgFile } = useChartExport()
113
+
114
+ const isLoading = computed(() => {
115
+ return props.dataProvider?.loading?.value || (props.dataProvider as any)?.initialLoad?.value || false
116
+ })
117
+
118
+ const maxZoomRef = computed(() => props.maxZoom)
119
+ const plotWidthRef = computed(() => Math.max(0, width.value - 75))
120
+ const plotLeftRef = computed(() => 55)
121
+ const viewport = useChartViewport({ maxZoom: maxZoomRef, plotWidth: plotWidthRef, plotLeft: plotLeftRef })
122
+ const { findNearestEuclidean } = useChartHitTest()
123
+
124
+ const {
125
+ scale, panOffset, isDragging, canPan,
126
+ zoomIn, zoomOut, resetZoom,
127
+ onWheel, onMouseDown, onMouseMove: onViewportMouseMove, onMouseUp, onMouseLeave: onViewportMouseLeave
128
+ } = viewport
129
+
130
+ const xFieldRef = computed(() => props.xField)
131
+ const yFieldRef = computed(() => props.yField)
132
+ const axes = useChartAxes({ items, xField: xFieldRef, yField: yFieldRef, width, height, scale, panOffset, padding: { top: 20, right: 20, bottom: 40, left: 55 } })
133
+
134
+ // hitPoints removed to save memory and DOM nodes
135
+
136
+ const maxSize = computed(() => Math.max(...items.value.map(r => Number(r[props.sizeField] ?? 1)), 1))
137
+
138
+ // Group items by series
139
+ const seriesMap = computed(() => {
140
+ const map = new Map<string, any[]>()
141
+ for (const item of items.value) {
142
+ const key = String(item[props.seriesField] ?? 'default')
143
+ if (!map.has(key)) map.set(key, [])
144
+ map.get(key)!.push(item)
145
+ }
146
+ return map
147
+ })
148
+
149
+ const hoveredPoint = computed(() => {
150
+ if (hoveredIndex.value === -1) return null
151
+ const item = items.value[hoveredIndex.value]
152
+ const sizeRaw = Number(item[props.sizeField] ?? 1)
153
+ const r = 4 + (sizeRaw / maxSize.value) * 16
154
+
155
+ // Find color for this item
156
+ const seriesKey = String(item[props.seriesField] ?? 'default')
157
+ const seriesKeys = Array.from(seriesMap.value.keys())
158
+ const seriesIdx = seriesKeys.indexOf(seriesKey)
159
+ const color = props.colors[seriesIdx % props.colors.length]
160
+
161
+ return {
162
+ x: axes.toX(Number(item[props.xField])),
163
+ y: axes.toY(Number(item[props.yField])),
164
+ r,
165
+ color,
166
+ item,
167
+ index: hoveredIndex.value
168
+ }
169
+ })
170
+
171
+ const draw = () => {
172
+ if (!ctx.value) return
173
+ clear(props.backgroundColor)
174
+ const c = ctx.value
175
+ const p = easeOut(progress.value)
176
+
177
+ // Grid & axes
178
+ c.save()
179
+ c.strokeStyle = 'rgba(100,100,120,0.1)'; c.lineWidth = 1
180
+ // Horizontal grid
181
+ for (let i = 0; i <= 6; i++) {
182
+ const y = axes.plotTop + (axes.plotHeight.value / 6) * i
183
+ c.beginPath(); c.moveTo(axes.plotLeft, y); c.lineTo(axes.plotLeft + axes.plotWidth.value, y); c.stroke()
184
+ }
185
+ // Vertical grid
186
+ for (let i = 0; i <= 6; i++) {
187
+ const x = axes.plotLeft + (axes.plotWidth.value / 6) * i
188
+ c.beginPath(); c.moveTo(x, axes.plotTop); c.lineTo(x, axes.plotTop + axes.plotHeight.value); c.stroke()
189
+ }
190
+
191
+ c.strokeStyle = 'rgba(150,150,170,0.5)'
192
+ c.beginPath(); c.moveTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value); c.lineTo(axes.plotLeft + axes.plotWidth.value, axes.plotTop + axes.plotHeight.value); c.stroke()
193
+ c.beginPath(); c.moveTo(axes.plotLeft, axes.plotTop); c.lineTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value); c.stroke()
194
+
195
+ c.fillStyle = 'rgba(150,150,170,0.8)'; c.font = '11px system-ui, sans-serif'
196
+
197
+ // Y labels
198
+ c.textAlign = 'right'
199
+ for (let i = 0; i <= 5; i++) {
200
+ const val = axes.yMin.value + (axes.yMax.value - axes.yMin.value) * (i / 5)
201
+ c.fillText(val.toFixed(1), axes.plotLeft - 8, axes.toY(val) + 4)
202
+ }
203
+
204
+ // X labels
205
+ c.textAlign = 'center'
206
+ for (let i = 0; i <= 5; i++) {
207
+ const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / 5)
208
+ c.fillText(val.toFixed(1), axes.toX(val), axes.plotTop + axes.plotHeight.value + 16)
209
+ }
210
+
211
+ // Titles
212
+ c.fillStyle = 'rgba(120,120,140,0.8)'
213
+ if (props.xAxisTitle) {
214
+ c.fillText(props.xAxisTitle, axes.plotLeft + axes.plotWidth.value / 2, axes.plotTop + axes.plotHeight.value + 34)
215
+ }
216
+ if (props.yAxisTitle) {
217
+ c.save()
218
+ c.translate(axes.plotLeft - 40, axes.plotTop + axes.plotHeight.value / 2)
219
+ c.rotate(-Math.PI / 2)
220
+ c.textAlign = 'center'
221
+ c.fillText(props.yAxisTitle, 0, 0)
222
+ c.restore()
223
+ }
224
+ c.restore()
225
+
226
+ c.save()
227
+ c.beginPath()
228
+ c.rect(axes.plotLeft, axes.plotTop, axes.plotWidth.value, axes.plotHeight.value)
229
+ c.clip()
230
+
231
+ let seriesIdx = 0
232
+ for (const [, seriesItems] of seriesMap.value) {
233
+ const color = props.colors[seriesIdx % props.colors.length]
234
+
235
+ seriesItems.forEach((item) => {
236
+ const x = axes.toX(Number(item[props.xField]))
237
+ const y = axes.toY(Number(item[props.yField]))
238
+ const sizeRaw = Number(item[props.sizeField] ?? 1)
239
+ const r = 4 + (sizeRaw / maxSize.value) * 16
240
+
241
+ // Global index if needed for hover, but findNearestEuclidean uses full items array
242
+ // We need to know if THIS specific item is the one hovered
243
+ const isHovered = items.value[hoveredIndex.value] === item
244
+ const alpha = p
245
+
246
+ c.save()
247
+ c.globalAlpha = alpha * (isHovered ? 1 : 0.75)
248
+ c.beginPath()
249
+ c.arc(x, y, isHovered ? r + 3 : r, 0, Math.PI * 2)
250
+ c.fillStyle = color
251
+ c.fill()
252
+ c.strokeStyle = '#fff'
253
+ c.lineWidth = 1.5
254
+ c.stroke()
255
+ c.restore()
256
+ })
257
+ seriesIdx++
258
+ }
259
+
260
+ c.restore()
261
+
262
+ if (props.showLegend) {
263
+ drawLegend(c)
264
+ }
265
+ }
266
+
267
+ const drawLegend = (c: CanvasRenderingContext2D) => {
268
+ c.save()
269
+ c.font = '11px system-ui, sans-serif'
270
+ c.textAlign = 'left'
271
+ c.textBaseline = 'middle'
272
+
273
+ let xOffset = axes.plotLeft
274
+ const yPos = axes.plotTop + axes.plotHeight.value + 28
275
+
276
+ let seriesIdx = 0
277
+ for (const [key] of seriesMap.value) {
278
+ const color = props.colors[seriesIdx % props.colors.length]
279
+
280
+ c.fillStyle = color
281
+ c.beginPath(); c.arc(xOffset + 5, yPos, 4, 0, Math.PI * 2); c.fill()
282
+
283
+ c.fillStyle = 'rgba(150,150,170,0.9)'
284
+ const label = key === 'default' ? 'Series' : key
285
+ c.fillText(label, xOffset + 15, yPos)
286
+
287
+ const metrics = c.measureText(label)
288
+ xOffset += metrics.width + 35
289
+ seriesIdx++
290
+ }
291
+ c.restore()
292
+ }
293
+
294
+ watch([items, scale, panOffset, hoveredIndex, progress], draw, { deep: true })
295
+ watch(items, () => { if (props.animated) animate() }, { deep: true })
296
+ watch(scale, v => emit('zoom-change', v))
297
+ watch(panOffset, v => emit('pan-change', v), { deep: true })
298
+
299
+ onMounted(async () => {
300
+ await nextTick()
301
+ if (containerRef.value && canvasRef.value) {
302
+ containerRef.value.style.position = 'relative'
303
+ setupCanvas(canvasRef.value)
304
+ if (props.animated) animate(); else draw()
305
+ }
306
+ })
307
+
308
+ const onMouseMove = (e: MouseEvent) => {
309
+ onViewportMouseMove(e)
310
+ if (isDragging.value) {
311
+ setHovered(-1)
312
+ hideTooltip()
313
+ return
314
+ }
315
+
316
+ const rect = containerRef.value?.getBoundingClientRect()
317
+ if (!rect) return
318
+ const mx = e.clientX - rect.left
319
+ const my = e.clientY - rect.top
320
+
321
+ const nearest = findNearestEuclidean(items.value, mx, my, axes, props.xField, props.yField, 20)
322
+ if (nearest) {
323
+ setHovered(nearest.index)
324
+ showTooltip(mx + 10, my - 10, `${nearest.item[props.xField]}: ${nearest.item[props.yField]}`, nearest.item, nearest.index)
325
+ emit('hover-point', nearest.item, nearest.index)
326
+ } else {
327
+ setHovered(-1)
328
+ hideTooltip()
329
+ }
330
+ }
331
+
332
+ const onMouseLeave = (e: MouseEvent) => {
333
+ onViewportMouseLeave(e)
334
+ setHovered(-1)
335
+ hideTooltip()
336
+ }
337
+
338
+ defineExpose({
339
+ zoomIn: viewport.zoomIn, zoomOut: viewport.zoomOut, resetZoom: viewport.resetZoom,
340
+ exportPng: () => canvasRef.value && exportPng(canvasRef.value, 'scatter-chart.png'),
341
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, 'scatter-chart.svg')
342
+ })
343
+ </script>
344
+
345
+ <style lang="scss" scoped>
346
+ .k-chart-wrapper {
347
+ position: relative; user-select: none;
348
+ .k-chart-controls { position: absolute; top: 8px; right: 8px; z-index: 10; display: flex; gap: 4px; }
349
+ .k-chart-btn { display: none; }
350
+ .k-chart-inner {
351
+ width: 100%;
352
+ height: 400px;
353
+ position: relative;
354
+ canvas, .k-chart-svg {
355
+ position: absolute;
356
+ top: 0;
357
+ left: 0;
358
+ width: 100%;
359
+ height: 100%;
360
+ display: block;
361
+ }
362
+ .k-chart-svg {
363
+ overflow: visible;
364
+ }
365
+ }
366
+ .k-chart-svg {
367
+ pointer-events: none;
368
+ .k-chart-hit-proxy {
369
+ pointer-events: all;
370
+ cursor: pointer;
371
+ }
372
+ }
373
+ .k-chart-tooltip { position: absolute; background: var(--bg-color-elevated, #1e1e2e); color: var(--text-color-primary, #cdd6f4); border: 1px solid var(--border-color-medium, #45475a); border-radius: 8px; padding: 6px 10px; font-size: 12px; pointer-events: none; z-index: 20; white-space: nowrap; box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
374
+ }
375
+ </style>