@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,301 @@
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(
11
+ v-show="!isLoading"
12
+ ref="svgRef"
13
+ :width="width"
14
+ :height="height"
15
+ )
16
+ // Dynamic Interaction Layer
17
+ g.k-chart-interaction(v-if="hoveredIndex !== -1 && hoveredCell")
18
+ rect.k-chart-hit-proxy(
19
+ :x="hoveredCell.x"
20
+ :y="hoveredCell.y"
21
+ :width="hoveredCell.w"
22
+ :height="hoveredCell.h"
23
+ fill="transparent"
24
+ style="cursor: pointer"
25
+ @click="emit('click-point', hoveredCell.item, hoveredIndex)"
26
+ )
27
+ rect(
28
+ :x="hoveredCell.x - 1"
29
+ :y="hoveredCell.y - 1"
30
+ :width="hoveredCell.w + 2"
31
+ :height="hoveredCell.h + 2"
32
+ fill="transparent"
33
+ stroke="#fff"
34
+ stroke-width="1.5"
35
+ rx="2"
36
+ pointer-events="none"
37
+ )
38
+
39
+ // 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' }")
41
+ slot(name="tooltip" :item="tooltipState.item" :index="tooltipState.index")
42
+ span {{ tooltipState.content }}
43
+ </template>
44
+
45
+ <script lang="ts" setup>
46
+ import { ref, watch, computed, onMounted, nextTick } from 'vue'
47
+ import { useChartSvg } from '../../composables/useChartSvg'
48
+ import { useChartData } from '../../composables/useChartData'
49
+ import { useChartCanvas } from '../../composables/useChartCanvas'
50
+ import { useChartExport } from '../../composables/useChartExport'
51
+
52
+ const props = withDefaults(defineProps<{
53
+ dataProvider?: any
54
+ rowField?: string
55
+ columnField?: string
56
+ valueField?: string
57
+ colorLow?: string
58
+ colorHigh?: string
59
+ colors?: string[]
60
+ backgroundColor?: string
61
+ showLegend?: boolean
62
+ xAxisTitle?: string
63
+ yAxisTitle?: string
64
+ }>(), {
65
+ rowField: 'row',
66
+ columnField: 'column',
67
+ valueField: 'value',
68
+ colorLow: '#1e1e2e',
69
+ colorHigh: '#6366f1',
70
+ colors: () => ['#1e1e2e', '#313244', '#45475a', '#585b70', '#cba6f7', '#f5c2e7', '#eba0ac', '#f38ba8'],
71
+ backgroundColor: '#ffffff',
72
+ showLegend: true,
73
+ xAxisTitle: '',
74
+ yAxisTitle: ''
75
+ })
76
+
77
+ const emit = defineEmits<{
78
+ 'click-point': [item: any, index: number]
79
+ 'hover-point': [item: any, index: number]
80
+ }>()
81
+
82
+ const containerRef = ref<HTMLElement | null>(null)
83
+ const canvasRef = ref<HTMLCanvasElement | null>(null)
84
+ const svgRef = ref<SVGSVGElement | null>(null)
85
+
86
+ const { ctx, width, height, clear, setupCanvas } = useChartCanvas()
87
+
88
+ const { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered } = useChartSvg()
89
+ const dataRef = computed(() => props.dataProvider)
90
+ const { items } = useChartData(dataRef)
91
+ const { exportPng, exportSvg: exportSvgFile } = useChartExport()
92
+
93
+ const isLoading = computed(() => {
94
+ return props.dataProvider?.loading?.value || (props.dataProvider as any)?.initialLoad?.value || false
95
+ })
96
+
97
+ const padLeft = 80
98
+ const padTop = 30
99
+ const padRight = 20
100
+ const padBottom = 20
101
+
102
+ const rows = computed(() => [...new Set(items.value.map(r => String(r[props.rowField])))])
103
+ const cols = computed(() => [...new Set(items.value.map(r => String(r[props.columnField])))])
104
+
105
+ const cellW = computed(() => Math.max(8, (width.value - padLeft - padRight) / (cols.value.length || 1)))
106
+ const cellH = computed(() => Math.max(8, (height.value - padTop - padBottom) / (rows.value.length || 1)))
107
+
108
+ const maxVal = computed(() => Math.max(...items.value.map(r => Number(r[props.valueField])), 1))
109
+
110
+ const hoveredCell = computed(() => {
111
+ if (hoveredIndex.value === -1) return null
112
+ return renderedCells.value[hoveredIndex.value]
113
+ })
114
+
115
+ const minVal = computed(() => Math.min(...items.value.map(r => Number(r[props.valueField])), 0))
116
+
117
+ const lerp = (t: number) => {
118
+ const low = parseInt((props.colorLow || '#1e1e2e').replace('#', ''), 16)
119
+ const high = parseInt((props.colorHigh || '#6366f1').replace('#', ''), 16)
120
+ const lr = (low >> 16) & 0xff; const lg = (low >> 8) & 0xff; const lb = low & 0xff
121
+ const hr = (high >> 16) & 0xff; const hg = (high >> 8) & 0xff; const hb = high & 0xff
122
+ const r = Math.round(lr + (hr - lr) * t)
123
+ const g = Math.round(lg + (hg - lg) * t)
124
+ const b = Math.round(lb + (hb - lb) * t)
125
+ return `rgb(${r},${g},${b})`
126
+ }
127
+
128
+ interface ICell { x: number; y: number; w: number; h: number; color: string; item: any; label: string }
129
+
130
+ const renderedCells = computed<ICell[]>(() => {
131
+ const range = maxVal.value - minVal.value || 1
132
+ return items.value.map(item => {
133
+ const ri = rows.value.indexOf(String(item[props.rowField]))
134
+ const ci = cols.value.indexOf(String(item[props.columnField]))
135
+ const t = (Number(item[props.valueField]) - minVal.value) / range
136
+ return {
137
+ x: padLeft + ci * cellW.value,
138
+ y: padTop + ri * cellH.value,
139
+ w: cellW.value - 1,
140
+ h: cellH.value - 1,
141
+ color: lerp(t),
142
+ item,
143
+ label: `${item[props.rowField]} × ${item[props.columnField]}: ${item[props.valueField]}`
144
+ }
145
+ })
146
+ })
147
+
148
+ const draw = () => {
149
+ if (!ctx.value) return
150
+ clear(props.backgroundColor)
151
+ const c = ctx.value
152
+
153
+ drawAxes(c)
154
+
155
+ renderedCells.value.forEach((cell, i) => {
156
+ const isHovered = hoveredIndex.value === i
157
+ c.fillStyle = cell.color
158
+ c.beginPath()
159
+ c.rect(cell.x, cell.y, cell.w, cell.h)
160
+ c.fill()
161
+
162
+ if (isHovered) {
163
+ c.strokeStyle = '#fff'
164
+ c.lineWidth = 2
165
+ c.strokeRect(cell.x + 1, cell.y + 1, cell.w - 2, cell.h - 2)
166
+ }
167
+ })
168
+
169
+ if (props.showLegend) drawLegend(c)
170
+ }
171
+
172
+ const drawAxes = (c: CanvasRenderingContext2D) => {
173
+ c.save()
174
+ c.fillStyle = 'rgba(120,120,140,0.8)'
175
+ c.font = '11px system-ui, sans-serif'
176
+
177
+ const plotW = width.value - padLeft - padRight
178
+ const plotH = height.value - padTop - padBottom
179
+
180
+ // Row labels (Y)
181
+ c.textAlign = 'right'
182
+ rows.value.forEach((label, i) => {
183
+ const y = padTop + i * cellH.value + cellH.value / 2 + 4
184
+ c.fillText(label, padLeft - 8, y)
185
+ })
186
+
187
+ // Col labels (X)
188
+ c.textAlign = 'center'
189
+ cols.value.forEach((label, i) => {
190
+ const x = padLeft + i * cellW.value + cellW.value / 2
191
+ c.fillText(label, x, padTop + plotH + 16)
192
+ })
193
+
194
+ // Titles
195
+ if (props.xAxisTitle) {
196
+ c.fillText(props.xAxisTitle, padLeft + plotW / 2, padTop + plotH + 34)
197
+ }
198
+ if (props.yAxisTitle) {
199
+ c.save()
200
+ c.translate(padLeft - 55, padTop + plotH / 2)
201
+ c.rotate(-Math.PI / 2)
202
+ c.textAlign = 'center'
203
+ c.fillText(props.yAxisTitle, 0, 0)
204
+ c.restore()
205
+ }
206
+ c.restore()
207
+ }
208
+
209
+ const drawLegend = (c: CanvasRenderingContext2D) => {
210
+ c.save()
211
+ const gradW = 120
212
+ const gradH = 10
213
+ const x = width.value - padRight - gradW
214
+ const y = height.value - padBottom + 10
215
+
216
+ const grad = c.createLinearGradient(x, 0, x + gradW, 0)
217
+ grad.addColorStop(0, props.colorLow || '#1e1e2e')
218
+ grad.addColorStop(1, props.colorHigh || '#6366f1')
219
+
220
+ c.fillStyle = grad
221
+ c.fillRect(x, y, gradW, gradH)
222
+
223
+ c.fillStyle = 'rgba(150,150,170,0.9)'
224
+ c.font = '10px system-ui, sans-serif'
225
+ c.textAlign = 'left'
226
+ c.fillText(minVal.value.toString(), x, y + gradH + 10)
227
+ c.textAlign = 'right'
228
+ c.fillText(maxVal.value.toString(), x + gradW, y + gradH + 10)
229
+ c.restore()
230
+ }
231
+
232
+ watch([renderedCells, hoveredIndex, width, height], draw)
233
+
234
+ onMounted(async () => {
235
+ await nextTick()
236
+ if (containerRef.value && canvasRef.value) {
237
+ setupCanvas(canvasRef.value)
238
+ draw()
239
+ }
240
+ })
241
+
242
+ const onMouseMove = (e: MouseEvent) => {
243
+ const rect = containerRef.value?.getBoundingClientRect()
244
+ if (!rect) return
245
+ const mx = e.clientX - rect.left
246
+ const my = e.clientY - rect.top
247
+
248
+ const idx = renderedCells.value.findIndex(c =>
249
+ mx >= c.x && mx <= c.x + c.w &&
250
+ my >= c.y && my <= c.y + c.h
251
+ )
252
+
253
+ if (idx !== -1) {
254
+ const cell = renderedCells.value[idx]
255
+ setHovered(idx)
256
+ showTooltip(mx + 10, my - 10, cell.label, cell.item, idx)
257
+ emit('hover-point', cell.item, idx)
258
+ } else {
259
+ setHovered(-1)
260
+ hideTooltip()
261
+ }
262
+ }
263
+
264
+ const onMouseLeave = () => {
265
+ setHovered(-1)
266
+ hideTooltip()
267
+ }
268
+
269
+ defineExpose({
270
+ exportPng: () => canvasRef.value && exportPng(canvasRef.value, 'heatmap.png'),
271
+ exportSvg: () => svgRef.value && exportSvgFile(svgRef.value, 'heatmap.svg')
272
+ })
273
+ </script>
274
+
275
+ <style lang="scss" scoped>
276
+ .k-chart-wrapper {
277
+ position: relative; user-select: none;
278
+ .k-chart-inner {
279
+ width: 100%;
280
+ height: 400px;
281
+ position: relative;
282
+ canvas, .k-chart-svg {
283
+ position: absolute;
284
+ top: 0;
285
+ left: 0;
286
+ width: 100%;
287
+ height: 100%;
288
+ display: block;
289
+ }
290
+ .k-chart-svg {
291
+ overflow: visible;
292
+ pointer-events: none;
293
+ .k-chart-hit-proxy {
294
+ pointer-events: all;
295
+ cursor: pointer;
296
+ }
297
+ }
298
+ }
299
+ .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); }
300
+ }
301
+ </style>