UNPKG

ag-charts-community

Version:

Advanced Charting / Charts supporting Javascript / Typescript / React / Angular / Vue

557 lines 23.5 kB
import { Group } from "./scene/group"; import { Selection } from "./scene/selection"; import { Line } from "./scene/shape/line"; import { Text } from "./scene/shape/text"; import { Arc } from "./scene/shape/arc"; import { BBox } from "./scene/bbox"; import { Matrix } from "./scene/matrix"; import { createId } from "./util/id"; import { normalizeAngle360, normalizeAngle360Inclusive, toRadians } from "./util/angle"; // import { Rect } from "./scene/shape/rect"; // debug (bbox) var Tags; (function (Tags) { Tags[Tags["Tick"] = 0] = "Tick"; Tags[Tags["GridLine"] = 1] = "GridLine"; })(Tags || (Tags = {})); export class AxisTick { constructor() { /** * The line width to be used by axis ticks. */ this.width = 1; /** * The line length to be used by axis ticks. */ this.size = 6; /** * The color of the axis ticks. * Use `undefined` rather than `rgba(0, 0, 0, 0)` to make the ticks invisible. */ this.color = 'rgba(195, 195, 195, 1)'; /** * A hint of how many ticks to use (the exact number of ticks might differ), * a `TimeInterval` or a `CountableTimeInterval`. * For example: * * axis.tick.count = 5; * axis.tick.count = year; * axis.tick.count = month.every(6); */ this.count = 10; } } export class AxisLabel { constructor() { this.fontStyle = undefined; this.fontWeight = undefined; this.fontSize = 12; this.fontFamily = 'Verdana, sans-serif'; /** * The padding between the labels and the ticks. */ this.padding = 5; /** * The color of the labels. * Use `undefined` rather than `rgba(0, 0, 0, 0)` to make labels invisible. */ this.color = 'rgba(87, 87, 87, 1)'; /** * Custom label rotation in degrees. * Labels are rendered perpendicular to the axis line by default. * Or parallel to the axis line, if the {@link parallel} is set to `true`. * The value of this config is used as the angular offset/deflection * from the default rotation. */ this.rotation = 0; /** * By default labels and ticks are positioned to the left of the axis line. * `true` positions the labels to the right of the axis line. * However, if the axis is rotated, its easier to think in terms * of this side or the opposite side, rather than left and right. * We use the term `mirror` for conciseness, although it's not * true mirroring - for example, when a label is rotated, so that * it is inclined at the 45 degree angle, text flowing from north-west * to south-east, ending at the tick to the left of the axis line, * and then we set this config to `true`, the text will still be flowing * from north-west to south-east, _starting_ at the tick to the right * of the axis line. */ this.mirrored = false; /** * Labels are rendered perpendicular to the axis line by default. * Setting this config to `true` makes labels render parallel to the axis line * and center aligns labels' text at the ticks. */ this.parallel = false; /** * In case {@param value} is a number, the {@param fractionDigits} parameter will * be provided as well. The `fractionDigits` corresponds to the number of fraction * digits used by the tick step. For example, if the tick step is `0.0005`, * the `fractionDigits` is 4. */ this.formatter = undefined; this.onFormatChange = undefined; } set format(value) { // See `TimeLocaleObject` docs for the list of supported format directives. if (this._format !== value) { this._format = value; if (this.onFormatChange) { this.onFormatChange(value); } } } get format() { return this._format; } } /** * A general purpose linear axis with no notion of orientation. * The axis is always rendered vertically, with horizontal labels positioned to the left * of the axis line by default. The axis can be {@link rotation | rotated} by an arbitrary angle, * so that it can be used as a top, right, bottom, left, radial or any other kind * of linear axis. * The generic `D` parameter is the type of the domain of the axis' scale. * The output range of the axis' scale is always numeric (screen coordinates). */ export class Axis { constructor(scale) { // debug (bbox) // private bboxRect = (() => { // const rect = new Rect(); // rect.fill = undefined; // rect.stroke = 'red'; // rect.strokeWidth = 1; // rect.strokeOpacity = 0.2; // return rect; // })(); this.id = createId(this); this.lineNode = new Line(); this.group = new Group(); this.line = { width: 1, color: 'rgba(195, 195, 195, 1)' }; this.tick = new AxisTick(); this.label = new AxisLabel(); this.translation = { x: 0, y: 0 }; this.rotation = 0; // axis rotation angle in degrees this.requestedRange = [0, 1]; this._visibleRange = [0, 1]; this._title = undefined; /** * The length of the grid. The grid is only visible in case of a non-zero value. * In case {@link radialGrid} is `true`, the value is interpreted as an angle * (in degrees). */ this._gridLength = 0; /** * The array of styles to cycle through when rendering grid lines. * For example, use two {@link GridStyle} objects for alternating styles. * Contains only one {@link GridStyle} object by default, meaning all grid lines * have the same style. */ this.gridStyle = [{ stroke: 'rgba(219, 219, 219, 1)', lineDash: [4, 2] }]; /** * `false` - render grid as lines of {@link gridLength} that extend the ticks * on the opposite side of the axis * `true` - render grid as concentric circles that go through the ticks */ this._radialGrid = false; this.fractionDigits = 0; this.thickness = 0; this.scale = scale; this.groupSelection = Selection.select(this.group).selectAll(); this.label.onFormatChange = this.onLabelFormatChange.bind(this); this.group.append(this.lineNode); // this.group.append(this.bboxRect); // debug (bbox) } set scale(value) { this._scale = value; this.requestedRange = value.range.slice(); this.onLabelFormatChange(); } get scale() { return this._scale; } set ticks(values) { this._ticks = values; } get ticks() { return this._ticks; } /** * Meant to be overridden in subclasses to provide extra context the the label formatter. * The return value of this function will be passed to the laber.formatter as the `axis` parameter. */ getMeta() { } updateRange() { const { requestedRange: rr, visibleRange: vr, scale } = this; const span = (rr[1] - rr[0]) / (vr[1] - vr[0]); const shift = span * vr[0]; const start = rr[0] - shift; scale.range = [start, start + span]; } /** * Checks if a point or an object is in range. * @param x A point (or object's starting point). * @param width Object's width. * @param tolerance Expands the range on both ends by this amount. */ inRange(x, width = 0, tolerance = 0) { return this.inRangeEx(x, width, tolerance) === 0; } inRangeEx(x, width = 0, tolerance = 0) { const { range } = this; // Account for inverted ranges, for example [500, 100] as well as [100, 500] const min = Math.min(range[0], range[1]); const max = Math.max(range[0], range[1]); if ((x + width) < (min - tolerance)) { return -1; // left of range } if (x > (max + tolerance)) { return 1; // right of range } return 0; // in range } set range(value) { this.requestedRange = value.slice(); this.updateRange(); } get range() { return this.requestedRange.slice(); } set visibleRange(value) { if (value && value.length === 2) { let [min, max] = value; min = Math.max(0, min); max = Math.min(1, max); min = Math.min(min, max); max = Math.max(min, max); this._visibleRange = [min, max]; this.updateRange(); } } get visibleRange() { return this._visibleRange.slice(); } set domain(value) { this.scale.domain = value.slice(); this.onLabelFormatChange(this.label.format); } get domain() { return this.scale.domain.slice(); } onLabelFormatChange(format) { if (format && this.scale && this.scale.tickFormat) { try { this.labelFormatter = this.scale.tickFormat(this.tick.count, format); } catch (e) { this.labelFormatter = undefined; console.error(e); } } else { this.labelFormatter = undefined; } } set title(value) { const oldTitle = this._title; if (oldTitle !== value) { if (oldTitle) { this.group.removeChild(oldTitle.node); } if (value) { value.node.rotation = -Math.PI / 2; this.group.appendChild(value.node); } this._title = value; // position title so that it doesn't briefly get rendered in the top left hand corner of the canvas before update is called. this.positionTitle(); } } get title() { return this._title; } set gridLength(value) { // Was visible and now invisible, or was invisible and now visible. if (this._gridLength && !value || !this._gridLength && value) { this.groupSelection = this.groupSelection.remove().setData([]); } this._gridLength = value; } get gridLength() { return this._gridLength; } set radialGrid(value) { if (this._radialGrid !== value) { this._radialGrid = value; this.groupSelection = this.groupSelection.remove().setData([]); } } get radialGrid() { return this._radialGrid; } /** * Creates/removes/updates the scene graph nodes that constitute the axis. * Supposed to be called _manually_ after changing _any_ of the axis properties. * This allows to bulk set axis properties before updating the nodes. * The node changes made by this method are rendered on the next animation frame. * We could schedule this method call automatically on the next animation frame * when any of the axis properties change (the way we do when properties of scene graph's * nodes change), but this will mean that we first wait for the next animation * frame to make changes to the nodes of the axis, then wait for another animation * frame to render those changes. It's nice to have everything update automatically, * but this extra level of async indirection will not just introduce an unwanted delay, * it will also make it harder to reason about the program. */ update() { const { group, scale, tick, label, gridStyle, requestedRange } = this; const requestedRangeMin = Math.min(requestedRange[0], requestedRange[1]); const requestedRangeMax = Math.max(requestedRange[0], requestedRange[1]); const rotation = toRadians(this.rotation); const parallelLabels = label.parallel; const labelRotation = normalizeAngle360(toRadians(label.rotation)); group.translationX = this.translation.x; group.translationY = this.translation.y; group.rotation = rotation; const halfBandwidth = (scale.bandwidth || 0) / 2; // The side of the axis line to position the labels on. // -1 = left (default) // 1 = right const sideFlag = label.mirrored ? 1 : -1; // When labels are parallel to the axis line, the `parallelFlipFlag` is used to // flip the labels to avoid upside-down text, when the axis is rotated // such that it is in the right hemisphere, i.e. the angle of rotation // is in the [0, π] interval. // The rotation angle is normalized, so that we have an easier time checking // if it's in the said interval. Since the axis is always rendered vertically // and then rotated, zero rotation means 12 (not 3) o-clock. // -1 = flip // 1 = don't flip (default) const parallelFlipRotation = normalizeAngle360(rotation); const parallelFlipFlag = !labelRotation && parallelFlipRotation >= 0 && parallelFlipRotation <= Math.PI ? -1 : 1; const regularFlipRotation = normalizeAngle360(rotation - Math.PI / 2); // Flip if the axis rotation angle is in the top hemisphere. const regularFlipFlag = !labelRotation && regularFlipRotation >= 0 && regularFlipRotation <= Math.PI ? -1 : 1; const alignFlag = labelRotation >= 0 && labelRotation <= Math.PI ? -1 : 1; const ticks = this.ticks || scale.ticks(this.tick.count); const update = this.groupSelection.setData(ticks); update.exit.remove(); const enter = update.enter.append(Group); // Line auto-snaps to pixel grid if vertical or horizontal. enter.append(Line).each(node => node.tag = Tags.Tick); if (this.gridLength) { if (this.radialGrid) { enter.append(Arc).each(node => node.tag = Tags.GridLine); } else { enter.append(Line).each(node => node.tag = Tags.GridLine); } } enter.append(Text); const groupSelection = update.merge(enter); groupSelection .attrFn('translationY', function (_, datum) { return Math.round(scale.convert(datum) + halfBandwidth); }) .attrFn('visible', function (node) { const min = Math.floor(requestedRangeMin); const max = Math.ceil(requestedRangeMax); return node.translationY >= min && node.translationY <= max; }); groupSelection.selectByTag(Tags.Tick) .each(line => { line.strokeWidth = tick.width; line.stroke = tick.color; }) .attr('x1', sideFlag * tick.size) .attr('x2', 0) .attr('y1', 0) .attr('y2', 0); if (this.gridLength && gridStyle.length) { const styleCount = gridStyle.length; let gridLines; if (this.radialGrid) { const angularGridLength = normalizeAngle360Inclusive(toRadians(this.gridLength)); gridLines = groupSelection.selectByTag(Tags.GridLine) .each((arc, datum) => { const radius = Math.round(scale.convert(datum) + halfBandwidth); arc.centerX = 0; arc.centerY = scale.range[0] - radius; arc.endAngle = angularGridLength; arc.radiusX = radius; arc.radiusY = radius; }); } else { gridLines = groupSelection.selectByTag(Tags.GridLine) .each(line => { line.x1 = 0; line.x2 = -sideFlag * this.gridLength; line.y1 = 0; line.y2 = 0; line.visible = Math.abs(line.parent.translationY - scale.range[0]) > 1; }); } gridLines.each((gridLine, _, index) => { const style = gridStyle[index % styleCount]; gridLine.stroke = style.stroke; gridLine.strokeWidth = tick.width; gridLine.lineDash = style.lineDash; gridLine.fill = undefined; }); } // `ticks instanceof NumericTicks` doesn't work here, so we feature detect. this.fractionDigits = ticks.fractionDigits >= 0 ? ticks.fractionDigits : 0; const labelSelection = groupSelection.selectByClass(Text) .each((node, datum, index) => { node.fontStyle = label.fontStyle; node.fontWeight = label.fontWeight; node.fontSize = label.fontSize; node.fontFamily = label.fontFamily; node.fill = label.color; node.textBaseline = parallelLabels && !labelRotation ? (sideFlag * parallelFlipFlag === -1 ? 'hanging' : 'bottom') : 'middle'; node.text = this.formatTickDatum(datum, index); node.textAlign = parallelLabels ? labelRotation ? (sideFlag * alignFlag === -1 ? 'end' : 'start') : 'center' : sideFlag * regularFlipFlag === -1 ? 'end' : 'start'; }); const labelX = sideFlag * (tick.size + label.padding); const autoRotation = parallelLabels ? parallelFlipFlag * Math.PI / 2 : (regularFlipFlag === -1 ? Math.PI : 0); labelSelection.each(label => { label.x = labelX; label.rotationCenterX = labelX; label.rotation = autoRotation + labelRotation; }); this.groupSelection = groupSelection; // Render axis line. const lineNode = this.lineNode; lineNode.x1 = 0; lineNode.x2 = 0; lineNode.y1 = requestedRange[0]; lineNode.y2 = requestedRange[1]; lineNode.strokeWidth = this.line.width; lineNode.stroke = this.line.color; lineNode.visible = ticks.length > 0; this.positionTitle(); // debug (bbox) // const bbox = this.computeBBox(); // const bboxRect = this.bboxRect; // bboxRect.x = bbox.x; // bboxRect.y = bbox.y; // bboxRect.width = bbox.width; // bboxRect.height = bbox.height; } positionTitle() { const { title, lineNode } = this; if (!title) { return; } let titleVisible = false; if (title.enabled && lineNode.visible) { titleVisible = true; const { label, rotation, requestedRange } = this; const sideFlag = label.mirrored ? 1 : -1; const parallelFlipRotation = normalizeAngle360(rotation); const padding = title.padding.bottom; const titleNode = title.node; const bbox = this.computeBBox({ excludeTitle: true }); const titleRotationFlag = sideFlag === -1 && parallelFlipRotation > Math.PI && parallelFlipRotation < Math.PI * 2 ? -1 : 1; titleNode.rotation = titleRotationFlag * sideFlag * Math.PI / 2; // titleNode.x = titleRotationFlag * sideFlag * (lineNode.y1 + lineNode.y2) / 2; // TODO: remove? titleNode.x = titleRotationFlag * sideFlag * (requestedRange[0] + requestedRange[1]) / 2; if (sideFlag === -1) { titleNode.y = titleRotationFlag * (-padding - bbox.width + Math.max(bbox.x + bbox.width, 0)); } else { titleNode.y = -padding - bbox.width - Math.min(bbox.x, 0); } titleNode.textBaseline = titleRotationFlag === 1 ? 'bottom' : 'top'; } title.node.visible = titleVisible; } // For formatting (nice rounded) tick values. formatTickDatum(datum, index) { const { label, labelFormatter, fractionDigits } = this; const meta = this.getMeta(); return label.formatter ? label.formatter({ value: fractionDigits >= 0 ? datum : String(datum), index, fractionDigits, formatter: labelFormatter, axis: meta }) : labelFormatter ? labelFormatter(datum) : typeof datum === 'number' && fractionDigits >= 0 // the `datum` is a floating point number ? datum.toFixed(fractionDigits) // the`datum` is an integer, a string or an object : String(datum); } // For formatting arbitrary values between the ticks. formatDatum(datum) { return String(datum); } computeBBox(options) { const { title, lineNode } = this; const labels = this.groupSelection.selectByClass(Text); let left = Infinity; let right = -Infinity; let top = Infinity; let bottom = -Infinity; labels.each(label => { // The label itself is rotated, but not translated, the group that // contains it is. So to capture the group transform in the label bbox // calculation we combine the transform matrices of the label and the group. // Depending on the timing of the `axis.computeBBox()` method call, we may // not have the group's and the label's transform matrices updated yet (because // the transform matrix is not recalculated whenever a node's transform attributes // change, instead it's marked for recalculation on the next frame by setting // the node's `dirtyTransform` flag to `true`), so we force them to update // right here by calling `computeTransformMatrix`. label.computeTransformMatrix(); const matrix = Matrix.flyweight(label.matrix); const group = label.parent; group.computeTransformMatrix(); matrix.preMultiplySelf(group.matrix); const labelBBox = label.computeBBox(); if (labelBBox) { const bbox = matrix.transformBBox(labelBBox); left = Math.min(left, bbox.x); right = Math.max(right, bbox.x + bbox.width); top = Math.min(top, bbox.y); bottom = Math.max(bottom, bbox.y + bbox.height); } }); if (title && title.enabled && lineNode.visible && (!options || !options.excludeTitle)) { const label = title.node; label.computeTransformMatrix(); const matrix = Matrix.flyweight(label.matrix); const labelBBox = label.computeBBox(); if (labelBBox) { const bbox = matrix.transformBBox(labelBBox); left = Math.min(left, bbox.x); right = Math.max(right, bbox.x + bbox.width); top = Math.min(top, bbox.y); bottom = Math.max(bottom, bbox.y + bbox.height); } } left = Math.min(left, 0); right = Math.max(right, 0); top = Math.min(top, lineNode.y1, lineNode.y2); bottom = Math.max(bottom, lineNode.y1, lineNode.y2); return new BBox(left, top, right - left, bottom - top); } } //# sourceMappingURL=axis.js.map