veritatisquam
Version:
Carbon charting components, lerna monorepo
356 lines (301 loc) • 9.33 kB
text/typescript
// Internal Imports
import { Bar } from './bar';
import { Tools } from '../../tools';
import {
CartesianOrientations,
ColorClassNameTypes,
Events,
Roles,
} from '../../interfaces';
// D3 Imports
import { map } from 'd3-collection';
import { select } from 'd3-selection';
import { ScaleBand, scaleBand } from 'd3-scale';
export class GroupedBar extends Bar {
type = 'grouped-bar';
groupScale: ScaleBand<any>;
padding = 5;
init() {
const eventsFragment = this.services.events;
// Highlight correct circle on legend item hovers
eventsFragment.addEventListener(
Events.Legend.ITEM_HOVER,
this.handleLegendOnHover
);
// Un-highlight circles on legend item mouseouts
eventsFragment.addEventListener(
Events.Legend.ITEM_MOUSEOUT,
this.handleLegendMouseOut
);
}
render(animate: boolean) {
// Chart options mixed with the internal configurations
const displayData = this.model.getDisplayData(this.configs.groups);
const options = this.getOptions();
const { groupMapsTo } = options.data;
// Get unique labels
this.setGroupScale();
// Grab container SVG
const svg = this.getContainerSVG({ withinChartClip: true });
const allDataLabels = map(displayData, (datum) => {
const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(
datum
);
return datum[domainIdentifier];
}).keys();
// Update data on bar groups
const barGroups = svg
.selectAll('g.bars')
.data(allDataLabels, (label) => label);
// Remove dot groups that need to be removed
barGroups.exit().attr('opacity', 0).remove();
// Add the bar groups that need to be introduced
const barGroupsEnter = barGroups
.enter()
.append('g')
.classed('bars', true)
.attr('role', Roles.GROUP)
.attr('data-name', 'bars');
// Update data on all bars
const allBarGroups = barGroupsEnter.merge(barGroups);
allBarGroups
// Transition
.transition(
this.services.transitions.getTransition(
'bar-group-update-enter',
animate
)
)
.attr('transform', (label, i) => {
const scaleValue = this.services.cartesianScales.getDomainValue(
label,
i
);
const translateBy = scaleValue - this.getGroupWidth() / 2;
// const translateBy = scaleValue - this.getGroupWidth(null) / 2 + this.getBarWidth(null);
if (
this.services.cartesianScales.getOrientation() ===
CartesianOrientations.VERTICAL
) {
return `translate(${translateBy}, 0)`;
} else {
// translate in the y direction for horizontal groups
return `translate(0, ${translateBy})`;
}
});
const bars = allBarGroups.selectAll('path.bar').data(
(label) => this.getDataCorrespondingToLabel(label),
(d) => d[groupMapsTo]
);
// Remove bars that are no longer needed
bars.exit().attr('opacity', 0).remove();
// Add the bars that need to be introduced
const barsEnter = bars.enter().append('path').attr('opacity', 0);
// code for vertical grouped bar charts
barsEnter
.merge(bars)
.classed('bar', true)
.transition(
this.services.transitions.getTransition(
'bar-update-enter',
animate
)
)
.attr('class', (d) =>
this.model.getColorClassName({
classNameTypes: [ColorClassNameTypes.FILL],
dataGroupName: d[groupMapsTo],
originalClassName: 'bar',
})
)
.style('fill', (d) => this.model.getFillColor(d[groupMapsTo]))
.attr('d', (d) => {
/*
* Orientation support for horizontal/vertical bar charts
* Determine coordinates needed for a vertical set of paths
* to draw the bars needed, and pass those coordinates down to
* generateSVGPathString() to decide whether it needs to flip them
*/
const startX = this.groupScale(d[groupMapsTo]);
const barWidth = this.getBarWidth();
const x0 = startX;
const x1 = startX + barWidth;
const rangeAxis = this.services.cartesianScales.getRangeAxisPosition(
{ datum: d }
);
const y0 = this.services.cartesianScales.getValueThroughAxisPosition(
rangeAxis,
0
);
const y1 = this.services.cartesianScales.getRangeValue(d);
// don't show if part of bar is out of zoom domain
if (this.isOutsideZoomedDomain(x0, x1)) {
return;
}
return Tools.generateSVGPathString(
{ x0, x1, y0, y1 },
this.services.cartesianScales.getOrientation()
);
})
.attr('opacity', 1)
// a11y
.attr('role', Roles.GRAPHICS_SYMBOL)
.attr('aria-roledescription', 'bar')
.attr('aria-label', (d) => d.value);
// Add event listeners to elements drawn
this.addEventListeners();
}
// Highlight elements that match the hovered legend item
handleLegendOnHover = (event: CustomEvent) => {
const { hoveredElement } = event.detail;
const { groupMapsTo } = this.getOptions().data;
this.parent
.selectAll('path.bar')
.transition(
this.services.transitions.getTransition('legend-hover-bar')
)
.attr('opacity', (d) =>
d[groupMapsTo] !== hoveredElement.datum()['name'] ? 0.3 : 1
);
};
// Un-highlight all elements
handleLegendMouseOut = (event: CustomEvent) => {
this.parent
.selectAll('path.bar')
.transition(
this.services.transitions.getTransition('legend-mouseout-bar')
)
.attr('opacity', 1);
};
addEventListeners() {
const self = this;
this.parent
.selectAll('path.bar')
.on('mouseover', function (datum) {
const hoveredElement = select(this);
hoveredElement.classed('hovered', true);
hoveredElement.transition(
self.services.transitions.getTransition(
'graph_element_mouseover_fill_update'
)
);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Bar.BAR_MOUSEOVER, {
element: hoveredElement,
datum,
});
// Show tooltip
self.services.events.dispatchEvent(Events.Tooltip.SHOW, {
hoveredElement,
data: [datum],
});
})
.on('mousemove', function (datum) {
const hoveredElement = select(this);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Bar.BAR_MOUSEMOVE, {
element: hoveredElement,
datum,
});
self.services.events.dispatchEvent(Events.Tooltip.MOVE);
})
.on('click', function (datum) {
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Bar.BAR_CLICK, {
element: select(this),
datum,
});
})
.on('mouseout', function (datum) {
const hoveredElement = select(this);
hoveredElement.classed('hovered', false);
hoveredElement.transition(
self.services.transitions.getTransition(
'graph_element_mouseout_fill_update'
)
);
// Dispatch mouse event
self.services.events.dispatchEvent(Events.Bar.BAR_MOUSEOUT, {
element: hoveredElement,
datum,
});
// Hide tooltip
self.services.events.dispatchEvent(Events.Tooltip.HIDE, {
hoveredElement,
});
});
}
destroy() {
// Remove event listeners
this.parent
.selectAll('path.bar')
.on('mouseover', null)
.on('mousemove', null)
.on('mouseout', null);
// Remove legend listeners
const eventsFragment = this.services.events;
eventsFragment.removeEventListener(
Events.Legend.ITEM_HOVER,
this.handleLegendOnHover
);
eventsFragment.removeEventListener(
Events.Legend.ITEM_MOUSEOUT,
this.handleLegendMouseOut
);
}
protected getDataCorrespondingToLabel(label: string) {
const displayData = this.model.getDisplayData(this.configs.groups);
return displayData.filter((datum) => {
const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(
datum
);
return datum[domainIdentifier] === label;
});
}
protected getGroupWidth() {
const activeData = this.model.getGroupedData(this.configs.groups);
const totalGroupPadding = this.getTotalGroupPadding();
return this.getBarWidth() * activeData.length + totalGroupPadding;
}
protected getTotalGroupPadding() {
const activeData = this.model.getGroupedData(this.configs.groups);
if (activeData.length === 1) {
return 0;
}
const domainScale = this.services.cartesianScales.getDomainScale();
const padding = Math.min(5, 5 * (domainScale.step() / 70));
return padding * (activeData.length - 1);
}
// Gets the correct width for bars based on options & configurations
protected getBarWidth() {
const options = this.getOptions();
const providedWidth = Tools.getProperty(options, 'bars', 'width');
const providedMaxWidth = Tools.getProperty(options, 'bars', 'maxWidth');
// If there's a provided width, compare with maxWidth and
// Determine which to return
if (providedWidth !== null) {
if (
providedMaxWidth === null ||
providedWidth <= providedMaxWidth
) {
return providedWidth;
}
}
const activeData = this.model.getGroupedData(this.configs.groups);
const numOfActiveDataGroups = activeData.length;
const totalGroupPadding = this.getTotalGroupPadding();
const domainScale = this.services.cartesianScales.getDomainScale();
return Math.min(
providedMaxWidth,
(domainScale.step() - totalGroupPadding) / numOfActiveDataGroups
);
}
protected setGroupScale() {
const activeData = this.model.getActiveDataGroupNames(
this.configs.groups
);
this.groupScale = scaleBand()
.domain(activeData)
.rangeRound([0, this.getGroupWidth()]);
}
}