@stellar-expert/ui-framework 1.19.1 → 1.20.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/charts/axis/axis.js +470 -0
- package/charts/axis/tick-positioner.js +172 -0
- package/charts/chart-default-options.js +158 -0
- package/charts/chart-loader.js +26 -0
- package/charts/chart-options-builder.js +81 -0
- package/charts/chart.js +99 -0
- package/charts/chart.scss +80 -0
- package/charts/core/animate.js +99 -0
- package/charts/core/chart.js +555 -0
- package/charts/core/color.js +89 -0
- package/charts/core/options.js +40 -0
- package/charts/core/svg-renderer.js +155 -0
- package/charts/core/time.js +129 -0
- package/charts/core/utilities.js +92 -0
- package/charts/globals.js +32 -0
- package/charts/index.js +4 -0
- package/charts/interaction/legend.js +125 -0
- package/charts/interaction/tooltip.js +185 -0
- package/charts/pie/pie-chart.js +269 -0
- package/charts/polar/polar-chart.js +99 -0
- package/charts/series/area-series.js +57 -0
- package/charts/series/candlestick-series.js +60 -0
- package/charts/series/column-series.js +55 -0
- package/charts/series/index.js +21 -0
- package/charts/series/line-series.js +21 -0
- package/charts/series/series.js +161 -0
- package/charts/stock/data-grouping.js +141 -0
- package/charts/stock/navigator.js +205 -0
- package/charts/stock/range-selector.js +140 -0
- package/effect/effect-description.js +1 -1
- package/index.d.ts +36 -0
- package/index.js +2 -0
- package/interaction/dialog.js +10 -4
- package/interaction/dialog.scss +13 -1
- package/package.json +5 -2
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import {isNumber, pick, relativeLength, defined} from '../core/utilities'
|
|
2
|
+
import {getDateTimeTickPositions, dateTimeLabelFormat, dateFormat} from '../core/time'
|
|
3
|
+
import {getLinearTickPositions, niceAlignedTicks, zeroBasedGridTicks, formatAxisNumber, axisNumberUnit} from './tick-positioner'
|
|
4
|
+
|
|
5
|
+
const LABEL_FONT = 12
|
|
6
|
+
const LABEL_FONT_CSS = LABEL_FONT + 'px "Roboto Condensed",sans-serif'
|
|
7
|
+
|
|
8
|
+
//accurate text width via a cached canvas context (the condensed font is much narrower than a
|
|
9
|
+
//char-count estimate, so estimating would rotate axis labels that actually fit horizontally)
|
|
10
|
+
let measureCtx = null
|
|
11
|
+
function measureTextWidth(text) {
|
|
12
|
+
if (typeof document === 'undefined')
|
|
13
|
+
return text.length * LABEL_FONT * 0.5
|
|
14
|
+
if (!measureCtx)
|
|
15
|
+
measureCtx = document.createElement('canvas').getContext('2d')
|
|
16
|
+
measureCtx.font = LABEL_FONT_CSS
|
|
17
|
+
return measureCtx.measureText(text).width
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Axis {
|
|
21
|
+
constructor(chart, userOptions, coll, index) {
|
|
22
|
+
this.chart = chart
|
|
23
|
+
this.options = userOptions || {}
|
|
24
|
+
this.coll = coll
|
|
25
|
+
this.index = index
|
|
26
|
+
this.isXAxis = coll === 'xAxis'
|
|
27
|
+
this.horiz = this.isXAxis
|
|
28
|
+
//stock charts default the y-axis to the right (opposite); honour an explicit option otherwise
|
|
29
|
+
this.opposite = this.options.opposite !== undefined
|
|
30
|
+
? !!this.options.opposite
|
|
31
|
+
: (coll === 'yAxis' && chart.chartType === 'StockChart')
|
|
32
|
+
this.reversed = !!this.options.reversed
|
|
33
|
+
//stock price-style axes (a StockChart y-axis with no title) float their value labels INSIDE
|
|
34
|
+
//the plot, so changing the visible range — and thus the label text width — never reflows the plot
|
|
35
|
+
//horizontally. Titled dashboard axes keep their labels outside.
|
|
36
|
+
this.labelsInside = coll === 'yAxis' && chart.chartType === 'StockChart' &&
|
|
37
|
+
!(this.options.title && this.options.title.text)
|
|
38
|
+
this.type = this.options.type || 'linear'
|
|
39
|
+
this.isLog = this.type === 'logarithmic'
|
|
40
|
+
this.categories = this.options.categories || null
|
|
41
|
+
this.series = []
|
|
42
|
+
this.userMin = this.options.min
|
|
43
|
+
this.userMax = this.options.max
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setExtremes(min, max) {
|
|
47
|
+
this.userMin = (min === null || min === undefined) ? undefined : min
|
|
48
|
+
this.userMax = (max === null || max === undefined) ? undefined : max
|
|
49
|
+
this.chart.redraw()
|
|
50
|
+
this.fireSetExtremes('setExtremes')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//notify an afterSetExtremes handler (if any) of the current visible window. Called after redraw so
|
|
54
|
+
//this.min/this.max reflect the new extremes; views hook this to lazy-load data for the window.
|
|
55
|
+
//Debounced: navigator drags fire setExtremes on every mousemove, which would hammer a data-loading
|
|
56
|
+
//handler (→ HTTP 429). Rapid changes coalesce into one trailing call; the initial 'load' fires at once.
|
|
57
|
+
fireSetExtremes(trigger) {
|
|
58
|
+
const handler = this.options.events && this.options.events.afterSetExtremes
|
|
59
|
+
if (typeof handler !== 'function')
|
|
60
|
+
return
|
|
61
|
+
if (this.extremesTimer) {
|
|
62
|
+
clearTimeout(this.extremesTimer)
|
|
63
|
+
this.extremesTimer = null
|
|
64
|
+
}
|
|
65
|
+
const fire = () => {
|
|
66
|
+
this.extremesTimer = null
|
|
67
|
+
if (!this.chart || !this.chart.container)
|
|
68
|
+
return //chart destroyed before the debounce elapsed
|
|
69
|
+
handler.call(this, {min: this.min, max: this.max, target: this, trigger})
|
|
70
|
+
}
|
|
71
|
+
if (trigger === 'load') {
|
|
72
|
+
fire()
|
|
73
|
+
} else {
|
|
74
|
+
this.extremesTimer = setTimeout(fire, 300)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getSeriesExtremes() {
|
|
79
|
+
let dataMin = Infinity
|
|
80
|
+
let dataMax = -Infinity
|
|
81
|
+
//y-axes fit the currently visible x-window (StockChart auto-scaling)
|
|
82
|
+
const xa = this.chart.xAxis[0]
|
|
83
|
+
const winMin = (!this.isXAxis && xa) ? xa.min : undefined
|
|
84
|
+
const winMax = (!this.isXAxis && xa) ? xa.max : undefined
|
|
85
|
+
for (const s of this.series) {
|
|
86
|
+
if (!s.visible)
|
|
87
|
+
continue
|
|
88
|
+
if (this.isXAxis) {
|
|
89
|
+
for (const v of s.xData) {
|
|
90
|
+
if (isNumber(v)) {
|
|
91
|
+
if (v < dataMin) dataMin = v
|
|
92
|
+
if (v > dataMax) dataMax = v
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
//y-extremes come from the drawn (grouped/stacked/OHLC) points within the visible window
|
|
97
|
+
for (const p of s.plotPoints) {
|
|
98
|
+
if (isNumber(winMin) && (p.x < winMin || p.x > winMax))
|
|
99
|
+
continue
|
|
100
|
+
const hi = isNumber(p.stackY) ? p.stackY : (isNumber(p.high) ? p.high : p.y)
|
|
101
|
+
const lo = isNumber(p.stackLow) ? p.stackLow : (isNumber(p.low) ? p.low : hi)
|
|
102
|
+
if (isNumber(hi)) {
|
|
103
|
+
if (hi < dataMin) dataMin = hi
|
|
104
|
+
if (hi > dataMax) dataMax = hi
|
|
105
|
+
}
|
|
106
|
+
if (isNumber(lo)) {
|
|
107
|
+
if (lo < dataMin) dataMin = lo
|
|
108
|
+
if (lo > dataMax) dataMax = lo
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
//include series thresholds (area/column baseline) so the fill sits on the zero line
|
|
114
|
+
if (!this.isXAxis) {
|
|
115
|
+
for (const s of this.series) {
|
|
116
|
+
if (s.visible && isNumber(s.threshold)) {
|
|
117
|
+
if (s.threshold < dataMin) dataMin = s.threshold
|
|
118
|
+
if (s.threshold > dataMax) dataMax = s.threshold
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (dataMin === Infinity) {
|
|
123
|
+
//no visible series on this axis (e.g. its only series was toggled off): keep the previous
|
|
124
|
+
//extremes so the labels/margins don't jump and the axis doesn't show a meaningless 0..1 scale
|
|
125
|
+
if (isNumber(this.dataMin) && isNumber(this.dataMax) && this.dataMax > this.dataMin)
|
|
126
|
+
return
|
|
127
|
+
dataMin = 0
|
|
128
|
+
dataMax = 1
|
|
129
|
+
}
|
|
130
|
+
this.dataMin = dataMin
|
|
131
|
+
this.dataMax = dataMax
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setScale() {
|
|
135
|
+
this.getSeriesExtremes()
|
|
136
|
+
let min = pick(this.userMin, this.dataMin)
|
|
137
|
+
let max = pick(this.userMax, this.dataMax)
|
|
138
|
+
//initial visible window for StockChart (xAxis.range) — active until the user sets extremes
|
|
139
|
+
if (this.isXAxis && isNumber(this.options.range) &&
|
|
140
|
+
this.userMin === undefined && this.userMax === undefined) {
|
|
141
|
+
max = this.dataMax
|
|
142
|
+
min = max - this.options.range
|
|
143
|
+
}
|
|
144
|
+
if (isNumber(this.options.floor))
|
|
145
|
+
min = Math.max(min, this.options.floor)
|
|
146
|
+
if (isNumber(this.options.ceiling))
|
|
147
|
+
max = Math.min(max, this.options.ceiling)
|
|
148
|
+
|
|
149
|
+
//tick density: x adapts to width (denser, ~one per 42px → more labels when there's room),
|
|
150
|
+
//y ~one gridline per 72px
|
|
151
|
+
const targetCount = this.horiz ? Math.max(2, Math.round(this.lenForTicks() / 42))
|
|
152
|
+
: Math.max(3, Math.round(this.lenForTicks() / 72))
|
|
153
|
+
|
|
154
|
+
if (this.isLog) {
|
|
155
|
+
//work in log-transformed space; ticks at integer exponents
|
|
156
|
+
const lMin = this.transform(Math.max(min, 0))
|
|
157
|
+
const lMax = this.transform(Math.max(max, 0))
|
|
158
|
+
const start = Math.floor(lMin)
|
|
159
|
+
const end = Math.max(start + 1, Math.ceil(lMax))
|
|
160
|
+
this.min = start
|
|
161
|
+
this.max = end
|
|
162
|
+
this.tickPositions = []
|
|
163
|
+
for (let i = start; i <= end; i++)
|
|
164
|
+
this.tickPositions.push(i)
|
|
165
|
+
} else if (this.type === 'datetime') {
|
|
166
|
+
if (min === max) {
|
|
167
|
+
min -= 0.5
|
|
168
|
+
max += 0.5
|
|
169
|
+
}
|
|
170
|
+
this.min = min
|
|
171
|
+
this.max = max
|
|
172
|
+
const {positions, unitName} = getDateTimeTickPositions(min, max, targetCount)
|
|
173
|
+
this.dateUnit = unitName
|
|
174
|
+
//thin labels so wide date strings (e.g. "27 May") don't overlap, instead of rotating them
|
|
175
|
+
let pos = positions
|
|
176
|
+
if (isNumber(this.len) && pos.length > 2) {
|
|
177
|
+
const sampleW = (String(this.labelText(pos[0])).length + 1) * LABEL_FONT * 0.55
|
|
178
|
+
const maxFit = Math.max(2, Math.floor(this.len / (sampleW + 6)))
|
|
179
|
+
if (pos.length > maxFit) {
|
|
180
|
+
const step = Math.ceil(pos.length / maxFit)
|
|
181
|
+
pos = pos.filter((unused, i) => i % step === 0)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.tickPositions = pos
|
|
185
|
+
} else if (this.categories) {
|
|
186
|
+
//half-padding on each side so columns sit between gridlines
|
|
187
|
+
this.min = -0.5
|
|
188
|
+
this.max = this.categories.length - 0.5
|
|
189
|
+
this.tickPositions = this.categories.map((unused, i) => i)
|
|
190
|
+
} else if (this.isXAxis) {
|
|
191
|
+
if (min === max) {
|
|
192
|
+
min -= 0.5
|
|
193
|
+
max += 0.5
|
|
194
|
+
}
|
|
195
|
+
const startOnTick = pick(this.options.startOnTick, false)
|
|
196
|
+
const endOnTick = pick(this.options.endOnTick, false)
|
|
197
|
+
const {positions, min: tMin, max: tMax} = getLinearTickPositions(min, max, targetCount, startOnTick, endOnTick)
|
|
198
|
+
this.min = startOnTick ? tMin : min
|
|
199
|
+
this.max = endOnTick ? tMax : max
|
|
200
|
+
this.tickPositions = positions
|
|
201
|
+
} else {
|
|
202
|
+
if (min === max) {
|
|
203
|
+
min -= 0.5
|
|
204
|
+
max += 0.5
|
|
205
|
+
}
|
|
206
|
+
const smallPane = isNumber(this.len) && this.len < 90
|
|
207
|
+
const padded = this.options.minPadding || this.options.maxPadding
|
|
208
|
+
//dashboard axes start at 0 → fixed 5-section grid (0/20/…/100%) with the data filling ≤90% of the
|
|
209
|
+
//height, so the top value never reaches the zoom controls. Skipped for padded/inline sparkline
|
|
210
|
+
//axes (their line should fill the height, not sit at 80%) and tiny panes (e.g. a volume strip).
|
|
211
|
+
if (!padded && !smallPane && min <= 1e-9 && max > 0) {
|
|
212
|
+
const r = zeroBasedGridTicks(max)
|
|
213
|
+
this.tickInterval = r.interval
|
|
214
|
+
this.tickPositions = r.positions
|
|
215
|
+
this.labelledPositions = r.labelled
|
|
216
|
+
this.min = r.min
|
|
217
|
+
this.max = r.max
|
|
218
|
+
this.zeroBased = true
|
|
219
|
+
} else {
|
|
220
|
+
//non-zero / padded / small axis (e.g. a zoomed price pane): nice round ticks over the visible
|
|
221
|
+
//range, hiding a pure-headroom top tick and reserving ~0.7 of a step above so it isn't flush
|
|
222
|
+
const count = Math.max(3, Math.round(this.lenForTicks() / 72)) + 1
|
|
223
|
+
const r = niceAlignedTicks(min, max, count)
|
|
224
|
+
let drawn = r.positions
|
|
225
|
+
if (drawn[drawn.length - 1] > max * (1 + 1e-9))
|
|
226
|
+
drawn = drawn.slice(0, -1)
|
|
227
|
+
const nIntervals = drawn.length - 1
|
|
228
|
+
const dataSteps = r.interval > 0 ? (max - r.min) / r.interval : nIntervals
|
|
229
|
+
const M = Math.max(nIntervals + 0.7, dataSteps + 0.15)
|
|
230
|
+
this.tickInterval = r.interval
|
|
231
|
+
this.tickPositions = drawn
|
|
232
|
+
this.labelledPositions = null
|
|
233
|
+
this.min = r.min
|
|
234
|
+
this.max = r.min + M * r.interval
|
|
235
|
+
this.zeroBased = false
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//optional fractional padding on either axis — insets the data from the plot edges. Used by inline
|
|
239
|
+
//sparklines so the line stroke doesn't clip vertically and isn't flush against the surrounding
|
|
240
|
+
//text/edges horizontally. Default 0, so regular charts are unaffected.
|
|
241
|
+
const minPad = pick(this.options.minPadding, 0)
|
|
242
|
+
const maxPad = pick(this.options.maxPadding, 0)
|
|
243
|
+
if (minPad || maxPad) {
|
|
244
|
+
const span = (this.max - this.min) || 1
|
|
245
|
+
this.min -= span * minPad
|
|
246
|
+
this.max += span * maxPad
|
|
247
|
+
}
|
|
248
|
+
//explicit tick positions (e.g. [] to suppress axis decorations on sparklines)
|
|
249
|
+
if (Array.isArray(this.options.tickPositions))
|
|
250
|
+
this.tickPositions = this.options.tickPositions
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Map a data value into the axis' internal (possibly log-transformed) space.
|
|
255
|
+
* @param {number} value
|
|
256
|
+
* @return {number}
|
|
257
|
+
*/
|
|
258
|
+
transform(value) {
|
|
259
|
+
if (!this.isLog)
|
|
260
|
+
return value
|
|
261
|
+
if (typeof this.log2lin === 'function')
|
|
262
|
+
return this.log2lin(value)
|
|
263
|
+
return Math.log(Math.max(value, 1e-9)) / Math.LN10
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Inverse of {@link transform} — internal space back to a data value.
|
|
268
|
+
* @param {number} linVal
|
|
269
|
+
* @return {number}
|
|
270
|
+
*/
|
|
271
|
+
invTransform(linVal) {
|
|
272
|
+
if (!this.isLog)
|
|
273
|
+
return linVal
|
|
274
|
+
if (typeof this.lin2log === 'function')
|
|
275
|
+
return this.lin2log(linVal)
|
|
276
|
+
return Math.pow(10, linVal)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
//axis length for tick-count targeting — prefer the actual pane length once it's been sized
|
|
280
|
+
lenForTicks() {
|
|
281
|
+
if (isNumber(this.len))
|
|
282
|
+
return this.len
|
|
283
|
+
const c = this.chart
|
|
284
|
+
return this.horiz ? (c.plotWidth || c.chartWidth) : (c.plotHeight || c.chartHeight)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setAxisSize() {
|
|
288
|
+
const {plotLeft, plotTop, plotWidth, plotHeight} = this.chart
|
|
289
|
+
if (this.horiz) {
|
|
290
|
+
this.left = plotLeft
|
|
291
|
+
this.len = plotWidth
|
|
292
|
+
this.axisPos = plotTop + plotHeight //bottom line
|
|
293
|
+
} else {
|
|
294
|
+
const total = plotHeight
|
|
295
|
+
const h = defined(this.options.height) ? relativeLength(this.options.height, total) : total
|
|
296
|
+
const t = defined(this.options.top) ? relativeLength(this.options.top, total) : 0
|
|
297
|
+
this.top = plotTop + t
|
|
298
|
+
this.len = h
|
|
299
|
+
this.offset = this.options.offset || 0
|
|
300
|
+
this.axisPos = this.opposite ? (plotLeft + plotWidth + this.offset) : (plotLeft - this.offset)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
//map a value already in internal (transformed) space to a pixel coordinate
|
|
305
|
+
linToPixels(linVal) {
|
|
306
|
+
const span = (this.max - this.min) || 1
|
|
307
|
+
const frac = (linVal - this.min) / span
|
|
308
|
+
if (this.horiz)
|
|
309
|
+
return this.left + (this.reversed ? 1 - frac : frac) * this.len
|
|
310
|
+
return this.top + (this.reversed ? frac : 1 - frac) * this.len
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
//map a data value to a pixel coordinate (applies log transform when needed)
|
|
314
|
+
toPixels(value) {
|
|
315
|
+
return this.linToPixels(this.transform(value))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
labelText(value) {
|
|
319
|
+
if (this.categories)
|
|
320
|
+
return this.categories[value] !== undefined ? this.categories[value] : ''
|
|
321
|
+
if (this.type === 'datetime')
|
|
322
|
+
return dateFormat(dateTimeLabelFormat(this.dateUnit), value)
|
|
323
|
+
if (this.isLog)
|
|
324
|
+
return formatAxisNumber(this.invTransform(value), this.labelUnit)
|
|
325
|
+
return formatAxisNumber(value, this.labelUnit)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
//single unit (k/M) shared by all numeric labels on this axis, from the largest tick value.
|
|
329
|
+
//log axes span many magnitudes, so each tick is formatted with its OWN unit (10 / 1k / 1M) instead
|
|
330
|
+
//of a shared one (which would render small ticks as "0.00001M")
|
|
331
|
+
computeLabelUnit() {
|
|
332
|
+
if (this.categories || this.type === 'datetime' || this.isLog || !this.tickPositions)
|
|
333
|
+
return undefined
|
|
334
|
+
let maxAbs = 0
|
|
335
|
+
for (const p of this.tickPositions) {
|
|
336
|
+
const dv = (this.isLog && this.invTransform) ? this.invTransform(p) : p
|
|
337
|
+
maxAbs = Math.max(maxAbs, Math.abs(dv))
|
|
338
|
+
}
|
|
339
|
+
return axisNumberUnit(maxAbs)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
estimateLabelWidth() {
|
|
343
|
+
let maxLen = 0
|
|
344
|
+
for (const pos of this.tickPositions || []) {
|
|
345
|
+
const t = String(this.labelText(pos))
|
|
346
|
+
if (t.length > maxLen) maxLen = t.length
|
|
347
|
+
}
|
|
348
|
+
return Math.ceil(maxLen * LABEL_FONT * 0.55) + 6
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
//horizontal labels overlap when their combined width exceeds the axis → rotate them
|
|
352
|
+
//(datetime labels are thinned instead of rotated, so they never rotate here)
|
|
353
|
+
needsRotation() {
|
|
354
|
+
if (!this.horiz || this.type === 'datetime' || !this.tickPositions || !isNumber(this.len))
|
|
355
|
+
return false
|
|
356
|
+
let maxW = 0
|
|
357
|
+
for (const pos of this.tickPositions) {
|
|
358
|
+
const w = measureTextWidth(String(this.labelText(pos)))
|
|
359
|
+
if (w > maxW) maxW = w
|
|
360
|
+
}
|
|
361
|
+
//rotate only when the widest label can't fit its category slot (kept horizontal whenever
|
|
362
|
+
//there's room) — 6px of breathing space between neighbours
|
|
363
|
+
const slot = this.len / this.tickPositions.length
|
|
364
|
+
return maxW + 6 > slot
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
render(gridGroup, axisGroup) {
|
|
368
|
+
const {chart} = this
|
|
369
|
+
const renderer = chart.renderer
|
|
370
|
+
this.labelUnit = this.computeLabelUnit()
|
|
371
|
+
const {plotLeft, plotTop, plotWidth, plotHeight} = chart
|
|
372
|
+
const gridColor = 'var(--color-border-shadow)'
|
|
373
|
+
const textStyle = {
|
|
374
|
+
fontSize: LABEL_FONT + 'px',
|
|
375
|
+
fontFamily: 'Roboto Condensed,sans-serif',
|
|
376
|
+
color: 'var(--color-text)',
|
|
377
|
+
fill: 'var(--color-text)'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (this.horiz) {
|
|
381
|
+
const y = plotTop + plotHeight
|
|
382
|
+
const rotate = this.needsRotation()
|
|
383
|
+
for (const pos of this.tickPositions) {
|
|
384
|
+
if (pos < this.min - 1e-6 || pos > this.max + 1e-6)
|
|
385
|
+
continue
|
|
386
|
+
const x = this.linToPixels(pos)
|
|
387
|
+
//gridline
|
|
388
|
+
if (this.options.gridLineWidth !== 0 && pick(this.options.gridLineWidth, 1)) {
|
|
389
|
+
renderer.line(x, plotTop, x, y, {'stroke-width': 1}).css({stroke: gridColor}).add(gridGroup)
|
|
390
|
+
}
|
|
391
|
+
//tick mark
|
|
392
|
+
renderer.line(x, y, x, y + 5, {'stroke-width': 1}).css({stroke: gridColor}).add(axisGroup)
|
|
393
|
+
//label (rotated -45° when labels would otherwise overlap)
|
|
394
|
+
if (rotate) {
|
|
395
|
+
renderer.label(this.labelText(pos), x, y + 14, {'text-anchor': 'end', transform: `rotate(-45 ${x} ${y + 14})`})
|
|
396
|
+
.css(textStyle).add(axisGroup)
|
|
397
|
+
} else {
|
|
398
|
+
renderer.label(this.labelText(pos), x, y + 18, {'text-anchor': 'middle'}).css(textStyle).add(axisGroup)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
//axis line (respect lineWidth: 0 — e.g. sparklines draw no baseline)
|
|
402
|
+
if (pick(this.options.lineWidth, 1))
|
|
403
|
+
renderer.line(plotLeft, y, plotLeft + plotWidth, y, {'stroke-width': 1}).css({stroke: gridColor}).add(axisGroup)
|
|
404
|
+
} else {
|
|
405
|
+
const axisX = this.axisPos
|
|
406
|
+
const offset = this.offset || 0
|
|
407
|
+
const inside = this.labelsInside
|
|
408
|
+
//inside labels: right-align just within the right edge (or left-align within the left edge),
|
|
409
|
+
//floating over the plot. Outside labels: hug the plot edge from the outside.
|
|
410
|
+
const labelAnchor = inside
|
|
411
|
+
? (this.opposite ? 'end' : 'start')
|
|
412
|
+
: (this.opposite ? 'start' : 'end')
|
|
413
|
+
//numbers sit close to the plot edge regardless of the axis offset (offset pushes the title out,
|
|
414
|
+
//so multiple offset panes keep their value labels near the chart)
|
|
415
|
+
const labelX = inside
|
|
416
|
+
? (this.opposite ? plotLeft + plotWidth - 4 : plotLeft + 4)
|
|
417
|
+
: (this.opposite ? plotLeft + plotWidth + 8 : plotLeft - 8)
|
|
418
|
+
//inside labels sit just above their gridline; outside labels are vertically centred
|
|
419
|
+
const labelDy = inside ? -3 : 4
|
|
420
|
+
//short panes (e.g. a 14%-height volume strip) only show the 0 label and skip minor gridlines
|
|
421
|
+
const smallPane = isNumber(this.len) && this.len < 90
|
|
422
|
+
//draw minor gridlines only for the first y-axis sharing this pane, so aligned dual axes
|
|
423
|
+
//(left+right on the same pane) don't double them up — regardless of which side it's on
|
|
424
|
+
const paneKey = Math.round(this.top) + '|' + Math.round(this.len)
|
|
425
|
+
const firstInPane = this.chart.yAxis.find(a =>
|
|
426
|
+
Math.round(a.top) + '|' + Math.round(a.len) === paneKey) === this
|
|
427
|
+
//minor gridlines: 4 faint lines between each major (gap = 20% of the step)
|
|
428
|
+
if (!smallPane && firstInPane && !this.isLog && !this.categories &&
|
|
429
|
+
pick(this.options.gridLineWidth, 1) && this.tickPositions.length >= 2) {
|
|
430
|
+
const step = this.tickPositions[1] - this.tickPositions[0]
|
|
431
|
+
if (step > 0) {
|
|
432
|
+
const minorStep = step / 5
|
|
433
|
+
for (let v = this.tickPositions[0] + minorStep; v <= this.max + 1e-6; v += minorStep) {
|
|
434
|
+
const rel = (v - this.tickPositions[0]) / step
|
|
435
|
+
if (Math.abs(rel - Math.round(rel)) < 1e-6)
|
|
436
|
+
continue //skip positions that coincide with a major tick
|
|
437
|
+
const my = this.linToPixels(v)
|
|
438
|
+
renderer.line(plotLeft, my, plotLeft + plotWidth, my, {'stroke-width': 1})
|
|
439
|
+
.css({stroke: gridColor, 'stroke-opacity': 0.35}).add(gridGroup)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
for (const pos of this.tickPositions) {
|
|
444
|
+
if (pos < this.min - 1e-6 || pos > this.max + 1e-6)
|
|
445
|
+
continue
|
|
446
|
+
const py = this.linToPixels(pos)
|
|
447
|
+
if (pick(this.options.gridLineWidth, 1)) {
|
|
448
|
+
renderer.line(plotLeft, py, plotLeft + plotWidth, py, {'stroke-width': 1}).css({stroke: gridColor}).add(gridGroup)
|
|
449
|
+
}
|
|
450
|
+
//labelledPositions (zero-based grid) hides the top 100% line's value; a short pane labels only 0
|
|
451
|
+
const labelled = !this.labelledPositions ||
|
|
452
|
+
this.labelledPositions.some(p => Math.abs(p - pos) < 1e-9)
|
|
453
|
+
if (labelled && (!smallPane || Math.abs(pos) < 1e-9))
|
|
454
|
+
renderer.label(this.labelText(pos), labelX, py + labelDy, {'text-anchor': labelAnchor}).css(textStyle).add(axisGroup)
|
|
455
|
+
}
|
|
456
|
+
//title
|
|
457
|
+
const titleText = this.options.title && this.options.title.text
|
|
458
|
+
if (titleText) {
|
|
459
|
+
//with an offset, align titles across panes at the offset distance; otherwise clear the labels
|
|
460
|
+
const cx = offset > 0
|
|
461
|
+
? (this.opposite ? axisX + 8 : axisX - 8)
|
|
462
|
+
: (this.opposite ? axisX + this.estimateLabelWidth() + 14 : axisX - this.estimateLabelWidth() - 14)
|
|
463
|
+
const cy = this.top + this.len / 2
|
|
464
|
+
const rot = this.opposite ? 90 : -90
|
|
465
|
+
renderer.label(titleText, cx, cy, {'text-anchor': 'middle', transform: `rotate(${rot} ${cx} ${cy})`})
|
|
466
|
+
.css({...textStyle}).add(axisGroup)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
//Linear "nice number" tick interval + positions.
|
|
2
|
+
import {addThousandsSep} from '../core/utilities'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Round an interval to a human-friendly value (1, 2, 2.5, 5, 10 * 10^n).
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeTickInterval(interval) {
|
|
8
|
+
const magnitude = Math.pow(10, Math.floor(Math.log(interval) / Math.LN10))
|
|
9
|
+
const normalized = interval / magnitude
|
|
10
|
+
let multiple
|
|
11
|
+
if (normalized < 1.5) multiple = 1
|
|
12
|
+
else if (normalized < 3) multiple = 2
|
|
13
|
+
else if (normalized < 7) multiple = 5
|
|
14
|
+
else multiple = 10
|
|
15
|
+
return multiple * magnitude
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute linear tick positions for [min, max].
|
|
20
|
+
* @return {{positions: number[], interval: number, min: number, max: number}}
|
|
21
|
+
*/
|
|
22
|
+
export function getLinearTickPositions(min, max, targetCount, startOnTick, endOnTick) {
|
|
23
|
+
if (max <= min) {
|
|
24
|
+
max = min + 1
|
|
25
|
+
}
|
|
26
|
+
const rough = (max - min) / Math.max(1, targetCount)
|
|
27
|
+
const interval = normalizeTickInterval(rough)
|
|
28
|
+
|
|
29
|
+
let tickMin = startOnTick ? Math.floor(min / interval) * interval : min
|
|
30
|
+
let tickMax = endOnTick ? Math.ceil(max / interval) * interval : max
|
|
31
|
+
|
|
32
|
+
const positions = []
|
|
33
|
+
//guard against floating point drift
|
|
34
|
+
const decimals = interval < 1 ? Math.ceil(-Math.log(interval) / Math.LN10) + 1 : 0
|
|
35
|
+
for (let pos = tickMin; pos <= tickMax + interval / 1e6; pos += interval) {
|
|
36
|
+
positions.push(decimals ? parseFloat(pos.toFixed(decimals)) : pos)
|
|
37
|
+
}
|
|
38
|
+
return {positions, interval, min: tickMin, max: tickMax}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//nice tick mantissas for range-fitted axes — lets them land on 3/4/6/8 to fit an exact gridline count.
|
|
42
|
+
//1.5 is excluded HERE (the in-range band search) because in a band like (128,154] it would grab 150 —
|
|
43
|
+
//yielding cluttered 150/450/750 labels; those ranges fall through to the ceil fallback below and land on
|
|
44
|
+
//a cleaner 2×10ⁿ (→200/800) instead.
|
|
45
|
+
const ALIGNED_NICE = [1, 2, 2.5, 3, 4, 5, 6, 8, 10]
|
|
46
|
+
|
|
47
|
+
//the ceil fallback DOES allow 1.5, so a tight small range (e.g. data ~6.2, interval ~1.24) lands on
|
|
48
|
+
//0,1.5,3,4.5,6 instead of the looser 0,2,4,6,8. This is safe: niceCeilAligned returns the smallest value
|
|
49
|
+
//>= v, so 1.5×10ⁿ is only ever chosen when v itself sits in (1×10ⁿ, 1.5×10ⁿ] — never for a band like 154
|
|
50
|
+
//(→200), so it can't reintroduce 150/750 on a large axis.
|
|
51
|
+
const ALIGNED_NICE_CEIL = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]
|
|
52
|
+
|
|
53
|
+
//smallest ALIGNED_NICE_CEIL value >= v
|
|
54
|
+
function niceCeilAligned(v) {
|
|
55
|
+
if (!(v > 0))
|
|
56
|
+
return 1
|
|
57
|
+
const base = Math.floor(Math.log(v) / Math.LN10)
|
|
58
|
+
for (let mag = base - 1; mag <= base + 2; mag++) {
|
|
59
|
+
const scale = Math.pow(10, mag)
|
|
60
|
+
for (const n of ALIGNED_NICE_CEIL)
|
|
61
|
+
if (n * scale >= v * (1 - 1e-9))
|
|
62
|
+
return n * scale
|
|
63
|
+
}
|
|
64
|
+
return Math.pow(10, base + 2)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//smallest "nice" value strictly above lo and at most hi, or null if the band contains none
|
|
68
|
+
function niceInRange(lo, hi) {
|
|
69
|
+
if (!(hi > 0))
|
|
70
|
+
return null
|
|
71
|
+
const candidates = []
|
|
72
|
+
const top = Math.floor(Math.log(hi) / Math.LN10) + 1
|
|
73
|
+
for (let mag = top; mag >= top - 3; mag--) {
|
|
74
|
+
const scale = Math.pow(10, mag)
|
|
75
|
+
for (const n of ALIGNED_NICE)
|
|
76
|
+
candidates.push(n * scale)
|
|
77
|
+
}
|
|
78
|
+
candidates.sort((a, b) => a - b)
|
|
79
|
+
for (const v of candidates)
|
|
80
|
+
if (v > lo * (1 + 1e-9) && v <= hi * (1 + 1e-9))
|
|
81
|
+
return v
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Range-fitted nice ticks for a non-zero-based axis (e.g. a zoomed price pane). Picks a nice interval that
|
|
87
|
+
* yields about `count` ticks: the band (range/count, range/(count-1)] holds the interval that places
|
|
88
|
+
* `count-1` intervals at or below the data, so the top tick can sit just under the data with a little
|
|
89
|
+
* headroom above it. When several axes share a pane they pass the same `count`, so their gridlines line up.
|
|
90
|
+
* @return {{min:number, max:number, interval:number, positions:number[]}}
|
|
91
|
+
*/
|
|
92
|
+
export function niceAlignedTicks(dataMin, dataMax, count) {
|
|
93
|
+
let dmin = dataMin
|
|
94
|
+
let dmax = dataMax
|
|
95
|
+
if (dmin === dmax) {
|
|
96
|
+
dmin -= 0.5
|
|
97
|
+
dmax += 0.5
|
|
98
|
+
}
|
|
99
|
+
const intervals = Math.max(1, count - 1)
|
|
100
|
+
const span = (dmax - dmin) || Math.abs(dmax) || 1
|
|
101
|
+
let interval = niceInRange(span / count, span / intervals)
|
|
102
|
+
if (!interval)
|
|
103
|
+
interval = niceCeilAligned(span / intervals)
|
|
104
|
+
const min = Math.floor(dmin / interval) * interval
|
|
105
|
+
const decimals = interval < 1 ? Math.ceil(-Math.log(interval) / Math.LN10) + 1 : 2
|
|
106
|
+
const positions = []
|
|
107
|
+
for (let i = 0; i < count; i++)
|
|
108
|
+
positions.push(parseFloat((min + interval * i).toFixed(decimals)))
|
|
109
|
+
const top = positions[positions.length - 1]
|
|
110
|
+
//small headroom so the series doesn't sit flush against the top edge; never below the last tick
|
|
111
|
+
return {min, max: Math.max(dmax + span * 0.04, top), interval, positions}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Zero-based dashboard grid: ALWAYS 5 equal sections (6 gridlines at 0/20/40/60/80/100% of the height).
|
|
116
|
+
* The interval is the smallest nice value (incl. 1.5×10ⁿ) ≥ dataMax/4.5, so the data max reaches at most
|
|
117
|
+
* 90% of the top (≈80% typically) and never touches the top edge / zoom controls:
|
|
118
|
+
* 38 → 10 (0,10,20,30,40,·50), 45 → 10, 46 → 15 (0,15,30,45,60,·75), 775M → 200M (…,800M,·1G).
|
|
119
|
+
* Because every axis spans 0..5·interval, two axes sharing a pane land on the SAME six pixel lines
|
|
120
|
+
* automatically — no cross-axis reconciliation needed. The top (100%) line is drawn but not labelled.
|
|
121
|
+
* @return {{min:number, max:number, interval:number, positions:number[], labelled:number[]}}
|
|
122
|
+
*/
|
|
123
|
+
export function zeroBasedGridTicks(dataMax) {
|
|
124
|
+
const SECTIONS = 5
|
|
125
|
+
//data max may reach at most 90% of the top section line → interval ≥ dataMax / (5 * 0.9) = dataMax / 4.5
|
|
126
|
+
const interval = niceCeilAligned((dataMax > 0 ? dataMax : 1) / 4.5)
|
|
127
|
+
const decimals = interval < 1 ? Math.ceil(-Math.log(interval) / Math.LN10) + 1 : 2
|
|
128
|
+
const positions = []
|
|
129
|
+
for (let i = 0; i <= SECTIONS; i++)
|
|
130
|
+
positions.push(parseFloat((interval * i).toFixed(decimals)))
|
|
131
|
+
return {
|
|
132
|
+
min: 0,
|
|
133
|
+
max: interval * SECTIONS,
|
|
134
|
+
interval,
|
|
135
|
+
positions,
|
|
136
|
+
labelled: positions.slice(0, SECTIONS) //topmost (100%) line is drawn but its value isn't shown
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pick a single unit for a whole axis from its largest absolute value, so every label uses the same
|
|
142
|
+
* scale (e.g. 500M / 1,000M / 1,500M / 2,000M rather than 500M / 1B / 1.5B).
|
|
143
|
+
* @return {{divisor:number, suffix:string}}
|
|
144
|
+
*/
|
|
145
|
+
export function axisNumberUnit(maxAbs) {
|
|
146
|
+
//SI-style numeric symbols: k (1e3), M (1e6), G (1e9), T (1e12) — so 10,000M renders as 10G
|
|
147
|
+
if (maxAbs >= 1e12) return {divisor: 1e12, suffix: 'T'}
|
|
148
|
+
if (maxAbs >= 1e9) return {divisor: 1e9, suffix: 'G'}
|
|
149
|
+
if (maxAbs >= 1e6) return {divisor: 1e6, suffix: 'M'}
|
|
150
|
+
if (maxAbs >= 1e3) return {divisor: 1e3, suffix: 'k'}
|
|
151
|
+
return {divisor: 1, suffix: ''}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Format an axis numeric label with thousands separators, using a fixed unit when provided.
|
|
156
|
+
* @param {number} value
|
|
157
|
+
* @param {{divisor:number, suffix:string}} [unit] - shared axis unit; derived per-value if omitted
|
|
158
|
+
*/
|
|
159
|
+
export function formatAxisNumber(value, unit) {
|
|
160
|
+
if (value === 0)
|
|
161
|
+
return '0'
|
|
162
|
+
const {divisor, suffix} = unit || axisNumberUnit(Math.abs(value))
|
|
163
|
+
const v = value / divisor
|
|
164
|
+
const a = Math.abs(v)
|
|
165
|
+
if (a > 0 && a < 1)
|
|
166
|
+
return parseFloat(v.toPrecision(4)) + suffix
|
|
167
|
+
return addThousandsSep(trimZeros(v)) + suffix
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function trimZeros(v) {
|
|
171
|
+
return String(parseFloat(v.toFixed(2)))
|
|
172
|
+
}
|