import { Link } from 'react-router';
import { min, max } from 'd3-array';
import { scaleLinear, ScaleLinear } from 'd3-scale';
+import { zoom, zoomIdentity } from 'd3-zoom';
+import { event, select } from 'd3-selection';
import { sortBy, uniq } from 'lodash';
import Tooltip from '../controls/Tooltip';
import { translate } from '../../helpers/l10n';
link?: string;
onClick?: (link?: string) => void;
r: number;
+ scale: number;
tooltip?: string | React.ReactNode;
x: number;
y: number;
onClick={this.props.onClick ? this.handleClick : undefined}
r={this.props.r}
style={{ fill: this.props.color, stroke: this.props.color }}
- transform={`translate(${this.props.x}, ${this.props.y})`}
+ transform={`translate(${this.props.x}, ${this.props.y}) scale(${this.props.scale})`}
/>
);
}
interface State {
- isMoving: boolean;
- moveOrigin: { x: number; y: number };
- zoom: number;
- zoomOrigin: { x: number; y: number };
+ transform: { x: number; y: number; k: number };
}
type Scale = ScaleLinear<number, number>;
export default class BubbleChart extends React.Component<Props, State> {
node: SVGSVGElement | null = null;
+ selection: any = null;
+ transform: any = null;
+ zoom: any = null;
static defaultProps = {
displayXGrid: true,
constructor(props: Props) {
super(props);
- this.state = {
- isMoving: false,
- moveOrigin: { x: 0, y: 0 },
- zoom: 1,
- zoomOrigin: { x: 0, y: 0 }
- };
+ this.state = { transform: { x: 0, y: 0, k: 1 } };
}
- componentDidMount() {
- document.addEventListener('mouseup', this.stopMoving);
- document.addEventListener('mousemove', this.updateZoomCenter);
+ componentDidUpdate() {
+ if (this.zoom && this.node) {
+ const rect = this.node.getBoundingClientRect();
+ this.zoom.translateExtent([[0, 0], [rect.width, rect.height]]);
+ }
}
- componentWillUnmount() {
- document.removeEventListener('mouseup', this.stopMoving);
- document.removeEventListener('mousemove', this.updateZoomCenter);
- }
+ boundNode = (node: SVGSVGElement) => {
+ this.node = node;
+ this.zoom = zoom()
+ .scaleExtent([1, 10])
+ .on('zoom', this.zoomed);
+ this.selection = select(this.node).call(this.zoom);
+ };
+
+ zoomed = () => {
+ this.setState({ transform: event.transform });
+ };
+
+ resetZoom = (event: React.MouseEvent<Link>) => {
+ event.stopPropagation();
+ event.preventDefault();
+ select(this.node).call(this.zoom.transform, zoomIdentity);
+ };
get formatXTick() {
return this.props.formatXTick || ((d: number) => String(d));
return this.props.padding || [10, 10, 10, 10];
}
- startMoving = (event: React.MouseEvent<SVGSVGElement>) => {
- if (this.node && this.state.zoom > 1) {
- const rect = this.node.getBoundingClientRect();
- this.setState({
- isMoving: true,
- moveOrigin: { x: event.clientX - rect.left, y: event.clientY - rect.top }
- });
- }
- };
-
- updateZoomCenter = (event: MouseEvent) => {
- if (this.node && this.state.isMoving) {
- const rect = this.node.getBoundingClientRect();
- const x = event.clientX - rect.left;
- const y = event.clientY - rect.top;
- this.setState(state => ({
- zoomOrigin: {
- x: Math.max(-100, state.zoomOrigin.x + (state.moveOrigin.x - x) / state.zoom),
- y: Math.max(-100, state.zoomOrigin.y + (state.moveOrigin.y - y) / state.zoom)
- },
- moveOrigin: { x, y }
- }));
- }
- };
-
- stopMoving = () => {
- this.setState({ isMoving: false });
- };
-
- onWheel = (event: React.WheelEvent<SVGSVGElement>) => {
- if (this.node) {
- event.stopPropagation();
- event.preventDefault();
-
- const rect = this.node.getBoundingClientRect();
- const mouseX = event.clientX - rect.left - this.padding[1];
- const mouseY = event.clientY - rect.top - this.padding[0];
-
- let delta = event.deltaY;
- if ((event as any).webkitDirectionInvertedFromDevice) {
- delta = -delta;
- }
-
- if (delta > 0) {
- this.handleZoomOut(mouseX, mouseY);
- } else {
- this.handleZoomIn(mouseX, mouseY);
- }
- }
- };
-
- handleZoomOut = (x: number, y: number) => {
- if (this.state.zoom === 1) {
- this.setState(state => ({
- zoom: Math.max(1.0, state.zoom - 0.5),
- zoomOrigin: { x, y }
- }));
- } else {
- this.setState(state => ({
- zoom: Math.max(1.0, state.zoom - 0.5)
- }));
- }
- };
-
- handleZoomIn = (x: number, y: number) => {
- if (this.state.zoom === 1) {
- this.setState(state => ({
- zoom: Math.min(10.0, state.zoom + 0.5),
- zoomOrigin: { x, y }
- }));
- } else {
- this.setState(state => ({
- zoom: Math.min(10.0, state.zoom + 0.5)
- }));
- }
- };
-
- resetZoom = (event: React.MouseEvent<Link>) => {
- event.stopPropagation();
- event.preventDefault();
- this.setState({
- zoom: 1,
- zoomOrigin: { x: 0, y: 0 }
- });
- };
-
getXRange(xScale: Scale, sizeScale: Scale, availableWidth: number) {
const minX = min(this.props.items, d => xScale(d.x) - sizeScale(d.size)) || 0;
const maxX = max(this.props.items, d => xScale(d.x) + sizeScale(d.size)) || 0;
}
getTicks(scale: Scale, format: (d: number) => string) {
- const ticks = scale.ticks(TICKS_COUNT).map(tick => format(tick));
+ const zoom = Math.ceil(this.state.transform.k);
+ const ticks = scale.ticks(TICKS_COUNT * zoom).map(tick => format(tick));
const uniqueTicksCount = uniq(ticks).length;
- const ticksCount = uniqueTicksCount < TICKS_COUNT ? uniqueTicksCount - 1 : TICKS_COUNT;
+ const ticksCount =
+ uniqueTicksCount < TICKS_COUNT * zoom ? uniqueTicksCount - 1 : TICKS_COUNT * zoom;
return scale.ticks(ticksCount);
}
- getZoomLevelLabel = () => this.state.zoom * 100 + '%';
+ getZoomLevelLabel = () => Math.floor(this.state.transform.k * 100) + '%';
- renderXGrid = (ticks: Array<number>, xScale: Scale, yScale: Scale, delta: number) => {
+ renderXGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
if (!this.props.displayXGrid) {
return null;
}
+ const { transform } = this.state;
const lines = ticks.map((tick, index) => {
const x = xScale(tick);
const y1 = yScale.range()[0];
<line
className="bubble-chart-grid"
key={index}
- x1={x * this.state.zoom - delta}
- x2={x * this.state.zoom - delta}
- y1={y1}
- y2={y2}
+ x1={x * transform.k + transform.x}
+ x2={x * transform.k + transform.x}
+ y1={y1 * transform.k}
+ y2={transform.k > 1 ? 0 : y2}
/>
);
});
return <g>{lines}</g>;
};
- renderYGrid = (ticks: Array<number>, xScale: Scale, yScale: Scale, delta: number) => {
+ renderYGrid = (ticks: number[], xScale: Scale, yScale: Scale) => {
if (!this.props.displayYGrid) {
return null;
}
+ const { transform } = this.state;
const lines = ticks.map((tick, index) => {
const y = yScale(tick);
const x1 = xScale.range()[0];
<line
className="bubble-chart-grid"
key={index}
- x1={x1}
- x2={x2}
- y1={y * this.state.zoom - delta}
- y2={y * this.state.zoom - delta}
+ x1={transform.k > 1 ? 0 : x1}
+ x2={x2 * transform.k}
+ y1={y * transform.k + transform.y}
+ y2={y * transform.k + transform.y}
/>
);
});
return <g>{lines}</g>;
};
- renderXTicks = (xTicks: Array<number>, xScale: Scale, yScale: Scale, delta: number) => {
+ renderXTicks = (xTicks: number[], xScale: Scale, yScale: Scale) => {
if (!this.props.displayXTicks) {
return null;
}
+ const { transform } = this.state;
const ticks = xTicks.map((tick, index) => {
- const x = xScale(tick);
+ const x = xScale(tick) * transform.k + transform.x;
const y = yScale.range()[0];
const innerText = this.formatXTick(tick);
- return (
- <text
- className="bubble-chart-tick"
- dy="1.5em"
- key={index}
- x={x * this.state.zoom - delta}
- y={y}>
+ return x > 0 ? (
+ <text className="bubble-chart-tick" dy="1.5em" key={index} x={x} y={y}>
{innerText}
</text>
- );
+ ) : null;
});
return <g>{ticks}</g>;
};
- renderYTicks = (yTicks: Array<number>, xScale: Scale, yScale: Scale, delta: number) => {
+ renderYTicks = (yTicks: number[], xScale: Scale, yScale: Scale) => {
if (!this.props.displayYTicks) {
return null;
}
+ const { transform } = this.state;
const ticks = yTicks.map((tick, index) => {
const x = xScale.range()[0];
- const y = yScale(tick);
+ const y = yScale(tick) * transform.k + transform.y;
const innerText = this.formatYTick(tick);
- return (
+ return y > 0 && y < this.props.height - 80 ? (
<text
className="bubble-chart-tick bubble-chart-tick-y"
dx="-0.5em"
dy="0.3em"
key={index}
x={x}
- y={y * this.state.zoom - delta}>
+ y={y}>
{innerText}
</text>
- );
+ ) : null;
});
return <g>{ticks}</g>;
};
renderChart = (width: number) => {
+ const { transform } = this.state;
const availableWidth = width - this.padding[1] - this.padding[3];
const availableHeight = this.props.height - this.padding[0] - this.padding[2];
xScale.range(this.getXRange(xScale, sizeScale, availableWidth));
yScale.range(this.getYRange(yScale, sizeScale, availableHeight));
- const centerXDelta = this.state.zoomOrigin.x * this.state.zoom - this.state.zoomOrigin.x;
- const centerYDelta = this.state.zoomOrigin.y * this.state.zoom - this.state.zoomOrigin.y;
-
const bubbles = sortBy(this.props.items, b => -b.size).map((item, index) => {
return (
<Bubble
link={item.link}
onClick={this.props.onBubbleClick}
r={sizeScale(item.size)}
+ scale={1 / transform.k}
tooltip={item.tooltip}
- x={xScale(item.x) * this.state.zoom - centerXDelta}
- y={yScale(item.y) * this.state.zoom - centerYDelta}
+ x={xScale(item.x)}
+ y={yScale(item.y)}
/>
);
});
return (
<svg
- className={classNames('bubble-chart', { 'is-moving': this.state.isMoving })}
+ className={classNames('bubble-chart')}
height={this.props.height}
- onMouseDown={this.startMoving}
- onWheel={this.onWheel}
- ref={node => (this.node = node)}
+ ref={this.boundNode}
width={width}>
+ <defs>
+ <clipPath id="graph-region">
+ <rect
+ // Extend clip by 2 pixels: one for clipRect border, and one for Bubble borders
+ height={this.props.height - this.padding[0] - this.padding[2] + 4}
+ width={width + 4}
+ x={-2}
+ y={-2}
+ />
+ </clipPath>
+ </defs>
<g transform={`translate(${this.padding[3]}, ${this.padding[0]})`}>
- <svg
- height={this.props.height - this.padding[0] - this.padding[2]}
- style={{ overflow: 'hidden' }}
- width={width}>
- {this.renderXGrid(xTicks, xScale, yScale, centerXDelta)}
- {this.renderYGrid(yTicks, xScale, yScale, centerYDelta)}
- {bubbles}
- </svg>
- {this.renderXTicks(xTicks, xScale, yScaleOriginal, centerXDelta)}
- {this.renderYTicks(yTicks, xScaleOriginal, yScale, centerYDelta)}
+ <g clipPath="url(#graph-region)">
+ {this.renderXGrid(xTicks, xScale, yScale)}
+ {this.renderYGrid(yTicks, xScale, yScale)}
+ <g transform={`translate(${transform.x}, ${transform.y}) scale(${transform.k})`}>
+ {bubbles}
+ </g>
+ </g>
+ {this.renderXTicks(xTicks, xScale, yScaleOriginal)}
+ {this.renderYTicks(yTicks, xScaleOriginal, yScale)}
</g>
</svg>
);
<div className="bubble-chart-zoom">
<Tooltip overlay={translate('component_measures.bubble_chart.zoom_level')}>
<Link
- className={classNames('outline-badge', { active: this.state.zoom > 1 })}
+ className={classNames('outline-badge', { active: this.state.transform.k > 1 })}
onClick={this.resetZoom}
to="#">
{this.getZoomLevelLabel()}