@stellar-expert/ui-framework 1.19.2 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/charts/axis/axis.js +470 -0
- package/charts/axis/tick-positioner.js +172 -0
- package/charts/chart-default-options.js +158 -0
- package/charts/chart-loader.js +26 -0
- package/charts/chart-options-builder.js +81 -0
- package/charts/chart.js +99 -0
- package/charts/chart.scss +80 -0
- package/charts/core/animate.js +99 -0
- package/charts/core/chart.js +555 -0
- package/charts/core/color.js +89 -0
- package/charts/core/options.js +40 -0
- package/charts/core/svg-renderer.js +155 -0
- package/charts/core/time.js +129 -0
- package/charts/core/utilities.js +92 -0
- package/charts/globals.js +32 -0
- package/charts/index.js +4 -0
- package/charts/interaction/legend.js +125 -0
- package/charts/interaction/tooltip.js +185 -0
- package/charts/pie/pie-chart.js +269 -0
- package/charts/polar/polar-chart.js +99 -0
- package/charts/series/area-series.js +57 -0
- package/charts/series/candlestick-series.js +60 -0
- package/charts/series/column-series.js +55 -0
- package/charts/series/index.js +21 -0
- package/charts/series/line-series.js +21 -0
- package/charts/series/series.js +161 -0
- package/charts/stock/data-grouping.js +141 -0
- package/charts/stock/navigator.js +205 -0
- package/charts/stock/range-selector.js +140 -0
- package/effect/effect-description.js +1 -1
- package/index.d.ts +36 -0
- package/index.js +2 -0
- package/package.json +5 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {Series} from './series'
|
|
2
|
+
import {pick, isNumber, clamp} from '../core/utilities'
|
|
3
|
+
import {animateColumns} from '../core/animate'
|
|
4
|
+
|
|
5
|
+
export class ColumnSeries extends Series {
|
|
6
|
+
render(group) {
|
|
7
|
+
this.group = group
|
|
8
|
+
const bars = []
|
|
9
|
+
const renderer = this.chart.renderer
|
|
10
|
+
const color = this.getColor()
|
|
11
|
+
const xAxis = this.xAxis
|
|
12
|
+
const yAxis = this.yAxis
|
|
13
|
+
const colOpts = this.chart.options.plotOptions.column || {}
|
|
14
|
+
const groupPadding = pick(colOpts.groupPadding, this.chart.options.plotOptions.series.groupPadding, 0.1)
|
|
15
|
+
const pointPadding = pick(colOpts.pointPadding, this.chart.options.plotOptions.series.pointPadding, 0.1)
|
|
16
|
+
const count = this.columnCount || 1
|
|
17
|
+
const idx = this.columnIndex || 0
|
|
18
|
+
const pointRange = this.chart.pointRange || ((xAxis.max - xAxis.min) / 50)
|
|
19
|
+
|
|
20
|
+
//baseline (threshold) pixel for un-stacked columns
|
|
21
|
+
const tVal = isNumber(this.threshold) ? this.threshold : yAxis.min
|
|
22
|
+
const baselineY = yAxis.toPixels(clamp(tVal, yAxis.min, yAxis.max))
|
|
23
|
+
|
|
24
|
+
const onCategory = !!xAxis.categories
|
|
25
|
+
for (const p of this.plotPoints) {
|
|
26
|
+
if (!isNumber(p.y))
|
|
27
|
+
continue
|
|
28
|
+
//column centered on the point x (slot spans [x-pointRange/2, x+pointRange/2]) so columns
|
|
29
|
+
//line up with the line/area plotted at x and with the axis tick at x
|
|
30
|
+
const center = xAxis.toPixels(p.x)
|
|
31
|
+
const categoryWidth = Math.abs(xAxis.toPixels(p.x + (onCategory ? 1 : pointRange)) - xAxis.toPixels(p.x))
|
|
32
|
+
const groupWidth = categoryWidth * (1 - 2 * groupPadding)
|
|
33
|
+
const offsetWidth = groupWidth / count
|
|
34
|
+
const colWidth = Math.max(1, offsetWidth * (1 - 2 * pointPadding))
|
|
35
|
+
const groupLeft = center - groupWidth / 2
|
|
36
|
+
const colX = groupLeft + idx * offsetWidth + (offsetWidth - colWidth) / 2
|
|
37
|
+
|
|
38
|
+
const top = isNumber(p.stackY) ? yAxis.toPixels(p.stackY) : yAxis.toPixels(p.y)
|
|
39
|
+
const bottom = isNumber(p.stackLow) ? yAxis.toPixels(p.stackLow) : baselineY
|
|
40
|
+
const y = Math.min(top, bottom)
|
|
41
|
+
const h = Math.max(0, Math.abs(bottom - top))
|
|
42
|
+
const rect = renderer.rect(colX, y, colWidth, h, {})
|
|
43
|
+
.css({fill: color})
|
|
44
|
+
.add(group)
|
|
45
|
+
//colTopY = pixel of the WHOLE column's top (stack total) so a stacked column fills as one unit.
|
|
46
|
+
//toPixels already applies the (log) transform — don't clamp the data value against the axis'
|
|
47
|
+
//internal min/max, which are in transformed space on a log axis
|
|
48
|
+
const total = isNumber(p.stackTotal) ? p.stackTotal : p.y
|
|
49
|
+
const colTopY = yAxis.toPixels(total)
|
|
50
|
+
bars.push({el: rect, topY: y, botY: y + h, baselineY, colTopY})
|
|
51
|
+
}
|
|
52
|
+
if (this.chart.animateThisRender)
|
|
53
|
+
animateColumns(bars)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {LineSeries} from './line-series'
|
|
2
|
+
import {AreaSeries} from './area-series'
|
|
3
|
+
import {ColumnSeries} from './column-series'
|
|
4
|
+
import {CandlestickSeries} from './candlestick-series'
|
|
5
|
+
|
|
6
|
+
//series type registry — extended in later phases (spline-smoothing, candlestick, variablepie, ...)
|
|
7
|
+
export const seriesTypes = {
|
|
8
|
+
line: LineSeries,
|
|
9
|
+
spline: LineSeries,
|
|
10
|
+
area: AreaSeries,
|
|
11
|
+
areaspline: AreaSeries,
|
|
12
|
+
column: ColumnSeries,
|
|
13
|
+
bar: ColumnSeries,
|
|
14
|
+
candlestick: CandlestickSeries,
|
|
15
|
+
ohlc: CandlestickSeries
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSeries(chart, options) {
|
|
19
|
+
const Ctor = seriesTypes[options.type] || LineSeries
|
|
20
|
+
return new Ctor(chart, options)
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {pick} from '../core/utilities'
|
|
2
|
+
import {revealLeftToRight} from '../core/animate'
|
|
3
|
+
import {Series} from './series'
|
|
4
|
+
|
|
5
|
+
export class LineSeries extends Series {
|
|
6
|
+
render(group) {
|
|
7
|
+
this.group = group
|
|
8
|
+
const renderer = this.chart.renderer
|
|
9
|
+
const color = this.getColor()
|
|
10
|
+
const lineWidth = pick(this.options.lineWidth,
|
|
11
|
+
this.chart.options.plotOptions.series.lineWidth, 2)
|
|
12
|
+
const d = this.linePath()
|
|
13
|
+
if (d) {
|
|
14
|
+
this.graph = renderer.path(d, {'stroke-width': lineWidth, fill: 'none', 'stroke-linejoin': 'round', 'stroke-linecap': 'round'})
|
|
15
|
+
.css({stroke: color})
|
|
16
|
+
.add(group)
|
|
17
|
+
if (this.chart.animateThisRender)
|
|
18
|
+
revealLeftToRight(this.chart, group)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {pick, isNumber} from '../core/utilities'
|
|
2
|
+
|
|
3
|
+
//Catmull-Rom -> cubic bezier smoothing for spline series
|
|
4
|
+
function smoothSegmentPath(pts) {
|
|
5
|
+
let d = `M ${pts[0].plotX} ${pts[0].plotY} `
|
|
6
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
7
|
+
const p0 = pts[i - 1] || pts[i]
|
|
8
|
+
const p1 = pts[i]
|
|
9
|
+
const p2 = pts[i + 1]
|
|
10
|
+
const p3 = pts[i + 2] || pts[i + 1]
|
|
11
|
+
const c1x = p1.plotX + (p2.plotX - p0.plotX) / 6
|
|
12
|
+
const c1y = p1.plotY + (p2.plotY - p0.plotY) / 6
|
|
13
|
+
const c2x = p2.plotX - (p3.plotX - p1.plotX) / 6
|
|
14
|
+
const c2y = p2.plotY - (p3.plotY - p1.plotY) / 6
|
|
15
|
+
d += `C ${c1x} ${c1y} ${c2x} ${c2y} ${p2.plotX} ${p2.plotY} `
|
|
16
|
+
}
|
|
17
|
+
return d
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Series {
|
|
21
|
+
constructor(chart, options) {
|
|
22
|
+
this.chart = chart
|
|
23
|
+
this.options = options
|
|
24
|
+
this.type = options.type || 'line'
|
|
25
|
+
this.name = options.name
|
|
26
|
+
this.visible = options.visible !== false
|
|
27
|
+
this.index = 0 //creation order, assigned by chart
|
|
28
|
+
//area/column rest on a zero baseline by default (the series `threshold`), pulling the axis to 0
|
|
29
|
+
this.threshold = options.threshold !== undefined ? options.threshold
|
|
30
|
+
: (/^(area|areaspline|column|bar)$/.test(this.type) ? 0 : null)
|
|
31
|
+
this.setData(options.data || [])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setData(data) {
|
|
35
|
+
this.xData = []
|
|
36
|
+
this.yData = []
|
|
37
|
+
this.points = []
|
|
38
|
+
for (const p of data) {
|
|
39
|
+
let x, y, point
|
|
40
|
+
if (Array.isArray(p)) {
|
|
41
|
+
if (p.length >= 5) {
|
|
42
|
+
//OHLC tuple [x, open, high, low, close]
|
|
43
|
+
x = p[0]
|
|
44
|
+
y = p[4]
|
|
45
|
+
point = {x, y, open: p[1], high: p[2], low: p[3], close: p[4]}
|
|
46
|
+
} else {
|
|
47
|
+
x = p[0]
|
|
48
|
+
y = p[1]
|
|
49
|
+
point = {x, y}
|
|
50
|
+
}
|
|
51
|
+
} else if (p && typeof p === 'object') {
|
|
52
|
+
x = pick(p.x, this.points.length)
|
|
53
|
+
y = p.y
|
|
54
|
+
point = {...p, x, y}
|
|
55
|
+
} else {
|
|
56
|
+
x = this.points.length
|
|
57
|
+
y = p
|
|
58
|
+
point = {x, y}
|
|
59
|
+
}
|
|
60
|
+
this.xData.push(x)
|
|
61
|
+
this.yData.push(y)
|
|
62
|
+
point.series = this
|
|
63
|
+
this.points.push(point)
|
|
64
|
+
}
|
|
65
|
+
//points actually drawn — replaced by the grouped set when data grouping is active
|
|
66
|
+
this.plotPoints = this.points
|
|
67
|
+
//live data update (e.g. lazy-loaded finer candles on zoom) — repaint. Skipped during the initial
|
|
68
|
+
//series construction, when the chart hasn't rendered yet.
|
|
69
|
+
if (this.chart && this.chart.hasRendered)
|
|
70
|
+
this.chart.redraw()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
bindAxes() {
|
|
74
|
+
this.xAxis = this.chart.xAxis[0]
|
|
75
|
+
const yIdx = isNumber(this.options.yAxis) ? this.options.yAxis : 0
|
|
76
|
+
this.yAxis = this.chart.yAxis[yIdx] || this.chart.yAxis[0]
|
|
77
|
+
this.xAxis.series.push(this)
|
|
78
|
+
this.yAxis.series.push(this)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get colorIndex() {
|
|
82
|
+
return pick(this.options.colorIndex, this.index)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get zIndex() {
|
|
86
|
+
return pick(this.options.index, this.index)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//resolve a plot option through the option cascade: per-series → plotOptions[type] → plotOptions.series
|
|
90
|
+
//(e.g. `step`/`connectNulls` are commonly set once on plotOptions.series and inherited by every series)
|
|
91
|
+
resolveOption(name, fallback) {
|
|
92
|
+
const po = this.chart.options.plotOptions || {}
|
|
93
|
+
return pick(this.options[name], (po[this.type] || {})[name], (po.series || {})[name], fallback)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getColor() {
|
|
97
|
+
if (this.options.color)
|
|
98
|
+
return this.options.color
|
|
99
|
+
const colors = this.chart.options.colors || []
|
|
100
|
+
return colors[this.colorIndex % colors.length] || '#08B5E5'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
translate() {
|
|
104
|
+
for (const pt of this.plotPoints) {
|
|
105
|
+
pt.plotX = this.xAxis.toPixels(pt.x)
|
|
106
|
+
pt.plotY = isNumber(pt.y) ? this.yAxis.toPixels(isNumber(pt.stackY) ? pt.stackY : pt.y) : null
|
|
107
|
+
pt.plotBottom = isNumber(pt.stackLow) ? this.yAxis.toPixels(pt.stackLow) : null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//build an SVG path "d" from plotted points (M/L), breaking on nulls unless connectNulls
|
|
112
|
+
linePath(closePathToBaseline) {
|
|
113
|
+
const connectNulls = this.resolveOption('connectNulls', false)
|
|
114
|
+
const step = this.resolveOption('step')
|
|
115
|
+
const segments = []
|
|
116
|
+
let current = []
|
|
117
|
+
for (const pt of this.plotPoints) {
|
|
118
|
+
if (pt.plotY === null) {
|
|
119
|
+
if (!connectNulls) {
|
|
120
|
+
if (current.length) segments.push(current)
|
|
121
|
+
current = []
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
current.push(pt)
|
|
127
|
+
}
|
|
128
|
+
if (current.length) segments.push(current)
|
|
129
|
+
this.segments = segments
|
|
130
|
+
|
|
131
|
+
const smooth = this.type === 'spline' || this.type === 'areaspline'
|
|
132
|
+
let d = ''
|
|
133
|
+
for (const seg of segments) {
|
|
134
|
+
if (smooth && seg.length > 2) {
|
|
135
|
+
d += smoothSegmentPath(seg)
|
|
136
|
+
} else {
|
|
137
|
+
seg.forEach((pt, i) => {
|
|
138
|
+
if (i === 0) {
|
|
139
|
+
d += `M ${pt.plotX} ${pt.plotY} `
|
|
140
|
+
} else if (step === 'left') {
|
|
141
|
+
d += `L ${pt.plotX} ${seg[i - 1].plotY} L ${pt.plotX} ${pt.plotY} `
|
|
142
|
+
} else if (step === 'right') {
|
|
143
|
+
d += `L ${seg[i - 1].plotX} ${pt.plotY} L ${pt.plotX} ${pt.plotY} `
|
|
144
|
+
} else {
|
|
145
|
+
d += `L ${pt.plotX} ${pt.plotY} `
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return d.trim()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
render() {
|
|
154
|
+
//overridden by subclasses
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
destroy() {
|
|
158
|
+
if (this.group)
|
|
159
|
+
this.group.destroy()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
//Downsample raw series points into calendar-aligned time buckets (stock data grouping).
|
|
2
|
+
import {isNumber} from '../core/utilities'
|
|
3
|
+
|
|
4
|
+
const day = 24 * 3600 * 1000
|
|
5
|
+
const week = 7 * day
|
|
6
|
+
|
|
7
|
+
const UNIT_MS = {
|
|
8
|
+
millisecond: 1,
|
|
9
|
+
second: 1000,
|
|
10
|
+
minute: 60000,
|
|
11
|
+
hour: 3600000,
|
|
12
|
+
day,
|
|
13
|
+
week,
|
|
14
|
+
month: 30 * day,
|
|
15
|
+
year: 365 * day
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function chooseInterval(units, approxInterval) {
|
|
19
|
+
//flatten the unit/multiple options into ascending candidate intervals
|
|
20
|
+
const candidates = []
|
|
21
|
+
for (const [unitName, multiples] of units)
|
|
22
|
+
for (const mult of multiples)
|
|
23
|
+
candidates.push({unitName, mult, ms: UNIT_MS[unitName] * mult})
|
|
24
|
+
candidates.sort((a, b) => a.ms - b.ms)
|
|
25
|
+
if (!candidates.length)
|
|
26
|
+
return {unitName: 'day', mult: 1, ms: day}
|
|
27
|
+
//pick the candidate NEAREST to the target (midpoint threshold) — not the first one >= target,
|
|
28
|
+
//which over-coarsens (e.g. picks a 6-month bucket where 2-month fits better)
|
|
29
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
30
|
+
const next = candidates[i + 1]
|
|
31
|
+
if (!next || approxInterval <= (candidates[i].ms + next.ms) / 2)
|
|
32
|
+
return candidates[i]
|
|
33
|
+
}
|
|
34
|
+
return candidates[candidates.length - 1]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bucketStart(x, unitName, mult) {
|
|
38
|
+
if (unitName === 'month') {
|
|
39
|
+
const d = new Date(x)
|
|
40
|
+
const m = Math.floor(d.getUTCMonth() / mult) * mult
|
|
41
|
+
return Date.UTC(d.getUTCFullYear(), m, 1)
|
|
42
|
+
}
|
|
43
|
+
if (unitName === 'year') {
|
|
44
|
+
const d = new Date(x)
|
|
45
|
+
const y = Math.floor(d.getUTCFullYear() / mult) * mult
|
|
46
|
+
return Date.UTC(y, 0, 1)
|
|
47
|
+
}
|
|
48
|
+
const ms = UNIT_MS[unitName] * mult
|
|
49
|
+
if (unitName === 'week') {
|
|
50
|
+
//epoch (1970-01-01) is a Thursday, so a plain floor would start weeks on Thursday. Weeks start
|
|
51
|
+
//on Monday — anchor to 1970-01-05 (the first Monday) so buckets align to week boundaries.
|
|
52
|
+
const MONDAY_ANCHOR = 4 * day
|
|
53
|
+
return Math.floor((x - MONDAY_ANCHOR) / ms) * ms + MONDAY_ANCHOR
|
|
54
|
+
}
|
|
55
|
+
return Math.floor(x / ms) * ms
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function aggregate(values, approximation) {
|
|
59
|
+
if (!values.length)
|
|
60
|
+
return null
|
|
61
|
+
switch (approximation) {
|
|
62
|
+
case 'sum':
|
|
63
|
+
return values.reduce((a, b) => a + b, 0)
|
|
64
|
+
case 'high':
|
|
65
|
+
return Math.max(...values)
|
|
66
|
+
case 'low':
|
|
67
|
+
return Math.min(...values)
|
|
68
|
+
case 'open':
|
|
69
|
+
return values[0]
|
|
70
|
+
case 'close':
|
|
71
|
+
return values[values.length - 1]
|
|
72
|
+
case 'average':
|
|
73
|
+
default:
|
|
74
|
+
return values.reduce((a, b) => a + b, 0) / values.length
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{x:number,y:number}[]} rawPoints - chronological raw points
|
|
80
|
+
* @param {{units:Array, groupPixelWidth:number, approximation:string}} grouping
|
|
81
|
+
* @param {number} xMin
|
|
82
|
+
* @param {number} xMax
|
|
83
|
+
* @param {number} plotWidth
|
|
84
|
+
* @param {string} defaultApproximation
|
|
85
|
+
* @return {{x:number,y:number}[]}
|
|
86
|
+
*/
|
|
87
|
+
export function groupSeriesData(rawPoints, grouping, xMin, xMax, plotWidth, defaultApproximation) {
|
|
88
|
+
if (!rawPoints.length)
|
|
89
|
+
return rawPoints
|
|
90
|
+
const groupPixelWidth = grouping.groupPixelWidth || 16
|
|
91
|
+
const groupCount = Math.max(1, Math.floor(plotWidth / groupPixelWidth))
|
|
92
|
+
const approxInterval = (xMax - xMin) / groupCount
|
|
93
|
+
const units = grouping.units || [['millisecond', [1]], ['second', [1]], ['minute', [1]], ['hour', [1]], ['day', [1]], ['week', [1]], ['month', [1]], ['year', [1]]]
|
|
94
|
+
const {unitName, mult} = chooseInterval(units, approxInterval)
|
|
95
|
+
const approximation = grouping.approximation || defaultApproximation || 'average'
|
|
96
|
+
|
|
97
|
+
//OHLC series (candlestick/ohlc) aggregate open=first, high=max, low=min, close=last per bucket
|
|
98
|
+
//instead of a single value — detect it from the point shape
|
|
99
|
+
const isOHLC = rawPoints.some(p => isNumber(p.open) && isNumber(p.close))
|
|
100
|
+
|
|
101
|
+
const grouped = []
|
|
102
|
+
let curStart = null
|
|
103
|
+
let bucketPts = []
|
|
104
|
+
let bucketFirst = null
|
|
105
|
+
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (!bucketPts.length)
|
|
108
|
+
return
|
|
109
|
+
//carry the bucket unit/multiple so the tooltip can show the group range (e.g. "September-October 2021")
|
|
110
|
+
const meta = {x: curStart, series: bucketFirst.series, groupUnit: unitName, groupMult: mult}
|
|
111
|
+
if (isOHLC) {
|
|
112
|
+
const open = bucketPts[0].open
|
|
113
|
+
const close = bucketPts[bucketPts.length - 1].close
|
|
114
|
+
grouped.push({
|
|
115
|
+
...meta,
|
|
116
|
+
open,
|
|
117
|
+
high: Math.max(...bucketPts.map(p => p.high)),
|
|
118
|
+
low: Math.min(...bucketPts.map(p => p.low)),
|
|
119
|
+
close,
|
|
120
|
+
y: close
|
|
121
|
+
})
|
|
122
|
+
} else {
|
|
123
|
+
grouped.push({...meta, y: aggregate(bucketPts.map(p => p.y), approximation)})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const p of rawPoints) {
|
|
128
|
+
if (isOHLC ? !isNumber(p.close) : !isNumber(p.y))
|
|
129
|
+
continue
|
|
130
|
+
const start = bucketStart(p.x, unitName, mult)
|
|
131
|
+
if (curStart === null || start !== curStart) {
|
|
132
|
+
flush()
|
|
133
|
+
curStart = start
|
|
134
|
+
bucketPts = []
|
|
135
|
+
bucketFirst = p
|
|
136
|
+
}
|
|
137
|
+
bucketPts.push(p)
|
|
138
|
+
}
|
|
139
|
+
flush()
|
|
140
|
+
return grouped
|
|
141
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {isNumber, clamp} from '../core/utilities'
|
|
2
|
+
import {animateLineDraw} from '../core/animate'
|
|
3
|
+
import {getDateTimeTickPositions, dateTimeLabelFormat, dateFormat} from '../core/time'
|
|
4
|
+
|
|
5
|
+
//Navigator look: a thin red overview line, the SELECTED window masked with a light fill,
|
|
6
|
+
//a light outline, and small grip handles centred vertically on each edge.
|
|
7
|
+
const NAV_LINE = '#df0000'
|
|
8
|
+
const MASK_FILL = 'rgba(102,133,194,0.3)'
|
|
9
|
+
const OUTLINE = 'rgba(204,204,204,0.6)'
|
|
10
|
+
const HANDLE_FILL = '#f2f2f2'
|
|
11
|
+
const HANDLE_STROKE = '#999'
|
|
12
|
+
const HANDLE_W = 8
|
|
13
|
+
const HANDLE_H = 15
|
|
14
|
+
|
|
15
|
+
//logical (pre display-padding) axis extremes — what the user actually selected
|
|
16
|
+
const viewMin = xAxis => isNumber(xAxis.logicalMin) ? xAxis.logicalMin : xAxis.min
|
|
17
|
+
const viewMax = xAxis => isNumber(xAxis.logicalMax) ? xAxis.logicalMax : xAxis.max
|
|
18
|
+
|
|
19
|
+
//Bottom navigator strip: a mini overview of the full series with a draggable selection window
|
|
20
|
+
//that drives the main x-axis extremes.
|
|
21
|
+
export class Navigator {
|
|
22
|
+
constructor(chart) {
|
|
23
|
+
this.chart = chart
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
render(group, top, height) {
|
|
27
|
+
const chart = this.chart
|
|
28
|
+
const renderer = chart.renderer
|
|
29
|
+
const xAxis = chart.xAxis[0]
|
|
30
|
+
const series = chart.series.find(s => s.points.length)
|
|
31
|
+
if (!series)
|
|
32
|
+
return
|
|
33
|
+
const left = chart.plotLeft
|
|
34
|
+
const width = chart.plotWidth
|
|
35
|
+
this.top = top
|
|
36
|
+
this.height = height
|
|
37
|
+
this.left = left
|
|
38
|
+
this.width = width
|
|
39
|
+
|
|
40
|
+
const navSeries = chart.options.navigator && chart.options.navigator.series
|
|
41
|
+
const pts = (navSeries && navSeries.data) ? toPoints(navSeries.data) : series.points
|
|
42
|
+
const dMin = pts[0].x
|
|
43
|
+
const dMax = pts[pts.length - 1].x
|
|
44
|
+
this.dataMin = dMin
|
|
45
|
+
this.dataMax = dMax
|
|
46
|
+
const xToPx = v => left + ((v - dMin) / ((dMax - dMin) || 1)) * width
|
|
47
|
+
|
|
48
|
+
let yMin = Infinity
|
|
49
|
+
let yMax = -Infinity
|
|
50
|
+
for (const p of pts) {
|
|
51
|
+
const v = isNumber(p.close) ? p.close : p.y
|
|
52
|
+
if (isNumber(v)) {
|
|
53
|
+
if (v < yMin) yMin = v
|
|
54
|
+
if (v > yMax) yMax = v
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const yToPx = v => top + height - ((v - yMin) / ((yMax - yMin) || 1)) * (height - 4) - 2
|
|
58
|
+
|
|
59
|
+
//faint background
|
|
60
|
+
renderer.rect(left, top, width, height, {}).css({fill: 'var(--color-border-shadow)', 'fill-opacity': 0.15}).add(group)
|
|
61
|
+
//time breakpoints across the full overview range, so it's clear where the selected window sits
|
|
62
|
+
this.renderTimeAxis(group, top, height, dMin, dMax, xToPx)
|
|
63
|
+
//overview as a thin line (no fill)
|
|
64
|
+
let d = ''
|
|
65
|
+
let started = false
|
|
66
|
+
for (const p of pts) {
|
|
67
|
+
const v = isNumber(p.close) ? p.close : p.y
|
|
68
|
+
if (isNumber(v)) {
|
|
69
|
+
d += `${started ? 'L' : 'M'} ${xToPx(p.x).toFixed(1)} ${yToPx(v).toFixed(1)} `
|
|
70
|
+
started = true
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const lineEl = renderer.path(d.trim(), {'stroke-width': 1, fill: 'none'}).css({stroke: NAV_LINE}).add(group)
|
|
74
|
+
|
|
75
|
+
//selection window edges (clamped into the strip) — use the LOGICAL extremes (pre display-padding)
|
|
76
|
+
const x0 = clamp(xToPx(viewMin(xAxis)), left, left + width)
|
|
77
|
+
const x1 = clamp(xToPx(viewMax(xAxis)), left, left + width)
|
|
78
|
+
//mask the SELECTED region with a light fill, leave the outside clear
|
|
79
|
+
renderer.rect(x0, top, Math.max(0, x1 - x0), height, {}).css({fill: MASK_FILL, 'pointer-events': 'none'}).add(group)
|
|
80
|
+
//outline around the whole strip and the selected window
|
|
81
|
+
renderer.rect(left, top, width, height, {}).css({fill: 'none', stroke: OUTLINE, 'stroke-width': 1, 'pointer-events': 'none'}).add(group)
|
|
82
|
+
renderer.rect(x0, top, Math.max(0, x1 - x0), height, {}).css({fill: 'none', stroke: OUTLINE, 'stroke-width': 1, 'pointer-events': 'none'}).add(group)
|
|
83
|
+
|
|
84
|
+
//draggable selection band (pan) — drawn under the handles so edge drags still resize
|
|
85
|
+
const band = renderer.rect(x0, top, Math.max(0, x1 - x0), height, {}).css({fill: 'transparent', cursor: 'grab'}).add(group)
|
|
86
|
+
this.bindBand(band)
|
|
87
|
+
//grip handles, centred vertically on each edge
|
|
88
|
+
const hy = top + (height - HANDLE_H) / 2
|
|
89
|
+
const h0 = this.drawHandle(group, x0, hy)
|
|
90
|
+
const h1 = this.drawHandle(group, x1, hy)
|
|
91
|
+
this.bindHandle(h0, 'min')
|
|
92
|
+
this.bindHandle(h1, 'max')
|
|
93
|
+
|
|
94
|
+
//entrance: draw the overview line left→right on the first paint
|
|
95
|
+
if (chart.animateThisRender)
|
|
96
|
+
animateLineDraw(lineEl)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//faint calendar gridlines spanning the full overview, with dimmed labels below the strip — the same
|
|
100
|
+
//datetime tick logic as the main x-axis, so the navigator reads as a miniature timeline
|
|
101
|
+
renderTimeAxis(group, top, height, dMin, dMax, xToPx) {
|
|
102
|
+
const renderer = this.chart.renderer
|
|
103
|
+
const targetCount = Math.max(2, Math.round(this.width / 90))
|
|
104
|
+
const {positions, unitName} = getDateTimeTickPositions(dMin, dMax, targetCount)
|
|
105
|
+
const fmt = dateTimeLabelFormat(unitName)
|
|
106
|
+
const gridColor = 'var(--color-border-shadow)'
|
|
107
|
+
const labelStyle = {
|
|
108
|
+
fontSize: '11px',
|
|
109
|
+
fontFamily: 'Roboto Condensed,sans-serif',
|
|
110
|
+
fill: 'var(--color-dimmed)',
|
|
111
|
+
color: 'var(--color-dimmed)'
|
|
112
|
+
}
|
|
113
|
+
for (const pos of positions) {
|
|
114
|
+
if (pos < dMin - 1e-6 || pos > dMax + 1e-6)
|
|
115
|
+
continue
|
|
116
|
+
const x = xToPx(pos)
|
|
117
|
+
renderer.line(x, top, x, top + height, {'stroke-width': 1}).css({stroke: gridColor, 'pointer-events': 'none'}).add(group)
|
|
118
|
+
renderer.label(dateFormat(fmt, pos), x, top + height + 13, {'text-anchor': 'middle'}).css(labelStyle).add(group)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
//a grip handle: rounded rect with two vertical grip lines
|
|
123
|
+
drawHandle(group, cx, hy) {
|
|
124
|
+
const renderer = this.chart.renderer
|
|
125
|
+
const rect = renderer.rect(cx - HANDLE_W / 2, hy, HANDLE_W, HANDLE_H, {rx: 2})
|
|
126
|
+
.css({fill: HANDLE_FILL, stroke: HANDLE_STROKE, 'stroke-width': 1, cursor: 'ew-resize'}).add(group)
|
|
127
|
+
renderer.line(cx - 1.5, hy + 4, cx - 1.5, hy + HANDLE_H - 4, {'stroke-width': 1})
|
|
128
|
+
.css({stroke: HANDLE_STROKE, 'pointer-events': 'none'}).add(group)
|
|
129
|
+
renderer.line(cx + 1.5, hy + 4, cx + 1.5, hy + HANDLE_H - 4, {'stroke-width': 1})
|
|
130
|
+
.css({stroke: HANDLE_STROKE, 'pointer-events': 'none'}).add(group)
|
|
131
|
+
return rect
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
bindBand(band) {
|
|
135
|
+
const chart = this.chart
|
|
136
|
+
const xAxis = chart.xAxis[0]
|
|
137
|
+
band.on('mousedown', e => {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
const svg = chart.renderer.root.element
|
|
140
|
+
const rect = svg.getBoundingClientRect()
|
|
141
|
+
const scaleX = chart.chartWidth / rect.width
|
|
142
|
+
const startPx = (e.clientX - rect.left) * scaleX
|
|
143
|
+
const startMin = viewMin(xAxis)
|
|
144
|
+
const startMax = viewMax(xAxis)
|
|
145
|
+
const span = startMax - startMin
|
|
146
|
+
const dataSpan = this.dataMax - this.dataMin
|
|
147
|
+
const move = ev => {
|
|
148
|
+
const px = (ev.clientX - rect.left) * scaleX
|
|
149
|
+
const delta = ((px - startPx) / this.width) * dataSpan
|
|
150
|
+
let min = startMin + delta
|
|
151
|
+
let max = startMax + delta
|
|
152
|
+
if (min < this.dataMin) {
|
|
153
|
+
min = this.dataMin
|
|
154
|
+
max = min + span
|
|
155
|
+
}
|
|
156
|
+
if (max > this.dataMax) {
|
|
157
|
+
max = this.dataMax
|
|
158
|
+
min = max - span
|
|
159
|
+
}
|
|
160
|
+
xAxis.setExtremes(min, max)
|
|
161
|
+
}
|
|
162
|
+
const up = () => {
|
|
163
|
+
document.removeEventListener('mousemove', move)
|
|
164
|
+
document.removeEventListener('mouseup', up)
|
|
165
|
+
}
|
|
166
|
+
document.addEventListener('mousemove', move)
|
|
167
|
+
document.addEventListener('mouseup', up)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
bindHandle(handle, which) {
|
|
172
|
+
const chart = this.chart
|
|
173
|
+
const xAxis = chart.xAxis[0]
|
|
174
|
+
handle.on('mousedown', e => {
|
|
175
|
+
e.preventDefault()
|
|
176
|
+
const svg = chart.renderer.root.element
|
|
177
|
+
const rect = svg.getBoundingClientRect()
|
|
178
|
+
const scaleX = chart.chartWidth / rect.width
|
|
179
|
+
const move = ev => {
|
|
180
|
+
const px = (ev.clientX - rect.left) * scaleX
|
|
181
|
+
const v = this.dataMin + ((px - this.left) / this.width) * (this.dataMax - this.dataMin)
|
|
182
|
+
let min = viewMin(xAxis)
|
|
183
|
+
let max = viewMax(xAxis)
|
|
184
|
+
const minSpan = (this.dataMax - this.dataMin) / 500
|
|
185
|
+
if (which === 'min')
|
|
186
|
+
min = Math.max(this.dataMin, Math.min(v, max - minSpan))
|
|
187
|
+
else
|
|
188
|
+
max = Math.min(this.dataMax, Math.max(v, min + minSpan))
|
|
189
|
+
xAxis.setExtremes(min, max)
|
|
190
|
+
}
|
|
191
|
+
const up = () => {
|
|
192
|
+
document.removeEventListener('mousemove', move)
|
|
193
|
+
document.removeEventListener('mouseup', up)
|
|
194
|
+
}
|
|
195
|
+
document.addEventListener('mousemove', move)
|
|
196
|
+
document.addEventListener('mouseup', up)
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function toPoints(data) {
|
|
202
|
+
return data.map(p => Array.isArray(p)
|
|
203
|
+
? {x: p[0], y: p[p.length >= 5 ? 4 : 1], close: p.length >= 5 ? p[4] : undefined}
|
|
204
|
+
: p)
|
|
205
|
+
}
|