diff --git a/demo/demo.js b/demo/demo.js index dbb8b3db4..ba0ff5440 100644 --- a/demo/demo.js +++ b/demo/demo.js @@ -2090,6 +2090,58 @@ d3.select(".chart_area") } }, + Title: { + MultilinedTitle: { + options: { + title: { + text: "Temperature History by Region, 2017-2018\nSource: community weather center" + }, + data: { + x: "x", + json: { + Temperature: [ + "29.39", + "29.7", + "29.37", + "28.87", + "28.62", + "27.72", + "27.61", + "27.82", + "27.48", + "26.78" + ], + x: [ + "01-10-2019 00:00", + "01-10-2019 00:30", + "01-10-2019 01:00", + "01-10-2019 01:30", + "01-10-2019 02:00", + "01-10-2019 02:30", + "01-10-2019 03:00", + "01-10-2019 03:30", + "01-10-2019 04:00", + "01-10-2019 04:30" + ] + }, + type: "area", + xFormat: "%m-%d-%Y %H:%M" + }, + axis: { + x: { + type: "timeseries" + } + }, + point: { + show: false + } + }, + style: [ + "#MultilinedTitle .bb-title tspan:first-child { font-size: 17px; font-weight: bold; }" + ] + } + }, + Tooltip: { HideTooltip: { options: { diff --git a/demo/simple-sidebar.css b/demo/simple-sidebar.css index d279fcd3d..c2131fc66 100644 --- a/demo/simple-sidebar.css +++ b/demo/simple-sidebar.css @@ -229,4 +229,6 @@ div.row { #RadarAxis .bb-levels polygon { stroke-dasharray: 1 3; stroke-width: 1px; } /* Style For tick position */ -#XAxisTickPosition .bb-axis-x line, #XAxisTickPosition .bb-axis-x path { visibility: hidden; } \ No newline at end of file +#XAxisTickPosition .bb-axis-x line, #XAxisTickPosition .bb-axis-x path { visibility: hidden; } + +#MultilinedTitle .bb-title tspan:first-child { font-size: 17px; font-weight: bold; } \ No newline at end of file diff --git a/spec/internals/title-spec.js b/spec/internals/title-spec.js index 1efc4323f..c7b9f1573 100644 --- a/spec/internals/title-spec.js +++ b/spec/internals/title-spec.js @@ -34,14 +34,18 @@ describe("TITLE", () => { describe("when given a title config option", () => { describe("with no padding and no position", () => { it("renders the title at the default config position", () => { - const titleEl = chart.internal.svg.select(".bb-title"); - - expect(+titleEl.attr("x") + titleEl.node().getBBox().width / 2).to.be.closeTo(320, 1); - expect(+titleEl.attr("y")).to.equal(titleEl.node().getBBox().height); + const title = chart.$.svg.select(".bb-title").node(); + const [x, y] = title.parentNode + .getAttribute("transform") + .split(",") + .map(v => util.parseNum(v)); + + expect(x).to.be.equal(chart.internal.currentWidth / 2); + expect(y).to.be.equal(title.getBBox().height); }); it("renders the title text", () => { - const titleEl = chart.internal.svg.select(".bb-title"); + const titleEl = chart.$.svg.select(".bb-title"); expect(titleEl.node().textContent).to.equal("new title"); }); @@ -63,51 +67,63 @@ describe("TITLE", () => { bottom: 40, left: 50 }, - position: "top-center" + position: "center" } }; }); describe("and position center", () => { it("renders the title at the default config position", () => { - const titleEl = chart.internal.svg.select(".bb-title"); - - expect(+titleEl.attr("x") + titleEl.node().getBBox().width / 2).to.be.closeTo(320, 1); - expect(+titleEl.attr("y")).to.be.closeTo(37, 2); + const title = chart.$.svg.select(".bb-title").node(); + const [x, y] = title.parentNode + .getAttribute("transform") + .split(",") + .map(v => util.parseNum(v)); + + expect(x).to.be.equal(chart.internal.currentWidth / 2); + expect(y).to.be.equal(title.getBBox().height + args.title.padding.top); }); it("adds the correct amount of padding to fit the title", () => { + const height = chart.$.svg.select(".bb-title").node().getBBox().height; + expect(chart.internal.getCurrentPaddingTop()).to.equal( - args.title.padding.top + - chart.internal.svg.select(".bb-title").node().getBBox().height + - args.title.padding.bottom + args.title.padding.top + height + args.title.padding.bottom ); }); }); describe("and position left", () => { before(() => { - args.title.position = "top-left"; + args.title.position = "left"; }); it("renders the title at the default config position", () => { - const titleEl = chart.internal.svg.select(".bb-title"); - - expect(+titleEl.attr("x")).to.be.closeTo(50, 2); - expect(+titleEl.attr("y")).to.be.closeTo(36, 2); // org : 34 + const title = chart.$.svg.select(".bb-title").node(); + const [x, y] = title.parentNode + .getAttribute("transform") + .split(",") + .map(v => util.parseNum(v)); + + expect(x).to.be.equal(0); + expect(y).to.be.equal(title.getBBox().height + args.title.padding.top); }); }); describe("and position right", () => { before(() => { - args.title.position = "top-right"; + args.title.position = "right"; }); it("renders the title at the default config position", () => { - const titleEl = chart.internal.svg.select(".bb-title"); - - expect(+titleEl.attr("x") + titleEl.node().getBBox().width).to.be.closeTo(610, 1); - expect(+titleEl.attr("y")).to.be.closeTo(36, 2); + const title = chart.$.svg.select(".bb-title").node(); + const [x, y] = title.parentNode + .getAttribute("transform") + .split(",") + .map(v => util.parseNum(v)); + + expect(x).to.be.equal(chart.internal.currentWidth); + expect(y).to.be.equal(title.getBBox().height + args.title.padding.top); }); }); }); diff --git a/src/config/Options.js b/src/config/Options.js index 87eb933b9..83abb6902 100644 --- a/src/config/Options.js +++ b/src/config/Options.js @@ -3107,22 +3107,27 @@ export default class Options { * @name title * @memberof Options * @type {Object} - * @property {String} [title.text] - * @property {Number} [title.padding.top=0] - * @property {Number} [title.padding.right=0] - * @property {Number} [title.padding.bottom=0] - * @property {Number} [title.padding.left=0] - * @property {String} [title.position=top-center] + * @property {String} [title.text] Title text. If contains `\n`, it's used as line break allowing multiline title. + * @property {Number} [title.padding.top=0] Top padding value. + * @property {Number} [title.padding.right=0] Right padding value. + * @property {Number} [title.padding.bottom=0] Bottom padding value. + * @property {Number} [title.padding.left=0] Left padding value. + * @property {String} [title.position=center] Available values are: 'center', 'right' and 'left'. + * @see [Demo](https://naver.github.io/billboard.js/demo/#Title.MultilinedTitle) * @example * title: { * text: "Title Text", + * + * // or Multiline title text + * text: "Main title text\nSub title text", + * * padding: { * top: 10, * right: 10, * bottom: 10, * left: 10 * }, - * position: "top-center" + * position: "center" * } */ title_text: undefined, @@ -3132,7 +3137,7 @@ export default class Options { bottom: 0, left: 0 }, - title_position: "top-center" + title_position: "center" }; } } diff --git a/src/internals/legend.js b/src/internals/legend.js index b0950d47f..697d36b2a 100644 --- a/src/internals/legend.js +++ b/src/internals/legend.js @@ -412,7 +412,7 @@ extend(ChartInternal.prototype, { const getTextBox = function(textElement, id) { if (!$$.legendItemTextBox[id]) { $$.legendItemTextBox[id] = - $$.getTextRect(textElement, CLASS.legendItem, textElement); + $$.getTextRect(textElement, CLASS.legendItem); } return $$.legendItemTextBox[id]; diff --git a/src/internals/text.js b/src/internals/text.js index 085373abb..ef6e59c2a 100644 --- a/src/internals/text.js +++ b/src/internals/text.js @@ -109,39 +109,35 @@ extend(ChartInternal.prototype, { /** * Gets the getBoundingClientRect value of the element * @private - * @param {HTMLElement|d3.selection} textVal + * @param {HTMLElement|d3.selection} element * @param {String} className - * @param {HTMLElement|d3.selection} elementVal * @returns {Object} value of element.getBoundingClientRect() */ - getTextRect(textVal, className, elementVal) { - const text = (textVal.node ? textVal.node() : textVal).textContent; - const element = elementVal.node ? elementVal.node() : elementVal; - - const dummy = d3Select("body").append("div") - .classed("bb", true); - - const svg = dummy.append("svg") - .style("visibility", "hidden") - .style("position", "fixed") - .style("top", "0px") - .style("left", "0px"); + getTextRect(element, className) { + const $$ = this; + let base = (element.node ? element.node() : element); - const font = d3Select(element).style("font"); - let rect; + if (!/text/i.test(base.tagName)) { + base = base.querySelector("text"); + } - svg.selectAll(".dummy") - .data([text]) - .enter() - .append("text") - .classed(className, !!className) - .style("font", font) - .text(text) - .each(function() { - rect = this.getBoundingClientRect(); - }); - - dummy.remove(); + const text = base.textContent; + const cacheKey = `$${text.replace(/\W/g, "_")}`; + let rect = $$.getCache(cacheKey); + + if (!rect) { + $$.svg.append("text") + .style("visibility", "hidden") + .style("font", d3Select(base).style("font")) + .classed(className, true) + .text(text) + .call(v => { + rect = v.node().getBoundingClientRect(); + }) + .remove(); + + $$.addCache(cacheKey, rect); + } return rect; }, diff --git a/src/internals/title.js b/src/internals/title.js index b3494ee36..d44dc1e6f 100644 --- a/src/internals/title.js +++ b/src/internals/title.js @@ -3,7 +3,29 @@ * billboard.js project is licensed under the MIT license */ import ChartInternal from "./ChartInternal"; -import {extend} from "./util"; +import {extend, isNumber} from "./util"; + +/** + * Get the text position + * @param {String} pos right, left or center + * @param {Number} width chart width + * @return {String|Number} text-anchor value or position in pixel + * @private + */ +const getTextPos = (pos = "left", width) => { + let position; + const isNum = isNumber(width); + + if (pos.indexOf("center") > -1) { + position = isNum ? width / 2 : "middle"; + } else if (pos.indexOf("right") > -1) { + position = isNum ? width : "end"; + } else { + position = isNum ? 0 : "start"; + } + + return position; +}; extend(ChartInternal.prototype, { /** @@ -13,9 +35,21 @@ extend(ChartInternal.prototype, { initTitle() { const $$ = this; - $$.title = $$.svg.append("text") - .text($$.config.title_text) - .attr("class", $$.CLASS.title); + if ($$.config.title_text) { + $$.title = $$.svg.append("g"); + + const text = $$.title + .append("text") + .style("text-anchor", getTextPos($$.config.title_position)) + .attr("class", $$.CLASS.title); + + $$.config.title_text.split("\n").forEach((v, i) => { + text.append("tspan") + .attr("x", 0) + .attr("dy", `${i ? "1.5" : ".3"}em`) + .text(v); + }); + } }, /** @@ -25,8 +59,15 @@ extend(ChartInternal.prototype, { redrawTitle() { const $$ = this; - $$.title.attr("x", $$.xForTitle.bind($$)) - .attr("y", $$.yForTitle.bind($$)); + if ($$.title) { + const y = $$.yForTitle.call($$); + + if (/g/i.test($$.title.node().tagName)) { + $$.title.attr("transform", `translate(${getTextPos($$.config.title_position, $$.currentWidth)}, ${y})`); + } else { + $$.title.attr("x", $$.xForTitle.call($$)).attr("y", y); + } + } }, /** @@ -40,15 +81,16 @@ extend(ChartInternal.prototype, { const position = config.title_position || "left"; let x; - if (position.indexOf("right") >= 0) { - x = $$.currentWidth - - $$.getTextRect($$.title, $$.CLASS.title, $$.title).width - - config.title_padding.right; - } else if (position.indexOf("center") >= 0) { - x = ($$.currentWidth - - $$.getTextRect($$.title, $$.CLASS.title, $$.title).width) / 2; + if (/(right|center)/.test(position)) { + x = $$.currentWidth - $$.getTextRect($$.title, $$.CLASS.title).width; + + if (position.indexOf("right") >= 0) { + x -= (config.title_padding.right || 0); + } else if (position.indexOf("center") >= 0) { + x /= 2; + } } else { // left - x = config.title_padding.left; + x = (config.title_padding.left || 0); } return x; @@ -62,8 +104,8 @@ extend(ChartInternal.prototype, { yForTitle() { const $$ = this; - return $$.config.title_padding.top + - $$.getTextRect($$.title, $$.CLASS.title, $$.title).height; + return ($$.config.title_padding.top || 0) + + $$.getTextRect($$.title, $$.CLASS.title).height; }, /** @@ -74,6 +116,6 @@ extend(ChartInternal.prototype, { getTitlePadding() { const $$ = this; - return $$.yForTitle() + $$.config.title_padding.bottom; + return $$.yForTitle() + ($$.config.title_padding.bottom || 0); }, });