@stellar-expert/ui-framework 1.19.2 → 1.21.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.
Files changed (43) hide show
  1. package/account/signer-key.js +1 -1
  2. package/charts/axis/axis.js +470 -0
  3. package/charts/axis/tick-positioner.js +172 -0
  4. package/charts/chart-default-options.js +158 -0
  5. package/charts/chart-loader.js +26 -0
  6. package/charts/chart-options-builder.js +81 -0
  7. package/charts/chart.js +99 -0
  8. package/charts/chart.scss +80 -0
  9. package/charts/core/animate.js +99 -0
  10. package/charts/core/chart.js +555 -0
  11. package/charts/core/color.js +89 -0
  12. package/charts/core/options.js +40 -0
  13. package/charts/core/svg-renderer.js +155 -0
  14. package/charts/core/time.js +129 -0
  15. package/charts/core/utilities.js +92 -0
  16. package/charts/globals.js +32 -0
  17. package/charts/index.js +4 -0
  18. package/charts/interaction/legend.js +125 -0
  19. package/charts/interaction/tooltip.js +185 -0
  20. package/charts/pie/pie-chart.js +269 -0
  21. package/charts/polar/polar-chart.js +99 -0
  22. package/charts/series/area-series.js +57 -0
  23. package/charts/series/candlestick-series.js +60 -0
  24. package/charts/series/column-series.js +55 -0
  25. package/charts/series/index.js +21 -0
  26. package/charts/series/line-series.js +21 -0
  27. package/charts/series/series.js +161 -0
  28. package/charts/stock/data-grouping.js +141 -0
  29. package/charts/stock/navigator.js +205 -0
  30. package/charts/stock/range-selector.js +140 -0
  31. package/contract/sc-val.js +1 -1
  32. package/directory/directory-hooks.js +1 -1
  33. package/effect/effect-description.js +2 -2
  34. package/filter/editors/account-filter-view.js +1 -1
  35. package/filter/filter-editors.js +1 -1
  36. package/index.d.ts +36 -0
  37. package/index.js +2 -0
  38. package/ledger/ledger-info-parser.js +1 -1
  39. package/package.json +14 -11
  40. package/stellar/key-type.js +1 -1
  41. package/stellar/signature-hint-utils.js +1 -1
  42. package/tx/op-description-view.js +1 -1
  43. package/tx/parser/tx-matcher.js +1 -1
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import {StrKey} from '@stellar/stellar-base'
2
+ import {StrKey} from '@stellar/stellar-sdk'
3
3
  import {AccountAddress} from './account-address'
4
4
 
5
5
  /**
@@ -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
+ }