@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,555 @@
|
|
|
1
|
+
import {Axis} from '../axis/axis'
|
|
2
|
+
import {niceAlignedTicks} from '../axis/tick-positioner'
|
|
3
|
+
import {createSeries} from '../series'
|
|
4
|
+
import {Legend} from '../interaction/legend'
|
|
5
|
+
import {Tooltip} from '../interaction/tooltip'
|
|
6
|
+
import {groupSeriesData} from '../stock/data-grouping'
|
|
7
|
+
import {RangeSelector} from '../stock/range-selector'
|
|
8
|
+
import {renderPolar} from '../polar/polar-chart'
|
|
9
|
+
import {renderPie} from '../pie/pie-chart'
|
|
10
|
+
import {Navigator} from '../stock/navigator'
|
|
11
|
+
import {merge, resolveContainer, pick, relativeLength, uniqueId, defined, isNumber} from './utilities'
|
|
12
|
+
import {getOptions} from './options'
|
|
13
|
+
import {SvgRenderer} from './svg-renderer'
|
|
14
|
+
|
|
15
|
+
const X_LABEL_HEIGHT = 22
|
|
16
|
+
|
|
17
|
+
export class Chart {
|
|
18
|
+
constructor(renderTo, userOptions, chartType) {
|
|
19
|
+
this.userOptions = userOptions || {}
|
|
20
|
+
this.options = merge(getOptions(), this.userOptions)
|
|
21
|
+
this.chartType = chartType || 'Chart'
|
|
22
|
+
this.container = resolveContainer(renderTo)
|
|
23
|
+
if (!this.container)
|
|
24
|
+
return
|
|
25
|
+
this.legend = new Legend(this)
|
|
26
|
+
this.tooltip = new Tooltip(this)
|
|
27
|
+
this.init()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
init() {
|
|
31
|
+
this.getChartSize()
|
|
32
|
+
this.container.style.position = 'relative'
|
|
33
|
+
this.renderer = new SvgRenderer(this.container, this.chartWidth, this.chartHeight)
|
|
34
|
+
this.createAxes()
|
|
35
|
+
this.createSeries()
|
|
36
|
+
this.normalizeCategories()
|
|
37
|
+
if (this.navigatorEnabled)
|
|
38
|
+
this.navigator = new Navigator(this)
|
|
39
|
+
this.render()
|
|
40
|
+
this.hasRendered = true
|
|
41
|
+
//range selector persists across redraws (lives in the container as HTML, not SVG)
|
|
42
|
+
if (this.options.rangeSelector && this.chartType === 'StockChart')
|
|
43
|
+
this.rangeSelector = new RangeSelector(this)
|
|
44
|
+
this.bindResize()
|
|
45
|
+
//lifecycle events, fired once the chart is fully built. Views use these to lazy-load
|
|
46
|
+
//finer-resolution data for the visible window (e.g. candlestick price history on zoom).
|
|
47
|
+
const chartEvents = this.options.chart && this.options.chart.events
|
|
48
|
+
if (chartEvents && typeof chartEvents.load === 'function')
|
|
49
|
+
chartEvents.load.call(this, {target: this})
|
|
50
|
+
//notify any afterSetExtremes handler of the initial visible window, so views that fetch data for the
|
|
51
|
+
//current extremes load it for the initial range too
|
|
52
|
+
const xa = this.xAxis && this.xAxis[0]
|
|
53
|
+
if (xa)
|
|
54
|
+
xa.fireSetExtremes('load')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get navigatorEnabled() {
|
|
58
|
+
return !!(this.options.navigator && this.options.navigator.enabled)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
showLoading(text) {
|
|
62
|
+
this.hideLoading()
|
|
63
|
+
const el = document.createElement('div')
|
|
64
|
+
el.className = 'ix-chart-loading'
|
|
65
|
+
el.textContent = text || 'Loading…'
|
|
66
|
+
Object.assign(el.style, {
|
|
67
|
+
position: 'absolute', inset: '0', display: 'flex', alignItems: 'center',
|
|
68
|
+
justifyContent: 'center', background: 'rgba(0,0,0,0.25)', color: 'var(--color-text)', zIndex: 15
|
|
69
|
+
})
|
|
70
|
+
this.container.appendChild(el)
|
|
71
|
+
this.loadingEl = el
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hideLoading() {
|
|
75
|
+
if (this.loadingEl && this.loadingEl.parentNode)
|
|
76
|
+
this.loadingEl.parentNode.removeChild(this.loadingEl)
|
|
77
|
+
this.loadingEl = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getChartSize() {
|
|
81
|
+
const opt = this.options.chart
|
|
82
|
+
const containerWidth = this.container.clientWidth || this.container.offsetWidth || 600
|
|
83
|
+
this.chartWidth = pick(opt.width, containerWidth) || 600
|
|
84
|
+
this.chartHeight = defined(opt.height) ? relativeLength(opt.height, this.chartWidth) : 400
|
|
85
|
+
this.spacing = opt.spacing || [10, 10, 15, 10]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
createAxes() {
|
|
89
|
+
const toArray = v => (Array.isArray(v) ? v : [v || {}])
|
|
90
|
+
const xDefaults = this.options.xAxis
|
|
91
|
+
const yDefaults = this.options.yAxis
|
|
92
|
+
//merge each axis options with the theme-level xAxis/yAxis defaults
|
|
93
|
+
this.xAxis = toArray(this.userOptions.xAxis).map((o, i) =>
|
|
94
|
+
new Axis(this, merge(xDefaults, o), 'xAxis', i))
|
|
95
|
+
if (!this.userOptions.xAxis)
|
|
96
|
+
this.xAxis = [new Axis(this, merge(xDefaults), 'xAxis', 0)]
|
|
97
|
+
this.yAxis = toArray(this.userOptions.yAxis).map((o, i) =>
|
|
98
|
+
new Axis(this, merge(yDefaults, o), 'yAxis', i))
|
|
99
|
+
if (!this.userOptions.yAxis)
|
|
100
|
+
this.yAxis = [new Axis(this, merge(yDefaults), 'yAxis', 0)]
|
|
101
|
+
//default axis type for x: datetime unless overridden (the explorer theme sets datetime)
|
|
102
|
+
for (const ax of this.xAxis) {
|
|
103
|
+
if (!ax.options.type)
|
|
104
|
+
ax.type = 'linear'
|
|
105
|
+
}
|
|
106
|
+
this.axes = [...this.xAxis, ...this.yAxis]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
createSeries() {
|
|
110
|
+
const defaultType = this.options.chart && this.options.chart.type
|
|
111
|
+
this.series = (this.userOptions.series || []).map((o, i) => {
|
|
112
|
+
const opts = (!o.type && defaultType) ? {...o, type: defaultType} : o
|
|
113
|
+
const s = createSeries(this, opts)
|
|
114
|
+
s.index = i
|
|
115
|
+
return s
|
|
116
|
+
})
|
|
117
|
+
for (const s of this.series)
|
|
118
|
+
s.bindAxes()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
//build category list from string x-values when xAxis.type === 'category' and no categories given
|
|
122
|
+
normalizeCategories() {
|
|
123
|
+
const xa = this.xAxis[0]
|
|
124
|
+
if (!xa || xa.type !== 'category' || xa.categories)
|
|
125
|
+
return
|
|
126
|
+
const cats = []
|
|
127
|
+
for (const s of this.series) {
|
|
128
|
+
s.points.forEach((p, i) => {
|
|
129
|
+
if (typeof p.x === 'string') {
|
|
130
|
+
let idx = cats.indexOf(p.x)
|
|
131
|
+
if (idx === -1) {
|
|
132
|
+
idx = cats.length
|
|
133
|
+
cats.push(p.x)
|
|
134
|
+
}
|
|
135
|
+
p.x = idx
|
|
136
|
+
s.xData[i] = idx
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
s.plotPoints = s.points
|
|
140
|
+
}
|
|
141
|
+
xa.categories = cats
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setPlotBox(leftW, rightW, xLabelH, legendH) {
|
|
145
|
+
const [spTop, spRight, spBottom, spLeft] = this.spacing
|
|
146
|
+
this.plotLeft = spLeft + leftW
|
|
147
|
+
this.plotTop = spTop
|
|
148
|
+
this.plotWidth = Math.max(50, this.chartWidth - this.plotLeft - spRight - rightW)
|
|
149
|
+
this.plotHeight = Math.max(50, this.chartHeight - this.plotTop - spBottom - xLabelH - legendH)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sideWidth(axes) {
|
|
153
|
+
return axes.reduce((m, a) => {
|
|
154
|
+
//labels float inside the plot → reserve only a tiny constant margin, so range/zoom changes
|
|
155
|
+
//(which alter the label text width) never reflow the plot horizontally
|
|
156
|
+
if (a.labelsInside)
|
|
157
|
+
return Math.max(m, 10)
|
|
158
|
+
const hasTitle = !!(a.options.title && a.options.title.text)
|
|
159
|
+
const offset = a.options.offset || 0
|
|
160
|
+
const labelExtent = a.estimateLabelWidth() + 8
|
|
161
|
+
//when offset>0 the (rotated) title sits at the offset distance, parallel to the labels — not
|
|
162
|
+
//beyond them — so the margin is offset + title thickness, NOT labelWidth + title + offset
|
|
163
|
+
const titleExtent = offset > 0
|
|
164
|
+
? offset + (hasTitle ? 24 : 0)
|
|
165
|
+
: a.estimateLabelWidth() + (hasTitle ? 18 : 0) + 8
|
|
166
|
+
return Math.max(m, labelExtent, titleExtent)
|
|
167
|
+
}, 0)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
layout() {
|
|
171
|
+
//fixed margins (sparklines): chart.margin = [top, right, bottom, left] overrides auto-layout
|
|
172
|
+
const margin = this.options.chart && this.options.chart.margin
|
|
173
|
+
if (margin) {
|
|
174
|
+
const [mt, mr, mb, ml] = margin
|
|
175
|
+
this.plotTop = mt
|
|
176
|
+
this.plotLeft = ml
|
|
177
|
+
this.plotWidth = Math.max(1, this.chartWidth - ml - mr)
|
|
178
|
+
this.plotHeight = Math.max(1, this.chartHeight - mt - mb)
|
|
179
|
+
for (const ax of this.axes) {
|
|
180
|
+
ax.setAxisSize()
|
|
181
|
+
ax.setScale()
|
|
182
|
+
ax.setAxisSize()
|
|
183
|
+
}
|
|
184
|
+
this.processData()
|
|
185
|
+
for (const ax of this.yAxis)
|
|
186
|
+
ax.setScale()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
//navigator occupies a strip below the x-axis labels
|
|
190
|
+
const bottomExtra = this.legend.getHeight() + (this.navigatorEnabled ? 60 : 0)
|
|
191
|
+
const leftAxes = this.yAxis.filter(a => !a.opposite)
|
|
192
|
+
const rightAxes = this.yAxis.filter(a => a.opposite)
|
|
193
|
+
|
|
194
|
+
//pass 1 — rough geometry, then group/stack so y-extremes reflect the drawn data
|
|
195
|
+
this.setPlotBox(50, rightAxes.length ? 50 : 10, X_LABEL_HEIGHT, bottomExtra)
|
|
196
|
+
for (const ax of this.axes) {
|
|
197
|
+
ax.setAxisSize()
|
|
198
|
+
ax.setScale()
|
|
199
|
+
}
|
|
200
|
+
this.processData()
|
|
201
|
+
for (const ax of this.yAxis)
|
|
202
|
+
ax.setScale()
|
|
203
|
+
|
|
204
|
+
//rotated x-axis labels (long categories) need more vertical room
|
|
205
|
+
const xLabelH = this.xAxis[0].needsRotation() ? 56 : X_LABEL_HEIGHT
|
|
206
|
+
this.xLabelHeight = xLabelH
|
|
207
|
+
|
|
208
|
+
//pass 2 — real margins from grouped y-labels
|
|
209
|
+
this.setPlotBox(this.sideWidth(leftAxes), this.sideWidth(rightAxes), xLabelH, bottomExtra)
|
|
210
|
+
for (const ax of this.axes) {
|
|
211
|
+
ax.setAxisSize()
|
|
212
|
+
ax.setScale()
|
|
213
|
+
}
|
|
214
|
+
this.processData()
|
|
215
|
+
for (const ax of this.axes) {
|
|
216
|
+
ax.setAxisSize()
|
|
217
|
+
ax.setScale()
|
|
218
|
+
ax.setAxisSize()
|
|
219
|
+
}
|
|
220
|
+
this.alignYAxes()
|
|
221
|
+
this.adjustXAxisPadding()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//share gridlines between y-axes that occupy the same pane (aligned ticks): same tick count
|
|
225
|
+
alignYAxes() {
|
|
226
|
+
const groups = new Map()
|
|
227
|
+
for (const a of this.yAxis) {
|
|
228
|
+
if (a.isLog || a.categories || !a.tickPositions || a.tickPositions.length < 2)
|
|
229
|
+
continue
|
|
230
|
+
const key = Math.round(a.top) + '|' + Math.round(a.len)
|
|
231
|
+
if (!groups.has(key))
|
|
232
|
+
groups.set(key, [])
|
|
233
|
+
groups.get(key).push(a)
|
|
234
|
+
}
|
|
235
|
+
for (const list of groups.values()) {
|
|
236
|
+
if (list.length < 2)
|
|
237
|
+
continue
|
|
238
|
+
//zero-based axes already share the fixed 0/20/…/100% grid (6 lines spanning 0..5·interval), so
|
|
239
|
+
//their i-th gridlines coincide by construction — no reconciliation needed (e.g. left 0..400 and
|
|
240
|
+
//right 0..40M both at 0/20/…/100%). Only mixed/non-zero panes need the shared-count pass below.
|
|
241
|
+
if (list.every(a => a.zeroBased))
|
|
242
|
+
continue
|
|
243
|
+
//Shared gridline count AND a common headroom factor, so every axis's i-th tick lands on the
|
|
244
|
+
//exact same pixel line — e.g. left 0,6,12,18,24,30 and right 0,2M,…,10M with 30G and 10M on the
|
|
245
|
+
//same top gridline. Each axis gets a pretty round interval (niceAlignedTicks) for its own count
|
|
246
|
+
//of ticks; then we scale all axes' max by one factor so the top ticks coincide.
|
|
247
|
+
const count = Math.max(3, Math.round((list[0].len || 300) / 72)) + 1
|
|
248
|
+
const specs = list.map(a => {
|
|
249
|
+
const lo = a.options.min !== undefined ? a.options.min : a.dataMin
|
|
250
|
+
const r = niceAlignedTicks(lo, a.dataMax, count)
|
|
251
|
+
return {a, positions: r.positions, interval: r.interval, base: r.min, dataMax: a.dataMax}
|
|
252
|
+
})
|
|
253
|
+
//hide the topmost tick row only when it is pure headroom on EVERY axis (the data sits below it
|
|
254
|
+
//on all of them) — that's the row that crowds the zoom controls. If any axis's data reaches its
|
|
255
|
+
//top tick, keep the row so that gridline still shows (e.g. supply 30G with data at 33G).
|
|
256
|
+
const dropTop = specs.every(s => s.positions[s.positions.length - 1] > s.dataMax * (1 + 1e-9))
|
|
257
|
+
for (const s of specs)
|
|
258
|
+
s.drawn = dropTop ? s.positions.slice(0, -1) : s.positions
|
|
259
|
+
//Express every axis' top edge as the SAME number of tick-steps above its base (M), so the i-th
|
|
260
|
+
//gridline lands on the same pixel line on all of them. M reserves STEP_HEAD of a step of empty
|
|
261
|
+
//space above the topmost drawn tick (so the top value isn't flush against the zoom controls), and
|
|
262
|
+
//is widened if any axis' data pokes above that, so nothing ever clips.
|
|
263
|
+
const STEP_HEAD = 0.7
|
|
264
|
+
let M = 0
|
|
265
|
+
for (const s of specs) {
|
|
266
|
+
const nIntervals = s.drawn.length - 1
|
|
267
|
+
const dataSteps = s.interval > 0 ? (s.dataMax - s.base) / s.interval : nIntervals
|
|
268
|
+
M = Math.max(M, nIntervals + STEP_HEAD, dataSteps + 0.15)
|
|
269
|
+
}
|
|
270
|
+
for (const s of specs) {
|
|
271
|
+
s.a.tickInterval = s.interval
|
|
272
|
+
s.a.tickPositions = s.drawn
|
|
273
|
+
s.a.min = s.base
|
|
274
|
+
s.a.max = s.base + M * s.interval
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
//data grouping + column metrics + stacking
|
|
280
|
+
processData() {
|
|
281
|
+
this.applyDataGrouping()
|
|
282
|
+
this.assignColumnMetrics()
|
|
283
|
+
this.applyStacking()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
applyDataGrouping() {
|
|
287
|
+
const baseGrouping = this.chartType === 'StockChart' &&
|
|
288
|
+
this.options.plotOptions && this.options.plotOptions.series && this.options.plotOptions.series.dataGrouping
|
|
289
|
+
const xAxis = this.xAxis[0]
|
|
290
|
+
for (const s of this.series) {
|
|
291
|
+
const seriesGrouping = s.options.dataGrouping
|
|
292
|
+
if (baseGrouping || seriesGrouping) {
|
|
293
|
+
const grouping = merge(baseGrouping || {}, seriesGrouping || {})
|
|
294
|
+
const defApprox = /column|bar/.test(s.type) ? 'sum' : 'average'
|
|
295
|
+
s.plotPoints = groupSeriesData(s.points, grouping, xAxis.min, xAxis.max, this.plotWidth, defApprox)
|
|
296
|
+
} else {
|
|
297
|
+
s.plotPoints = s.points
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
getStacking(s) {
|
|
303
|
+
const po = this.options.plotOptions || {}
|
|
304
|
+
return s.options.stacking || (po[s.type] && po[s.type].stacking) || (po.series && po.series.stacking) || null
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
assignColumnMetrics() {
|
|
308
|
+
const colSeries = this.series.filter(s => s.visible && /^(column|bar)$/.test(s.type))
|
|
309
|
+
//group columns per y-axis (pane): columns in different panes are independent, so e.g. a lone
|
|
310
|
+
//volume series fills its slot while Payments+Trades sit side-by-side in the main pane
|
|
311
|
+
const byAxis = new Map()
|
|
312
|
+
for (const s of colSeries) {
|
|
313
|
+
const key = this.yAxis.indexOf(s.yAxis)
|
|
314
|
+
if (!byAxis.has(key))
|
|
315
|
+
byAxis.set(key, [])
|
|
316
|
+
byAxis.get(key).push(s)
|
|
317
|
+
}
|
|
318
|
+
for (const list of byAxis.values()) {
|
|
319
|
+
const stackingActive = list.some(s => this.getStacking(s))
|
|
320
|
+
let visIdx = 0
|
|
321
|
+
for (const s of list) {
|
|
322
|
+
s.columnCount = stackingActive ? 1 : list.length
|
|
323
|
+
s.columnIndex = stackingActive ? 0 : visIdx++
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
//chart-wide point range = smallest gap between adjacent points of any "slotted" series
|
|
327
|
+
//(columns/candles), used for bar width AND for x-axis edge padding
|
|
328
|
+
const slotted = this.series.filter(s => s.visible && /^(column|bar|candlestick|ohlc)$/.test(s.type))
|
|
329
|
+
let pr = Infinity
|
|
330
|
+
for (const s of slotted) {
|
|
331
|
+
//only count gaps between drawn points — skip null markers (e.g. the trailing [now, null…] the
|
|
332
|
+
//price view appends), which would otherwise shrink the slot and thin the bars/candles
|
|
333
|
+
let prevX = null
|
|
334
|
+
for (const pt of s.plotPoints) {
|
|
335
|
+
if (!isNumber(pt.y))
|
|
336
|
+
continue
|
|
337
|
+
if (prevX !== null) {
|
|
338
|
+
const d = pt.x - prevX
|
|
339
|
+
if (d > 0 && d < pr) pr = d
|
|
340
|
+
}
|
|
341
|
+
prevX = pt.x
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
this.hasSlottedSeries = slotted.length > 0
|
|
345
|
+
this.pointRange = pr === Infinity ? (this.xAxis[0].max - this.xAxis[0].min) / 50 : pr
|
|
346
|
+
}
|
|
347
|
+
|
|
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
|
+
//draw a white separator at the boundary between stacked y-axis panes (e.g. Total XLM over Fee Pool)
|
|
351
|
+
renderPaneSeparators() {
|
|
352
|
+
const tops = new Set()
|
|
353
|
+
for (const ax of this.yAxis) {
|
|
354
|
+
//a pane that starts below the plot top is a lower pane → its top is a boundary
|
|
355
|
+
if (isNumber(ax.top) && ax.top > this.plotTop + 1)
|
|
356
|
+
tops.add(Math.round(ax.top))
|
|
357
|
+
}
|
|
358
|
+
if (!tops.size)
|
|
359
|
+
return
|
|
360
|
+
for (const y of tops) {
|
|
361
|
+
this.renderer.line(this.plotLeft, y, this.plotLeft + this.plotWidth, y, {'stroke-width': 1})
|
|
362
|
+
.css({stroke: '#fff', 'stroke-opacity': 0.45}).add(this.axisGroup)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
adjustXAxisPadding() {
|
|
367
|
+
const xa = this.xAxis[0]
|
|
368
|
+
//remember the logical (pre-pad) extremes — the navigator/range-selector operate on THESE so the
|
|
369
|
+
//display padding isn't read back and re-applied each redraw (which would creep the window on drag)
|
|
370
|
+
xa.logicalMin = xa.min
|
|
371
|
+
xa.logicalMax = xa.max
|
|
372
|
+
if (xa.categories)
|
|
373
|
+
return //category axis already pads ±0.5
|
|
374
|
+
const range = xa.max - xa.min
|
|
375
|
+
if (!(range > 0))
|
|
376
|
+
return
|
|
377
|
+
const slotPad = (this.hasSlottedSeries && this.pointRange > 0) ? this.pointRange / 2 : 0
|
|
378
|
+
const pad = Math.max(slotPad, range * 0.02)
|
|
379
|
+
xa.min -= pad
|
|
380
|
+
xa.max += pad
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
applyStacking() {
|
|
384
|
+
const groups = new Map()
|
|
385
|
+
for (const s of this.series) {
|
|
386
|
+
const stacking = this.getStacking(s)
|
|
387
|
+
//clear any prior stack values
|
|
388
|
+
for (const p of s.plotPoints) {
|
|
389
|
+
p.stackY = undefined
|
|
390
|
+
p.stackLow = undefined
|
|
391
|
+
}
|
|
392
|
+
//hidden series drop out of the stack entirely, so the remaining series re-stack from 0
|
|
393
|
+
//instead of leaving a floating gap where the hidden one used to be
|
|
394
|
+
if (!stacking || !s.visible)
|
|
395
|
+
continue
|
|
396
|
+
const key = this.yAxis.indexOf(s.yAxis) + '|' + stacking
|
|
397
|
+
if (!groups.has(key))
|
|
398
|
+
groups.set(key, {stacking, list: []})
|
|
399
|
+
groups.get(key).list.push(s)
|
|
400
|
+
}
|
|
401
|
+
for (const {stacking, list} of groups.values()) {
|
|
402
|
+
const totals = new Map()
|
|
403
|
+
if (stacking === 'percent') {
|
|
404
|
+
for (const s of list)
|
|
405
|
+
for (const p of s.plotPoints)
|
|
406
|
+
if (isNumber(p.y)) totals.set(p.x, (totals.get(p.x) || 0) + p.y)
|
|
407
|
+
}
|
|
408
|
+
const cum = new Map()
|
|
409
|
+
//stack in REVERSE series order (reversed stacks): the last declared
|
|
410
|
+
//series sits at the bottom, the first on top
|
|
411
|
+
for (const s of [...list].reverse()) {
|
|
412
|
+
for (const p of s.plotPoints) {
|
|
413
|
+
if (!isNumber(p.y))
|
|
414
|
+
continue
|
|
415
|
+
const prev = cum.get(p.x) || 0
|
|
416
|
+
const next = prev + p.y
|
|
417
|
+
if (stacking === 'percent') {
|
|
418
|
+
const tot = totals.get(p.x) || 1
|
|
419
|
+
p.stackLow = prev / tot * 100
|
|
420
|
+
p.stackY = next / tot * 100
|
|
421
|
+
} else {
|
|
422
|
+
p.stackLow = prev
|
|
423
|
+
p.stackY = next
|
|
424
|
+
}
|
|
425
|
+
cum.set(p.x, next)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
//record the full column total per x so the load animation can fill each stacked column from
|
|
429
|
+
//the baseline as one unit (lower segment finishes first, upper one keeps rising)
|
|
430
|
+
for (const s of list)
|
|
431
|
+
for (const p of s.plotPoints)
|
|
432
|
+
if (isNumber(p.y))
|
|
433
|
+
p.stackTotal = stacking === 'percent' ? 100 : cum.get(p.x)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
render() {
|
|
438
|
+
//animate only on the very first paint; redraws (zoom, legend toggle, resize) are instant.
|
|
439
|
+
//honour an explicit animation:false on the chart or series options (e.g. sparklines)
|
|
440
|
+
const chartAnim = this.options.chart && this.options.chart.animation
|
|
441
|
+
const seriesAnim = this.options.plotOptions && this.options.plotOptions.series &&
|
|
442
|
+
this.options.plotOptions.series.animation
|
|
443
|
+
this.animateThisRender = this.animateOnRender !== false && chartAnim !== false && seriesAnim !== false
|
|
444
|
+
this.animateOnRender = false
|
|
445
|
+
//polar/radar charts use a dedicated coordinate path
|
|
446
|
+
if (this.options.chart && this.options.chart.polar) {
|
|
447
|
+
renderPolar(this)
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
//pie / variable-pie use a radial sector path
|
|
451
|
+
const chartType = this.options.chart && this.options.chart.type
|
|
452
|
+
if (chartType === 'pie' || chartType === 'variablepie') {
|
|
453
|
+
renderPie(this)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
this.layout()
|
|
457
|
+
const renderer = this.renderer
|
|
458
|
+
|
|
459
|
+
//plot background
|
|
460
|
+
renderer.rect(this.plotLeft, this.plotTop, this.plotWidth, this.plotHeight)
|
|
461
|
+
.css({fill: 'transparent'}).add(renderer.root)
|
|
462
|
+
|
|
463
|
+
//groups (draw order: gridlines, series, axes/labels, legend)
|
|
464
|
+
this.gridGroup = renderer.group('ix-grid').add(renderer.root)
|
|
465
|
+
const clipId = uniqueId('plotclip')
|
|
466
|
+
const clipRef = renderer.clipRect(this.plotLeft, this.plotTop - 2, this.plotWidth, this.plotHeight + 4, clipId)
|
|
467
|
+
this.seriesGroup = renderer.group('ix-series').attr('clip-path', clipRef).add(renderer.root)
|
|
468
|
+
this.axisGroup = renderer.group('ix-axes').add(renderer.root)
|
|
469
|
+
this.legendGroup = renderer.group('ix-legend').add(renderer.root)
|
|
470
|
+
|
|
471
|
+
//axes (y first for gridlines under, then x)
|
|
472
|
+
for (const ax of this.yAxis)
|
|
473
|
+
ax.render(this.gridGroup, this.axisGroup)
|
|
474
|
+
for (const ax of this.xAxis)
|
|
475
|
+
ax.render(this.gridGroup, this.axisGroup)
|
|
476
|
+
|
|
477
|
+
//white separator line between stacked y-axis panes (multi-pane / dual charts)
|
|
478
|
+
this.renderPaneSeparators()
|
|
479
|
+
|
|
480
|
+
//series sorted by z-index
|
|
481
|
+
const ordered = this.series.slice().sort((a, b) => a.zIndex - b.zIndex)
|
|
482
|
+
for (const s of ordered) {
|
|
483
|
+
if (!s.visible)
|
|
484
|
+
continue
|
|
485
|
+
s.translate()
|
|
486
|
+
const g = renderer.group('ix-series-' + s.index).add(this.seriesGroup)
|
|
487
|
+
s.render(g)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.legend.render(this.legendGroup)
|
|
491
|
+
if (this.navigator) {
|
|
492
|
+
const navGroup = renderer.group('ix-navigator').add(renderer.root)
|
|
493
|
+
const navTop = this.plotTop + this.plotHeight + (this.xLabelHeight || X_LABEL_HEIGHT) + 6
|
|
494
|
+
this.navigator.render(navGroup, navTop, 32)
|
|
495
|
+
}
|
|
496
|
+
this.tooltip.bind()
|
|
497
|
+
if (this.rangeSelector)
|
|
498
|
+
this.rangeSelector.reposition()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
redraw() {
|
|
502
|
+
//clear and re-render into the same container
|
|
503
|
+
this.tooltip.destroy()
|
|
504
|
+
this.renderer.destroy()
|
|
505
|
+
this.renderer = new SvgRenderer(this.container, this.chartWidth, this.chartHeight)
|
|
506
|
+
this.render()
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
bindResize() {
|
|
510
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
511
|
+
let raf
|
|
512
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
513
|
+
cancelAnimationFrame(raf)
|
|
514
|
+
raf = requestAnimationFrame(() => this.reflow())
|
|
515
|
+
})
|
|
516
|
+
this.resizeObserver.observe(this.container)
|
|
517
|
+
}
|
|
518
|
+
//mobile rotation (portrait↔landscape) often settles a frame or two AFTER the event fires, so the
|
|
519
|
+
//ResizeObserver may read a transient size — reflow again on the next frame to catch the final layout
|
|
520
|
+
if (typeof window !== 'undefined') {
|
|
521
|
+
this.onOrientation = () => requestAnimationFrame(() => this.reflow())
|
|
522
|
+
window.addEventListener('orientationchange', this.onOrientation)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
reflow() {
|
|
527
|
+
if (!this.container)
|
|
528
|
+
return
|
|
529
|
+
const prevWidth = this.chartWidth
|
|
530
|
+
const prevHeight = this.chartHeight
|
|
531
|
+
this.getChartSize()
|
|
532
|
+
//redraw when EITHER dimension changed meaningfully (height matters for %-height/orientation charts);
|
|
533
|
+
//the 2px deadband absorbs sub-pixel ResizeObserver noise that would otherwise cause a redraw storm
|
|
534
|
+
if (Math.abs(this.chartWidth - prevWidth) < 2 && Math.abs(this.chartHeight - prevHeight) < 2)
|
|
535
|
+
return
|
|
536
|
+
this.redraw()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
destroy() {
|
|
540
|
+
for (const ax of (this.axes || []))
|
|
541
|
+
if (ax.extremesTimer)
|
|
542
|
+
clearTimeout(ax.extremesTimer)
|
|
543
|
+
if (this.resizeObserver)
|
|
544
|
+
this.resizeObserver.disconnect()
|
|
545
|
+
if (this.onOrientation && typeof window !== 'undefined')
|
|
546
|
+
window.removeEventListener('orientationchange', this.onOrientation)
|
|
547
|
+
if (this.rangeSelector)
|
|
548
|
+
this.rangeSelector.destroy()
|
|
549
|
+
if (this.tooltip)
|
|
550
|
+
this.tooltip.destroy()
|
|
551
|
+
if (this.renderer)
|
|
552
|
+
this.renderer.destroy()
|
|
553
|
+
this.container = null
|
|
554
|
+
}
|
|
555
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
//Minimal color parser supporting the formats the explorer theme actually uses:
|
|
2
|
+
//hex (#rgb / #rrggbb), rgb()/rgba(), hsl()/hsla().
|
|
3
|
+
//CSS custom properties (var(--x)) are NOT parsed here — they're applied as raw style strings by the renderer.
|
|
4
|
+
|
|
5
|
+
function parse(input) {
|
|
6
|
+
if (input instanceof Color)
|
|
7
|
+
return {rgba: input.rgba.slice()}
|
|
8
|
+
if (typeof input !== 'string')
|
|
9
|
+
return {rgba: [0, 0, 0, 1]}
|
|
10
|
+
const str = input.trim()
|
|
11
|
+
//hex
|
|
12
|
+
let m = /^#([0-9a-f]{3,8})$/i.exec(str)
|
|
13
|
+
if (m) {
|
|
14
|
+
let hex = m[1]
|
|
15
|
+
if (hex.length === 3) {
|
|
16
|
+
hex = hex.split('').map(c => c + c).join('')
|
|
17
|
+
}
|
|
18
|
+
const r = parseInt(hex.substr(0, 2), 16)
|
|
19
|
+
const g = parseInt(hex.substr(2, 2), 16)
|
|
20
|
+
const b = parseInt(hex.substr(4, 2), 16)
|
|
21
|
+
const a = hex.length >= 8 ? parseInt(hex.substr(6, 2), 16) / 255 : 1
|
|
22
|
+
return {rgba: [r, g, b, a]}
|
|
23
|
+
}
|
|
24
|
+
//rgb/rgba
|
|
25
|
+
m = /^rgba?\(([^)]+)\)$/i.exec(str)
|
|
26
|
+
if (m) {
|
|
27
|
+
const parts = m[1].split(',').map(s => parseFloat(s))
|
|
28
|
+
return {rgba: [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] === undefined ? 1 : parts[3]]}
|
|
29
|
+
}
|
|
30
|
+
//hsl/hsla
|
|
31
|
+
m = /^hsla?\(([^)]+)\)$/i.exec(str)
|
|
32
|
+
if (m) {
|
|
33
|
+
const parts = m[1].split(',')
|
|
34
|
+
const h = parseFloat(parts[0])
|
|
35
|
+
const s = parseFloat(parts[1]) / 100
|
|
36
|
+
const l = parseFloat(parts[2]) / 100
|
|
37
|
+
const a = parts[3] === undefined ? 1 : parseFloat(parts[3])
|
|
38
|
+
return {rgba: [...hslToRgb(h, s, l), a]}
|
|
39
|
+
}
|
|
40
|
+
return {rgba: [0, 0, 0, 1], raw: str}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hslToRgb(h, s, l) {
|
|
44
|
+
h = ((h % 360) + 360) % 360 / 360
|
|
45
|
+
let r, g, b
|
|
46
|
+
if (s === 0) {
|
|
47
|
+
r = g = b = l
|
|
48
|
+
} else {
|
|
49
|
+
const hue2rgb = (p, q, t) => {
|
|
50
|
+
if (t < 0) t += 1
|
|
51
|
+
if (t > 1) t -= 1
|
|
52
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t
|
|
53
|
+
if (t < 1 / 2) return q
|
|
54
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
|
|
55
|
+
return p
|
|
56
|
+
}
|
|
57
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
|
58
|
+
const p = 2 * l - q
|
|
59
|
+
r = hue2rgb(p, q, h + 1 / 3)
|
|
60
|
+
g = hue2rgb(p, q, h)
|
|
61
|
+
b = hue2rgb(p, q, h - 1 / 3)
|
|
62
|
+
}
|
|
63
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Color {
|
|
67
|
+
constructor(input) {
|
|
68
|
+
this.rgba = parse(input).rgba
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
setOpacity(alpha) {
|
|
72
|
+
this.rgba[3] = alpha
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get(format = 'rgba') {
|
|
77
|
+
const [r, g, b, a] = this.rgba
|
|
78
|
+
if (format === 'rgb')
|
|
79
|
+
return `rgb(${r},${g},${b})`
|
|
80
|
+
return `rgba(${r},${g},${b},${a})`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
brighten(amount) {
|
|
84
|
+
for (let i = 0; i < 3; i++) {
|
|
85
|
+
this.rgba[i] = Math.max(0, Math.min(255, Math.round(this.rgba[i] + 255 * amount)))
|
|
86
|
+
}
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {merge} from './utilities'
|
|
2
|
+
|
|
3
|
+
//Engine baseline defaults. The explorer theme (applied via setOptions in chart-default-options.js)
|
|
4
|
+
//deep-merges on top of this, and per-chart options on top of that.
|
|
5
|
+
const defaultOptions = {
|
|
6
|
+
chart: {
|
|
7
|
+
width: null,
|
|
8
|
+
height: null,
|
|
9
|
+
spacing: [10, 10, 15, 10],
|
|
10
|
+
backgroundColor: null,
|
|
11
|
+
style: {fontFamily: 'sans-serif'}
|
|
12
|
+
},
|
|
13
|
+
colors: ['#f5a04f', '#08B5E5', '#46c266', '#a25fb0', '#0ee0e0', '#a6664f'],
|
|
14
|
+
title: {text: '', style: {fontSize: '17px'}},
|
|
15
|
+
xAxis: {type: 'linear', gridLineWidth: 1, tickLength: 5},
|
|
16
|
+
yAxis: {gridLineWidth: 1, tickLength: 0},
|
|
17
|
+
tooltip: {enabled: true, shared: true, split: false},
|
|
18
|
+
legend: {enabled: true, symbolWidth: 12, itemDistance: 20},
|
|
19
|
+
plotOptions: {
|
|
20
|
+
series: {lineWidth: 2, marker: {enabled: false, radius: 3}},
|
|
21
|
+
line: {},
|
|
22
|
+
spline: {},
|
|
23
|
+
area: {},
|
|
24
|
+
column: {pointPadding: 0.1, groupPadding: 0.1, borderWidth: 0}
|
|
25
|
+
},
|
|
26
|
+
series: []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let globalOptions = merge(defaultOptions)
|
|
30
|
+
|
|
31
|
+
export function getOptions() {
|
|
32
|
+
return globalOptions
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setOptions(options) {
|
|
36
|
+
globalOptions = merge(globalOptions, options)
|
|
37
|
+
return globalOptions
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export {defaultOptions}
|