Skip to content

Commit 2838f65

Browse files
committed
feat(morph): support funnel / pyramid / gauge in cross-type morphs
Adds the three first-class chart-type aliases to the morph engine so any chart can morph to/from a funnel, pyramid, or gauge in addition to the existing bar / pie / donut / radialBar / polarArea pairs. Engine - MorphTypeChange: `funnel` + `pyramid` added to BAR_FAMILY, `gauge` added to RADIAL_FAMILY. `_captureFromDOM` treats `fromType === 'gauge'` as a radialBar-shaped source (same selector, same arc-to-segment conversion). - UpdateHelpers: `fromType` is now `w.config.chart.requestedType || w.config.chart.type` so a chart that *started* as an alias keeps its user-facing identity for morph detection — without this, a chart rendered as funnel would internally report `bar` and any subsequent morph would be incorrectly classified as same-family. - Radial.js: `morphFromFilled` check expanded to recognize funnel and pyramid sources (they're Bar.js variants and render as fills, so the donut-segment intermediate target is needed for radialBar destinations). Update-path crash fix - UpdateHelpers: `ch.config.normalizeAliasedChartType(options)` runs before the config merge. On initial render this happens inside `Config.init()`, but the update path constructs Config directly and skips init() — without normalize, an `updateOptions({chart: {type:'funnel'}})` would merge an unrecognized chart.type into w.config and crash in `Core.plotChartType`'s switch fallthrough (`line.draw` on a null `line` when `needsLine` was false). Leaving-alias residue cleanup - UpdateHelpers: when transitioning out of an alias state (the previous render was funnel/pyramid/gauge and the new request is a non-alias type), `options.chart.requestedType` is explicitly set to the new type and `options.plotOptions.bar.isFunnel` is cleared (when leaving funnel or pyramid). Without this, the second cross-type morph in/out of funnel/pyramid failed because the previous alias's `requestedType` and `isFunnel: true` lingered on w.config and would be preserved through the merge. Why this lives in UpdateHelpers and not Config.normalizeAliasedChartType: normalize runs on initial chart creation too, where the legacy contract `chart.type: 'bar' + plotOptions.bar.isFunnel: true` must leave `requestedType` undefined and `isFunnel: true` preserved verbatim. Only the update path has the context to know we're transitioning between modes. Pyramid sort - Config._applyPyramidSort: pyramid now sorts series data ascending (smallest value at top, largest at bottom) so the rendered shape is actually a pyramid regardless of input order. The same permutation is applied to xaxis.categories and the top-level labels array so legend and tooltips stay aligned. Sort is non-mutating — the user's input arrays are deep-cloned via Array.prototype.map before being placed on `opts.series` / `opts.xaxis.categories` / `opts.labels`. Pre-existing `defaults.pyramid()` was a stub that just returned `defaults.funnel()`; the comment claiming "ordering is handled in Config" was aspirational. This adds the missing piece. Demo - samples chart-type-morph: Funnel + Pyramid buttons added. `plotOptions.funnel: { shape: 'trapezoid', lastShape: 'taper' }` so the funnel renders in trapezoid mode. Bar-height slider (30–100%) added beneath the speed slider, controlling `plotOptions.bar.barHeight` live and threading through type-switch transitions so horizontal-bar / funnel / pyramid all honor the chosen thickness on subsequent morphs. Tests - first-class-types.spec.js: two new cases for pyramid sort — verifies sorted internal config (series / xaxis.categories) AND that the user's original arrays are not mutated; second case proves the same for top-level labels. - morph-type-change.spec.js: new pair / shape rules for funnel ↔ pie/donut/radialBar/polarArea, within-family bar ↔ funnel ↔ pyramid, and gauge ↔ bar / pie / radialBar. 1647/1647 unit tests pass.
1 parent 2583060 commit 2838f65

8 files changed

Lines changed: 276 additions & 10 deletions

File tree

samples/source/misc/chart-type-morph.xml

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ p {
7676
<button data-type="donut">Donut</button>
7777
<button data-type="polarArea">Polar area</button>
7878
<button data-type="radialBar">Radial bar</button>
79+
<button data-type="funnel">Funnel</button>
80+
<button data-type="pyramid">Pyramid</button>
7981
</div>
8082

8183
<div class="actions">
@@ -87,6 +89,12 @@ p {
8789
<input type="range" id="speed" min="100" max="15000" step="50" value="700" />
8890
<span id="speed-value">700</span> ms
8991
</div>
92+
93+
<div class="speed-row">
94+
Bar height (horizontal bar / funnel / pyramid):
95+
<input type="range" id="bar-height" min="30" max="100" step="5" value="70" />
96+
<span id="bar-height-value">70</span>%
97+
</div>
9098
</div>
9199
</html>
92100

@@ -119,13 +127,17 @@ plotOptions: {
119127
distributed: true,
120128
},
121129
radialBar: {
130+
inverseOrder: true,
122131
hollow: {
123132
size: '5%'
124133
},
125134
track: {
126135
show: false
127136
}
128-
}
137+
},
138+
funnel: {
139+
shape: 'trapezoid',
140+
},
129141
},
130142
yaxis: {
131143
show: false,
@@ -161,9 +173,10 @@ var LABELS = ['Direct', 'Organic', 'Referral', 'Social', 'Email']
161173
var currentType = 'bar'
162174
var currentHorizontal = false
163175
var currentValues = [44, 55, 41, 27, 33]
176+
var currentBarHeight = 70
164177

165178
function buildSeriesFor(type, values) {
166-
if (type === 'bar') {
179+
if (type === 'bar' || type === 'funnel' || type === 'pyramid') {
167180
return [{ name: 'Sessions', data: values.slice() }]
168181
}
169182
return values.slice()
@@ -191,7 +204,12 @@ document.querySelectorAll('.actions button[data-type]').forEach(function (btn) {
191204
setActive(currentType, currentHorizontal)
192205
chart.updateOptions({
193206
chart: { type: currentType },
194-
plotOptions: { bar: { horizontal: currentHorizontal } },
207+
plotOptions: {
208+
bar: {
209+
horizontal: currentHorizontal,
210+
barHeight: currentBarHeight + '%',
211+
},
212+
},
195213
series: buildSeriesFor(currentType, currentValues),
196214
labels: LABELS,
197215
})
@@ -217,4 +235,17 @@ speedInput.addEventListener('input', function () {
217235
)
218236
})
219237

238+
var barHeightInput = document.querySelector('#bar-height')
239+
var barHeightValue = document.querySelector('#bar-height-value')
240+
barHeightInput.addEventListener('input', function () {
241+
var v = parseInt(barHeightInput.value, 10)
242+
barHeightValue.textContent = v
243+
currentBarHeight = v
244+
chart.updateOptions(
245+
{ plotOptions: { bar: { barHeight: v + '%' } } },
246+
false,
247+
false,
248+
)
249+
})
250+
220251
</vanilla-js-script>

samples/vanilla-js/misc/chart-type-morph.html

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ <h1>Chart-type morph</h1>
102102
<button data-type="donut">Donut</button>
103103
<button data-type="polarArea">Polar area</button>
104104
<button data-type="radialBar">Radial bar</button>
105+
<button data-type="funnel">Funnel</button>
106+
<button data-type="pyramid">Pyramid</button>
105107
</div>
106108

107109
<div class="actions">
@@ -113,6 +115,12 @@ <h1>Chart-type morph</h1>
113115
<input type="range" id="speed" min="100" max="15000" step="50" value="700" />
114116
<span id="speed-value">700</span> ms
115117
</div>
118+
119+
<div class="speed-row">
120+
Bar height (horizontal bar / funnel / pyramid):
121+
<input type="range" id="bar-height" min="30" max="100" step="5" value="70" />
122+
<span id="bar-height-value">70</span>%
123+
</div>
116124
</div>
117125

118126
<script>
@@ -145,13 +153,17 @@ <h1>Chart-type morph</h1>
145153
distributed: true,
146154
},
147155
radialBar: {
156+
inverseOrder: true,
148157
hollow: {
149158
size: '5%',
150159
},
151160
track: {
152161
show: false,
153162
},
154163
},
164+
funnel: {
165+
shape: 'trapezoid',
166+
},
155167
},
156168
yaxis: {
157169
show: false,
@@ -184,9 +196,10 @@ <h1>Chart-type morph</h1>
184196
var currentType = 'bar'
185197
var currentHorizontal = false
186198
var currentValues = [44, 55, 41, 27, 33]
199+
var currentBarHeight = 70
187200

188201
function buildSeriesFor(type, values) {
189-
if (type === 'bar') {
202+
if (type === 'bar' || type === 'funnel' || type === 'pyramid') {
190203
return [{ name: 'Sessions', data: values.slice() }]
191204
}
192205
return values.slice()
@@ -215,7 +228,12 @@ <h1>Chart-type morph</h1>
215228
setActive(currentType, currentHorizontal)
216229
chart.updateOptions({
217230
chart: { type: currentType },
218-
plotOptions: { bar: { horizontal: currentHorizontal } },
231+
plotOptions: {
232+
bar: {
233+
horizontal: currentHorizontal,
234+
barHeight: currentBarHeight + '%',
235+
},
236+
},
219237
series: buildSeriesFor(currentType, currentValues),
220238
labels: LABELS,
221239
})
@@ -241,6 +259,18 @@ <h1>Chart-type morph</h1>
241259
)
242260
})
243261

262+
var barHeightInput = document.querySelector('#bar-height')
263+
var barHeightValue = document.querySelector('#bar-height-value')
264+
barHeightInput.addEventListener('input', function () {
265+
var v = parseInt(barHeightInput.value, 10)
266+
barHeightValue.textContent = v
267+
currentBarHeight = v
268+
chart.updateOptions(
269+
{ plotOptions: { bar: { barHeight: v + '%' } } },
270+
false,
271+
false,
272+
)
273+
})
244274
</script>
245275

246276

src/charts/Radial.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,9 +431,13 @@ class Radial extends Pie {
431431
const morphFromType = morphActive
432432
? this.ctx.morphTypeChange.getFromType()
433433
: null
434+
// funnel + pyramid are Bar.js variants — same fill-based rendering as
435+
// a regular bar, so they take the same morph-from-filled treatment.
434436
const morphFromFilled =
435437
!!morphFrom &&
436438
(morphFromType === 'bar' ||
439+
morphFromType === 'funnel' ||
440+
morphFromType === 'pyramid' ||
437441
morphFromType === 'pie' ||
438442
morphFromType === 'donut' ||
439443
morphFromType === 'polarArea')

src/modules/MorphTypeChange.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ import { parsePath } from '../svg/PathMorphing'
2727
* chaining and the chart behaves exactly as before.
2828
*/
2929

30-
const BAR_FAMILY = new Set(['bar'])
31-
const RADIAL_FAMILY = new Set(['pie', 'donut', 'polarArea', 'radialBar'])
30+
// funnel + pyramid are rendered by Bar.js internally (Config.js aliases them
31+
// to `chart.type: 'bar'` with `plotOptions.bar.isFunnel: true, horizontal:
32+
// true`). gauge is aliased to radialBar. Treating them as members of the
33+
// bar / radial families lets the morph engine accept them as source or
34+
// target without any renderer-side changes.
35+
const BAR_FAMILY = new Set(['bar', 'funnel', 'pyramid'])
36+
const RADIAL_FAMILY = new Set(['pie', 'donut', 'polarArea', 'radialBar', 'gauge'])
3237

3338
/** @param {string} type */
3439
function familyOf(type) {
@@ -180,7 +185,9 @@ export default class MorphTypeChange {
180185
})
181186
})
182187
} else if (fam === 'radial') {
183-
if (fromType === 'radialBar') {
188+
// `gauge` is an alias for radialBar (see Config.normalizeAliasedChartType),
189+
// so it captures from the same selector / arc-shape.
190+
if (fromType === 'radialBar' || fromType === 'gauge') {
184191
// radialBar paths are STROKED open arcs (fill=none, stroke=color,
185192
// stroke-width ≈ ring thickness). If we hand the raw `d` to a pie/
186193
// polarArea/donut element (which fills, not strokes), the implicit

src/modules/helpers/UpdateHelpers.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,20 @@ export default class UpdateHelpers {
6161
// before the config merge so it sees the OLD chart.type while the
6262
// outgoing DOM is still mounted. Null-safe — if the feature isn't
6363
// registered, this no-ops via optional chaining.
64+
//
65+
// `requestedType` preserves the user-facing alias (funnel, pyramid,
66+
// gauge) that Config.normalizeAliasedChartType collapses into the
67+
// internal chart.type (bar, radialBar). Without it, a chart that
68+
// started as funnel would report `fromType: 'bar'` and lose the
69+
// ability to morph FROM funnel into anything in the bar family
70+
// (funnel → pyramid, funnel → bar). The incoming `options.chart.type`
71+
// is the raw value the user passed and has not yet been normalized.
6472
if (animate && options && typeof options === 'object') {
6573
const newType = options?.chart?.type
66-
if (newType && newType !== w.config.chart.type) {
74+
const fromType = w.config.chart.requestedType || w.config.chart.type
75+
if (newType && newType !== fromType) {
6776
ch.morphTypeChange?.captureBeforeDestroy({
68-
fromType: w.config.chart.type,
77+
fromType,
6978
toType: newType,
7079
newSeries: options.series || w.config.series,
7180
})
@@ -74,6 +83,42 @@ export default class UpdateHelpers {
7483

7584
if (options && typeof options === 'object') {
7685
ch.config = new Config(options)
86+
// Collapse user-facing chart-type aliases (funnel / pyramid → bar
87+
// with isFunnel; gauge → radialBar). On the initial render this
88+
// runs inside Config.init(), but the update path constructs Config
89+
// directly and skips init(), so we'd otherwise merge an
90+
// unrecognized chart.type into w.config and crash in
91+
// Core.plotChartType's switch fallthrough.
92+
//
93+
// When transitioning OUT of an alias state (the previous render
94+
// was funnel/pyramid/gauge and the new request is a non-alias
95+
// type), explicitly clear the alias residue on `options` so the
96+
// Utils.extend merge below overrides w.config's stale fields.
97+
// We can't clear them in `normalizeAliasedChartType` itself —
98+
// that runs on initial chart creation too, where the legacy
99+
// contract `chart.type: 'bar' + plotOptions.bar.isFunnel: true`
100+
// must leave both `requestedType` undefined and `isFunnel: true`.
101+
const incomingType = options.chart && options.chart.type
102+
const isAliasRequest =
103+
incomingType === 'funnel' ||
104+
incomingType === 'pyramid' ||
105+
incomingType === 'gauge'
106+
const wasAlias = !!w.config.chart.requestedType
107+
if (incomingType && !isAliasRequest && wasAlias) {
108+
options.chart = options.chart || {}
109+
options.chart.requestedType = incomingType
110+
// Only clear isFunnel when the previous alias was funnel/pyramid
111+
// (gauge doesn't touch this flag).
112+
const prev = w.config.chart.requestedType
113+
if (prev === 'funnel' || prev === 'pyramid') {
114+
options.plotOptions = options.plotOptions || {}
115+
options.plotOptions.bar = options.plotOptions.bar || {}
116+
if (options.plotOptions.bar.isFunnel === undefined) {
117+
options.plotOptions.bar.isFunnel = false
118+
}
119+
}
120+
}
121+
ch.config.normalizeAliasedChartType(options)
77122
options = CoreUtils.extendArrayProps(ch.config, options, w)
78123

79124
// fixes #914, #623

src/modules/settings/Config.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,83 @@ export default class Config {
158158
opts.plotOptions.bar.isFunnel = true
159159
opts.plotOptions.bar.horizontal = true
160160
opts.chart.type = 'bar'
161+
if (requested === 'pyramid') {
162+
this._applyPyramidSort(opts)
163+
}
161164
} else if (requested === 'gauge') {
162165
opts.chart.type = 'radialBar'
163166
}
164167
return opts
165168
}
166169

170+
/**
171+
* Pyramid wants the smallest value at the top (the apex) and the largest
172+
* at the bottom (the base), regardless of the order the user passed data
173+
* in. We reuse the funnel renderer with the data array sorted ascending,
174+
* keeping `xaxis.categories` paired up so the legend / category labels
175+
* stay aligned with the rendered stages.
176+
*
177+
* Sorting is done on *clones* of the user-supplied arrays — the original
178+
* input objects they passed to updateOptions / new ApexCharts are never
179+
* mutated. Only `opts.series` / `opts.xaxis.categories` (which are about
180+
* to be merged into `w.config`) carry the sorted view.
181+
*
182+
* @param {Record<string, any>} opts
183+
*/
184+
_applyPyramidSort(opts) {
185+
if (!Array.isArray(opts.series) || opts.series.length === 0) return
186+
187+
/** @param {any} v */
188+
const valueOf = (v) => {
189+
if (typeof v === 'number') return v
190+
if (v && typeof v === 'object') {
191+
if (typeof v.y === 'number') return v.y
192+
if (Array.isArray(v)) return Number(v[1])
193+
}
194+
return Number(v)
195+
}
196+
197+
// Funnel / pyramid is normally single-series. Use the first series'
198+
// original data order to derive a single index permutation, then apply
199+
// it to every series + the categories so they all stay paired up.
200+
const firstSeries = opts.series[0]
201+
if (!firstSeries || !Array.isArray(firstSeries.data)) return
202+
203+
const order = firstSeries.data
204+
.map(/** @param {any} _v @param {number} i */ (_v, i) => i)
205+
.sort(
206+
/** @param {number} a @param {number} b */
207+
(a, b) => valueOf(firstSeries.data[a]) - valueOf(firstSeries.data[b]),
208+
)
209+
210+
opts.series = opts.series.map(
211+
/** @param {any} s */ (s) => {
212+
if (!s || !Array.isArray(s.data)) return s
213+
return {
214+
...s,
215+
data: order.map(/** @param {number} i */ (i) => s.data[i]),
216+
}
217+
},
218+
)
219+
220+
const rawCats = opts.xaxis?.categories
221+
if (Array.isArray(rawCats)) {
222+
opts.xaxis = { ...opts.xaxis }
223+
opts.xaxis.categories = order.map(
224+
/** @param {number} i */ (i) => rawCats[i],
225+
)
226+
}
227+
228+
// Some callers pass categories via the top-level `labels` (pie-style
229+
// contract that also works for distributed bars / funnel charts).
230+
// Reorder it the same way so the legend and tooltip labels track.
231+
if (Array.isArray(opts.labels)) {
232+
opts.labels = order.map(
233+
/** @param {number} i */ (i) => opts.labels[i],
234+
)
235+
}
236+
}
237+
167238
/**
168239
* @param {string} chartType
169240
* @param {Record<string, any>} chartDefaults

0 commit comments

Comments
 (0)