diff --git a/config/rollup/esm.js b/config/rollup/esm.js index 8b25d0790..e972f8165 100644 --- a/config/rollup/esm.js +++ b/config/rollup/esm.js @@ -34,9 +34,10 @@ const plugins = [ preventAssignment: true }) ]; + const external = id => /^d3-/.test(id); -const bbPlugins = readdirSync(resolvePath("../../src/Plugin/"), { +const bbPlugins = readdirSync(resolvePath("../src/Plugin/"), { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) diff --git a/config/webpack/plugin.cjs b/config/webpack/plugin.cjs index 8457ac1b0..a902d8c78 100644 --- a/config/webpack/plugin.cjs +++ b/config/webpack/plugin.cjs @@ -71,7 +71,7 @@ module.exports = (common, env) => { return mergeWithCustomize({ customizeObject: customizeObject({ entry: "replace", - output: "replace" + output: "append" }) })(common, config); }; diff --git a/demo/chart.js b/demo/chart.js index 6b8023122..a57ddd5bd 100644 --- a/demo/chart.js +++ b/demo/chart.js @@ -395,14 +395,24 @@ code.data; this.$chartArea.appendChild($el); - if (/^(legend|tooltip)Template/.test(key)) { + if (/^(legend|tooltip)Template/.test(key) || /(sparkline)/.test(key)) { + const name = RegExp.$1; + let attrName = "id"; + template = document.createElement("div"); - template.id = this.getLowerFirstCase(RegExp.$1); + + if (key === "sparkline") { + attrName = "className"; + } + + template[attrName] = this.getLowerFirstCase(RegExp.$1); template.style.textAlign = "center"; this.$chartArea.appendChild(template); - template = "<div id=\""+ template.id +"\"></div>"; - } else if (typeKey[0] === "Plugins") { + template = "<div "+ attrName.replace(/name/i, "") +"=\""+ template[attrName] +"\"></div>"; + } + + if (typeKey[0] === "Plugins") { type.options._plugins.forEach(function(v) { plugins = Object.keys(v).map(function(p) { return new bb.plugin[p](v[p]); diff --git a/demo/demo.js b/demo/demo.js index 15229ae75..e7abfd882 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -3078,6 +3078,76 @@ d3.select(".chart_area") } }] } + }, + Sparkline: { + description: "Generates multiple tiny charts from single instance.
Must load or import plugin before the use.", + options: { + size: { + width: 150, + height: 50 + }, + data: { + columns: [ + ["data1", 130, 200, 150, 140, 160, 150], + ["data2", 200, 130, 90, 240, 130, 220], + ["data3", 300, 200, 160, 400, 250, 250], + ["data4", 300, 200, 160, 400, 250, 250], + ["data5", 30, 120, 250, 340, 160, 50], + ["data6", 290, 270, 220, 250, 260, 120], + ["data7", 283, 170, 275, 143, 220, 255], + ["data8", 130, 220, 350, 340, 260, 150], + ["data9", 330, 220, 150, 240, 360, 250], + ["data10", 300, 200, 160, 400, 250, 250], + ["data11", 230, 320, 510, 140, 160, 150], + ["data12", 200, 130, 90, 240, 130, 220], + ["data13", 300, 200, 160, 400, 250, 250], + ["data14", 230, 230, 390, 200, 290, 220], + ["data15", 130, 120, 150, 140, 160, 150], + ["data16", 290, 270, 220, 250, 160, 120], + ["data17", 283, 170, 275, 143, 220, 255], + ["data18", 130, 220, 150, 240, 360, 250], + ["data19", 350, 230, 390, 222, 321, 123], + ["data20", 300, 200, 160, 400, 250, 250], + ["data21", 330, 320, 350, 240, 260, 250], + ["data22", 200, 130, 90, 240, 130, 220], + ["data23", 300, 200, 160, 400, 250, 250], + ["data24", 321, 231, 129, 297, 333, 220], + ["data25", 230, 320, 250, 340, 260, 350], + ["data26", 290, 170, 210, 150, 160, 220], + ["data27", 283, 170, 275, 143, 220, 255], + ["data28", 330, 320, 350, 340, 360, 350], + ["data29", 50, 330, 90, 340, 130, 320], + ["data30", 300, 200, 160, 400, 250, 250], + ], + type: "area", + types: { + data1: "area", + data2: "area", + data4: "area-spline", + data5: "step", + data8: "area-step", + data9: "spline", + data10: "bar", + data12: "spline", + data15: "bar", + data20: "area-spline", + data21: "area", + data22: "area", + data23: "bar", + data24: "area-spline", + data25: "step", + data27: "area-step", + data28: "area-step", + data29: "spline", + data30: "bar", + } + }, + _plugins: [{ + sparkline: { + selector: ".sparkline" + } + }] + } } }, Point: { diff --git a/demo/index.html b/demo/index.html index 95cac54e9..c9c8ca3a0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -184,6 +184,9 @@

Sample code

}), plugins_tableview: path.map(function(p) { return p + "plugin/billboardjs-plugin-tableview.js" + }), + plugins_sparkline: path.map(function(p) { + return p + "plugin/billboardjs-plugin-sparkline.js" }) }); diff --git a/demo/tomorrow.css b/demo/tomorrow.css index 09722e546..bf1fb1088 100644 --- a/demo/tomorrow.css +++ b/demo/tomorrow.css @@ -56,4 +56,18 @@ pre code { #description { font-size: 13px; color: gray; +} + +.sparkline { + border: solid 1px #eee; + width: 150px; + height: 50px; + float: left; +} + +.chart_area::after { + display: block; + float: none; + clear: left; + content: ""; } \ No newline at end of file diff --git a/package.json b/package.json index 31624125d..400c0c373 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,7 @@ "style-loader": "^3.2.1", "terser-webpack-plugin": "^5.1.4", "tslib": "^2.3.1", - "typescript": "^4.4.2", + "typescript": "^4.3.5", "webpack": "^5.51.1", "webpack-bundle-analyzer": "^4.4.2", "webpack-clean": "^1.2.5", diff --git a/src/ChartInternal/shape/bar.ts b/src/ChartInternal/shape/bar.ts index a5cf5b7f9..1f7e46263 100644 --- a/src/ChartInternal/shape/bar.ts +++ b/src/ChartInternal/shape/bar.ts @@ -45,7 +45,7 @@ export default { // Bars for each data mainBarEnter.append("g") .attr("class", classBars) - .style("cursor", d => (isSelectable?.bind($$.api)(d) ? "pointer" : null)); + .style("cursor", d => (isSelectable?.bind?.($$.api)(d) ? "pointer" : null)); }, /** diff --git a/src/ChartInternal/shape/point.ts b/src/ChartInternal/shape/point.ts index 54e952eea..b77ad4a64 100644 --- a/src/ChartInternal/shape/point.ts +++ b/src/ChartInternal/shape/point.ts @@ -250,13 +250,17 @@ export default { }; }, - getCircles(i: number, id: string) { - const $$ = this; - const suffix = (isValue(i) ? `-${i}` : ``); + // getCircles(i: number, id: string) { + // const $$ = this; + // const suffix = (isValue(i) ? `-${i}` : ``); - return (id ? $$.$el.main.selectAll(`.${CLASS.circles}${$$.getTargetSelectorSuffix(id)}`) : $$.$el.main) - .selectAll(`.${CLASS.circle}${suffix}`); - }, + // if (!$$.$el.circle.empty()) { + // return $$.$el.circle.filter(d => d.id === (id || d.id) && (isValue(i) ? d.index === i : true)); + // } + + // return (id ? $$.$el.main.selectAll(`.${CLASS.circles}${$$.getTargetSelectorSuffix(id)}`) : $$.$el.main) + // .selectAll(`.${CLASS.circle}${suffix}`); + // }, expandCircles(i: number, id: string, reset?: boolean): void { const $$ = this; @@ -264,7 +268,7 @@ export default { reset && $$.unexpandCircles(); - const circles = $$.getCircles(i, id).classed(CLASS.EXPANDED, true); + const circles = $$.getShapeByIndex("circle", i, id).classed(CLASS.EXPANDED, true); const scale = r(circles) / $$.config.point_r; const ratio = 1 - scale; @@ -292,7 +296,7 @@ export default { const $$ = this; const r = $$.pointR.bind($$); - const circles = $$.getCircles(i) + const circles = $$.getShapeByIndex("circle", i) .filter(function() { return d3Select(this).classed(CLASS.EXPANDED); }) diff --git a/src/ChartInternal/shape/shape.ts b/src/ChartInternal/shape/shape.ts index ccc9a82a1..5c7c2c90b 100644 --- a/src/ChartInternal/shape/shape.ts +++ b/src/ChartInternal/shape/shape.ts @@ -23,6 +23,7 @@ import { curveStep as d3CurveStep } from "d3-shape"; import {select as d3Select} from "d3-selection"; +import {d3Selection} from "types/types"; import CLASS from "../../config/classes"; import {capitalize, getUnique, isObjectType, isNumber, isValue, isUndefined, notEmpty} from "../../module/util"; @@ -156,7 +157,7 @@ export default { ); return d => { - const ind = $$.getIndices(indices, d.id); + const ind = $$.getIndices(indices, d.id, "getShapeX"); const index = d.id in ind ? ind[d.id] : 0; const targetsNum = (ind.__max__ || 0) + 1; let x = 0; @@ -348,14 +349,24 @@ export default { * @returns {d3Selection} * @private */ - getShapeByIndex(shapeName: string, i: number, id?: string) { + getShapeByIndex(shapeName: string, i: number, id?: string): d3Selection { const $$ = this; - const {main} = $$.$el; + const {$el} = $$; const suffix = (isValue(i) ? `-${i}` : ``); + let shape = $el[shapeName]; + + // filter from shape reference if has + if (shape && !shape.empty()) { + shape = shape + .filter(d => (id ? d.id === id : true)) + .filter(d => (isValue(i) ? d.index === i : true)); + } else { + shape = (id ? $el.main + .selectAll(`.${CLASS[`${shapeName}s`]}${$$.getTargetSelectorSuffix(id)}`) : $el.main) + .selectAll(`.${CLASS[shapeName]}${suffix}`); + } - return (id ? main - .selectAll(`.${CLASS[`${shapeName}s`]}${$$.getTargetSelectorSuffix(id)}`) : main) - .selectAll(`.${CLASS[shapeName]}${suffix}`); + return shape; }, isWithinShape(that, d): boolean { diff --git a/src/Plugin/bubblecompare/index.ts b/src/Plugin/bubblecompare/index.ts index 2c1d04175..0e91a7504 100644 --- a/src/Plugin/bubblecompare/index.ts +++ b/src/Plugin/bubblecompare/index.ts @@ -34,7 +34,7 @@ import Plugin from "../Plugin"; * }); * @example * import {bb} from "billboard.js"; - * import BubbleCompare from "billboard.js/dist/billboardjs-plugin-bubblecompare.esm"; + * import BubbleCompare from "billboard.js/dist/billboardjs-plugin-bubblecompare"; * * bb.generate({ * plugins: [ diff --git a/src/Plugin/sparkline/Options.ts b/src/Plugin/sparkline/Options.ts new file mode 100644 index 000000000..305230997 --- /dev/null +++ b/src/Plugin/sparkline/Options.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2021 ~ present NAVER Corp. + * billboard.js project is licensed under the MIT license + */ +/** + * Sparkline plugin option class + * @class SparklineOptions + * @param {Options} options Sparkline plugin options + * @augments Plugin + * @returns {TableviewOptions} + * @private + */ +export default class Options { + constructor() { + return { + /** + * Specify sparkline charts holder selector. + * - **NOTE:** The amount of holder should match with the amount of data. If has less, will append necessaray amount nodes as sibling of main chart. + * @name selector + * @memberof plugin-sparkline + * @type {string} + * @default undefined + * @example + * selector: ".sparkline" + */ + selector: undefined + }; + } +} diff --git a/src/Plugin/sparkline/index.ts b/src/Plugin/sparkline/index.ts new file mode 100644 index 000000000..f165c16ae --- /dev/null +++ b/src/Plugin/sparkline/index.ts @@ -0,0 +1,277 @@ +/** + * Copyright (c) 2021 ~ present NAVER Corp. + * billboard.js project is licensed under the MIT license + */ +import CLASS from "../../config/classes"; +import Plugin from "../Plugin"; +import Options from "./Options"; +import {IData} from "../../ChartInternal/data/IData"; +import {loadConfig} from "../../config/config"; + +/** + * Sparkline plugin.
+ * Generates sparkline charts + * - **NOTE:** + * - Plugins aren't built-in. Need to be loaded or imported to be used. + * - Non required modules from billboard.js core, need to be installed separately. + * + * - **Bear in mind:** + * - Use this plugin to visualize multiple tiny chart only and chart APIs won't work properly. + * - Sparkline chart size will be based on the main chart element size. To control spakrline charts, is highly recommended to set `size` option. + * - Bubble, scatter and Arc(pie, donut, ratdar) types aren't supported. + * - Some options will be stricted to be: + * - `resize.auto = false` + * - `axis.x.show = false` + * - `axis.y.show = false` + * - `axis.y.padding = 10` + * - `legend.show = false` + * + * @class plugin-sparkline + * @param {object} options sparkline plugin options + * @augments Plugin + * @returns {Sparkline} + * @example + * // Plugin must be loaded before the use. + * + * + * var chart = bb.generate({ + * ... + * plugins: [ + * new bb.plugin.sparkline({ + * selector: ".sparkline" + * }), + * ] + * }); + * @example + * import {bb} from "billboard.js"; + * import Sparkline from "billboard.js/dist/billboardjs-plugin-sparkline"; + * + * bb.generate({ + * ... + * plugins: [ + * new Sparkline({ ... }) + * ] + * }) + */ +export default class Sparkline extends Plugin { + static version = `0.0.1`; + private config; + private element; + + constructor(options) { + super(options); + this.config = new Options(); + + return this; + } + + $beforeInit(): void { + loadConfig.call(this, this.options); + + this.validate(); + this.element = [].slice.call(document.querySelectorAll(this.config.selector)); + + // override internal methods + this.overrideInternals(); + + // override options + this.overrideOptions(); + + // bind event handlers's context + this.overHandler = this.overHandler.bind(this); + this.moveHandler = this.moveHandler.bind(this); + this.outHandler = this.outHandler.bind(this); + } + + validate(): void { + const {$$, config} = this; + let msg = ""; + + if (!config.selector || !document.querySelector(config.selector)) { + msg = "No holder elements found from given selector option."; + } + + if ($$.hasType("bubble") || $$.hasType("scatter") || $$.hasArcType($$.data.targets)) { + msg = "Contains non supported chart types."; + } + + if (msg) { + throw new Error(`[Sparkline plugin] ${msg}`); + } + } + + overrideInternals(): void { + const {$$} = this; + const {getBarW, getIndices} = $$; + + // override internal methods to positioning bars + $$.getIndices = function(indices, id, caller) { + return caller === "getShapeX" ? {} : getIndices.call(this, indices, id); + }; + + $$.getBarW = function(type, axis) { + return getBarW.call(this, type, axis, 1); + }; + } + + overrideOptions(): void { + const {config} = this.$$; + + config.legend_show = false; + config.resize_auto = false; + + config.axis_x_show = false; + config.axis_x_padding = { + left: 15, + right: 15, + unit: "px" + }; + + config.axis_y_show = false; + config.axis_y_padding = 5; + + if (!config.tooltip_position) { + config.tooltip_position = function(data, width, height) { + const {internal: {state: {event}}} = this; + let top = event.pageY - (height * 1.35); + let left = event.pageX - (width / 2); + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + return {top, left}; + }; + } + } + + $init(): void { + const {$$} = this; + const {$el} = $$; + + // make disable-ish main chart element + $el.chart + .style("width", "0") + .style("height", "0") + .style("pointer-events", "none"); + + document.body.appendChild($el.tooltip.node()); + } + + $afterInit(): void { + const {$$} = this; + + $$.$el.svg.attr("style", null) + .style("width", "0") + .style("height", "0"); + + this.bindEvents(true); + } + + /** + * Bind tooltip event handlers for each sparkline elements. + * @param {boolean} bind or unbind + * @private + */ + bindEvents(bind = true): void { + if (this.$$.config.interaction_enabled) { + const method = `${bind ? "add" : "remove"}EventListener`; + + this.element + .forEach(el => { + const svg = el.querySelector("svg"); + + svg[method]("mouseover", this.overHandler); + svg[method]("mousemove", this.moveHandler); + svg[method]("mouseout", this.outHandler); + }); + } + } + + overHandler(e): void { + const {$$} = this; + const {state: {eventReceiver}} = $$; + + eventReceiver.rect = e.target.getBoundingClientRect(); + } + + moveHandler(e): void { + const {$$} = this; + const index = $$.getDataIndexFromEvent(e); + const data = $$.api.data(e.target.__id)?.[0] as IData; + const d = data?.values?.[index]; + + if (d && !d.name) { + d.name = d.id; + } + + $$.state.event = e; + $$.setExpand(index, data.id, true); + $$.showTooltip([d], e.target); + } + + outHandler(e): void { + const {$$} = this; + + $$.state.event = e; + $$.unexpandCircles(); + $$.hideTooltip(); + } + + $redraw(): void { + const {$$} = this; + const {$el} = $$; + + let el = this.element; + const data = $$.api.data(); + const svgWrapper = $el.chart.html().match(/]*>/)?.[0]; + + // append sparkline holder if is less than the data length + if (el.length < data.length) { + const chart = $el.chart.node(); + + for (let i = data.length - el.length; i > 0; i--) { + chart.parentNode.insertBefore(el[0].cloneNode(), chart.nextSibling); + } + + this.element = document.querySelectorAll(this.config.selector); + el = this.element; + } + + data.map(v => v.id) + .forEach((id, i) => { + const selector = `.${CLASS.target}-${id}`; + const shape = $el.main.selectAll(selector); + let svg = el[i].querySelector("svg"); + + if (!svg) { + el[i].innerHTML = `${svgWrapper}`; + svg = el[i].querySelector("svg"); + svg.__id = id; + } + + if (!svg.querySelector(selector)) { + shape.style("opacity", null); + } + + shape + .style("fill", "none") + .style("opacity", null); + + svg.innerHTML = ""; + svg.appendChild(shape.node()); + }); + } + + $willDestroy(): void { + this.bindEvents(false); + this.element + .forEach(el => { + el.innerHTML = ""; + }); + } +} diff --git a/src/Plugin/stanford/index.ts b/src/Plugin/stanford/index.ts index c5e9b9f12..f4ce0627f 100644 --- a/src/Plugin/stanford/index.ts +++ b/src/Plugin/stanford/index.ts @@ -91,7 +91,7 @@ import {compareEpochs, isEmpty, isFunction, isString, parseDate, pointInRegion} * }); * @example * import {bb} from "billboard.js"; - * import Stanford from "billboard.js/dist/billboardjs-plugin-stanford.esm"; + * import Stanford from "billboard.js/dist/billboardjs-plugin-stanford"; * * bb.generate({ * plugins: [ diff --git a/src/Plugin/tableview/index.ts b/src/Plugin/tableview/index.ts index 871413e2f..d272ec56c 100644 --- a/src/Plugin/tableview/index.ts +++ b/src/Plugin/tableview/index.ts @@ -42,7 +42,7 @@ import {isNumber, tplProcess} from "../../module/util"; * }); * @example * import {bb} from "billboard.js"; - * import TableView from "billboard.js/dist/billboardjs-plugin-tableview.esm"; + * import TableView from "billboard.js/dist/billboardjs-plugin-tableview"; * * bb.generate({ * ... diff --git a/src/Plugin/textoverlap/index.ts b/src/Plugin/textoverlap/index.ts index c90bf3daf..4b6e9aeac 100644 --- a/src/Plugin/textoverlap/index.ts +++ b/src/Plugin/textoverlap/index.ts @@ -50,7 +50,7 @@ import Options from "./Options"; * }); * @example * import {bb} from "billboard.js"; - * import TextOverlap from "billboard.js/dist/billboardjs-plugin-textoverlap.esm"; + * import TextOverlap from "billboard.js/dist/billboardjs-plugin-textoverlap"; * * bb.generate({ * plugins: [ diff --git a/test/plugin/sparkline/sparkline-spec.ts b/test/plugin/sparkline/sparkline-spec.ts new file mode 100644 index 000000000..7f9a378b8 --- /dev/null +++ b/test/plugin/sparkline/sparkline-spec.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2021 ~ present NAVER Corp. + * billboard.js project is licensed under the MIT license + */ +/* eslint-disable */ +import {expect} from "chai"; +import CLASS from "../../../src/config/classes"; +import Sparkline from "../../../src/Plugin/sparkline"; +import util from "../../assets/util"; + +describe("PLUGIN: SPARKLINE", () => { + let chart; + const selector = ".sparkline"; + const args = { + size: { + width: 150, + height: 50 + }, + data: { + columns: [ + ["data1", 30, 20, 50], + ["data2", 200, 130, 90], + ["data3", 300, 200, 160] + ], + }, + plugins: [ + new Sparkline({ + selector + }) + ] + }; + let body = document.body.innerHTML; + + before(() => { + const div = document.createElement("div"); + + div.className = selector.replace(".", ""); + document.body.append(div); + }); + + after(() => { + chart.destroy(); + document.body.innerHTML = body; + }); + + beforeEach(() => { + chart = util.generate(args); + }); + + it("Sparkline generated correctly?", () => { + expect(document.body.querySelectorAll(selector).length).to.be.equal(chart.data().length); + }); + + it("check for tooltip interaction", () => { + const el = chart.plugins[0].element[0]; + const {tooltip} = chart.$; + const svg = el.querySelector("svg"); + + // hover 1st chart element + util.fireEvent(svg, "mousemove", { + clientX: 10, + clientY: 10 + }, chart); + + expect(tooltip.style("display")).to.be.equal("block"); + + expect(tooltip.select("th").text()).to.be.equal("0"); + expect(tooltip.select(".name").text()).to.be.equal("data1"); + expect(tooltip.select(".value").text()).to.be.equal("30"); + + const circle = svg.querySelector(`.${CLASS.EXPANDED}`); + + expect(circle).to.be.ok; + expect(circle.classList.contains(`${CLASS.circle}-0`)).to.be.true; + + // when + util.fireEvent(svg, "mouseout", { + clientX: 10, + clientY: 10 + }, chart); + + expect(tooltip.style("display")).to.be.equal("none"); + + console.log(1); + }) +}); diff --git a/types/plugin/sparkline/index.d.ts b/types/plugin/sparkline/index.d.ts new file mode 100644 index 000000000..f2f7f8dfe --- /dev/null +++ b/types/plugin/sparkline/index.d.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2017 ~ present NAVER Corp. + * billboard.js project is licensed under the MIT license + */ +import {Plugin} from "../plugin"; +import {SparklineOptions} from "./options"; + +export default class Sparkline extends Plugin { + /** + * Sparkline plugin.
+ * Generates sparkline charts + * - **NOTE:** + * - Plugins aren't built-in. Need to be loaded or imported to be used. + * - Non required modules from billboard.js core, need to be installed separately. + * + * - **Bear in mind:** + * - Use this plugin to visualize multiple tiny chart only and chart APIs won't work properly. + * - Sparkline chart size will be based on the main chart element size. To control spakrline charts, is highly recommended to set `size` option. + * - Bubble, scatter and Arc(pie, donut, ratdar) types aren't supported. + * - Some options will be stricted to be: + * - `resize.auto = false` + * - `axis.x.show = false` + * - `axis.y.show = false` + * - `axis.y.padding = 10` + * - `legend.show = false` + */ + constructor(options: SparklineOptions); +} diff --git a/types/plugin/sparkline/options.d.ts b/types/plugin/sparkline/options.d.ts new file mode 100644 index 000000000..849e20548 --- /dev/null +++ b/types/plugin/sparkline/options.d.ts @@ -0,0 +1,7 @@ +export interface SparklineOptions { + /** + * Specify sparkline charts holder selector. + * - **NOTE:** The amount of holder should match with the amount of data. If has less, will append necessaray amount nodes as sibling of main chart. + */ + selector?: string; +}