@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,45 @@
1
+ import { ref, type Ref } from 'vue'
2
+
3
+ export interface IChartAnimationContext {
4
+ progress: Ref<number>
5
+ animate: () => void
6
+ stop: () => void
7
+ }
8
+
9
+ export function useChartAnimation(duration = 600): IChartAnimationContext {
10
+ const progress = ref(0)
11
+ let rafId: number | null = null
12
+ let startTime: number | null = null
13
+
14
+ const animate = () => {
15
+ stop()
16
+ progress.value = 0
17
+ startTime = null
18
+
19
+ const tick = (now: number) => {
20
+ if (startTime === null) startTime = now
21
+ const elapsed = now - startTime
22
+ progress.value = Math.min(elapsed / duration, 1)
23
+ if (progress.value < 1) {
24
+ rafId = requestAnimationFrame(tick)
25
+ } else {
26
+ rafId = null
27
+ }
28
+ }
29
+ rafId = requestAnimationFrame(tick)
30
+ }
31
+
32
+ const stop = () => {
33
+ if (rafId !== null) {
34
+ cancelAnimationFrame(rafId)
35
+ rafId = null
36
+ }
37
+ }
38
+
39
+ return { progress, animate, stop }
40
+ }
41
+
42
+ /** Easing function: ease-out cubic */
43
+ export function easeOut(t: number): number {
44
+ return 1 - Math.pow(1 - t, 3)
45
+ }
@@ -0,0 +1,105 @@
1
+ import { computed, type Ref, type ComputedRef } from 'vue'
2
+
3
+ export type TScaleMode = 'linear' | 'logarithmic' | 'time'
4
+
5
+ export interface IChartAxesOptions {
6
+ items: Ref<any[]>
7
+ xField: Ref<string>
8
+ yField: Ref<string>
9
+ width: Ref<number>
10
+ height: Ref<number>
11
+ scale: Ref<number>
12
+ panOffset: Ref<{ x: number; y: number }>
13
+ padding?: { top: number; right: number; bottom: number; left: number }
14
+ scaleMode?: TScaleMode
15
+ }
16
+
17
+ export interface IChartAxesContext {
18
+ xMin: ComputedRef<number>
19
+ xMax: ComputedRef<number>
20
+ yMin: ComputedRef<number>
21
+ yMax: ComputedRef<number>
22
+ plotLeft: number
23
+ plotTop: number
24
+ plotWidth: ComputedRef<number>
25
+ plotHeight: ComputedRef<number>
26
+ toX: (value: number) => number
27
+ toY: (value: number) => number
28
+ fromX: (px: number) => number
29
+ }
30
+
31
+ /**
32
+ * Robustly parses a value into a number.
33
+ * Handles: numbers, numeric strings, and ISO date strings.
34
+ */
35
+ export function parseValue(val: any): number {
36
+ if (val === null || val === undefined) return 0
37
+ const n = Number(val)
38
+ if (!isNaN(n)) return n
39
+ if (typeof val === 'string') {
40
+ const d = Date.parse(val)
41
+ if (!isNaN(d)) return d
42
+ }
43
+ return 0
44
+ }
45
+
46
+ export function useChartAxes(opts: IChartAxesOptions): IChartAxesContext {
47
+ const pad = opts.padding ?? { top: 20, right: 20, bottom: 40, left: 55 }
48
+
49
+ const plotLeft = pad.left
50
+ const plotTop = pad.top
51
+
52
+ const plotWidth = computed(() => Math.max(0, opts.width.value - pad.left - pad.right))
53
+ const plotHeight = computed(() => Math.max(0, opts.height.value - pad.top - pad.bottom))
54
+
55
+ // Domain extents from data
56
+ const rawXMin = computed(() => {
57
+ const vals = opts.items.value.map(r => parseValue(r[opts.xField.value]))
58
+ return vals.length ? Math.min(...vals) : 0
59
+ })
60
+ const rawXMax = computed(() => {
61
+ const vals = opts.items.value.map(r => parseValue(r[opts.xField.value]))
62
+ return vals.length ? Math.max(...vals) : 1
63
+ })
64
+ const yMin = computed(() => {
65
+ const vals = opts.items.value.map(r => parseValue(r[opts.yField.value]))
66
+ return vals.length ? Math.min(...vals) : 0
67
+ })
68
+ const yMax = computed(() => {
69
+ const vals = opts.items.value.map(r => parseValue(r[opts.yField.value]))
70
+ return vals.length ? Math.max(...vals) : 1
71
+ })
72
+
73
+ // Apply zoom + pan to X domain
74
+ // panOffset.x is in pixel-space; convert back to domain
75
+ const xRange = computed(() => rawXMax.value - rawXMin.value)
76
+ const visibleXRange = computed(() => xRange.value / opts.scale.value)
77
+
78
+ const panOffsetDomain = computed(() => {
79
+ const pxRange = plotWidth.value
80
+ if (pxRange === 0) return 0
81
+ return (opts.panOffset.value.x / pxRange) * xRange.value
82
+ })
83
+
84
+ const xMin = computed(() => rawXMin.value + panOffsetDomain.value)
85
+ const xMax = computed(() => xMin.value + visibleXRange.value)
86
+
87
+ const toX = (value: number): number => {
88
+ const range = xMax.value - xMin.value
89
+ if (range === 0) return plotLeft
90
+ return plotLeft + ((value - xMin.value) / range) * plotWidth.value
91
+ }
92
+
93
+ const toY = (value: number): number => {
94
+ const range = yMax.value - yMin.value
95
+ if (range === 0) return plotTop + plotHeight.value
96
+ return plotTop + plotHeight.value - ((value - yMin.value) / range) * plotHeight.value
97
+ }
98
+
99
+ const fromX = (px: number): number => {
100
+ const range = xMax.value - xMin.value
101
+ return xMin.value + ((px - plotLeft) / plotWidth.value) * range
102
+ }
103
+
104
+ return { xMin, xMax, yMin, yMax, plotLeft, plotTop, plotWidth, plotHeight, toX, toY, fromX }
105
+ }
@@ -0,0 +1,67 @@
1
+ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
2
+
3
+ export interface IChartCanvasContext {
4
+ canvas: Ref<HTMLCanvasElement | null>
5
+ ctx: Ref<CanvasRenderingContext2D | null>
6
+ width: Ref<number>
7
+ height: Ref<number>
8
+ dpr: Ref<number>
9
+ clear: (color?: string) => void
10
+ setupCanvas: (el: HTMLCanvasElement) => void
11
+ }
12
+
13
+ export function useChartCanvas(): IChartCanvasContext {
14
+ const canvas = ref<HTMLCanvasElement | null>(null)
15
+ const ctx = ref<CanvasRenderingContext2D | null>(null)
16
+ const width = ref(0)
17
+ const height = ref(0)
18
+ const dpr = ref(window?.devicePixelRatio ?? 1)
19
+
20
+ let resizeObserver: ResizeObserver | null = null
21
+
22
+ const applySize = (el: HTMLCanvasElement, w: number, h: number) => {
23
+ const ratio = window.devicePixelRatio ?? 1
24
+ dpr.value = ratio
25
+ el.width = w * ratio
26
+ el.height = h * ratio
27
+ ctx.value?.scale(ratio, ratio)
28
+ width.value = w
29
+ height.value = h
30
+ }
31
+
32
+ const setupCanvas = (el: HTMLCanvasElement) => {
33
+ canvas.value = el
34
+ ctx.value = el.getContext('2d')
35
+
36
+ const rect = el.getBoundingClientRect()
37
+ applySize(el, rect.width, rect.height)
38
+
39
+ resizeObserver = new ResizeObserver((entries) => {
40
+ for (const entry of entries) {
41
+ const { width: w, height: h } = entry.contentRect
42
+ if (canvas.value && ctx.value) {
43
+ // Re-get context after resize to reset transform
44
+ ctx.value = canvas.value.getContext('2d')
45
+ applySize(canvas.value, w, h)
46
+ }
47
+ }
48
+ })
49
+ resizeObserver.observe(el)
50
+ }
51
+
52
+ const clear = (color?: string) => {
53
+ if (ctx.value && canvas.value) {
54
+ ctx.value.clearRect(0, 0, width.value, height.value)
55
+ if (color && color !== 'transparent') {
56
+ ctx.value.fillStyle = color
57
+ ctx.value.fillRect(0, 0, width.value, height.value)
58
+ }
59
+ }
60
+ }
61
+
62
+ onUnmounted(() => {
63
+ resizeObserver?.disconnect()
64
+ })
65
+
66
+ return { canvas, ctx, width, height, dpr, clear, setupCanvas }
67
+ }
@@ -0,0 +1,79 @@
1
+ import { ref, watch, onMounted, isRef, type Ref } from 'vue'
2
+
3
+ export interface IChartDataContext<T = any> {
4
+ items: Ref<T[]>
5
+ loading: Ref<boolean>
6
+ error: Ref<string | null>
7
+ reload: () => Promise<void>
8
+ }
9
+
10
+ /**
11
+ * Robust data fetcher for charts. Supports:
12
+ * 1. Plain arrays
13
+ * 2. Katlux DataProviders (with .pageData ref)
14
+ * 3. Legacy fetchable providers (with .fetch() method)
15
+ */
16
+ export function useChartData<T = any>(
17
+ providerOrData: Ref<any>
18
+ ): IChartDataContext<T> {
19
+ const items = ref<T[]>([]) as Ref<T[]>
20
+ const loading = ref(false)
21
+ const error = ref<string | null>(null)
22
+
23
+ const load = async () => {
24
+ const source = providerOrData.value
25
+ if (!source) {
26
+ items.value = []
27
+ return
28
+ }
29
+
30
+ loading.value = true
31
+ error.value = null
32
+
33
+ try {
34
+ // 1. Plain array
35
+ if (Array.isArray(source)) {
36
+ items.value = source as T[]
37
+ return
38
+ }
39
+
40
+ // 2. Katlux DataProvider (has pageData ref)
41
+ if (source.pageData) {
42
+ items.value = (isRef(source.pageData) ? source.pageData.value : source.pageData) as T[]
43
+ return
44
+ }
45
+
46
+ // 3. Legacy fetchable provider
47
+ if (typeof source.fetch === 'function') {
48
+ const result = await source.fetch()
49
+ items.value = (result?.rows ?? result ?? []) as T[]
50
+ return
51
+ }
52
+
53
+ // 4. Object with rows
54
+ if (source.rows) {
55
+ items.value = source.rows as T[]
56
+ return
57
+ }
58
+ } catch (e: any) {
59
+ error.value = e?.message ?? 'Failed to load chart data'
60
+ } finally {
61
+ loading.value = false
62
+ }
63
+ }
64
+
65
+ const reload = load
66
+
67
+ onMounted(load)
68
+
69
+ // Watch the provider instance itself
70
+ watch(providerOrData, load)
71
+
72
+ // High priority: watch the pageData of the current provider (if it exists)
73
+ // This ensures that when a provider finishes an async load, the chart re-renders.
74
+ watch(() => providerOrData.value?.pageData?.value, (newRows) => {
75
+ if (newRows) items.value = newRows as T[]
76
+ }, { deep: true })
77
+
78
+ return { items, loading, error, reload }
79
+ }
@@ -0,0 +1,40 @@
1
+ export interface IChartExportContext {
2
+ exportPng: (canvas: HTMLCanvasElement, filename?: string) => void
3
+ exportSvg: (svgEl: SVGSVGElement, filename?: string) => void
4
+ }
5
+
6
+ export function useChartExport(): IChartExportContext {
7
+ const download = (href: string, filename: string) => {
8
+ const a = document.createElement('a')
9
+ a.href = href
10
+ a.download = filename
11
+ a.style.display = 'none'
12
+ document.body.appendChild(a)
13
+ a.click()
14
+ document.body.removeChild(a)
15
+ }
16
+
17
+ const exportPng = (canvas: HTMLCanvasElement, filename = 'chart.png') => {
18
+ try {
19
+ const dataUrl = canvas.toDataURL('image/png')
20
+ download(dataUrl, filename)
21
+ } catch (e) {
22
+ console.error('[KChart] PNG export failed:', e)
23
+ }
24
+ }
25
+
26
+ const exportSvg = (svgEl: SVGSVGElement, filename = 'chart.svg') => {
27
+ try {
28
+ const serializer = new XMLSerializer()
29
+ const svgStr = serializer.serializeToString(svgEl)
30
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' })
31
+ const url = URL.createObjectURL(blob)
32
+ download(url, filename)
33
+ URL.revokeObjectURL(url)
34
+ } catch (e) {
35
+ console.error('[KChart] SVG export failed:', e)
36
+ }
37
+ }
38
+
39
+ return { exportPng, exportSvg }
40
+ }
@@ -0,0 +1,71 @@
1
+ import { type Ref } from 'vue'
2
+ import { parseValue, type IChartAxesContext } from './useChartAxes'
3
+
4
+ export function useChartHitTest() {
5
+ /**
6
+ * Finds the nearest item based on X-coordinate.
7
+ * Optimized for Line and Area charts where points are usually ordered by X.
8
+ */
9
+ const findNearestX = (items: any[], mouseX: number, xField: string, axes: IChartAxesContext): { item: any; index: number } | null => {
10
+ if (!items.length) return null
11
+
12
+ // Convert pixel mouseX to data-space
13
+ const dataX = axes.fromX(mouseX)
14
+
15
+ let nearestIdx = 0
16
+ let minDiff = Math.abs(parseValue(items[0][xField]) - dataX)
17
+
18
+ // Basic linear search (could be binary search if we guarantee sorting,
19
+ // but simple enough for a few thousands)
20
+ for (let i = 1; i < items.length; i++) {
21
+ const val = parseValue(items[i][xField])
22
+ const diff = Math.abs(val - dataX)
23
+ if (diff < minDiff) {
24
+ minDiff = diff
25
+ nearestIdx = i
26
+ }
27
+ }
28
+
29
+ return { item: items[nearestIdx], index: nearestIdx }
30
+ }
31
+
32
+ /**
33
+ * Finds the nearest item based on Euclidean distance.
34
+ * Used for Scatter and Bubble charts.
35
+ */
36
+ const findNearestEuclidean = (
37
+ items: any[],
38
+ mouseX: number,
39
+ mouseY: number,
40
+ axes: IChartAxesContext,
41
+ xField: string,
42
+ yField: string,
43
+ threshold: number = 20
44
+ ): { item: any; index: number } | null => {
45
+ if (!items.length) return null
46
+
47
+ let nearestIdx = -1
48
+ let minDistanceSq = threshold * threshold
49
+
50
+ items.forEach((item, i) => {
51
+ const px = axes.toX(parseValue(item[xField]))
52
+ const py = axes.toY(parseValue(item[yField]))
53
+
54
+ const dx = mouseX - px
55
+ const dy = mouseY - py
56
+ const distSq = dx * dx + dy * dy
57
+
58
+ if (distSq < minDistanceSq) {
59
+ minDistanceSq = distSq
60
+ nearestIdx = i
61
+ }
62
+ })
63
+
64
+ return nearestIdx !== -1 ? { item: items[nearestIdx], index: nearestIdx } : null
65
+ }
66
+
67
+ return {
68
+ findNearestX,
69
+ findNearestEuclidean
70
+ }
71
+ }
@@ -0,0 +1,45 @@
1
+ import { ref, computed, type Ref } from 'vue'
2
+
3
+ export interface ITooltipState {
4
+ visible: boolean
5
+ x: number
6
+ y: number
7
+ content: string
8
+ item: any
9
+ index: number
10
+ }
11
+
12
+ export interface IChartSvgContext {
13
+ hoveredIndex: Ref<number>
14
+ tooltipState: Ref<ITooltipState>
15
+ showTooltip: (x: number, y: number, content: string, item: any, index: number) => void
16
+ hideTooltip: () => void
17
+ setHovered: (index: number) => void
18
+ }
19
+
20
+ export function useChartSvg(): IChartSvgContext {
21
+ const hoveredIndex = ref<number>(-1)
22
+
23
+ const tooltipState = ref<ITooltipState>({
24
+ visible: false,
25
+ x: 0,
26
+ y: 0,
27
+ content: '',
28
+ item: null,
29
+ index: -1
30
+ })
31
+
32
+ const showTooltip = (x: number, y: number, content: string, item: any, index: number) => {
33
+ tooltipState.value = { visible: true, x, y, content, item, index }
34
+ }
35
+
36
+ const hideTooltip = () => {
37
+ tooltipState.value.visible = false
38
+ }
39
+
40
+ const setHovered = (index: number) => {
41
+ hoveredIndex.value = index
42
+ }
43
+
44
+ return { hoveredIndex, tooltipState, showTooltip, hideTooltip, setHovered }
45
+ }
@@ -0,0 +1,140 @@
1
+ import { ref, watch, type Ref } from 'vue'
2
+
3
+ export interface IViewportState {
4
+ scale: Ref<number>
5
+ panOffset: Ref<{ x: number; y: number }>
6
+ isDragging: Ref<boolean>
7
+ canPan: Ref<boolean>
8
+ }
9
+
10
+ export interface IChartViewportContext extends IViewportState {
11
+ zoomIn: () => void
12
+ zoomOut: () => void
13
+ resetZoom: () => void
14
+ onWheel: (e: WheelEvent) => void
15
+ onMouseDown: (e: MouseEvent) => void
16
+ onMouseMove: (e: MouseEvent) => void
17
+ onMouseUp: (e: MouseEvent) => void
18
+ onMouseLeave: (e: MouseEvent) => void
19
+ }
20
+
21
+ const ZOOM_STEP = 0.25
22
+ const MIN_SCALE = 1
23
+ const PAN_CLAMP_THRESHOLD = 0.01
24
+
25
+ export function useChartViewport(
26
+ options: {
27
+ maxZoom?: Ref<number>
28
+ plotWidth: Ref<number>
29
+ plotLeft?: Ref<number>
30
+ }
31
+ ): IChartViewportContext {
32
+ const maxZoom = options.maxZoom ?? ref(10)
33
+ const scale = ref(1)
34
+ const panOffset = ref({ x: 0, y: 0 })
35
+ const isDragging = ref(false)
36
+ const canPan = ref(false)
37
+ let dragStart = { x: 0, y: 0 }
38
+ let panStart = { x: 0, y: 0 }
39
+
40
+ // Clamp panOffset so the viewport doesn't go out of data bounds
41
+ const clampPan = () => {
42
+ const maxPanX = plotWidth() * (1 - 1 / scale.value)
43
+ panOffset.value.x = Math.max(0, Math.min(panOffset.value.x, maxPanX))
44
+ panOffset.value.y = 0 // Only horizontal pan for now
45
+ }
46
+
47
+ const plotWidth = () => options.plotWidth.value
48
+
49
+ const updateCanPan = () => {
50
+ canPan.value = scale.value > MIN_SCALE + PAN_CLAMP_THRESHOLD
51
+ }
52
+
53
+ const resetPan = () => {
54
+ panOffset.value = { x: 0, y: 0 }
55
+ }
56
+
57
+ const zoomTo = (newScale: number, anchorX?: number) => {
58
+ const prev = scale.value
59
+ const clamped = Math.min(Math.max(newScale, MIN_SCALE), maxZoom.value)
60
+
61
+ if (anchorX !== undefined && prev !== clamped) {
62
+ // Keep the data point under cursor fixed
63
+ // anchorX is relative to chart plot area
64
+ panOffset.value.x += anchorX * (1 / prev - 1 / clamped)
65
+ }
66
+
67
+ scale.value = clamped
68
+ if (scale.value <= MIN_SCALE) resetPan()
69
+ else clampPan()
70
+ updateCanPan()
71
+ }
72
+
73
+ const zoomIn = () => zoomTo(scale.value + ZOOM_STEP)
74
+ const zoomOut = () => zoomTo(scale.value - ZOOM_STEP)
75
+ const resetZoom = () => {
76
+ scale.value = MIN_SCALE
77
+ resetPan()
78
+ updateCanPan()
79
+ }
80
+
81
+ // Ctrl + scroll to zoom, Shift + scroll to pan
82
+ const onWheel = (e: WheelEvent) => {
83
+ if (e.ctrlKey) {
84
+ e.preventDefault()
85
+
86
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
87
+ const plotLeft = options.plotLeft?.value ?? 0
88
+ const anchorX = e.clientX - rect.left - plotLeft
89
+
90
+ const delta = e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP
91
+ zoomTo(scale.value + delta, anchorX)
92
+ } else if (e.shiftKey) {
93
+ e.preventDefault()
94
+ if (!canPan.value) return
95
+
96
+ const delta = e.deltaX !== 0 ? e.deltaX : e.deltaY
97
+ const dx = delta / scale.value
98
+ panOffset.value.x += dx
99
+ clampPan()
100
+ }
101
+ }
102
+
103
+ // Mouse drag for pan
104
+ const onMouseDown = (e: MouseEvent) => {
105
+ if (e.button !== 0 || !canPan.value) return
106
+ isDragging.value = true
107
+ dragStart = { x: e.clientX, y: e.clientY }
108
+ panStart = { x: panOffset.value.x, y: panOffset.value.y }
109
+ }
110
+
111
+ const onMouseMove = (e: MouseEvent) => {
112
+ if (!isDragging.value || !canPan.value) return
113
+ const dx = -(e.clientX - dragStart.x) / scale.value
114
+ panOffset.value.x = panStart.x + dx
115
+ clampPan()
116
+ }
117
+
118
+ const onMouseUp = (_e: MouseEvent) => {
119
+ isDragging.value = false
120
+ }
121
+
122
+ const onMouseLeave = (_e: MouseEvent) => {
123
+ isDragging.value = false
124
+ }
125
+
126
+ return {
127
+ scale,
128
+ panOffset,
129
+ isDragging,
130
+ canPan,
131
+ zoomIn,
132
+ zoomOut,
133
+ resetZoom,
134
+ onWheel,
135
+ onMouseDown,
136
+ onMouseMove,
137
+ onMouseUp,
138
+ onMouseLeave
139
+ }
140
+ }