diff --git a/demo/demo.js b/demo/demo.js index 38543d805..9e0a6aabc 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -1606,79 +1606,59 @@ var demos = { ] }, point: { - type: { - create: function(element, cssClassFn, sizeFn, fillStyleFn) { - return element.enter().append("polygon") - .attr("class", cssClassFn) - .style("fill", fillStyleFn); - }, - update: function(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, - withTransition, flow, selectedCircles) { - var size = this.pointR(element) * 3.0; - var halfSize = size * 0.5; - - function getPoints(d) { - var x1 = xPosFn(d); - var y1 = yPosFn(d) - halfSize; - var x2 = x1 - halfSize; - var y2 = y1 + size; - var x3 = x1 + halfSize; - var y3 = y2; - - return [x1, y1, x2, y2, x3, y3].join(" "); - } - - return element - .attr("points", getPoints) - .style("opacity", opacityStyleFn) - .style("fill", fillStyleFn); - } - } + pattern: [ + "" + ] } } }, - CustomPointsDiamond: { + CustomPointsDiamonds: { options: { data: { columns: [ - ['data1', 100, 200, 1000, 900, 500], + ['data1', 100, 400, 1000, 900, 500], ['data2', 20, 40, 500, 300, 200] ] }, point: { - type: { - create: function(element, cssClassFn, sizeFn, fillStyle) { - // create custom an element node - return element.enter().append("polygon") - .attr("class", cssClassFn) - .style("fill", fillStyle); - }, - - update: function(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, - withTransition, flow, selectedCircles) { - var size = this.pointR(element) * 3.0; - var halfSize = size * 0.5; - - function getPoints(d) { - var x1 = xPosFn(d); - var y1 = yPosFn(d) - halfSize; - var x2 = x1 - halfSize; - var y2 = y1 + halfSize; - var x3 = x1; - var y3 = y2 + halfSize; - var x4 = x1 + halfSize; - var y4 = y2; - - return [x1, y1, x2, y2, x3, y3, x4, y4].join(" "); - } - - // style the custom element added - return element - .attr("points", getPoints) - .style("opacity", opacityStyleFn) - .style("fill", fillStyleFn); - } - } + pattern: [ + "" + ] + } + } + }, + CustomPointsHearts: { + options: { + data: { + columns: [ + ['data1', 100, 400, 1000, 900, 500], + ['data2', 20, 40, 500, 300, 200] + ] + }, + point: { + pattern: [ + "" + ] + } + } + }, + CombinationPoints: { + options: { + data: { + columns: [ + ['data1', 100, 400, 1000, 900, 500], + ['data2', 20, 40, 500, 300, 200], + ['data3', 80, 350, 800, 450, 500], + ['data4', 150, 240, 300, 700, 300] + ] + }, + point: { + pattern: [ + "circle", + "rectangle", + "", + "" + ] } } } diff --git a/spec/interaction-spec.js b/spec/interaction-spec.js index db8b67037..e76fed13e 100644 --- a/spec/interaction-spec.js +++ b/spec/interaction-spec.js @@ -163,7 +163,7 @@ describe("INTERACTION", () => { }); it("set option point.type='rectangle'", () => { - args.point.type = "rectangle"; + args.point.pattern = ["rectangle"]; clicked = false; data = null; }); @@ -183,34 +183,9 @@ describe("INTERACTION", () => { }); it("set option point.type=polygon(custom triangle)", () => { - args.point.type = { - create: function(element, cssClassFn, sizeFn, fillStyleFn) { - return element.enter().append("polygon") - .attr("class", cssClassFn) - .style("fill", fillStyleFn); - }, - update: function(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, - withTransition, flow, selectedCircles) { - var size = this.pointR(element) * 3.0; - var halfSize = size * 0.5; - - function getPoints(d) { - var x1 = xPosFn(d); - var y1 = yPosFn(d) - halfSize; - var x2 = x1 - halfSize; - var y2 = y1 + size; - var x3 = x1 + halfSize; - var y3 = y2; - - return [x1, y1, x2, y2, x3, y3].join(" "); - } - - return element - .attr("points", getPoints) - .style("opacity", opacityStyleFn) - .style("fill", fillStyleFn); - } - }; + args.point.pattern = [ + "" + ]; clicked = false; data = null; @@ -219,20 +194,20 @@ describe("INTERACTION", () => { it("check for data click for polygon data point", () => { const main = chart.internal.main; const rect = main.select(`.${CLASS.eventRect}.${CLASS.eventRect}`).node(); - const circle = main.select(`.${CLASS.circles}-data2 polygon`).node().getBBox(); + const circle = main.select(`.${CLASS.circles}-data2 use`).node().getBBox(); util.fireEvent(rect, "click", { clientX: circle.x, clientY: circle.y }, chart); - expect(clicked).to.be.true; - expect(data.value).to.be.equal(20); + expect(clicked).to.be.false; + expect(data).to.be.equal(null); }); it("set option data.type='area'", () => { args.data.type = "area"; - args.point.type = "circle"; + args.point.pattern = ["circle"]; clicked = false; data = null; diff --git a/spec/shape.point-spec.js b/spec/shape.point-spec.js index 920d86ed3..49f2ee567 100644 --- a/spec/shape.point-spec.js +++ b/spec/shape.point-spec.js @@ -51,7 +51,7 @@ describe("SHAPE POINT", () => { ] }, point: { - type: "rectangle" + pattern: ["rectangle"] } }; }); @@ -76,46 +76,17 @@ describe("SHAPE POINT", () => { ] }, point: { - type: { - create(element, cssClassFn, sizeFn, fillStyle) { - return element.enter().append("polygon") - .attr("class", cssClassFn) - .style("fill", fillStyle); - }, - - update(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, - withTransition, flow, selectedCircles) { - let mainCircles; - const triangleSize = 10; - - function getPoints(d) { - const x1 = xPosFn(d); - const y1 = yPosFn(d) - (triangleSize * 0.5); - const x2 = x1 - (triangleSize * 0.5); - const y2 = y1 + (triangleSize * 0.5); - const x3 = x1; - const y3 = y2 + (triangleSize * 0.5); - const x4 = x1 + (triangleSize * 0.5); - const y4 = y2; - return `${x1} ${y1} ${x2} ${y2} ${x3} ${y3} ${x4} ${y4}`; - } - - mainCircles = element - .attr("points", getPoints) - .style("opacity", opacityStyleFn) - .style("fill", fillStyleFn); - - return mainCircles; - } - } + pattern: [ + "" + ] } }; }); - it("Should render svg polygon elements", () => { + it("Should render svg \"use\" elements", () => { const target = chart.internal.svg.select(".bb-chart-line.bb-target-data1"); const circlesEl = target.select(".bb-circles-data1").node(); - const polygons = circlesEl.getElementsByTagName("polygon"); + const polygons = circlesEl.getElementsByTagName("use"); expect(polygons.length).to.be.equal(6); }); diff --git a/src/api/api.flow.js b/src/api/api.flow.js index 595f91416..954384472 100644 --- a/src/api/api.flow.js +++ b/src/api/api.flow.js @@ -406,7 +406,9 @@ extend(ChartInternal.prototype, { mainCircle .attr("x", xFunc) - .attr("y", yFunc); + .attr("y", yFunc) + .attr("cx", cx) // when pattern is used, it possibly contain 'circle' also. + .attr("cy", cy); } mainText diff --git a/src/config/Options.js b/src/config/Options.js index 059171697..31d282661 100644 --- a/src/config/Options.js +++ b/src/config/Options.js @@ -2087,14 +2087,23 @@ export default class Options { * @property {Boolean} [point.focus.expand.r=point.r*1.75] The radius size of each point on focus.
* - **Note:** For 'bubble' type, the default is `bubbleSize*1.15` * @property {Number} [point.select.r=point.r*4] The radius size of each point on selected. - * @property {String|Object} [point.type="circle"] The type of point to be drawn
- * - **Note:** If chart has 'bubble' type, only circle can be used.
+ * @property {String} [point.type="circle"] The type of point to be drawn
+ * - **Note:** + * - If chart has 'bubble' type, only circle can be used. + * - For IE, non circle point expansions are not supported due to lack of transform support. * - **Available Values:** * - circle * - rectangle - * - * @property {Function} [point.type.create] If specified will be invoked to create data points, this function must return a d3 selection. - * @property {Function} [point.type.update] If specified will be invoked to update data points, this function must return a d3 selection. + * @property {Array} [point.pattern=[]] The type of point or svg shape as string, to be drawn for each line
+ * - **Note:** + * - This is an `experimental` feature and can have some unexpected behaviors. + * - If chart has 'bubble' type, only circle can be used. + * - For IE, non circle point expansions are not supported due to lack of transform support. + * - **Available Values:** + * - circle + * - rectangle + * - svg shape tag interpreted as string
+ * (ex. ``) * @example * point: { * show: false, @@ -2116,22 +2125,15 @@ export default class Options { * r: 3 * }, * + * // valid values are "circle" or "rectangle" * type: "rectangle", * - * // or for custom shapes you can use an object with a "create" and "update" functions - * type: { - * // to create a custom type, set create & update functions as well - * create: function(element, cssClassFn, sizeFn, fillStyleFn) { - * // should create node element to be used as data point and must return a d3.selection - * ... - * return element; - * }, - * update: function(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, withTransition, flow, selectedCircles) { - * // adjust the position & styles to the given element and must return a d3.selection - * ... - * return element; - * } - * } + * // or indicate as pattern + * pattern: [ + * "circle", + * "rectangle", + * "" + * ], * } */ point_show: true, @@ -2139,6 +2141,7 @@ export default class Options { point_sensitivity: 10, point_focus_expand_enabled: true, point_focus_expand_r: undefined, + point_pattern: [], point_select_r: undefined, point_type: "circle", diff --git a/src/internals/ChartInternal.js b/src/internals/ChartInternal.js index 69682b42d..65ee8c84f 100644 --- a/src/internals/ChartInternal.js +++ b/src/internals/ChartInternal.js @@ -82,7 +82,8 @@ export default class ChartInternal { const config = $$.config; // MEMO: clipId needs to be unique because it conflicts when multiple charts exist - $$.clipId = `bb-${+new Date()}-clip`; + $$.datetimeId = `bb-${+new Date()}`; + $$.clipId = `${$$.datetimeId}-clip`; $$.clipIdForXAxis = `${$$.clipId}-xaxis`; $$.clipIdForYAxis = `${$$.clipId}-yaxis`; $$.clipIdForGrid = `${$$.clipId}-grid`; @@ -101,6 +102,7 @@ export default class ChartInternal { $$.color = $$.generateColor(); $$.levelColor = $$.generateLevelColor(); + $$.point = $$.generatePoint(); $$.extraLineClasses = $$.generateExtraLineClass(); @@ -257,17 +259,17 @@ export default class ChartInternal { $$.svg.attr("class", config.svg_classname); // Define defs - const defs = $$.svg.append("defs"); + $$.defs = $$.svg.append("defs"); - $$.clipChart = $$.appendClip(defs, $$.clipId); - $$.clipXAxis = $$.appendClip(defs, $$.clipIdForXAxis); - $$.clipYAxis = $$.appendClip(defs, $$.clipIdForYAxis); - $$.clipGrid = $$.appendClip(defs, $$.clipIdForGrid); - $$.clipSubchart = $$.appendClip(defs, $$.clipIdForSubchart); + $$.clipChart = $$.appendClip($$.defs, $$.clipId); + $$.clipXAxis = $$.appendClip($$.defs, $$.clipIdForXAxis); + $$.clipYAxis = $$.appendClip($$.defs, $$.clipIdForYAxis); + $$.clipGrid = $$.appendClip($$.defs, $$.clipIdForGrid); + $$.clipSubchart = $$.appendClip($$.defs, $$.clipIdForSubchart); // set color patterns if (isFunction(config.color_tiles) && $$.patterns) { - $$.patterns.forEach(p => defs.append(() => p.node)); + $$.patterns.forEach(p => $$.defs.append(() => p.node)); } $$.updateSvgSize(); diff --git a/src/internals/type.js b/src/internals/type.js index 060226797..be371c69f 100644 --- a/src/internals/type.js +++ b/src/internals/type.js @@ -112,7 +112,11 @@ extend(ChartInternal.prototype, { // determine if is 'circle' data point isCirclePoint() { - return this.config.point_type === "circle"; + const config = this.config; + const pattern = config.point_pattern; + + return config.point_type === "circle" && + (!pattern || (isArray(pattern) && pattern.length === 0)); }, lineData(d) { diff --git a/src/internals/util.js b/src/internals/util.js index f063f6d4b..7063430d0 100644 --- a/src/internals/util.js +++ b/src/internals/util.js @@ -267,6 +267,7 @@ const colorizePattern = (pattern, color) => { * Copy array like object to array * @param {Object} v * @returns {Array} + * @private */ const toArray = v => [].slice.call(v); @@ -274,6 +275,7 @@ const toArray = v => [].slice.call(v); * Get css rules for specified stylesheets * @param {Array} styleSheets The stylesheets to get the rules from * @returns {Array} + * @private */ const getCssRules = styleSheets => { let rules = []; diff --git a/src/shape/line.js b/src/shape/line.js index 654a974f1..0556a20cf 100644 --- a/src/shape/line.js +++ b/src/shape/line.js @@ -435,15 +435,8 @@ extend(ChartInternal.prototype, { $$.mainCircle.exit().remove(); - let createFn = $$.circle.create; - - if ($$.hasValidPointType()) { - createFn = $$[$$.config.point_type].create; - } else if ($$.hasValidPointDrawMethods()) { - createFn = $$.config.point_type.create; - } - - $$.mainCircle = createFn($$.mainCircle, $$.classCircle.bind($$), $$.pointR.bind($$), $$.color) + $$.mainCircle = $$.mainCircle.enter() + .append($$.point("create", this, $$.classCircle.bind($$), $$.pointR.bind($$), $$.color)) .merge($$.mainCircle) .style("opacity", $$.initialOpacityForCircle.bind($$)); }, @@ -451,30 +444,20 @@ extend(ChartInternal.prototype, { redrawCircle(cx, cy, withTransition, flow) { const $$ = this; const selectedCircles = $$.main.selectAll(`.${CLASS.selectedCircle}`); - const pointType = $$.config.point_type; if (!$$.config.point_show) { return []; } - let updateFn = $$.circle.update; + const mainCircles = []; - if ($$.hasValidPointType()) { - updateFn = $$[pointType].update; - } else if ($$.hasValidPointDrawMethods()) { - updateFn = pointType.update; - } + $$.mainCircle.each(function(d) { + const fn = $$.point("update", $$, cx, cy, $$.opacityForCircle.bind($$), $$.color, withTransition, flow, selectedCircles).bind(this); + const result = fn(d); + + mainCircles.push(result); + }); - const mainCircles = updateFn.bind(this)( - $$.mainCircle, - cx, - cy, - $$.opacityForCircle.bind($$), - $$.color, - withTransition, - flow, - $$.main.selectAll(`.${CLASS.selectedCircle}`) - ); const posAttr = $$.isCirclePoint() ? "c" : ""; return [ @@ -529,17 +512,26 @@ extend(ChartInternal.prototype, { $$.unexpandCircles(); } - const circles = $$.getCircles(i, id) - .classed(CLASS.EXPANDED, true); + const circles = $$.getCircles(i, id).classed(CLASS.EXPANDED, true); + const scale = r(circles) / $$.config.point_r; if ($$.isCirclePoint()) { circles.attr("r", r); } else { - const scale = r(circles) / $$.config.point_r; - - circles - .style("transform-origin", "center") - .style("transform", `scale(${scale})`); + // transform must be applied to each node individually + circles.each(function(d) { + const point = d3Select(this); + + const box = this.getBBox(); + const x1 = box.x + (box.width * 0.5); + const y1 = box.y + (box.height * 0.5); + const x2 = (1 - scale) * x1; + const y2 = (1 - scale) * y1; + + this.tagName === "circle" ? + point.attr("r", r) : + point.style("transform", `translate(${x2}px, ${y2}px) scale(${scale})`); + }); } }, @@ -551,13 +543,12 @@ extend(ChartInternal.prototype, { .filter(function() { return d3Select(this).classed(CLASS.EXPANDED); }) .classed(CLASS.EXPANDED, false); - if ($$.isCirclePoint()) { - circles.attr("r", r); - } else { - circles - .style("transform-origin", null) - .style("transform", null); - } + const scale = r(circles) / $$.config.point_r; + + circles.attr("r", r); + + !$$.isCirclePoint() && + circles.style("transform", `scale(${scale})`); }, pointR(d) { diff --git a/src/shape/point.js b/src/shape/point.js index 5dde82929..774ba2b98 100644 --- a/src/shape/point.js +++ b/src/shape/point.js @@ -2,32 +2,145 @@ * Copyright (c) 2017 NAVER Corp. * billboard.js project is licensed under the MIT license */ +import { + namespaces as d3Namespaces, + select as d3Select +} from "d3"; import ChartInternal from "../internals/ChartInternal"; -import {isFunction, isObjectType, extend} from "../internals/util"; +import {isFunction, isObjectType, extend, notEmpty} from "../internals/util"; extend(ChartInternal.prototype, { hasValidPointType(type) { return /^(circle|rect(angle)?|polygon|ellipse)$/i.test(type || this.config.point_type); }, - hasValidPointDrawMethods() { - const pointType = this.config.point_type; + hasValidPointDrawMethods(type) { + const pointType = type || this.config.point_type; return isObjectType(pointType) && isFunction(pointType.create) && isFunction(pointType.update); }, + insertPointInfoDefs(point, id) { + const $$ = this; + const parser = new DOMParser(); + const doc = parser.parseFromString(point, "image/svg+xml"); + const node = doc.firstChild; + const clone = document.createElementNS(d3Namespaces.svg, node.nodeName.toLowerCase()); + const attribs = node.attributes; + + for (let i = 0, l = attribs.length; i < l; i++) { + const name = attribs[i].name; + + clone.setAttribute(name, node.getAttribute(name)); + } + + clone.id = id; + clone.style.fill = "inherit"; + clone.style.stroke = "none"; + + $$.defs.node().appendChild(clone); + }, + + pointFromDefs(id) { + return this.defs.select(`#${id}`); + }, + + generatePoint() { + const $$ = this; + const config = $$.config; + const ids = []; + const pattern = notEmpty(config.point_pattern) ? config.point_pattern : [config.point_type]; + + return function(method, context, ...args) { + return function(d) { + const id = d.id || (d.data && d.data.id) || d; + const element = d3Select(this); + let point; + + if (ids.indexOf(id) < 0) { + ids.push(id); + } + point = pattern[ids.indexOf(id) % pattern.length]; + + if ($$.hasValidPointType(point)) { + point = $$[point]; + } else if (!$$.hasValidPointDrawMethods(point)) { + const pointId = `${$$.datetimeId}-point-${id}`; + const pointFromDefs = $$.pointFromDefs(pointId); + + if (pointFromDefs.size() < 1) { + $$.insertPointInfoDefs(point, pointId); + } + + if (method === "create") { + return $$.custom.create.bind(context)(element, pointId, ...args); + } else if (method === "update") { + return $$.custom.update.bind(context)(element, ...args); + } + } + + return point[method].bind(context)(element, ...args); + }; + }; + }, + getTransitionName() { return Math.random().toString(); }, + custom: { + create(element, id, cssClassFn, sizeFn, fillStyleFn) { + return element.append("use") + .attr("xlink:href", `#${id}`) + .attr("class", cssClassFn) + .style("fill", fillStyleFn) + .node(); + }, + + update(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, + withTransition, flow, selectedCircles) { + const $$ = this; + const box = element.node().getBBox(); + const xPosFn2 = d => xPosFn(d) - (box.width * 0.5); + const yPosFn2 = d => yPosFn(d) - (box.height * 0.5); + let mainCircles = element; + + if (withTransition) { + const transitionName = $$.getTransitionName(); + + if (flow) { + mainCircles = element + .attr("x", xPosFn2); + } + + mainCircles = element + .transition(transitionName) + .attr("x", xPosFn2) + .attr("y", yPosFn2) + .transition(transitionName); + + selectedCircles.transition($$.getTransitionName()); + } else { + mainCircles = element + .attr("x", xPosFn2) + .attr("y", yPosFn2); + } + + return mainCircles + .style("opacity", opacityStyleFn) + .style("fill", fillStyleFn); + } + }, + // 'circle' data point circle: { create(element, cssClassFn, sizeFn, fillStyleFn) { - return element.enter().append("circle") + return element.append("circle") .attr("class", cssClassFn) .attr("r", sizeFn) - .style("fill", fillStyleFn); + .style("fill", fillStyleFn) + .node(); }, update(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn, @@ -73,11 +186,12 @@ extend(ChartInternal.prototype, { create(element, cssClassFn, sizeFn, fillStyleFn) { const rectSizeFn = d => sizeFn(d) * 2.0; - return element.enter().append("rect") + return element.append("rect") .attr("class", cssClassFn) .attr("width", rectSizeFn) .attr("height", rectSizeFn) - .style("fill", fillStyleFn); + .style("fill", fillStyleFn) + .node(); }, update(element, xPosFn, yPosFn, opacityStyleFn, fillStyleFn,