• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

chartjs / chartjs-plugin-zoom / 12162496266

04 Dec 2024 03:04PM UTC coverage: 88.346% (+0.4%) from 87.903%
12162496266

push

github

web-flow
refactor: convert to typescript (#907)

* refactor: convert to typescript

* fix: windows lint

* fix: docs-build (= remove typedoc)

* fix: try to fix windows test

* fix: one more fix for windows

* chore: tuning

* fix: removing comments

* chore: use -Infinity/Infinity

* chore: fix issues

* fix: tests

* fix: reduce complexity

* fix: api documentation

* fix: linting issues, license year

264 of 331 branches covered (79.76%)

Branch coverage included in aggregate %.

676 of 733 new or added lines in 9 files covered. (92.22%)

676 of 733 relevant lines covered (92.22%)

1623.82 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.7
/src/scale.types.ts
1
import { almostEquals, isNullOrUndef, isNumber, valueOrDefault } from 'chart.js/helpers'
1✔
2
import { getState, type ScaleRange, type State } from './state'
1✔
3
import type { Point, Scale, TimeScale, TimeUnit } from 'chart.js'
1✔
4
import type { LimitOptions, ScaleLimits } from './options'
1✔
5

1✔
6
export type ZoomFunction = (scale: Scale, zoom: number, center: Point, limits: LimitOptions) => boolean
1✔
7
export type ZoomRectFunction = (scale: Scale, from: number, to: number, limits: LimitOptions) => boolean
1✔
8
export type PanFunction = (scale: Scale, delta: number, limits: LimitOptions) => boolean
1✔
9

1✔
10
const isTimeScale = (scale: Scale): scale is TimeScale => scale.type === 'time'
117✔
11

1✔
12
const isNotNumber = (value?: number): value is undefined => value === undefined || isNaN(value)
229✔
13

1✔
14
export function zoomDelta(
1✔
15
  val: number | undefined,
10✔
16
  min: number | undefined,
10✔
17
  range: number,
10✔
18
  newRange: number
10✔
19
): ScaleRange {
10✔
20
  const minPercent = range && isNumber(val) && isNumber(min) ? Math.max(0, Math.min(1, (val - min) / range)) : 0
738✔
21
  const maxPercent = 1 - minPercent
738✔
22

10✔
23
  return {
738✔
24
    min: newRange * minPercent,
10✔
25
    max: newRange * maxPercent,
10✔
26
  }
10✔
27
}
10✔
28

1✔
29
function getValueAtPoint(scale: Scale, point: Point): number | undefined {
30
  const pixel = scale.isHorizontal() ? point.x : point.y
728✔
31

32
  return scale.getValueForPixel(pixel)
728✔
33
}
34

1✔
35
function linearZoomDelta(scale: Scale, zoom: number, center: Point): ScaleRange {
36
  const range = scale.max - scale.min
692✔
37
  const newRange = range * (zoom - 1)
692✔
38
  const centerValue = getValueAtPoint(scale, center)
692✔
39

40
  return zoomDelta(centerValue, scale.min, range, newRange)
692✔
41
}
42

1✔
43
function logarithmicZoomRange(scale: Scale, zoom: number, center: Point) {
44
  const centerValue = getValueAtPoint(scale, center)
36✔
45

46
  // Return the original range, if value could not be determined.
47
  if (centerValue === undefined) {
36!
NEW
48
    return { min: scale.min, max: scale.max }
×
49
  }
50

51
  const logMin = Math.log10(scale.min)
36✔
52
  const logMax = Math.log10(scale.max)
36✔
53
  const logCenter = Math.log10(centerValue)
36✔
54
  const logRange = logMax - logMin
36✔
55
  const newLogRange = logRange * (zoom - 1)
36✔
56
  const delta = zoomDelta(logCenter, logMin, logRange, newLogRange)
36✔
57

58
  return {
36✔
59
    min: Math.pow(10, logMin + delta.min),
60
    max: Math.pow(10, logMax - delta.max),
61
  }
62
}
63

1✔
64
function getScaleLimits(scale: Scale, limits?: LimitOptions): ScaleLimits {
65
  return limits?.[scale.id] || limits?.[scale.axis] || {}
1,460✔
66
}
67

1✔
68
function getLimit(state: State, scale: Scale, scaleLimits: ScaleLimits, prop: 'min' | 'max', fallback: number): number {
69
  let limit = scaleLimits[prop]
5,808✔
70
  if (limit === 'original') {
5,808✔
71
    const original = state.originalScaleLimits[scale.id][prop]
2,904✔
72
    if (isNumber(original.options)) {
2,904✔
73
      return original.options
456✔
74
    }
75

76
    if (!isNullOrUndef(original.options)) {
2,448✔
77
      const parsed = scale.parse(original.options)
968✔
78
      if (isNumber(parsed)) {
968✔
79
        return parsed
968✔
80
      }
81
    }
82

83
    limit = original.scale
1,480✔
84
  }
85
  return valueOrDefault(limit, fallback)
4,384✔
86
}
87

1✔
88
function linearRange(scale: Scale, pixel0: number, pixel1: number): ScaleRange {
89
  const v0 = scale.getValueForPixel(pixel0) ?? scale.min
128!
90
  const v1 = scale.getValueForPixel(pixel1) ?? scale.max
128!
91
  return {
128✔
92
    min: Math.min(v0, v1),
93
    max: Math.max(v0, v1),
94
  }
95
}
96

1✔
97
function fixRange(
98
  range: number,
99
  { min, max, minLimit, maxLimit }: { min: number; max: number; minLimit: number; maxLimit: number },
100
  state: State,
101
  scale: Scale
102
) {
103
  const offset = (range - max + min) / 2
1,444✔
104
  min -= offset
1,444✔
105
  max += offset
1,444✔
106

107
  // In case the values are really close to the original values, use the original values.
108
  const origLimits: ScaleLimits = { min: 'original', max: 'original' }
1,444✔
109
  const origMin = getLimit(state, scale, origLimits, 'min', -Infinity)
1,444✔
110
  const origMax = getLimit(state, scale, origLimits, 'max', Infinity)
1,444✔
111

112
  const epsilon = range / 1e6
1,444✔
113
  if (almostEquals(min, origMin, epsilon)) {
1,444✔
114
    min = origMin
64✔
115
  }
116
  if (almostEquals(max, origMax, epsilon)) {
1,444✔
117
    max = origMax
52✔
118
  }
119

120
  // Apply limits
121
  if (min < minLimit) {
1,444✔
122
    min = minLimit
16✔
123
    max = Math.min(minLimit + range, maxLimit)
16✔
124
  } else if (max > maxLimit) {
1,428✔
125
    max = maxLimit
20✔
126
    min = Math.max(maxLimit - range, minLimit)
20✔
127
  }
128

129
  return { min, max }
1,444✔
130
}
131

1✔
132
export function updateRange(
1✔
133
  scale: Scale,
134
  { min, max }: ScaleRange,
135
  limits?: LimitOptions,
136
  zoom: boolean | 'pan' = false
480✔
137
): boolean {
138
  const state = getState(scale.chart)
1,460✔
139
  const { options: scaleOpts } = scale
1,460✔
140

141
  const scaleLimits = getScaleLimits(scale, limits)
1,460✔
142
  const { minRange = 0 } = scaleLimits
1,460✔
143
  const minLimit = getLimit(state, scale, scaleLimits, 'min', -Infinity)
1,460✔
144
  const maxLimit = getLimit(state, scale, scaleLimits, 'max', Infinity)
1,460✔
145

146
  if (zoom === 'pan' && (min < minLimit || max > maxLimit)) {
1,460!
147
    // At limit: No change but return true to indicate no need to store the delta.
148
    return true
4✔
149
  }
150

151
  const scaleRange = scale.max - scale.min
1,456✔
152
  const range = zoom ? Math.max(max - min, minRange) : scaleRange
1,456✔
153

154
  if (zoom && range === minRange && scaleRange <= minRange) {
1,456✔
155
    // At range limit: No change but return true to indicate no need to store the delta.
156
    return true
12✔
157
  }
158

159
  const newRange = fixRange(range, { min, max, minLimit, maxLimit }, state, scale)
1,444✔
160

161
  scaleOpts.min = newRange.min
1,444✔
162
  scaleOpts.max = newRange.max
1,444✔
163

164
  state.updatedScaleLimits[scale.id] = newRange
1,444✔
165

166
  // return true if the scale range is changed
167
  return scale.parse(newRange.min) !== scale.min || scale.parse(newRange.max) !== scale.max
1,444✔
168
}
169

1✔
170
function zoomNumericalScale(scale: Scale, zoom: number, center: Point, limits: LimitOptions) {
171
  const delta = linearZoomDelta(scale, zoom, center)
580✔
172
  const newRange = { min: scale.min + delta.min, max: scale.max - delta.max }
580✔
173
  return updateRange(scale, newRange, limits, true)
580✔
174
}
175

1✔
176
function zoomLogarithmicScale(scale: Scale, zoom: number, center: Point, limits: LimitOptions) {
177
  const newRange = logarithmicZoomRange(scale, zoom, center)
36✔
178
  return updateRange(scale, newRange, limits, true)
36✔
179
}
180

1✔
181
function zoomRectNumericalScale(scale: Scale, from: number, to: number, limits: LimitOptions) {
182
  return updateRange(scale, linearRange(scale, from, to), limits, true)
128✔
183
}
184

1✔
185
const integerChange = (v: number) =>
5✔
186
  v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1)
224✔
187

1✔
188
function existCategoryFromMaxZoom(scale: Scale) {
189
  const labels = scale.getLabels()
4✔
190
  const maxIndex = labels.length - 1
4✔
191

192
  if (scale.min > 0) {
4✔
193
    scale.min -= 1
4✔
194
  }
195
  if (scale.max < maxIndex) {
4✔
196
    scale.max += 1
4✔
197
  }
198
}
199

1✔
200
function zoomCategoryScale(scale: Scale, zoom: number, center: Point, limits: LimitOptions) {
201
  const delta = linearZoomDelta(scale, zoom, center)
112✔
202
  if (scale.min === scale.max && zoom < 1) {
112✔
203
    existCategoryFromMaxZoom(scale)
4✔
204
  }
205
  const newRange = { min: scale.min + integerChange(delta.min), max: scale.max - integerChange(delta.max) }
112✔
206

207
  return updateRange(scale, newRange, limits, true)
112✔
208
}
209

1✔
210
function scaleLength(scale: Scale) {
211
  return scale.isHorizontal() ? scale.width : scale.height
480✔
212
}
213

1✔
214
function panCategoryScale(scale: Scale, delta: number, limits: LimitOptions) {
215
  const labels = scale.getLabels()
480✔
216
  const lastLabelIndex = labels.length - 1
480✔
217
  let { min, max } = scale
480✔
218
  // The visible range. Ticks can be skipped, and thus not reliable.
219
  const range = Math.max(max - min, 1)
480✔
220
  // How many pixels of delta is required before making a step. stepSize, but limited to max 1/10 of the scale length.
221
  const stepDelta = Math.round(scaleLength(scale) / Math.max(range, 10))
480✔
222
  const stepSize = Math.round(Math.abs(delta / stepDelta))
480✔
223
  let applied
224
  if (delta < -stepDelta) {
480✔
225
    max = Math.min(max + stepSize, lastLabelIndex)
264✔
226
    min = range === 1 ? max : max - range
264✔
227
    applied = max === lastLabelIndex
264✔
228
  } else if (delta > stepDelta) {
216✔
229
    min = Math.max(0, min - stepSize)
144✔
230
    max = range === 1 ? min : min + range
144✔
231
    applied = min === 0
144✔
232
  }
233

234
  return updateRange(scale, { min, max }, limits) || Boolean(applied)
480✔
235
}
236

1✔
237
const OFFSETS: Record<TimeUnit, number> = {
5✔
238
  millisecond: 0,
1✔
239
  second: 500, // 500 ms
1✔
240
  minute: 30 * 1000, // 30 s
1✔
241
  hour: 30 * 60 * 1000, // 30 m
1✔
242
  day: 12 * 60 * 60 * 1000, // 12 h
1✔
243
  week: 3.5 * 24 * 60 * 60 * 1000, // 3.5 d
1✔
244
  month: 15 * 24 * 60 * 60 * 1000, // 15 d
1✔
245
  quarter: 60 * 24 * 60 * 60 * 1000, // 60 d
1✔
246
  year: 182 * 24 * 60 * 60 * 1000, // 182 d
1✔
247
}
1✔
248

1✔
249
function panNumericalScale(scale: Scale, delta: number, limits: LimitOptions, pan = false) {
112✔
250
  const { min: prevStart, max: prevEnd } = scale
116✔
251
  let offset = 0
116✔
252
  if (isTimeScale(scale)) {
116✔
253
    const round = scale.options.time?.round
24✔
254
    offset = round ? OFFSETS[round] : 0
24!
255
  }
256
  const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart + offset) - delta)
116✔
257
  const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd + offset) - delta)
116✔
258
  if (isNotNumber(newMin) || isNotNumber(newMax)) {
116✔
259
    // NaN can happen for 0-dimension scales (either because they were configured
260
    // with min === max or because the chart has 0 plottable area).
261
    return true
4✔
262
  }
263
  return updateRange(scale, { min: newMin, max: newMax }, limits, pan ? 'pan' : false)
112✔
264
}
265

1✔
266
function panNonLinearScale(scale: Scale, delta: number, limits: LimitOptions) {
267
  return panNumericalScale(scale, delta, limits, true)
4✔
268
}
269

1✔
270
export const zoomFunctions: Record<string, ZoomFunction> = {
5✔
271
  category: zoomCategoryScale,
1✔
272
  default: zoomNumericalScale,
1✔
273
  logarithmic: zoomLogarithmicScale,
1✔
274
}
1✔
275

1✔
276
export const zoomRectFunctions: Record<string, ZoomRectFunction> = {
5✔
277
  default: zoomRectNumericalScale,
1✔
278
}
1✔
279

1✔
280
export const panFunctions: Record<string, PanFunction> = {
5✔
281
  category: panCategoryScale,
1✔
282
  default: panNumericalScale,
1✔
283
  logarithmic: panNonLinearScale,
1✔
284
  timeseries: panNonLinearScale,
1✔
285
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc