@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,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
+ }