diff --git a/src/ChartInternal/data/data.ts b/src/ChartInternal/data/data.ts index 735811c9b..b3d8a993c 100644 --- a/src/ChartInternal/data/data.ts +++ b/src/ChartInternal/data/data.ts @@ -8,8 +8,10 @@ import {KEY} from "../../module/Cache"; import { findIndex, getScrollPosition, + getTransformCTM, getUnique, hasValue, + hasViewBox, isArray, isBoolean, isDefined, @@ -701,7 +703,11 @@ export default { */ getDataIndexFromEvent(event): number { const $$ = this; - const {$el, config, state: {hasRadar, inputType, eventReceiver: {coords, rect}}} = $$; + const { + $el, + config, + state: {hasRadar, inputType, eventReceiver: {coords, rect}} + } = $$; let index; if (hasRadar) { @@ -724,11 +730,19 @@ export default { event.changedTouches[0] : event; + let point = isRotated ? + e.clientY + scrollPos.y - rect.top : + e.clientX + scrollPos.x - rect.left; + + if (hasViewBox($el.svg)) { + const pos = [point, 0]; + isRotated && pos.reverse(); + point = getTransformCTM($el.svg.node(), ...pos)[isRotated ? "y" : "x"]; + } + index = findIndex( coords, - isRotated ? - e.clientY + scrollPos.y - rect.top : - e.clientX + scrollPos.x - rect.left, + point, 0, coords.length - 1, isRotated diff --git a/src/ChartInternal/interactions/interaction.ts b/src/ChartInternal/interactions/interaction.ts index 7319879ff..7f3253c6b 100644 --- a/src/ChartInternal/interactions/interaction.ts +++ b/src/ChartInternal/interactions/interaction.ts @@ -6,7 +6,14 @@ import {drag as d3Drag} from "d3-drag"; import {select as d3Select} from "d3-selection"; import {$ARC, $AXIS, $COMMON, $SHAPE} from "../../config/classes"; import {KEY} from "../../module/Cache"; -import {emulateEvent, getPointer, isNumber, isObject} from "../../module/util"; +import { + emulateEvent, + getPointer, + getTransformCTM, + hasViewBox, + isNumber, + isObject +} from "../../module/util"; import type {IArcDataRow} from "../data/IData"; export default { @@ -152,11 +159,11 @@ export default { d3Drag() .on("drag", function(event) { state.event = event; - $$.drag(getPointer(event, this)); + $$.drag(getPointer(event, this)); }) .on("start", function(event) { state.event = event; - $$.dragstart(getPointer(event, this)); + $$.dragstart(getPointer(event, this)); }) .on("end", event => { state.event = event; @@ -183,7 +190,7 @@ export default { hasRadar, hasTreemap }, - $el: {eventRect, funnel, radar, treemap} + $el: {eventRect, funnel, radar, svg, treemap} } = $$; let element = ( ((hasFunnel || hasTreemap) && eventReceiver.rect) || @@ -211,12 +218,20 @@ export default { } } - const x = left + (mouse ? mouse[0] : 0) + ( + let x = left + (mouse ? mouse[0] : 0) + ( isMultipleX || isRotated ? 0 : (width / 2) ); // value 4, is to adjust coordinate value set from: scale.ts - updateScales(): $$.getResettedPadding(1) - const y = top + (mouse ? mouse[1] : 0) + (isRotated ? 4 : 0); + let y = top + (mouse ? mouse[1] : 0) + (isRotated ? 4 : 0); + + if (hasViewBox(svg)) { + const ctm = getTransformCTM($$.$el.svg.node(), x, y, false); + + x = ctm.x; + y = ctm.y; + } + const params = { screenX: x, screenY: y, diff --git a/src/ChartInternal/internals/tooltip.ts b/src/ChartInternal/internals/tooltip.ts index 68d8e0ba3..bd9badd6c 100644 --- a/src/ChartInternal/internals/tooltip.ts +++ b/src/ChartInternal/internals/tooltip.ts @@ -8,6 +8,8 @@ import {document} from "../../module/browser"; import { callFn, getPointer, + getTransformCTM, + hasViewBox, isEmpty, isFunction, isObject, @@ -294,7 +296,7 @@ export default { */ setTooltipPosition(dataToShow: IDataRow[], eventTarget: SVGElement): void { const $$ = this; - const {config, scale, state, $el: {eventRect, tooltip}} = $$; + const {config, scale, state, $el: {eventRect, tooltip, svg}} = $$; const {bindto} = config.tooltip_contents; const isRotated = config.axis_rotated; const datum = tooltip?.datum(); @@ -341,7 +343,11 @@ export default { height, eventRect?.node(), currPos - ) ?? $$.getTooltipPosition.bind($$)(width, height, currPos); + ) ?? ( + hasViewBox(svg) ? + $$.getTooltipPositionViewBox.bind($$)(width, height, currPos) : + $$.getTooltipPosition.bind($$)(width, height, currPos) + ); ["top", "left"].forEach(v => { const value = pos[v]; @@ -356,6 +362,51 @@ export default { } }, + getTooltipPositionViewBox(tWidth: number, tHeight: number, + currPos: {[key: string]: number}): {top: number, left: number} { + const $$ = this; + const {$el: {eventRect, main}, config, state} = $$; + + const isRotated = config.axis_rotated; + const hasArcType = $$.hasArcType(undefined, ["radar"]) || state.hasFunnel || + state.hasTreemap; + const target = (state.hasRadar ? main : eventRect)?.node() ?? state.event.target; + const size = 38; // getTransformCTM($el.svg.node(), 10, 0, false).x; + + let {x, y} = currPos; + + if (state.hasAxis) { + x = isRotated ? x : currPos.xAxis; + y = isRotated ? currPos.xAxis : y; + } + + // currPos는 SVG 좌표계 기준으로 전달됨 + const ctm = getTransformCTM(target, x, y, false); + + let top = ctm.y; + let left = ctm.x + size; + + if (hasArcType) { + top += tHeight; + left -= size; // (tWidth / 2); + } + + const rect = (hasArcType ? main.node() : target).getBoundingClientRect(); + + if (left + tWidth > rect.right) { + left = rect.right - tWidth - size; + } + + if (top + tHeight > rect.bottom) { + top -= tHeight + size; + } + + return { + top, + left + }; + }, + /** * Returns the position of the tooltip * @param {string} tWidth Width value of tooltip element @@ -375,6 +426,7 @@ export default { const hasArcType = $$.hasArcType(); const svgLeft = $$.getSvgLeft(true); let chartRight = svgLeft + current.width - $$.getCurrentPaddingByDirection("right"); + const size = 20; let {x, y} = currPos; diff --git a/src/ChartInternal/internals/type.ts b/src/ChartInternal/internals/type.ts index 8eb39fc46..4f0ad8f9a 100644 --- a/src/ChartInternal/internals/type.ts +++ b/src/ChartInternal/internals/type.ts @@ -4,6 +4,7 @@ */ import {TYPE, TYPE_BY_CATEGORY} from "../../config/const"; import {isArray, isNumber, isString} from "../../module/util"; +import type {IData} from "../data/IData"; export default { /** @@ -137,7 +138,7 @@ export default { * @returns {boolean} * @private */ - hasArcType(targets, exclude): boolean { + hasArcType(targets?: IData, exclude?: string[]): boolean { return this.hasTypeOf("Arc", targets, exclude); }, diff --git a/src/module/util.ts b/src/module/util.ts index 6380abd82..5f24f443d 100644 --- a/src/module/util.ts +++ b/src/module/util.ts @@ -34,9 +34,11 @@ export { getRange, getRectSegList, getScrollPosition, + getTransformCTM, getTranslation, getUnique, hasValue, + hasViewBox, isArray, isBoolean, isDefined, @@ -268,7 +270,7 @@ function getPathBox( * @returns {Array} [x, y] Coordinates x, y array * @private */ -function getPointer(event, element?: Element): number[] { +function getPointer(event, element?: SVGElement): number[] { const touches = event && (event.touches || (event.sourceEvent && event.sourceEvent.touches))?.[0]; let pointer = [0, 0]; @@ -537,6 +539,23 @@ function getScrollPosition(node: HTMLElement) { }; } +/** + * Get translation string from screen <--> svg point + * @param {SVGGraphicsElement} node graphics element + * @param {number} x target x point + * @param {number} y target y point + * @param {boolean} inverse inverse flag + * @returns {object} + */ +function getTransformCTM(node: SVGGraphicsElement, x = 0, y = 0, inverse = true): DOMPoint { + const point = new DOMPoint(x, y); + const screen = node.getScreenCTM(); + + return point.matrixTransform( + inverse ? screen?.inverse() : screen + ); +} + /** * Gets the SVGMatrix of an SVGGElement * @param {SVGElement} node Node element @@ -787,6 +806,17 @@ function parseDate(date: Date | string | number | any): Date { return parsedDate; } +/** + * Check if svg element has viewBox attribute + * @param {d3Selection} svg Target svg selection + * @returns {boolean} + */ +function hasViewBox(svg: d3Selection): boolean { + const attr = svg.attr("viewBox"); + + return attr ? /(\d+(\.\d+)?){3}/.test(attr) : false; +} + /** * Return if the current doc is visible or not * @returns {boolean} diff --git a/test/assets/module/util.ts b/test/assets/module/util.ts index 13f99f9a4..87040cd8f 100644 --- a/test/assets/module/util.ts +++ b/test/assets/module/util.ts @@ -33,8 +33,10 @@ export const { getRectSegList, getScrollPosition, getTranslation, + getTransformCTM, getUnique, hasValue, + hasViewBox, isArray, isBoolean, isDefined, diff --git a/test/interactions/viewbox-spec.ts b/test/interactions/viewbox-spec.ts new file mode 100644 index 000000000..d21d3d2fb --- /dev/null +++ b/test/interactions/viewbox-spec.ts @@ -0,0 +1,175 @@ +/** + * Copyright (c) 2017 ~ present NAVER Corp. + * billboard.js project is licensed under the MIT license + */ +/* eslint-disable */ +/* global describe, beforeEach, it, expect */ +import {beforeEach, beforeAll, afterEach, describe, expect, it, afterAll} from "vitest"; +import {getTransformCTM} from "../assets/module/util"; +import {fireEvent} from "../assets/helper"; +import util from "../assets/util"; + +describe("viewBox", function() { + let chart; + let temp: any = []; + let args: any = { + size: { + width: 300, + height: 200 + }, + resize: { + auto: false + }, + data: { + columns: [ + ["data1", 300, 350, 300, 0, 0, 0], + ["data2", 130, 100, 140, 200, 150, 50] + ], + type: "bar" + }, + axis: { + rotated: false + }, + onrendered() { + this.$.svg.attr("viewBox", "0 0 320 240") + .style("width", "100%") + .style("height", "auto"); + }, + tooltip: { + onshown() { + const type = this.config("data.type"); + const selector = type === "bar" ? "th" : ".name"; + const value = this.$.tooltip.select(selector)?.text?.(); + + temp.push(value); + } + } + }; + + beforeEach(() => { + chart = util.generate(args); + }); + + afterEach(() => { + temp = []; + chart.destroy(); + }); + + describe("check interaction", () => { + beforeAll(() => { + args = { + size: { + width: 300, + height: 200 + }, + resize: { + auto: false + }, + data: { + columns: [ + ["data1", 300, 350, 300, 0, 0, 0], + ["data2", 130, 100, 140, 200, 150, 50] + ], + type: "bar" + }, + axis: { + rotated: false + }, + onafterinit() { + this.$.svg.attr("viewBox", "0 0 320 240") + .style("width", "100%") + .style("height", "auto"); + }, + tooltip: { + onshown() { + const type = this.config("data.type"); + const isBar = type === "bar"; + const selector = isBar ? "th" : ".name"; + const value = this.$.tooltip.select(selector)?.text?.(); + + this.$.tooltip.style("top") + + if (isBar) { + temp.push(value); + } else { + temp.push({ + top: util.parseNum(this.$.tooltip.style("top")), + left: util.parseNum(this.$.tooltip.style("left")), + value + }); + } + } + } + }; + }); + + it("check transform CTM", () => { + const {internal: {$el: {svg}}} = chart; + const ctm = getTransformCTM(svg.node(), 100, 100); + const inverse = getTransformCTM(svg.node(), ctm.x, ctm.y, false); + + expect(inverse.x).to.be.equal(100); + expect(inverse.y).to.be.equal(100); + }); + + it("should show tooltip on viewBox scale", () => { + const {internal: {$el: {eventRect}}} = chart; + + [100, 150, 200, 270, 350, 450].forEach(x => { + fireEvent(eventRect.node(), "mousemove", { + clientX: x, + clientY: 50 + }, chart); + }); + + expect(temp.map(Number)).to.be.deep.equal([0, 1, 2, 3, 4, 5]); + }); + + it("set options: axis.rotated=true", () => { + args.axis.rotated = true; + }); + + it("should show tooltip on viewBox scale: roated axis", () => { + const {internal: {$el: {eventRect}}} = chart; + + [10, 50, 100, 150, 200, 250].forEach(y => { + fireEvent(eventRect.node(), "mousemove", { + clientX: 50, + clientY: y + }, chart); + }); + + expect(temp.map(Number)).to.be.deep.equal([0, 1, 2, 3, 4, 5]); + + }); + + it("set options", () => { + args.data.type = "pie"; + }); + + it("should show tooltip on pie", () => { + const {arc} = chart.$; + const expected = [ + { + "left": 0, + "top": 426.656, + "value": "data1", + }, + { + "left": 303.112, + "top": 117.552, + "value": "data2", + } + ]; + + arc.selectAll("path").each(function(d, i) { + fireEvent(this, "mousemove", { + clientX: 250, + clientY: 150 + }, chart); + }); + + expect(temp).to.be.deep.equal(expected); + }); + }); +});