diff --git a/nezha-fronted/src/assets/css/components/chart/chart.scss b/nezha-fronted/src/assets/css/components/chart/chart.scss
index 6e782a2e5..33d72199b 100644
--- a/nezha-fronted/src/assets/css/components/chart/chart.scss
+++ b/nezha-fronted/src/assets/css/components/chart/chart.scss
@@ -647,3 +647,9 @@
.tickLineFirst{
stroke: $--color-text-primary;;
}
+
+.text-ellipsis{
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/nezha-fronted/src/components/chart/ChartScreenHeader.vue b/nezha-fronted/src/components/chart/ChartScreenHeader.vue
index d7f31ae0c..ef9e3acb0 100644
--- a/nezha-fronted/src/components/chart/ChartScreenHeader.vue
+++ b/nezha-fronted/src/components/chart/ChartScreenHeader.vue
@@ -134,15 +134,17 @@ export default {
case 'table' :
case 'stat' :
case 'gauge' :
- case 'sankey' :
case 'pie' :
- case 'bubble' :
case 'treemap' :
case 'log' :
case 'hexagon' :
case 'diagram' :
case 'url':
case 'clock':
+ case 'bubble' :
+ case 'rank' :
+ case 'sankey' :
+ case 'funnel' :
return true
default: return false
}
diff --git a/nezha-fronted/src/components/chart/chart.vue b/nezha-fronted/src/components/chart/chart.vue
index c88136028..0f4d97750 100644
--- a/nezha-fronted/src/components/chart/chart.vue
+++ b/nezha-fronted/src/components/chart/chart.vue
@@ -26,24 +26,6 @@
:is-fullscreen="isFullscreen"
@chartIsNoData="chartIsNoData"
>
-
-
-
+
+
+
+
@@ -223,13 +232,10 @@ import chartClock from './chart/chartClock'
import chartDiagram from './chart/chartDiagram'
import chartEndpointInfo from './chart/chartEndpointInfo'
import chartGauge from './chart/chartGauge'
-import chartSankey from './chart/chartSankey'
import chartGroup from './chart/chartGroup'
import chartLog from './chart/chartLog'
import chartNoData from './chart/chartNoData'
import chartPie from './chart/chartPie'
-import chartBubble from './chart/chartBubble'
-import chartRank from './chart/chartRank'
import chartStat from './chart/chartStat'
import chartTable from './chart/chartTable'
import chartText from './chart/chartText'
@@ -240,7 +246,11 @@ import chartValue from './chart/chartValue'
import chartHexagonD3 from './chart/chartHexagonD3'
import chartMap from './chart/chartMap'
import chartTopology from './chart/chartTopology'
-import { getOption, isTimeSeries, isHexagon, isUrl, isText, isChartPie, isChartBubble, isChartRank, isChartBar, isTreemap, isLog, isStat, isDiagram, isGroup, isAutotopology, isMap, isAssetInfo, isEndpointInfo, isTable, isGauge, isSankey, isClock, isTopology } from './chart/tools'
+import chartBubble from './chart/chartBubble'
+import chartRank from './chart/chartRank'
+import chartSankey from './chart/chartSankey'
+import chartFunnel from './chart/chartFunnel'
+import { getOption, isTimeSeries, isHexagon, isUrl, isText, isChartPie, isChartBar, isTreemap, isLog, isStat, isDiagram, isGroup, isAutotopology, isMap, isAssetInfo, isEndpointInfo, isTable, isGauge, isClock, isTopology, isChartBubble, isChartRank, isSankey, isFunnel } from './chart/tools'
import lodash from 'lodash'
export default {
@@ -254,13 +264,10 @@ export default {
chartDiagram,
chartEndpointInfo,
chartGauge,
- chartSankey,
chartGroup,
chartLog,
chartNoData,
chartPie,
- chartBubble,
- chartRank,
chartStat,
chartTable,
chartText,
@@ -270,7 +277,11 @@ export default {
chartValue,
chartHexagonD3,
chartMap,
- chartTopology
+ chartTopology,
+ chartBubble,
+ chartRank,
+ chartSankey,
+ chartFunnel
},
props: {
chartInfo: Object,
@@ -328,8 +339,6 @@ export default {
isTimeSeries,
isHexagon,
isChartPie,
- isChartBubble,
- isChartRank,
isChartBar,
isUrl,
isText,
@@ -344,9 +353,12 @@ export default {
isMap,
isTable,
isGauge,
- isSankey,
isClock,
isTopology,
+ isChartBubble,
+ isChartRank,
+ isSankey,
+ isFunnel,
chartIsNoData (flag) {
this.chartChildrenData = flag
},
diff --git a/nezha-fronted/src/components/chart/chart/chartBubble.vue b/nezha-fronted/src/components/chart/chart/chartBubble.vue
index aba79fc2d..ff26a5f41 100644
--- a/nezha-fronted/src/components/chart/chart/chartBubble.vue
+++ b/nezha-fronted/src/components/chart/chart/chartBubble.vue
@@ -173,7 +173,10 @@ export default {
return d.y - d.r
})
.style('font-size', function (d) {
- return d.r / 3 > 10 ? d.r / 3 : 0
+ let fontSize
+ fontSize = d.r / 4 > 10 ? d.r / 4 : 0
+ fontSize = fontSize > 30 ? 30 : fontSize
+ return fontSize
})
.style('border-radius', '50%')
.html((d) => {
diff --git a/nezha-fronted/src/components/chart/chart/chartFunnel.vue b/nezha-fronted/src/components/chart/chart/chartFunnel.vue
new file mode 100644
index 000000000..50f0ee4e1
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/chartFunnel.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Colorizer.js b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Colorizer.js
new file mode 100644
index 000000000..c6c0db49a
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Colorizer.js
@@ -0,0 +1,177 @@
+class Colorizer {
+ /**
+ * @return {void}
+ */
+ constructor () {
+ this.hexExpression = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
+ this.instanceId = null
+ this.labelFill = null
+ this.scale = null
+ }
+
+ /**
+ * @param {string} instanceId
+ *
+ * @return {void}
+ */
+ setInstanceId (instanceId) {
+ this.instanceId = instanceId
+ }
+
+ /**
+ * @param {string} fill
+ *
+ * @return {void}
+ */
+ setLabelFill (fill) {
+ this.labelFill = fill
+ }
+
+ /**
+ * @param {function|Array} scale
+ *
+ * @return {void}
+ */
+ setScale (scale) {
+ this.scale = scale
+ }
+
+ /**
+ * Given a raw data block, return an appropriate color for the block.
+ *
+ * @param {string} fill
+ * @param {Number} index
+ * @param {string} fillType
+ *
+ * @return {Object}
+ */
+ getBlockFill (fill, index, fillType) {
+ const raw = this.getBlockRawFill(fill, index)
+
+ return {
+ raw,
+ actual: this.getBlockActualFill(raw, index, fillType)
+ }
+ }
+
+ /**
+ * Return the raw hex color for the block.
+ *
+ * @param {string} fill
+ * @param {Number} index
+ *
+ * @return {string}
+ */
+ getBlockRawFill (fill, index) {
+ // Use the block's color, if set and valid
+ if (this.hexExpression.test(fill)) {
+ return fill
+ }
+
+ // Otherwise, attempt to use the array scale
+ if (Array.isArray(this.scale)) {
+ return this.scale[index]
+ }
+
+ // Finally, use a functional scale
+ return this.scale(index)
+ }
+
+ /**
+ * Return the actual background for the block.
+ *
+ * @param {string} raw
+ * @param {Number} index
+ * @param {string} fillType
+ *
+ * @return {string}
+ */
+ getBlockActualFill (raw, index, fillType) {
+ if (fillType === 'solid') {
+ return raw
+ }
+
+ return `url(#${this.getGradientId(index)})`
+ }
+
+ /**
+ * Return the gradient ID for the given index.
+ *
+ * @param {Number} index
+ *
+ * @return {string}
+ */
+ getGradientId (index) {
+ return `${this.instanceId}-gradient-${index}`
+ }
+
+ /**
+ * Given a raw data block, return an appropriate label color.
+ *
+ * @param {string} labelFill
+ *
+ * @return {string}
+ */
+ getLabelColor (labelFill) {
+ return this.hexExpression.test(labelFill) ? labelFill : this.labelFill
+ }
+
+ /**
+ * Shade a color to the given percentage.
+ *
+ * @param {string} color A hex color.
+ * @param {number} shade The shade adjustment. Can be positive or negative.
+ *
+ * @return {string}
+ */
+ shade (color, shade) {
+ const { R, G, B } = this.hexToRgb(color)
+ const t = shade < 0 ? 0 : 255
+ const p = shade < 0 ? shade * -1 : shade
+
+ const converted = 0x1000000 +
+ ((Math.round((t - R) * p) + R) * 0x10000) +
+ ((Math.round((t - G) * p) + G) * 0x100) +
+ (Math.round((t - B) * p) + B)
+
+ return `#${converted.toString(16).slice(1)}`
+ }
+
+ /**
+ * Convert a hex color to an RGB object.
+ *
+ * @param {string} color
+ *
+ * @returns {{R: Number, G: number, B: number}}
+ */
+ hexToRgb (color) {
+ let hex = color.slice(1)
+
+ if (hex.length === 3) {
+ hex = this.expandHex(hex)
+ }
+
+ const f = parseInt(hex, 16)
+
+ /* eslint-disable no-bitwise */
+ const R = f >> 16
+ const G = (f >> 8) & 0x00FF
+ const B = f & 0x0000FF
+ /* eslint-enable */
+
+ return { R, G, B }
+ }
+
+ /**
+ * Expands a three character hex code to six characters.
+ *
+ * @param {string} hex
+ *
+ * @return {string}
+ */
+ expandHex (hex) {
+ return hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
+ }
+}
+
+export default Colorizer
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/D3Funnel.js b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/D3Funnel.js
new file mode 100644
index 000000000..e4b849453
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/D3Funnel.js
@@ -0,0 +1,1157 @@
+import { easeLinear } from 'd3-ease'
+import { range } from 'd3-array'
+import { scaleOrdinal } from 'd3-scale'
+import { schemeCategory10 } from 'd3-scale-chromatic'
+import { select } from 'd3-selection'
+import 'd3-transition'
+import { nanoid } from 'nanoid'
+
+import Colorizer from './Colorizer'
+import Formatter from './Formatter'
+import Navigator from './Navigator'
+import Utils from './Utils'
+
+class D3Funnel {
+ static defaults = {
+ chart: {
+ width: 350,
+ height: 400,
+ bottomWidth: 1 / 3,
+ bottomPinch: 0,
+ inverted: false,
+ horizontal: false,
+ animate: 0,
+ curve: {
+ enabled: false,
+ height: 20,
+ shade: -0.4
+ },
+ totalCount: null,
+ customAnimate:true
+ },
+ block: {
+ dynamicHeight: false,
+ dynamicSlope: false,
+ barOverlay: false,
+ fill: {
+ scale: scaleOrdinal(schemeCategory10).domain(range(0, 10)),
+ type: 'solid'
+ },
+ minHeight: 0,
+ highlight: false
+ },
+ label: {
+ enabled: true,
+ fontFamily: null,
+ fontSize: '14px',
+ fill: '#fff',
+ format: '{l}: {f}'
+ },
+ tooltip: {
+ enabled: false,
+ format: '{l}: {f}'
+ },
+ events: {
+ click: {
+ block: null
+ },
+ mouseenter: {
+ block: null
+ },
+ mousemove: {
+ block: null
+ },
+ mouseleave: {
+ block: null
+ }
+ }
+ };
+
+ /**
+ * @param {string|HTMLElement} selector A selector for the container element.
+ *
+ * @return {void}
+ */
+ constructor (selector) {
+ this.container = select(selector).node()
+
+ this.colorizer = new Colorizer()
+ this.formatter = new Formatter()
+ this.navigator = new Navigator()
+
+ this.id = null
+
+ // Bind event handlers
+ this.onMouseOver = this.onMouseOver.bind(this)
+ this.onMouseOut = this.onMouseOut.bind(this)
+ }
+
+ /**
+ * Remove the funnel and its events from the DOM.
+ *
+ * @return {void}
+ */
+ destroy () {
+ const container = select(this.container)
+
+ // D3's remove method appears to be sufficient for removing the events
+ container.selectAll('svg').remove()
+
+ // Remove other elements from container
+ container.selectAll('*').remove()
+
+ // Remove inner text from container
+ container.text('')
+ }
+
+ /**
+ * Draw the chart inside the container with the data and configuration
+ * specified. This will remove any previous SVG elements in the container
+ * and draw a new funnel chart on top of it.
+ *
+ * @param {Array} data A list of rows containing a category, a count,
+ * and optionally a color (in hex).
+ * @param {Object} options An optional configuration object to override
+ * defaults. See the docs.
+ *
+ * @return {void}
+ */
+ draw (data, options = {}) {
+ this.destroy()
+
+ this.initialize(data, options)
+
+ this.drawOntoDom()
+ }
+
+ /**
+ * Initialize and calculate important variables for drawing the chart.
+ *
+ * @param {Array} data
+ * @param {Object} options
+ *
+ * @return {void}
+ */
+ initialize (data, options) {
+ this.validateData(data)
+
+ const settings = this.getSettings(options)
+
+ this.id = `d3-funnel-${nanoid()}`
+
+ // Set labels
+ this.labelFormatter = this.formatter.getFormatter(settings.label.format)
+ this.tooltipFormatter = this.formatter.getFormatter(settings.tooltip.format)
+
+ // Set color scales
+ this.colorizer.setInstanceId(this.id)
+ this.colorizer.setLabelFill(settings.label.fill)
+ this.colorizer.setScale(settings.block.fill.scale)
+
+ // Initialize funnel chart settings
+ this.settings = {
+ width: settings.chart.width,
+ height: settings.chart.height,
+ bottomWidth: settings.chart.width * settings.chart.bottomWidth,
+ bottomPinch: settings.chart.bottomPinch,
+ isInverted: settings.chart.inverted,
+ isCurved: settings.chart.curve.enabled,
+ curveHeight: settings.chart.curve.height,
+ curveShade: settings.chart.curve.shade,
+ addValueOverlay: settings.block.barOverlay,
+ animation: settings.chart.animate,
+ totalCount: settings.chart.totalCount,
+ fillType: settings.block.fill.type,
+ hoverEffects: settings.block.highlight,
+ dynamicHeight: settings.block.dynamicHeight,
+ dynamicSlope: settings.block.dynamicSlope,
+ minHeight: settings.block.minHeight,
+ label: settings.label,
+ tooltip: settings.tooltip,
+ onBlockClick: settings.events.click.block,
+ onBlockEnter: settings.events.mouseenter.block,
+ onBlockMove: settings.events.mousemove.block,
+ onBlockLeave: settings.events.mouseleave.block,
+ customAnimate:settings.chart.customAnimate //opacity过渡
+ }
+
+ this.setBlocks(data)
+ }
+
+ /**
+ * @param {Array} data
+ *
+ * @return void
+ */
+ validateData (data) {
+ if (Array.isArray(data) === false) {
+ throw new Error('Data must be an array.')
+ }
+
+ if (data.length === 0) {
+ throw new Error('Data array must contain at least one element.')
+ }
+
+ if (typeof data[0] !== 'object') {
+ throw new Error('Data array elements must be an object.')
+ }
+
+ if (
+ (Array.isArray(data[0]) && data[0].length < 2) ||
+ (Array.isArray(data[0]) === false && (
+ data[0].label === undefined || data[0].value === undefined
+ ))
+ ) {
+ throw new Error('Data array elements must contain a label and value.')
+ }
+ }
+
+ /**
+ * @param {Object} options
+ *
+ * @return {Object}
+ */
+ getSettings (options) {
+ const containerDimensions = this.getContainerDimensions()
+ const defaults = this.getDefaultSettings(containerDimensions)
+
+ // Prepare the configuration settings based on the defaults
+ let settings = Utils.extend({}, defaults)
+
+ // Override default settings with user options
+ settings = Utils.extend(settings, options)
+
+ // Account for any percentage-based dimensions
+ settings.chart = {
+ ...settings.chart,
+ ...this.castDimensions(settings, containerDimensions)
+ }
+
+ return settings
+ }
+
+ /**
+ * Return default settings.
+ *
+ * @param {Object} containerDimensions
+ *
+ * @return {Object}
+ */
+ getDefaultSettings (containerDimensions) {
+ const settings = D3Funnel.defaults
+
+ // Set the default width and height based on the container
+ settings.chart = {
+ ...settings.chart,
+ ...containerDimensions
+ }
+
+ return settings
+ }
+
+ /**
+ * Get the width/height dimensions of the container.
+ *
+ * @return {{width: Number, height: Number}}
+ */
+ getContainerDimensions () {
+ const dimensions = {
+ width: parseFloat(select(this.container).style('width')),
+ height: parseFloat(select(this.container).style('height'))
+ };
+
+ // Remove container dimensions that resolve to zero
+ ['width', 'height'].forEach((direction) => {
+ if (dimensions[direction] === 0) {
+ delete dimensions[direction]
+ }
+ })
+
+ return dimensions
+ }
+
+ /**
+ * Cast dimensions into tangible or meaningful numbers.
+ *
+ * @param {Object} chart
+ * @param {Object} containerDimensions
+ *
+ * @return {{width: Number, height: Number}}
+ */
+ castDimensions ({ chart }, containerDimensions) {
+ const dimensions = {}
+
+ Object.keys(containerDimensions).forEach((direction) => {
+ const chartDimension = chart[direction]
+ const containerDimension = containerDimensions[direction]
+
+ if (/%$/.test(String(chartDimension))) {
+ // Convert string into a percentage of the container
+ dimensions[direction] = (parseFloat(chartDimension) / 100) * containerDimension
+ } else if (chartDimension <= 0) {
+ // If case of non-positive number, set to a usable number
+ dimensions[direction] = D3Funnel.defaults.chart[direction]
+ } else {
+ dimensions[direction] = chartDimension
+ }
+ })
+
+ return dimensions
+ }
+
+ /**
+ * Register the raw data into a standard block format and pre-calculate
+ * some values.
+ *
+ * @param {Array} data
+ *
+ * @return void
+ */
+ setBlocks (data) {
+ const totalCount = this.getTotalCount(data)
+
+ this.blocks = this.standardizeData(data, totalCount)
+ }
+
+ /**
+ * Return the total count of all blocks.
+ *
+ * @param {Array} data
+ *
+ * @return {Number}
+ */
+ getTotalCount (data) {
+ if (this.settings.totalCount !== null) {
+ return this.settings.totalCount || 0
+ }
+
+ return data.reduce((a, b) => a + Utils.getRawBlockCount(b), 0)
+ }
+
+ /**
+ * Convert the raw data into a standardized format.
+ *
+ * @param {Array} data
+ * @param {Number} totalCount
+ *
+ * @return {Array}
+ */
+ standardizeData (data, totalCount) {
+ return data.map((rawBlock, index) => {
+ const block = Array.isArray(rawBlock) ? Utils.convertLegacyBlock(rawBlock) : rawBlock
+ const ratio = totalCount > 0 ? (block.value / totalCount || 0) : 1 / data.length
+
+ return {
+ index,
+ ratio,
+ value: block.value,
+ height: this.settings.height * ratio,
+ fill: this.colorizer.getBlockFill(
+ block.backgroundColor,
+ index,
+ this.settings.fillType
+ ),
+ label: {
+ enabled: !block.hideLabel,
+ raw: block.label,
+ formatted: this.formatter.format(block, this.labelFormatter),
+ color: this.colorizer.getLabelColor(block.labelColor)
+ },
+ tooltip: {
+ enabled: block.enabled,
+ formatted: this.formatter.format(block, this.tooltipFormatter)
+ },
+ data: Utils.extend({}, block)
+ }
+ })
+ }
+
+ /**
+ * Draw the chart onto the DOM.
+ *
+ * @return {void}
+ */
+ drawOntoDom () {
+ // Add the SVG
+ this.svg = select(this.container)
+ .append('svg')
+ .attr('id', this.id)
+ .attr('width', this.settings.width)
+ .attr('height', this.settings.height);
+
+ [this.blockPaths, this.overlayPaths] = this.makePaths()
+
+ // Define color gradients
+ if (this.settings.fillType === 'gradient') {
+ this.defineColorGradients(this.svg)
+ }
+
+ // Add top oval if curved
+ if (this.settings.isCurved) {
+ this.drawTopOval(this.svg, this.blockPaths)
+ }
+
+ // Add each block
+ this.drawBlock(0)
+ }
+
+ /**
+ * Create the paths to be used to define the discrete funnel blocks and
+ * returns the results in an array.
+ *
+ * @return {Array, Array}
+ */
+ makePaths () {
+ // Calculate the important fixed positions
+ const bottomLeftX = (this.settings.width - this.settings.bottomWidth) / 2
+ const centerX = this.settings.width / 2
+
+ let paths = []
+ let overlayPaths = []
+
+ // Calculate change in x, y direction
+ this.dx = this.getDx(bottomLeftX)
+ this.dy = this.getDy()
+
+ // Initialize velocity
+ let { dx, dy } = this
+
+ // Initialize starting positions
+ let prevLeftX = 0
+ let prevRightX = this.settings.width
+ let prevHeight = 0
+
+ // Start from the bottom for inverted
+ if (this.settings.isInverted) {
+ prevLeftX = bottomLeftX
+ prevRightX = this.settings.width - bottomLeftX
+ }
+
+ // Initialize next positions
+ let nextLeftX = 0
+ let nextRightX = 0
+ let nextHeight = 0
+
+ // Move down if there is an initial curve
+ if (this.settings.isCurved) {
+ prevHeight = this.settings.curveHeight / 2
+ }
+
+ let totalHeight = this.settings.height
+
+ // This is greedy in that the block will have a guaranteed height
+ // and the remaining is shared among the ratio, instead of being
+ // shared according to the remaining minus the guaranteed
+ if (this.settings.minHeight !== 0) {
+ totalHeight = this.settings.height - (this.settings.minHeight * this.blocks.length)
+ }
+
+ let slopeHeight = this.settings.height
+
+ // Correct slope height if there are blocks being pinched (and thus
+ // requiring a sharper curve)
+ if (this.settings.bottomPinch > 0) {
+ this.blocks.forEach((block, i) => {
+ let height = (totalHeight * block.ratio)
+
+ // Add greedy minimum height
+ if (this.settings.minHeight !== 0) {
+ height += this.settings.minHeight
+ }
+
+ // Account for any curvature
+ if (this.settings.isCurved) {
+ height += this.settings.curveHeight / this.blocks.length
+ }
+
+ if (this.settings.isInverted) {
+ if (i < this.settings.bottomPinch) {
+ slopeHeight -= height
+ }
+ } else if (i >= this.blocks.length - this.settings.bottomPinch) {
+ slopeHeight -= height
+ }
+ })
+ }
+
+ // The slope will determine the x points on each block iteration
+ // Given: slope = (y1 - y2) / (x1 - x2)
+ // (x1, y1) = (bottomLeftX, height)
+ // (x2, y2) = (0, 0)
+ const slope = slopeHeight / bottomLeftX
+
+ // Create the path definition for each funnel block
+ // Remember to loop back to the beginning point for a closed path
+ this.blocks.forEach((block, i) => {
+ // Make heights proportional to block weight
+ if (this.settings.dynamicHeight) {
+ // Slice off the height proportional to this block
+ dy = totalHeight * block.ratio
+
+ // Add greedy minimum height
+ if (this.settings.minHeight !== 0) {
+ dy += this.settings.minHeight
+ }
+
+ // Account for any curvature
+ if (this.settings.isCurved) {
+ dy -= this.settings.curveHeight / this.blocks.length
+ }
+
+ // Given: y = mx + b
+ // Given: b = 0 (when funnel), b = this.settings.height (when pyramid)
+ // For funnel, x_i = y_i / slope
+ nextLeftX = (prevHeight + dy) / slope
+
+ // For pyramid, x_i = y_i - this.settings.height / -slope
+ if (this.settings.isInverted) {
+ nextLeftX = ((prevHeight + dy) - this.settings.height) / (-1 * slope)
+ }
+
+ // If bottomWidth is 0, adjust last x position (to circumvent
+ // errors associated with rounding)
+ if (this.settings.bottomWidth === 0 && i === this.blocks.length - 1) {
+ // For funnel, last position is the center
+ nextLeftX = this.settings.width / 2
+
+ // For pyramid, last position is the origin
+ if (this.settings.isInverted) {
+ nextLeftX = 0
+ }
+ }
+
+ // If bottomWidth is same as width, stop x velocity
+ if (this.settings.bottomWidth === this.settings.width) {
+ nextLeftX = prevLeftX
+ }
+
+ // Prevent NaN or Infinite values (caused by zero heights)
+ if (Number.isNaN(nextLeftX) || !Number.isFinite(nextLeftX)) {
+ nextLeftX = 0
+ }
+
+ // Calculate the shift necessary for both x points
+ dx = nextLeftX - prevLeftX
+
+ if (this.settings.isInverted) {
+ dx = prevLeftX - nextLeftX
+ }
+ }
+
+ // Make slope width proportional to change in block value
+ if (this.settings.dynamicSlope && !this.settings.isInverted) {
+ const nextBlockValue = this.blocks[i + 1]
+ ? this.blocks[i + 1].value
+ : block.value
+
+ const widthRatio = nextBlockValue / block.value
+ dx = (1 - widthRatio) * (centerX - prevLeftX)
+ }
+
+ // Stop velocity for pinched blocks
+ if (this.settings.bottomPinch > 0) {
+ // Check if we've reached the bottom of the pinch
+ // If so, stop changing on x
+ if (!this.settings.isInverted) {
+ if (i >= this.blocks.length - this.settings.bottomPinch) {
+ dx = 0
+ }
+ // Pinch at the first blocks relating to the bottom pinch
+ // Revert back to normal velocity after pinch
+ } else {
+ // Revert velocity back to the initial if we are using
+ // static heights (prevents zero velocity if isInverted
+ // and bottomPinch are non trivial and dynamicHeight is
+ // false)
+ if (!this.settings.dynamicHeight) {
+ ({ dx } = this)
+ }
+
+ dx = i < this.settings.bottomPinch ? 0 : dx
+ }
+ }
+
+ // Calculate the position of next block
+ nextLeftX = prevLeftX + dx
+ nextRightX = prevRightX - dx
+ nextHeight = prevHeight + dy
+
+ this.blocks[i].height = dy
+
+ // Expand outward if inverted
+ if (this.settings.isInverted) {
+ nextLeftX = prevLeftX - dx
+ nextRightX = prevRightX + dx
+ }
+
+ const dimensions = {
+ centerX,
+ prevLeftX,
+ prevRightX,
+ prevHeight,
+ nextLeftX,
+ nextRightX,
+ nextHeight,
+ curveHeight: this.settings.curveHeight,
+ ratio: block.ratio
+ }
+
+ if (this.settings.isCurved) {
+ paths = [...paths, this.navigator.makeCurvedPaths(dimensions)]
+
+ if (this.settings.addValueOverlay) {
+ overlayPaths = [
+ ...overlayPaths,
+ this.navigator.makeCurvedPaths(dimensions, true)
+ ]
+ }
+ } else {
+ paths = [...paths, this.navigator.makeStraightPaths(dimensions)]
+
+ if (this.settings.addValueOverlay) {
+ overlayPaths = [
+ ...overlayPaths,
+ this.navigator.makeStraightPaths(dimensions, true)
+ ]
+ }
+ }
+
+ // Set the next block's previous position
+ prevLeftX = nextLeftX
+ prevRightX = nextRightX
+ prevHeight = nextHeight
+ })
+
+ return [paths, overlayPaths]
+ }
+
+ /**
+ * @param {Number} bottomLeftX
+ *
+ * @return {Number}
+ */
+ getDx (bottomLeftX) {
+ // Will be sharper if there is a pinch
+ if (this.settings.bottomPinch > 0) {
+ return bottomLeftX / (this.blocks.length - this.settings.bottomPinch)
+ }
+
+ return bottomLeftX / this.blocks.length
+ }
+
+ /**
+ * @return {Number}
+ */
+ getDy () {
+ // Curved chart needs reserved pixels to account for curvature
+ if (this.settings.isCurved) {
+ return (this.settings.height - this.settings.curveHeight) / this.blocks.length
+ }
+
+ return this.settings.height / this.blocks.length
+ }
+
+ /**
+ * Define the linear color gradients.
+ *
+ * @param {Object} svg
+ *
+ * @return {void}
+ */
+ defineColorGradients (svg) {
+ const defs = svg.append('defs')
+
+ // Create a gradient for each block
+ this.blocks.forEach((block, index) => {
+ const color = block.fill.raw
+ const shade = this.colorizer.shade(color, -0.2)
+
+ // Create linear gradient
+ const gradient = defs.append('linearGradient')
+ .attr('id', this.colorizer.getGradientId(index))
+
+ // Define the gradient stops
+ const stops = [
+ [0, shade],
+ [40, color],
+ [60, color],
+ [100, shade]
+ ]
+
+ // Add the gradient stops
+ stops.forEach((stop) => {
+ gradient.append('stop')
+ .attr('offset', `${stop[0]}%`)
+ .attr('style', `stop-color: ${stop[1]}`)
+ })
+ })
+ }
+
+ /**
+ * Draw the top oval of a curved funnel.
+ *
+ * @param {Object} svg
+ * @param {Array} blockPaths
+ *
+ * @return {void}
+ */
+ drawTopOval (svg, blockPaths) {
+ const centerX = this.settings.width / 2
+
+ // Create path from top-most block
+ const paths = blockPaths[0]
+ const topCurve = paths[1][1] + (this.settings.curveHeight / 2)
+
+ const path = this.navigator.plot([
+ ['M', paths[0][0], paths[0][1]],
+ ['Q', centerX, topCurve],
+ [' ', paths[2][0], paths[2][1]],
+ ['M', paths[2][0], this.settings.curveHeight / 2],
+ ['Q', centerX, 0],
+ [' ', paths[0][0], this.settings.curveHeight / 2]
+ ])
+
+ // Draw top oval
+ svg.append('path')
+ .attr('fill', this.colorizer.shade(this.blocks[0].fill.raw, this.settings.curveShade))
+ .attr('d', path)
+ }
+
+ /**
+ * Draw the next block in the iteration.
+ *
+ * @param {int} index
+ *
+ * @return {void}
+ */
+ drawBlock (index) {
+ if (index === this.blocks.length) {
+ return
+ }
+
+ // Create a group just for this block
+ const group = this.svg.append('g')
+ const block = this.blocks[index]
+
+ // Fetch path element
+ const path = this.getBlockPath(group, index)
+
+ path.style('cursor', 'pointer')
+
+ // Attach data to the element
+ this.attachData(path, block)
+
+ let overlayPath = null
+ let pathColor = block.fill.actual
+
+ if (this.settings.addValueOverlay) {
+ overlayPath = this.getOverlayPath(group, index)
+ this.attachData(overlayPath, block)
+
+ // Add data attribute to distinguish between paths
+ path.node().setAttribute('pathType', 'background')
+ overlayPath.node().setAttribute('pathType', 'foreground')
+
+ // Default path becomes background of lighter shade
+ pathColor = this.colorizer.shade(block.fill.raw, 0.3)
+ }
+
+ // Add animation components
+ if (this.settings.animation !== 0) {
+ path.transition()
+ .duration(this.settings.animation)
+ .ease(easeLinear)
+ .attr('fill', pathColor)
+ .attr('d', this.getPathDefinition(index))
+ .on('end', () => {
+ this.drawBlock(index + 1)
+ })
+ } else {
+ path.attr('fill', pathColor)
+ .attr('d', this.getPathDefinition(index))
+ .style('opacity', 0)
+ .transition('opacity').duration(this.settings.customAnimate ? 600 : 0)
+ .style('opacity', 1)
+ this.drawBlock(index + 1)
+ }
+
+ // Add path overlay
+ if (this.settings.addValueOverlay) {
+ path.attr('stroke', this.blocks[index].fill.raw)
+
+ if (this.settings.animation !== 0) {
+ overlayPath.transition()
+ .duration(this.settings.animation)
+ .ease(easeLinear)
+ .attr('fill', block.fill.actual)
+ .attr('d', this.getOverlayPathDefinition(index))
+ } else {
+ overlayPath.attr('fill', block.fill.actual)
+ .attr('d', this.getOverlayPathDefinition(index))
+ }
+ }
+
+ // Add the hover events
+ if (this.settings.hoverEffects) {
+ [path, overlayPath].forEach((target) => {
+ if (!target) {
+ return
+ }
+
+ target
+ .on('mouseover', this.onMouseOver)
+ .on('mouseout', this.onMouseOut)
+ })
+ }
+
+ // Add block click event
+ if (this.settings.onBlockClick !== null) {
+ [path, overlayPath].forEach((target) => {
+ if (!target) {
+ return
+ }
+
+ target.style('cursor', 'pointer')
+ .on('click', this.settings.onBlockClick)
+ })
+ }
+
+ // 自定义tooltip
+ [path, overlayPath].forEach((target) => {
+ if (!target) {
+ return
+ }
+ target
+ .on('mouseenter', this.settings.onBlockEnter)
+ .on('mousemove', this.settings.onBlockMove)
+ .on('mouseleave', this.settings.onBlockLeave)
+ })
+
+ // Add tooltips
+ if (this.settings.tooltip.enabled) {
+ [path, overlayPath].forEach((target) => {
+ if (!target) {
+ return
+ }
+
+ target.node().addEventListener('mouseout', () => {
+ if (this.tooltip) {
+ this.container.removeChild(this.tooltip)
+ this.tooltip = null
+ }
+ })
+ target.node().addEventListener('mousemove', (e) => {
+ if (!this.tooltip) {
+ this.tooltip = document.createElement('div')
+ this.tooltip.setAttribute('class', 'd3-funnel-tooltip')
+ this.container.appendChild(this.tooltip)
+ }
+
+ this.tooltip.innerText = block.tooltip.formatted
+
+ const width = this.tooltip.offsetWidth
+ const height = this.tooltip.offsetHeight
+ const rect = this.container.getBoundingClientRect()
+ const heightOffset = height + 5
+ const containerY = rect.y + window.scrollY
+ const isAbove = e.pageY - heightOffset < containerY
+ const top = isAbove ? e.pageY + 5 : e.pageY - heightOffset
+
+ const styles = [
+ 'display: inline-block',
+ 'position: absolute',
+ `left: ${e.pageX - (width / 2)}px`,
+ `top: ${top}px`,
+ `border: 1px solid ${block.fill.raw}`,
+ 'background: rgb(255,255,255,0.75)',
+ 'padding: 5px 15px',
+ 'color: #000',
+ 'font-size: 14px',
+ 'font-weight: bold',
+ 'text-align: center',
+ 'cursor: default',
+ 'pointer-events: none'
+ ]
+ this.tooltip.setAttribute('style', styles.join(';'))
+ })
+ })
+ }
+
+ if (this.settings.label.enabled && block.label.enabled) {
+ this.addBlockLabel(group, index)
+ }
+ }
+
+ /**
+ * @param {Object} group
+ * @param {int} index
+ *
+ * @return {Object}
+ */
+ getBlockPath (group, index) {
+ const path = group.append('path')
+
+ if (this.settings.animation !== 0) {
+ this.addBeforeTransition(path, index, false)
+ }
+
+ return path
+ }
+
+ /**
+ * @param {Object} group
+ * @param {int} index
+ *
+ * @return {Object}
+ */
+ getOverlayPath (group, index) {
+ const path = group.append('path')
+
+ if (this.settings.animation !== 0) {
+ this.addBeforeTransition(path, index, true)
+ }
+
+ return path
+ }
+
+ /**
+ * Set the attributes of a path element before its animation.
+ *
+ * @param {Object} path
+ * @param {int} index
+ * @param {boolean} isOverlay
+ *
+ * @return {void}
+ */
+ addBeforeTransition (path, index, isOverlay) {
+ const paths = isOverlay ? this.overlayPaths[index] : this.blockPaths[index]
+
+ let beforePath = ''
+ let beforeFill = ''
+
+ // Construct the top of the trapezoid and leave the other elements
+ // hovering around to expand downward on animation
+ if (!this.settings.isCurved) {
+ beforePath = this.navigator.plot([
+ ['M', paths[0][0], paths[0][1]],
+ ['L', paths[1][0], paths[1][1]],
+ ['L', paths[1][0], paths[1][1]],
+ ['L', paths[0][0], paths[0][1]]
+ ])
+ } else {
+ beforePath = this.navigator.plot([
+ ['M', paths[0][0], paths[0][1]],
+ ['Q', paths[1][0], paths[1][1]],
+ [' ', paths[2][0], paths[2][1]],
+ ['L', paths[2][0], paths[2][1]],
+ ['M', paths[2][0], paths[2][1]],
+ ['Q', paths[1][0], paths[1][1]],
+ [' ', paths[0][0], paths[0][1]]
+ ])
+ }
+
+ // Use previous fill color, if available
+ if (this.settings.fillType === 'solid' && index > 0) {
+ beforeFill = this.blocks[index - 1].fill.actual
+ // Otherwise use current background
+ } else {
+ beforeFill = this.blocks[index].fill.actual
+ }
+
+ path.attr('d', beforePath)
+ .attr('fill', beforeFill)
+ }
+
+ /**
+ * Attach data to the target element. Also attach the current node to the
+ * data object.
+ *
+ * @param {Object} element
+ * @param {Object} data
+ *
+ * @return {void}
+ */
+ attachData (element, data) {
+ const nodeData = {
+ ...data,
+ node: element.node()
+ }
+
+ element.data([nodeData])
+ }
+
+ /**
+ * @param {int} index
+ *
+ * @return {string}
+ */
+ getPathDefinition (index) {
+ const commands = []
+
+ this.blockPaths[index].forEach((command) => {
+ commands.push([command[2], command[0], command[1]])
+ })
+
+ return this.navigator.plot(commands)
+ }
+
+ /**
+ * @param {int} index
+ *
+ * @return {string}
+ */
+ getOverlayPathDefinition (index) {
+ const commands = []
+
+ this.overlayPaths[index].forEach((command) => {
+ commands.push([command[2], command[0], command[1]])
+ })
+
+ return this.navigator.plot(commands)
+ }
+
+ /**
+ * @param {Object} event
+ * @param {Object} data
+ *
+ * @return {void}
+ */
+ onMouseOver (event, data) {
+ const children = event.target.parentElement.childNodes;
+
+ // Highlight all paths within one block
+ [...children].forEach((node) => {
+ if (node.nodeName.toLowerCase() === 'path') {
+ const type = node.getAttribute('pathType') || ''
+ if (type === 'foreground') {
+ select(node).attr('fill', this.colorizer.shade(data.fill.raw, -0.5))
+ } else {
+ select(node).transition('fill').attr('fill', this.colorizer.shade(data.fill.raw, 0.2))
+ }
+ }
+ })
+ }
+
+ /**
+ * @param {Object} event
+ * @param {Object} data
+ *
+ * @return {void}
+ */
+ onMouseOut (event, data) {
+ const children = event.target.parentElement.childNodes;
+
+ // Restore original color for all paths of a block
+ [...children].forEach((node) => {
+ if (node.nodeName.toLowerCase() === 'path') {
+ const type = node.getAttribute('pathType') || ''
+ if (type === 'background') {
+ const backgroundColor = this.colorizer.shade(data.fill.raw, 0.3)
+ select(node).attr('fill', backgroundColor)
+ } else {
+ select(node).transition('fill').attr('fill', data.fill.actual)
+ }
+ }
+ })
+ }
+
+ /**
+ * @param {Object} group
+ * @param {int} index
+ *
+ * @return {void}
+ */
+ addBlockLabel (group, index) {
+ const paths = this.blockPaths[index]
+
+ const formattedLabel = this.blocks[index].label.formatted
+ // const fill = this.blocks[index].label.color
+
+ // Center the text
+ // const x = this.settings.width / 2
+ // const y = this.getTextY(paths)
+
+ // const text = group.append('text')
+ // .attr('x', x)
+ // .attr('y', y)
+ // .attr('fill', fill)
+ // .attr('font-size', this.settings.label.fontSize)
+ // .attr('text-anchor', 'middle')
+ // .attr('dominant-baseline', 'middle')
+ // .attr('pointer-events', 'none')
+
+ // // Add font-family, if exists
+ // if (this.settings.label.fontFamily !== null) {
+ // text.attr('font-family', this.settings.label.fontFamily)
+ // }
+
+ // this.addLabelLines(text, formattedLabel, x)
+
+ // 修改源码使用foreignObject支持HTML 显示图标
+ const foreignObject = group.append('foreignObject')
+ const block = this.blocks[index]
+ this.attachData(foreignObject, block)
+
+ foreignObject.attr('width', (d) => {
+ return paths[2][0] - paths[3][0]
+ })
+ .attr('x', paths[3][0])
+ .attr('y', paths[0][1])
+ .attr('height', (d) => {
+ return d.height
+ })
+ .html(d=>{
+ return d.height>32?formattedLabel:''
+ })
+ .attr('pointer-events', 'none')
+ .style('opacity', 0)
+ .transition('opacity').duration(this.settings.customAnimate ? 800 : 0)
+ .style('opacity', 1)
+ .style('cursor', 'pointer')
+ }
+
+ /**
+ * Add elements for each line of the formatted label.
+ *
+ * @param {Object} text
+ * @param {String} formattedLabel
+ * @param {Number} x
+ *
+ * @return {void}
+ */
+ addLabelLines (text, formattedLabel, x) {
+ const lines = formattedLabel.split('\n')
+ const lineHeight = 20
+
+ // dy will signify the change from the initial height y
+ // We need to initially start the first line at the very top, factoring
+ // in the other number of lines
+ const initialDy = (-1 * lineHeight * (lines.length - 1)) / 2
+
+ lines.forEach((line, i) => {
+ const dy = i === 0 ? initialDy : lineHeight
+
+ text.append('tspan').attr('x', x).attr('dy', dy).text(line)
+ })
+ }
+
+ /**
+ * Returns the y position of the given label's text. This is determined by
+ * taking the mean of the bases.
+ *
+ * @param {Array} paths
+ *
+ * @return {Number}
+ */
+ getTextY (paths) {
+ const { isCurved, curveHeight } = this.settings
+
+ if (isCurved) {
+ return ((paths[2][1] + paths[3][1]) / 2) + ((1.5 * curveHeight) / this.blocks.length)
+ }
+
+ return (paths[1][1] + paths[2][1]) / 2
+ }
+}
+
+export default D3Funnel
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Formatter.js b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Formatter.js
new file mode 100644
index 000000000..90daee6c5
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Formatter.js
@@ -0,0 +1,75 @@
+class Formatter {
+ /**
+ * Register the format function.
+ *
+ * @param {string|function} format
+ *
+ * @return {function}
+ */
+ getFormatter (format) {
+ if (typeof format === 'function') {
+ return format
+ }
+
+ return (label, value, formattedValue) => (
+ this.stringFormatter(label, value, formattedValue, format)
+ )
+ }
+
+ /**
+ * Format the given value according to the data point or the format.
+ *
+ * @param {string} label
+ * @param {number} value
+ * @param {*} formattedValue
+ * @param {function} formatter
+ *
+ * @return string
+ */
+ format ({ label, value, formattedValue = null }, formatter) {
+ return formatter(label, value, formattedValue, arguments[0])
+ }
+
+ /**
+ * Format the string according to a simple expression.
+ *
+ * {l}: label
+ * {v}: raw value
+ * {f}: formatted value
+ *
+ * @param {string} label
+ * @param {number} value
+ * @param {*} formattedValue
+ * @param {string} expression
+ *
+ * @return {string}
+ */
+ stringFormatter (label, value, formattedValue, expression) {
+ let formatted = formattedValue
+
+ // Attempt to use supplied formatted value
+ // Otherwise, use the default
+ if (formattedValue === null) {
+ formatted = this.getDefaultFormattedValue(value)
+ }
+
+ return expression
+ .split('{l}')
+ .join(label)
+ .split('{v}')
+ .join(value)
+ .split('{f}')
+ .join(formatted)
+ }
+
+ /**
+ * @param {number} value
+ *
+ * @return {string}
+ */
+ getDefaultFormattedValue (value) {
+ return value.toLocaleString()
+ }
+}
+
+export default Formatter
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Navigator.js b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Navigator.js
new file mode 100644
index 000000000..6c63d03cc
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Navigator.js
@@ -0,0 +1,245 @@
+class Navigator {
+ /**
+ * Given a list of path commands, returns the compiled description.
+ *
+ * @param {Array} commands
+ *
+ * @return {string}
+ */
+ plot (commands) {
+ let path = ''
+
+ commands.forEach((command) => {
+ path += `${command[0]}${command[1]},${command[2]} `
+ })
+
+ return path.replace(/ +/g, ' ').trim()
+ }
+
+ /**
+ * @param {Object} dimensions
+ * @param {boolean} isValueOverlay
+ *
+ * @return {Array}
+ */
+ makeCurvedPaths (dimensions, isValueOverlay = false) {
+ const points = this.makeBezierPoints(dimensions)
+
+ if (isValueOverlay) {
+ return this.makeBezierPath(points, dimensions.ratio)
+ }
+
+ return this.makeBezierPath(points)
+ }
+
+ /**
+ * @param {Number} centerX
+ * @param {Number} prevLeftX
+ * @param {Number} prevRightX
+ * @param {Number} prevHeight
+ * @param {Number} nextLeftX
+ * @param {Number} nextRightX
+ * @param {Number} nextHeight
+ * @param {Number} curveHeight
+ *
+ * @return {Object}
+ */
+ makeBezierPoints ({
+ centerX,
+ prevLeftX,
+ prevRightX,
+ prevHeight,
+ nextLeftX,
+ nextRightX,
+ nextHeight,
+ curveHeight
+ }) {
+ return {
+ p00: {
+ x: prevLeftX,
+ y: prevHeight
+ },
+ p01: {
+ x: centerX,
+ y: prevHeight + (curveHeight / 2)
+ },
+ p02: {
+ x: prevRightX,
+ y: prevHeight
+ },
+
+ p10: {
+ x: nextLeftX,
+ y: nextHeight
+ },
+ p11: {
+ x: centerX,
+ y: nextHeight + curveHeight
+ },
+ p12: {
+ x: nextRightX,
+ y: nextHeight
+ }
+ }
+ }
+
+ /**
+ * @param {Object} p00
+ * @param {Object} p01
+ * @param {Object} p02
+ * @param {Object} p10
+ * @param {Object} p11
+ * @param {Object} p12
+ * @param {Number} ratio
+ *
+ * @return {Array}
+ */
+ makeBezierPath ({
+ p00,
+ p01,
+ p02,
+ p10,
+ p11,
+ p12
+ }, ratio = 1) {
+ const curve0 = this.getQuadraticBezierCurve(p00, p01, p02, ratio)
+ const curve1 = this.getQuadraticBezierCurve(p10, p11, p12, ratio)
+
+ return [
+ // Top Bezier curve
+ [curve0.p0.x, curve0.p0.y, 'M'],
+ [curve0.p1.x, curve0.p1.y, 'Q'],
+ [curve0.p2.x, curve0.p2.y, ''],
+ // Right line
+ [curve1.p2.x, curve1.p2.y, 'L'],
+ // Bottom Bezier curve
+ [curve1.p2.x, curve1.p2.y, 'M'],
+ [curve1.p1.x, curve1.p1.y, 'Q'],
+ [curve1.p0.x, curve1.p0.y, ''],
+ // Left line
+ [curve0.p0.x, curve0.p0.y, 'L']
+ ]
+ }
+
+ /**
+ * @param {Object} p0
+ * @param {Object} p1
+ * @param {Object} p2
+ * @param {Number} t
+ *
+ * @return {Object}
+ */
+ getQuadraticBezierCurve (p0, p1, p2, t = 1) {
+ // Quadratic Bezier curve syntax: M(P0) Q(P1) P2
+ // Where P0, P2 are the curve endpoints and P1 is the control point
+
+ // More generally, at 0 <= t <= 1, we have the following:
+ // Q0(t), which varies linearly from P0 to P1
+ // Q1(t), which varies linearly from P1 to P2
+ // B(t), which is interpolated linearly between Q0(t) and Q1(t)
+
+ // For an intermediate curve at 0 <= t <= 1:
+ // P1(t) = Q0(t)
+ // P2(t) = B(t)
+
+ return {
+ p0,
+ p1: {
+ x: this.getLinearInterpolation(p0, p1, t, 'x'),
+ y: this.getLinearInterpolation(p0, p1, t, 'y')
+ },
+ p2: {
+ x: this.getQuadraticInterpolation(p0, p1, p2, t, 'x'),
+ y: this.getQuadraticInterpolation(p0, p1, p2, t, 'y')
+ }
+ }
+ }
+
+ /**
+ * @param {Object} p0
+ * @param {Object} p1
+ * @param {Number} t
+ * @param {string} axis
+ *
+ * @return {Number}
+ */
+ getLinearInterpolation (p0, p1, t, axis) {
+ return p0[axis] + (t * (p1[axis] - p0[axis]))
+ }
+
+ /**
+ * @param {Object} p0
+ * @param {Object} p1
+ * @param {Object} p2
+ * @param {Number} t
+ * @param {string} axis
+ *
+ * @return {Number}
+ */
+ getQuadraticInterpolation (p0, p1, p2, t, axis) {
+ return (((1 - t) ** 2) * p0[axis]) +
+ (2 * (1 - t) * t * p1[axis]) +
+ ((t ** 2) * p2[axis])
+ }
+
+ /**
+ * @param {Number} prevLeftX
+ * @param {Number} prevRightX
+ * @param {Number} prevHeight
+ * @param {Number} nextLeftX
+ * @param {Number} nextRightX
+ * @param {Number} nextHeight
+ * @param {Number} ratio
+ * @param {boolean} isValueOverlay
+ *
+ * @return {Object}
+ */
+ makeStraightPaths ({
+ prevLeftX,
+ prevRightX,
+ prevHeight,
+ nextLeftX,
+ nextRightX,
+ nextHeight,
+ ratio
+ }, isValueOverlay = false) {
+ if (isValueOverlay) {
+ const lengthTop = (prevRightX - prevLeftX)
+ const lengthBtm = (nextRightX - nextLeftX)
+ let rightSideTop = (lengthTop * (ratio || 0)) + prevLeftX
+ let rightSideBtm = (lengthBtm * (ratio || 0)) + nextLeftX
+
+ // Overlay should not be longer than the max length of the path
+ rightSideTop = Math.min(rightSideTop, lengthTop)
+ rightSideBtm = Math.min(rightSideBtm, lengthBtm)
+
+ return [
+ // Start position
+ [prevLeftX, prevHeight, 'M'],
+ // Move to right
+ [rightSideTop, prevHeight, 'L'],
+ // Move down
+ [rightSideBtm, nextHeight, 'L'],
+ // Move to left
+ [nextLeftX, nextHeight, 'L'],
+ // Wrap back to top
+ [prevLeftX, prevHeight, 'L']
+ ]
+ }
+
+ return [
+ // Start position
+ [prevLeftX, prevHeight, 'M'],
+ // Move to right
+ [prevRightX, prevHeight, 'L'],
+ // Move down
+ [nextRightX, nextHeight, 'L'],
+ // Move to left
+ [nextLeftX, nextHeight, 'L'],
+ // Wrap back to top
+ [prevLeftX, prevHeight, 'L']
+ ]
+ }
+}
+
+export default Navigator
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Utils.js b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Utils.js
new file mode 100644
index 000000000..953dd00c2
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/d3-funnel/Utils.js
@@ -0,0 +1,78 @@
+class Utils {
+ /**
+ * Determine whether the given parameter is an extendable object.
+ *
+ * @param {*} a
+ *
+ * @return {boolean}
+ */
+ static isExtendableObject (a) {
+ return typeof a === 'object' && a !== null && !Array.isArray(a)
+ }
+
+ /**
+ * Extends an object with the members of another.
+ *
+ * @param {Object} a The object to be extended.
+ * @param {Object} b The object to clone from.
+ *
+ * @return {Object}
+ */
+ static extend (a, b) {
+ let result = {}
+
+ // If a is non-trivial, extend the result with it
+ if (Object.keys(a).length > 0) {
+ result = Utils.extend({}, a)
+ }
+
+ // Copy over the properties in b into a
+ Object.keys(b).forEach((prop) => {
+ if (Utils.isExtendableObject(b[prop])) {
+ if (Utils.isExtendableObject(a[prop])) {
+ result[prop] = Utils.extend(a[prop], b[prop])
+ } else {
+ result[prop] = Utils.extend({}, b[prop])
+ }
+ } else {
+ result[prop] = b[prop]
+ }
+ })
+
+ return result
+ }
+
+ /**
+ * Convert the legacy block array to a block object.
+ *
+ * @param {Array} block
+ *
+ * @returns {Object}
+ */
+ static convertLegacyBlock (block) {
+ return {
+ label: block[0],
+ value: Utils.getRawBlockCount(block),
+ formattedValue: Array.isArray(block[1]) ? block[1][1] : null,
+ backgroundColor: block[2],
+ labelColor: block[3]
+ }
+ }
+
+ /**
+ * Given a raw data block, return its count.
+ *
+ * @param {Array} block
+ *
+ * @return {Number}
+ */
+ static getRawBlockCount (block) {
+ if (Array.isArray(block)) {
+ return Array.isArray(block[1]) ? block[1][0] : block[1]
+ }
+
+ return block.value
+ }
+}
+
+export default Utils
diff --git a/nezha-fronted/src/components/chart/chart/d3-funnel/index.js b/nezha-fronted/src/components/chart/chart/d3-funnel/index.js
new file mode 100644
index 000000000..1c8062f98
--- /dev/null
+++ b/nezha-fronted/src/components/chart/chart/d3-funnel/index.js
@@ -0,0 +1,2 @@
+// Export default to provide support for non-ES6 solutions
+module.exports = require('./d3-funnel/D3Funnel').default
diff --git a/nezha-fronted/src/components/chart/chart/legend.vue b/nezha-fronted/src/components/chart/chart/legend.vue
index feb3240ba..bee01a0ae 100644
--- a/nezha-fronted/src/components/chart/chart/legend.vue
+++ b/nezha-fronted/src/components/chart/chart/legend.vue
@@ -130,7 +130,8 @@ export default {
if (isTimeSeries(this.chartInfo.type) && (this.isConnect && this.isConnect !== 'none')) {
chart.connect('timeSeriesGroup')
}
- if (this.chartInfo.type !== 'pie' && this.chartInfo.type !== 'bar' && this.chartInfo.type !== 'treemap') {
+
+ if (this.chartInfo.type == 'line' || this.chartInfo.type == 'area' || this.chartInfo.type == 'point') {
if (this.chartInfo.param.min || this.chartInfo.param.max) {
return
}
diff --git a/nezha-fronted/src/components/chart/chart/tools.js b/nezha-fronted/src/components/chart/chart/tools.js
index 0ed08daf7..20ae4354a 100644
--- a/nezha-fronted/src/components/chart/chart/tools.js
+++ b/nezha-fronted/src/components/chart/chart/tools.js
@@ -67,26 +67,18 @@ export function getOption (type) {
export function isTimeSeries (type) {
return type === chartType.line || type === chartType.area || type === chartType.point
}
-
export function isHexagon (type) {
return type === chartType.hexagon
}
export function isChartPie (type) {
return type === chartType.pie
}
-export function isChartBubble (type) {
- return type === chartType.bubble
-}
-export function isChartRank (type) {
- return type === chartType.rank
-}
export function isChartBar (type) {
return type === chartType.bar
}
export function isUrl (type) {
return type === chartType.url
}
-
export function isText (type) {
return type === chartType.text
}
@@ -123,15 +115,25 @@ export function isTable (type) {
export function isGauge (type) {
return type === chartType.gauge
}
-export function isSankey (type) {
- return type === chartType.sankey
-}
export function isClock (type) {
return type === chartType.clock
}
export function isTopology (type) {
return type === chartType.topologyLink
}
+export function isChartBubble (type) {
+ return type === chartType.bubble
+}
+export function isChartRank (type) {
+ return type === chartType.rank
+}
+export function isSankey (type) {
+ return type === chartType.sankey
+}
+export function isFunnel (type) {
+ return type === chartType.funnel
+}
+
export function getGroupHeight (arr) {
if (arr.length) {
let lastItem = []
diff --git a/nezha-fronted/src/components/common/js/common.js b/nezha-fronted/src/components/common/js/common.js
index f806c3e6c..5f6f569a5 100644
--- a/nezha-fronted/src/components/common/js/common.js
+++ b/nezha-fronted/src/components/common/js/common.js
@@ -23,7 +23,7 @@ export function getChart (key) {
}
export function setChart (key, value) {
- chartCache[`chart${key}`] && chartCache[`chart${key}`].dispose()
+ chartCache[`chart${key}`] && chartCache[`chart${key}`].dispose && chartCache[`chart${key}`].dispose()
chartCache[`chart${key}`] = value
}
const hexagonCache = {}
diff --git a/nezha-fronted/src/components/common/js/constants.js b/nezha-fronted/src/components/common/js/constants.js
index 078fd7656..c37de65bf 100644
--- a/nezha-fronted/src/components/common/js/constants.js
+++ b/nezha-fronted/src/components/common/js/constants.js
@@ -263,10 +263,6 @@ export const chart = {
value: 'gauge',
label: i18n.t('dashboard.panel.chartForm.typeVal.gauge.label')
},
- {
- value: 'sankey',
- label: i18n.t('dashboard.panel.chartForm.typeVal.sankey.label')
- },
{
value: 'hexagon',
label: i18n.t('dashboard.panel.chartForm.typeVal.hexagonFigure.label')
@@ -307,7 +303,6 @@ export const chart = {
value: 'group',
label: i18n.t('dashboard.panel.chartForm.group')
},
-
{
value: 'logs',
label: i18n.t('overall.logs')
@@ -324,14 +319,6 @@ export const chart = {
value: 'pie',
label: i18n.t('dashboard.panel.chartForm.typeVal.pie.label')
},
- {
- value: 'bubble',
- label: i18n.t('dashboard.panel.chartForm.typeVal.bubble.label')
- },
- {
- value: 'rank',
- label: i18n.t('dashboard.panel.chartForm.typeVal.rank.label')
- },
{
value: 'log',
label: i18n.t('dashboard.panel.chartForm.typeVal.log.label')
@@ -351,6 +338,22 @@ export const chart = {
{
value: 'clock',
label: i18n.t('dashboard.panel.chartForm.typeVal.clock.label')
+ },
+ {
+ value: 'bubble',
+ label: i18n.t('dashboard.panel.chartForm.typeVal.bubble.label')
+ },
+ {
+ value: 'rank',
+ label: i18n.t('dashboard.panel.chartForm.typeVal.rank.label')
+ },
+ {
+ value: 'sankey',
+ label: i18n.t('dashboard.panel.chartForm.typeVal.sankey.label')
+ },
+ {
+ value: 'funnel',
+ label: i18n.t('dashboard.panel.chartForm.typeVal.funnel.label')
}
]
}
@@ -471,10 +474,7 @@ export const chartType = {
table: 'table',
stat: 'stat',
gauge: 'gauge',
- sankey: 'sankey',
pie: 'pie',
- bubble: 'bubble',
- rank: 'rank',
treemap: 'treemap',
log: 'log',
text: 'text',
@@ -487,7 +487,11 @@ export const chartType = {
topology: 'topology',
map: 'map',
hexagon: 'hexagon',
- topologyLink: 'topologyLink'
+ topologyLink: 'topologyLink',
+ bubble: 'bubble',
+ rank: 'rank',
+ sankey: 'sankey',
+ funnel: 'funnel'
}
export const chartLegendPlacement = {
diff --git a/nezha-fronted/src/components/common/rightBox/chart/chartConfig.vue b/nezha-fronted/src/components/common/rightBox/chart/chartConfig.vue
index 8bd754a8c..e9db17c03 100644
--- a/nezha-fronted/src/components/common/rightBox/chart/chartConfig.vue
+++ b/nezha-fronted/src/components/common/rightBox/chart/chartConfig.vue
@@ -319,7 +319,7 @@