@stellar-expert/ui-framework 1.21.3 → 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 +2 -3
- package/charts/chart-options-builder.js +3 -3
- package/charts/chart.scss +21 -0
- package/charts/core/chart.js +60 -10
- package/charts/core/time.js +12 -8
- package/charts/interaction/zoom.js +157 -0
- package/charts/stock/data-grouping.js +14 -0
- package/charts/stock/range-selector.js +7 -0
- package/package.json +1 -1
package/charts/axis/axis.js
CHANGED
|
@@ -161,9 +161,8 @@ export class Axis {
|
|
|
161
161
|
if (isNumber(this.options.ceiling))
|
|
162
162
|
max = Math.min(max, this.options.ceiling)
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
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))
|
|
167
166
|
: Math.max(3, Math.round(this.lenForTicks() / 72))
|
|
168
167
|
|
|
169
168
|
if (this.isLog) {
|
|
@@ -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
|
|
|
@@ -297,16 +299,59 @@ export class Chart {
|
|
|
297
299
|
const baseGrouping = this.chartType === 'StockChart' &&
|
|
298
300
|
this.options.plotOptions && this.options.plotOptions.series && this.options.plotOptions.series.dataGrouping
|
|
299
301
|
const xAxis = this.xAxis[0]
|
|
300
|
-
|
|
302
|
+
const groupings = this.series.map(s => {
|
|
301
303
|
const seriesGrouping = s.options.dataGrouping
|
|
302
|
-
if (baseGrouping
|
|
303
|
-
|
|
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) {
|
|
304
335
|
const defApprox = /column|bar/.test(s.type) ? 'sum' : 'average'
|
|
305
|
-
s.plotPoints = groupSeriesData(s.points, grouping,
|
|
336
|
+
s.plotPoints = groupSeriesData(s.points, {...grouping, groupPixelWidth: sharedPixelWidth},
|
|
337
|
+
xAxis.min, xAxis.max, groupingWidth, defApprox)
|
|
306
338
|
} else {
|
|
307
339
|
s.plotPoints = s.points
|
|
308
340
|
}
|
|
309
|
-
}
|
|
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
|
|
310
355
|
}
|
|
311
356
|
|
|
312
357
|
getStacking(s) {
|
|
@@ -355,8 +400,6 @@ export class Chart {
|
|
|
355
400
|
this.pointRange = pr === Infinity ? (this.xAxis[0].max - this.xAxis[0].min) / 50 : pr
|
|
356
401
|
}
|
|
357
402
|
|
|
358
|
-
//breathing room on both x-edges so data doesn't sit flush against the axes; at least half a
|
|
359
|
-
//point-width (so edge columns/candles aren't clipped) and at least ~2% of the range
|
|
360
403
|
//draw a white separator at the boundary between stacked y-axis panes (e.g. Total XLM over Fee Pool)
|
|
361
404
|
renderPaneSeparators() {
|
|
362
405
|
const tops = new Set()
|
|
@@ -384,8 +427,11 @@ export class Chart {
|
|
|
384
427
|
const range = xa.max - xa.min
|
|
385
428
|
if (!(range > 0))
|
|
386
429
|
return
|
|
387
|
-
|
|
388
|
-
|
|
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)
|
|
389
435
|
xa.min -= pad
|
|
390
436
|
xa.max += pad
|
|
391
437
|
}
|
|
@@ -504,6 +550,7 @@ export class Chart {
|
|
|
504
550
|
this.navigator.render(navGroup, navTop, 32)
|
|
505
551
|
}
|
|
506
552
|
this.tooltip.bind()
|
|
553
|
+
this.zoomSelection.bind()
|
|
507
554
|
if (this.rangeSelector)
|
|
508
555
|
this.rangeSelector.reposition()
|
|
509
556
|
}
|
|
@@ -511,6 +558,7 @@ export class Chart {
|
|
|
511
558
|
redraw() {
|
|
512
559
|
//clear and re-render into the same container
|
|
513
560
|
this.tooltip.destroy()
|
|
561
|
+
this.zoomSelection.destroy()
|
|
514
562
|
this.renderer.destroy()
|
|
515
563
|
this.renderer = new SvgRenderer(this.container, this.chartWidth, this.chartHeight)
|
|
516
564
|
this.render()
|
|
@@ -558,6 +606,8 @@ export class Chart {
|
|
|
558
606
|
this.rangeSelector.destroy()
|
|
559
607
|
if (this.tooltip)
|
|
560
608
|
this.tooltip.destroy()
|
|
609
|
+
if (this.zoomSelection)
|
|
610
|
+
this.zoomSelection.destroy()
|
|
561
611
|
if (this.renderer)
|
|
562
612
|
this.renderer.destroy()
|
|
563
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
|
+
}
|
|
@@ -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
|
|
|
@@ -136,6 +136,13 @@ export class RangeSelector {
|
|
|
136
136
|
this.reposition()
|
|
137
137
|
}
|
|
138
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
|
+
|
|
139
146
|
highlight() {
|
|
140
147
|
if (!this.buttons)
|
|
141
148
|
return
|