diff --git a/docs/views/heatMap/api/heatMap.md b/docs/views/heatMap/api/heatMap.md index cfb28beb4..e65c66314 100644 --- a/docs/views/heatMap/api/heatMap.md +++ b/docs/views/heatMap/api/heatMap.md @@ -147,6 +147,7 @@ const chartData = | 이름 | 타입 | 디폴트 | 설명 | 종류(예시) | | --- | ---- | ----- | --- | ----------| | show | Boolean | false | Legend 표시 여부 | true /false | +| type | String | 'icon' | Legend type 지정 | 'icon', 'gradient' | | position | String | 'right' | Legend 위치 | 'top', 'right', 'bottom', 'left' | | color | Hex, RGB, RGBA Code(String) | '#353740' | 폰트 색상 | | | inactive | Hex, RGB, RGBA Code(String) | '#aaa' | 비활성화 상태의 폰트 색상 | | @@ -212,6 +213,7 @@ const chartOptions = { | color | Hex, RGB, RGBA Code(String) | '#FFFFFF' | stroke color 지정 | | | lineWidth | number | 1 | stroke 선 굵기 지정 | | | opacity | number | 1 | stroke opacity 지정 | 0.1 ~ 1 | +| radius | number | 0 | border radius 조정 | | ### 3. resize-timeout - Default : 0 diff --git a/docs/views/heatMap/example/Default.vue b/docs/views/heatMap/example/Default.vue index d634bb72b..32d010a3c 100644 --- a/docs/views/heatMap/example/Default.vue +++ b/docs/views/heatMap/example/Default.vue @@ -35,14 +35,15 @@ import { onMounted, reactive } from 'vue'; show: true, }, indicator: { - use: false, + use: true, }, axesX: [{ type: 'step', - showAxis: false, + showGrid: false, }], axesY: [{ type: 'step', + showGrid: false, }], heatMapColor: { min: '#FFC19E', diff --git a/docs/views/heatMap/example/Gradient.vue b/docs/views/heatMap/example/Gradient.vue new file mode 100644 index 000000000..fa33538bb --- /dev/null +++ b/docs/views/heatMap/example/Gradient.vue @@ -0,0 +1,91 @@ + + + diff --git a/docs/views/heatMap/props.js b/docs/views/heatMap/props.js index a7356f7db..fb52f9ef7 100644 --- a/docs/views/heatMap/props.js +++ b/docs/views/heatMap/props.js @@ -6,6 +6,8 @@ import Event from './example/Event'; import EventRaw from '!!raw-loader!./example/Event'; import Time from './example/Time'; import TimeRaw from '!!raw-loader!./example/Time'; +import Gradient from './example/Gradient'; +import GradientRaw from '!!raw-loader!./example/Gradient'; export default { mdText, @@ -25,5 +27,10 @@ export default { component: Event, parsedData: parseComponent(EventRaw), }, + Gradient: { + description: 'gradient 범주로 표현 가능합니다.', + component: Gradient, + parsedData: parseComponent(GradientRaw), + }, }, }; diff --git a/package.json b/package.json index 6ddde6621..5557c8cbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "evui", - "version": "3.3.19", + "version": "3.3.20", "description": "A EXEM Library project", "author": "exem ", "license": "MIT", diff --git a/src/components/chart/chart.core.js b/src/components/chart/chart.core.js index 854c4bc1f..31486121a 100644 --- a/src/components/chart/chart.core.js +++ b/src/components/chart/chart.core.js @@ -8,6 +8,7 @@ import StepScale from './scale/scale.step'; import TimeCategoryScale from './scale/scale.time.category'; import Title from './plugins/plugins.title'; import Legend from './plugins/plugins.legend'; +import GradientLegend from './plugins/plugins.legend.gradient'; import Interaction from './plugins/plugins.interaction'; import Tooltip from './plugins/plugins.tooltip'; import Pie from './plugins/plugins.pie'; @@ -23,6 +24,10 @@ class EvChart { Object.assign(this, Pie); Object.assign(this, Tip); + if (options.type === 'heatMap' && options.legend.type === 'gradient') { + Object.assign(this, GradientLegend); + } + this.target = target; this.data = data; this.options = options; @@ -163,6 +168,7 @@ class EvChart { maxTipOpt: { background: maxTip.background, color: maxTip.color }, selectLabel: { option: selectLabel, selected: this.defaultSelectInfo }, selectSeries: { option: selectSeries, selected: this.defaultSelectInfo }, + overlayCtx: this.overlayCtx, }; let showIndex = 0; diff --git a/src/components/chart/element/element.heatmap.js b/src/components/chart/element/element.heatmap.js index 92b4dcf2c..94ef5d8c2 100644 --- a/src/components/chart/element/element.heatmap.js +++ b/src/components/chart/element/element.heatmap.js @@ -1,15 +1,17 @@ import { merge } from 'lodash-es'; import Util from '../helpers/helpers.util'; import { HEAT_MAP_OPTION } from '../helpers/helpers.constant'; +import { convertToPercent } from '../../../common/utils'; class HeatMap { - constructor(sId, opt, colorOpt) { + constructor(sId, opt, colorOpt, isGradient) { const merged = merge({}, HEAT_MAP_OPTION, opt); Object.keys(merged).forEach((key) => { this[key] = merged[key]; }); - this.createColorAxis(colorOpt); + this.isGradient = isGradient; + this.createColorState(colorOpt); this.sId = sId; this.data = []; @@ -27,41 +29,66 @@ class HeatMap { * @param colorOpt * @returns {*[]} */ - createColorAxis(colorOpt) { - const colorAxis = []; + createColorState(colorOpt) { + const colorState = []; + const regex = /[^0-9]&[^,]/g; const { min, max, categoryCnt, error, stroke } = colorOpt; - const minColor = min.includes('#') ? Util.hexToRgb(min) : min; - const maxColor = max.includes('#') ? Util.hexToRgb(max) : max; + const minColor = min.includes('#') ? Util.hexToRgb(min) : min.replace(regex, ''); + const maxColor = max.includes('#') ? Util.hexToRgb(max) : max.replace(regex, ''); const [minR, minG, minB] = minColor.split(','); const [maxR, maxG, maxB] = maxColor.split(','); - const unitR = Math.floor((minR - maxR) / (categoryCnt - 1)); - const unitG = Math.floor((minG - maxG) / (categoryCnt - 1)); - const unitB = Math.floor((minB - maxB) / (categoryCnt - 1)); - - for (let ix = 0; ix < categoryCnt; ix++) { - const r = +minR - (unitR * ix); - const g = +minG - (unitG * ix); - const b = +minB - (unitB * ix); - - colorAxis.push({ - id: `color#${ix}`, - value: `rgb(${r},${g},${b})`, - state: 'normal', - show: true, - }); + if (this.isGradient) { + colorState.push({ + minColor: { minR, minG, minB }, + maxColor: { maxR, maxG, maxB }, + categoryCnt, + start: 0, + end: 100, + selectedValue: null, + }); + } else { + const unitR = Math.floor((minR - maxR) / (categoryCnt - 1)); + const unitG = Math.floor((minG - maxG) / (categoryCnt - 1)); + const unitB = Math.floor((minB - maxB) / (categoryCnt - 1)); + + for (let ix = 0; ix < categoryCnt; ix++) { + const r = +minR - (unitR * ix); + const g = +minG - (unitG * ix); + const b = +minB - (unitB * ix); + + colorState.push({ + id: `color#${ix}`, + color: `rgb(${r},${g},${b})`, + state: 'normal', + show: true, + }); + } } - this.colorAxis = colorAxis; + this.colorState = colorState; this.errorColor = error; this.stroke = stroke; } - getColorIndex(value) { + getColorForGradient(value) { + const { minColor, maxColor } = this.colorState[0]; + + const { minR, minG, minB } = minColor; + const { maxR, maxG, maxB } = maxColor; + + const r = +minR - Math.floor(((minR - maxR) * value) / 100); + const g = +minG - Math.floor(((minG - maxG) * value) / 100); + const b = +minB - Math.floor(((minB - maxB) * value) / 100); + + return `rgb(${r},${g},${b})`; + } + + getColorIndexForIcon(value) { const { existError, min, interval, decimalPoint } = this.valueOpt; - const maxIndex = this.colorAxis.length - 1; + const maxIndex = this.colorState.length - 1; if (existError && value < 0) { return maxIndex; } @@ -75,11 +102,52 @@ class HeatMap { return colorIndex; } + getItemInfo(value) { + const { min, max } = this.valueOpt; + const itemInfo = { + show: false, + opacity: 0, + dataColor: null, + id: null, + isHighlight: null, + }; + if (this.isGradient) { + const ratio = convertToPercent(value - min, max - min); + const { start, end, selectedValue } = this.colorState[0]; + if (value < 0 || (start <= ratio && ratio <= end)) { + itemInfo.show = true; + itemInfo.isHighlight = selectedValue !== null + && (Math.floor(value) === Math.floor(min + ((max - min) * (selectedValue / 100)))); + itemInfo.opacity = 1; + itemInfo.dataColor = value < 0 + ? this.errorColor : this.getColorForGradient(ratio); + } + } else { + const colorIndex = this.getColorIndexForIcon(value); + const { show, state, color, id } = this.colorState[colorIndex]; + itemInfo.show = show; + itemInfo.opacity = state === 'downplay' ? 0.1 : 1; + itemInfo.dataColor = value < 0 ? this.errorColor : color; + itemInfo.id = id; + } + return itemInfo; + } + drawItem(ctx, x, y, w, h) { ctx.beginPath(); if (this.stroke.show) { - ctx.strokeRect(x, y, w, h); - ctx.fillRect(x, y, w, h); + const { radius } = this.stroke; + if (radius > 0) { + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + ctx.fill(); + } else { + ctx.strokeRect(x, y, w, h); + ctx.fillRect(x, y, w, h); + } } else { const aliasPixel = Util.aliasPixel(1); ctx.fillRect( @@ -122,7 +190,7 @@ class HeatMap { return; } - const { ctx, chartRect, labelOffset } = param; + const { ctx, chartRect, labelOffset, overlayCtx } = param; const xArea = chartRect.chartWidth - (labelOffset.left + labelOffset.right); const yArea = chartRect.chartHeight - (labelOffset.top + labelOffset.bottom); @@ -142,11 +210,11 @@ class HeatMap { if (xp !== null && yp !== null && (value !== null && value !== undefined)) { - const colorIndex = this.getColorIndex(value); - const opacity = this.colorAxis[colorIndex].state === 'downplay' ? 0.1 : 1; - item.dataColor = value < 0 ? this.errorColor : this.colorAxis[colorIndex].value; - item.cId = this.colorAxis[colorIndex].id; - if (this.colorAxis[colorIndex].show) { + const { show, opacity, dataColor, id, isHighlight } = this.getItemInfo(value); + item.dataColor = dataColor; + item.cId = id; + ctx.save(); + if (show) { ctx.fillStyle = Util.colorStringToRgba(item.dataColor, opacity); if (this.stroke.show) { const { color, lineWidth, opacity: sOpacity } = this.stroke; @@ -161,25 +229,25 @@ class HeatMap { h -= (lineWidth * 2); } this.drawItem(ctx, xp, yp, w, h); + ctx.restore(); + + item.xp = xp; + item.yp = yp; + item.w = w; + item.h = h; if (this.showValue.use) { this.drawValueLabels({ context: ctx, data: item, - positions: { - x: xp, - y: yp, - w, - h, - }, }); } + if (isHighlight) { + this.itemHighlight({ + data: item, + }, overlayCtx); + } } - - item.xp = xp; - item.yp = yp; - item.w = w; - item.h = h; } }); } @@ -190,9 +258,9 @@ class HeatMap { * @param context canvas context * @param data series value data (model.store.js addData return value) */ - drawValueLabels({ context, data, positions }) { + drawValueLabels({ context, data }) { const { fontSize, textColor, align, formatter, decimalPoint } = this.showValue; - const { x, y, w, h } = positions; + const { xp: x, yp: y, w, h, o: value } = data; const ctx = context; ctx.save(); @@ -204,8 +272,6 @@ class HeatMap { ctx.textBaseline = 'middle'; ctx.textAlign = align !== 'center' ? 'left' : 'center'; - const value = data.o; - let formattedTxt; if (formatter) { formattedTxt = formatter(value); @@ -295,31 +361,36 @@ class HeatMap { const h = gdata.h; const cId = gdata.cId; - const isShow = this.colorAxis.find(({ id }) => id === cId)?.show; - + let isShow; + if (this.isGradient) { + const { min, max } = this.valueOpt; + const ratio = convertToPercent(gdata.o - min, max - min); + const { start, end } = this.colorState[0]; + isShow = (start <= ratio && ratio <= end) || gdata.o === -1; + } else { + isShow = this.colorState.find(({ id }) => id === cId)?.show; + } ctx.save(); + ctx.shadowOffsetX = 2; + ctx.shadowOffsetY = 2; + ctx.shadowBlur = 4; + if (x !== null && y !== null && isShow) { const color = gdata.dataColor; - ctx.strokeStyle = Util.colorStringToRgba(color, 1); - ctx.fillStyle = Util.colorStringToRgba(color, this.highlight.maxShadowOpacity); - ctx.shadowColor = color; - this.drawItem(ctx, x, y, w, h); + ctx.shadowColor = Util.colorStringToRgba('#605F5F'); + ctx.strokeStyle = Util.colorStringToRgba(color); + ctx.fillStyle = Util.colorStringToRgba(color); + this.drawItem(ctx, x - 2, y - 2, w + 4, h + 4); + + ctx.restore(); if (this.showValue.use) { this.drawValueLabels({ context: ctx, data: gdata, - positions: { - x, - y, - w, - h, - }, }); } } - - ctx.restore(); } /** diff --git a/src/components/chart/helpers/helpers.constant.js b/src/components/chart/helpers/helpers.constant.js index 21275d260..b718840c8 100644 --- a/src/components/chart/helpers/helpers.constant.js +++ b/src/components/chart/helpers/helpers.constant.js @@ -142,6 +142,7 @@ export const HEAT_MAP_OPTION = { show: true, highlight: { maxShadowOpacity: 0.4, + brightness: 150, }, xAxisIndex: 0, yAxisIndex: 0, diff --git a/src/components/chart/model/model.series.js b/src/components/chart/model/model.series.js index 39950ab43..dc1b1323c 100644 --- a/src/components/chart/model/model.series.js +++ b/src/components/chart/model/model.series.js @@ -54,7 +54,9 @@ const modules = { return new Pie(id, opt, index); } else if (type === 'heatMap') { this.seriesInfo.charts.heatMap.push(id); - return new HeatMap(id, opt, this.options.heatMapColor); + const { heatMapColor, legend } = this.options; + const isGradient = legend.type === 'gradient'; + return new HeatMap(id, opt, heatMapColor, isGradient); } return false; diff --git a/src/components/chart/model/model.store.js b/src/components/chart/model/model.store.js index 5c27db134..3b17051ed 100644 --- a/src/components/chart/model/model.store.js +++ b/src/components/chart/model/model.store.js @@ -418,7 +418,7 @@ const modules = { }, getSeriesValueOptForHeatMap(series) { - const data = series.data; + const { data, colorState, isGradient } = series; const colorOpt = this.options.heatMapColor; const categoryCnt = colorOpt.categoryCnt; const decimalPoint = colorOpt.decimalPoint; @@ -441,10 +441,14 @@ const modules = { } }); - if (isExistError && series.colorAxis.length === categoryCnt) { - series.colorAxis.push({ + if ( + isExistError + && !isGradient + && colorState.length === categoryCnt + ) { + colorState.push({ id: `color#${categoryCnt}`, - value: colorOpt.error, + color: colorOpt.error, state: 'normal', show: true, }); diff --git a/src/components/chart/plugins/plugins.legend.gradient.js b/src/components/chart/plugins/plugins.legend.gradient.js new file mode 100644 index 000000000..b59c7ef68 --- /dev/null +++ b/src/components/chart/plugins/plugins.legend.gradient.js @@ -0,0 +1,568 @@ +import { convertToPercent } from '../../../common/utils'; + +const MAX_HANDLE_SIZE = 28; + +const MIN_BOX_SIZE = { + width: 70, + height: 60, +}; + +const modules = { + /** + * Create legend DOM + * + * @returns {undefined} + */ + createLegendLayout() { + this.legendDOM = document.createElement('div'); + this.legendDOM.className = 'ev-chart-legend'; + this.legendBoxDOM = document.createElement('div'); + this.legendBoxDOM.className = 'ev-chart-legend-box'; + this.containerDOM = document.createElement('div'); + this.containerDOM.className = 'ev-chart-legend-container'; + + this.legendBoxDOM.appendChild(this.containerDOM); + this.legendDOM.appendChild(this.legendBoxDOM); + this.wrapperDOM.appendChild(this.legendDOM); + }, + + /** + * Initialize legend + * If there was no initialization, create DOM and set default layout. + * It not, there will already be set layout, so add a legend for each series with group + * + * @returns {undefined} + */ + initLegend() { + if (!this.isInitLegend) { + this.createLegendLayout(); + this.createLegend(); + } + const series = Object.values(this.seriesList)[0]; + this.setLegendStyle(series); + this.initEvent(); + + this.isInitLegend = true; + this.legendDragInfo = { + dragging: false, + isStart: true, + }; + }, + + /** + * Initialize legend event + * + * @returns {undefined} + */ + initEvent() { + if (this.isInitLegend) { + return; + } + + this.onLegendMouseDown = (e) => { + e.stopPropagation(); + e.preventDefault(); + + const type = e.target.dataset.type; + + let targetDOM; + if (type === 'handle') { + targetDOM = e.target; + } else if (type === 'handle-btn') { + targetDOM = e.target.parentElement; + } else if (type === 'handle-btn-color') { + targetDOM = e.target.parentElement.parentElement; + } else { + return; + } + + const { colorState } = Object.values(this.seriesList)[0]; + const { start, end } = colorState[0]; + + colorState[0].selectedValue = null; + this.clearOverlay(); + + this.legendDragInfo.dragging = true; + this.legendDragInfo.isStart = start !== end + ? targetDOM.className.includes('start') + : this.legendDragInfo.isStart; + targetDOM.classList.add('dragging'); + this.legendBoxDOM.addEventListener('mousemove', this.onLegendMouseMove, false); + this.legendBoxDOM.addEventListener('mouseup', this.onLegendMouseUp, false); + }; + + this.onLegendMouseMove = (e) => { + e.stopPropagation(); + e.preventDefault(); + + const { dragging, isStart } = this.legendDragInfo; + + if (dragging) { + let value = this.getSelectedValue(e); + value = this.isSide ? 100 - value : value; + const dir = isStart ? 'start' : 'end'; + + const { colorState } = Object.values(this.seriesList)[0]; + const { start, end } = colorState[0]; + if ((isStart && value > end) || (!isStart && value < start)) { + return; + } + colorState[0][dir] = value; + + this.update({ + updateSeries: false, + updateSelTip: { update: false, keepDomain: false }, + }); + } + }; + + this.onLegendMouseUp = () => { + this.legendDragInfo.dragging = false; + + const targetDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-handle dragging')[0]; + targetDOM?.classList.remove('dragging'); + this.legendBoxDOM.removeEventListener('mouseup', this.onLegendMouseUp, false); + }; + + /** + * callback for legendBoxDOM hovering + * + * @returns {undefined} + */ + this.onLegendBoxOver = (e) => { + const type = e.target.dataset.type; + + const { colorState, valueOpt } = Object.values(this.seriesList)[0]; + const state = colorState[0]; + + let value = this.getSelectedValue(e); + value = this.isSide ? 100 - value : value; + if (['line', 'thumb', 'layer', 'overlay', 'overlay-item'].includes(type)) { + if (state.start <= value && value <= state.end) { + state.selectedValue = value; + this.createLegendOverlay(value, valueOpt); + } else { + return; + } + } else if (['handle', 'handle-btn', 'handle-btn-color'].includes(type)) { + const isStart = e.target.className.includes('start'); + state.selectedValue = isStart ? state.start : state.end; + this.clearOverlay(); + } else { + return; + } + + this.update({ + updateSeries: false, + updateSelTip: { update: false, keepDomain: false }, + }); + }; + + /** + * callback for mouseleave event on legendBoxDOM + * + * @returns {undefined} + */ + this.onLegendBoxLeave = () => { + this.legendDragInfo.dragging = false; + + const lineDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-line')[0]; + const targetDOM = lineDOM.getElementsByClassName('ev-chart-legend-thumb')[0]; + this.clearOverlay(targetDOM); + + const { colorState } = Object.values(this.seriesList)[0]; + colorState[0].selectedValue = null; + + this.update({ + updateSeries: false, + updateSelTip: { update: false, keepDomain: false }, + }); + }; + + this.legendBoxDOM.addEventListener('mousedown', this.onLegendMouseDown); + this.legendBoxDOM.addEventListener('mouseover', this.onLegendBoxOver); + this.legendBoxDOM.addEventListener('mouseleave', this.onLegendBoxLeave); + }, + + getSelectedValue(evt) { + const { x, y, width, height } = this.containerDOM.getBoundingClientRect(); + const isTop = !this.isSide; + + const sp = isTop ? x : y; + const size = isTop ? width : height; + let movePoint = isTop ? evt.clientX : evt.clientY; + if (movePoint < sp) { + movePoint = sp; + } else if (movePoint > sp + size) { + movePoint = sp + size; + } + + const move = movePoint - sp; + return +convertToPercent(move, size); + }, + + /** + * To update legend, reset all process. + * + * @returns {undefined} + */ + updateLegend() { + this.resetLegend(); + this.createLegend(); + + const series = Object.values(this.seriesList)[0]; + this.setLegendStyle(series); + }, + + /** + * To update legend, remove all of legendBoxDOM's children + * + * @returns {undefined} + */ + resetLegend() { + const containerDOM = this.containerDOM; + + if (!containerDOM) { + return; + } + + while (containerDOM.hasChildNodes()) { + containerDOM.removeChild(containerDOM.firstChild); + } + }, + + clearOverlay() { + const targetDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-line')[0]; + const overlayDOM = targetDOM.getElementsByClassName('ev-chart-legend-overlay')[0]; + if (overlayDOM) { + targetDOM.removeChild(overlayDOM); + + const thumbDOM = targetDOM.getElementsByClassName('ev-chart-legend-thumb')[0]; + const labels = thumbDOM.children; + labels.forEach((labelDOM) => { + labelDOM.style.opacity = 1; + }); + } + }, + + createLegendOverlay(value, opt) { + this.clearOverlay(); + const handleSize = this.legendHandleSize; + const { min, max } = opt; + + const targetDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-line')[0]; + + const overlayDOM = document.createElement('div'); + overlayDOM.className = 'ev-chart-legend-overlay'; + overlayDOM.dataset.type = 'overlay'; + + const tooltipDOM = document.createElement('div'); + tooltipDOM.className = 'ev-chart-legend-overlay-tooltip'; + tooltipDOM.innerText = Math.floor(min + ((max - min) * (value / 100))); + + const itemDOM = document.createElement('span'); + itemDOM.className = 'ev-chart-legend-overlay-item'; + itemDOM.dataset.type = 'overlay-item'; + + let itemStyle; + let tooltipStyle; + const position = Math.floor(handleSize / 2) + 14; + if (this.isSide) { + tooltipStyle = `top:${100 - value}%;left:${position}px;transform:translateY(-50%);`; + itemStyle = `top:${100 - value}%;transform:translateY(-50%);`; + } else { + tooltipStyle = `top:-${position}px;left:${value}%;transform:translateX(-50%);`; + itemStyle = `left:${value}%;transform:translateX(-50%);`; + } + itemStyle += `width:${handleSize - 10}px;height:${handleSize - 10}px;`; + + tooltipDOM.style.cssText = tooltipStyle; + itemDOM.style.cssText = itemStyle; + + overlayDOM.appendChild(tooltipDOM); + overlayDOM.appendChild(itemDOM); + targetDOM.appendChild(overlayDOM); + + const thumbDOM = targetDOM.getElementsByClassName('ev-chart-legend-thumb')[0]; + const labels = thumbDOM.children; + labels.forEach((labelDOM) => { + labelDOM.style.opacity = 0.2; + }); + }, + + createLegendHandle(type) { + const colorBtnDOM = document.createElement('span'); + colorBtnDOM.className = `ev-chart-legend-handle-btn-color ${type}`; + colorBtnDOM.dataset.type = 'handle-btn-color'; + + const btnDOM = document.createElement('div'); + btnDOM.className = `ev-chart-legend-handle-btn ${type}`; + btnDOM.dataset.type = 'handle-btn'; + + btnDOM.appendChild(colorBtnDOM); + + const handleDOM = document.createElement('div'); + handleDOM.className = `ev-chart-legend-handle ${type}`; + handleDOM.dataset.type = 'handle'; + + handleDOM.appendChild(btnDOM); + + return handleDOM; + }, + + createLegendLabel() { + const textDOM = document.createElement('span'); + textDOM.className = 'ev-chart-legend-label-text'; + + const labelDOM = document.createElement('div'); + labelDOM.className = 'ev-chart-legend-label'; + + return labelDOM; + }, + + /** + * Create legend DOM + * + * @returns {undefined} + */ + createLegend() { + const opt = this.options.legend; + this.isSide = !['top', 'bottom'].includes(opt.position); + const legendSize = this.isSide ? opt.width : opt.height; + this.legendHandleSize = legendSize > MAX_HANDLE_SIZE ? MAX_HANDLE_SIZE : legendSize; + const handleSize = this.legendHandleSize; + + const startHandleDOM = this.createLegendHandle(this.isSide ? 'end' : 'start', handleSize); + const endHandleDOM = this.createLegendHandle(this.isSide ? 'start' : 'end', handleSize); + + const lineLayerDOM = document.createElement('div'); + lineLayerDOM.className = 'ev-chart-legend-line-layer'; + lineLayerDOM.dataset.type = 'line-layer'; + const thumbDOM = document.createElement('div'); + thumbDOM.className = 'ev-chart-legend-thumb'; + thumbDOM.dataset.type = 'thumb'; + + thumbDOM.appendChild(this.createLegendLabel()); + thumbDOM.appendChild(this.createLegendLabel()); + + const lineDOM = document.createElement('div'); + lineDOM.className = 'ev-chart-legend-line'; + lineDOM.dataset.type = 'line'; + + lineDOM.appendChild(lineLayerDOM); + lineDOM.appendChild(thumbDOM); + + this.containerDOM.appendChild(lineDOM); + this.containerDOM.appendChild(startHandleDOM); + this.containerDOM.appendChild(endHandleDOM); + }, + + setLegendStyle(series) { + const dir = this.isSide ? 'top' : 'right'; + const handleSize = this.legendHandleSize; + + const { valueOpt, colorState } = series; + + const { min, max, decimalPoint } = valueOpt; + const { start, end } = colorState[0]; + const startColor = series.getColorForGradient(start); + const endColor = series.getColorForGradient(end); + let gradient = `linear-gradient(to ${dir}, `; + gradient += `${startColor}, ${endColor})`; + + const labelPosition = Math.floor(handleSize / 2) + 14; + let defaultHandleStyle = `width:${handleSize}px;height:${handleSize}px;`; + let labelStyle; + let startStyle; + let endStyle; + let thumbStyle = `background:${gradient};`; + + if (this.isSide) { + defaultHandleStyle += `margin-top:-${handleSize / 2}px;`; + labelStyle = `left:${labelPosition}px;transform:translateY(-50%);top:`; + startStyle = `top:${100 - end}%;`; + endStyle = `top:${100 - start}%;`; + thumbStyle += `top:${100 - end}%;height:${end - start}%;`; + } else { + defaultHandleStyle += `margin-left:-${handleSize / 2}px;`; + labelStyle = `top:-${labelPosition}px;transform:translateX(-50%);left:`; + startStyle = `left:${start}%;`; + endStyle = `left:${end}%;`; + thumbStyle += `left:${start}%;width:${end - start}%;`; + } + + const minText = (min + ((max - min) * (start / 100))).toFixed(decimalPoint); + const maxText = (min + ((max - min) * (end / 100))).toFixed(decimalPoint); + + const thumbDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-thumb')[0]; + thumbDOM.style.cssText = thumbStyle; + + const labelDOM = thumbDOM.getElementsByClassName('ev-chart-legend-label'); + labelDOM[0].style.cssText = `${labelStyle}0%;`; + labelDOM[1].style.cssText = `${labelStyle}100%;`; + labelDOM[0].innerText = this.isSide ? maxText : minText; + labelDOM[1].innerText = this.isSide ? minText : maxText; + + const handleDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-handle'); + handleDOM[0].style.cssText = defaultHandleStyle + startStyle; + handleDOM[1].style.cssText = defaultHandleStyle + endStyle; + + const btnDOM = this.containerDOM.getElementsByClassName('ev-chart-legend-handle-btn-color'); + btnDOM[0].style.backgroundColor = this.isSide ? endColor : startColor; + btnDOM[1].style.backgroundColor = this.isSide ? startColor : endColor; + }, + + /** + * Set legend components position by option + * + * @returns {undefined} + */ + setLegendPosition() { + const opt = this.options; + const position = opt?.legend?.position; + const { width: minWidth, height: minHeight } = MIN_BOX_SIZE; + const handleSize = this.legendHandleSize; + + const title = opt?.title?.show ? opt?.title?.height : 0; + const positionTop = title + minHeight; + const { top = 0, bottom = 0, left = 0, right = 0 } = opt?.legend?.padding ?? {}; + const wrapperStyle = this.wrapperDOM.style; + + if (!wrapperStyle) { + return; + } + + let legendStyle; + let boxStyle; + let containerStyle; + let chartRect; + + switch (position) { + case 'top': + wrapperStyle.padding = `${positionTop}px 0 0 0`; + chartRect = this.chartDOM.getBoundingClientRect(); + + boxStyle = `padding:${handleSize + 7}px ${right}px ${bottom}px ${left}px;`; + boxStyle += 'width:100%'; + boxStyle += `height${minHeight}px;`; + + legendStyle = `width:${chartRect.width}px;`; + legendStyle += `height:${minHeight}px;`; + legendStyle += `top:${title}px;`; + break; + case 'right': + wrapperStyle.padding = `${title}px ${minWidth}px 0 0`; + chartRect = this.chartDOM.getBoundingClientRect(); + + boxStyle = `padding:${top}px ${right}px ${bottom}px ${left}px;`; + boxStyle += `width:${minWidth}px;`; + boxStyle += 'height:100%;'; + boxStyle += `max-height:${chartRect.height}px;`; + + legendStyle = `width:${minWidth}px;`; + legendStyle += `height:${chartRect.height}px;`; + legendStyle += `top:${title}px;right:0px;`; + break; + case 'bottom': + wrapperStyle.padding = `${title}px 0 ${minHeight}px 0`; + chartRect = this.chartDOM.getBoundingClientRect(); + + boxStyle = `padding:${handleSize + 7}px ${right}px ${bottom}px ${left}px;`; + boxStyle += 'width:100%;'; + boxStyle += `height:${minHeight}px;`; + + legendStyle = `width:${chartRect.width}px;`; + legendStyle += `height:${minHeight}px;`; + + legendStyle += 'bottom:0px;left:0px;'; + break; + case 'left': + wrapperStyle.padding = `${title}px 0 0 ${minWidth}px`; + chartRect = this.chartDOM.getBoundingClientRect(); + + boxStyle = `padding:${top}px ${right}px ${bottom}px ${left}px;`; + boxStyle += 'display:absolute;'; + boxStyle += 'bottom:0px;'; + boxStyle += `width:${minWidth}px;`; + boxStyle += 'height:100%;'; + boxStyle += `maxHeight:${chartRect.height}px;`; + + legendStyle = `width:${minWidth}px;`; + legendStyle += `height:${chartRect.height}px;`; + legendStyle += `top:${title}px;left:0px`; + break; + default: + break; + } + if (['top', 'bottom'].includes(position)) { + const containerSize = chartRect.width / 2; + + containerStyle = `left:${(chartRect.width / 2) - (containerSize / 2)}px;`; + containerStyle += `width:${containerSize}px;`; + containerStyle += `height:${handleSize}px;`; + containerStyle += 'padding:4px 0;'; + containerStyle += 'margin:0 4px;'; + } else { + const containerSize = chartRect.height / 2; + + containerStyle = `top:${(chartRect.height / 2) - (containerSize / 2)}px;`; + containerStyle += 'left:5px;'; + containerStyle += `width:${handleSize}px;`; + containerStyle += `height:${containerSize}px;`; + containerStyle += 'padding:0 4px;'; + containerStyle += 'margin:4px 0;'; + } + + this.containerDOM.style.cssText = containerStyle; + this.legendBoxDOM.style.cssText = boxStyle; + this.legendDOM.style.cssText = legendStyle; + }, + + /** + * Update legend components size + * + * @returns {undefined} + */ + updateLegendContainerSize() { + const series = Object.values(this.seriesList)[0]; + this.setLegendStyle(series); + }, + + /** + * Show legend components by manipulating css + * + * @returns {undefined} + */ + showLegend() { + if (this.resizeDOM) { + this.resizeDOM.style.display = 'block'; + } + + if (this.legendDOM) { + this.legendDOM.style.display = 'block'; + } + }, + + /** + * Hide legend components by manipulating css + * + * @returns {undefined} + */ + hideLegend() { + const opt = this.options; + const wrapperStyle = this.wrapperDOM?.style; + const legendStyle = this.legendDOM?.style; + const title = opt?.title?.show ? opt?.title?.height : 0; + + if (!legendStyle || !wrapperStyle) { + return; + } + + legendStyle.display = 'none'; + legendStyle.width = '0'; + legendStyle.height = '0'; + wrapperStyle.padding = `${title}px 0 0 0`; + }, +}; + +export default modules; diff --git a/src/components/chart/plugins/plugins.legend.js b/src/components/chart/plugins/plugins.legend.js index cdc088af8..1f56b7782 100644 --- a/src/components/chart/plugins/plugins.legend.js +++ b/src/components/chart/plugins/plugins.legend.js @@ -81,12 +81,12 @@ const modules = { Object.values(seriesList).forEach((series) => { if (!series.isExistGrp && series.showLegend) { - const { colorAxis, valueOpt } = series; + const { colorState, valueOpt } = series; const { min, max, interval, existError, decimalPoint } = valueOpt; - const length = colorAxis.length; + const length = colorState.length; const endIndex = existError ? length - 2 : length - 1; for (let index = 0; index < length; index++) { - const colorItem = colorAxis[index]; + const colorItem = colorState[index]; const minValue = min + (interval * index); let maxValue = minValue + interval; if (index < endIndex) { @@ -112,7 +112,7 @@ const modules = { this.addLegend({ cId: colorItem.id, - color: colorItem.value, + color: colorItem.color, name, }); } @@ -292,7 +292,7 @@ const modules = { const nameDOM = targetDOM?.getElementsByClassName('ev-chart-legend-name')[0]; const isActive = !colorDOM?.className.includes('inactive'); const targetId = nameDOM.series.cId; - const activeCount = series.colorAxis.filter(colorItem => colorItem.show).length; + const activeCount = series.colorState.filter(colorItem => colorItem.show).length; if (isActive && activeCount === 1) { return; @@ -311,9 +311,9 @@ const modules = { nameDOM.style.color = opt.color; } - const targetIndex = series.colorAxis.findIndex(colorItem => colorItem.id === targetId); + const targetIndex = series.colorState.findIndex(colorItem => colorItem.id === targetId); if (targetIndex > -1) { - series.colorAxis[targetIndex].show = !isActive; + series.colorState[targetIndex].show = !isActive; } colorDOM.classList.toggle('inactive'); @@ -344,7 +344,7 @@ const modules = { const nameDOM = targetDOM.getElementsByClassName('ev-chart-legend-name')[0]; const targetId = nameDOM.series.cId; - series.colorAxis.forEach((colorItem) => { + series.colorState.forEach((colorItem) => { colorItem.state = colorItem.id === targetId ? 'highlight' : 'downplay'; }); @@ -361,7 +361,7 @@ const modules = { */ this.onLegendBoxLeave = () => { const series = Object.values(this.seriesList)[0]; - series.colorAxis.forEach((item) => { + series.colorState.forEach((item) => { item.state = 'normal'; }); @@ -510,6 +510,7 @@ const modules = { } containerDOM.style.height = '18px'; containerDOM.style.display = 'inline-block'; + containerDOM.style.overflow = 'hidden'; containerDOM.dataset.type = 'container'; this.legendBoxDOM.appendChild(containerDOM); diff --git a/src/components/chart/plugins/plugins.tooltip.js b/src/components/chart/plugins/plugins.tooltip.js index 0a3d41adb..f1330eedf 100644 --- a/src/components/chart/plugins/plugins.tooltip.js +++ b/src/components/chart/plugins/plugins.tooltip.js @@ -3,6 +3,7 @@ import debounce from '@/common/utils.debounce'; import dayjs from 'dayjs'; import Canvas from '../helpers/helpers.canvas'; import Util from '../helpers/helpers.util'; +import { convertToPercent } from '../../../common/utils'; const TITLE_HEIGHT = 30; const TEXT_HEIGHT = 14; @@ -367,9 +368,19 @@ const modules = { const opt = this.options.tooltip; const valueFormatter = typeof opt.formatter === 'function' ? opt.formatter : opt.formatter?.value; const titleFormatter = opt.formatter?.title; + const series = Object.values(this.seriesList)[0]; + + let isShow = false; + const { colorState, isGradient } = series; + if (isGradient) { + const { min, max } = series.valueOpt; + const ratio = convertToPercent(hitItem.o - min, max - min); + const { start, end } = colorState[0]; + isShow = (start <= ratio && ratio <= end) || hitItem.o === -1; + } else { + isShow = colorState.find(({ id }) => id === hitItem.cId)?.show; + } - const colorAxis = Object.values(this.seriesList)[0].colorAxis; - const isShow = colorAxis.find(({ id }) => id === hitItem.cId)?.show; if (!isShow) { this.tooltipClear(); return; diff --git a/src/components/chart/style/chart.scss b/src/components/chart/style/chart.scss index 53b80e699..af48cf5ad 100644 --- a/src/components/chart/style/chart.scss +++ b/src/components/chart/style/chart.scss @@ -39,7 +39,6 @@ .ev-chart-legend-container { position: relative; - overflow: hidden; } .ev-chart-legend-color { @@ -96,6 +95,97 @@ overflow: hidden; } +.ev-chart-legend-line { + position: relative; + width: 100%; + height: 100%; + border-radius: 10px; + background-color: #E3E3E3; + + &-layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.ev-chart-legend-thumb { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 10px; + color: #000000; +} + +.ev-chart-legend-handle { + position: absolute; + top: 0; + left: 0; + cursor: pointer; + + &.dragging, + &:hover { + transform: scale(1.2); + } + + &-btn { + width: 100%; + height: 100%; + border-radius: 50%; + background-color: $color-white; + transition: transform 0.1s ease-in-out; + box-sizing: border-box; + border: 2px solid #979797; + + &-color { + position: absolute; + top: 4px; + left: 4px; + width: calc(100% - 8px); + height: calc(100% - 8px); + border-radius: 50%; + } + } +} + +.ev-chart-legend-label { + position: absolute; + font-size: 12px; + line-height: 1.4em; + + &-text { + display: block; + white-space: nowrap; + } +} + +.ev-chart-legend-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + .ev-chart-legend-overlay-item { + position: absolute; + top: 1px; + left: 1px; + border-radius: 50%; + border: 1px solid #FFFFFF; + background-color: transparent; + } + + .ev-chart-legend-overlay-tooltip { + position: absolute; + font-size: 12px; + line-height: 1.4em; + } +} + .ev-chart-resize-bar { position: absolute; background: transparent; diff --git a/src/components/chart/uses.js b/src/components/chart/uses.js index d43822c8e..0da5582f5 100644 --- a/src/components/chart/uses.js +++ b/src/components/chart/uses.js @@ -22,6 +22,7 @@ const DEFAULT_OPTIONS = { }, legend: { show: true, + type: 'icon', position: 'right', color: '#353740', inactive: '#aaa', @@ -109,12 +110,13 @@ const DEFAULT_OPTIONS = { heatMapColor: { min: '#FFFFFF', max: '#0052FF', - categoryCnt: 5, + categoryCnt: 1, stroke: { show: false, color: '#FFFFFF', lineWidth: 1, opacity: 1, + radius: 0, }, error: '#FF0000', decimalPoint: 0,