@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,427 @@
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="plotLeft"
23
+ :y="plotTop"
24
+ :width="plotWidth.value"
25
+ :height="plotHeight.value"
26
+ )
27
+ g.k-chart-hits
28
+ // Hit detection is now handled by calculation in onMouseMove
29
+
30
+ // Dynamic Interaction Layer
31
+ g.k-chart-interaction(v-if="hoveredIndex !== -1 && hoveredBar" clip-path="url(#plot-clip)")
32
+ rect.k-chart-hit-proxy(
33
+ :x="hoveredBar.x"
34
+ :y="hoveredBar.y"
35
+ :width="hoveredBar.w"
36
+ :height="hoveredBar.h"
37
+ fill="transparent"
38
+ style="cursor: pointer"
39
+ @click="emit('click-point', hoveredBar.item, hoveredIndex)"
40
+ )
41
+ rect(
42
+ :x="hoveredBar.x - 2"
43
+ :y="hoveredBar.y - 2"
44
+ :width="hoveredBar.w + 4"
45
+ :height="hoveredBar.h + 4"
46
+ fill="transparent"
47
+ stroke="#fff"
48
+ stroke-width="2"
49
+ rx="2"
50
+ pointer-events="none"
51
+ )
52
+ .k-chart-tooltip(v-if="tooltipState.visible && !isLoading" :style="{ left: tooltipState.x + 'px', top: tooltipState.y + 'px' }")
53
+ slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
54
+ span {{ tooltipState.content }}
55
+ </template>
56
+
57
+ <script lang="ts" setup>
58
+ import { ref, watch, computed, onMounted, nextTick } from 'vue'
59
+ import { useChartCanvas } from '../../composables/useChartCanvas'
60
+ import { useChartSvg } from '../../composables/useChartSvg'
61
+ import { useChartData } from '../../composables/useChartData'
62
+ import { useChartViewport } from '../../composables/useChartViewport'
63
+ import { useChartAnimation, easeOut } from '../../composables/useChartAnimation'
64
+ import { useChartExport } from '../../composables/useChartExport'
65
+
66
+ const props = withDefaults(defineProps<{
67
+ dataProvider?: any
68
+ categoryField?: string
69
+ valueField?: string
70
+ groupField?: string
71
+ orientation?: 'vertical' | 'horizontal'
72
+ mode?: 'simple' | 'clustered' | 'stacked'
73
+ animated?: boolean
74
+ zoomable?: boolean
75
+ maxZoom?: number
76
+ colors?: string[]
77
+ backgroundColor?: string
78
+ showLegend?: boolean
79
+ xAxisTitle?: string
80
+ yAxisTitle?: string
81
+ }>(), {
82
+ categoryField: 'category',
83
+ valueField: 'value',
84
+ groupField: 'group',
85
+ orientation: 'vertical',
86
+ mode: 'clustered',
87
+ animated: true,
88
+ zoomable: true,
89
+ maxZoom: 8,
90
+ colors: () => ['#6366f1', '#22d3ee', '#f59e0b', '#10b981', '#f43f5e', '#a855f7'],
91
+ backgroundColor: '#ffffff',
92
+ showLegend: true,
93
+ xAxisTitle: '',
94
+ yAxisTitle: ''
95
+ })
96
+
97
+ const emit = defineEmits<{
98
+ 'click-point': [item: any, index: number]
99
+ 'hover-point': [item: any, index: number]
100
+ 'zoom-change': [scale: number]
101
+ 'pan-change': [offset: { x: number; y: number }]
102
+ }>()
103
+
104
+ const containerRef = ref<HTMLElement | null>(null)
105
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
106
+ const svgRef = ref<SVGSVGElement | null>(null)
107
+
108
+ const { ctx, width, height, clear, setupCanvas } = useChartCanvas()
109
+ const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg()
110
+ const dataRef = computed(() => props.dataProvider)
111
+ const { items } = useChartData(dataRef)
112
+ const { progress, animate } = useChartAnimation()
113
+ const { exportPng, exportSvg: exportSvgFile } = useChartExport()
114
+
115
+ const isLoading = computed(() => {
116
+ return props.dataProvider?.loading?.value || (props.dataProvider as any)?.initialLoad?.value || false
117
+ })
118
+
119
+ const maxZoomRef = computed(() => props.maxZoom)
120
+ const plotWidthRef = computed(() => Math.max(0, width.value - 75))
121
+ const plotLeftRef = computed(() => 55)
122
+ const viewport = useChartViewport({ maxZoom: maxZoomRef, plotWidth: plotWidthRef, plotLeft: plotLeftRef })
123
+ const {
124
+ scale, panOffset, isDragging, canPan,
125
+ zoomIn, zoomOut, resetZoom,
126
+ onWheel, onMouseDown, onMouseMove: onViewportMouseMove, onMouseUp, onMouseLeave: onViewportMouseLeave
127
+ } = viewport
128
+
129
+ const PAD = { top: 20, right: 20, bottom: 50, left: 55 }
130
+
131
+ const plotLeft = PAD.left
132
+ const plotTop = PAD.top
133
+ const plotWidth = computed(() => Math.max(0, width.value - PAD.left - PAD.right))
134
+ const plotHeight = computed(() => Math.max(0, height.value - PAD.top - PAD.bottom))
135
+
136
+ // Gather unique categories and groups
137
+ const categories = computed(() => [...new Set(items.value.map(r => String(r[props.categoryField])))])
138
+ const groups = computed(() => [...new Set(items.value.map(r => String(r[props.groupField] ?? 'default')))])
139
+
140
+ const maxValue = computed(() => {
141
+ if (props.mode === 'stacked') {
142
+ const sums = categories.value.map(cat =>
143
+ items.value.filter(r => String(r[props.categoryField]) === cat)
144
+ .reduce((acc, r) => acc + Math.max(0, Number(r[props.valueField])), 0)
145
+ )
146
+ return Math.max(...sums, 10)
147
+ }
148
+ const vals = items.value.map(r => Number(r[props.valueField]))
149
+ return vals.length ? Math.max(...vals, 10) : 10
150
+ })
151
+
152
+ // Apply zoom/pan to visible categories
153
+ const visibleCatCount = computed(() => Math.max(1, Math.ceil(categories.value.length / scale.value)))
154
+ const panCatOffset = computed(() => {
155
+ const maxPan = categories.value.length - visibleCatCount.value
156
+ const ratio = panOffset.value.x / (plotWidth.value || 1)
157
+ return Math.round(Math.min(Math.max(ratio * categories.value.length, 0), maxPan))
158
+ })
159
+ const visibleCategories = computed(() =>
160
+ categories.value.slice(panCatOffset.value, panCatOffset.value + visibleCatCount.value)
161
+ )
162
+
163
+ interface IBarRect { x: number; y: number; w: number; h: number; item: any; label: string }
164
+ const hitBars = ref<IBarRect[]>([])
165
+
166
+ const hoveredBar = computed(() => {
167
+ if (hoveredIndex.value === -1) return null
168
+ return hitBars.value[hoveredIndex.value]
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
+ const bars: IBarRect[] = []
177
+
178
+ const catCount = visibleCategories.value.length
179
+ const gCount = groups.value.length || 1
180
+ const catWidth = plotWidth.value / (catCount || 1)
181
+
182
+ let barWidth = catWidth * 0.7
183
+ if (props.mode === 'clustered' && gCount > 1) {
184
+ barWidth = (catWidth * 0.8) / gCount
185
+ }
186
+
187
+ // Grid & Y labels
188
+ c.save()
189
+ c.strokeStyle = 'rgba(100,100,120,0.1)'
190
+ c.lineWidth = 1
191
+ c.fillStyle = 'rgba(150,150,170,0.8)'
192
+ c.font = '11px system-ui, sans-serif'
193
+ c.textAlign = 'right'
194
+ for (let i = 0; i <= 5; i++) {
195
+ const val = (maxValue.value / 5) * i
196
+ const y = plotTop + plotHeight.value - (val / maxValue.value) * plotHeight.value
197
+ c.beginPath(); c.moveTo(plotLeft, y); c.lineTo(plotLeft + plotWidth.value, y); c.stroke()
198
+ c.fillText(val.toFixed(0), plotLeft - 8, y + 4)
199
+ }
200
+
201
+ // Vertical grid (optional for bar charts, but let's add thin lines between categories)
202
+ c.strokeStyle = 'rgba(100,100,120,0.05)'
203
+ for (let i = 0; i <= catCount; i++) {
204
+ const x = plotLeft + i * catWidth
205
+ c.beginPath(); c.moveTo(x, plotTop); c.lineTo(x, plotTop + plotHeight.value); c.stroke()
206
+ }
207
+
208
+ // Lines
209
+ c.strokeStyle = 'rgba(150,150,170,0.5)'
210
+ c.beginPath(); c.moveTo(plotLeft, plotTop + plotHeight.value); c.lineTo(plotLeft + plotWidth.value, plotTop + plotHeight.value); c.stroke()
211
+ c.beginPath(); c.moveTo(plotLeft, plotTop); c.lineTo(plotLeft, plotTop + plotHeight.value); c.stroke()
212
+ c.restore()
213
+
214
+ // Clipping for plot area
215
+ c.save()
216
+ c.beginPath()
217
+ c.rect(plotLeft, plotTop, plotWidth.value, plotHeight.value)
218
+ c.clip()
219
+
220
+ // Draw bars per visible category
221
+ visibleCategories.value.forEach((cat, catIdx) => {
222
+ const catItems = items.value.filter(r => String(r[props.categoryField]) === cat)
223
+ const catX = plotLeft + catIdx * catWidth
224
+
225
+ if (props.mode === 'stacked') {
226
+ let yOffset = 0
227
+ groups.value.forEach((group, gIdx) => {
228
+ const item = catItems.find(r => String(r[props.groupField] ?? 'default') === group)
229
+ if (!item) return
230
+ const val = Number(item[props.valueField]) * p
231
+ const bh = (val / maxValue.value) * plotHeight.value
232
+ const bx = catX + (catWidth - barWidth) / 2
233
+ const by = plotTop + plotHeight.value - (yOffset + val) / maxValue.value * plotHeight.value
234
+ const isHovered = hoveredIndex.value === bars.length
235
+ const color = props.colors[gIdx % props.colors.length]
236
+ c.fillStyle = isHovered ? color : color + 'dd'
237
+ c.fillRect(bx, by, barWidth, bh)
238
+
239
+ // Stroke for separation
240
+ c.strokeStyle = 'rgba(255,255,255,0.3)'
241
+ c.lineWidth = 1
242
+ c.strokeRect(bx, by, barWidth, bh)
243
+
244
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` })
245
+ yOffset += Number(item[props.valueField])
246
+ })
247
+ } else if (props.mode === 'simple') {
248
+ const item = catItems[0]
249
+ if (item) {
250
+ const isHovered = hoveredIndex.value === bars.length
251
+ const val = Number(item[props.valueField]) * p
252
+ const bh = (val / maxValue.value) * plotHeight.value
253
+ const bx = catX + (catWidth - barWidth) / 2
254
+ const by = plotTop + plotHeight.value - bh
255
+ const color = props.colors[0] || '#6366f1'
256
+ c.fillStyle = isHovered ? color : color + 'dd'
257
+ c.fillRect(bx, by, barWidth, bh)
258
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat}: ${item[props.valueField]}` })
259
+ }
260
+ } else {
261
+ // clustered
262
+ groups.value.forEach((group, gIdx) => {
263
+ const item = catItems.find(r => String(r[props.groupField] ?? 'default') === group)
264
+ if (!item) return
265
+ const isHovered = hoveredIndex.value === bars.length
266
+ const val = Number(item[props.valueField]) * p
267
+ const bh = (val / maxValue.value) * plotHeight.value
268
+
269
+ const gCountTotal = groups.value.length
270
+ const totalBarsW = gCountTotal * barWidth + (gCountTotal - 1) * 2
271
+ const startX = catX + (catWidth - totalBarsW) / 2
272
+ const bx = startX + gIdx * (barWidth + 2)
273
+ const by = plotTop + plotHeight.value - bh
274
+
275
+ const color = props.colors[gIdx % props.colors.length]
276
+ c.fillStyle = isHovered ? color : color + 'dd'
277
+ c.fillRect(bx, by, barWidth, bh)
278
+ bars.push({ x: bx, y: by, w: barWidth, h: bh, item, label: `${cat} (${group}): ${item[props.valueField]}` })
279
+ })
280
+ }
281
+
282
+ })
283
+ c.restore()
284
+
285
+ // X labels - Draw AFTER clipping so they are visible
286
+ c.save()
287
+ c.fillStyle = 'rgba(150,150,170,0.9)'
288
+ c.font = '11px system-ui, sans-serif'
289
+ c.textAlign = 'center'
290
+ visibleCategories.value.forEach((cat, catIdx) => {
291
+ const catX = plotLeft + catIdx * catWidth
292
+ const label = cat.length > 10 ? cat.slice(0, 10) + '…' : cat
293
+ c.fillText(label, catX + catWidth / 2, plotTop + plotHeight.value + 18)
294
+ })
295
+ c.restore()
296
+
297
+ // Axis Titles
298
+ c.save()
299
+ c.fillStyle = 'rgba(120,120,140,0.8)'
300
+ if (props.xAxisTitle) {
301
+ c.textAlign = 'center'
302
+ c.fillText(props.xAxisTitle, plotLeft + plotWidth.value / 2, plotTop + plotHeight.value + 38)
303
+ }
304
+ if (props.yAxisTitle) {
305
+ c.translate(plotLeft - 40, plotTop + plotHeight.value / 2)
306
+ c.rotate(-Math.PI / 2)
307
+ c.textAlign = 'center'
308
+ c.fillText(props.yAxisTitle, 0, 0)
309
+ }
310
+ c.restore()
311
+
312
+ hitBars.value = bars
313
+ if (props.showLegend) {
314
+ drawLegend(c)
315
+ }
316
+ }
317
+
318
+ const drawLegend = (c: CanvasRenderingContext2D) => {
319
+ c.save()
320
+ c.font = '11px system-ui, sans-serif'
321
+ c.textAlign = 'left'
322
+ c.textBaseline = 'middle'
323
+
324
+ let xOffset = plotLeft
325
+ const yPos = plotTop + plotHeight.value + 35
326
+
327
+ groups.value.forEach((group, i) => {
328
+ const color = props.colors[i % props.colors.length]
329
+ c.fillStyle = color
330
+ c.fillRect(xOffset, yPos - 4, 10, 8)
331
+
332
+ c.fillStyle = 'rgba(150,150,170,0.9)'
333
+ const label = group === 'default' ? 'Series' : group
334
+ c.fillText(label, xOffset + 15, yPos)
335
+ xOffset += c.measureText(label).width + 35
336
+ })
337
+ c.restore()
338
+ }
339
+
340
+ watch([items, scale, panOffset, hoveredIndex, progress, () => props.mode, () => props.orientation], draw, { deep: true })
341
+ watch(items, () => { if (props.animated) animate() }, { deep: true })
342
+ watch(scale, v => emit('zoom-change', v))
343
+ watch(panOffset, v => emit('pan-change', v), { deep: true })
344
+
345
+ onMounted(async () => {
346
+ await nextTick()
347
+ if (containerRef.value && canvasRef.value) {
348
+ setupCanvas(canvasRef.value)
349
+ if (props.animated) animate()
350
+ else draw()
351
+ }
352
+ })
353
+
354
+ const onMouseMove = (e: MouseEvent) => {
355
+ onViewportMouseMove(e)
356
+ if (isDragging.value) {
357
+ setHovered(-1)
358
+ hideTooltip()
359
+ return
360
+ }
361
+
362
+ const rect = containerRef.value?.getBoundingClientRect()
363
+ if (!rect) return
364
+ const mouseX = e.clientX - rect.left
365
+ const mouseY = e.clientY - rect.top
366
+
367
+ // Find if mouse is within a bar
368
+ const hovered = hitBars.value.find(b =>
369
+ mouseX >= b.x && mouseX <= b.x + b.w &&
370
+ mouseY >= b.y && mouseY <= b.y + b.h
371
+ )
372
+
373
+ if (hovered) {
374
+ const idx = hitBars.value.indexOf(hovered)
375
+ setHovered(idx)
376
+ showTooltip(mouseX + 10, mouseY - 10, hovered.label, hovered.item, idx)
377
+ emit('hover-point', hovered.item, idx)
378
+ } else {
379
+ setHovered(-1)
380
+ hideTooltip()
381
+ }
382
+ }
383
+
384
+ const onMouseLeave = (e: MouseEvent) => {
385
+ onViewportMouseLeave(e)
386
+ setHovered(-1)
387
+ hideTooltip()
388
+ }
389
+
390
+ defineExpose({
391
+ zoomIn: viewport.zoomIn, zoomOut: viewport.zoomOut, resetZoom: viewport.resetZoom,
392
+ exportPng: () => canvasRef.value && exportPng(canvasRef.value, 'bar-chart.png'),
393
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, 'bar-chart.svg')
394
+ })
395
+ </script>
396
+
397
+ <style lang="scss" scoped>
398
+ .k-chart-wrapper {
399
+ position: relative; user-select: none;
400
+ .k-chart-controls { position: absolute; top: 8px; right: 8px; z-index: 10; display: flex; gap: 4px; }
401
+ .k-chart-btn { display: none; }
402
+ .k-chart-inner {
403
+ width: 100%;
404
+ height: 400px;
405
+ position: relative;
406
+ canvas, .k-chart-svg {
407
+ position: absolute;
408
+ top: 0;
409
+ left: 0;
410
+ width: 100%;
411
+ height: 100%;
412
+ display: block;
413
+ }
414
+ .k-chart-svg {
415
+ overflow: visible;
416
+ }
417
+ }
418
+ .k-chart-svg {
419
+ pointer-events: none;
420
+ .k-chart-hit-proxy {
421
+ pointer-events: all;
422
+ cursor: pointer;
423
+ }
424
+ }
425
+ .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); }
426
+ }
427
+ </style>