UNPKG

@progress/kendo-charts

Version:

Kendo UI platform-independent Charts library

952 lines (759 loc) 28.7 kB
import CategoryAxis from './category-axis'; import AxisLabel from './axis-label'; import Box from './box'; import { CENTER, DATE, DEFAULT_PRECISION, MAX_VALUE, OBJECT, X, Y } from '../common/constants'; import { deepExtend, defined, inArray, last, limitValue, round, setDefaultOptions, sparseArrayLimits } from '../common'; import { MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS, TIME_PER_MINUTE, TIME_PER_HOUR, TIME_PER_DAY, TIME_PER_WEEK, TIME_PER_MONTH, TIME_PER_YEAR, TIME_PER_UNIT } from '../date-utils/constants'; import { dateComparer, toDate, addTicks, addDuration, dateDiff, absoluteDateDiff, dateIndex, dateEquals, toTime, parseDate, parseDates, firstDay } from '../date-utils'; import { DateLabelFormats } from './constants'; const AUTO = "auto"; const BASE_UNITS = [ MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS ]; const FIT = "fit"; function categoryRange(categories, clearCache) { if (clearCache) { categories._range = undefined; } let range = categories._range; if (!range) { range = categories._range = sparseArrayLimits(categories); range.min = toDate(range.min); range.max = toDate(range.max); } return range; } class EmptyDateRange { constructor(options) { this.options = options; } displayIndices() { return { min: 0, max: 1 }; } displayRange() { return {}; } total() { return {}; } valueRange() { return {}; } valueIndex() { return -1; } values() { return []; } totalIndex() { return -1; } valuesCount() { return 0; } totalCount() { return 0; } dateAt() { return null; } } class DateRange { constructor(start, end, options) { this.options = options; options.baseUnitStep = options.baseUnitStep || 1; const { roundToBaseUnit, justified } = options; this.start = addDuration(start, 0, options.baseUnit, options.weekStartDay); const lowerEnd = this.roundToTotalStep(end); const expandEnd = !justified && dateEquals(end, lowerEnd) && !options.justifyEnd; this.end = this.roundToTotalStep(end, !justified, expandEnd ? 1 : 0); const min = options.min || start; this.valueStart = this.roundToTotalStep(min); this.displayStart = roundToBaseUnit ? this.valueStart : min; const max = options.max; if (!max) { this.valueEnd = lowerEnd; this.displayEnd = roundToBaseUnit || expandEnd ? this.end : end; } else { const next = !justified && dateEquals(max, this.roundToTotalStep(max)) ? -1 : 0; this.valueEnd = this.roundToTotalStep(max, false, next); this.displayEnd = roundToBaseUnit ? this.roundToTotalStep(max, !justified) : options.max; } if (this.valueEnd < this.valueStart) { this.valueEnd = this.valueStart; } if (this.displayEnd <= this.displayStart) { this.displayEnd = this.roundToTotalStep(this.displayStart, false, 1); } } displayRange() { return { min: this.displayStart, max: this.displayEnd }; } displayIndices() { if (!this._indices) { const options = this.options; const { baseUnit, baseUnitStep } = options; const minIdx = dateIndex(this.displayStart, this.valueStart, baseUnit, baseUnitStep); const maxIdx = dateIndex(this.displayEnd, this.valueStart, baseUnit, baseUnitStep); this._indices = { min: minIdx, max: maxIdx }; } return this._indices; } total() { return { min: this.start, max: this.end }; } totalCount() { const last = this.totalIndex(this.end); return last + (this.options.justified ? 1 : 0); } valueRange() { return { min: this.valueStart, max: this.valueEnd }; } valueIndex(value) { const options = this.options; return Math.floor(dateIndex(value, this.valueStart, options.baseUnit, options.baseUnitStep)); } totalIndex(value) { const options = this.options; return Math.floor(dateIndex(value, this.start, options.baseUnit, options.baseUnitStep)); } dateIndex(value) { const options = this.options; return dateIndex(value, this.valueStart, options.baseUnit, options.baseUnitStep); } valuesCount() { const maxIdx = this.valueIndex(this.valueEnd); return maxIdx + 1; } values() { let values = this._values; if (!values) { const options = this.options; const range = this.valueRange(); this._values = values = []; for (let date = range.min; date <= range.max;) { values.push(date); date = addDuration(date, options.baseUnitStep, options.baseUnit, options.weekStartDay); } } return values; } dateAt(index, total) { const options = this.options; return addDuration(total ? this.start : this.valueStart, options.baseUnitStep * index, options.baseUnit, options.weekStartDay); } roundToTotalStep(value, upper, next) { const { baseUnit, baseUnitStep, weekStartDay } = this.options; const start = this.start; const step = dateIndex(value, start, baseUnit, baseUnitStep); let roundedStep = upper ? Math.ceil(step) : Math.floor(step); if (next) { roundedStep += next; } return addDuration(start, roundedStep * baseUnitStep, baseUnit, weekStartDay); } } function autoBaseUnit(options, startUnit, startStep) { const categoryLimits = categoryRange(options.categories); const span = (options.max || categoryLimits.max) - (options.min || categoryLimits.min); const { autoBaseUnitSteps, maxDateGroups } = options; const autoUnit = options.baseUnit === FIT; let autoUnitIx = startUnit ? BASE_UNITS.indexOf(startUnit) : 0; let baseUnit = autoUnit ? BASE_UNITS[autoUnitIx++] : options.baseUnit; let units = span / TIME_PER_UNIT[baseUnit]; let totalUnits = units; let unitSteps, step, nextStep; while (!step || units >= maxDateGroups) { unitSteps = unitSteps || autoBaseUnitSteps[baseUnit].slice(0); do { nextStep = unitSteps.shift(); } while (nextStep && startUnit === baseUnit && nextStep < startStep); if (nextStep) { step = nextStep; units = totalUnits / step; } else if (baseUnit === last(BASE_UNITS)) { step = Math.ceil(totalUnits / maxDateGroups); break; } else if (autoUnit) { baseUnit = BASE_UNITS[autoUnitIx++] || last(BASE_UNITS); totalUnits = span / TIME_PER_UNIT[baseUnit]; unitSteps = null; } else { if (units > maxDateGroups) { step = Math.ceil(totalUnits / maxDateGroups); } break; } } options.baseUnitStep = step; options.baseUnit = baseUnit; } function defaultBaseUnit(options) { const categories = options.categories; const count = defined(categories) ? categories.length : 0; let minDiff = MAX_VALUE; let lastCategory, unit; for (let categoryIx = 0; categoryIx < count; categoryIx++) { const category = categories[categoryIx]; if (category && lastCategory) { let diff = Math.abs(absoluteDateDiff(category, lastCategory)); if (diff !== 0) { minDiff = Math.min(minDiff, diff); if (minDiff >= TIME_PER_YEAR) { unit = YEARS; } else if (minDiff >= TIME_PER_MONTH - TIME_PER_DAY * 3) { unit = MONTHS; } else if (minDiff >= TIME_PER_WEEK) { unit = WEEKS; } else if (minDiff >= TIME_PER_DAY) { unit = DAYS; } else if (minDiff >= TIME_PER_HOUR) { unit = HOURS; } else if (minDiff >= TIME_PER_MINUTE) { unit = MINUTES; } else { unit = SECONDS; } } } lastCategory = category; } options.baseUnit = unit || DAYS; } function initUnit(options) { const baseUnit = (options.baseUnit || "").toLowerCase(); const useDefault = baseUnit !== FIT && !inArray(baseUnit, BASE_UNITS); if (useDefault) { defaultBaseUnit(options); } if (baseUnit === FIT || options.baseUnitStep === AUTO) { autoBaseUnit(options); } return options; } class DateCategoryAxis extends CategoryAxis { clone() { const copy = new DateCategoryAxis(Object.assign({}, this.options), this.chartService); copy.createLabels(); return copy; } categoriesHash() { const start = this.dataRange.total().min; return this.options.baseUnit + this.options.baseUnitStep + start; } initUserOptions(options) { return options; } initFields() { super.initFields(); const chartService = this.chartService; const intlService = chartService.intl; let options = this.options; let categories = options.categories || []; if (!categories._parsed) { categories = parseDates(intlService, categories); categories._parsed = true; } options = deepExtend({ roundToBaseUnit: true }, options, { categories: categories, min: parseDate(intlService, options.min), max: parseDate(intlService, options.max), weekStartDay: firstDay(options, intlService) }); if (chartService.panning && chartService.isPannable(options.vertical ? Y : X)) { options.roundToBaseUnit = false; } options.userSetBaseUnit = options.userSetBaseUnit || options.baseUnit; options.userSetBaseUnitStep = options.userSetBaseUnitStep || options.baseUnitStep; this.options = options; options.srcCategories = categories; if (categories.length > 0) { const range = categoryRange(categories, true); const maxDivisions = options.maxDivisions; const safeOptions = initUnit(options); const forecast = options._forecast; if (forecast) { if (forecast.before > 0) { range.min = addDuration(range.min, -forecast.before, safeOptions.baseUnit, safeOptions.weekStartDay); } if (forecast.after > 0) { range.max = addDuration(range.max, forecast.after, safeOptions.baseUnit, safeOptions.weekStartDay); } } this.dataRange = new DateRange(range.min, range.max, safeOptions); if (maxDivisions) { const dataRange = this.dataRange.displayRange(); const divisionOptions = Object.assign({}, options, { justified: true, roundToBaseUnit: false, baseUnit: 'fit', min: dataRange.min, max: dataRange.max, maxDateGroups: maxDivisions }); const dataRangeOptions = this.dataRange.options; autoBaseUnit(divisionOptions, dataRangeOptions.baseUnit, dataRangeOptions.baseUnitStep); this.divisionRange = new DateRange(range.min, range.max, divisionOptions); } else { this.divisionRange = this.dataRange; } } else { options.baseUnit = options.baseUnit || DAYS; this.dataRange = this.divisionRange = new EmptyDateRange(options); } this.rangeLabels = []; } tickIndices(stepSize) { const { dataRange, divisionRange } = this; const valuesCount = divisionRange.valuesCount(); if (!this.options.maxDivisions || !valuesCount) { return super.tickIndices(stepSize); } const indices = []; let values = divisionRange.values(); let offset = 0; if (!this.options.justified) { values = values.concat(divisionRange.dateAt(valuesCount)); offset = 0.5;//align ticks to the center of not justified categories } for (let idx = 0; idx < values.length; idx++) { indices.push(dataRange.dateIndex(values[idx]) + offset); if (stepSize !== 1 && idx >= 1) { const last = indices.length - 1; indices.splice(idx, 0, indices[last - 1] + (indices[last] - indices[last - 1]) * stepSize); } } return indices; } shouldRenderNote(value) { const range = this.range(); const categories = this.options.categories || []; return dateComparer(value, range.min) >= 0 && dateComparer(value, range.max) <= 0 && categories.length; } parseNoteValue(value) { return parseDate(this.chartService.intl, value); } noteSlot(value) { return this.getSlot(value); } translateRange(delta) { const options = this.options; const { baseUnit, weekStartDay, vertical } = options; const lineBox = this.lineBox(); const size = vertical ? lineBox.height() : lineBox.width(); let range = this.range(); const scale = size / (range.max - range.min); const offset = round(delta / scale, DEFAULT_PRECISION); if (range.min && range.max) { const from = addTicks(options.min || range.min, offset); const to = addTicks(options.max || range.max, offset); range = { min: addDuration(from, 0, baseUnit, weekStartDay), max: addDuration(to, 0, baseUnit, weekStartDay) }; } return range; } labelsRange() { return { min: this.options.labels.skip, max: this.divisionRange.valuesCount() }; } pan(delta) { if (this.isEmpty()) { return null; } const options = this.options; const lineBox = this.lineBox(); const size = options.vertical ? lineBox.height() : lineBox.width(); const { min, max } = this.dataRange.displayRange(); const totalLimits = this.dataRange.total(); const scale = size / (max - min); const offset = round(delta / scale, DEFAULT_PRECISION) * (options.reverse ? -1 : 1); const from = addTicks(min, offset); const to = addTicks(max, offset); const panRange = this.limitRange(toTime(from), toTime(to), toTime(totalLimits.min), toTime(totalLimits.max), offset); if (panRange) { panRange.min = toDate(panRange.min); panRange.max = toDate(panRange.max); panRange.baseUnit = options.baseUnit; panRange.baseUnitStep = options.baseUnitStep || 1; panRange.userSetBaseUnit = options.userSetBaseUnit; panRange.userSetBaseUnitStep = options.userSetBaseUnitStep; return panRange; } } pointsRange(start, end) { if (this.isEmpty()) { return null; } const pointsRange = super.pointsRange(start, end); const datesRange = this.dataRange.displayRange(); const indicesRange = this.dataRange.displayIndices(); const scale = dateDiff(datesRange.max, datesRange.min) / (indicesRange.max - indicesRange.min); const options = this.options; const min = addTicks(datesRange.min, pointsRange.min * scale); const max = addTicks(datesRange.min, pointsRange.max * scale); return { min: min, max: max, baseUnit: options.userSetBaseUnit || options.baseUnit, baseUnitStep: options.userSetBaseUnitStep || options.baseUnitStep }; } scaleRange(scale, cursor) { if (this.isEmpty()) { return {}; } const options = this.options; const fit = options.userSetBaseUnit === FIT; const totalLimits = this.dataRange.total(); const { min: rangeMin, max: rangeMax } = this.dataRange.displayRange(); const position = Math.abs(this.pointOffset(cursor)); const range = rangeMax - rangeMin; const delta = this.scaleToDelta(scale, range); const minDelta = Math.round(position * delta); const maxDelta = Math.round((1 - position) * delta); let { baseUnit } = this.dataRange.options; let min = new Date(rangeMin.getTime() + minDelta); let max = new Date(rangeMax.getTime() - maxDelta); if (fit) { const { autoBaseUnitSteps, maxDateGroups } = options; const maxDiff = last(autoBaseUnitSteps[baseUnit]) * maxDateGroups * TIME_PER_UNIT[baseUnit]; const rangeDiff = dateDiff(rangeMax, rangeMin); const diff = dateDiff(max, min); let baseUnitIndex = BASE_UNITS.indexOf(baseUnit); let autoBaseUnitStep, ticks; if (diff < TIME_PER_UNIT[baseUnit] && baseUnit !== MILLISECONDS) { baseUnit = BASE_UNITS[baseUnitIndex - 1]; autoBaseUnitStep = last(autoBaseUnitSteps[baseUnit]); ticks = (rangeDiff - (maxDateGroups - 1) * autoBaseUnitStep * TIME_PER_UNIT[baseUnit]) / 2; min = addTicks(rangeMin, ticks); max = addTicks(rangeMax, -ticks); } else if (diff > maxDiff && baseUnit !== YEARS) { let stepIndex = 0; do { baseUnitIndex++; baseUnit = BASE_UNITS[baseUnitIndex]; stepIndex = 0; ticks = 2 * TIME_PER_UNIT[baseUnit]; do { autoBaseUnitStep = autoBaseUnitSteps[baseUnit][stepIndex]; stepIndex++; } while (stepIndex < autoBaseUnitSteps[baseUnit].length && ticks * autoBaseUnitStep < rangeDiff); } while (baseUnit !== YEARS && ticks * autoBaseUnitStep < rangeDiff); ticks = (ticks * autoBaseUnitStep - rangeDiff) / 2; if (ticks > 0) { min = addTicks(rangeMin, -ticks); max = addTicks(rangeMax, ticks); min = addTicks(min, limitValue(max, totalLimits.min, totalLimits.max) - max); max = addTicks(max, limitValue(min, totalLimits.min, totalLimits.max) - min); } } } if (min && max && dateDiff(max, min) > 0) { return { min: min, max: max, baseUnit: options.userSetBaseUnit || options.baseUnit, baseUnitStep: options.userSetBaseUnitStep || options.baseUnitStep }; } } zoomRange(scale, cursor) { const totalLimits = this.dataRange.total(); const range = this.scaleRange(scale, cursor); if (range) { if (range.min < totalLimits.min) { range.min = totalLimits.min; } if (range.max > totalLimits.max) { range.max = totalLimits.max; } } return range; } range() { return this.dataRange.displayRange(); } createLabels() { super.createLabels(); this.createRangeLabels(); } clearLabels() { super.clearLabels(); this.rangeLabels = []; } arrangeLabels() { this.arrangeRangeLabels(); super.arrangeLabels(); } arrangeRangeLabels() { const { options, rangeLabels } = this; if (rangeLabels.length === 0) { return; } const lineBox = this.lineBox(); const vertical = options.vertical; const mirror = options.rangeLabels.mirror || options.labels.mirror; const firstLabel = rangeLabels[0]; if (firstLabel) { const position = vertical ? lineBox.y1 - (firstLabel.box.height() / 2) : lineBox.x1; this.positionLabel(firstLabel, mirror, position); } const lastLabel = rangeLabels[1]; if (lastLabel) { const position = vertical ? lineBox.y2 - (lastLabel.box.height() / 2) : lineBox.x2; this.positionLabel(lastLabel, mirror, position); } } autoRotateLabels() { super.autoRotateLabels(); this.autoRotateRangeLabels(); } hideOutOfRangeLabels() { super.hideOutOfRangeLabels(); this.hideOverlappingLabels(); } hideOverlappingLabels() { const { rangeLabels, labels } = this; if (rangeLabels.length === 0) { return; } function clip(rangeLabel, label) { if (!label.options.visible || label.box.overlaps(rangeLabel.box)) { label.options.visible = false; return true; } return false; } const firstRangeLabel = rangeLabels[0]; if (firstRangeLabel && firstRangeLabel.options.visible) { for (let i = 0; i < labels.length; i++) { const overlaps = clip(firstRangeLabel, labels[i]); if (!overlaps) { break; } } } const lastRangeLabel = rangeLabels[1]; if (lastRangeLabel && lastRangeLabel.options.visible) { for (let i = labels.length - 1; i > 0; --i) { const overlaps = clip(lastRangeLabel, labels[i]); if (!overlaps) { break; } } } } contentBox() { const box = super.contentBox(); const rangeLabels = this.rangeLabels; for (let i = 0; i < rangeLabels.length; i++) { const label = rangeLabels[i]; if (label.options.visible) { box.wrap(label.box); } } return box; } createAxisLabel(index, labelOptions, labelContext = {}) { const options = this.options; const dataItem = options.dataItems && !options.maxDivisions ? options.dataItems[index] : null; const date = this.divisionRange.dateAt(index); const unitFormat = labelOptions.dateFormats[this.divisionRange.options.baseUnit]; labelOptions.format = labelOptions.format || unitFormat; labelContext.dataItem = dataItem; const text = this.axisLabelText(date, labelOptions, labelContext); if (text) { return new AxisLabel(date, text, index, dataItem, labelOptions); } } createRangeLabels() { const { displayStart, displayEnd } = this.divisionRange; const options = this.options; const labelOptions = Object.assign({}, options.labels, options.rangeLabels, { align: CENTER, zIndex: options.zIndex }); if (labelOptions.visible !== true) { return; } this.normalizeLabelRotation(labelOptions); labelOptions.alignRotation = CENTER; if (labelOptions.rotation === "auto") { labelOptions.rotation = 0; options.autoRotateRangeLabels = true; } const unitFormat = labelOptions.dateFormats[this.divisionRange.options.baseUnit]; labelOptions.format = labelOptions.format || unitFormat; const createLabel = (index, date, text) => { if (text) { const label = new AxisLabel(date, text, index, null, labelOptions); this.append(label); this.rangeLabels.push(label); } }; const startText = this.axisLabelText(displayStart, labelOptions, { index: 0, count: 2 }); createLabel(0, displayStart, startText); const endText = this.axisLabelText(displayEnd, labelOptions, { index: 1, count: 2 }); createLabel(1, displayEnd, endText); } autoRotateRangeLabels() { const labels = this.rangeLabels; if (!this.options.autoRotateRangeLabels || this.options.vertical || labels.length !== 2) { return; } const rotateLabel = (label, tickPositions, index) => { const width = Math.abs(tickPositions[index + 1] - tickPositions[index]) * 2; const angle = this.autoRotateLabelAngle(label.box, width); if (angle !== 0) { label.options.rotation = angle; label.reflow(new Box()); } }; const tickPositions = this.getMajorTickPositions(); rotateLabel(labels[0], tickPositions, 0); rotateLabel(labels[1], tickPositions, tickPositions.length - 2); } categoryIndex(value) { return this.dataRange.valueIndex(value); } slot(from, to, limit) { const dateRange = this.dataRange; let start = from; let end = to; if (start instanceof Date) { start = dateRange.dateIndex(start); } if (end instanceof Date) { end = dateRange.dateIndex(end); } const slot = this.getSlot(start, end, limit); if (slot) { return slot.toRect(); } } getSlot(a, b, limit) { let start = a; let end = b; if (typeof start === OBJECT) { start = this.categoryIndex(start); } if (typeof end === OBJECT) { end = this.categoryIndex(end); } return super.getSlot(start, end, limit); } valueRange() { const options = this.options; const range = categoryRange(options.srcCategories); return { min: toDate(range.min), max: toDate(range.max) }; } categoryAt(index, total) { return this.dataRange.dateAt(index, total); } categoriesCount() { return this.dataRange.valuesCount(); } rangeIndices() { return this.dataRange.displayIndices(); } labelsBetweenTicks() { return !this.divisionRange.options.justified; } prepareUserOptions() { if (this.isEmpty()) { return; } this.options.categories = this.dataRange.values(); } getCategory(point) { const index = this.pointCategoryIndex(point); if (index === null) { return null; } return this.dataRange.dateAt(index); } totalIndex(value) { return this.dataRange.totalIndex(value); } currentRangeIndices() { const range = this.dataRange.valueRange(); return { min: this.dataRange.totalIndex(range.min), max: this.dataRange.totalIndex(range.max) }; } totalRange() { return this.dataRange.total(); } totalRangeIndices() { const range = this.dataRange.total(); return { min: this.dataRange.totalIndex(range.min), max: this.dataRange.totalIndex(range.max) }; } totalCount() { return this.dataRange.totalCount(); } isEmpty() { return !this.options.srcCategories.length; } roundedRange() { if (this.options.roundToBaseUnit !== false || this.isEmpty()) { return this.range(); } const options = this.options; const datesRange = categoryRange(options.srcCategories); const dateRange = new DateRange(datesRange.min, datesRange.max, Object.assign({}, options, { justified: false, roundToBaseUnit: true, justifyEnd: false })); return dateRange.displayRange(); } } setDefaultOptions(DateCategoryAxis, { type: DATE, labels: { dateFormats: DateLabelFormats }, rangeLabels: { visible: false }, autoBaseUnitSteps: { milliseconds: [ 1, 10, 100 ], seconds: [ 1, 2, 5, 15, 30 ], minutes: [ 1, 2, 5, 15, 30 ], hours: [ 1, 2, 3 ], days: [ 1, 2, 3 ], weeks: [ 1, 2 ], months: [ 1, 2, 3, 6 ], years: [ 1, 2, 3, 5, 10, 25, 50 ] }, maxDateGroups: 10 }); export default DateCategoryAxis;