diff --git a/src/Chart/api/subchart.ts b/src/Chart/api/subchart.ts index e0da7771b..98da2998d 100644 --- a/src/Chart/api/subchart.ts +++ b/src/Chart/api/subchart.ts @@ -2,99 +2,161 @@ * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ +import {extend, parseDate} from "../../module/util"; import {$COMMON} from "../../config/classes"; +import type {TDomain} from "../../ChartInternal/data/IData"; -export default { - subchart: { - /** - * Show subchart - * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. - * @function subchart․show - * @instance - * @memberof Chart - * @example - * // for ESM imports, needs to import 'subchart' and must be instantiated first to enable subchart's API. - * import {subchart} from "billboard.js"; - * - * const chart = bb.generate({ - * ... - * subchart: { - * // need to be instantiated by calling 'subchart()' - * enabled: subchart() - * - * // in case don't want subchart to be shown at initialization, instantiate with '!subchart()' - * enabled: !subchart() - * } - * }); - * - * chart.subchart.show(); - */ - show(): void { - const $$ = this.internal; - const {$el: {subchart}, config} = $$; - const show = config.subchart_show; - - if (!show) { - // unbind zoom event bound to chart rect area - $$.unbindZoomEvent(); - - config.subchart_show = !show; - !subchart.main && $$.initSubchart(); - - let $target = subchart.main.selectAll(`.${$COMMON.target}`); - - // need to cover when new data has been loaded - if ($$.data.targets.length !== $target.size()) { - $$.updateSizes(); - $$.updateTargetsForSubchart($$.data.targets); - - $target = subchart.main?.selectAll(`.${$COMMON.target}`); - } - - $target?.style("opacity", null); - subchart.main?.style("display", null); - - this.resize(); - } - }, - - /** - * Hide generated subchart - * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. - * @function subchart․hide - * @instance - * @memberof Chart - * @example - * chart.subchart.hide(); - */ - hide(): void { - const $$ = this.internal; - const {$el: {subchart: {main}}, config} = $$; - - if (config.subchart_show && main?.style("display") !== "none") { - config.subchart_show = false; - main.style("display", "none"); - - this.resize(); +/** + * Select subchart by giving x domain range. + * @function subchart + * @instance + * @memberof Chart + * @param {Array} domainValue If domain range is given, the subchart will be seleted to the given domain. If no argument is given, the current subchart selection domain will be returned. + * @returns {Array} domain value in array + * @example + * // Specify domain for subchart selection + * chart.subchart([1, 2]); + * + * // Get the current subchart selection domain range + * chart.subchart(); + */ +// NOTE: declared funciton assigning to variable to prevent duplicated method generation in JSDoc. +const subchart = function(domainValue?: T): T | undefined { + const $$ = this.internal; + const {axis, brush, config, scale: {x, subX}} = $$; + let domain: any = domainValue; + + if (config.subchart_show && Array.isArray(domain)) { + if (axis.isTimeSeries()) { + domain = domain.map(x => parseDate.bind($$)(x)); + } + + const isWithinRange = $$.withinRange( + domain, + $$.getZoomDomain("subX", true), + $$.getZoomDomain("subX") + ); + + isWithinRange && brush.move( + brush.getSelection(), + domain.map(subX) + ); + } else { + domain = x.orgDomain(); + } + + return domain as T; +}; + +extend(subchart, { + /** + * Show subchart + * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. + * @function subchart․show + * @instance + * @memberof Chart + * @example + * // for ESM imports, needs to import 'subchart' and must be instantiated first to enable subchart's API. + * import {subchart} from "billboard.js"; + * + * const chart = bb.generate({ + * ... + * subchart: { + * // need to be instantiated by calling 'subchart()' + * enabled: subchart() + * + * // in case don't want subchart to be shown at initialization, instantiate with '!subchart()' + * enabled: !subchart() + * } + * }); + * + * chart.subchart.show(); + */ + show(): void { + const $$ = this.internal; + const {$el: {subchart}, config} = $$; + const show = config.subchart_show; + + if (!show) { + // unbind zoom event bound to chart rect area + $$.unbindZoomEvent(); + + config.subchart_show = !show; + !subchart.main && $$.initSubchart(); + + let $target = subchart.main.selectAll(`.${$COMMON.target}`); + + // need to cover when new data has been loaded + if ($$.data.targets.length !== $target.size()) { + $$.updateSizes(); + $$.updateTargetsForSubchart($$.data.targets); + + $target = subchart.main?.selectAll(`.${$COMMON.target}`); } - }, - - /** - * Toggle the visiblity of subchart - * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. - * @function subchart․toggle - * @instance - * @memberof Chart - * @example - * // When subchart is hidden, will be shown - * // When subchart is shown, will be hidden - * chart.subchart.toggle(); - */ - toggle(): void { - const $$ = this.internal; - const {config} = $$; - - this.subchart[config.subchart_show ? "hide" : "show"](); + + $target?.style("opacity", null); + subchart.main?.style("display", null); + + this.resize(); + } + }, + + /** + * Hide generated subchart + * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. + * @function subchart․hide + * @instance + * @memberof Chart + * @example + * chart.subchart.hide(); + */ + hide(): void { + const $$ = this.internal; + const {$el: {subchart: {main}}, config} = $$; + + if (config.subchart_show && main?.style("display") !== "none") { + config.subchart_show = false; + main.style("display", "none"); + + this.resize(); } + }, + + /** + * Toggle the visiblity of subchart + * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. + * @function subchart․toggle + * @instance + * @memberof Chart + * @example + * // When subchart is hidden, will be shown + * // When subchart is shown, will be hidden + * chart.subchart.toggle(); + */ + toggle(): void { + const $$ = this.internal; + const {config} = $$; + + this.subchart[config.subchart_show ? "hide" : "show"](); + }, + + /** + * Reset subchart selection + * @function subchart․reset + * @instance + * @memberof Chart + * @example + * // Reset subchart selection + * chart.subchart.reset(); + */ + reset(): void { + const $$ = this.internal; + const {brush} = $$; + + brush.clear(brush.getSelection()); } +}); + +export default { + subchart }; diff --git a/src/Chart/api/zoom.ts b/src/Chart/api/zoom.ts index f6535173f..4654e6bb5 100644 --- a/src/Chart/api/zoom.ts +++ b/src/Chart/api/zoom.ts @@ -4,29 +4,7 @@ */ import {zoomIdentity as d3ZoomIdentity, zoomTransform as d3ZoomTransform} from "d3-zoom"; import {extend, getMinMax, isDefined, isObject, parseDate} from "../../module/util"; - -/** - * Check if the given domain is within zoom range - * @param {Array} domain Target domain value - * @param {Array} current Current zoom domain value - * @param {Array} range Zoom range value - * @param {boolean} isInverted Whether the axis is inverted or not - * @returns {boolean} - * @private - */ -function withinRange( - domain: (number|Date)[], current, range: number[], isInverted = false -): boolean { - const [min, max] = range; - - return domain.every((v, i) => ( - i === 0 ? ( - isInverted ? +v <= min : +v >= min - ) : ( - isInverted ? +v >= max : +v <= max - ) - ) && !(domain.every((v, i) => v === current[i]))); -} +import type {TDomainRange} from "../../ChartInternal/data/IData"; /** * Zoom by giving x domain range. @@ -48,29 +26,28 @@ function withinRange( * // Get the current zoomed domain range * chart.zoom(); */ -const zoom = function(domainValue?: (Date|number|string)[]): (Date|number)[]|undefined { +// NOTE: declared funciton assigning to variable to prevent duplicated method generation in JSDoc. +const zoom = function(domainValue?: T): T | undefined { const $$ = this.internal; const {$el, axis, config, org, scale} = $$; const isRotated = config.axis_rotated; - const isInverted = config.axis_x_inverted; const isCategorized = axis.isCategorized(); - let domain = domainValue; + let domain: any = domainValue; - if (config.zoom_enabled && domain) { + if (config.zoom_enabled && Array.isArray(domain)) { if (axis.isTimeSeries()) { domain = domain.map(x => parseDate.bind($$)(x)); } - const isWithinRange = withinRange( - domain as (number|Date)[], - $$.getZoomDomain(true), - $$.getZoomDomain(), - isInverted + const isWithinRange = $$.withinRange( + domain, + $$.getZoomDomain("zoom", true), + $$.getZoomDomain("zoom") ); if (isWithinRange) { if (isCategorized) { - domain = domain.map((v, i) => Number(v) + (i === 0 ? 0 : 1)); + domain = domain.map((v, i) => Number(v) + (i === 0 ? 0 : 1)) as T; } // hide any possible tooltip show before the zoom @@ -103,11 +80,10 @@ const zoom = function(domainValue?: (Date|number|string)[]): (Date|number)[]|und $$.setZoomResetButton(); } } else { - domain = scale.zoom ? - scale.zoom.domain() : scale.x.orgDomain(); + domain = scale.zoom?.domain() ?? scale.x.orgDomain(); } - return domain as (Date|number)[]; + return domain; }; extend(zoom, { @@ -129,7 +105,7 @@ extend(zoom, { * // Disable zooming * chart.zoom.enable(false); */ - enable: function(enabled: boolean | "wheel" | "drag" | any): void { + enable(enabled: boolean | "wheel" | "drag" | any): void { const $$ = this.internal; const {config} = $$; @@ -160,7 +136,7 @@ extend(zoom, { * // Set maximum range value * chart.zoom.max(20); */ - max: function(max?: number): number { + max(max?: number): number { const $$ = this.internal; const {config, org: {xDomain}} = $$; @@ -182,7 +158,7 @@ extend(zoom, { * // Set minimum range value * chart.zoom.min(-1); */ - min: function(min?: number): number { + min(min?: number): number { const $$ = this.internal; const {config, org: {xDomain}} = $$; @@ -210,7 +186,7 @@ extend(zoom, { * max: 100 * }); */ - range: function(range): {min: (number|undefined)[], max: (number|undefined)[]} { + range(range): {min: (number|undefined)[], max: (number|undefined)[]} { const zoom = this.zoom; if (isObject(range)) { diff --git a/src/ChartInternal/data/IData.ts b/src/ChartInternal/data/IData.ts index 2eda05ebd..ff2801fa1 100644 --- a/src/ChartInternal/data/IData.ts +++ b/src/ChartInternal/data/IData.ts @@ -10,6 +10,9 @@ type TDataRow = { name?: string; }; +export type TDomain = Date | number; +export type TDomainRange = [TDomain, TDomain]; + export interface ITreemapData { name: string; id?: string; // for compatibility @@ -19,7 +22,7 @@ export interface ITreemapData { } export interface IDataRow extends TDataRow { - x: number | string | Date; + x: TDomain & string; } export interface IDataPoint extends IDataRow { diff --git a/src/ChartInternal/internals/domain.ts b/src/ChartInternal/internals/domain.ts index 25cd45f75..c24eca1c9 100644 --- a/src/ChartInternal/internals/domain.ts +++ b/src/ChartInternal/internals/domain.ts @@ -3,7 +3,7 @@ * billboard.js project is licensed under the MIT license */ import {TYPE, TYPE_BY_CATEGORY} from "../../config/const"; -import type {IData} from "../data/IData"; +import type {IData, TDomainRange} from "../data/IData"; import {brushEmpty, getBrushSelection, getMinMax, isDefined, notEmpty, isValue, isObject, isNumber, diffDomain, parseDate, sortValue} from "../../module/util"; export default { @@ -368,21 +368,25 @@ export default { }, /** - * Get zoom domain + * Get subchart/zoom domain + * @param {string} type "subX" or "zoom" + * @param {boolean} getCurrent Get current domain if true * @returns {Array} zoom domain * @private */ - getZoomDomain(): [number|Date, number|Date] { + getZoomDomain(type: "subX" | "zoom" = "zoom", getCurrent = false): TDomainRange { const $$ = this; - const {config, org} = $$; - let [min, max] = org.xDomain; + const {config, scale, org} = $$; + let [min, max] = getCurrent && scale[type] ? scale[type].domain() : org.xDomain; - if (isDefined(config.zoom_x_min)) { - min = getMinMax("min", [min, config.zoom_x_min]); - } + if (type === "zoom") { + if (isDefined(config.zoom_x_min)) { + min = getMinMax("min", [min, config.zoom_x_min]); + } - if (isDefined(config.zoom_x_max)) { - max = getMinMax("max", [max, config.zoom_x_max]); + if (isDefined(config.zoom_x_max)) { + max = getMinMax("max", [max, config.zoom_x_max]); + } } return [min, max]; @@ -409,5 +413,37 @@ export default { } return domainLength * (pixels / state[length]); + }, + + /** + * Check if the given domain is within subchart/zoom range + * @param {Array} domain Target domain value + * @param {Array} current Current subchart/zoom domain value + * @param {Array} range subchart/zoom range value + * @returns {boolean} + * @private + */ + withinRange(domain: T, current: T, range: T): boolean { + const $$ = this; + const isInverted = $$.config.axis_x_inverted; + const [min, max] = range as number[]; + + if (Array.isArray(domain)) { + const target = [...domain]; + + isInverted && target.reverse(); + + if (target[0] < target[1]) { + return domain.every((v, i) => ( + i === 0 ? ( + isInverted ? +v <= min : +v >= min + ) : ( + isInverted ? +v >= max : +v <= max + ) + ) && !(domain.every((v, i) => v === current[i]))); + } + } + + return false; } }; diff --git a/test/api/subchart-spec.ts b/test/api/subchart-spec.ts index bb0093589..060ec2293 100644 --- a/test/api/subchart-spec.ts +++ b/test/api/subchart-spec.ts @@ -177,4 +177,109 @@ describe("API subchart", () => { expect(spy.calledOnce).to.be.false; }); }); + + describe(".subchart() / .subchart.reset()", () => { + before(() => { + args = { + data: { + columns: [ + ["sample", 30, 200, 100, 400, 150, 250] + ], + type: "line" + }, + subchart: { + show: true, + showHandle: true + } + }; + }); + + it("check subchart selection and reset", () => { + const {brush, scale: {x, subX}} = chart.internal; + const range = [1, 3]; + + // when + chart.subchart(range); + + const selection = brush.getSelection().select(".selection"); + const posX = +selection.attr("x"); + const width = +selection.attr("width"); + + expect(posX).to.be.equal(subX(range[0])); + expect(posX + width).to.be.closeTo(subX(range[1]), 1); + + // when + chart.subchart.reset(); + + expect(selection.attr("width")).to.be.null; + expect(x.domain()).to.be.deep.equal(subX.domain()); + }); + + it("when trying to give out of bounds data.", () => { + const {brush, scale: {x, subX}} = chart.internal; + const selection = brush.getSelection().select(".selection"); + + // when + chart.subchart([100, 200]); + + expect(selection.attr("width")).to.be.null; + expect(x.domain()).to.be.deep.equal(subX.domain()); + + // when + chart.subchart([3, 1]); + + expect(selection.attr("width")).to.be.null; + expect(x.domain()).to.be.deep.equal(subX.domain()); + + // when + chart.subchart([2, 2]); + + expect(selection.attr("width")).to.be.null; + expect(x.domain()).to.be.deep.equal(subX.domain()); + }); + + it("set options: axis.x.type='timeseries'", () => { + args = { + data: { + x: "x", + columns: [ + ["x", '2023-08-01', '2023-08-02', '2023-08-03', '2023-08-04', '2023-08-05'], + ["data1", 30, 200, 100, 170, 150], + ], + type: "line" + }, + axis: { + x: { + type: "timeseries" + } + }, + subchart: { + show: true, + showHandle: true + } + }; + }); + + it("when x is timeseries", () => { + const {brush, scale: {x, subX}} = chart.internal; + const range = ["2023-08-03", "2023-08-05"]; + const rangeParsed = range.map(v => new Date(`${v} 00:00`)); + + // when + chart.subchart(range); + + const selection = brush.getSelection().select(".selection"); + const posX = +selection.attr("x"); + const width = +selection.attr("width"); + + expect(posX).to.be.equal(subX(rangeParsed[0])); + expect(posX + width).to.be.closeTo(subX(rangeParsed[1]), 1); + + // when + chart.subchart.reset(); + + expect(selection.attr("width")).to.be.null; + expect(x.domain()).to.be.deep.equal(subX.domain()); + }); + }); }); diff --git a/test/internals/axis-x-spec.ts b/test/internals/axis-x-spec.ts index c47230b5a..8adf0b44f 100644 --- a/test/internals/axis-x-spec.ts +++ b/test/internals/axis-x-spec.ts @@ -207,7 +207,7 @@ describe("X AXIS", function() { } }); - it("shoud zoom & unzoomed.", () => { + it("should zoom & unzoomed.", () => { const {line: {lines}} = chart.$; const initialPath = lines.attr("d"); diff --git a/types/chart.d.ts b/types/chart.d.ts index fc89bbc2f..e70484006 100644 --- a/types/chart.d.ts +++ b/types/chart.d.ts @@ -251,6 +251,12 @@ export interface Chart { }; subchart: { + /** + * Select subchart by giving x domain range. + * @param domain If domain range is given, the subchart will be seleted to the given domain. If no argument is given, the current subchart selection domain will be returned. + */ + (domain?: Array): Array; + /** * Hide generated subchart * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. @@ -268,6 +274,11 @@ export interface Chart { * - **NOTE:** for ESM imports, needs to import 'subchart' exports and instantiate it by calling `subchart()`. */ toggle(): void; + + /** + * Reset subchart selection + */ + reset(): void; }; tooltip: {