@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.
- package/build.config.ts +4 -0
- package/package.json +31 -0
- package/src/module.ts +25 -0
- package/src/runtime/components/KAreaChart/KAreaChart.vue +410 -0
- package/src/runtime/components/KBarChart/KBarChart.vue +427 -0
- package/src/runtime/components/KHeatMap/KHeatMap.vue +301 -0
- package/src/runtime/components/KLineChart/KLineChart.vue +493 -0
- package/src/runtime/components/KPieChart/KPieChart.vue +307 -0
- package/src/runtime/components/KScatterChart/KScatterChart.vue +375 -0
- package/src/runtime/composables/useChartAnimation.ts +45 -0
- package/src/runtime/composables/useChartAxes.ts +105 -0
- package/src/runtime/composables/useChartCanvas.ts +67 -0
- package/src/runtime/composables/useChartData.ts +79 -0
- package/src/runtime/composables/useChartExport.ts +40 -0
- package/src/runtime/composables/useChartHitTest.ts +71 -0
- package/src/runtime/composables/useChartSvg.ts +45 -0
- package/src/runtime/composables/useChartViewport.ts +140 -0
|
@@ -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
|
+
}
|