d2b
Version:
526 lines (434 loc) • 17 kB
JavaScript
import { annotation } from 'd3-svg-annotation';
import { scaleBand, scaleLinear } from 'd3-scale';
import { axisBottom, axisLeft, axisTop, axisRight } from 'd3-axis';
import { select } from 'd3-selection';
import { set } from 'd3-collection';
import { extent } from 'd3-array';
import 'd3-transition';
import base from '../model/base';
import chartFrame from '../util/chartFrame';
import legend from '../util/legend';
import plane from '../svg/plane';
import color from '../util/color';
import tooltipAxis from '../util/tooltipAxis';
import d2bid from '../util/id';
import oreq from '../util/oreq';
import chartAxisAdvanced from '../chartAdvanced/axis';
import { updateAxis as updateAnnotations } from '../util/annotation';
export default function () {
const $$ = {};
const chart = function (context) {
context.call($$.chartFrame);
$$.legend
.empty(d => d.data.hidden)
.setEmpty((d, i, state) => d.data.hidden = state)
.html(d => d.label)
.key(d => d.label)
.color(d => d.color);
const selection = (context.selection)? context.selection() : context;
selection.each(function (datum) {
update.call(this, datum, context !== selection ? context : null);
});
selection.dispatch('chart-axis-updated', {bubbles: true});
selection.dispatch('chart-updated', {bubbles: true});
return chart;
};
base(chart, $$)
.addAdvancedConfig(chartAxisAdvanced)
.addProp('plane', plane())
.addProp('annotation', annotation ? annotation() : null)
.addProp('chartFrame', chartFrame().legendEnabled(true).breadcrumbsEnabled(false))
.addProp('legend', legend().clickable(true).dblclickable(true))
.addPropFunctor('tooltipConfig', d => d.tooltipConfig)
.addPropFunctor('duration', 250)
.addPropFunctor('x', {})
.addPropFunctor('y', {})
.addPropFunctor('x2', {})
.addPropFunctor('y2', {})
.addPropFunctor('clipPlane', true)
.addPropFunctor('annotations', d => d.annotations)
.addPropFunctor('groups', d => d.groups)
.addPropFunctor('sets', d => d.sets)
// group functors
.addPropFunctor('groupLabel', d => d.label)
.addPropFunctor('groupColor', d => color($$.groupLabel(d)))
// set functors
.addPropFunctor('setKey', (d, i) => i)
.addPropFunctor('setGenerators', d => d.generators)
.addPropFunctor('setXType', d => d.xType)
.addPropFunctor('setYType', d => d.yType)
.addPropFunctor('setGraphs', d => d.graphs)
// graph functors
.addPropFunctor('graphKey', d => d.label)
.addPropFunctor('graphLabel', d => d.label)
.addPropFunctor('graphGroup', d => d.group)
.addPropFunctor('graphColor', d => color($$.graphLabel(d)))
.addPropFunctor('graphTooltipConfig', d => d.tooltipConfig)
.addPropFunctor('graphAnnotations', d => d.annotations)
// annotation functors
.addPropFunctor('annotationKey', (d, i) => i)
.addPropFunctor('annotationXType', d => d.xType)
.addPropFunctor('annotationYType', d => d.yType)
.addPropFunctor('annotationZ', d => d.z)
.addPropFunctor('annotationX', d => d.x)
.addPropFunctor('annotationY', d => d.y)
.addPropFunctor('annotationX2', d => d.x2)
.addPropFunctor('annotationY2', d => d.y2)
.addPropFunctor('annotationColor', d => d.color);
function update (datum, transition) {
const container = select(this),
chartContainer = container.select('.d2b-chart-container'),
chartNode = chartContainer.node(),
legendContainer = container.select('.d2b-legend-container'),
size = chartContainer.node().__size__,
sets = getSets(datum),
allGraphs = getAllGraphs(sets),
duration = $$.duration(datum),
groups = getGroups(datum, sets),
clipPlane = $$.clipPlane(datum);
// define chart level annotations
let annotations = ($$.annotations(datum) || []).slice().map((a, i) => {
return {
key: $$.annotationKey(a, i),
xType: $$.annotationXType(a) || 'x',
yType: $$.annotationYType(a) || 'y',
z: $$.annotationZ(a) || 'front',
x: $$.annotationX(a),
y: $$.annotationY(a),
x2: $$.annotationX2(a),
y2: $$.annotationY2(a),
color: $$.annotationColor(a) || 'steelblue',
data: a
};
});
propagateHidden(groups);
// make tooltip sticky on the chart node because tooltipAxis requires
// one instance per axisChart
let tooltip = chartNode.tooltip = chartNode.tooltip || tooltipAxis().trackX(true).trackY(false).threshold(50);
tooltip
.title(points => `${oreq(points[0].x, points[0].x1, points[0].median)}`)
.clear();
// update functionality
$$.legend.values(groups);
(transition ? legendContainer.transition(transition) : legend).call($$.legend);
legendContainer
.call($$.legend)
.on('change', () => container.transition().duration(duration).call(chart))
.selectAll('.d2b-legend-item')
.on('mouseover', d => legendMouseover(d, chartContainer))
.on('mouseout', d => legendMouseout(d, chartContainer));
// update plane dimensions, width and height
$$.plane.size(size);
let plane = chartContainer.selectAll('.d2b-axis-plane').data([datum]),
planeUpdate = plane,
planeEnter = plane.enter().append('g').attr('class', 'd2b-axis-plane');
planeUpdate = plane = plane.merge(planeEnter);
// enter axis-set wrapper
let wrapper = chartContainer.selectAll('.d2b-axis-wrapper').data([datum]),
wrapperUpdate = wrapper,
wrapperEnter = wrapper.enter().append('g').attr('class', 'd2b-axis-wrapper');
wrapperEnter.append('rect').attr('class', 'd2b-axis-background');
wrapperEnter.append('g').attr('class', 'd2b-axis-back-annotations');
wrapperEnter.append('g').attr('class', 'd2b-axis-sets');
wrapperEnter.append('g').attr('class', 'd2b-axis-front-annotations');
wrapperUpdate = wrapper = wrapper.merge(wrapperEnter);
let backAnnotationsUpdate = wrapper.select('.d2b-axis-back-annotations'),
frontAnnotationsUpdate = wrapper.select('.d2b-axis-front-annotations');
// enter axis-sets
let set = wrapper.select('.d2b-axis-sets').selectAll('.d2b-axis-set').data(sets, d => d.key),
setEnter = set.enter().append('g').attr('class', 'd2b-axis-set'),
setExit = set.exit();
set = set.merge(setEnter).order();
// queue transitions if context is a transition
if (transition) {
setExit = setExit.transition(transition);
wrapperUpdate = wrapperUpdate.transition(transition);
planeUpdate = planeUpdate.transition(transition);
backAnnotationsUpdate = backAnnotationsUpdate.transition(transition);
frontAnnotationsUpdate = frontAnnotationsUpdate.transition(transition);
}
// initialze generator and visible point sets
const visible = {
x: [],
x2: [],
y: [],
y2: []
};
set.each(function (s) {
const el = select(this),
graphs = s.graphs.filter(g => !g.data.hidden),
graphsRaw = graphs.map(g => g.data);
// add graph annotations to chart annotations
graphs.forEach(g => {
annotations = annotations.concat(g.annotations.map((a, i) => {
return {
key: `${s.key}-${g.key}-${$$.annotationKey(a, i)}`,
xType: s.xType,
yType: s.yType,
z: $$.annotationZ(a) || 'front',
x: $$.annotationX(a),
y: $$.annotationY(a),
x2: $$.annotationX2(a),
y2: $$.annotationY2(a),
color: $$.annotationColor(a) || g.color,
data: a,
graph: g.data
};
}));
});
this.genUpdate = el.selectAll('.d2b-graph-generator')
.data(s.generators.slice().reverse(), d => d.key);
this.genEnter = this.genUpdate.enter().append('g')
.attr('class', 'd2b-graph-generator')
.style('opacity', 0);
this.genExit = this.genUpdate.exit();
this.gen = this.genUpdate.merge(this.genEnter).order();
const size = this.gen.size();
this.gen.each(function (d, i) {
const gen = select(this),
visiblePoints = d.generator
.tooltipGraph(graph => {
if (i < size - 1) return null;
let tooltipGraph = tooltip.graph(d2bid());
matchGraph(graph, allGraphs).tooltipConfig(tooltipGraph);
return tooltipGraph;
})
.color(graph => matchGraph(graph, allGraphs).color)
.graphs(graphsRaw)
.getVisiblePoints(gen);
if (d.generator.duration) d.generator.duration(duration);
visiblePoints.forEach(point => {
visible[s.xType].push(point.x);
visible[s.yType].push(point.y);
});
});
});
const xData = $$.x(datum, visible.x),
yData = $$.y(datum, visible.y),
x2Data = $$.x2(datum, visible.x2),
y2Data = $$.y2(datum, visible.y2);
setupAxis(xData, visible.x, axisDefaults.x);
setupAxis(yData, visible.y, axisDefaults.y);
setupAxis(x2Data, visible.x2, axisDefaults.x2);
setupAxis(y2Data, visible.y2, axisDefaults.y2);
$$.plane
.axis(d => d.__axis__)
.x(xData.__axis__ && visible.x.length ? xData : null)
.y(yData.__axis__ && visible.y.length ? yData : null)
.x2(x2Data.__axis__ && visible.x2.length ? x2Data : null)
.y2(y2Data.__axis__ && visible.y2.length ? y2Data : null);
// update plane
planeEnter.call($$.plane);
planeUpdate.call($$.plane);
// after plane update, fetch plane box
const planeBox = $$.plane.box(plane);
// add clip path from plane
const planeClip = `url(#${plane.select('.d2b-clip-plane').attr('id')})`;
wrapper.attr('clip-path', clipPlane ? planeClip : '');
// update the graphs with their generators
set.each(function (s) {
const xAxis = s.xType === 'x2'? x2Data.__axis__ : xData.__axis__,
yAxis = s.yType === 'y2'? y2Data.__axis__ : yData.__axis__;
if (transition) {
this.genUpdate = this.genUpdate.transition(transition);
this.genExit = this.genExit.transition(transition).style('opacity', 0);
}
this.genExit.remove();
this.gen.each(function (d) {
let el = select(this);
if (transition) el = el.transition(transition);
d.generator
.x(xAxis.scale())
.y(yAxis.scale());
el.style('opacity', 1).call(d.generator);
});
select(this).on('change', () => container.transition().duration(duration).call(chart));
});
// remaining transitions and exits
setExit.style('opacity', 0).remove();
// position wrapper
wrapperEnter
.attr('transform', `translate(${planeBox.left}, ${planeBox.top})`)
.select('rect.d2b-axis-background')
.attr('height', Math.max(0, planeBox.height))
.attr('width', Math.max(0, planeBox.width));
wrapperUpdate
.attr('transform', `translate(${planeBox.left}, ${planeBox.top})`)
.select('rect.d2b-axis-background')
.attr('height', Math.max(0, planeBox.height))
.attr('width', Math.max(0, planeBox.width));
// update annotations
if ($$.annotation) {
const annotationScales = {
x: xData.__scale__,
y: yData.__scale__,
x2: x2Data.__scale__,
y2: y2Data.__scale__
};
backAnnotationsUpdate
.call(
updateAnnotations,
$$.annotation,
annotations.filter(a => a.z === 'back'),
annotationScales
);
frontAnnotationsUpdate
.call(
updateAnnotations,
$$.annotation,
annotations.filter(a => a.z === 'front'),
annotationScales
);
}
// configure tooltip
tooltip.row(point => {
const graphLabel = matchGraph(point.graph.data, allGraphs).label;
return `${graphLabel}: ${oreq(point.y, point.y1, point.median)}`;
});
$$.tooltipConfig(tooltip);
tooltip
.svgContainer(wrapper)
.tracker(wrapper)
.size(planeBox);
}
// defaultz axis components
const bandDefault = scaleBand(),
linearDefault = scaleLinear(),
axisDefaults = {
x: {
band: bandDefault.copy(),
linear: linearDefault.copy(),
axis: axisBottom()
},
y: {
band: bandDefault.copy(),
linear: linearDefault.copy(),
axis: axisLeft()
},
x2: {
band: bandDefault.copy(),
linear: linearDefault.copy(),
axis: axisTop()
},
y2: {
band: bandDefault.copy(),
linear: linearDefault.copy(),
axis: axisRight()
}
};
function getGroups (d, sets = getSets(d), graphs = getAllGraphs(sets)) {
const graphGroups = graphs.filter(graph => !graph.group);
graphGroups.forEach(g => {
g.groupType = 'graph';
g.groupGraphs = [g];
});
return ($$.groups(d) || []).map(group => {
const newGroup = {
groupType: 'group',
data: group,
label: $$.groupLabel(group),
color: $$.groupColor(group)
};
newGroup.groupGraphs = graphs.filter(graph => newGroup.label === graph.group);
newGroup.groupGraphs.forEach(g => {
g.color = newGroup.color;
});
return newGroup;
})
.concat(graphGroups);
}
function getSets (d) {
return $$.sets(d).map((set, i) => {
const generatorTypes = {};
return {
data: set,
key: $$.setKey(set, i),
xType: $$.setXType(set) || 'x',
yType: $$.setYType(set) || 'y',
generators: $$.setGenerators(set).map((generator, i) => {
const type = generator.type();
// only annotate first generator
if (i !== 0) (generator.pannotation || generator.pannotations || function () {})(null);
generator
.annotation($$.annotation)
.key($$.graphKey);
generatorTypes[type] = generatorTypes[type] || 0;
return {
data: generator,
key: `${type}-${generatorTypes[type] += 1}`,
generator: generator
};
}),
graphs: getSetGraphs(set)
};
});
}
function getSetGraphs (d) {
return $$.setGraphs(d).map((graph, i) => {
return {
data: graph,
label: $$.graphLabel(graph) || '',
key: $$.graphKey(graph, i),
color: $$.graphColor(graph),
group: $$.graphGroup(graph),
annotations: $$.graphAnnotations(graph) || [],
tooltipConfig: $$.graphTooltipConfig(graph) || function () {}
};
});
}
function getAllGraphs (sets) {
return [].concat.apply([], sets.map( set => set.graphs ));
}
function propagateHidden (groups) {
groups.forEach(group => {
group.groupGraphs.forEach(graph => graph.data.hidden = group.data.hidden);
});
}
function legendMouseover(d, selection) {
const graphs = selection.selectAll('.d2b-graph'),
annotations = selection.selectAll('.d2b-axis-annotation');
if (!d.groupGraphs.some(graph => !graph.data.hidden)) return;
graphs
.style('opacity', 0.2)
.filter(graph => {
return d.data === graph.data ||
(d.groupGraphs.map(d => d.data) || []).indexOf(graph.data) > -1;
})
.style('opacity', '');
annotations
.style('opacity', 0.2)
.filter(annotation => {
return d.data === annotation.graph ||
(d.groupGraphs.map(d => d.data) || []).indexOf(annotation.graph) > -1;
})
.style('opacity', '');
}
function legendMouseout(d, selection) {
selection.selectAll('.d2b-graph').style('opacity', 1);
selection.selectAll('.d2b-axis-annotation').style('opacity', 1);
}
function matchGraph(graph, allGraphs) {
return allGraphs.filter(g => g.data === graph || g.data === graph.data)[0];
}
function setupAxis(data, points, defaults) {
if (!points.length) data.hidden = true;
const axis = data.axis || defaults.axis,
scale = data.scale ? data.scale.copy() : getScale(points, defaults);
let domain = scale.domain();
if (!scale.bandwidth && data.linearPadding) {
const dist = domain[1] - domain[0];
domain[0] = domain[0] + dist * data.linearPadding[0];
domain[1] = domain[1] + dist * data.linearPadding[1];
}
scale.domain(domain);
data.__axis__ = axis.scale(scale);
data.__scale__ = scale;
}
function getScale(points, defaults) {
const band = points.some(d => isNaN(d)),
domain = band ? set(points).values() : extent(points).map(d => d || 0),
scale = band ? defaults.band : defaults.linear;
return scale.domain(domain);
}
return chart;
}