@progress/kendo-charts
Version:
Kendo UI platform-independent Charts library
948 lines (791 loc) • 28.2 kB
JavaScript
import { drawing as draw, geometry as geom } from '@progress/kendo-drawing';
import ChartElement from './chart-element';
import TextBox from './text-box';
import AxisLabel from './axis-label';
import Note from './note';
import Box from './box';
import { ChartService } from '../services';
import createAxisTick from './utils/create-axis-tick';
import createAxisGridLine from './utils/create-axis-grid-line';
import { NONE, BLACK, CENTER, TOP, BOTTOM, LEFT, RIGHT, OUTSIDE, X, Y, WIDTH, HEIGHT } from '../common/constants';
import { alignPathToPixel, deepExtend, getTemplate, grep, defined, isObject, inArray, limitValue, round, setDefaultOptions } from '../common';
class Axis extends ChartElement {
constructor(options, chartService = new ChartService()) {
super(options);
this.chartService = chartService;
if (!this.options.visible) {
this.options = deepExtend({}, this.options, {
labels: {
visible: false
},
line: {
visible: false
},
margin: 0,
majorTickSize: 0,
minorTickSize: 0
});
}
this.options.minorTicks = deepExtend({}, {
color: this.options.line.color,
width: this.options.line.width,
visible: this.options.minorTickType !== NONE
}, this.options.minorTicks, {
size: this.options.minorTickSize,
align: this.options.minorTickType
});
this.options.majorTicks = deepExtend({}, {
color: this.options.line.color,
width: this.options.line.width,
visible: this.options.majorTickType !== NONE
}, this.options.majorTicks, {
size: this.options.majorTickSize,
align: this.options.majorTickType
});
this.initFields();
if (!this.options._deferLabels) {
this.createLabels();
}
this.createTitle();
this.createNotes();
}
initFields() {
}
// abstract labelsCount(): Number
// abstract createAxisLabel(index, options): AxisLabel
labelsRange() {
return {
min: this.options.labels.skip,
max: this.labelsCount()
};
}
normalizeLabelRotation(labelOptions) {
const rotation = labelOptions.rotation;
if (isObject(rotation)) {
labelOptions.alignRotation = rotation.align;
labelOptions.rotation = rotation.angle;
}
}
createLabels() {
const options = this.options;
const align = options.vertical ? RIGHT : CENTER;
const labelOptions = deepExtend({ }, options.labels, {
align: align,
zIndex: options.zIndex
});
const step = Math.max(1, labelOptions.step);
this.clearLabels();
if (labelOptions.visible) {
this.normalizeLabelRotation(labelOptions);
if (labelOptions.rotation === "auto") {
labelOptions.rotation = 0;
options.autoRotateLabels = true;
}
const range = this.labelsRange();
for (let idx = range.min; idx < range.max; idx += step) {
const labelContext = { index: idx, count: range.max };
let label = this.createAxisLabel(idx, labelOptions, labelContext);
if (label) {
this.append(label);
this.labels.push(label);
}
}
}
}
clearLabels() {
this.children = grep(this.children, child => !(child instanceof AxisLabel));
this.labels = [];
}
clearTitle() {
if (this.title) {
this.children = grep(this.children, child => child !== this.title);
this.title = undefined;
}
}
clear() {
this.clearLabels();
this.clearTitle();
}
lineBox() {
const { options, box } = this;
const vertical = options.vertical;
const mirror = options.labels.mirror;
const axisX = mirror ? box.x1 : box.x2;
const axisY = mirror ? box.y2 : box.y1;
const lineWidth = options.line.width || 0;
return vertical ?
new Box(axisX, box.y1, axisX, box.y2 - lineWidth) :
new Box(box.x1, axisY, box.x2 - lineWidth, axisY);
}
createTitle() {
const options = this.options;
const titleOptions = deepExtend({
rotation: options.vertical ? -90 : 0,
text: "",
zIndex: 1,
visualSize: true
}, options.title);
if (titleOptions.visible && titleOptions.text) {
const title = new TextBox(titleOptions.text, titleOptions);
this.append(title);
this.title = title;
}
}
createNotes() {
const options = this.options;
const notes = options.notes;
const items = notes.data || [];
this.notes = [];
for (let i = 0; i < items.length; i++) {
const item = deepExtend({}, notes, items[i]);
item.value = this.parseNoteValue(item.value);
const note = new Note({
value: item.value,
text: item.label.text,
dataItem: item
}, item, this.chartService);
if (note.options.visible) {
if (defined(note.options.position)) {
if (options.vertical && !inArray(note.options.position, [ LEFT, RIGHT ])) {
note.options.position = options.reverse ? LEFT : RIGHT;
} else if (!options.vertical && !inArray(note.options.position, [ TOP, BOTTOM ])) {
note.options.position = options.reverse ? BOTTOM : TOP;
}
} else {
if (options.vertical) {
note.options.position = options.reverse ? LEFT : RIGHT;
} else {
note.options.position = options.reverse ? BOTTOM : TOP;
}
}
this.append(note);
this.notes.push(note);
}
}
}
parseNoteValue(value) {
return value;
}
renderVisual() {
super.renderVisual();
this.createPlotBands();
}
createVisual() {
super.createVisual();
this.createBackground();
this.createLine();
}
gridLinesVisual() {
let gridLines = this._gridLines;
if (!gridLines) {
gridLines = this._gridLines = new draw.Group({
zIndex: -2
});
this.appendVisual(this._gridLines);
}
return gridLines;
}
createTicks(lineGroup) {
const options = this.options;
const lineBox = this.lineBox();
const mirror = options.labels.mirror;
const majorUnit = options.majorTicks.visible ? options.majorUnit : 0;
const tickLineOptions = {
// TODO
// _alignLines: options._alignLines,
vertical: options.vertical
};
function render(tickPositions, tickOptions, skipUnit) {
const count = tickPositions.length;
const step = Math.max(1, tickOptions.step);
if (tickOptions.visible) {
for (let i = tickOptions.skip; i < count; i += step) {
if (defined(skipUnit) && (i % skipUnit === 0)) {
continue;
}
tickLineOptions.tickX = mirror ? lineBox.x2 : lineBox.x2 - tickOptions.size;
tickLineOptions.tickY = mirror ? lineBox.y1 - tickOptions.size : lineBox.y1;
tickLineOptions.position = tickPositions[i];
lineGroup.append(createAxisTick(tickLineOptions, tickOptions));
}
}
}
render(this.getMajorTickPositions(), options.majorTicks);
render(this.getMinorTickPositions(), options.minorTicks, majorUnit / options.minorUnit);
}
createLine() {
const options = this.options;
const line = options.line;
const lineBox = this.lineBox();
if (line.width > 0 && line.visible) {
const path = new draw.Path({
stroke: {
width: line.width,
color: line.color,
dashType: line.dashType
}
/* TODO
zIndex: line.zIndex,
*/
});
path.moveTo(lineBox.x1, lineBox.y1)
.lineTo(lineBox.x2, lineBox.y2);
if (options._alignLines) {
alignPathToPixel(path);
}
const group = this._lineGroup = new draw.Group();
group.append(path);
this.visual.append(group);
this.createTicks(group);
}
}
getActualTickSize() {
const options = this.options;
let tickSize = 0;
if (options.majorTicks.visible && options.minorTicks.visible) {
tickSize = Math.max(options.majorTicks.size, options.minorTicks.size);
} else if (options.majorTicks.visible) {
tickSize = options.majorTicks.size;
} else if (options.minorTicks.visible) {
tickSize = options.minorTicks.size;
}
return tickSize;
}
createBackground() {
const { options, box } = this;
const background = options.background;
if (background) {
this._backgroundPath = draw.Path.fromRect(box.toRect(), {
fill: {
color: background
},
stroke: null
});
this.visual.append(this._backgroundPath);
}
}
createPlotBands() {
const options = this.options;
const plotBands = options.plotBands || [];
const vertical = options.vertical;
const plotArea = this.plotArea;
if (plotBands.length === 0) {
return;
}
const group = this._plotbandGroup = new draw.Group({
zIndex: -1
});
const altAxis = grep(this.pane.axes, axis => axis.options.vertical !== this.options.vertical)[0];
for (let idx = 0; idx < plotBands.length; idx++) {
let item = plotBands[idx];
let slotX, slotY;
let labelOptions = item.label;
let label;
if (vertical) {
slotX = (altAxis || plotArea.axisX).lineBox();
slotY = this.getSlot(item.from, item.to, true);
} else {
slotX = this.getSlot(item.from, item.to, true);
slotY = (altAxis || plotArea.axisY).lineBox();
}
if (labelOptions) {
labelOptions.vAlign = labelOptions.position || LEFT;
label = this.createPlotBandLabel(
labelOptions,
item,
new Box(
slotX.x1,
slotY.y1,
slotX.x2,
slotY.y2
)
);
}
if (slotX.width() !== 0 && slotY.height() !== 0) {
const bandRect = new geom.Rect(
[ slotX.x1, slotY.y1 ],
[ slotX.width(), slotY.height() ]
);
const path = draw.Path.fromRect(bandRect, {
fill: {
color: item.color,
opacity: item.opacity
},
stroke: null
});
group.append(path);
if (label) {
group.append(label);
}
}
}
this.appendVisual(group);
}
createPlotBandLabel(label, item, box) {
if (label.visible === false) {
return null;
}
let text = label.text;
let textbox;
if (defined(label) && label.visible) {
const labelTemplate = getTemplate(label);
if (labelTemplate) {
text = labelTemplate({ text: text, item: item });
} else if (label.format) {
text = this.chartService.format.auto(label.format, text);
}
if (!label.color) {
label.color = this.options.labels.color;
}
}
textbox = new TextBox(text, label);
textbox.reflow(box);
textbox.renderVisual();
return textbox.visual;
}
createGridLines(altAxis) {
const options = this.options;
const { minorGridLines, majorGridLines, minorUnit, vertical } = options;
const axisLineVisible = altAxis.options.line.visible;
const majorUnit = majorGridLines.visible ? options.majorUnit : 0;
const lineBox = altAxis.lineBox();
const linePos = lineBox[vertical ? "y1" : "x1"];
const lineOptions = {
lineStart: lineBox[vertical ? "x1" : "y1"],
lineEnd: lineBox[vertical ? "x2" : "y2"],
vertical: vertical
};
const majorTicks = [];
const container = this.gridLinesVisual();
function render(tickPositions, gridLine, skipUnit) {
const count = tickPositions.length;
const step = Math.max(1, gridLine.step);
if (gridLine.visible) {
for (let i = gridLine.skip; i < count; i += step) {
let pos = round(tickPositions[i]);
if (!inArray(pos, majorTicks)) {
if (i % skipUnit !== 0 && (!axisLineVisible || linePos !== pos)) {
lineOptions.position = pos;
container.append(createAxisGridLine(lineOptions, gridLine));
majorTicks.push(pos);
}
}
}
}
}
render(this.getMajorTickPositions(), majorGridLines);
render(this.getMinorTickPositions(), minorGridLines, majorUnit / minorUnit);
return container.children;
}
reflow(box) {
const { options, labels, title } = this;
const vertical = options.vertical;
const count = labels.length;
const sizeFn = vertical ? WIDTH : HEIGHT;
const titleSize = title ? title.box[sizeFn]() : 0;
const space = this.getActualTickSize() + options.margin + titleSize;
const rootBox = (this.getRoot() || {}).box || box;
const boxSize = rootBox[sizeFn]();
let maxLabelSize = 0;
for (let i = 0; i < count; i++) {
let labelSize = labels[i].box[sizeFn]();
if (labelSize + space <= boxSize) {
maxLabelSize = Math.max(maxLabelSize, labelSize);
}
}
if (vertical) {
this.box = new Box(
box.x1, box.y1,
box.x1 + maxLabelSize + space, box.y2
);
} else {
this.box = new Box(
box.x1, box.y1,
box.x2, box.y1 + maxLabelSize + space
);
}
this.arrangeTitle();
this.arrangeLabels();
this.arrangeNotes();
}
getLabelsTickPositions() {
return this.getMajorTickPositions();
}
labelTickIndex(label) {
return label.index;
}
arrangeLabels() {
const { options, labels } = this;
const labelsBetweenTicks = this.labelsBetweenTicks();
const vertical = options.vertical;
const mirror = options.labels.mirror;
const tickPositions = this.getLabelsTickPositions();
for (let idx = 0; idx < labels.length; idx++) {
const label = labels[idx];
const tickIx = this.labelTickIndex(label);
const labelSize = vertical ? label.box.height() : label.box.width();
const firstTickPosition = tickPositions[tickIx];
const nextTickPosition = tickPositions[tickIx + 1];
let positionStart, positionEnd;
if (vertical) {
if (labelsBetweenTicks) {
const middle = firstTickPosition + (nextTickPosition - firstTickPosition) / 2;
positionStart = middle - (labelSize / 2);
} else {
positionStart = firstTickPosition - (labelSize / 2);
}
positionEnd = positionStart;
} else {
if (labelsBetweenTicks) {
positionStart = firstTickPosition;
positionEnd = nextTickPosition;
} else {
positionStart = firstTickPosition - (labelSize / 2);
positionEnd = positionStart + labelSize;
}
}
this.positionLabel(label, mirror, positionStart, positionEnd);
}
}
positionLabel(label, mirror, positionStart, positionEnd = positionStart) {
const options = this.options;
const vertical = options.vertical;
const lineBox = this.lineBox();
const labelOffset = this.getActualTickSize() + options.margin;
let labelBox;
if (vertical) {
let labelX = lineBox.x2;
if (mirror) {
labelX += labelOffset;
label.options.rotationOrigin = LEFT;
} else {
labelX -= labelOffset + label.box.width();
label.options.rotationOrigin = RIGHT;
}
labelBox = label.box.move(labelX, positionStart);
} else {
let labelY = lineBox.y1;
if (mirror) {
labelY -= labelOffset + label.box.height();
label.options.rotationOrigin = BOTTOM;
} else {
labelY += labelOffset;
label.options.rotationOrigin = TOP;
}
labelBox = new Box(
positionStart, labelY,
positionEnd, labelY + label.box.height()
);
}
label.reflow(labelBox);
}
autoRotateLabelAngle(labelBox, slotWidth) {
if (labelBox.width() < slotWidth) {
return 0;
}
if (labelBox.height() > slotWidth) {
return -90;
}
return -45;
}
autoRotateLabels() {
if (!this.options.autoRotateLabels || this.options.vertical) {
return false;
}
const tickPositions = this.getMajorTickPositions();
const labels = this.labels;
const limit = Math.min(labels.length, tickPositions.length - 1);
let angle = 0;
for (let idx = 0; idx < limit; idx++) {
const width = Math.abs(tickPositions[idx + 1] - tickPositions[idx]);
const labelBox = labels[idx].box;
const labelAngle = this.autoRotateLabelAngle(labelBox, width);
if (labelAngle !== 0) {
angle = labelAngle;
}
if (angle === -90) {
break;
}
}
if (angle !== 0) {
for (let idx = 0; idx < labels.length; idx++) {
labels[idx].options.rotation = angle;
labels[idx].reflow(new Box());
}
return true;
}
}
arrangeTitle() {
const { options, title } = this;
const mirror = options.labels.mirror;
const vertical = options.vertical;
if (title) {
if (vertical) {
title.options.align = mirror ? RIGHT : LEFT;
title.options.vAlign = title.options.position;
} else {
title.options.align = title.options.position;
title.options.vAlign = mirror ? TOP : BOTTOM;
}
title.reflow(this.box);
}
}
arrangeNotes() {
for (let idx = 0; idx < this.notes.length; idx++) {
const item = this.notes[idx];
const value = item.options.value;
let slot;
if (defined(value)) {
if (this.shouldRenderNote(value)) {
item.show();
} else {
item.hide();
}
slot = this.noteSlot(value);
} else {
item.hide();
}
item.reflow(slot || this.lineBox());
}
}
noteSlot(value) {
return this.getSlot(value);
}
alignTo(secondAxis) {
const lineBox = secondAxis.lineBox();
const vertical = this.options.vertical;
const pos = vertical ? Y : X;
this.box.snapTo(lineBox, pos);
if (vertical) {
this.box.shrink(0, this.lineBox().height() - lineBox.height());
} else {
this.box.shrink(this.lineBox().width() - lineBox.width(), 0);
}
this.box[pos + 1] -= this.lineBox()[pos + 1] - lineBox[pos + 1];
this.box[pos + 2] -= this.lineBox()[pos + 2] - lineBox[pos + 2];
}
axisLabelText(value, options, context) {
let text;
const tmpl = getTemplate(options);
const defaultText = () => {
if (!options.format) {
return value;
}
return this.chartService.format.localeAuto(
options.format, [ value ], options.culture
);
};
if (tmpl) {
const templateContext = Object.assign({}, context, {
get text() { return defaultText(); },
value,
format: options.format,
culture: options.culture
});
text = tmpl(templateContext);
} else {
text = defaultText();
}
return text;
}
slot(from , to, limit) {
const slot = this.getSlot(from, to, limit);
if (slot) {
return slot.toRect();
}
}
contentBox() {
const box = this.box.clone();
const labels = this.labels;
if (labels.length) {
const axis = this.options.vertical ? Y : X;
if (this.chartService.isPannable(axis)) {
const offset = this.maxLabelOffset();
box[axis + 1] -= offset.start;
box[axis + 2] += offset.end;
} else {
if (labels[0].options.visible) {
box.wrap(labels[0].box);
}
const lastLabel = labels[labels.length - 1];
if (lastLabel.options.visible) {
box.wrap(lastLabel.box);
}
}
}
return box;
}
maxLabelOffset() {
const { vertical, reverse } = this.options;
const labelsBetweenTicks = this.labelsBetweenTicks();
const tickPositions = this.getLabelsTickPositions();
const offsetField = vertical ? Y : X;
const labels = this.labels;
const startPosition = reverse ? 1 : 0;
const endPosition = reverse ? 0 : 1;
let maxStartOffset = 0;
let maxEndOffset = 0;
for (let idx = 0; idx < labels.length; idx++) {
const label = labels[idx];
const tickIx = this.labelTickIndex(label);
let startTick, endTick;
if (labelsBetweenTicks) {
startTick = tickPositions[tickIx + startPosition];
endTick = tickPositions[tickIx + endPosition];
} else {
startTick = endTick = tickPositions[tickIx];
}
maxStartOffset = Math.max(maxStartOffset, startTick - label.box[offsetField + 1]);
maxEndOffset = Math.max(maxEndOffset, label.box[offsetField + 2] - endTick);
}
return {
start: maxStartOffset,
end: maxEndOffset
};
}
limitRange(from, to, min, max, offset) {
const options = this.options;
if ((from < min && offset < 0 && (!defined(options.min) || options.min <= min)) || (max < to && offset > 0 && (!defined(options.max) || max <= options.max))) {
return null;
}
if ((to < min && offset > 0) || (max < from && offset < 0)) {
return {
min: from,
max: to
};
}
const rangeSize = to - from;
let minValue = from;
let maxValue = to;
if (from < min && offset < 0) {
minValue = limitValue(from, min, max);
maxValue = limitValue(from + rangeSize, min + rangeSize, max);
} else if (to > max && offset > 0) {
maxValue = limitValue(to, min, max);
minValue = limitValue(to - rangeSize, min, max - rangeSize);
}
return {
min: minValue,
max: maxValue
};
}
valueRange() {
return {
min: this.seriesMin,
max: this.seriesMax
};
}
lineDir() {
/*
* Axis line direction:
* * Vertical: up.
* * Horizontal: right.
*/
const { vertical, reverse } = this.options;
return (vertical ? -1 : 1) * (reverse ? -1 : 1);
}
lineInfo() {
const { vertical } = this.options;
const lineBox = this.lineBox();
const lineSize = vertical ? lineBox.height() : lineBox.width();
const axis = vertical ? Y : X;
const axisDir = this.lineDir();
const startEdge = axisDir === 1 ? 1 : 2;
const axisOrigin = axis + startEdge.toString();
const lineStart = lineBox[axisOrigin];
return {
axis,
axisOrigin,
axisDir,
lineBox,
lineSize,
lineStart
};
}
pointOffset(point) {
const { axis, axisDir, axisOrigin, lineBox, lineSize } = this.lineInfo();
const relative = axisDir > 0 ? point[axis] - lineBox[axisOrigin] : lineBox[axisOrigin] - point[axis];
const offset = relative / lineSize;
return offset;
}
// Computes the axis range change (delta) for a given scale factor.
// The delta is subtracted from the axis range:
// * delta > 0 reduces the axis range (zoom-in)
// * delta < 0 expands the axis range (zoom-out)
scaleToDelta(rawScale, range) {
// Scale >= 1 would result in axis range of 0.
// Scale <= -1 would reverse the scale direction.
const MAX_SCALE = 0.999;
const scale = limitValue(rawScale, -MAX_SCALE, MAX_SCALE);
let delta;
if (scale > 0) {
delta = range * Math.min(1, scale);
} else {
delta = range - (range / (1 + scale));
}
return delta;
}
labelsBetweenTicks() {
return !this.options.justified;
}
//add legacy fields to the options that are no longer generated by default
prepareUserOptions() {
}
}
setDefaultOptions(Axis, {
labels: {
visible: true,
rotation: 0,
mirror: false,
step: 1,
skip: 0
},
line: {
width: 1,
color: BLACK,
visible: true
},
title: {
visible: true,
position: CENTER
},
majorTicks: {
align: OUTSIDE,
size: 4,
skip: 0,
step: 1
},
minorTicks: {
align: OUTSIDE,
size: 3,
skip: 0,
step: 1
},
axisCrossingValue: 0,
majorTickType: OUTSIDE,
minorTickType: NONE,
majorGridLines: {
skip: 0,
step: 1
},
minorGridLines: {
visible: false,
width: 1,
color: BLACK,
skip: 0,
step: 1
},
// TODO: Move to line or labels options
margin: 5,
visible: true,
reverse: false,
justified: true,
notes: {
label: {
text: ""
}
},
_alignLines: true,
_deferLabels: false
});
export default Axis;