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 @@