diff --git a/.eslintrc.yml b/.eslintrc.yml index 389c35be5..875ccdcb0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -31,3 +31,4 @@ rules: es/no-regexp-named-capture-groups: "error" es/no-regexp-s-flag: "error" es/no-regexp-unicode-property-escapes: "error" + linebreak-style: ["error", "unix"] diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..7535f72b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# We'll let Git's auto-detection algorithm infer if a file is text. If it is, +# enforce LF line endings regardless of OS or git configurations. +* text=auto eol=lf + +# Isolate binary files in case the auto-detection algorithm fails and +# marks them as text files (which could brick them). +*.{png,jpg,jpeg,gif,webp,woff,woff2} binary diff --git a/docs/samples/types/label.md b/docs/samples/types/label.md index 1f5442ae3..d30b81d44 100644 --- a/docs/samples/types/label.md +++ b/docs/samples/types/label.md @@ -1,144 +1,144 @@ -# Label - -```js chart-editor -// -const DATA_COUNT = 8; -const MIN = 10; -const MAX = 100; - -Utils.srand(8); - -const labels = []; -for (let i = 0; i < DATA_COUNT; ++i) { - labels.push('' + i); -} - -const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; - -const data = { - labels: labels, - datasets: [{ - data: Utils.numbers(numberCfg), - }, { - data: Utils.numbers(numberCfg), - }, { - data: Utils.numbers(numberCfg), - }] -}; -// - -// -const annotation1 = { - type: 'label', - xScaleID: 'x', - xValue: 0, - yScaleID: 'y', - yValue: minValue, - position: { - x: 'start', - y: 'end' - }, - content: (ctx) => 'Lower bound: ' + minValue(ctx).toFixed(3) -}; -// - -// -const annotation2 = { - type: 'label', - xScaleID: 'x', - xValue: 0, - yScaleID: 'y', - yValue: maxValue, - position: { - x: 'start', - y: 'start' - }, - content: (ctx) => 'Upper bound: ' + maxValue(ctx).toFixed(3) -}; -// - -/* */ -const config = { - type: 'line', - data, - options: { - plugins: { - annotation: { - annotations: { - annotation1, - annotation2 - } - } - }, - // Core options - scales: { - y: { - stacked: true - } - } - } -}; -/* */ - -// -function minValue(ctx) { - const dataset = ctx.chart.data.datasets[0]; - const min = dataset.data.reduce((max, point) => Math.min(point, max), Infinity); - return isFinite(min) ? min : 0; -} - -function maxValue(ctx) { - const datasets = ctx.chart.data.datasets; - const count = datasets[0].data.length; - let max = 0; - for (let i = 0; i < count; i++) { - let sum = 0; - for (const dataset of datasets) { - sum += dataset.data[i]; - } - max = Math.max(max, sum); - } - return max; -} -// - -var actions = [ - { - name: 'Randomize', - handler: function(chart) { - chart.data.datasets.forEach(function(dataset, i) { - dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); - }); - - chart.update(); - } - }, - { - name: 'Add data', - handler: function(chart) { - chart.data.labels.push(chart.data.labels.length); - chart.data.datasets.forEach(function(dataset, i) { - dataset.data.push(Utils.rand(MIN, MAX)); - }); - - chart.update(); - } - }, - { - name: 'Remove data', - handler: function(chart) { - chart.data.labels.shift(); - chart.data.datasets.forEach(function(dataset, i) { - dataset.data.shift(); - }); - - chart.update(); - } - } -]; - -module.exports = { - actions: actions, - config: config, -}; -``` +# Label + +```js chart-editor +// +const DATA_COUNT = 8; +const MIN = 10; +const MAX = 100; + +Utils.srand(8); + +const labels = []; +for (let i = 0; i < DATA_COUNT; ++i) { + labels.push('' + i); +} + +const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; + +const data = { + labels: labels, + datasets: [{ + data: Utils.numbers(numberCfg), + }, { + data: Utils.numbers(numberCfg), + }, { + data: Utils.numbers(numberCfg), + }] +}; +// + +// +const annotation1 = { + type: 'label', + xScaleID: 'x', + xValue: 0, + yScaleID: 'y', + yValue: minValue, + position: { + x: 'start', + y: 'end' + }, + content: (ctx) => 'Lower bound: ' + minValue(ctx).toFixed(3) +}; +// + +// +const annotation2 = { + type: 'label', + xScaleID: 'x', + xValue: 0, + yScaleID: 'y', + yValue: maxValue, + position: { + x: 'start', + y: 'start' + }, + content: (ctx) => 'Upper bound: ' + maxValue(ctx).toFixed(3) +}; +// + +/* */ +const config = { + type: 'line', + data, + options: { + plugins: { + annotation: { + annotations: { + annotation1, + annotation2 + } + } + }, + // Core options + scales: { + y: { + stacked: true + } + } + } +}; +/* */ + +// +function minValue(ctx) { + const dataset = ctx.chart.data.datasets[0]; + const min = dataset.data.reduce((max, point) => Math.min(point, max), Infinity); + return isFinite(min) ? min : 0; +} + +function maxValue(ctx) { + const datasets = ctx.chart.data.datasets; + const count = datasets[0].data.length; + let max = 0; + for (let i = 0; i < count; i++) { + let sum = 0; + for (const dataset of datasets) { + sum += dataset.data[i]; + } + max = Math.max(max, sum); + } + return max; +} +// + +var actions = [ + { + name: 'Randomize', + handler: function(chart) { + chart.data.datasets.forEach(function(dataset, i) { + dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); + }); + + chart.update(); + } + }, + { + name: 'Add data', + handler: function(chart) { + chart.data.labels.push(chart.data.labels.length); + chart.data.datasets.forEach(function(dataset, i) { + dataset.data.push(Utils.rand(MIN, MAX)); + }); + + chart.update(); + } + }, + { + name: 'Remove data', + handler: function(chart) { + chart.data.labels.shift(); + chart.data.datasets.forEach(function(dataset, i) { + dataset.data.shift(); + }); + + chart.update(); + } + } +]; + +module.exports = { + actions: actions, + config: config, +}; +``` diff --git a/docs/samples/types/polygon.md b/docs/samples/types/polygon.md index f4ca30ef1..fc36f79a4 100644 --- a/docs/samples/types/polygon.md +++ b/docs/samples/types/polygon.md @@ -1,121 +1,121 @@ -# Polygon - -```js chart-editor -// -const DATA_COUNT = 8; -const MIN = 10; -const MAX = 100; - -Utils.srand(5); - -const labels = []; -for (let i = 0; i < DATA_COUNT; ++i) { - labels.push('' + i); -} - -const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; - -const data = { - labels: labels, - datasets: [{ - data: Utils.numbers(numberCfg), - }, { - data: Utils.numbers(numberCfg), - }, { - data: Utils.numbers(numberCfg), - }] -}; -// - -// -const annotation1 = { - type: 'polygon', - scaleID: 'y', - borderWidth: 3, - borderColor: 'black', - backgroundColor: 'rgba(0,255,255,0.4)', - radius: 25, - xValue: (ctx) => value(ctx, 0, 2, 'x'), - yValue: (ctx) => value(ctx, 0, 2, 'y') -}; -// - -// -const annotation2 = { - type: 'polygon', - scaleID: 'y', - borderWidth: 5, - borderColor: 'red', - backgroundColor: 'transparent', - radius: 25, - sides: 5, - xValue: (ctx) => value(ctx, 1, 4, 'x'), - yValue: (ctx) => value(ctx, 1, 4, 'y') -}; -// - -/* */ -const config = { - type: 'line', - data, - options: { - plugins: { - annotation: { - annotations: { - annotation1, - annotation2 - } - } - }, - } -}; -/* */ - -// -function value(ctx, datasetIndex, index, prop) { - const meta = ctx.chart.getDatasetMeta(datasetIndex); - const parsed = meta.controller.getParsed(index); - return parsed ? parsed[prop] : NaN; -} -// - -var actions = [ - { - name: 'Randomize', - handler: function(chart) { - chart.data.datasets.forEach(function(dataset, i) { - dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); - }); - - chart.update(); - } - }, - { - name: 'Add data', - handler: function(chart) { - chart.data.labels.push(chart.data.labels.length); - chart.data.datasets.forEach(function(dataset, i) { - dataset.data.push(Utils.rand(MIN, MAX)); - }); - - chart.update(); - } - }, - { - name: 'Remove data', - handler: function(chart) { - chart.data.labels.shift(); - chart.data.datasets.forEach(function(dataset, i) { - dataset.data.shift(); - }); - - chart.update(); - } - } -]; - -module.exports = { - actions: actions, - config: config -}; -``` +# Polygon + +```js chart-editor +// +const DATA_COUNT = 8; +const MIN = 10; +const MAX = 100; + +Utils.srand(5); + +const labels = []; +for (let i = 0; i < DATA_COUNT; ++i) { + labels.push('' + i); +} + +const numberCfg = {count: DATA_COUNT, min: MIN, max: MAX}; + +const data = { + labels: labels, + datasets: [{ + data: Utils.numbers(numberCfg), + }, { + data: Utils.numbers(numberCfg), + }, { + data: Utils.numbers(numberCfg), + }] +}; +// + +// +const annotation1 = { + type: 'polygon', + scaleID: 'y', + borderWidth: 3, + borderColor: 'black', + backgroundColor: 'rgba(0,255,255,0.4)', + radius: 25, + xValue: (ctx) => value(ctx, 0, 2, 'x'), + yValue: (ctx) => value(ctx, 0, 2, 'y') +}; +// + +// +const annotation2 = { + type: 'polygon', + scaleID: 'y', + borderWidth: 5, + borderColor: 'red', + backgroundColor: 'transparent', + radius: 25, + sides: 5, + xValue: (ctx) => value(ctx, 1, 4, 'x'), + yValue: (ctx) => value(ctx, 1, 4, 'y') +}; +// + +/* */ +const config = { + type: 'line', + data, + options: { + plugins: { + annotation: { + annotations: { + annotation1, + annotation2 + } + } + }, + } +}; +/* */ + +// +function value(ctx, datasetIndex, index, prop) { + const meta = ctx.chart.getDatasetMeta(datasetIndex); + const parsed = meta.controller.getParsed(index); + return parsed ? parsed[prop] : NaN; +} +// + +var actions = [ + { + name: 'Randomize', + handler: function(chart) { + chart.data.datasets.forEach(function(dataset, i) { + dataset.data = dataset.data.map(() => Utils.rand(MIN, MAX)); + }); + + chart.update(); + } + }, + { + name: 'Add data', + handler: function(chart) { + chart.data.labels.push(chart.data.labels.length); + chart.data.datasets.forEach(function(dataset, i) { + dataset.data.push(Utils.rand(MIN, MAX)); + }); + + chart.update(); + } + }, + { + name: 'Remove data', + handler: function(chart) { + chart.data.labels.shift(); + chart.data.datasets.forEach(function(dataset, i) { + dataset.data.shift(); + }); + + chart.update(); + } + } +]; + +module.exports = { + actions: actions, + config: config +}; +``` diff --git a/src/types/label.js b/src/types/label.js index 51eed4427..a26416a81 100644 --- a/src/types/label.js +++ b/src/types/label.js @@ -1,278 +1,278 @@ -import {drawBox, drawLabel, drawPoint, measureLabelSize, isLabelVisible, getChartPoint, getRectCenterPoint, toPosition, setBorderStyle, getSize, inPointRange, isBoundToPoint, getChartRect} from '../helpers'; +import {drawBox, drawLabel, drawPoint, measureLabelSize, isLabelVisible, getChartPoint, getRectCenterPoint, toPosition, setBorderStyle, getSize, inPointRange, isBoundToPoint, getChartRect} from '../helpers'; import {color as getColor, toPadding} from 'chart.js/helpers'; -import {Element} from 'chart.js'; - -export default class LabelAnnotation extends Element { - - inRange(mouseX, mouseY) { - return this.inLabelRange(mouseX, mouseY) || (this.isPointVisible() && inPointRange({x: mouseX, y: mouseY}, this.point, this.options.point.radius)); - } - - inLabelRange(mouseX, mouseY, gap) { - if (this.labelRect) { - const adjust = gap || 0; - const {x, y, width, height} = this.isBoxVisible() ? this.getProps(['x', 'y', 'width', 'height']) : this.labelRect; - - return mouseX >= x + adjust && - mouseX <= x + width - adjust && - mouseY >= y + adjust && - mouseY <= y + height - adjust; - } - return false; - } - - isBoxVisible() { - const color = getColor(this.options.backgroundColor); - return (color && color.valid && color.rgb.a > 0) || this.options.borderWidth > 0; - } - - isPointVisible() { - return this.options.point && this.options.point.enabled && this.point && !this.inLabelRange(this.point.x, this.point.y, 1); - } - - isCalloutVisible() { - return this.options.callout && this.options.callout.enabled && this.point && !this.inLabelRange(this.point.x, this.point.y); - } - - getCenterPoint(useFinalPosition) { - return getRectCenterPoint(this.getProps(['x', 'y', 'width', 'height'], useFinalPosition)); - } - - draw(ctx) { - if (this.labelRect) { - applyCallout(ctx, this); - applyBox(ctx, this); - drawLabel(ctx, this.labelRect, this.options); - applyPoint(ctx, this); - } - } - - resolveElementProperties(chart, options) { - this.point = !isBoundToPoint(options) ? getRectCenterPoint(getChartRect(chart, options)) : getChartPoint(chart, options); - - if (isLabelVisible(options)) { - const padding = toPadding(options.padding); - const labelSize = measureLabelSize(chart.ctx, options); - const elemDim = measureRect(this.point, labelSize, options, padding); - this.labelRect = { - x: elemDim.x + padding.left + (options.borderWidth / 2), - y: elemDim.y + padding.top + (options.borderWidth / 2), - width: labelSize.width, - height: labelSize.height - }; - return elemDim; - } - this.point = null; - this.labelRect = null; - return {options: {}}; - } -} - -LabelAnnotation.id = 'labelAnnotation'; - -LabelAnnotation.defaults = { - adjustScaleRange: true, - backgroundColor: 'transparent', - borderCapStyle: 'butt', - borderDash: [], - borderDashOffset: 0, - borderJoinStyle: 'miter', - borderRadius: 0, - borderWidth: 0, - callout: { - borderCapStyle: 'butt', - borderColor: undefined, - borderDash: [], - borderDashOffset: 0, - borderJoinStyle: 'miter', - borderWidth: 1, - enabled: false, - margin: 5, - position: 'auto', - side: 5, - start: '50%', - }, - point: { - backgroundColor: undefined, - borderColor: undefined, - borderDash: [], - borderDashOffset: 0, - borderWidth: 1, - enabled: false, - pointStyle: 'circle', - radius: 3, - rotation: 0 - }, - color: 'black', - content: null, - display: true, - font: { - family: undefined, - lineHeight: undefined, - size: undefined, - style: undefined, - weight: undefined - }, - height: undefined, - padding: 6, - position: 'center', - textAlign: 'center', - width: undefined, - xAdjust: 0, - xMax: undefined, - xMin: undefined, - xScaleID: 'x', - xValue: undefined, - yAdjust: 0, - yMax: undefined, - yMin: undefined, - yScaleID: 'y', - yValue: undefined -}; - -LabelAnnotation.defaultRoutes = { - borderColor: 'color', - backgroundColor: 'color', -}; - -function measureRect(point, size, options, padding) { - const width = size.width + padding.width + options.borderWidth; - const height = size.height + padding.height + options.borderWidth; - const position = toPosition(options.position); - - return { - x: calculatePosition(point.x, width, options.xAdjust, position.x), - y: calculatePosition(point.y, height, options.yAdjust, position.y), - width, - height - }; -} - -function calculatePosition(start, size, adjust, position) { - if (position === 'start') { - return start + adjust; - } else if (position === 'end') { - return start - size + adjust; - } - return start - size / 2 + adjust; -} - -function applyCallout(ctx, element) { - if (element.isCalloutVisible()) { - drawCallout(ctx, element); - } -} - -function applyBox(ctx, element) { - if (element.isBoxVisible()) { - drawBox(ctx, element, element.options); - } -} - -function applyPoint(ctx, element) { - if (element.isPointVisible()) { - drawPoint(ctx, element.point, element.options.point); - } -} - -function drawCallout(ctx, element) { - const point = element.point; - const options = element.options; - const callout = options.callout; - const position = resolveCalloutPosition(element); - if (!position) { - return; - } - const {separatorStart, separatorEnd} = getCalloutSeparatorCoord(element, position); - const {sideStart, sideEnd} = getCalloutSideCoord(element, position, separatorStart); - ctx.save(); - ctx.beginPath(); - const stroke = setBorderStyle(ctx, callout); - if (callout.margin > 0 || options.borderWidth === 0) { - ctx.moveTo(separatorStart.x, separatorStart.y); - ctx.lineTo(separatorEnd.x, separatorEnd.y); - } - ctx.moveTo(sideStart.x, sideStart.y); - ctx.lineTo(sideEnd.x, sideEnd.y); - ctx.lineTo(point.x, point.y); - if (stroke) { - ctx.stroke(); - } - ctx.restore(); -} - -function getCalloutSeparatorCoord(element, position) { - const {x, y, width, height} = element; - const adjust = getCalloutSeparatorAdjust(element, position); - let separatorStart, separatorEnd; - if (position === 'left' || position === 'right') { - separatorStart = {x: x + adjust, y}; - separatorEnd = {x: separatorStart.x, y: separatorStart.y + height}; - } else if (position === 'top' || position === 'bottom') { - separatorStart = {x, y: y + adjust}; - separatorEnd = {x: separatorStart.x + width, y: separatorStart.y}; - } - return {separatorStart, separatorEnd}; -} - -function getCalloutSeparatorAdjust(element, position) { - const {width, height, options} = element; - const adjust = options.callout.margin + options.borderWidth / 2; - if (position === 'right') { - return width + adjust; - } else if (position === 'bottom') { - return height + adjust; - } - return -adjust; -} - -function getCalloutSideCoord(element, position, separatorStart) { - const {y, width, height, options} = element; - const start = options.callout.start; - const side = getCalloutSideAdjust(position, options.callout); - let sideStart, sideEnd; - if (position === 'left' || position === 'right') { - sideStart = {x: separatorStart.x, y: y + getStartSize(height, start)}; - sideEnd = {x: sideStart.x + side, y: sideStart.y}; - } else if (position === 'top' || position === 'bottom') { - sideStart = {x: separatorStart.x + getStartSize(width, start), y: separatorStart.y}; - sideEnd = {x: sideStart.x, y: sideStart.y + side}; - } - return {sideStart, sideEnd}; -} - -function getStartSize(size, value) { - const start = getSize(size, value); - return Math.min(Math.max(start, 0), size); -} - -function getCalloutSideAdjust(position, options) { - const side = options.side; - if (position === 'left' || position === 'top') { - return -side; - } - return side; -} - -function resolveCalloutPosition(element) { - const position = element.options.callout.position; - if (position === 'left' || position === 'right' || position === 'top' || position === 'bottom') { - return position; - } - return resolveCalloutAutoPosition(element); -} - -function resolveCalloutAutoPosition(element) { - const {x, y, width, height, point, options} = element; - const {margin, side} = options.callout; - const adjust = margin + side; - if (point.x < (x - adjust)) { - return 'left'; - } else if (point.x > (x + width + adjust)) { - return 'right'; - } else if (point.y < (y + height + adjust)) { - return 'top'; - } else if (point.y > (y - adjust)) { - return 'bottom'; - } -} +import {Element} from 'chart.js'; + +export default class LabelAnnotation extends Element { + + inRange(mouseX, mouseY) { + return this.inLabelRange(mouseX, mouseY) || (this.isPointVisible() && inPointRange({x: mouseX, y: mouseY}, this.point, this.options.point.radius)); + } + + inLabelRange(mouseX, mouseY, gap) { + if (this.labelRect) { + const adjust = gap || 0; + const {x, y, width, height} = this.isBoxVisible() ? this.getProps(['x', 'y', 'width', 'height']) : this.labelRect; + + return mouseX >= x + adjust && + mouseX <= x + width - adjust && + mouseY >= y + adjust && + mouseY <= y + height - adjust; + } + return false; + } + + isBoxVisible() { + const color = getColor(this.options.backgroundColor); + return (color && color.valid && color.rgb.a > 0) || this.options.borderWidth > 0; + } + + isPointVisible() { + return this.options.point && this.options.point.enabled && this.point && !this.inLabelRange(this.point.x, this.point.y, 1); + } + + isCalloutVisible() { + return this.options.callout && this.options.callout.enabled && this.point && !this.inLabelRange(this.point.x, this.point.y); + } + + getCenterPoint(useFinalPosition) { + return getRectCenterPoint(this.getProps(['x', 'y', 'width', 'height'], useFinalPosition)); + } + + draw(ctx) { + if (this.labelRect) { + applyCallout(ctx, this); + applyBox(ctx, this); + drawLabel(ctx, this.labelRect, this.options); + applyPoint(ctx, this); + } + } + + resolveElementProperties(chart, options) { + this.point = !isBoundToPoint(options) ? getRectCenterPoint(getChartRect(chart, options)) : getChartPoint(chart, options); + + if (isLabelVisible(options)) { + const padding = toPadding(options.padding); + const labelSize = measureLabelSize(chart.ctx, options); + const elemDim = measureRect(this.point, labelSize, options, padding); + this.labelRect = { + x: elemDim.x + padding.left + (options.borderWidth / 2), + y: elemDim.y + padding.top + (options.borderWidth / 2), + width: labelSize.width, + height: labelSize.height + }; + return elemDim; + } + this.point = null; + this.labelRect = null; + return {options: {}}; + } +} + +LabelAnnotation.id = 'labelAnnotation'; + +LabelAnnotation.defaults = { + adjustScaleRange: true, + backgroundColor: 'transparent', + borderCapStyle: 'butt', + borderDash: [], + borderDashOffset: 0, + borderJoinStyle: 'miter', + borderRadius: 0, + borderWidth: 0, + callout: { + borderCapStyle: 'butt', + borderColor: undefined, + borderDash: [], + borderDashOffset: 0, + borderJoinStyle: 'miter', + borderWidth: 1, + enabled: false, + margin: 5, + position: 'auto', + side: 5, + start: '50%', + }, + point: { + backgroundColor: undefined, + borderColor: undefined, + borderDash: [], + borderDashOffset: 0, + borderWidth: 1, + enabled: false, + pointStyle: 'circle', + radius: 3, + rotation: 0 + }, + color: 'black', + content: null, + display: true, + font: { + family: undefined, + lineHeight: undefined, + size: undefined, + style: undefined, + weight: undefined + }, + height: undefined, + padding: 6, + position: 'center', + textAlign: 'center', + width: undefined, + xAdjust: 0, + xMax: undefined, + xMin: undefined, + xScaleID: 'x', + xValue: undefined, + yAdjust: 0, + yMax: undefined, + yMin: undefined, + yScaleID: 'y', + yValue: undefined +}; + +LabelAnnotation.defaultRoutes = { + borderColor: 'color', + backgroundColor: 'color', +}; + +function measureRect(point, size, options, padding) { + const width = size.width + padding.width + options.borderWidth; + const height = size.height + padding.height + options.borderWidth; + const position = toPosition(options.position); + + return { + x: calculatePosition(point.x, width, options.xAdjust, position.x), + y: calculatePosition(point.y, height, options.yAdjust, position.y), + width, + height + }; +} + +function calculatePosition(start, size, adjust, position) { + if (position === 'start') { + return start + adjust; + } else if (position === 'end') { + return start - size + adjust; + } + return start - size / 2 + adjust; +} + +function applyCallout(ctx, element) { + if (element.isCalloutVisible()) { + drawCallout(ctx, element); + } +} + +function applyBox(ctx, element) { + if (element.isBoxVisible()) { + drawBox(ctx, element, element.options); + } +} + +function applyPoint(ctx, element) { + if (element.isPointVisible()) { + drawPoint(ctx, element.point, element.options.point); + } +} + +function drawCallout(ctx, element) { + const point = element.point; + const options = element.options; + const callout = options.callout; + const position = resolveCalloutPosition(element); + if (!position) { + return; + } + const {separatorStart, separatorEnd} = getCalloutSeparatorCoord(element, position); + const {sideStart, sideEnd} = getCalloutSideCoord(element, position, separatorStart); + ctx.save(); + ctx.beginPath(); + const stroke = setBorderStyle(ctx, callout); + if (callout.margin > 0 || options.borderWidth === 0) { + ctx.moveTo(separatorStart.x, separatorStart.y); + ctx.lineTo(separatorEnd.x, separatorEnd.y); + } + ctx.moveTo(sideStart.x, sideStart.y); + ctx.lineTo(sideEnd.x, sideEnd.y); + ctx.lineTo(point.x, point.y); + if (stroke) { + ctx.stroke(); + } + ctx.restore(); +} + +function getCalloutSeparatorCoord(element, position) { + const {x, y, width, height} = element; + const adjust = getCalloutSeparatorAdjust(element, position); + let separatorStart, separatorEnd; + if (position === 'left' || position === 'right') { + separatorStart = {x: x + adjust, y}; + separatorEnd = {x: separatorStart.x, y: separatorStart.y + height}; + } else if (position === 'top' || position === 'bottom') { + separatorStart = {x, y: y + adjust}; + separatorEnd = {x: separatorStart.x + width, y: separatorStart.y}; + } + return {separatorStart, separatorEnd}; +} + +function getCalloutSeparatorAdjust(element, position) { + const {width, height, options} = element; + const adjust = options.callout.margin + options.borderWidth / 2; + if (position === 'right') { + return width + adjust; + } else if (position === 'bottom') { + return height + adjust; + } + return -adjust; +} + +function getCalloutSideCoord(element, position, separatorStart) { + const {y, width, height, options} = element; + const start = options.callout.start; + const side = getCalloutSideAdjust(position, options.callout); + let sideStart, sideEnd; + if (position === 'left' || position === 'right') { + sideStart = {x: separatorStart.x, y: y + getStartSize(height, start)}; + sideEnd = {x: sideStart.x + side, y: sideStart.y}; + } else if (position === 'top' || position === 'bottom') { + sideStart = {x: separatorStart.x + getStartSize(width, start), y: separatorStart.y}; + sideEnd = {x: sideStart.x, y: sideStart.y + side}; + } + return {sideStart, sideEnd}; +} + +function getStartSize(size, value) { + const start = getSize(size, value); + return Math.min(Math.max(start, 0), size); +} + +function getCalloutSideAdjust(position, options) { + const side = options.side; + if (position === 'left' || position === 'top') { + return -side; + } + return side; +} + +function resolveCalloutPosition(element) { + const position = element.options.callout.position; + if (position === 'left' || position === 'right' || position === 'top' || position === 'bottom') { + return position; + } + return resolveCalloutAutoPosition(element); +} + +function resolveCalloutAutoPosition(element) { + const {x, y, width, height, point, options} = element; + const {margin, side} = options.callout; + const adjust = margin + side; + if (point.x < (x - adjust)) { + return 'left'; + } else if (point.x > (x + width + adjust)) { + return 'right'; + } else if (point.y < (y + height + adjust)) { + return 'top'; + } else if (point.y > (y - adjust)) { + return 'bottom'; + } +}