@stellar-expert/ui-framework 1.21.2 → 1.21.4

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.
@@ -131,6 +131,21 @@ export class Axis {
131
131
  this.dataMax = dataMax
132
132
  }
133
133
 
134
+ //pixels at the top of the pane that must stay clear of drawn data — the floating range-selector
135
+ //buttons overlay the top ~26px of the plot, so the pane that starts at the plot top scales its
136
+ //max up until the tallest point (incl. candle wicks) renders below the controls
137
+ reservedTopPixels() {
138
+ const c = this.chart
139
+ if (this.isXAxis || c.chartType !== 'StockChart' || !c.options.rangeSelector)
140
+ return 0
141
+ if (!isNumber(this.len) || !isNumber(this.top) || !isNumber(c.plotTop))
142
+ return 0
143
+ //only the top pane sits under the controls; lower panes (e.g. a volume strip) are unaffected
144
+ if (this.top > c.plotTop + 1)
145
+ return 0
146
+ return 26
147
+ }
148
+
134
149
  setScale() {
135
150
  this.getSeriesExtremes()
136
151
  let min = pick(this.userMin, this.dataMin)
@@ -146,9 +161,8 @@ export class Axis {
146
161
  if (isNumber(this.options.ceiling))
147
162
  max = Math.min(max, this.options.ceiling)
148
163
 
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))
164
+ const xTickSpacing = this.type === 'datetime' ? 100 : 42
165
+ const targetCount = this.horiz ? Math.max(2, Math.round(this.lenForTicks() / xTickSpacing))
152
166
  : Math.max(3, Math.round(this.lenForTicks() / 72))
153
167
 
154
168
  if (this.isLog) {
@@ -226,7 +240,12 @@ export class Axis {
226
240
  drawn = drawn.slice(0, -1)
227
241
  const nIntervals = drawn.length - 1
228
242
  const dataSteps = r.interval > 0 ? (max - r.min) / r.interval : nIntervals
229
- const M = Math.max(nIntervals + 0.7, dataSteps + 0.15)
243
+ let M = Math.max(nIntervals + 0.7, dataSteps + 0.15)
244
+ //widen the headroom so the data top maps below the reserved zoom-controls band:
245
+ //(M - dataSteps)/M of the pane height must be at least reserve/len
246
+ const reserve = this.reservedTopPixels()
247
+ if (reserve && this.len > reserve * 2)
248
+ M = Math.max(M, dataSteps / (1 - reserve / this.len))
230
249
  this.tickInterval = r.interval
231
250
  this.tickPositions = drawn
232
251
  this.labelledPositions = null
@@ -25,9 +25,9 @@ const groupedPlotOptions = {
25
25
  series: {
26
26
  dataGrouping: {
27
27
  units: groupingUnits,
28
- //17 (not 16) so the bucket interval clears the weekly/bi-weekly rounding boundary at the 1y
29
- //view keeps the 1y window on monthly buckets instead of tipping to weekly
30
- groupPixelWidth: 17
28
+ //grouping is width-dependent: wider plots get proportionally finer buckets. 16.5px per
29
+ //bucket puts the 2-month↔6-month transition of a ~10y daily series at a ~540px plot area
30
+ groupPixelWidth: 16.5
31
31
  }
32
32
  }
33
33
  }
package/charts/chart.scss CHANGED
@@ -61,6 +61,27 @@
61
61
  }
62
62
  }
63
63
 
64
+ //floating "Reset zoom" control shown over the plot top-right corner after a drag-zoom selection
65
+ //on charts without the range-selector row; positioned inline by zoom.js (top/right from plot box)
66
+ .chart-reset-zoom {
67
+ position: absolute;
68
+ z-index: 5;
69
+ display: inline-block;
70
+ border: 1px solid var(--color-contrast-border);
71
+ border-radius: 3px;
72
+ padding: 2px 8px;
73
+ font: 12px 'Roboto Condensed', sans-serif;
74
+ line-height: 1.4;
75
+ background: rgba(176, 185, 189, 0.1);
76
+ color: var(--color-text);
77
+ cursor: pointer;
78
+ user-select: none;
79
+
80
+ &:hover {
81
+ background: rgba(176, 185, 189, 0.25);
82
+ }
83
+ }
84
+
64
85
  .chart-placeholder {
65
86
  position: absolute;
66
87
  width: 100%;
@@ -3,7 +3,8 @@ import {niceAlignedTicks} from '../axis/tick-positioner'
3
3
  import {createSeries} from '../series'
4
4
  import {Legend} from '../interaction/legend'
5
5
  import {Tooltip} from '../interaction/tooltip'
6
- import {groupSeriesData} from '../stock/data-grouping'
6
+ import {ZoomSelection} from '../interaction/zoom'
7
+ import {groupSeriesData, defaultGroupPixelWidth} from '../stock/data-grouping'
7
8
  import {RangeSelector} from '../stock/range-selector'
8
9
  import {renderPolar} from '../polar/polar-chart'
9
10
  import {renderPie} from '../pie/pie-chart'
@@ -24,6 +25,7 @@ export class Chart {
24
25
  return
25
26
  this.legend = new Legend(this)
26
27
  this.tooltip = new Tooltip(this)
28
+ this.zoomSelection = new ZoomSelection(this)
27
29
  this.init()
28
30
  }
29
31
 
@@ -62,9 +64,15 @@ export class Chart {
62
64
  this.hideLoading()
63
65
  const el = document.createElement('div')
64
66
  el.className = 'ix-chart-loading'
65
- el.textContent = text || 'Loading…'
67
+ if (text)
68
+ el.textContent = text
66
69
  Object.assign(el.style, {
67
- position: 'absolute', inset: '0', display: 'flex', alignItems: 'center',
70
+ position: 'absolute',
71
+ left: (this.plotLeft || 0) + 'px',
72
+ top: (this.plotTop || 0) + 'px',
73
+ width: (this.plotWidth || 0) + 'px',
74
+ height: (this.plotHeight || 0) + 'px',
75
+ display: 'flex', alignItems: 'center',
68
76
  justifyContent: 'center', background: 'rgba(0,0,0,0.25)', color: 'var(--color-text)', zIndex: 15
69
77
  })
70
78
  this.container.appendChild(el)
@@ -266,6 +274,10 @@ export class Chart {
266
274
  const nIntervals = s.drawn.length - 1
267
275
  const dataSteps = s.interval > 0 ? (s.dataMax - s.base) / s.interval : nIntervals
268
276
  M = Math.max(M, nIntervals + STEP_HEAD, dataSteps + 0.15)
277
+ //keep the data top below the reserved zoom-controls band (see Axis.reservedTopPixels)
278
+ const reserve = s.a.reservedTopPixels()
279
+ if (reserve && s.a.len > reserve * 2)
280
+ M = Math.max(M, dataSteps / (1 - reserve / s.a.len))
269
281
  }
270
282
  for (const s of specs) {
271
283
  s.a.tickInterval = s.interval
@@ -287,16 +299,59 @@ export class Chart {
287
299
  const baseGrouping = this.chartType === 'StockChart' &&
288
300
  this.options.plotOptions && this.options.plotOptions.series && this.options.plotOptions.series.dataGrouping
289
301
  const xAxis = this.xAxis[0]
290
- for (const s of this.series) {
302
+ const groupings = this.series.map(s => {
291
303
  const seriesGrouping = s.options.dataGrouping
292
- if (baseGrouping || seriesGrouping) {
293
- const grouping = merge(baseGrouping || {}, seriesGrouping || {})
304
+ if (!baseGrouping && !seriesGrouping)
305
+ return null
306
+ const grouping = merge(baseGrouping || {}, seriesGrouping || {})
307
+ if (!isNumber(grouping.groupPixelWidth))
308
+ grouping.groupPixelWidth = defaultGroupPixelWidth(s.type)
309
+ return grouping
310
+ })
311
+ //bucket density follows the VIEWPORT width rather than the (possibly column-constrained)
312
+ //container: a chart squeezed into a half-width dashboard column keeps the same bucket size
313
+ //it would get in a single-column layout, so side-by-side and full-width charts read alike.
314
+ //SINGLE_COLUMN_CHROME ≈ page paddings + axis margins around a full-width plot; the cap keeps
315
+ //very wide monitors from over-refining charts that are themselves capped by the page container
316
+ const SINGLE_COLUMN_CHROME = 238
317
+ const VIRTUAL_WIDTH_CAP = 1100
318
+ const virtualWidth = typeof window === 'undefined'
319
+ ? 0
320
+ : Math.min(window.innerWidth - SINGLE_COLUMN_CHROME, VIRTUAL_WIDTH_CAP)
321
+ const groupingWidth = Math.max(this.plotWidth, virtualWidth)
322
+ let sharedPixelWidth = 0
323
+ let doGrouping = false
324
+ this.series.forEach((s, i) => {
325
+ const grouping = groupings[i]
326
+ if (!grouping || !s.visible)
327
+ return
328
+ sharedPixelWidth = Math.max(sharedPixelWidth, grouping.groupPixelWidth)
329
+ if (grouping.forced || this.countVisiblePoints(s, xAxis) > groupingWidth / grouping.groupPixelWidth)
330
+ doGrouping = true
331
+ })
332
+ this.series.forEach((s, i) => {
333
+ const grouping = groupings[i]
334
+ if (grouping && doGrouping) {
294
335
  const defApprox = /column|bar/.test(s.type) ? 'sum' : 'average'
295
- s.plotPoints = groupSeriesData(s.points, grouping, xAxis.min, xAxis.max, this.plotWidth, defApprox)
336
+ s.plotPoints = groupSeriesData(s.points, {...grouping, groupPixelWidth: sharedPixelWidth},
337
+ xAxis.min, xAxis.max, groupingWidth, defApprox)
296
338
  } else {
297
339
  s.plotPoints = s.points
298
340
  }
299
- }
341
+ })
342
+ }
343
+
344
+ //number of points inside the currently visible x-window (grouping density is decided on what's
345
+ //on screen, not the full dataset — so zooming into a sparse period turns grouping off)
346
+ countVisiblePoints(s, xAxis) {
347
+ const {min, max} = xAxis
348
+ if (!isNumber(min) || !isNumber(max))
349
+ return s.points.length
350
+ let count = 0
351
+ for (const p of s.points)
352
+ if (p.x >= min && p.x <= max)
353
+ count++
354
+ return count
300
355
  }
301
356
 
302
357
  getStacking(s) {
@@ -345,8 +400,6 @@ export class Chart {
345
400
  this.pointRange = pr === Infinity ? (this.xAxis[0].max - this.xAxis[0].min) / 50 : pr
346
401
  }
347
402
 
348
- //breathing room on both x-edges so data doesn't sit flush against the axes; at least half a
349
- //point-width (so edge columns/candles aren't clipped) and at least ~2% of the range
350
403
  //draw a white separator at the boundary between stacked y-axis panes (e.g. Total XLM over Fee Pool)
351
404
  renderPaneSeparators() {
352
405
  const tops = new Set()
@@ -374,8 +427,11 @@ export class Chart {
374
427
  const range = xa.max - xa.min
375
428
  if (!(range > 0))
376
429
  return
377
- const slotPad = (this.hasSlottedSeries && this.pointRange > 0) ? this.pointRange / 2 : 0
378
- const pad = Math.max(slotPad, range * 0.02)
430
+ //pad only charts with slotted series (columns/candles), so their edge bars aren't clipped in half;
431
+ //pure line/area charts render flush to the plot edges
432
+ if (!this.hasSlottedSeries || !(this.pointRange > 0))
433
+ return
434
+ const pad = Math.max(this.pointRange / 2, range * 0.02)
379
435
  xa.min -= pad
380
436
  xa.max += pad
381
437
  }
@@ -494,6 +550,7 @@ export class Chart {
494
550
  this.navigator.render(navGroup, navTop, 32)
495
551
  }
496
552
  this.tooltip.bind()
553
+ this.zoomSelection.bind()
497
554
  if (this.rangeSelector)
498
555
  this.rangeSelector.reposition()
499
556
  }
@@ -501,6 +558,7 @@ export class Chart {
501
558
  redraw() {
502
559
  //clear and re-render into the same container
503
560
  this.tooltip.destroy()
561
+ this.zoomSelection.destroy()
504
562
  this.renderer.destroy()
505
563
  this.renderer = new SvgRenderer(this.container, this.chartWidth, this.chartHeight)
506
564
  this.render()
@@ -548,6 +606,8 @@ export class Chart {
548
606
  this.rangeSelector.destroy()
549
607
  if (this.tooltip)
550
608
  this.tooltip.destroy()
609
+ if (this.zoomSelection)
610
+ this.zoomSelection.destroy()
551
611
  if (this.renderer)
552
612
  this.renderer.destroy()
553
613
  this.container = null
@@ -56,16 +56,20 @@ const UNITS = [
56
56
  export function getDateTimeTickPositions(minTs, maxTs, targetCount) {
57
57
  const range = maxTs - minTs
58
58
  const approx = range / Math.max(1, targetCount)
59
- //pick unit + multiple whose interval is closest to (>=) approx
59
+ //pick the unit + multiple NEAREST to the target interval
60
+ //not the first one >= target, which over-coarsens (e.g. 5-year ticks where 2-year fit better)
61
+ const candidates = []
62
+ for (const unit of UNITS)
63
+ for (const mult of unit.multiples)
64
+ candidates.push({unit, mult, ms: unit.ms * mult})
60
65
  let chosen = UNITS[UNITS.length - 1]
61
66
  let chosenCount = chosen.multiples[chosen.multiples.length - 1]
62
- outer: for (const unit of UNITS) {
63
- for (const mult of unit.multiples) {
64
- if (unit.ms * mult >= approx) {
65
- chosen = unit
66
- chosenCount = mult
67
- break outer
68
- }
67
+ for (let i = 0; i < candidates.length; i++) {
68
+ const next = candidates[i + 1]
69
+ if (!next || approx <= (candidates[i].ms + next.ms) / 2) {
70
+ chosen = candidates[i].unit
71
+ chosenCount = candidates[i].mult
72
+ break
69
73
  }
70
74
  }
71
75
 
@@ -0,0 +1,157 @@
1
+ import {isNumber} from '../core/utilities'
2
+
3
+ //same tint as the navigator selection window, so both "selected range" visuals read as one
4
+ const SELECTION_FILL = 'rgba(102,133,194,0.25)'
5
+ //clicks and sub-threshold jitters don't zoom
6
+ const MIN_SELECTION_PX = 10
7
+
8
+ //Drag-to-zoom over the plot area: press → drag to highlight an x-range →
9
+ //release to zoom into it. Enabled on stock charts and any chart with chart.zoomType === 'x'.
10
+ export class ZoomSelection {
11
+ constructor(chart) {
12
+ this.chart = chart
13
+ }
14
+
15
+ get enabled() {
16
+ const c = this.chart
17
+ const zoomType = c.options.chart && c.options.chart.zoomType
18
+ return zoomType === 'x' || c.chartType === 'StockChart'
19
+ }
20
+
21
+ bind() {
22
+ if (!this.enabled)
23
+ return
24
+ this.svg = this.chart.renderer.root.element
25
+ this.downHandler = e => this.onDown(e)
26
+ this.svg.addEventListener('mousedown', this.downHandler)
27
+ //charts without the range-selector button row get a floating "Reset zoom" control while
28
+ //a drag-selected range is active (the control survives redraws since bind() re-runs on each)
29
+ if (this.isZoomed())
30
+ this.showResetButton()
31
+ }
32
+
33
+ //user-driven extremes are active and there is no range selector to reset them with
34
+ isZoomed() {
35
+ const chart = this.chart
36
+ const xa = chart.xAxis && chart.xAxis[0]
37
+ return !chart.rangeSelector && !!xa && (xa.userMin !== undefined || xa.userMax !== undefined)
38
+ }
39
+
40
+ showResetButton() {
41
+ if (this.resetBtn)
42
+ return
43
+ const chart = this.chart
44
+ const btn = document.createElement('span')
45
+ btn.className = 'chart-reset-zoom'
46
+ btn.setAttribute('role', 'button')
47
+ btn.tabIndex = 0
48
+ btn.textContent = 'Reset zoom'
49
+ btn.style.top = (chart.plotTop + 6) + 'px'
50
+ btn.style.right = (Math.max(0, chart.chartWidth - chart.plotLeft - chart.plotWidth) + 6) + 'px'
51
+ const reset = () => chart.xAxis[0].setExtremes(null, null)
52
+ btn.addEventListener('click', reset)
53
+ btn.addEventListener('keydown', e => {
54
+ if (e.key === 'Enter' || e.key === ' ') {
55
+ e.preventDefault()
56
+ reset()
57
+ }
58
+ })
59
+ chart.container.appendChild(btn)
60
+ this.resetBtn = btn
61
+ }
62
+
63
+ removeResetButton() {
64
+ if (this.resetBtn && this.resetBtn.parentNode)
65
+ this.resetBtn.parentNode.removeChild(this.resetBtn)
66
+ this.resetBtn = null
67
+ }
68
+
69
+ toChartX(e) {
70
+ const rect = this.svg.getBoundingClientRect()
71
+ return (e.clientX - rect.left) * (this.chart.chartWidth / rect.width)
72
+ }
73
+
74
+ onDown(e) {
75
+ if (e.button !== 0)
76
+ return
77
+ const chart = this.chart
78
+ const rect = this.svg.getBoundingClientRect()
79
+ const mx = (e.clientX - rect.left) * (chart.chartWidth / rect.width)
80
+ const my = (e.clientY - rect.top) * (chart.chartHeight / rect.height)
81
+ //react only on the plot area itself — the navigator strip and axis labels live outside it
82
+ //and keep their own interactions
83
+ if (mx < chart.plotLeft || mx > chart.plotLeft + chart.plotWidth ||
84
+ my < chart.plotTop || my > chart.plotTop + chart.plotHeight)
85
+ return
86
+ e.preventDefault()
87
+ this.startX = mx
88
+ this.overlay = chart.renderer.rect(mx, chart.plotTop, 0, chart.plotHeight, {})
89
+ .css({fill: SELECTION_FILL, 'pointer-events': 'none'})
90
+ .add(chart.renderer.root)
91
+ this.moveHandler = ev => this.onMove(ev)
92
+ this.upHandler = ev => this.onUp(ev)
93
+ document.addEventListener('mousemove', this.moveHandler)
94
+ document.addEventListener('mouseup', this.upHandler)
95
+ }
96
+
97
+ selectionBounds(ev) {
98
+ const chart = this.chart
99
+ const x = Math.max(chart.plotLeft, Math.min(this.toChartX(ev), chart.plotLeft + chart.plotWidth))
100
+ return [Math.min(this.startX, x), Math.max(this.startX, x)]
101
+ }
102
+
103
+ onMove(ev) {
104
+ const [x0, x1] = this.selectionBounds(ev)
105
+ if (this.overlay)
106
+ this.overlay.attr({x: x0, width: x1 - x0})
107
+ //the tooltip's svg-level listener fired before this document-level one — hide what it drew,
108
+ //so the selection band isn't obscured while dragging
109
+ if (this.chart.tooltip)
110
+ this.chart.tooltip.hide()
111
+ }
112
+
113
+ onUp(ev) {
114
+ const chart = this.chart
115
+ const [x0, x1] = this.selectionBounds(ev)
116
+ this.cancel()
117
+ if (x1 - x0 < MIN_SELECTION_PX)
118
+ return
119
+ const xAxis = chart.xAxis[0]
120
+ const span = (xAxis.max - xAxis.min) || 1
121
+ const toValue = px => xAxis.min + ((px - xAxis.left) / xAxis.len) * span
122
+ let vMin = toValue(x0)
123
+ let vMax = toValue(x1)
124
+ //respect the axis' minimum zoom window (e.g. 1h on the price chart): widen around the center
125
+ const minRange = xAxis.options.minRange
126
+ if (isNumber(minRange) && vMax - vMin < minRange) {
127
+ const mid = (vMin + vMax) / 2
128
+ vMin = mid - minRange / 2
129
+ vMax = mid + minRange / 2
130
+ }
131
+ //a manually selected range doesn't correspond to any period button
132
+ if (chart.rangeSelector)
133
+ chart.rangeSelector.clearSelection()
134
+ xAxis.setExtremes(vMin, vMax)
135
+ }
136
+
137
+ //abort an in-flight drag and remove the selection band
138
+ cancel() {
139
+ if (this.moveHandler) {
140
+ document.removeEventListener('mousemove', this.moveHandler)
141
+ document.removeEventListener('mouseup', this.upHandler)
142
+ this.moveHandler = this.upHandler = null
143
+ }
144
+ if (this.overlay) {
145
+ this.overlay.destroy()
146
+ this.overlay = null
147
+ }
148
+ }
149
+
150
+ destroy() {
151
+ this.cancel()
152
+ this.removeResetButton()
153
+ if (this.svg && this.downHandler)
154
+ this.svg.removeEventListener('mousedown', this.downHandler)
155
+ this.svg = null
156
+ }
157
+ }
@@ -40,7 +40,7 @@ export class CandlestickSeries extends Series {
40
40
  continue
41
41
  const cx = xAxis.toPixels(p.x)
42
42
  const catW = Math.abs(xAxis.toPixels(p.x + pr) - xAxis.toPixels(p.x))
43
- const w = Math.max(1, catW * 0.6)
43
+ const w = Math.max(1.5, catW * 0.8 - 0.5)
44
44
  const up = p.close >= p.open
45
45
  const color = up ? upColor : downColor
46
46
  const yHigh = yAxis.toPixels(p.high)
@@ -1,6 +1,20 @@
1
1
  //Downsample raw series points into calendar-aligned time buckets (stock data grouping).
2
2
  import {isNumber} from '../core/utilities'
3
3
 
4
+ /**
5
+ * Default bucket pixel size per series type: columns/candles need room for a
6
+ * readable bar, lines/areas keep near-raw resolution
7
+ * @param {string} type - series type
8
+ * @return {number}
9
+ */
10
+ export function defaultGroupPixelWidth(type) {
11
+ if (/^(column|bar|candlestick)$/.test(type))
12
+ return 10
13
+ if (/^(ohlc|hlc)$/.test(type))
14
+ return 5
15
+ return 2
16
+ }
17
+
4
18
  const day = 24 * 3600 * 1000
5
19
  const week = 7 * day
6
20
 
@@ -57,7 +57,8 @@ export class Navigator {
57
57
  const yToPx = v => top + height - ((v - yMin) / ((yMax - yMin) || 1)) * (height - 4) - 2
58
58
 
59
59
  //faint background
60
- renderer.rect(left, top, width, height, {}).css({fill: 'var(--color-border-shadow)', 'fill-opacity': 0.15}).add(group)
60
+ const bg = renderer.rect(left, top, width, height, {}).css({fill: 'var(--color-border-shadow)', 'fill-opacity': 0.15, cursor: 'pointer'}).add(group)
61
+ this.bindBackground(bg)
61
62
  //time breakpoints across the full overview range, so it's clear where the selected window sits
62
63
  this.renderTimeAxis(group, top, height, dMin, dMax, xToPx)
63
64
  //overview as a thin line (no fill)
@@ -70,7 +71,7 @@ export class Navigator {
70
71
  started = true
71
72
  }
72
73
  }
73
- const lineEl = renderer.path(d.trim(), {'stroke-width': 1, fill: 'none'}).css({stroke: NAV_LINE}).add(group)
74
+ const lineEl = renderer.path(d.trim(), {'stroke-width': 1, fill: 'none'}).css({stroke: NAV_LINE, 'pointer-events': 'none'}).add(group)
74
75
 
75
76
  //selection window edges (clamped into the strip) — use the LOGICAL extremes (pre display-padding)
76
77
  const x0 = clamp(xToPx(viewMin(xAxis)), left, left + width)
@@ -131,6 +132,33 @@ export class Navigator {
131
132
  return rect
132
133
  }
133
134
 
135
+ //click on the unselected part of the strip: move the selection window (same span) so it centers
136
+ //on the clicked date, clamped to the data edges
137
+ bindBackground(bg) {
138
+ const chart = this.chart
139
+ const xAxis = chart.xAxis[0]
140
+ bg.on('mousedown', e => {
141
+ e.preventDefault()
142
+ const svg = chart.renderer.root.element
143
+ const rect = svg.getBoundingClientRect()
144
+ const scaleX = chart.chartWidth / rect.width
145
+ const px = (e.clientX - rect.left) * scaleX
146
+ const v = this.dataMin + ((px - this.left) / this.width) * (this.dataMax - this.dataMin)
147
+ const span = viewMax(xAxis) - viewMin(xAxis)
148
+ let min = v - span / 2
149
+ let max = v + span / 2
150
+ if (min < this.dataMin) {
151
+ min = this.dataMin
152
+ max = min + span
153
+ }
154
+ if (max > this.dataMax) {
155
+ max = this.dataMax
156
+ min = max - span
157
+ }
158
+ xAxis.setExtremes(min, max)
159
+ })
160
+ }
161
+
134
162
  bindBand(band) {
135
163
  const chart = this.chart
136
164
  const xAxis = chart.xAxis[0]
@@ -88,18 +88,40 @@ export class RangeSelector {
88
88
  this.zoomLabel.style.display = compact ? 'none' : ''
89
89
  }
90
90
 
91
+ //full data range, unioned with the navigator's own series. Views that lazy-load data on zoom replace
92
+ //the main series with just the visible sub-window (keeping the full lifespan only in navigator.series),
93
+ //so the axis' dataMin/dataMax shrink to the loaded slice — anchoring the buttons on those would make
94
+ //'All' re-select the current window instead of the whole history
95
+ getUnionExtremes() {
96
+ const xAxis = this.chart.xAxis[0]
97
+ let {dataMin, dataMax} = xAxis
98
+ const navSeries = this.chart.options.navigator && this.chart.options.navigator.series
99
+ const navData = navSeries && navSeries.data
100
+ if (navData && navData.length) {
101
+ for (const p of navData) {
102
+ const x = Array.isArray(p) ? p[0] : p && p.x
103
+ if (!isNumber(x))
104
+ continue
105
+ if (!isNumber(dataMin) || x < dataMin) dataMin = x
106
+ if (!isNumber(dataMax) || x > dataMax) dataMax = x
107
+ }
108
+ }
109
+ return {dataMin, dataMax}
110
+ }
111
+
91
112
  apply(b, i) {
92
113
  const xAxis = this.chart.xAxis[0]
114
+ const {dataMin, dataMax} = this.getUnionExtremes()
93
115
  if (b.type === 'all') {
94
116
  this.selected = 'all'
95
117
  //explicit full extremes so the initial xAxis.range window doesn't re-apply
96
- xAxis.setExtremes(xAxis.dataMin, xAxis.dataMax)
118
+ xAxis.setExtremes(dataMin, dataMax)
97
119
  } else {
98
120
  const count = b.count || 1
99
121
  //the window always ENDS at the last data point; going back a calendar
100
122
  //month/year/day for the start. Anchoring the end on dataMax keeps the right edge flush with the
101
123
  //data instead of snapping forward to the next month boundary (which left a gap on the right).
102
- const max = xAxis.dataMax
124
+ const max = dataMax
103
125
  const d = new Date(max)
104
126
  if (b.type === 'year')
105
127
  d.setUTCFullYear(d.getUTCFullYear() - count)
@@ -114,6 +136,13 @@ export class RangeSelector {
114
136
  this.reposition()
115
137
  }
116
138
 
139
+ //deselect all period buttons — the current extremes were set manually (e.g. drag-to-zoom)
140
+ //and don't correspond to any predefined period
141
+ clearSelection() {
142
+ this.selected = null
143
+ this.highlight()
144
+ }
145
+
117
146
  highlight() {
118
147
  if (!this.buttons)
119
148
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stellar-expert/ui-framework",
3
- "version": "1.21.2",
3
+ "version": "1.21.4",
4
4
  "description": "StellarExpert shared UI components library",
5
5
  "author": "orbitlens <orbit@stellar.expert>",
6
6
  "license": "MIT",