@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,185 @@
|
|
|
1
|
+
import {dateFormat} from '../core/time'
|
|
2
|
+
import {isNumber, addThousandsSep} from '../core/utilities'
|
|
3
|
+
|
|
4
|
+
function formatValue(v, tooltipOpts) {
|
|
5
|
+
if (!isNumber(v))
|
|
6
|
+
return '—'
|
|
7
|
+
let s
|
|
8
|
+
if (tooltipOpts && isNumber(tooltipOpts.valueDecimals)) {
|
|
9
|
+
s = v.toFixed(tooltipOpts.valueDecimals)
|
|
10
|
+
} else {
|
|
11
|
+
s = String(Math.round(v * 1000) / 1000)
|
|
12
|
+
}
|
|
13
|
+
s = addThousandsSep(s)
|
|
14
|
+
if (tooltipOpts && tooltipOpts.valuePrefix)
|
|
15
|
+
s = tooltipOpts.valuePrefix + s
|
|
16
|
+
if (tooltipOpts && tooltipOpts.valueSuffix)
|
|
17
|
+
s += tooltipOpts.valueSuffix
|
|
18
|
+
return s
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DAY = 24 * 3600 * 1000
|
|
22
|
+
|
|
23
|
+
//Header for a hovered point: a single instant, or the grouped bucket range ("September-October 2021")
|
|
24
|
+
//when data grouping carries a unit/multiple on the point.
|
|
25
|
+
function pointHeader(point, key, xAxis) {
|
|
26
|
+
if (xAxis.type !== 'datetime')
|
|
27
|
+
return xAxis.categories ? xAxis.categories[key] : key
|
|
28
|
+
const unit = point && point.groupUnit
|
|
29
|
+
const mult = (point && point.groupMult) || 1
|
|
30
|
+
const d = new Date(key)
|
|
31
|
+
if (unit === 'year')
|
|
32
|
+
return mult > 1 ? `${d.getUTCFullYear()}-${d.getUTCFullYear() + mult - 1}` : `${d.getUTCFullYear()}`
|
|
33
|
+
if (unit === 'month') {
|
|
34
|
+
if (mult <= 1)
|
|
35
|
+
return dateFormat('%B %Y', key)
|
|
36
|
+
const end = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + mult - 1, 1)
|
|
37
|
+
return `${dateFormat('%B', key)}-${dateFormat('%B', end)} ${new Date(end).getUTCFullYear()}`
|
|
38
|
+
}
|
|
39
|
+
if ((unit === 'week' || unit === 'day') && mult * (unit === 'week' ? 7 : 1) > 1) {
|
|
40
|
+
const span = (unit === 'week' ? 7 : 1) * mult
|
|
41
|
+
return `${dateFormat('%b %e', key)} - ${dateFormat('%b %e, %Y', key + (span - 1) * DAY)}`
|
|
42
|
+
}
|
|
43
|
+
//single day / sub-day instant
|
|
44
|
+
return dateFormat('%b %e, %Y', key)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Tooltip {
|
|
48
|
+
constructor(chart) {
|
|
49
|
+
this.chart = chart
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
bind() {
|
|
53
|
+
const chart = this.chart
|
|
54
|
+
if (!chart.options.tooltip.enabled)
|
|
55
|
+
return
|
|
56
|
+
this.overlay = chart.renderer.group('ix-tooltip-overlay').add(chart.renderer.root)
|
|
57
|
+
this.box = document.createElement('div')
|
|
58
|
+
Object.assign(this.box.style, {
|
|
59
|
+
position: 'absolute',
|
|
60
|
+
pointerEvents: 'none',
|
|
61
|
+
zIndex: 20,
|
|
62
|
+
display: 'none',
|
|
63
|
+
background: 'rgba(247,247,247,0.85)',
|
|
64
|
+
border: '1px solid var(--color-border-shadow)',
|
|
65
|
+
borderRadius: '3px',
|
|
66
|
+
padding: '5px 8px',
|
|
67
|
+
font: '12px Roboto Condensed,sans-serif',
|
|
68
|
+
color: '#15171a',
|
|
69
|
+
whiteSpace: 'nowrap',
|
|
70
|
+
boxShadow: '0 1px 6px rgba(0,0,0,0.3)'
|
|
71
|
+
})
|
|
72
|
+
chart.container.appendChild(this.box)
|
|
73
|
+
this.svg = chart.renderer.root.element
|
|
74
|
+
this.moveHandler = e => this.onMove(e)
|
|
75
|
+
this.leaveHandler = () => this.hide()
|
|
76
|
+
this.svg.addEventListener('mousemove', this.moveHandler)
|
|
77
|
+
this.svg.addEventListener('mouseleave', this.leaveHandler)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onMove(e) {
|
|
81
|
+
const chart = this.chart
|
|
82
|
+
const rect = this.svg.getBoundingClientRect()
|
|
83
|
+
const scaleX = chart.chartWidth / rect.width
|
|
84
|
+
const mx = (e.clientX - rect.left) * scaleX
|
|
85
|
+
const my = (e.clientY - rect.top) * (chart.chartHeight / rect.height)
|
|
86
|
+
if (mx < chart.plotLeft || mx > chart.plotLeft + chart.plotWidth ||
|
|
87
|
+
my < chart.plotTop || my > chart.plotTop + chart.plotHeight) {
|
|
88
|
+
this.hide()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const xAxis = chart.xAxis[0]
|
|
92
|
+
const span = (xAxis.max - xAxis.min) || 1
|
|
93
|
+
const xVal = xAxis.min + ((mx - xAxis.left) / xAxis.len) * span
|
|
94
|
+
|
|
95
|
+
//snap to the nearest DRAWN point (grouped buckets / columns), so the crosshair jumps column-to-column
|
|
96
|
+
//and the tooltip reports the group value — not the raw ungrouped sample
|
|
97
|
+
let key = null
|
|
98
|
+
for (const s of chart.series) {
|
|
99
|
+
if (!s.visible || !s.plotPoints.length) continue
|
|
100
|
+
let best = null, bd = Infinity
|
|
101
|
+
for (const p of s.plotPoints) {
|
|
102
|
+
const d = Math.abs(p.x - xVal)
|
|
103
|
+
if (d < bd) { bd = d; best = p }
|
|
104
|
+
}
|
|
105
|
+
if (best) { key = best.x; break }
|
|
106
|
+
}
|
|
107
|
+
if (key === null) { this.hide(); return }
|
|
108
|
+
|
|
109
|
+
const rows = []
|
|
110
|
+
const markers = []
|
|
111
|
+
for (const s of chart.series) {
|
|
112
|
+
if (!s.visible) continue
|
|
113
|
+
let best = null, bd = Infinity
|
|
114
|
+
for (const p of s.plotPoints) {
|
|
115
|
+
const d = Math.abs(p.x - key)
|
|
116
|
+
if (d < bd) { bd = d; best = p }
|
|
117
|
+
}
|
|
118
|
+
if (best && isNumber(best.y)) {
|
|
119
|
+
//series-level tooltip wins; fall back to the chart-level tooltip (e.g. OHLC pointFormatter)
|
|
120
|
+
const tOpts = s.options.tooltip || chart.options.tooltip
|
|
121
|
+
if (tOpts && typeof tOpts.pointFormatter === 'function') {
|
|
122
|
+
rows.push(`<div><span style="color:${s.getColor()}">●</span> ${tOpts.pointFormatter.call(best)}</div>`)
|
|
123
|
+
} else {
|
|
124
|
+
rows.push(`<div><span style="color:${s.getColor()}">●</span> ${s.name || ''}: <b>${formatValue(best.y, tOpts)}</b></div>`)
|
|
125
|
+
}
|
|
126
|
+
markers.push({s, p: best})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!rows.length) { this.hide(); return }
|
|
130
|
+
|
|
131
|
+
//crosshair
|
|
132
|
+
const cx = xAxis.toPixels(key)
|
|
133
|
+
this.overlay.element.innerHTML = ''
|
|
134
|
+
if (chart.options.tooltip.crosshairs !== false && xAxis.options.crosshair !== false) {
|
|
135
|
+
chart.renderer.line(cx, chart.plotTop, cx, chart.plotTop + chart.plotHeight, {'stroke-width': 1})
|
|
136
|
+
.css({stroke: 'var(--color-text)', 'stroke-opacity': 0.55}).add(this.overlay)
|
|
137
|
+
}
|
|
138
|
+
//highlight the hovered point — only on line/area series (columns show via the crosshair),
|
|
139
|
+
//a single point marker
|
|
140
|
+
for (const {s, p} of markers) {
|
|
141
|
+
if (/^(column|bar)$/.test(s.type))
|
|
142
|
+
continue
|
|
143
|
+
if (!isNumber(p.plotX) || !isNumber(p.plotY))
|
|
144
|
+
continue
|
|
145
|
+
const color = s.getColor()
|
|
146
|
+
//translucent halo behind the point
|
|
147
|
+
chart.renderer.circle(p.plotX, p.plotY, 8, {})
|
|
148
|
+
.css({fill: color, 'fill-opacity': 0.25, stroke: 'none'})
|
|
149
|
+
.add(this.overlay)
|
|
150
|
+
//the point marker: series-color fill with a white ring
|
|
151
|
+
chart.renderer.circle(p.plotX, p.plotY, 4, {})
|
|
152
|
+
.css({fill: color, stroke: '#fff', 'stroke-width': 2})
|
|
153
|
+
.add(this.overlay)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const header = pointHeader(markers[0] && markers[0].p, key, xAxis)
|
|
157
|
+
this.box.innerHTML = `<div style="opacity:.7;margin-bottom:2px">${header}</div>` + rows.join('')
|
|
158
|
+
this.box.style.display = 'block'
|
|
159
|
+
|
|
160
|
+
//position relative to container, flip side past mid-plot
|
|
161
|
+
const boxRect = this.box.getBoundingClientRect()
|
|
162
|
+
const pxCss = cx / scaleX
|
|
163
|
+
const left = pxCss > rect.width / 2 ? pxCss - boxRect.width - 14 : pxCss + 14
|
|
164
|
+
const top = Math.max(4, (e.clientY - rect.top) - boxRect.height / 2)
|
|
165
|
+
this.box.style.left = left + 'px'
|
|
166
|
+
this.box.style.top = top + 'px'
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
hide() {
|
|
170
|
+
if (this.box)
|
|
171
|
+
this.box.style.display = 'none'
|
|
172
|
+
if (this.overlay)
|
|
173
|
+
this.overlay.element.innerHTML = ''
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
destroy() {
|
|
177
|
+
if (this.svg) {
|
|
178
|
+
this.svg.removeEventListener('mousemove', this.moveHandler)
|
|
179
|
+
this.svg.removeEventListener('mouseleave', this.leaveHandler)
|
|
180
|
+
}
|
|
181
|
+
if (this.box && this.box.parentNode)
|
|
182
|
+
this.box.parentNode.removeChild(this.box)
|
|
183
|
+
this.box = null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
//Pie / variable-pie rendering path (contract users chart).
|
|
2
|
+
//variable-pie: slice ANGLE scales with y, slice OUTER RADIUS scales with z.
|
|
3
|
+
import {pick, relativeLength, isNumber, addThousandsSep} from '../core/utilities'
|
|
4
|
+
import {animate, DEFAULT_DURATION} from '../core/animate'
|
|
5
|
+
|
|
6
|
+
const TEXT_STYLE = {
|
|
7
|
+
fontSize: '12px',
|
|
8
|
+
fontFamily: 'Roboto Condensed,sans-serif',
|
|
9
|
+
fill: 'var(--color-text)',
|
|
10
|
+
color: 'var(--color-text)'
|
|
11
|
+
}
|
|
12
|
+
const LEGEND_LINE = 18
|
|
13
|
+
|
|
14
|
+
function sectorPath(cx, cy, rInner, rOuter, a0, a1) {
|
|
15
|
+
const cos0 = Math.cos(a0)
|
|
16
|
+
const sin0 = Math.sin(a0)
|
|
17
|
+
const cos1 = Math.cos(a1)
|
|
18
|
+
const sin1 = Math.sin(a1)
|
|
19
|
+
const large = (a1 - a0) > Math.PI ? 1 : 0
|
|
20
|
+
const xo0 = cx + rOuter * cos0
|
|
21
|
+
const yo0 = cy + rOuter * sin0
|
|
22
|
+
const xo1 = cx + rOuter * cos1
|
|
23
|
+
const yo1 = cy + rOuter * sin1
|
|
24
|
+
const xi1 = cx + rInner * cos1
|
|
25
|
+
const yi1 = cy + rInner * sin1
|
|
26
|
+
const xi0 = cx + rInner * cos0
|
|
27
|
+
const yi0 = cy + rInner * sin0
|
|
28
|
+
return `M ${xo0} ${yo0} A ${rOuter} ${rOuter} 0 ${large} 1 ${xo1} ${yo1} ` +
|
|
29
|
+
`L ${xi1} ${yi1} A ${rInner} ${rInner} 0 ${large} 0 ${xi0} ${yi0} Z`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//just the outer curved edge of a sector (used for the hover band — outside only, not around the slice)
|
|
33
|
+
function outerArcPath(cx, cy, rOuter, a0, a1) {
|
|
34
|
+
const large = (a1 - a0) > Math.PI ? 1 : 0
|
|
35
|
+
const xo0 = cx + rOuter * Math.cos(a0)
|
|
36
|
+
const yo0 = cy + rOuter * Math.sin(a0)
|
|
37
|
+
const xo1 = cx + rOuter * Math.cos(a1)
|
|
38
|
+
const yo1 = cy + rOuter * Math.sin(a1)
|
|
39
|
+
return `M ${xo0} ${yo0} A ${rOuter} ${rOuter} 0 ${large} 1 ${xo1} ${yo1}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//reused HTML tooltip for pie slices (light, like the cartesian tooltip)
|
|
43
|
+
function ensurePieTooltip(chart) {
|
|
44
|
+
if (chart.pieTooltipEl && chart.pieTooltipEl.parentNode)
|
|
45
|
+
return chart.pieTooltipEl
|
|
46
|
+
const el = document.createElement('div')
|
|
47
|
+
Object.assign(el.style, {
|
|
48
|
+
position: 'absolute', pointerEvents: 'none', zIndex: 20, display: 'none',
|
|
49
|
+
background: 'rgba(247,247,247,0.92)', border: '1px solid var(--color-border-shadow)',
|
|
50
|
+
borderRadius: '3px', padding: '5px 8px', font: '12px Roboto Condensed,sans-serif',
|
|
51
|
+
color: '#15171a', whiteSpace: 'nowrap', boxShadow: '0 1px 6px rgba(0,0,0,0.3)'
|
|
52
|
+
})
|
|
53
|
+
chart.container.appendChild(el)
|
|
54
|
+
chart.pieTooltipEl = el
|
|
55
|
+
return el
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function showPieTip(tip, chart, e, point, color, pct, isVariable) {
|
|
59
|
+
const seriesName = chart.series[0].name || ''
|
|
60
|
+
let html = `<div style="opacity:.7;margin-bottom:2px">${point.name || ''}</div>`
|
|
61
|
+
html += `<div><span style="color:${color}">●</span> ${seriesName}</div>`
|
|
62
|
+
html += `<div>Value: <b>${addThousandsSep(point.y)}</b> (${pct.toFixed(1)}%)</div>`
|
|
63
|
+
if (isVariable && isNumber(point.z))
|
|
64
|
+
html += `<div>Size: <b>${addThousandsSep(point.z)}</b></div>`
|
|
65
|
+
tip.innerHTML = html
|
|
66
|
+
tip.style.display = 'block'
|
|
67
|
+
const rect = chart.renderer.root.element.getBoundingClientRect()
|
|
68
|
+
const bx = tip.getBoundingClientRect()
|
|
69
|
+
let left = e.clientX - rect.left + 12
|
|
70
|
+
if (left + bx.width > rect.width)
|
|
71
|
+
left = e.clientX - rect.left - bx.width - 12
|
|
72
|
+
const top = Math.max(2, e.clientY - rect.top - bx.height - 6)
|
|
73
|
+
tip.style.left = left + 'px'
|
|
74
|
+
tip.style.top = top + 'px'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//estimated vertical space the bottom legend needs (so the pie shrinks to make room)
|
|
78
|
+
function pieLegendHeight(points, chartWidth) {
|
|
79
|
+
const availW = chartWidth - 20
|
|
80
|
+
let rows = 1, curW = 0
|
|
81
|
+
for (const p of points) {
|
|
82
|
+
const w = 10 + 6 + Math.ceil(String(p.name || '').length * 12 * 0.5)
|
|
83
|
+
const add = (curW ? 16 : 0) + w
|
|
84
|
+
if (curW && curW + add > availW) {
|
|
85
|
+
rows++
|
|
86
|
+
curW = w
|
|
87
|
+
} else {
|
|
88
|
+
curW += add
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return 12 + rows * LEGEND_LINE
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//per-point legend (color circle + name) under the pie — wrapped, left-aligned within a centred block
|
|
95
|
+
function renderPieLegend(chart, renderer, points, colorFor) {
|
|
96
|
+
const w = chart.chartWidth
|
|
97
|
+
const symbolW = 10, gap = 6, itemGap = 16
|
|
98
|
+
const group = renderer.group('ix-pie-legend').add(renderer.root)
|
|
99
|
+
const built = points.map((p, i) => {
|
|
100
|
+
const itemGroup = renderer.group('ix-legend-item').add(group)
|
|
101
|
+
renderer.circle(symbolW / 2, -4, 4, {}).css({fill: colorFor(i)}).add(itemGroup)
|
|
102
|
+
const label = renderer.label(p.name || '', symbolW + gap, 0, {'text-anchor': 'start'}).css(TEXT_STYLE).add(itemGroup)
|
|
103
|
+
let tw
|
|
104
|
+
try {
|
|
105
|
+
tw = label.getBBox().width
|
|
106
|
+
} catch (e) {
|
|
107
|
+
tw = Math.ceil(String(p.name || '').length * 12 * 0.5)
|
|
108
|
+
}
|
|
109
|
+
return {itemGroup, w: symbolW + gap + tw}
|
|
110
|
+
})
|
|
111
|
+
//wrap into rows that fit the chart width
|
|
112
|
+
const availW = w - 20
|
|
113
|
+
const rows = []
|
|
114
|
+
let cur = [], curW = 0
|
|
115
|
+
for (const b of built) {
|
|
116
|
+
const add = (cur.length ? itemGap : 0) + b.w
|
|
117
|
+
if (cur.length && curW + add > availW) {
|
|
118
|
+
rows.push({items: cur, w: curW})
|
|
119
|
+
cur = []
|
|
120
|
+
curW = 0
|
|
121
|
+
}
|
|
122
|
+
curW += (cur.length ? itemGap : 0) + b.w
|
|
123
|
+
cur.push(b)
|
|
124
|
+
}
|
|
125
|
+
if (cur.length)
|
|
126
|
+
rows.push({items: cur, w: curW})
|
|
127
|
+
//left-align rows to a common left edge, block centred, stacked upward from the bottom
|
|
128
|
+
const maxRowW = rows.reduce((m, r) => Math.max(m, r.w), 0)
|
|
129
|
+
const blockLeft = Math.max(10, (w - maxRowW) / 2)
|
|
130
|
+
const bottomBaseline = chart.chartHeight - 8
|
|
131
|
+
rows.forEach((row, ri) => {
|
|
132
|
+
const baseline = bottomBaseline - (rows.length - 1 - ri) * LEGEND_LINE
|
|
133
|
+
let x = blockLeft
|
|
134
|
+
for (const b of row.items) {
|
|
135
|
+
b.itemGroup.attr('transform', `translate(${x}, ${baseline})`)
|
|
136
|
+
x += b.w + itemGap
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render a pie/variable-pie chart.
|
|
143
|
+
* @param {import('../core/chart').Chart} chart
|
|
144
|
+
*/
|
|
145
|
+
export function renderPie(chart) {
|
|
146
|
+
const renderer = chart.renderer
|
|
147
|
+
const series = chart.series[0]
|
|
148
|
+
if (!series || !series.points.length)
|
|
149
|
+
return
|
|
150
|
+
const w = chart.chartWidth
|
|
151
|
+
const h = chart.chartHeight
|
|
152
|
+
const opts = series.options
|
|
153
|
+
const isVariable = series.type === 'variablepie'
|
|
154
|
+
//slice labels off by default → use the bottom legend instead; both are config-toggleable
|
|
155
|
+
const dataLabelsEnabled = !!(opts.dataLabels && opts.dataLabels.enabled)
|
|
156
|
+
//legend off by default for pie/variable-pie — opt in with series.showInLegend: true
|
|
157
|
+
const legendEnabled = opts.showInLegend === true
|
|
158
|
+
|
|
159
|
+
const points = series.points.filter(p => isNumber(p.y) && p.y > 0)
|
|
160
|
+
const total = points.reduce((sum, p) => sum + p.y, 0) || 1
|
|
161
|
+
const colors = chart.options.colors || []
|
|
162
|
+
const colorFor = i => colors[i % colors.length] || '#08B5E5'
|
|
163
|
+
|
|
164
|
+
const legendH = legendEnabled ? pieLegendHeight(points, w) : 0
|
|
165
|
+
const cx = w / 2
|
|
166
|
+
const cy = (h - legendH) / 2
|
|
167
|
+
const labelPad = dataLabelsEnabled ? 34 : 10 //room for outside labels only when shown
|
|
168
|
+
const maxRadius = Math.min(w, h - legendH) / 2 - labelPad
|
|
169
|
+
const innerSize = relativeLength(pick(opts.innerSize, 0), maxRadius * 2) / 2
|
|
170
|
+
|
|
171
|
+
//z-range for variable radius
|
|
172
|
+
let zMin = pick(opts.zMin, Infinity)
|
|
173
|
+
let zMax = pick(opts.zMax, -Infinity)
|
|
174
|
+
if (isVariable) {
|
|
175
|
+
for (const p of points) {
|
|
176
|
+
if (isNumber(p.z)) {
|
|
177
|
+
if (p.z < zMin) zMin = p.z
|
|
178
|
+
if (p.z > zMax) zMax = p.z
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!isNumber(zMin)) zMin = 0
|
|
182
|
+
if (!isNumber(zMax) || zMax === zMin) zMax = zMin + 1
|
|
183
|
+
}
|
|
184
|
+
const minPointSize = relativeLength(pick(opts.minPointSize, 10), maxRadius * 2) / 2
|
|
185
|
+
|
|
186
|
+
const tip = ensurePieTooltip(chart)
|
|
187
|
+
const group = renderer.group('ix-pie').add(renderer.root)
|
|
188
|
+
const labelGroup = renderer.group('ix-pie-labels').add(renderer.root)
|
|
189
|
+
|
|
190
|
+
//overlay (drawn on top) holds the hover highlight: a solid same-colour band only OUTSIDE the
|
|
191
|
+
//segment (a wide stroke whose inner half is covered by a redrawn copy of the slice).
|
|
192
|
+
const SHADOW_W = 10
|
|
193
|
+
const slicePaths = []
|
|
194
|
+
const sliceDefs = []
|
|
195
|
+
const sweepSlices = [] //geometry for the angular entrance sweep
|
|
196
|
+
const overlay = renderer.group('ix-pie-hover').add(renderer.root)
|
|
197
|
+
let hoverEls = []
|
|
198
|
+
const clearHover = () => {
|
|
199
|
+
hoverEls.forEach(el => el.destroy())
|
|
200
|
+
hoverEls = []
|
|
201
|
+
}
|
|
202
|
+
let angle = -Math.PI / 2
|
|
203
|
+
points.forEach((p, i) => {
|
|
204
|
+
const slice = (p.y / total) * Math.PI * 2
|
|
205
|
+
const a0 = angle
|
|
206
|
+
const a1 = angle + slice
|
|
207
|
+
let rOuter = maxRadius
|
|
208
|
+
if (isVariable) {
|
|
209
|
+
const zFrac = (pick(p.z, zMin) - zMin) / (zMax - zMin || 1)
|
|
210
|
+
rOuter = innerSize + minPointSize + (maxRadius - innerSize - minPointSize) * zFrac
|
|
211
|
+
}
|
|
212
|
+
const color = colorFor(i)
|
|
213
|
+
const pct = p.y / total * 100
|
|
214
|
+
const d = sectorPath(cx, cy, innerSize, rOuter, a0, a1)
|
|
215
|
+
const arc = outerArcPath(cx, cy, rOuter, a0, a1)
|
|
216
|
+
sliceDefs.push({d, arc, color})
|
|
217
|
+
const path = renderer.path(d, {'stroke-width': 1.5})
|
|
218
|
+
.css({fill: color, stroke: '#fff', cursor: 'pointer', opacity: 1})
|
|
219
|
+
.add(group)
|
|
220
|
+
slicePaths.push(path)
|
|
221
|
+
sweepSlices.push({el: path, a0, a1, rOuter})
|
|
222
|
+
//hover: dim the others, draw a solid outer band + white outline over this slice
|
|
223
|
+
path.on('mouseover', () => {
|
|
224
|
+
slicePaths.forEach((sp, j) => sp.css({opacity: j === i ? 1 : 0.35}))
|
|
225
|
+
clearHover()
|
|
226
|
+
//wide solid band along the OUTER edge only, centred on the arc → outer half stays visible,
|
|
227
|
+
//inner half is overdrawn by the redrawn slice (so the band hugs only the outside curve)
|
|
228
|
+
const band = renderer.path(arc, {'stroke-width': SHADOW_W * 2, fill: 'none'})
|
|
229
|
+
.css({stroke: color, 'stroke-opacity': 0.5, 'stroke-linecap': 'butt', 'pointer-events': 'none'}).add(overlay)
|
|
230
|
+
//redraw the slice on top so the band shows only outside, plus the white outline
|
|
231
|
+
const top = renderer.path(d, {'stroke-width': 1.5})
|
|
232
|
+
.css({fill: color, stroke: '#fff', 'pointer-events': 'none'}).add(overlay)
|
|
233
|
+
hoverEls.push(band, top)
|
|
234
|
+
})
|
|
235
|
+
path.on('mousemove', e => showPieTip(tip, chart, e, p, color, pct, isVariable))
|
|
236
|
+
path.on('mouseout', () => {
|
|
237
|
+
slicePaths.forEach(sp => sp.css({opacity: 1}))
|
|
238
|
+
clearHover()
|
|
239
|
+
tip.style.display = 'none'
|
|
240
|
+
})
|
|
241
|
+
//optional data label (name) outside the slice
|
|
242
|
+
if (dataLabelsEnabled) {
|
|
243
|
+
const mid = (a0 + a1) / 2
|
|
244
|
+
const lx = cx + (maxRadius + 10) * Math.cos(mid)
|
|
245
|
+
const ly = cy + (maxRadius + 10) * Math.sin(mid)
|
|
246
|
+
const anchor = Math.cos(mid) < -0.2 ? 'end' : (Math.cos(mid) > 0.2 ? 'start' : 'middle')
|
|
247
|
+
renderer.label(p.name || '', lx, ly + 4, {'text-anchor': anchor}).css(TEXT_STYLE).add(labelGroup)
|
|
248
|
+
}
|
|
249
|
+
angle = a1
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
if (legendEnabled)
|
|
253
|
+
renderPieLegend(chart, renderer, points, colorFor)
|
|
254
|
+
|
|
255
|
+
//entrance: every sector grows its angular width at the same time, each from its own start edge,
|
|
256
|
+
//all reaching full width together to close the circle (no rotation, full radius throughout)
|
|
257
|
+
if (chart.animateThisRender && sweepSlices.length) {
|
|
258
|
+
const fullDraw = s => sectorPath(cx, cy, innerSize, s.rOuter, s.a0, s.a1)
|
|
259
|
+
animate(DEFAULT_DURATION, k => {
|
|
260
|
+
for (const s of sweepSlices) {
|
|
261
|
+
const end = s.a0 + k * (s.a1 - s.a0)
|
|
262
|
+
s.el.attr('d', end > s.a0 ? sectorPath(cx, cy, innerSize, s.rOuter, s.a0, end) : '')
|
|
263
|
+
}
|
|
264
|
+
}, () => {
|
|
265
|
+
for (const s of sweepSlices)
|
|
266
|
+
s.el.attr('d', fullDraw(s))
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
//Polar/radar rendering path (asset rating chart): categories around a circle, radial value axis,
|
|
2
|
+
//polygon gridlines (gridLineInterpolation: 'polygon').
|
|
3
|
+
import {pick, relativeLength} from '../core/utilities'
|
|
4
|
+
import {animateFromCenter} from '../core/animate'
|
|
5
|
+
|
|
6
|
+
const TEXT_STYLE = {
|
|
7
|
+
fontSize: '12px',
|
|
8
|
+
fontFamily: 'Roboto Condensed,sans-serif',
|
|
9
|
+
fill: 'var(--color-text)',
|
|
10
|
+
color: 'var(--color-text)'
|
|
11
|
+
}
|
|
12
|
+
const GRID_COLOR = 'var(--color-border-shadow)'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a chart in polar coordinates.
|
|
16
|
+
* @param {import('../core/chart').Chart} chart
|
|
17
|
+
*/
|
|
18
|
+
export function renderPolar(chart) {
|
|
19
|
+
const renderer = chart.renderer
|
|
20
|
+
const w = chart.chartWidth
|
|
21
|
+
const h = chart.chartHeight
|
|
22
|
+
const pane = chart.options.pane || {}
|
|
23
|
+
const radius = relativeLength(pane.size || '85%', Math.min(w, h)) / 2
|
|
24
|
+
const cx = w / 2
|
|
25
|
+
const cy = h / 2
|
|
26
|
+
const xAxis = chart.xAxis[0]
|
|
27
|
+
const yAxis = chart.yAxis[0]
|
|
28
|
+
const cats = xAxis.categories || []
|
|
29
|
+
const series0 = chart.series[0]
|
|
30
|
+
const n = cats.length || (series0 ? series0.points.length : 0)
|
|
31
|
+
if (!n)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
const yMin = pick(yAxis.options.min, 0)
|
|
35
|
+
let yMax = pick(yAxis.options.max, undefined)
|
|
36
|
+
if (yMax === undefined) {
|
|
37
|
+
yMax = 1
|
|
38
|
+
for (const s of chart.series)
|
|
39
|
+
for (const p of s.points)
|
|
40
|
+
if (p.y > yMax) yMax = p.y
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const angleFor = i => -Math.PI / 2 + (i / n) * Math.PI * 2
|
|
44
|
+
const pointAt = (i, value) => {
|
|
45
|
+
const r = ((value - yMin) / ((yMax - yMin) || 1)) * radius
|
|
46
|
+
const a = angleFor(i)
|
|
47
|
+
return [cx + r * Math.cos(a), cy + r * Math.sin(a)]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const gridGroup = renderer.group('ix-polar-grid').add(renderer.root)
|
|
51
|
+
const axisGroup = renderer.group('ix-polar-axis').add(renderer.root)
|
|
52
|
+
const seriesGroup = renderer.group('ix-polar-series').add(renderer.root)
|
|
53
|
+
|
|
54
|
+
//polygon rings (gridlines only — no per-ring value labels)
|
|
55
|
+
const rings = 5
|
|
56
|
+
for (let k = 1; k <= rings; k++) {
|
|
57
|
+
const v = yMin + (yMax - yMin) * k / rings
|
|
58
|
+
let d = ''
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
const [x, y] = pointAt(i, v)
|
|
61
|
+
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '
|
|
62
|
+
}
|
|
63
|
+
renderer.path(d + 'Z', {'stroke-width': 1, fill: 'none'}).css({stroke: GRID_COLOR}).add(gridGroup)
|
|
64
|
+
}
|
|
65
|
+
//single "0" label at the centre (only the origin value is shown)
|
|
66
|
+
renderer.label('0', cx + 4, cy + 4, {'text-anchor': 'start'}).css(TEXT_STYLE).add(axisGroup)
|
|
67
|
+
|
|
68
|
+
//spokes + category labels
|
|
69
|
+
for (let i = 0; i < n; i++) {
|
|
70
|
+
const [ex, ey] = pointAt(i, yMax)
|
|
71
|
+
renderer.line(cx, cy, ex, ey, {'stroke-width': 1}).css({stroke: GRID_COLOR}).add(gridGroup)
|
|
72
|
+
const [lx, ly] = pointAt(i, yMax * 1.13)
|
|
73
|
+
const anchor = Math.abs(lx - cx) < 6 ? 'middle' : (lx > cx ? 'start' : 'end')
|
|
74
|
+
renderer.label(cats[i] || '', lx, ly + 4, {'text-anchor': anchor}).css(TEXT_STYLE).add(axisGroup)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//series — closed radar polygons
|
|
78
|
+
for (const s of chart.series) {
|
|
79
|
+
const color = s.getColor()
|
|
80
|
+
const filled = /area/.test(s.type)
|
|
81
|
+
let d = ''
|
|
82
|
+
const verts = []
|
|
83
|
+
s.points.forEach((p, i) => {
|
|
84
|
+
const [x, y] = pointAt(i, p.y)
|
|
85
|
+
verts.push([x, y])
|
|
86
|
+
d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '
|
|
87
|
+
})
|
|
88
|
+
renderer.path(d + 'Z', {'stroke-width': 2, fill: filled ? color : 'none'})
|
|
89
|
+
.css({stroke: color, 'fill-opacity': filled ? 0.25 : 0})
|
|
90
|
+
.add(seriesGroup)
|
|
91
|
+
//vertex markers
|
|
92
|
+
for (const [x, y] of verts)
|
|
93
|
+
renderer.circle(x, y, 3, {}).css({fill: color}).add(seriesGroup)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//entrance: the data polygons grow out from the centre to their actual positions
|
|
97
|
+
if (chart.animateThisRender)
|
|
98
|
+
animateFromCenter(seriesGroup, cx, cy)
|
|
99
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {pick, uniqueId, isNumber, clamp} from '../core/utilities'
|
|
2
|
+
import {revealLeftToRight} from '../core/animate'
|
|
3
|
+
import {LineSeries} from './line-series'
|
|
4
|
+
|
|
5
|
+
export class AreaSeries extends LineSeries {
|
|
6
|
+
resolveFill() {
|
|
7
|
+
const seriesFill = this.options.fillColor
|
|
8
|
+
const areaDefaults = (this.chart.options.plotOptions.area) || {}
|
|
9
|
+
const fc = seriesFill !== undefined ? seriesFill : areaDefaults.fillColor
|
|
10
|
+
//fillColor: null -> tint of the series color (used by stacked-percent distribution)
|
|
11
|
+
if (fc === null || fc === undefined)
|
|
12
|
+
return {fill: this.getColor(), opacity: 0.5}
|
|
13
|
+
//gradient object {linearGradient:{x1,y1,x2,y2}, stops:[[off,col],...]}
|
|
14
|
+
if (typeof fc === 'object' && fc.stops) {
|
|
15
|
+
const v = fc.linearGradient || {x1: 0, y1: 0, x2: 0, y2: 1}
|
|
16
|
+
const ref = this.chart.renderer.linearGradient(v, fc.stops, uniqueId('grad'))
|
|
17
|
+
return {fill: ref, opacity: 1}
|
|
18
|
+
}
|
|
19
|
+
return {fill: fc, opacity: 1}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
render(group) {
|
|
23
|
+
this.group = group
|
|
24
|
+
const renderer = this.chart.renderer
|
|
25
|
+
const color = this.getColor()
|
|
26
|
+
const lineWidth = pick(this.options.lineWidth, this.chart.options.plotOptions.series.lineWidth, 2)
|
|
27
|
+
|
|
28
|
+
//area fill — line path closed down to the axis baseline
|
|
29
|
+
const lineD = this.linePath()
|
|
30
|
+
const step = this.resolveOption('step')
|
|
31
|
+
if (lineD && this.segments.length) {
|
|
32
|
+
const tVal = isNumber(this.threshold) ? this.threshold : this.yAxis.min
|
|
33
|
+
const baselineY = this.yAxis.toPixels(clamp(tVal, this.yAxis.min, this.yAxis.max))
|
|
34
|
+
let areaD = ''
|
|
35
|
+
for (const seg of this.segments) {
|
|
36
|
+
const first = seg[0]
|
|
37
|
+
const last = seg[seg.length - 1]
|
|
38
|
+
areaD += `M ${first.plotX} ${baselineY} `
|
|
39
|
+
seg.forEach((pt, i) => {
|
|
40
|
+
if (step === 'left' && i > 0) {
|
|
41
|
+
areaD += `L ${pt.plotX} ${seg[i - 1].plotY} `
|
|
42
|
+
} else if (step === 'right' && i > 0) {
|
|
43
|
+
areaD += `L ${seg[i - 1].plotX} ${pt.plotY} `
|
|
44
|
+
}
|
|
45
|
+
areaD += `L ${pt.plotX} ${pt.plotY} `
|
|
46
|
+
})
|
|
47
|
+
areaD += `L ${last.plotX} ${baselineY} Z `
|
|
48
|
+
}
|
|
49
|
+
const {fill, opacity} = this.resolveFill()
|
|
50
|
+
renderer.path(areaD.trim(), {'stroke-width': 0}).css({fill, 'fill-opacity': opacity}).add(group)
|
|
51
|
+
//line on top
|
|
52
|
+
renderer.path(lineD, {'stroke-width': lineWidth, fill: 'none', 'stroke-linejoin': 'round'}).css({stroke: color}).add(group)
|
|
53
|
+
if (this.chart.animateThisRender)
|
|
54
|
+
revealLeftToRight(this.chart, group)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {isNumber, pick} from '../core/utilities'
|
|
2
|
+
import {Series} from './series'
|
|
3
|
+
|
|
4
|
+
export class CandlestickSeries extends Series {
|
|
5
|
+
//candlesticks plot OHLC, not a single y; expose extremes from high/low for the axis
|
|
6
|
+
yExtremes(point) {
|
|
7
|
+
return [point.low, point.high]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
render(group) {
|
|
11
|
+
this.group = group
|
|
12
|
+
const renderer = this.chart.renderer
|
|
13
|
+
const xAxis = this.xAxis
|
|
14
|
+
const yAxis = this.yAxis
|
|
15
|
+
const theme = this.chart.options.plotOptions.candlestick || {}
|
|
16
|
+
const upColor = pick(theme.upColor, 'var(--color-price-up)')
|
|
17
|
+
const downColor = pick(theme.color, 'var(--color-price-down)')
|
|
18
|
+
const lineColor = pick(theme.lineColor, 'var(--color-dimmed)')
|
|
19
|
+
const lineWidth = pick(theme.lineWidth, 0.6)
|
|
20
|
+
|
|
21
|
+
//local point range — smallest gap between adjacent DRAWN candles. Ignore points without a close
|
|
22
|
+
//(e.g. the trailing [now, null…] marker the price view appends to extend the axis), which would
|
|
23
|
+
//otherwise be a sub-day gap that shrinks the slot and renders thin candles with wide spacing.
|
|
24
|
+
let pr = Infinity
|
|
25
|
+
let prevX = null
|
|
26
|
+
for (const pt of this.plotPoints) {
|
|
27
|
+
if (!isNumber(pt.close))
|
|
28
|
+
continue
|
|
29
|
+
if (prevX !== null) {
|
|
30
|
+
const d = pt.x - prevX
|
|
31
|
+
if (d > 0 && d < pr) pr = d
|
|
32
|
+
}
|
|
33
|
+
prevX = pt.x
|
|
34
|
+
}
|
|
35
|
+
if (pr === Infinity)
|
|
36
|
+
pr = (xAxis.max - xAxis.min) / 100
|
|
37
|
+
|
|
38
|
+
for (const p of this.plotPoints) {
|
|
39
|
+
if (!isNumber(p.close))
|
|
40
|
+
continue
|
|
41
|
+
const cx = xAxis.toPixels(p.x)
|
|
42
|
+
const catW = Math.abs(xAxis.toPixels(p.x + pr) - xAxis.toPixels(p.x))
|
|
43
|
+
const w = Math.max(1, catW * 0.6)
|
|
44
|
+
const up = p.close >= p.open
|
|
45
|
+
const color = up ? upColor : downColor
|
|
46
|
+
const yHigh = yAxis.toPixels(p.high)
|
|
47
|
+
const yLow = yAxis.toPixels(p.low)
|
|
48
|
+
const yOpen = yAxis.toPixels(p.open)
|
|
49
|
+
const yClose = yAxis.toPixels(p.close)
|
|
50
|
+
//wick
|
|
51
|
+
renderer.line(cx, yHigh, cx, yLow, {'stroke-width': 1}).css({stroke: lineColor}).add(group)
|
|
52
|
+
//body
|
|
53
|
+
const top = Math.min(yOpen, yClose)
|
|
54
|
+
const h = Math.max(1, Math.abs(yClose - yOpen))
|
|
55
|
+
renderer.rect(cx - w / 2, top, w, h, {'stroke-width': lineWidth})
|
|
56
|
+
.css({fill: color, stroke: lineColor})
|
|
57
|
+
.add(group)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|