@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,155 @@
1
+ //Low-level SVG drawing layer — the single place that touches the DOM.
2
+ //Everything above this works in abstract element wrappers.
3
+
4
+ const SVG_NS = 'http://www.w3.org/2000/svg'
5
+
6
+ export class SvgElement {
7
+ constructor(nodeName) {
8
+ this.element = document.createElementNS(SVG_NS, nodeName)
9
+ }
10
+
11
+ attr(key, value) {
12
+ if (typeof key === 'object') {
13
+ for (const k of Object.keys(key))
14
+ this.attr(k, key[k])
15
+ return this
16
+ }
17
+ if (value === undefined || value === null)
18
+ return this
19
+ //colors and fonts go through the style channel so CSS custom properties (var(--x)) resolve
20
+ if ((key === 'fill' || key === 'stroke') && /var\(|inherit|currentColor/.test(String(value))) {
21
+ this.element.style[key] = value
22
+ } else {
23
+ this.element.setAttribute(key, value)
24
+ }
25
+ return this
26
+ }
27
+
28
+ css(styles) {
29
+ Object.assign(this.element.style, styles)
30
+ return this
31
+ }
32
+
33
+ addClass(name) {
34
+ this.element.setAttribute('class', ((this.element.getAttribute('class') || '') + ' ' + name).trim())
35
+ return this
36
+ }
37
+
38
+ text(content) {
39
+ this.element.textContent = content
40
+ return this
41
+ }
42
+
43
+ html(content) {
44
+ this.element.innerHTML = content
45
+ return this
46
+ }
47
+
48
+ on(event, handler) {
49
+ this.element.addEventListener(event, handler)
50
+ return this
51
+ }
52
+
53
+ add(parent) {
54
+ (parent ? parent.element : null)?.appendChild(this.element)
55
+ return this
56
+ }
57
+
58
+ getBBox() {
59
+ try {
60
+ return this.element.getBBox()
61
+ } catch (e) {
62
+ return {x: 0, y: 0, width: 0, height: 0}
63
+ }
64
+ }
65
+
66
+ destroy() {
67
+ if (this.element.parentNode)
68
+ this.element.parentNode.removeChild(this.element)
69
+ this.element = null
70
+ }
71
+ }
72
+
73
+ export class SvgRenderer {
74
+ constructor(container, width, height) {
75
+ this.container = container
76
+ this.root = new SvgElement('svg')
77
+ this.root.attr({version: '1.1', class: 'ix-chart-root'})
78
+ this.root.css({display: 'block', overflow: 'visible'})
79
+ this.setSize(width, height)
80
+ container.appendChild(this.root.element)
81
+ this.defs = this.element('defs').add(this.root)
82
+ }
83
+
84
+ setSize(width, height) {
85
+ this.width = width
86
+ this.height = height
87
+ this.root.attr({width, height, viewBox: `0 0 ${width} ${height}`})
88
+ }
89
+
90
+ element(nodeName) {
91
+ return new SvgElement(nodeName)
92
+ }
93
+
94
+ group(className) {
95
+ const g = this.element('g')
96
+ if (className)
97
+ g.attr('class', className)
98
+ return g
99
+ }
100
+
101
+ path(d, attrs) {
102
+ const p = this.element('path').attr('d', Array.isArray(d) ? d.join(' ') : d)
103
+ p.attr({fill: 'none', ...attrs})
104
+ return p
105
+ }
106
+
107
+ rect(x, y, width, height, attrs) {
108
+ return this.element('rect').attr({x, y, width: Math.max(0, width), height: Math.max(0, height), ...attrs})
109
+ }
110
+
111
+ line(x1, y1, x2, y2, attrs) {
112
+ return this.element('line').attr({x1, y1, x2, y2, ...attrs})
113
+ }
114
+
115
+ circle(cx, cy, r, attrs) {
116
+ return this.element('circle').attr({cx, cy, r, ...attrs})
117
+ }
118
+
119
+ label(content, x, y, attrs) {
120
+ const t = this.element('text').attr({x, y, ...attrs})
121
+ t.text(content)
122
+ return t
123
+ }
124
+
125
+ /**
126
+ * Create a linear gradient in <defs> and return a `url(#id)` reference.
127
+ * @param {{x1,y1,x2,y2}} vector - direction in object bounding box units (0..1)
128
+ * @param {[number, string][]} stops - [offset, color] pairs
129
+ * @param {string} id
130
+ */
131
+ linearGradient(vector, stops, id) {
132
+ const grad = this.element('linearGradient').attr({
133
+ id,
134
+ x1: vector.x1, y1: vector.y1, x2: vector.x2, y2: vector.y2
135
+ })
136
+ for (const [offset, col] of stops) {
137
+ this.element('stop').attr({offset, 'stop-color': col}).add(grad)
138
+ }
139
+ grad.add(this.defs)
140
+ return `url(#${id})`
141
+ }
142
+
143
+ clipRect(x, y, width, height, id) {
144
+ const clip = this.element('clipPath').attr('id', id)
145
+ this.rect(x, y, width, height).add(clip)
146
+ clip.add(this.defs)
147
+ return `url(#${id})`
148
+ }
149
+
150
+ destroy() {
151
+ if (this.root && this.root.element && this.root.element.parentNode)
152
+ this.root.element.parentNode.removeChild(this.root.element)
153
+ this.root = null
154
+ }
155
+ }
@@ -0,0 +1,129 @@
1
+ //UTC date math, datetime tick generation and label formatting.
2
+ //The explorer theme uses time.useUTC = true, so everything here is UTC-based.
3
+
4
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5
+ const MONTHS_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
6
+
7
+ const sec = 1000
8
+ const min = 60 * sec
9
+ const hour = 60 * min
10
+ const day = 24 * hour
11
+ const week = 7 * day
12
+
13
+ function pad(n, len = 2) {
14
+ return String(n).padStart(len, '0')
15
+ }
16
+
17
+ /**
18
+ * Minimal time-style formatter (UTC) covering the tokens the explorer uses.
19
+ */
20
+ export function dateFormat(format, timestamp) {
21
+ const d = new Date(timestamp)
22
+ const replacements = {
23
+ Y: d.getUTCFullYear(),
24
+ y: pad(d.getUTCFullYear() % 100),
25
+ m: pad(d.getUTCMonth() + 1),
26
+ b: MONTHS[d.getUTCMonth()],
27
+ B: MONTHS_FULL[d.getUTCMonth()],
28
+ d: pad(d.getUTCDate()),
29
+ e: d.getUTCDate(),
30
+ H: pad(d.getUTCHours()),
31
+ M: pad(d.getUTCMinutes()),
32
+ S: pad(d.getUTCSeconds())
33
+ }
34
+ return format.replace(/%([A-Za-z])/g, (m, t) => (replacements[t] !== undefined ? replacements[t] : m))
35
+ }
36
+
37
+ //candidate time units with their length in ms and allowed multiples
38
+ const UNITS = [
39
+ {name: 'millisecond', ms: 1, multiples: [1, 2, 5, 10, 20, 50, 100, 200, 500]},
40
+ {name: 'second', ms: sec, multiples: [1, 2, 5, 10, 15, 30]},
41
+ {name: 'minute', ms: min, multiples: [1, 2, 5, 10, 15, 30]},
42
+ {name: 'hour', ms: hour, multiples: [1, 2, 3, 4, 6, 8, 12]},
43
+ {name: 'day', ms: day, multiples: [1, 2]},
44
+ {name: 'week', ms: week, multiples: [1, 2]},
45
+ {name: 'month', ms: 30 * day, multiples: [1, 2, 3, 4, 6]},
46
+ {name: 'year', ms: 365 * day, multiples: [1, 2, 5, 10, 25, 50, 100]}
47
+ ]
48
+
49
+ /**
50
+ * Choose an appropriate datetime tick unit and produce calendar-aligned UTC tick positions.
51
+ * @param {number} minTs
52
+ * @param {number} maxTs
53
+ * @param {number} targetCount - desired number of ticks
54
+ * @return {{positions: number[], unitName: string, count: number}}
55
+ */
56
+ export function getDateTimeTickPositions(minTs, maxTs, targetCount) {
57
+ const range = maxTs - minTs
58
+ const approx = range / Math.max(1, targetCount)
59
+ //pick unit + multiple whose interval is closest to (>=) approx
60
+ let chosen = UNITS[UNITS.length - 1]
61
+ let chosenCount = chosen.multiples[chosen.multiples.length - 1]
62
+ outer: for (const unit of UNITS) {
63
+ for (const mult of unit.multiples) {
64
+ if (unit.ms * mult >= approx) {
65
+ chosen = unit
66
+ chosenCount = mult
67
+ break outer
68
+ }
69
+ }
70
+ }
71
+
72
+ const positions = []
73
+ const unitName = chosen.name
74
+
75
+ if (unitName === 'year') {
76
+ const startYear = new Date(minTs).getUTCFullYear()
77
+ const endYear = new Date(maxTs).getUTCFullYear()
78
+ const step = chosenCount
79
+ let y = Math.floor(startYear / step) * step
80
+ for (; y <= endYear + step; y += step) {
81
+ positions.push(Date.UTC(y, 0, 1))
82
+ }
83
+ } else if (unitName === 'month') {
84
+ const start = new Date(minTs)
85
+ let y = start.getUTCFullYear()
86
+ let m = Math.floor(start.getUTCMonth() / chosenCount) * chosenCount
87
+ let ts = Date.UTC(y, m, 1)
88
+ while (ts <= maxTs + chosen.ms * chosenCount) {
89
+ positions.push(ts)
90
+ m += chosenCount
91
+ ts = Date.UTC(y, m, 1)
92
+ }
93
+ } else {
94
+ const interval = chosen.ms * chosenCount
95
+ //align to the unit boundary (start of day for day/week, etc.)
96
+ let start = Math.floor(minTs / interval) * interval
97
+ if (unitName === 'week') {
98
+ //align to Monday
99
+ const d = new Date(start)
100
+ const dow = (d.getUTCDay() + 6) % 7
101
+ start -= dow * day
102
+ }
103
+ for (let ts = start; ts <= maxTs + interval; ts += interval) {
104
+ positions.push(ts)
105
+ }
106
+ }
107
+
108
+ return {positions, unitName, count: chosenCount}
109
+ }
110
+
111
+ //default label format per unit
112
+ export function dateTimeLabelFormat(unitName) {
113
+ switch (unitName) {
114
+ case 'year':
115
+ return '%Y'
116
+ case 'month':
117
+ return "%b '%y"
118
+ case 'week':
119
+ case 'day':
120
+ return '%e %b'
121
+ case 'hour':
122
+ case 'minute':
123
+ return '%H:%M'
124
+ case 'second':
125
+ return '%H:%M:%S'
126
+ default:
127
+ return '%H:%M:%S'
128
+ }
129
+ }
@@ -0,0 +1,92 @@
1
+ //Small shared helpers. Zero-dependency.
2
+
3
+ export function isObject(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
5
+ }
6
+
7
+ export function isNumber(value) {
8
+ return typeof value === 'number' && !isNaN(value)
9
+ }
10
+
11
+ export function defined(value) {
12
+ return value !== undefined && value !== null
13
+ }
14
+
15
+ export function pick(...args) {
16
+ for (const arg of args)
17
+ if (defined(arg))
18
+ return arg
19
+ return undefined
20
+ }
21
+
22
+ /**
23
+ * Deep merge: plain objects are merged recursively,
24
+ * everything else (arrays, class instances, primitives) is copied by assignment (replaced).
25
+ * Pass `true` as the first argument to merge into a fresh object (deep clone of sources).
26
+ * @param {...*} sources
27
+ * @return {{}}
28
+ */
29
+ export function merge(...sources) {
30
+ let i = 0
31
+ if (sources[0] === true) {
32
+ i = 1
33
+ }
34
+ const result = {}
35
+ function doMerge(target, source) {
36
+ if (!isObject(source))
37
+ return target
38
+ for (const key of Object.keys(source)) {
39
+ const val = source[key]
40
+ if (isObject(val)) {
41
+ target[key] = doMerge(isObject(target[key]) ? target[key] : {}, val)
42
+ } else {
43
+ target[key] = Array.isArray(val) ? val.slice() : val
44
+ }
45
+ }
46
+ return target
47
+ }
48
+ for (; i < sources.length; i++) {
49
+ doMerge(result, sources[i])
50
+ }
51
+ return result
52
+ }
53
+
54
+ /**
55
+ * Clamp a number into the [min, max] range.
56
+ */
57
+ export function clamp(value, min, max) {
58
+ return value < min ? min : (value > max ? max : value)
59
+ }
60
+
61
+ /**
62
+ * Resolve a percentage/absolute value against a total ('80%' -> 0.8*total, 40 -> 40).
63
+ */
64
+ export function relativeLength(value, total, offset = 0) {
65
+ if (typeof value === 'string' && value.endsWith('%'))
66
+ return parseFloat(value) / 100 * total + offset
67
+ return (parseFloat(value) || 0) + offset
68
+ }
69
+
70
+ /**
71
+ * Insert thousands separators into the integer part of a number/string ('1234.5' -> '1,234.5').
72
+ */
73
+ export function addThousandsSep(value) {
74
+ const parts = String(value).split('.')
75
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
76
+ return parts.join('.')
77
+ }
78
+
79
+ let idCounter = 0
80
+
81
+ export function uniqueId(prefix = 'svg') {
82
+ return prefix + (++idCounter)
83
+ }
84
+
85
+ /**
86
+ * Resolve a DOM element from an id string or an element reference.
87
+ */
88
+ export function resolveContainer(renderTo) {
89
+ if (typeof renderTo === 'string')
90
+ return document.getElementById(renderTo)
91
+ return renderTo
92
+ }
@@ -0,0 +1,32 @@
1
+ //The engine namespace exposed to consumers and to the theme/options-builder — the small public
2
+ //surface (chart constructors, options, Color, Axis, merge) that views and modules touch.
3
+ import {Chart} from './core/chart'
4
+ import {getOptions, setOptions} from './core/options'
5
+ import {Color} from './core/color'
6
+ import {Axis} from './axis/axis'
7
+ import {merge} from './core/utilities'
8
+
9
+ function ChartCtor(renderTo, options) {
10
+ return new Chart(renderTo, options, 'Chart')
11
+ }
12
+
13
+ function StockChartCtor(renderTo, options) {
14
+ return new Chart(renderTo, options, 'StockChart')
15
+ }
16
+
17
+ const ChartEngine = {
18
+ Chart: ChartCtor,
19
+ StockChart: StockChartCtor,
20
+ chart: (renderTo, options) => new Chart(renderTo, options, 'Chart'),
21
+ stockChart: (renderTo, options) => new Chart(renderTo, options, 'StockChart'),
22
+ setOptions,
23
+ getOptions,
24
+ Color: input => new Color(input),
25
+ Axis,
26
+ merge,
27
+ //module-extension hook — a module is a `module(engine)` function that augments this namespace
28
+ win: typeof window !== 'undefined' ? window : undefined
29
+ }
30
+
31
+ export {ChartEngine}
32
+ export default ChartEngine
@@ -0,0 +1,4 @@
1
+ //Pure-JS SVG charting library — public entry point.
2
+ //Props: options/type/title/grouped/range/noLegend/inline/modules/children.
3
+ export {default} from './chart'
4
+ export {ChartEngine, ChartLoader} from './chart'
@@ -0,0 +1,125 @@
1
+ //Bottom-centered horizontal legend with click-to-toggle series visibility; wraps to multiple rows when narrow.
2
+ const FONT = 12
3
+ const LINE_HEIGHT = 16
4
+
5
+ export class Legend {
6
+ constructor(chart) {
7
+ this.chart = chart
8
+ }
9
+
10
+ //estimated height reserved during layout — grows when the row wraps on narrow charts
11
+ getHeight() {
12
+ if (!this.chart.options.legend.enabled)
13
+ return 0
14
+ const items = this.chart.series.filter(s => s.name)
15
+ if (!items.length)
16
+ return 0
17
+ const symbolW = 12, gap = 8, itemGap = 20
18
+ //estimate available width (plotWidth not final on first layout → fall back to chartWidth)
19
+ const availW = (this.chart.plotWidth || (this.chart.chartWidth || 600) - 90)
20
+ const widths = items.map(s => symbolW + gap + Math.ceil(String(s.name).length * FONT * 0.5))
21
+ let rows = 1, curW = 0
22
+ for (const w of widths) {
23
+ const add = (curW ? itemGap : 0) + w
24
+ if (curW && curW + add > availW) {
25
+ rows++
26
+ curW = w
27
+ } else {
28
+ curW += add
29
+ }
30
+ }
31
+ return 24 + rows * LINE_HEIGHT
32
+ }
33
+
34
+ render(group) {
35
+ const chart = this.chart
36
+ if (!chart.options.legend.enabled)
37
+ return
38
+ const items = chart.series.filter(s => s.name).slice().sort((a, b) => a.zIndex - b.zIndex)
39
+ if (!items.length)
40
+ return
41
+ const renderer = chart.renderer
42
+ const symbolW = 12
43
+ const gap = 8
44
+ const itemGap = 20
45
+
46
+ //pass 1 — build each item (symbol + label) at local origin (baseline y=0) and measure the REAL
47
+ //text width via getBBox. A char-count estimate over-shoots for the condensed font.
48
+ const built = items.map(s => {
49
+ const color = s.getColor()
50
+ const itemGroup = renderer.group('ix-legend-item').add(group)
51
+ itemGroup.element.style.cursor = 'pointer'
52
+ renderer.circle(symbolW / 2, -FONT / 2 + 1, 5, {})
53
+ .css({fill: color, opacity: s.visible ? 1 : 0.35}).add(itemGroup)
54
+ const label = renderer.label(s.name, symbolW + gap, 0, {'text-anchor': 'start'})
55
+ .css({
56
+ fontSize: FONT + 'px',
57
+ fontFamily: 'Roboto Condensed,sans-serif',
58
+ fill: 'var(--color-text)',
59
+ color: 'var(--color-text)',
60
+ 'text-decoration': s.visible ? 'none' : 'line-through',
61
+ opacity: s.visible ? 1 : 0.5
62
+ }).add(itemGroup)
63
+ let textW
64
+ try {
65
+ textW = label.getBBox().width
66
+ } catch (e) {
67
+ textW = Math.ceil(String(s.name).length * FONT * 0.5)
68
+ }
69
+ itemGroup.on('click', () => {
70
+ s.visible = !s.visible
71
+ chart.redraw()
72
+ })
73
+ //hover highlights this series (dims the others) and tints the hovered legend text —
74
+ //but only for visible series: hovering a hidden item must not dim/hide the visible ones
75
+ itemGroup.on('mouseover', () => {
76
+ if (!s.visible)
77
+ return
78
+ label.css({fill: 'var(--color-primary)', color: 'var(--color-primary)'})
79
+ for (const other of chart.series)
80
+ if (other.group)
81
+ other.group.element.style.opacity = other === s ? '1' : '0.25'
82
+ })
83
+ itemGroup.on('mouseout', () => {
84
+ if (!s.visible)
85
+ return
86
+ label.css({fill: 'var(--color-text)', color: 'var(--color-text)'})
87
+ for (const other of chart.series)
88
+ if (other.group)
89
+ other.group.element.style.opacity = '1'
90
+ })
91
+ return {itemGroup, w: symbolW + gap + textW}
92
+ })
93
+
94
+ //pass 2 — wrap items into rows that fit the plot width (flexible / responsive)
95
+ const availW = chart.plotWidth
96
+ const rows = []
97
+ let cur = [], curW = 0
98
+ for (const b of built) {
99
+ const add = (cur.length ? itemGap : 0) + b.w
100
+ if (cur.length && curW + add > availW) {
101
+ rows.push({items: cur, w: curW})
102
+ cur = []
103
+ curW = 0
104
+ }
105
+ curW += (cur.length ? itemGap : 0) + b.w
106
+ cur.push(b)
107
+ }
108
+ if (cur.length)
109
+ rows.push({items: cur, w: curW})
110
+
111
+ //pass 3 — rows are LEFT-aligned to a common left edge, and the whole block is
112
+ //centred under the plot by its widest row; rows stack upward from the bottom baseline
113
+ const bottomBaseline = chart.chartHeight - chart.spacing[2] - 6
114
+ const maxRowW = rows.reduce((m, r) => Math.max(m, r.w), 0)
115
+ const blockLeft = chart.plotLeft + Math.max(0, (chart.plotWidth - maxRowW) / 2)
116
+ rows.forEach((row, ri) => {
117
+ const baseline = bottomBaseline - (rows.length - 1 - ri) * LINE_HEIGHT
118
+ let x = blockLeft
119
+ for (const b of row.items) {
120
+ b.itemGroup.attr('transform', `translate(${x}, ${baseline})`)
121
+ x += b.w + itemGap
122
+ }
123
+ })
124
+ }
125
+ }