diff --git a/docs/views/scatterChart/api/scatterChart.md b/docs/views/scatterChart/api/scatterChart.md index 6eb263e9d..420039f86 100644 --- a/docs/views/scatterChart/api/scatterChart.md +++ b/docs/views/scatterChart/api/scatterChart.md @@ -11,7 +11,19 @@ ``` >## Props -### 1. data +### 1. v-model:selectedItem +- option에서 [selectItem](#selectitem) 옵션을 사용할 경우 유효한 바인딩 +- 현재 선택된 Item에 대한 정보 (seriesID, dataIndex) +#### Example +``` +const selectedItem = ref({ + seriesID: 'series1', // Series ID (key) + dataIndex: 0, // 몇번째 데이터인지 +}); +``` + + +### 2. data | 이름 | 타입 | 디폴트 | 설명 | 종류 | |------------ |-----------|---------|-------------------------|---------------------------------------------------| | series | Object | {} | 특정 데이터에 대한 시리즈 옵션 | | @@ -42,7 +54,7 @@ const chartData = }; ``` -### 2. options +### 3. options | 이름 | 타입 | 디폴트 | 설명 | 종류(예시) | |------------ |-----------|---------|-------------------------|---------------------------------------------------| | type | String | '' | series 별로 type값을 지정하지 않을 경우 일괄 적용될 차트의 타입 | 'bar', 'pie', 'line', 'scatter' | @@ -55,6 +67,7 @@ const chartData = | dragSelection | Object | ([상세](#dragselection)) | drag-select의 사용 여부 | | | padding | Object | { top: 20, right: 2, left: 2, bottom: 4 } | 차트 내부 padding 값 | | tooltip | Object | ([상세](#tooltip)) | 차트에 마우스를 올릴 경우 툴팁 표시 여부 및 속성 | | + | selectItem | Object | ([상세](#selectitem)) | 차트 아이템 선택 기능 활성화 여부 및 속성 | | #### axesX axesY ##### type 공통 @@ -176,14 +189,27 @@ const chartData = | showAllValueInRange | Boolean | false | 동일한 axes값을 가진 전체 series를 Tooltip에 표시 | | formatter | function | null | 데이터가 표시되기 전에 데이터의 형식을 지정하는 데 사용 | ({x, y, name}) => y + '%' | +#### selectItem +| 이름 | 타입 | 디폴트 | 설명 | 종류(예시) | +| --- | ---- | ----- | --- | ----------| +| use | Boolean | false | 차트 아이템 선택 기능 | | +| showTextTip | Boolean | false | 선택한 위치의 TextTip(text 포함 화살표, 흡사 말풍선) 생성 여부 | | +| tipText | String | 'value' | 선택한 위치에 TextTip을 생성한다면 어떤 값 | 'value', 'label | +| showTip | Boolean | false | 선택한 위치의 Tip(화살표) 생성 여부 | | +| showIndicator | Boolean | false | 선택한 label의 indicator 표시 | | +| fixedPosTop | Boolean | false | indicator 및 tip의 위치를 최대값으로 고정 | | +| useApproximateValue | Boolean | false | 가까운 label을 선택 | | +| indicatorColor | Hex, RGB, RGBA Code(String) | '#000000' | indicator 색상 | | +| tipBackground | Hex, RGB, RGBA Code(String) | '#000000' | tip 배경색상 | | +| tipTextColor | Hex, RGB, RGBA Code(String) | '#FFFFFF' | tip 글자 색상 | | +| useSeriesOpacity | Boolean | false | 선택한 항목을 제외한 나머지 항목들에 반투명 효과 적용 여부 | | -### 3. resize-timeout +### 4. resize-timeout - Default : 0 - debounce 사용. 연속으로 이벤트가 발생한 경우, 마지막 이벤트가 끝난 시점을 기준으로 `주어진 시간 (resize-timeout)` 이후 콜백 실행 ->### Event - +### 5. Event | 이름 | 파라미터 | 설명 | |------|----------|------| | drag-select | data, range | 그래프에서 드래그를 해서 선택영역 안의 데이터와 선택영역에 대한 범위 값을 얻을 수 있다.

ex) data : [{ seriesName, seriesId, items: [] }, {...}, {...}]
ex) range : { xMin, xMax, yMin, yMax }

data의 요소 propery중 items 는 해당 Series의 데이터 들이 있으며 x, y값은 데이터 기반 +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
기본 선택값 v-model
+ {{ defaultSelectItem }} +
+
+
클릭된 Item 정보
+ {{ clickedInfo }} +
+
+
+ + + + + diff --git a/docs/views/scatterChart/props.js b/docs/views/scatterChart/props.js index eee9f7a53..f637faeea 100644 --- a/docs/views/scatterChart/props.js +++ b/docs/views/scatterChart/props.js @@ -4,6 +4,8 @@ import Default from './example/Default'; import DefaultRaw from '!!raw-loader!./example/Default'; import Event from './example/Event'; import EventRaw from '!!raw-loader!./example/Event'; +import SelectItem from './example/SelectItem'; +import SelectItemRaw from '!!raw-loader!./example/SelectItem'; import PlotLine from './example/PlotLine'; import PlotLineRaw from '!!raw-loader!./example/PlotLine'; @@ -15,6 +17,11 @@ export default { component: Default, parsedData: parseComponent(DefaultRaw), }, + SelectItem: { + description: 'Point를 선택 표시할 수 있습니다.', + component: SelectItem, + parsedData: parseComponent(SelectItemRaw), + }, Event: { description: 'Drag Select, Click, Double Click 이벤트 등록이 가능 합니다', component: Event, diff --git a/src/components/chart/chart.core.js b/src/components/chart/chart.core.js index 5724b8bc5..8a4f8bffd 100644 --- a/src/components/chart/chart.core.js +++ b/src/components/chart/chart.core.js @@ -153,7 +153,7 @@ class EvChart { * @returns {undefined} */ drawSeries(hitInfo) { - const { maxTip, selectLabel } = this.options; + const { maxTip, selectLabel, selectItem } = this.options; const opt = { ctx: this.bufferCtx, @@ -181,30 +181,69 @@ class EvChart { for (let jx = 0; jx < chartTypeSet.length; jx++) { const series = this.seriesList[chartTypeSet[jx]]; - if (chartType === 'line' || chartType === 'scatter' || chartType === 'heatMap') { - series.draw(opt); - } else if (chartType === 'bar') { - const { thickness, borderRadius } = this.options; - series.draw({ thickness, borderRadius, showSeriesCount, showIndex, ...opt }); - - if (series.show) { - showIndex++; + switch (chartType) { + case 'line': + case 'heatMap': { + series.draw(opt); + break; } - } else { - const selectInfo = hitInfo - ?? this.lastHitInfo - ?? { sId: this.defaultSelectItemInfo?.seriesID }; - - if (this.options.sunburst) { - this.drawSunburst(selectInfo); - } else { - this.drawPie(selectInfo); + case 'bar': { + const { thickness, borderRadius } = this.options; + series.draw({ thickness, borderRadius, showSeriesCount, showIndex, ...opt }); + if (series.show) { + showIndex++; + } + break; } - - if (this.options.doughnutHoleSize > 0) { - this.drawDoughnutHole(); + case 'pie': { + const selectInfo = hitInfo + ?? this.lastHitInfo + ?? { sId: this.defaultSelectItemInfo?.seriesID }; + + if (this.options.sunburst) { + this.drawSunburst(selectInfo); + } else { + this.drawPie(selectInfo); + } + + if (this.options.doughnutHoleSize > 0) { + this.drawDoughnutHole(); + } + break; + } + case 'scatter': { + if (selectItem.use && selectItem.useSeriesOpacity) { + if (hitInfo) { + if (hitInfo?.maxIndex || hitInfo?.maxIndex === 0) { + opt.selectInfo = { + seriesID: hitInfo.sId, + dataIndex: hitInfo.maxIndex, + }; + } else { + opt.selectInfo = null; + } + } else if (this.lastHitInfo?.maxIndex || this.lastHitInfo?.maxIndex === 0) { + opt.selectInfo = { + seriesID: this.lastHitInfo.sId, + dataIndex: this.lastHitInfo.maxIndex, + }; + } else if (this.defaultSelectItemInfo?.dataIndex + || this.defaultSelectItemInfo?.dataIndex === 0) { + opt.selectInfo = { + seriesID: this.defaultSelectItemInfo.seriesID, + dataIndex: this.defaultSelectItemInfo.dataIndex, + }; + } else { + opt.selectInfo = null; + } + } + + series.draw(opt); + break; + } + default: { + break; } - break; } } } @@ -621,7 +660,7 @@ class EvChart { this.labelOffset = this.getLabelOffset(); this.initSelectedLabelInfo(); - this.render(); + this.render(updateInfo?.hitInfo); const isDragMove = this.dragInfo && this.drawSelectionArea; if (isDragMove) { diff --git a/src/components/chart/element/element.scatter.js b/src/components/chart/element/element.scatter.js index f08606dca..d88662112 100644 --- a/src/components/chart/element/element.scatter.js +++ b/src/components/chart/element/element.scatter.js @@ -69,18 +69,34 @@ class Scatter { return item; }, this.data[0]); - const getOpacity = (colorStr) => { + const getOpacity = (colorStr, dataIndex) => { const noneDownplayOpacity = colorStr.includes('rgba') ? Util.getOpacity(colorStr) : 1; - return this.state === 'downplay' ? 0.1 : noneDownplayOpacity; + let resultOpacity = noneDownplayOpacity; + + const { selectInfo } = param; + if (selectInfo) { + const isSelectedData = selectInfo?.seriesID === this.sId + && selectInfo?.dataIndex === dataIndex; + + if (isSelectedData) { + resultOpacity = noneDownplayOpacity; + } else { + resultOpacity = 0.1; + } + } else { + resultOpacity = this.state === 'downplay' ? 0.1 : noneDownplayOpacity; + } + + return resultOpacity; }; - this.data.forEach((curr) => { + this.data.forEach((curr, idx) => { if (curr.xp !== null && curr.yp !== null) { const color = curr.dataColor || this.color; - ctx.strokeStyle = Util.colorStringToRgba(color, getOpacity(color)); + ctx.strokeStyle = Util.colorStringToRgba(color, getOpacity(color, idx)); const pointFillColor = curr.dataColor || this.pointFill; - ctx.fillStyle = Util.colorStringToRgba(pointFillColor, getOpacity(pointFillColor)); + ctx.fillStyle = Util.colorStringToRgba(pointFillColor, getOpacity(pointFillColor, idx)); Canvas.drawPoint(ctx, this.pointStyle, this.pointSize, curr.xp, curr.yp); } @@ -148,11 +164,11 @@ class Scatter { findGraphData(offset) { const xp = offset[0]; const yp = offset[1]; - const item = { data: null, hit: false, color: this.color }; + const item = { data: null, hit: false, color: this.color, index: null }; const pointSize = this.pointSize; const gdata = this.data; - const foundItem = gdata.find((data) => { + const targetIndex = gdata.findIndex((data) => { const x = data.xp; const y = data.yp; @@ -162,8 +178,9 @@ class Scatter { && (yp <= y + pointSize); }); - if (foundItem) { - item.data = foundItem; + if (targetIndex > -1) { + item.data = gdata[targetIndex]; + item.index = targetIndex; item.hit = true; } diff --git a/src/components/chart/element/element.tip.js b/src/components/chart/element/element.tip.js index 7bda67934..fe6e7c50b 100644 --- a/src/components/chart/element/element.tip.js +++ b/src/components/chart/element/element.tip.js @@ -51,28 +51,30 @@ const modules = { selArgs.text = numberWithComma(selArgs.value); } - this.drawTextTip({ opt: selTipOpt, tipType: 'sel', isSamePos, ...selArgs }); + this.drawTextTip({ opt: selTipOpt, tipType: 'sel', seriesOpt: seriesInfo, isSamePos, ...selArgs }); } if (selTipOpt.showIndicator) { - this.drawFixedIndicator({ opt: selTipOpt, ...selArgs }); + this.drawFixedIndicator({ opt: selTipOpt, seriesOpt: seriesInfo, ...selArgs }); } } - if (tipLocationInfo && tipLocationInfo.label !== null) { + if (tipLocationInfo && tipLocationInfo?.label && tipLocationInfo?.label === 0) { this.lastHitInfo = tipLocationInfo; } } + if (maxTipOpt.use && !isExistSelectedLabel) { const maxSID = this.minMax[isHorizontal ? 'x' : 'y'][0].maxSID; - maxArgs = this.calculateTipInfo(this.seriesList[maxSID], 'max', null); + const seriesInfo = this.seriesList[maxSID]; + maxArgs = this.calculateTipInfo(seriesInfo, 'max', null); if (maxTipOpt.use && maxArgs) { maxArgs.text = numberWithComma(maxArgs.value); - this.drawTextTip({ opt: maxTipOpt, tipType: 'max', ...maxArgs }); + this.drawTextTip({ opt: maxTipOpt, tipType: 'max', seriesOpt: seriesInfo, ...maxArgs }); if (maxTipOpt.showIndicator) { - this.drawFixedIndicator({ opt: maxTipOpt, ...maxArgs }); + this.drawFixedIndicator({ opt: maxTipOpt, seriesOpt: seriesInfo, ...maxArgs }); } } } @@ -175,6 +177,14 @@ const modules = { xArea - size.comboOffset, xsp + (size.comboOffset / 2), ); + } else if (type === 'scatter') { + dp = Canvas.calculateX( + ldata, + graphX.graphMin, + graphX.graphMax, + xArea, + xsp, + ); } const sizeObj = { xArea, yArea, graphX, graphY, xsp, xep, ysp }; @@ -185,8 +195,14 @@ const modules = { drawFixedIndicator(param) { const isHorizontal = !!this.options.horizontal; const ctx = this.bufferCtx; - const { graphX, graphY, xArea, yArea, xsp, ysp, dp, type, value, opt } = param; - const offset = type === 'bar' ? 0 : 3; + const { graphX, graphY, xArea, yArea, xsp, ysp, dp, type, value, opt, seriesOpt } = param; + let offset = 0; + + if (type === 'line') { + offset += 3; + } else if (type === 'scatter') { + offset += seriesOpt?.pointSize ?? 0; + } let gp; @@ -351,12 +367,20 @@ const modules = { const isHorizontal = !!this.options.horizontal; const ctx = this.bufferCtx; const { graphX, graphY, xArea, yArea, xsp, xep, ysp } = param; - const { dp, value, text, opt, type, tipType, isSamePos } = param; + const { dp, value, text, opt, type, tipType, isSamePos, seriesOpt } = param; const arrowSize = 4; const maxTipHeight = 20; const borderRadius = 4; - const offset = type === 'bar' ? 4 : 6; + + let offset = 1; + if (type === 'line') { + offset += 6; + } else if (type === 'scatter') { + offset += seriesOpt?.pointSize; + } else if (type === 'bar') { + offset += 4; + } let gp; let tdp = dp; diff --git a/src/components/chart/plugins/plugins.interaction.js b/src/components/chart/plugins/plugins.interaction.js index 65103151d..44b61112a 100644 --- a/src/components/chart/plugins/plugins.interaction.js +++ b/src/components/chart/plugins/plugins.interaction.js @@ -54,7 +54,11 @@ const modules = { if (tooltip.use) { this.setTooltipLayoutPosition(hitInfo, e); - this.drawTooltip(hitInfo, this.tooltipCtx); + if (type === 'scatter') { + this.drawTooltipForScatter(hitInfo, this.tooltipCtx); + } else { + this.drawTooltip(hitInfo, this.tooltipCtx); + } } } else if (tooltip.use) { this.hideTooltipDOM(); diff --git a/src/components/chart/plugins/plugins.legend.js b/src/components/chart/plugins/plugins.legend.js index b506aeb83..2f6bef09e 100644 --- a/src/components/chart/plugins/plugins.legend.js +++ b/src/components/chart/plugins/plugins.legend.js @@ -197,6 +197,7 @@ const modules = { this.update({ updateSeries: false, updateSelTip: { update: false, keepDomain: false }, + hitInfo: { sId: targetId }, }); }; diff --git a/src/components/chart/plugins/plugins.tooltip.js b/src/components/chart/plugins/plugins.tooltip.js index cf4284497..45feb083d 100644 --- a/src/components/chart/plugins/plugins.tooltip.js +++ b/src/components/chart/plugins/plugins.tooltip.js @@ -1,5 +1,6 @@ import { numberWithComma } from '@/common/utils'; import debounce from '@/common/utils.debounce'; +import dayjs from 'dayjs'; import Canvas from '../helpers/helpers.canvas'; import Util from '../helpers/helpers.util'; @@ -342,18 +343,7 @@ const modules = { ctx.restore(); - // set tooltipDOM's style - this.tooltipDOM.style.overflowY = 'hidden'; - this.tooltipDOM.style.backgroundColor = opt.backgroundColor; - this.tooltipDOM.style.border = `1px solid ${opt.borderColor}`; - this.tooltipDOM.style.color = opt.fontColor; - - if (opt.useShadow) { - const shadowColor = `rgba(0, 0, 0, ${opt.shadowOpacity})`; - this.tooltipDOM.style.boxShadow = `2px 2px 2px ${shadowColor}`; - } - - this.tooltipDOM.style.display = 'block'; + this.setTooltipDOMStyle(opt); }, /** @@ -421,14 +411,181 @@ const modules = { ctx.fillText(itemValue, itemX + COLOR_MARGIN, itemY); ctx.closePath(); - // set tooltipDOM's style + this.setTooltipDOMStyle(opt); + }, + + /** + * + * @param hitInfo + * @param context + */ + drawTooltipForScatter(hitInfo, context) { + const ctx = context; + const items = hitInfo.items; + const [, maxValue] = hitInfo.maxTip; + const seriesKeys = this.alignSeriesList(Object.keys(items)); + const boxPadding = { t: 8, b: 8, r: 8, l: 8 }; + const opt = this.options.tooltip; + const xAxisOpt = this.options.axesX[0]; + + let x = 2; + let y = 2; + + x += Util.aliasPixel(x); + y += Util.aliasPixel(y); + + ctx.save(); + ctx.scale(this.pixelRatio, this.pixelRatio); + + if (this.tooltipBodyDOM.style.overflowY === 'auto') { + boxPadding.r += SCROLL_WIDTH; + } + + x += boxPadding.l; + y += boxPadding.t; + + ctx.font = FONT_STYLE; + + const seriesList = []; + seriesKeys.forEach((seriesName) => { + seriesList.push({ + data: items[seriesName].data, + color: items[seriesName].color, + name: items[seriesName].name, + }); + }); + + if (opt.sortByValue) { + seriesList.sort((a, b) => { + let prev = a.data.o; + let next = b.data.o; + + if (prev === null || prev === undefined) { + prev = a.data.y; + } + + if (next === null || next === undefined) { + next = b.data.y; + } + + return next - prev; + }); + } + + let textLineCnt = 1; + for (let ix = 0; ix < seriesList.length; ix++) { + const gdata = seriesList[ix].data; + const color = seriesList[ix].color; + const name = seriesList[ix].name; + const xValue = gdata.x; + let yValue; + if (gdata.o === null) { + yValue = gdata.y; + } else if (!isNaN(gdata.o)) { + yValue = gdata.o; + } + + let itemX = x + 4; + let itemY = y + (textLineCnt * TEXT_HEIGHT); + itemX += Util.aliasPixel(itemX); + itemY += Util.aliasPixel(itemY); + + ctx.beginPath(); + + if (typeof color !== 'string') { + ctx.fillStyle = Canvas.createGradient( + ctx, + false, + { x: itemX - 4, y: itemY, w: 12, h: -12 }, + color, + ); + } else { + ctx.fillStyle = color; + } + + // 1. Draw series color + ctx.fillRect(itemX - 4, itemY - 12, 12, 12); + ctx.fillStyle = opt.fontColor; + + // 2. Draw series name + ctx.textBaseline = 'Bottom'; + const seriesNameSpaceWidth = opt.maxWidth - Math.round(ctx.measureText(maxValue).width) + - boxPadding.l - boxPadding.r - COLOR_MARGIN - VALUE_MARGIN; + const xPos = itemX + COLOR_MARGIN; + const yPos = itemY; + + if (seriesNameSpaceWidth > ctx.measureText(name).width) { // draw normally + ctx.fillText(name, xPos, yPos); + } else if (opt.textOverflow === 'wrap') { // draw with wrap + let line = ''; + let yPosWithWrap = yPos; + + for (let jx = 0; jx < name.length; jx++) { + const char = name[jx]; + const temp = `${line}${char}`; + + if (ctx.measureText(temp).width > seriesNameSpaceWidth) { + ctx.fillText(line, xPos, yPosWithWrap); + line = char; + textLineCnt += 1; + yPosWithWrap += TEXT_HEIGHT; + } else { + line = temp; + } + } + ctx.fillText(line, xPos, yPosWithWrap); + } else { // draw with ellipsis + const shortSeriesName = Util.truncateLabelWithEllipsis(name, seriesNameSpaceWidth, ctx); + ctx.fillText(shortSeriesName, xPos, yPos); + } + + ctx.save(); + + // 3. Draw value + let formattedTxt; + if (opt.formatter) { + formattedTxt = opt.formatter({ + x: xValue, + y: yValue, + name, + }); + } + + if (!opt.formatter || typeof formattedTxt !== 'string') { + const formattedXValue = xAxisOpt.type === 'time' + ? dayjs(xValue).format(xAxisOpt.timeFormat) + : numberWithComma(xValue); + const formattedYValue = numberWithComma(yValue); + formattedTxt = `${formattedXValue}, ${formattedYValue}`; + } + + ctx.textAlign = 'right'; + ctx.fillText(formattedTxt, this.tooltipDOM.offsetWidth - boxPadding.r, itemY); + ctx.restore(); + ctx.closePath(); + + // 4. add lineSpacing + y += LINE_SPACING; + textLineCnt += 1; + } + + ctx.restore(); + + this.setTooltipDOMStyle(opt); + }, + + /** + * set style properties on tooltip DOM + * @param tooltipOptions + */ + setTooltipDOMStyle(tooltipOptions) { this.tooltipDOM.style.overflowY = 'hidden'; - this.tooltipDOM.style.backgroundColor = opt.backgroundColor; - this.tooltipDOM.style.border = `1px solid ${opt.borderColor}`; - this.tooltipDOM.style.color = opt.fontColor; + this.tooltipDOM.style.backgroundColor = tooltipOptions.backgroundColor; + this.tooltipDOM.style.border = `1px solid ${tooltipOptions.borderColor}`; + this.tooltipDOM.style.color = tooltipOptions.fontColor; - if (opt.useShadow) { - const shadowColor = `rgba(0, 0, 0, ${opt.shadowOpacity})`; + if (tooltipOptions.useShadow) { + const shadowColor = `rgba(0, 0, 0, ${tooltipOptions.shadowOpacity})`; this.tooltipDOM.style.boxShadow = `2px 2px 2px ${shadowColor}`; } diff --git a/src/components/chart/uses.js b/src/components/chart/uses.js index ab026dc72..298c6ac66 100644 --- a/src/components/chart/uses.js +++ b/src/components/chart/uses.js @@ -81,6 +81,7 @@ const DEFAULT_OPTIONS = { indicatorColor: '#000000', tipBackground: '#000000', tipTextColor: '#FFFFFF', + useSeriesOpacity: false, }, selectLabel: { use: false,