@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.
@@ -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}