@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,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
|
package/charts/index.js
ADDED
|
@@ -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
|
+
}
|