@stellar-expert/ui-framework 1.19.1 → 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,158 @@
1
+ //StellarExpert explorer chart theme — colors, fonts and per-type defaults applied on top of the
2
+ //engine baseline via setOptions, plus the custom logarithmic axis scaling used by balance/distribution charts.
3
+ import ChartEngine from './globals'
4
+
5
+ //allow negative values on the logarithmic axis
6
+ ChartEngine.Axis.prototype.allowNegativeLog = true
7
+
8
+ //override conversions (custom log scaling used by account-balance / distribution charts)
9
+ ChartEngine.Axis.prototype.log2lin = function (num) {
10
+ let adjustedNum = Math.abs(num)
11
+ if (adjustedNum < 10) {
12
+ adjustedNum += (10 - adjustedNum) / 10
13
+ }
14
+ const result = Math.log(adjustedNum) / Math.LN10
15
+ return num < 0 ? -result : result
16
+ }
17
+ ChartEngine.Axis.prototype.lin2log = function (num) {
18
+ let result = Math.pow(10, Math.abs(num))
19
+ if (result < 10) {
20
+ result = (10 * (result - 1)) / (10 - 1)
21
+ }
22
+ return num < 0 ? -result : result
23
+ }
24
+
25
+ const defaultTextStyle = {
26
+ fontSize: '12px',
27
+ fontFamily: 'Roboto Condensed,sans-serif',
28
+ fontWeight: 400,
29
+ color: 'var(--color-text)'
30
+ }
31
+
32
+ const primaryColor = '#08B5E5'
33
+
34
+ const theme = ChartEngine.theme = {
35
+ colors: ['hsl(193,93%,46%)', 'hsl(27,93%,66%)', 'hsl(130,60%,46%)', 'hsl(290,40%,46%)',
36
+ 'hsl(180,93%,46%)', 'hsl(13,40%,46%)', 'hsl(115,40%,46%)', 'hsl(270,40%,66%)',
37
+ 'hsl(155,60%,46%)', 'hsl(0,40%,66%)', 'hsl(100,43%,66%)', 'hsl(250,50%,66%)',
38
+ 'hsl(143,40%,66%)', 'hsl(340,40%,46%)', 'hsl(212,40%,46%)', 'hsl(235,40%,46%)',
39
+ 'hsl(135,60%,40%)', 'hsl(320,50%,66%)'
40
+ ],
41
+ title: {
42
+ text: '',
43
+ style: {...defaultTextStyle, fontSize: '17px'}
44
+ },
45
+ chart: {
46
+ backgroundColor: null,
47
+ zoomType: 'x',
48
+ spacing: [2, 2, 2, 2],
49
+ style: {
50
+ fontFamily: 'sans-serif'
51
+ }
52
+ },
53
+ scrollbar: {
54
+ enabled: false
55
+ },
56
+ tooltip: {
57
+ shared: true,
58
+ split: false,
59
+ borderWidth: 0,
60
+ headerFormat: '<span>{point.key}</span><br/>',
61
+ shadow: false
62
+ },
63
+ legend: {
64
+ itemStyle: defaultTextStyle,
65
+ itemHoverStyle: {color: 'var(--color-highlight)'},
66
+ itemDisabledStyle: {color: 'var(--color-dimmed)'}
67
+ },
68
+ navigator: {
69
+ enabled: false
70
+ },
71
+ credits: {
72
+ enabled: false
73
+ },
74
+ navigation: {
75
+ buttonOptions: {
76
+ symbolFill: 'var(--color-border-shadow)'
77
+ }
78
+ },
79
+ xAxis: {
80
+ type: 'datetime',
81
+ crosshair: true,
82
+ ordinal: false,
83
+ minPadding: 0,
84
+ maxPadding: 0,
85
+ gridLineWidth: 1,
86
+ lineColor: 'var(--color-border-shadow)',
87
+ gridLineColor: 'var(--color-border-shadow)',
88
+ minorGridLineColor: 'var(--color-border-shadow)',
89
+ minorTickColor: 'var(--color-border-shadow)',
90
+ tickColor: 'var(--color-border-shadow)',
91
+ title: {
92
+ style: defaultTextStyle,
93
+ margin: 4
94
+ },
95
+ labels: {
96
+ style: defaultTextStyle
97
+ }
98
+ },
99
+ yAxis: {
100
+ minorTickInterval: 'auto',
101
+ lineColor: 'var(--color-border-shadow)',
102
+ gridLineColor: 'var(--color-border-shadow)',
103
+ minorGridLineColor: 'var(--color-border-shadow)',
104
+ minorTickColor: 'var(--color-border-shadow)',
105
+ tickColor: 'var(--color-border-shadow)',
106
+ title: {
107
+ style: defaultTextStyle
108
+ },
109
+ labels: {
110
+ style: defaultTextStyle
111
+ }
112
+ },
113
+ plotOptions: {
114
+ candlestick: {
115
+ upColor: 'var(--color-price-up)',
116
+ color: 'var(--color-price-down)',
117
+ lineColor: 'var(--color-dimmed)',
118
+ lineWidth: 0.6
119
+ },
120
+ area: {
121
+ fillColor: {
122
+ linearGradient: {
123
+ x1: 0,
124
+ y1: 0,
125
+ x2: 0,
126
+ y2: 1
127
+ },
128
+ stops: [
129
+ [0, ChartEngine.Color(primaryColor).setOpacity(0.8).get('rgba')],
130
+ [1, ChartEngine.Color(primaryColor).setOpacity(0).get('rgba')]
131
+ ]
132
+ },
133
+ lineWidth: 2,
134
+ states: {
135
+ hover: {
136
+ lineWidth: 2
137
+ }
138
+ }
139
+ },
140
+ series: {
141
+ pointPadding: 0.1,
142
+ groupPadding: 0.1
143
+ }
144
+ },
145
+ time: {
146
+ useUTC: true
147
+ },
148
+ background2: '#F0F0EA',
149
+ lang: {
150
+ decimalPoint: '.',
151
+ thousandsSep: ','
152
+ }
153
+ }
154
+
155
+ //apply the theme
156
+ ChartEngine.setOptions(theme)
157
+
158
+ export {ChartEngine}
@@ -0,0 +1,26 @@
1
+ import React from 'react'
2
+ import cn from 'classnames'
3
+
4
+ const placeholderBars = [30, 60, 42, 40, 70, 62, 80, 83]
5
+
6
+ export function ChartLoader({title, unavailable = false, container}) {
7
+ return <div className={cn('chart', container !== undefined ? container : 'segment blank')}>
8
+ {!!title && <>
9
+ <h3>{title}</h3>
10
+ <hr className="flare"/>
11
+ </>}
12
+ <div className="chart-placeholder">
13
+ {placeholderBars.map(v => <div key={v} style={{height: v + '%'}}/>)}
14
+ </div>
15
+ <div className="v-center-block">
16
+ {unavailable ?
17
+ <div className="text-center dimmed">
18
+ <span className="icon icon-minus-circle"/> Data unavailable
19
+ </div> :
20
+ <div className="text-center">
21
+ <div className="loader"/>
22
+ <div className="text-small dimmed text-center" style={{marginTop: '-2em'}}>Loading chart data...</div>
23
+ </div>}
24
+ </div>
25
+ </div>
26
+ }
@@ -0,0 +1,81 @@
1
+ //Normalizes per-chart options before they reach the engine: applies grouping/range/legend flags and
2
+ //registers optional engine modules. Uses the engine's own merge (no deepmerge dependency).
3
+ import {merge} from './core/utilities'
4
+ import {ChartEngine} from './chart-default-options'
5
+
6
+ const rangeSelectorOptions = {
7
+ //the in-house range selector renders span controls styled via chart.scss (.chart-zoom-btn);
8
+ //only the button definitions are consumed by the engine
9
+ buttons: [
10
+ {type: 'month', count: 1, text: '1m'},
11
+ {type: 'month', count: 3, text: '3m'},
12
+ {type: 'month', count: 6, text: '6m'},
13
+ {type: 'year', count: 1, text: '1y'},
14
+ {type: 'all', text: 'All'}
15
+ ]
16
+ }
17
+
18
+ const groupingUnits = [
19
+ ['day', [1, 3]],
20
+ ['week', [1, 2]],
21
+ ['month', [1, 2, 6]]
22
+ ]
23
+
24
+ const groupedPlotOptions = {
25
+ series: {
26
+ dataGrouping: {
27
+ units: groupingUnits,
28
+ //17 (not 16) so the bucket interval clears the weekly/bi-weekly rounding boundary at the 1y
29
+ //view — keeps the 1y window on monthly buckets instead of tipping to weekly
30
+ groupPixelWidth: 17
31
+ }
32
+ }
33
+ }
34
+
35
+ const activeModules = new Set()
36
+
37
+ function extendWithModules(modules) {
38
+ if (!modules)
39
+ return
40
+ for (const module of modules)
41
+ if (!activeModules.has(module)) {
42
+ module(ChartEngine)
43
+ activeModules.add(module)
44
+ }
45
+ }
46
+
47
+ export function prepareChartOptions(externalOptions, {modules, noLegend, grouped, range, inline}) {
48
+ extendWithModules(modules)
49
+ const extraOptions = {}
50
+ extraOptions.legend = {enabled: !noLegend}
51
+ //inline sparklines have no axis margins, so give both axes a little fractional padding: vertical keeps
52
+ //the line stroke off the top/bottom edges, horizontal insets the line so it isn't flush against the
53
+ //surrounding label/edges (deep-merged, so per-chart axis settings are preserved)
54
+ if (inline) {
55
+ extraOptions.xAxis = {minPadding: 0.1, maxPadding: 0.05}
56
+ extraOptions.yAxis = {minPadding: 0.12, maxPadding: 0.12}
57
+ }
58
+ if (grouped) {
59
+ extraOptions.plotOptions = groupedPlotOptions
60
+ }
61
+ if (range) {
62
+ extraOptions.rangeSelector = rangeSelectorOptions
63
+ if (typeof range === 'string') {
64
+ const now = new Date()
65
+ const period = new Date(now.valueOf())
66
+ switch (range) {
67
+ case 'year':
68
+ period.setUTCFullYear(period.getUTCFullYear() - 1)
69
+ extraOptions.xAxis = {range: now - period}
70
+ break
71
+ case 'month':
72
+ period.setUTCMonth(period.getUTCMonth() - 1)
73
+ extraOptions.xAxis = {range: now - period}
74
+ break
75
+ default:
76
+ throw new TypeError('Invalid date range for a chart: ' + range)
77
+ }
78
+ }
79
+ }
80
+ return merge(externalOptions, extraOptions)
81
+ }
@@ -0,0 +1,99 @@
1
+ import React, {useRef} from 'react'
2
+ import cn from 'classnames'
3
+ import PropTypes from 'prop-types'
4
+ import {useDeepEffect} from '../state/state-hooks'
5
+ import {withErrorBoundary} from '../errors/error-boundary'
6
+ import {ChartEngine} from './chart-default-options'
7
+ import {prepareChartOptions} from './chart-options-builder'
8
+ import {ChartLoader} from './chart-loader'
9
+ import './chart.scss'
10
+
11
+ export {ChartEngine, ChartLoader}
12
+
13
+ /**
14
+ * Pure-JS SVG charting component (zero external dependencies)
15
+ * @param {Object} props
16
+ * @param {{}} props.options - Chart data and options
17
+ * @param {'Chart'|'StockChart'} [props.type] - Chart type (`StockChart` adds navigator/range-selector support)
18
+ * @param {String|JSX.Element} [props.title] - Chart title rendered above the plot
19
+ * @param {Boolean} [props.inline] - Render the chart as inline-block (for sparkline charts)
20
+ * @param {Boolean} [props.grouped] - Apply data grouping (downsample dense series into time buckets)
21
+ * @param {true|false|'year'|'month'} [props.range] - Enable the range selector / initial visible window
22
+ * @param {Boolean} [props.noLegend] - Hide the legend section
23
+ * @param {String} [props.container] - Container CSS class (defaults to `segment blank`)
24
+ * @param {String} [props.className] - Additional CSS classes
25
+ * @param {{}} [props.style] - Additional inline styles
26
+ * @param {Function[]} [props.modules] - Additional engine modules to register (`module(engine)`)
27
+ * @param {*} [props.children] - Optional content added to the chart header
28
+ * @constructor
29
+ */
30
+ export default function Chart({
31
+ options,
32
+ type = 'Chart',
33
+ title,
34
+ inline,
35
+ grouped,
36
+ range,
37
+ noLegend,
38
+ container = 'segment blank',
39
+ className,
40
+ style,
41
+ modules,
42
+ children
43
+ }) {
44
+ const chart = useRef(null)
45
+ const chartIdRef = useRef(`ixchart${Math.floor(Math.random() * 0x10000000000000)}`)
46
+
47
+ //deep-compare deps so a re-render with a freshly-built (but equal) options object doesn't tear down
48
+ //and recreate the chart — only real option/type changes rebuild it
49
+ useDeepEffect(() => {
50
+ if (chart.current) {
51
+ chart.current.destroy()
52
+ chart.current = null
53
+ }
54
+ if (!options)
55
+ return
56
+ const mergedOptions = prepareChartOptions(options, {grouped, range, noLegend, modules, inline})
57
+ chart.current = new ChartEngine[type](chartIdRef.current, mergedOptions)
58
+ return () => {
59
+ if (chart.current) {
60
+ chart.current.destroy()
61
+ chart.current = null
62
+ }
63
+ }
64
+ }, [options, modules, type, inline, title])
65
+
66
+ if (!options)
67
+ return <ChartLoader title={title}/>
68
+
69
+ const containerStyle = {...style}
70
+ if (inline) {
71
+ containerStyle.display = 'inline-block'
72
+ return <div id={chartIdRef.current} style={containerStyle}/>
73
+ }
74
+ return <div className={cn('chart', container, className)} style={containerStyle}>
75
+ {!!title && <h3>{title}</h3>}
76
+ {children}
77
+ <hr className="flare"/>
78
+ <div className="v-center-block">
79
+ <div id={chartIdRef.current}/>
80
+ </div>
81
+ </div>
82
+ }
83
+
84
+ Chart.propTypes = {
85
+ options: PropTypes.object,
86
+ type: PropTypes.oneOf(['StockChart', 'Chart']),
87
+ title: PropTypes.any,
88
+ noLegend: PropTypes.bool,
89
+ grouped: PropTypes.bool,
90
+ range: PropTypes.oneOf([true, false, 'year', 'month']),
91
+ inline: PropTypes.bool,
92
+ container: PropTypes.string,
93
+ className: PropTypes.string,
94
+ style: PropTypes.object,
95
+ modules: PropTypes.arrayOf(PropTypes.func)
96
+ }
97
+
98
+ Chart.Loader = ChartLoader
99
+ Chart.withErrorBoundary = withErrorBoundary
@@ -0,0 +1,80 @@
1
+ .chart {
2
+ position: relative;
3
+ z-index: 0;
4
+ min-height: 30em;
5
+
6
+ .v-center-block {
7
+ min-height: 26em;
8
+ }
9
+ }
10
+
11
+ //floating zoom/range selector — self-contained styling owned by the charts lib. Controls are spans
12
+ //(not <button>/.button) so they never inherit the global project button look; only the overlay's
13
+ //horizontal offset (dynamic plotLeft) is set inline in range-selector.js
14
+ .chart-range-selector {
15
+ position: absolute;
16
+ top: 2px;
17
+ z-index: 5;
18
+ display: flex;
19
+ gap: 3px;
20
+ align-items: center;
21
+ font: 11px 'Roboto Condensed', sans-serif;
22
+
23
+ .zoom-label {
24
+ opacity: 0.6;
25
+ margin-right: 3px;
26
+ }
27
+
28
+ .chart-zoom-btn {
29
+ display: inline-block;
30
+ border-radius: 3px;
31
+ padding: 2px 8px;
32
+ font: 12px 'Roboto Condensed', sans-serif;
33
+ line-height: 1.4;
34
+ background: rgba(176, 185, 189, 0.1);
35
+ color: var(--color-text);
36
+ cursor: pointer;
37
+ user-select: none;
38
+
39
+ &:hover {
40
+ background: rgba(176, 185, 189, 0.25);
41
+ }
42
+
43
+ &.selected {
44
+ background: rgb(83, 87, 89);
45
+ font-weight: 700;
46
+ }
47
+
48
+ &.disabled {
49
+ color: var(--color-dimmed);
50
+ opacity: 0.5;
51
+ cursor: default;
52
+ background: rgba(176, 185, 189, 0.1);
53
+ }
54
+ }
55
+ }
56
+
57
+ @media (max-width: 420px) {
58
+ .chart-range-selector .chart-zoom-btn {
59
+ padding: 1px 6px;
60
+ font-size: 11px;
61
+ }
62
+ }
63
+
64
+ .chart-placeholder {
65
+ position: absolute;
66
+ width: 100%;
67
+ height: 100%;
68
+ top: 0;
69
+ left: 0;
70
+ opacity: 0.04;
71
+ display: flex;
72
+ align-items: flex-end;
73
+ padding: $space-standard;
74
+
75
+ & > * {
76
+ background: #000;
77
+ margin: $space-micro;
78
+ width: 100%;
79
+ }
80
+ }
@@ -0,0 +1,99 @@
1
+ //Load/draw animations for the initial chart render (a per-series-type entrance).
2
+ //Only runs once, on first paint — redraws (zoom, legend toggle, resize) skip it.
3
+ import {uniqueId} from './utilities'
4
+
5
+ export const DEFAULT_DURATION = 1500
6
+
7
+ //easeOutCubic — fast start, gentle settle
8
+ function easeOut(t) {
9
+ return 1 - Math.pow(1 - t, 3)
10
+ }
11
+
12
+ const raf = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb => setTimeout(() => cb(now()), 16))
13
+ const now = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : new Date().getTime())
14
+
15
+ /**
16
+ * Drive a 0→1 eased progress value over `duration` ms.
17
+ * @param {number} duration
18
+ * @param {(progress: number) => void} onStep called each frame with eased progress in [0,1]
19
+ * @param {() => void} [onDone]
20
+ */
21
+ export function animate(duration, onStep, onDone) {
22
+ const start = now()
23
+ onStep(0)
24
+ function frame() {
25
+ let t = (now() - start) / duration
26
+ if (t > 1) t = 1
27
+ onStep(easeOut(t))
28
+ if (t < 1)
29
+ raf(frame)
30
+ else if (onDone)
31
+ onDone()
32
+ }
33
+ raf(frame)
34
+ }
35
+
36
+ /**
37
+ * Fill columns from the chart baseline as a single rising level. For a stacked column the whole stack
38
+ * grows from 0: the lower segment reaches its final size first and stops, the upper one keeps rising.
39
+ * @param {{el: import('./svg-renderer').SvgElement, topY: number, botY: number, baselineY: number, colTopY: number}[]} bars
40
+ * topY/botY = this segment's final top/bottom pixel; colTopY = the whole column's top pixel (stack total)
41
+ */
42
+ export function animateColumns(bars, duration = DEFAULT_DURATION) {
43
+ if (!bars.length)
44
+ return
45
+ animate(duration, k => {
46
+ for (const b of bars) {
47
+ //the fill level rises from the baseline up to the full column top
48
+ const levelY = b.baselineY - (b.baselineY - b.colTopY) * k
49
+ //clamp it into this segment's own [topY, botY] band
50
+ const visibleTop = Math.min(b.botY, Math.max(b.topY, levelY))
51
+ b.el.attr({y: visibleTop, height: Math.max(0, b.botY - visibleTop)})
52
+ }
53
+ })
54
+ }
55
+
56
+ /**
57
+ * Reveal a series group left→right by growing a clip rectangle (used for line/area "drawing").
58
+ * @param {import('./chart').Chart} chart
59
+ * @param {import('./svg-renderer').SvgElement} group
60
+ */
61
+ export function revealLeftToRight(chart, group, duration = DEFAULT_DURATION) {
62
+ const r = chart.renderer
63
+ const id = uniqueId('anim-clip')
64
+ const clip = r.element('clipPath').attr('id', id)
65
+ const rect = r.rect(chart.plotLeft, chart.plotTop - 4, 0, chart.plotHeight + 8)
66
+ rect.add(clip)
67
+ clip.add(r.defs)
68
+ group.attr('clip-path', `url(#${id})`)
69
+ animate(duration, k => rect.attr('width', chart.plotWidth * k))
70
+ }
71
+
72
+ /**
73
+ * Draw a line path on left→right by animating its stroke dash offset (used by the navigator overview).
74
+ * @param {import('./svg-renderer').SvgElement} pathEl
75
+ */
76
+ export function animateLineDraw(pathEl, duration = DEFAULT_DURATION) {
77
+ let len
78
+ try {
79
+ len = pathEl.element.getTotalLength()
80
+ } catch (e) {
81
+ return
82
+ }
83
+ if (!len)
84
+ return
85
+ pathEl.attr({'stroke-dasharray': len, 'stroke-dashoffset': len})
86
+ animate(duration, k => pathEl.attr('stroke-dashoffset', len * (1 - k)),
87
+ () => pathEl.attr({'stroke-dasharray': 'none', 'stroke-dashoffset': 0}))
88
+ }
89
+
90
+ /**
91
+ * Grow a group out from a centre point (radar/polar entrance).
92
+ */
93
+ export function animateFromCenter(group, cx, cy, duration = DEFAULT_DURATION) {
94
+ animate(duration, k => {
95
+ const s = Math.max(0.0001, k)
96
+ group.attr('transform', `translate(${cx * (1 - s)} ${cy * (1 - s)}) scale(${s})`)
97
+ }, () => group.attr('transform', ''))
98
+ }
99
+