src/elements/element.path.base.js
import {LayerLabels} from './decorators/layer-labels';
import {CSS_PREFIX} from '../const';
import {
d3_animationInterceptor,
d3_setAttrs as attrs,
d3_setClasses as classes,
d3_transition as transition
} from '../utils/d3-decorators';
import * as utils from '../utils/utils';
import * as utilsDom from '../utils/utils-dom';
import * as utilsDraw from '../utils/utils-draw';
import {isNonSyntheticRecord} from '../utils/utils-grammar';
import * as d3 from 'd3-selection';
import {drawAnchors, highlightAnchors} from './decorators/anchors';
import {getClosestPointInfo} from '../utils/utils-position';
const datumClass = `i-role-datum`;
const pointPref = `${CSS_PREFIX}dot-line dot-line i-role-dot ${datumClass} ${CSS_PREFIX}dot`;
const BasePath = {
init(xConfig) {
const config = xConfig;
config.guide = utils.defaults(
(config.guide || {}),
{
animationSpeed: 0,
cssClass: '',
maxHighlightDistance: 32,
widthCssClass: '',
color: {},
label: {}
}
);
config.guide.label = utils.defaults(
config.guide.label,
{
fontSize: 11,
hideEqualLabels: true,
position: [
'auto:avoid-label-label-overlap',
'auto:avoid-label-anchor-overlap',
'auto:avoid-label-edges-overlap',
'auto:adjust-on-label-overflow',
'auto:hide-on-label-label-overlap',
'auto:hide-on-label-edges-overlap'
]
});
config.guide.color = utils.defaults(config.guide.color || {}, {fill: null});
if (['never', 'hover', 'always'].indexOf(config.guide.showAnchors) < 0) {
config.guide.showAnchors = 'hover';
}
config.transformRules = [];
config.adjustRules = [];
return config;
},
baseModel(screenModel) {
const kRound = 10000;
var baseModel = {
gog: screenModel.model,
x: screenModel.x,
y: screenModel.y,
x0: screenModel.x0,
y0: screenModel.y0,
size: screenModel.size,
group: screenModel.group,
order: screenModel.order,
color: screenModel.color,
class: screenModel.class,
groupAttributes: {},
pathAttributesUpdateInit: {},
pathAttributesUpdateDone: {},
pathAttributesEnterInit: {},
pathAttributesEnterDone: {},
pathElement: null,
dotAttributes: {
r: ((d) => (Math.round(kRound * baseModel.size(d) / 2) / kRound)),
cx: (d) => baseModel.x(d),
cy: (d) => baseModel.y(d),
fill: (d) => baseModel.color(d),
class: (d) => (`${pointPref} ${baseModel.class(d)}`)
},
dotAttributesDefault: {
r: 0,
cy: (d) => baseModel.y0(d)
}
};
return baseModel;
},
addInteraction() {
const node = this.node();
const config = this.node().config;
const createFilter = ((data, falsy) => ((row) => row === data ? true : falsy));
node.on('highlight', (sender, filter) => this.highlight(filter));
node.on('highlight-data-points', (sender, filter) => this.highlightDataPoints(filter));
if (config.guide.showAnchors !== 'never') {
node.on('data-hover', ((sender, e) => this.highlightDataPoints(createFilter(e.data, null))));
}
},
draw() {
const node = this.node();
const config = node.config;
const guide = config.guide;
const options = config.options;
options.container = options.slot(config.uid);
const screenModel = node.screenModel;
const model = this.buildModel(screenModel);
this.domElementModel = model;
const createUpdateFunc = d3_animationInterceptor;
const classToSelector = (cls) => cls.split(/\s+/g).map((c) => `.${c}`).join('');
const updateGroupContainer = function (selection) {
selection.call(attrs(model.groupAttributes));
const points = selection
.selectAll(classToSelector(pointPref))
.data((fiber) => (fiber.length <= 1) ? fiber : [], screenModel.id);
points
.exit()
.call(createUpdateFunc(
guide.animationSpeed,
null,
{r: 0},
(node) => d3.select(node).remove()));
points
.call(createUpdateFunc(guide.animationSpeed, null, model.dotAttributes));
const merged = points
.enter()
.append('circle')
.call(createUpdateFunc(guide.animationSpeed, model.dotAttributesDefault, model.dotAttributes))
.merge(points);
node.subscribe(merged);
const updatePath = (selection) => {
if (config.guide.animationSpeed > 0 && !document.hidden) {
// HACK: This call fixes stacked area tween (some paths are intersected on
// synthetic points). Maybe caused by async call of `toPoint`.
selection.attr(model.pathTween.attr, function (d) {
return model.pathTween.fn.call(this, d)(0);
});
transition(selection, config.guide.animationSpeed, 'pathTransition')
.attrTween(model.pathTween.attr, model.pathTween.fn);
} else {
selection.attr(model.pathTween.attr, function (d) {
return model.pathTween.fn.call(this, d)(1);
});
}
};
const series = selection
.selectAll(`${model.pathElement}:not(.i-data-anchor)`)
// eslint-disable-next-line @typescript-eslint/no-use-before-define
.data((fiber) => (fiber.length > 1) ? [fiber] : [], getDataSetId);
series
.exit()
.remove();
series
.call(createUpdateFunc(
guide.animationSpeed,
model.pathAttributesUpdateInit,
model.pathAttributesUpdateDone,
model.afterPathUpdate
));
const allSeries = series
.enter()
.append(model.pathElement)
.call(createUpdateFunc(
guide.animationSpeed,
model.pathAttributesEnterInit,
model.pathAttributesEnterDone,
model.afterPathUpdate
))
.merge(series)
.call(updatePath);
node.subscribe(merged);
if (guide.showAnchors !== 'never') {
const allDots = drawAnchors(node, model, selection);
node.subscribe(allDots);
}
};
const fullFibers = screenModel.toFibers();
const pureFibers = fullFibers.map((arr) => arr.filter(isNonSyntheticRecord));
const frameSelection = options.container.selectAll('.frame');
// NOTE: If any point from new dataset is equal to a point from old dataset,
// we assume that path remains the same.
// TODO: Id of data array should remain the same (then use `fib => self.screenModel.id(fib)`).
const getDataSetId = (() => {
const current = (frameSelection.empty() ? [] : frameSelection.data());
const currentIds = new Map();
frameSelection.each(function (d) {
currentIds.set(d, Number(this.getAttribute('data-id')));
});
const currentInnerIds = current.reduce((map, ds) => {
map.set(ds, ds.map(screenModel.id));
return map;
}, new Map());
const newIds = new Map();
var notFoundCounter = Math.max(0, ...Array.from(currentIds.values()));
return (fib) => {
if (newIds.has(fib)) {
return newIds.get(fib);
}
const fibIds = fib.map((f) => screenModel.id(f));
const matching = (Array.from(currentInnerIds.entries()).find(([, currIds]) => {
return fibIds.some((newId) => {
return currIds.some((id) => id === newId);
});
}) || [null])[0];
var result;
if (matching) {
result = currentIds.get(matching);
} else {
++notFoundCounter;
result = notFoundCounter;
}
newIds.set(fib, result);
return result;
};
})();
this._getDataSetId = getDataSetId;
const frameBinding = frameSelection
.data(fullFibers, getDataSetId);
frameBinding
.exit()
.remove();
frameBinding
.call(updateGroupContainer);
frameBinding
.enter()
.append('g')
.attr('data-id', getDataSetId)
.call(updateGroupContainer);
frameBinding.order();
this._boundsInfo = this._getBoundsInfo(options.container.selectAll('.i-data-anchor').nodes());
node.subscribe(new LayerLabels(
screenModel.model,
config.flip,
config.guide.label,
options).draw(pureFibers));
},
_getBoundsInfo(dots) {
if (dots.length === 0) {
return null;
}
const screenModel = this.node().screenModel;
const {flip} = this.node().config;
const items = dots
.map((node) => {
const data = d3.select(node).data()[0];
const x = screenModel.x(data);
const y = screenModel.y(data);
return {node, data, x, y};
})
// TODO: Removed elements should not be passed to this function.
.filter((item) => !isNaN(item.x) && !isNaN(item.y));
const bounds = items.reduce(
(bounds, {x, y}) => {
bounds.left = Math.min(x, bounds.left);
bounds.right = Math.max(x, bounds.right);
bounds.top = Math.min(y, bounds.top);
bounds.bottom = Math.max(y, bounds.bottom);
return bounds;
}, {
left: Number.MAX_VALUE,
right: Number.MIN_VALUE,
top: Number.MAX_VALUE,
bottom: Number.MIN_VALUE
});
const ticks = utils.unique(items.map(flip ?
((item) => item.y) :
((item) => item.x))).sort((a, b) => a - b);
const groups = ticks.reduce(((obj, value) => (obj[value] = [], obj)), {});
items.forEach((item) => {
const tick = ticks.find(flip ? ((value) => item.y === value) : ((value) => item.x === value));
groups[tick].push(item);
});
const split = (values) => {
if (values.length === 1) {
return groups[values[0]];
}
const midIndex = Math.ceil(values.length / 2);
const middle = (values[midIndex - 1] + values[midIndex]) / 2;
return {
middle,
lower: split(values.slice(0, midIndex)),
greater: split(values.slice(midIndex))
};
};
const tree = split(ticks);
return {bounds, tree};
},
getClosestElement(cursorX, cursorY) {
if (!this._boundsInfo) {
return null;
}
const {bounds, tree} = this._boundsInfo;
const container = this.node().config.options.container;
const {flip} = this.node().config;
const translate = utilsDraw.getDeepTransformTranslate(container.node());
const {maxHighlightDistance} = this.node().config.guide;
if ((cursorX < bounds.left + translate.x - maxHighlightDistance) ||
(cursorX > bounds.right + translate.x + maxHighlightDistance) ||
(cursorY < bounds.top + translate.y - maxHighlightDistance) ||
(cursorY > bounds.bottom + translate.y + maxHighlightDistance)
) {
return null;
}
const cursor = (flip ? (cursorY - translate.y) : (cursorX - translate.x));
const items = (function getClosestElements(el) {
if (Array.isArray(el)) {
return el;
}
return getClosestElements(cursor > el.middle ? el.greater : el.lower);
})(tree)
.map((el) => {
const x = (el.x + translate.x);
const y = (el.y + translate.y);
const distance = Math.abs(flip ? (cursorY - y) : (cursorX - x));
const secondaryDistance = Math.abs(flip ? (cursorX - x) : (cursorY - y));
return {node: el.node, data: el.data, distance, secondaryDistance, x, y};
});
return getClosestPointInfo(cursorX, cursorY, items);
},
highlight(filter) {
const container = this.node().config.options.container;
const x = 'tau-chart__highlighted';
const _ = 'tau-chart__dimmed';
const paths = container.selectAll('.i-role-path');
const targetFibers = paths.data()
.filter((fiber) => {
return fiber
.filter(isNonSyntheticRecord)
.some(filter);
});
const hasTarget = (targetFibers.length > 0);
paths
.call(classes({
[x]: ((fiber) => hasTarget && targetFibers.indexOf(fiber) >= 0),
[_]: ((fiber) => hasTarget && targetFibers.indexOf(fiber) < 0)
}));
const classed = {
[x]: ((d) => filter(d) === true),
[_]: ((d) => filter(d) === false)
};
container
.selectAll('.i-role-dot')
.call(classes(classed));
container
.selectAll('.i-role-label')
.call(classes(classed));
this._sortElements(filter);
},
highlightDataPoints(filter) {
const node = this.node();
const elModel = this.domElementModel;
const dots = highlightAnchors(node, elModel, filter);
const container = node.config.options.container;
// Display cursor line
const flip = node.config.flip;
const highlighted = dots.filter(filter);
var cursorLine = container.select('.cursor-line');
if (highlighted.empty()) {
cursorLine.remove();
} else {
if (cursorLine.empty()) {
cursorLine = container.append('line');
}
const model = node.screenModel.model;
const x1 = model.xi(highlighted.data()[0]);
const x2 = model.xi(highlighted.data()[0]);
const domain = model.scaleY.domain();
const y1 = model.scaleY(domain[0]);
const y2 = model.scaleY(domain[1]);
cursorLine
.attr('class', 'cursor-line')
.attr('x1', flip ? y1 : x1)
.attr('y1', flip ? x1 : y1)
.attr('x2', flip ? y2 : x2)
.attr('y2', flip ? x2 : y2);
}
this._sortElements(filter);
},
_sortElements(filter) {
const container = this.node().config.options.container;
const pathId = new Map();
const pathFilter = new Map();
const getDataSetId = this._getDataSetId;
container.selectAll('.i-role-path').each(function (d) {
pathId.set(this, getDataSetId(d));
pathFilter.set(this, d
.filter(isNonSyntheticRecord)
.some(filter));
});
const compareFilterThenGroupId = utils.createMultiSorter(
(a, b) => (pathFilter.get(a) - pathFilter.get(b)),
(a, b) => (pathId.get(a) - pathId.get(b))
);
const elementsOrder = {
line: 0,
g: 1,
text: 2
};
utilsDom.sortChildren(container.node(), (a, b) => {
if (a.tagName === 'g' && b.tagName === 'g') {
return compareFilterThenGroupId(a, b);
}
return (elementsOrder[a.tagName] - elementsOrder[b.tagName]);
});
}
};
export {BasePath};