@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.
- package/charts/axis/axis.js +23 -4
- package/charts/chart-options-builder.js +3 -3
- package/charts/chart.scss +21 -0
- package/charts/core/chart.js +72 -12
- package/charts/core/time.js +12 -8
- package/charts/interaction/zoom.js +157 -0
- package/charts/series/candlestick-series.js +1 -1
- package/charts/stock/data-grouping.js +14 -0
- package/charts/stock/navigator.js +30 -2
- package/charts/stock/range-selector.js +31 -2
- package/package.json +1 -1
package/charts/axis/axis.js
CHANGED
|
@@ -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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
29
|
-
//
|
|
30
|
-
groupPixelWidth:
|
|
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%;
|
package/charts/core/chart.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
67
|
+
if (text)
|
|
68
|
+
el.textContent = text
|
|
66
69
|
Object.assign(el.style, {
|
|
67
|
-
position: 'absolute',
|
|
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
|
-
|
|
302
|
+
const groupings = this.series.map(s => {
|
|
291
303
|
const seriesGrouping = s.options.dataGrouping
|
|
292
|
-
if (baseGrouping
|
|
293
|
-
|
|
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,
|
|
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
|
-
|
|
378
|
-
|
|
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
|
package/charts/core/time.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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(
|
|
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 =
|
|
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
|