apexcharts
Version:
A JavaScript Chart Library
305 lines (270 loc) • 8.86 kB
JavaScript
// @ts-check
import Utils from './../utils/Utils'
/**
* Default blend strength for each state filter when `states.*.filter.value`
* is not supplied. See `applyFilter` for the blend math.
*/
const DEFAULT_INTENSITY = { lighten: 0.15, darken: 0.35 }
/**
* ApexCharts Filters Class for setting hover/active states on the paths.
*
* @module Formatters
**/
class Filters {
/**
* @param {import('../types/internal').ChartStateW} w
*/
constructor(w) {
this.w = w
}
/**
* The three chart types drawn by the Pie module.
* @param {any} w
*/
static isSliceChart(w) {
const type = w.config.chart.type
return type === 'pie' || type === 'donut' || type === 'polarArea'
}
/**
* True when a pie / donut slice carries the hover state itself, as the
* outline band traced outside its rim. The band IS the hover feedback, so
* the states.hover lighten filter must not also recolour the slice: the
* point of the band is that a hovered slice keeps its own colour.
* `states.hover.filter.type: 'none'` still turns every hover visual off, so
* this only claims the state when a hover visual was wanted at all.
* @param {any} w
*/
static hoverOutlineOwnsHoverState(w) {
return (
Filters.isSliceChart(w) &&
w.config.states.hover.filter.type !== 'none' &&
w.config.plotOptions.pie.hoverOutline?.show === true
)
}
/**
* True when a pie / donut slice carries the selected state itself, by
* sliding out of the pie (see Pie.offsetSlice). Moving out of the pie is a
* strong enough signal on its own, so the states.active darken filter would
* only muddy the slice colour on top of it. polarArea is excluded: it never
* slides (its radius is the value), so it keeps the filter as its only
* click feedback.
* @param {any} w
*/
static sliceOffsetOwnsActiveState(w) {
return (
Filters.isSliceChart(w) &&
w.config.chart.type !== 'polarArea' &&
w.config.plotOptions.pie.expandOnClick === true &&
w.config.plotOptions.pie.expandOffset > 0 &&
!Filters.drilldownBlocksSliceOffset(w)
)
}
/**
* A drilldown pie / donut does not slide its slices out at all: a click there
* is navigation, so the slice would pull out and then be thrown away by the
* drill it just triggered, which reads as a glitch rather than as motion.
* The states.active filter comes back as the click feedback (it is instant,
* so the re-render lands on top of it rather than fighting it).
* @param {any} w
*/
static drilldownBlocksSliceOffset(w) {
return Filters.isSliceChart(w) && w.config.drilldown?.enabled === true
}
// create a re-usable filter which can be appended other filter effects and applied to multiple elements
/**
* @param {any} el
* @param {number} i
*/
getDefaultFilter(el, i) {
const w = this.w
if (el.unfilter) {
el.unfilter(true)
}
if (w.config.chart.dropShadow.enabled) {
this.dropShadow(el, w.config.chart.dropShadow, i)
}
}
/**
* @param {any} el
* @param {number} i
* @param {string} filterType
* @param {number} [intensity] Blend strength (0 to 1). Defaults per type.
*/
applyFilter(el, i, filterType, intensity) {
const w = this.w
if (el.unfilter) {
el.unfilter(true)
}
if (filterType === 'none') {
this.getDefaultFilter(el, i)
return
}
const shadowAttr = w.config.chart.dropShadow
// Blend the source colour towards white (lighten) or black (darken) by
// `t`, instead of applying a flat multiplicative gain. A gain of 2 clips
// every channel above 0.5 straight to pure white, which is why light
// series used to wash out entirely on hover; a convex blend can never
// leave the [0,1] range so nothing ever blows out or crushes to black.
//
// The per-channel shift is proportional to the head-room: t*(1-c) for
// lighten, t*c for darken. So an already-light colour is nudged only
// slightly on lighten (and an already-dark one only slightly on darken):
// the effect self-scales to the base colour, per pixel, with no need to
// read back the series colour (this keeps gradients/patterns correct too).
const fallback =
filterType === 'lighten'
? DEFAULT_INTENSITY.lighten
: DEFAULT_INTENSITY.darken
const t = Math.max(
0,
Math.min(1, typeof intensity === 'number' ? intensity : fallback),
)
const diag = 1 - t
const offset = filterType === 'lighten' ? t : 0
if (el.filterWith) {
/**
* @param {any} add
*/
el.filterWith((/** @type {any} */ add) => {
add.colorMatrix({
type: 'matrix',
values: `
${diag} 0 0 0 ${offset}
0 ${diag} 0 0 ${offset}
0 0 ${diag} 0 ${offset}
0 0 0 1 0
`,
in: 'SourceGraphic',
result: 'brightness',
})
if (shadowAttr.enabled) {
this.addShadow(add, i, shadowAttr, 'brightness')
}
})
if (!shadowAttr.noUserSpaceOnUse) {
el.filterer()?.node?.setAttribute('filterUnits', 'userSpaceOnUse')
}
// this scales the filter to a bigger size so that the dropshadow doesn't crops
this._scaleFilterSize(el.filterer()?.node)
}
}
// appends dropShadow to the filter object which can be chained with other filter effects
/**
* @param {any} add
* @param {number} i
* @param {Record<string, any>} attrs
* @param {string} source
*/
addShadow(add, i, attrs, source) {
const w = this.w
let { blur, top, left, color, opacity } = attrs
color = Array.isArray(color) ? color[i] : color
if (w.config.chart.dropShadow.enabledOnSeries?.length > 0) {
if (w.config.chart.dropShadow.enabledOnSeries.indexOf(i) === -1) {
return add
}
}
add.offset({
in: source,
dx: left,
dy: top,
result: 'offset',
})
add.gaussianBlur({
in: 'offset',
stdDeviation: blur,
result: 'blur',
})
add.flood({
'flood-color': color,
'flood-opacity': opacity,
result: 'flood',
})
add.composite({
in: 'flood',
in2: 'blur',
operator: 'in',
result: 'shadow',
})
add.merge(['shadow', source])
}
// directly adds dropShadow to the element and returns the same element.
/**
* @param {any} el
* @param {Record<string, any>} attrs
*/
dropShadow(el, attrs, i = 0) {
const w = this.w
if (el.unfilter) {
el.unfilter(true)
}
if (Utils.isMsEdge() && w.config.chart.type === 'radialBar') {
// in radialbar charts, dropshadow is clipping actual drawing in IE
return el
}
if (w.config.chart.dropShadow.enabledOnSeries?.length > 0) {
if (w.config.chart.dropShadow.enabledOnSeries?.indexOf(i) === -1) {
return el
}
}
if (el.filterWith) {
el.filterWith((/** @type {any} */ add) => {
this.addShadow(add, i, attrs, 'SourceGraphic')
})
if (!attrs.noUserSpaceOnUse) {
el.filterer()?.node?.setAttribute('filterUnits', 'userSpaceOnUse')
}
// this scales the filter to a bigger size so that the dropshadow doesn't crops
this._scaleFilterSize(el.filterer()?.node)
}
return el
}
/**
* @param {any} el
* @param {number} realIndex
* @param {number} dataPointIndex
*/
setSelectionFilter(el, realIndex, dataPointIndex) {
const w = this.w
if (typeof w.interact.selectedDataPoints[realIndex] !== 'undefined') {
if (
w.interact.selectedDataPoints[realIndex].indexOf(dataPointIndex) > -1
) {
el.node.setAttribute('selected', true)
// A pre-selected pie / donut slice is rendered slid out of the pie
// instead (Pie.pieClicked runs for it), so skip the darken here too.
if (Filters.sliceOffsetOwnsActiveState(w)) return
const activeFilter = w.config.states.active.filter
// activeFilter is an object ({ type, value }); compare its type, not the
// object itself, or the guard is always true and states.active.filter.type
// 'none' is silently ignored.
if (activeFilter.type !== 'none') {
this.applyFilter(el, realIndex, activeFilter.type, activeFilter.value)
}
}
}
}
/**
* @param {any} el
*/
_scaleFilterSize(el) {
if (!el) return
/**
* @param {Record<string, any>} attrs
*/
const setAttributes = (attrs) => {
for (const key in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, key)) {
el.setAttribute(key, attrs[key])
}
}
}
setAttributes({
width: '200%',
height: '200%',
x: '-50%',
y: '-50%',
})
}
}
export default Filters