@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,493 @@
1
+ <template lang="pug">
2
+ .k-chart-wrapper(
3
+ ref="wrapperRef"
4
+ :class="{ 'k-chart--grabbing': isDragging, 'k-chart--pannable': canPan }"
5
+ :style="{ cursor: isDragging ? 'grabbing' : canPan ? 'grab' : 'default' }"
6
+ @wheel.prevent="onWheel"
7
+ @mousedown="onMouseDown"
8
+ @mousemove="onMouseMove"
9
+ @mouseup="onMouseUp"
10
+ @mouseleave="onMouseLeave"
11
+ )
12
+ .k-chart-controls(v-if="zoomable")
13
+ KButton(size="small" @click="zoomIn" title="Zoom In") +
14
+ KButton(size="small" @click="zoomOut" title="Zoom Out") −
15
+ KButton(size="small" @click="resetZoom" title="Reset Zoom") ↺
16
+ .k-chart-inner(ref="containerRef")
17
+ KLoader(:loading="isLoading" overlay)
18
+ canvas(ref="canvasRef" v-show="!isLoading")
19
+ svg.k-chart-svg(
20
+ v-show="!isLoading"
21
+ ref="svgRef"
22
+ :width="width"
23
+ :height="height"
24
+ )
25
+ defs
26
+ clipPath(id="plot-clip")
27
+ rect(
28
+ :x="axes.plotLeft"
29
+ :y="axes.plotTop"
30
+ :width="axes.plotWidth.value"
31
+ :height="axes.plotHeight.value"
32
+ )
33
+ // 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)")
35
+ circle.k-chart-hit-proxy(
36
+ v-for="(pt, i) in hoveredPoints"
37
+ :key="i"
38
+ :cx="pt.x"
39
+ :cy="pt.y"
40
+ :r="10"
41
+ fill="transparent"
42
+ style="cursor: pointer"
43
+ @click="emit('click-point', pt.item, pt.index)"
44
+ )
45
+ // Visible indicators
46
+ circle(
47
+ v-for="(pt, i) in hoveredPoints"
48
+ :key="'v' + i"
49
+ :cx="pt.x"
50
+ :cy="pt.y"
51
+ :r="5"
52
+ :fill="pt.color"
53
+ stroke="#fff"
54
+ stroke-width="2"
55
+ pointer-events="none"
56
+ )
57
+ // Tooltip
58
+ .k-chart-tooltip(
59
+ v-if="tooltipState.visible && !isLoading"
60
+ :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }"
61
+ )
62
+ slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
63
+ span {{ tooltipState.content }}
64
+ </template>
65
+
66
+ <script lang="ts" setup>
67
+ import { ref, watch, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
68
+ import { useChartCanvas } from '../../composables/useChartCanvas'
69
+ import { useChartSvg } from '../../composables/useChartSvg'
70
+ import { useChartData } from '../../composables/useChartData'
71
+ import { useChartAxes, parseValue } from '../../composables/useChartAxes'
72
+ import { useChartViewport } from '../../composables/useChartViewport'
73
+ import { useChartAnimation, easeOut } from '../../composables/useChartAnimation'
74
+ import { useChartExport } from '../../composables/useChartExport'
75
+ import { useChartHitTest } from '../../composables/useChartHitTest'
76
+
77
+ interface ILinePoint { x: number; y: number; item: any }
78
+
79
+ const props = withDefaults(defineProps<{
80
+ dataProvider?: any
81
+ xField?: string
82
+ yField?: string
83
+ seriesField?: string
84
+ animated?: boolean
85
+ zoomable?: boolean
86
+ maxZoom?: number
87
+ colors?: string[]
88
+ backgroundColor?: string
89
+ showLegend?: boolean
90
+ xAxisTitle?: string
91
+ yAxisTitle?: string
92
+ }>(), {
93
+ xField: 'x',
94
+ yField: 'y',
95
+ seriesField: 'series',
96
+ animated: true,
97
+ zoomable: true,
98
+ maxZoom: 10,
99
+ colors: () => ['#6366f1', '#22d3ee', '#f59e0b', '#10b981', '#f43f5e', '#a855f7', '#14b8a6', '#fb923c'],
100
+ backgroundColor: '#ffffff',
101
+ showLegend: true,
102
+ xAxisTitle: '',
103
+ yAxisTitle: ''
104
+ })
105
+
106
+ const emit = defineEmits<{
107
+ 'click-point': [item: any, index: number]
108
+ 'hover-point': [item: any, index: number]
109
+ 'zoom-change': [scale: number]
110
+ 'pan-change': [offset: { x: number; y: number }]
111
+ }>()
112
+
113
+ const wrapperRef = ref<HTMLElement | null>(null)
114
+ const containerRef = ref<HTMLElement | null>(null)
115
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
116
+ const svgRef = ref<SVGSVGElement | null>(null)
117
+
118
+ const { ctx, width, height, clear, setupCanvas } = useChartCanvas()
119
+ const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg()
120
+ const dataRef = computed(() => props.dataProvider)
121
+ const { items, loading } = useChartData(dataRef)
122
+ const { progress, animate } = useChartAnimation()
123
+ const { exportPng, exportSvg: exportSvgFile } = useChartExport()
124
+
125
+ const isLoading = computed(() => {
126
+ return props.dataProvider?.loading?.value || (props.dataProvider as any)?.initialLoad?.value || false
127
+ })
128
+
129
+ const maxZoomRef = computed(() => props.maxZoom)
130
+ const plotWidthRef = computed(() => Math.max(0, width.value - 75))
131
+ const plotLeftRef = computed(() => 55)
132
+
133
+ const viewport = useChartViewport({ maxZoom: maxZoomRef, plotWidth: plotWidthRef, plotLeft: plotLeftRef })
134
+ const { findNearestX } = useChartHitTest()
135
+
136
+ const {
137
+ scale, panOffset, isDragging, canPan,
138
+ zoomIn, zoomOut, resetZoom,
139
+ onWheel, onMouseDown, onMouseMove: onViewportMouseMove, onMouseUp, onMouseLeave: onViewportMouseLeave
140
+ } = viewport
141
+
142
+ const xFieldRef = computed(() => props.xField)
143
+ const yFieldRef = computed(() => props.yField)
144
+
145
+ // Group items by series
146
+ const seriesMap = computed(() => {
147
+ const map = new Map<string, any[]>()
148
+ for (const item of items.value) {
149
+ const key = String(item[props.seriesField] ?? 'default')
150
+ if (!map.has(key)) map.set(key, [])
151
+ map.get(key)!.push(item)
152
+ }
153
+ return map
154
+ })
155
+
156
+ const axes = useChartAxes({
157
+ items,
158
+ xField: xFieldRef,
159
+ yField: yFieldRef,
160
+ width,
161
+ height,
162
+ scale,
163
+ panOffset,
164
+ padding: { top: 20, right: 20, bottom: 40, left: 55 }
165
+ })
166
+
167
+ // hitPoints is no longer needed for SVG, but we'll keep the logic if needed for something else,
168
+ // or just remove it to save memory. I'll remove it.
169
+
170
+ const draw = () => {
171
+ if (!ctx.value) return
172
+ clear(props.backgroundColor)
173
+ const c = ctx.value
174
+ const p = easeOut(progress.value)
175
+
176
+ drawGrid(c)
177
+ drawAxes(c)
178
+
179
+ // Clipping for plot area
180
+ c.save()
181
+ c.beginPath()
182
+ c.rect(axes.plotLeft, axes.plotTop, axes.plotWidth.value, axes.plotHeight.value)
183
+ c.clip()
184
+
185
+ let seriesIdx = 0
186
+ for (const [, seriesItems] of seriesMap.value) {
187
+ const color = props.colors[seriesIdx % props.colors.length]
188
+ drawLine(c, seriesItems, color, p)
189
+ seriesIdx++
190
+ }
191
+ c.restore()
192
+
193
+ if (props.showLegend) {
194
+ drawLegend(c)
195
+ }
196
+ }
197
+
198
+ const drawLegend = (c: CanvasRenderingContext2D) => {
199
+ c.save()
200
+ c.font = '11px system-ui, sans-serif'
201
+ c.textAlign = 'left'
202
+ c.textBaseline = 'middle'
203
+
204
+ const keys = Array.from(seriesMap.value.keys())
205
+ let xOffset = axes.plotLeft
206
+ const yPos = axes.plotTop + axes.plotHeight.value + 28
207
+
208
+ keys.forEach((key, i) => {
209
+ const color = props.colors[i % props.colors.length]
210
+
211
+ // Icon
212
+ c.fillStyle = color
213
+ c.beginPath()
214
+ c.arc(xOffset + 5, yPos, 4, 0, Math.PI * 2)
215
+ c.fill()
216
+
217
+ // Text
218
+ c.fillStyle = 'rgba(150,150,170,0.9)'
219
+ const label = key === 'default' ? 'Series' : key
220
+ c.fillText(label, xOffset + 15, yPos)
221
+
222
+ xOffset += c.measureText(label).width + 35
223
+ })
224
+ c.restore()
225
+ }
226
+
227
+ const drawGrid = (c: CanvasRenderingContext2D) => {
228
+ c.save()
229
+ c.strokeStyle = 'rgba(100,100,120,0.1)'
230
+ c.lineWidth = 1
231
+
232
+ // Horizontal grid
233
+ const ySteps = 6
234
+ for (let i = 0; i <= ySteps; i++) {
235
+ const y = axes.plotTop + (axes.plotHeight.value / ySteps) * i
236
+ c.beginPath()
237
+ c.moveTo(axes.plotLeft, y)
238
+ c.lineTo(axes.plotLeft + axes.plotWidth.value, y)
239
+ c.stroke()
240
+ }
241
+
242
+ // Vertical grid
243
+ const xSteps = 6
244
+ for (let i = 0; i <= xSteps; i++) {
245
+ const x = axes.plotLeft + (axes.plotWidth.value / xSteps) * i
246
+ c.beginPath()
247
+ c.moveTo(x, axes.plotTop)
248
+ c.lineTo(x, axes.plotTop + axes.plotHeight.value)
249
+ c.stroke()
250
+ }
251
+ c.restore()
252
+ }
253
+
254
+ const drawAxes = (c: CanvasRenderingContext2D) => {
255
+ c.save()
256
+ c.strokeStyle = 'rgba(150,150,170,0.5)'
257
+ c.lineWidth = 1
258
+ c.fillStyle = 'rgba(150,150,170,0.8)'
259
+ c.font = '11px system-ui, sans-serif'
260
+
261
+ // Y axis labels
262
+ c.textAlign = 'right'
263
+ const ySteps = 5
264
+ for (let i = 0; i <= ySteps; i++) {
265
+ const val = axes.yMin.value + (axes.yMax.value - axes.yMin.value) * (i / ySteps)
266
+ const y = axes.toY(val)
267
+ c.fillText(val.toFixed(1), axes.plotLeft - 8, y + 4)
268
+ }
269
+
270
+ // X axis labels
271
+ c.textAlign = 'center'
272
+ const xSteps = 5
273
+ for (let i = 0; i <= xSteps; i++) {
274
+ const val = axes.xMin.value + (axes.xMax.value - axes.xMin.value) * (i / xSteps)
275
+ const x = axes.toX(val)
276
+
277
+ let label = val.toFixed(1)
278
+ // If it's a large number (like a timestamp), format it briefly
279
+ if (val > 1000000000000) label = new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
280
+
281
+ c.fillText(label, x, axes.plotTop + axes.plotHeight.value + 16)
282
+ }
283
+
284
+ // X axis line
285
+ c.beginPath()
286
+ c.moveTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value)
287
+ c.lineTo(axes.plotLeft + axes.plotWidth.value, axes.plotTop + axes.plotHeight.value)
288
+ c.stroke()
289
+
290
+ // Y axis line
291
+ c.beginPath()
292
+ c.moveTo(axes.plotLeft, axes.plotTop)
293
+ c.lineTo(axes.plotLeft, axes.plotTop + axes.plotHeight.value)
294
+ c.stroke()
295
+
296
+ // Axis Titles
297
+ c.fillStyle = 'rgba(120,120,140,0.8)'
298
+ if (props.xAxisTitle) {
299
+ c.textAlign = 'center'
300
+ c.fillText(props.xAxisTitle, axes.plotLeft + axes.plotWidth.value / 2, axes.plotTop + axes.plotHeight.value + 34)
301
+ }
302
+ if (props.yAxisTitle) {
303
+ c.save()
304
+ c.translate(axes.plotLeft - 40, axes.plotTop + axes.plotHeight.value / 2)
305
+ c.rotate(-Math.PI / 2)
306
+ c.textAlign = 'center'
307
+ c.fillText(props.yAxisTitle, 0, 0)
308
+ c.restore()
309
+ }
310
+
311
+ c.restore()
312
+ }
313
+
314
+ const drawLine = (c: CanvasRenderingContext2D, data: any[], color: string, progress: number) => {
315
+ if (!data.length) return
316
+ const sorted = [...data].sort((a, b) => parseValue(a[props.xField]) - parseValue(b[props.xField]))
317
+ const totalPts = sorted.length
318
+ const drawCount = Math.max(1, Math.floor(totalPts * progress))
319
+
320
+ c.save()
321
+ c.strokeStyle = color
322
+ c.lineWidth = 2.5
323
+ c.lineJoin = 'round'
324
+ c.lineCap = 'round'
325
+ c.beginPath()
326
+ sorted.slice(0, drawCount).forEach((item, i) => {
327
+ const x = axes.toX(parseValue(item[props.xField]))
328
+ const y = axes.toY(parseValue(item[props.yField]))
329
+ if (i === 0) c.moveTo(x, y)
330
+ else c.lineTo(x, y)
331
+ })
332
+ c.stroke()
333
+
334
+ // Draw dots
335
+ for (let i = 0; i < drawCount; i++) {
336
+ const item = sorted[i]
337
+ const x = axes.toX(parseValue(item[props.xField]))
338
+ const y = axes.toY(parseValue(item[props.yField]))
339
+ const isHovered = hoveredIndex.value === items.value.indexOf(item)
340
+ c.beginPath()
341
+ c.arc(x, y, isHovered ? 5 : 3, 0, Math.PI * 2)
342
+ c.fillStyle = isHovered ? '#fff' : color
343
+ c.strokeStyle = color
344
+ c.lineWidth = 2
345
+ c.fill()
346
+ c.stroke()
347
+ }
348
+ c.restore()
349
+ }
350
+
351
+ // React to data, pan, scale, hover
352
+ watch([items, scale, panOffset, hoveredIndex, progress], draw, { deep: true })
353
+ watch(items, () => { if (props.animated) animate() }, { deep: true })
354
+ watch(scale, (v) => emit('zoom-change', v))
355
+ watch(panOffset, (v) => emit('pan-change', v), { deep: true })
356
+
357
+ onMounted(async () => {
358
+ await nextTick()
359
+ if (containerRef.value && canvasRef.value) {
360
+ setupCanvas(canvasRef.value)
361
+ if (props.animated) animate()
362
+ else draw()
363
+ }
364
+ })
365
+
366
+ // Points for all series at the current hovered X
367
+ const hoveredPoints = computed(() => {
368
+ if (hoveredIndex.value === -1) return []
369
+
370
+ const item = items.value[hoveredIndex.value]
371
+ const xVal = parseValue(item[props.xField])
372
+ const xPx = axes.toX(xVal)
373
+
374
+ const pts: any[] = []
375
+ let seriesIdx = 0
376
+ for (const [key, seriesItems] of seriesMap.value) {
377
+ const seriesItem = seriesItems.find(it => parseValue(it[props.xField]) === xVal)
378
+ if (seriesItem) {
379
+ pts.push({
380
+ x: xPx,
381
+ y: axes.toY(parseValue(seriesItem[props.yField])),
382
+ color: props.colors[seriesIdx % props.colors.length],
383
+ item: seriesItem,
384
+ index: items.value.indexOf(seriesItem)
385
+ })
386
+ }
387
+ seriesIdx++
388
+ }
389
+ return pts
390
+ })
391
+
392
+ const onMouseMove = (e: MouseEvent) => {
393
+ onViewportMouseMove(e)
394
+
395
+ if (isDragging.value) {
396
+ setHovered(-1)
397
+ hideTooltip()
398
+ return
399
+ }
400
+
401
+ const rect = wrapperRef.value?.getBoundingClientRect()
402
+ if (!rect) return
403
+
404
+ const mouseX = e.clientX - rect.left
405
+ const mouseY = e.clientY - rect.top
406
+ const nearest = findNearestX(items.value, mouseX, props.xField, axes)
407
+
408
+ if (nearest) {
409
+ // Position tooltip near mouse cursor
410
+ setHovered(nearest.index)
411
+ showTooltip(mouseX + 10, mouseY - 20, `${nearest.item[props.xField]}: ${nearest.item[props.yField]}`, nearest.item, nearest.index)
412
+ emit('hover-point', nearest.item, nearest.index)
413
+ } else {
414
+ setHovered(-1)
415
+ hideTooltip()
416
+ }
417
+ }
418
+
419
+ const onMouseLeave = (e: MouseEvent) => {
420
+ onViewportMouseLeave(e)
421
+ setHovered(-1)
422
+ hideTooltip()
423
+ }
424
+
425
+ // Exposed methods
426
+ defineExpose({
427
+ zoomIn: viewport.zoomIn,
428
+ zoomOut: viewport.zoomOut,
429
+ resetZoom: viewport.resetZoom,
430
+ exportPng: () => canvasRef.value && exportPng(canvasRef.value, 'line-chart.png'),
431
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, 'line-chart.svg')
432
+ })
433
+ </script>
434
+
435
+ <style lang="scss" scoped>
436
+ .k-chart-wrapper {
437
+ position: relative;
438
+ user-select: none;
439
+
440
+ .k-chart-controls {
441
+ position: absolute;
442
+ top: 8px;
443
+ right: 8px;
444
+ z-index: 10;
445
+ display: flex;
446
+ gap: 4px;
447
+ }
448
+
449
+
450
+
451
+ .k-chart-inner {
452
+ width: 100%;
453
+ height: 400px;
454
+ position: relative;
455
+
456
+ canvas, .k-chart-svg {
457
+ position: absolute;
458
+ top: 0;
459
+ left: 0;
460
+ width: 100%;
461
+ height: 100%;
462
+ display: block;
463
+ }
464
+
465
+ .k-chart-svg {
466
+ overflow: visible;
467
+ }
468
+ }
469
+
470
+ .k-chart-svg {
471
+ pointer-events: none;
472
+
473
+ .k-chart-hit-proxy {
474
+ pointer-events: all;
475
+ cursor: pointer;
476
+ }
477
+ }
478
+
479
+ .k-chart-tooltip {
480
+ position: absolute;
481
+ background: var(--bg-color-elevated, #1e1e2e);
482
+ color: var(--text-color-primary, #cdd6f4);
483
+ border: 1px solid var(--border-color-medium, #45475a);
484
+ border-radius: 8px;
485
+ padding: 6px 10px;
486
+ font-size: 12px;
487
+ pointer-events: none;
488
+ z-index: 20;
489
+ white-space: nowrap;
490
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
491
+ }
492
+ }
493
+ </style>