diff --git a/.eslintrc b/.eslintrc
index be727efd6..21d169a1e 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -2,6 +2,7 @@
"extends": [
"eslint:recommended",
"plugin:react/recommended",
+ "plugin:react-hooks/recommended",
"plugin:jest/recommended",
"prettier",
"prettier/react"
@@ -19,8 +20,8 @@
"**/dist/",
"**/es/"
],
- "settings":{
- "react":{
+ "settings": {
+ "react": {
"version": "detect"
}
},
@@ -31,12 +32,19 @@
},
"rules": {
"consistent-return": 0,
- "max-len": [1, 110, 4],
- "max-params": ["error", 6],
+ "max-len": [
+ 1,
+ 110,
+ 4
+ ],
+ "max-params": [
+ "error",
+ 6
+ ],
"object-curly-spacing": 0,
"babel/object-curly-spacing": 2,
- "jest/require-top-level-describe":"error",
+ "jest/require-top-level-describe": "error",
"react/prop-types": "off",
"prettier/prettier": "warn"
}
-}
+}
\ No newline at end of file
diff --git a/packages/react-vis/package.json b/packages/react-vis/package.json
index 8abe0a0a9..b1d3b1510 100644
--- a/packages/react-vis/package.json
+++ b/packages/react-vis/package.json
@@ -72,6 +72,7 @@
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.5.0",
"eslint-plugin-jest": "^23.13.2",
+ "eslint-plugin-react-hooks": "^4.0.4",
"jest": "^25.5.4",
"jsdom": "^9.9.1",
"node-sass": "^4.9.3",
diff --git a/packages/react-vis/src/index.js b/packages/react-vis/src/index.js
index 6a0b11d3e..2c4c549af 100644
--- a/packages/react-vis/src/index.js
+++ b/packages/react-vis/src/index.js
@@ -74,6 +74,9 @@ export Treemap from 'treemap';
export ContentClipPath from './plot/content-clip-path';
+export Selection from './plot/selection';
+export Window from './plot/window';
+
export {
makeHeightFlexible,
makeVisFlexible,
diff --git a/packages/react-vis/src/plot/selection.js b/packages/react-vis/src/plot/selection.js
new file mode 100644
index 000000000..456db7cb8
--- /dev/null
+++ b/packages/react-vis/src/plot/selection.js
@@ -0,0 +1,179 @@
+import React, {useEffect, useState, useCallback, useRef} from 'react';
+import {getAttributeScale} from '../utils/scales-utils';
+
+const DEFAULT_STATE = {
+ brushing: false,
+ bounds: null,
+ startPosition: null
+};
+
+export default function Selection(props) {
+ const {
+ events: {mouseMove, mouseDown, mouseUp, mouseLeave},
+ onSelecting,
+ onSelected,
+ enableX = true,
+ enableY = true,
+ marginLeft = 0,
+ marginTop = 0,
+ innerWidth = 0,
+ innerHeight = 0,
+ xDomain,
+ yDomain
+ } = props;
+
+ const [state, setState] = useState(DEFAULT_STATE);
+ // The 'state' is being assigned to the 'ref' so that the `useCallback`s can
+ // reference the value without directly depending on it.
+ // This is important for performance reasons, as directly depending on the state,
+ // will cause the event handlers to be added and removed for each move of the mouse.
+ // The lifecycle of the callbacks isn't affected by the value of the 'state', so
+ // there is no harm in using the `stateRef` to get the latest value of the `state`
+ const stateRef = useRef();
+ stateRef.current = state;
+
+ const convertArea = useCallback(
+ area => {
+ const xScale = getAttributeScale(props, 'x');
+ const yScale = getAttributeScale(props, 'y');
+
+ // If the axis isn't enabled, then use the domain to ensure
+ // that the entire space is selected.
+ return {
+ left: enableX ? xScale.invert(area.left - marginLeft) : xDomain[0],
+ top: enableY ? yScale.invert(area.top - marginTop) : yDomain[1],
+ right: enableX ? xScale.invert(area.right - marginLeft) : yDomain[1],
+ bottom: enableY ? yScale.invert(area.bottom - marginTop) : yDomain[0]
+ };
+ },
+ [enableX, enableY, marginLeft, marginTop, props, xDomain, yDomain]
+ );
+
+ const onMouseMove = useCallback(
+ e => {
+ // Get the current value of 'state'
+ const state = stateRef.current;
+ if (!state.brushing) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ const position = getPosition(e);
+
+ const bounds = {
+ left: enableX
+ ? Math.min(position.x, state.startPosition.x)
+ : marginLeft,
+ top: enableY ? Math.min(position.y, state.startPosition.y) : marginTop,
+ right: enableX
+ ? Math.max(position.x, state.startPosition.x)
+ : innerWidth + marginLeft,
+ bottom: enableY
+ ? Math.max(position.y, state.startPosition.y)
+ : innerHeight + marginTop
+ };
+
+ onSelecting && onSelecting(convertArea(bounds));
+
+ setState({
+ ...state,
+ bounds
+ });
+ },
+ [
+ convertArea,
+ enableX,
+ enableY,
+ innerHeight,
+ innerWidth,
+ marginLeft,
+ marginTop,
+ onSelecting
+ ]
+ );
+
+ const onMouseDown = useCallback(e => {
+ e.stopPropagation();
+ e.preventDefault();
+ const {x, y} = getPosition(e);
+
+ const bounds = {left: x, top: y, right: x, bottom: y};
+
+ setState(state => ({
+ ...state,
+ brushing: true,
+ bounds,
+ startPosition: {x, y}
+ }));
+ }, []);
+
+ const onMouseUp = useCallback(
+ e => {
+ // Get the current value of 'state'
+ const state = stateRef.current;
+
+ if (!state.brushing) {
+ return setState(DEFAULT_STATE);
+ }
+
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (
+ state.bounds.bottom - state.bounds.top > 5 &&
+ state.bounds.right - state.bounds.left > 5
+ ) {
+ onSelected && onSelected(convertArea(state.bounds));
+ } else {
+ onSelected && onSelected(null);
+ }
+
+ setState(DEFAULT_STATE);
+ },
+ [convertArea, onSelected]
+ );
+
+ const onMouseLeave = useCallback(() => {
+ const state = stateRef.current;
+ if (state.brushing) {
+ setState(DEFAULT_STATE);
+ }
+ }, []);
+
+ useEffect(() => mouseMove.subscribe(onMouseMove), [mouseMove, onMouseMove]);
+ useEffect(() => mouseDown.subscribe(onMouseDown), [mouseDown, onMouseDown]);
+ useEffect(() => mouseUp.subscribe(onMouseUp), [mouseUp, onMouseUp]);
+ useEffect(() => mouseLeave.subscribe(onMouseLeave), [
+ mouseLeave,
+ onMouseLeave
+ ]);
+
+ if (!state.brushing) {
+ return null;
+ }
+
+ const {bounds} = state;
+ const {opacity = 0.2, color, style} = props;
+
+ return (
+
+ );
+}
+Selection.requiresSVG = true;
+
+function getPosition(event) {
+ event = event.nativeEvent ?? event;
+ const x = event.type === 'touchstart' ? event.pageX : event.offsetX;
+ const y = event.type === 'touchstart' ? event.pageY : event.offsetY;
+ return {x, y};
+}
diff --git a/packages/react-vis/src/plot/window.js b/packages/react-vis/src/plot/window.js
new file mode 100644
index 000000000..ff5838165
--- /dev/null
+++ b/packages/react-vis/src/plot/window.js
@@ -0,0 +1,157 @@
+import React, {useMemo, useCallback, useRef, useState, useEffect} from 'react';
+import {getAttributeScale} from '../utils/scales-utils';
+
+const DEFAULT_STATE = {
+ dragging: false,
+ startPosition: null,
+ offset: null,
+
+ bounds: null
+};
+
+export default function Window(props) {
+ const {
+ yDomain,
+ xDomain,
+ left = xDomain[0],
+ top = yDomain[1],
+ right = xDomain[1],
+ bottom = yDomain[0],
+ onMoving,
+ onMoveComplete,
+ enableX = true,
+ enableY = true,
+ events: {mouseMove, mouseLeave}
+ } = props;
+
+ const xScale = useMemo(() => getAttributeScale(props, 'x'), [props]);
+ const yScale = useMemo(() => getAttributeScale(props, 'y'), [props]);
+
+ const [state, setState] = useState(DEFAULT_STATE);
+ const stateRef = useRef();
+ stateRef.current = state;
+
+ const pixelBounds = useMemo(() => {
+ return {
+ x: xScale(left) + (state.offset?.x ?? 0),
+ y: yScale(top) + (state.offset?.y ?? 0),
+ width: xScale(right) - xScale(left),
+ height: yScale(bottom) - yScale(top)
+ };
+ }, [
+ bottom,
+ left,
+ right,
+ state.offset?.x,
+ state.offset?.y,
+ top,
+ xScale,
+ yScale
+ ]);
+
+ const onMouseDown = useCallback(e => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ setState({
+ dragging: true,
+ startPosition: getPosition(e),
+ offset: {x: 0, y: 0}
+ });
+ }, []);
+
+ const onMouseMove = useCallback(
+ e => {
+ const {dragging, startPosition} = stateRef.current;
+ if (!dragging) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+
+ const position = getPosition(e);
+ const pixelOffset = {
+ x: enableX ? position.x - startPosition.x : 0,
+ y: enableY ? position.y - startPosition.y : 0
+ };
+
+ const bounds = {
+ left: xScale.invert(xScale(left) + pixelOffset.x),
+ top: yScale.invert(yScale(top) + pixelOffset.y),
+ right: xScale.invert(xScale(right) + pixelOffset.x),
+ bottom: yScale.invert(yScale(bottom) + pixelOffset.y)
+ };
+
+ onMoving && onMoving(bounds);
+
+ setState(state => ({
+ ...state,
+ offset: pixelOffset,
+ bounds
+ }));
+ },
+ [bottom, enableX, enableY, left, onMoving, right, top, xScale, yScale]
+ );
+
+ const onMouseUp = useCallback(
+ e => {
+ const {dragging} = stateRef.current;
+ if (!dragging) {
+ return;
+ }
+ e.stopPropagation();
+ e.preventDefault();
+
+ const state = stateRef.current;
+
+ setState(DEFAULT_STATE);
+ onMoveComplete && onMoveComplete(state.bounds);
+ },
+ [onMoveComplete]
+ );
+
+ const onPlotMouseLeave = useCallback(() => {
+ const state = stateRef.current;
+ if (state.dragging) {
+ setState(DEFAULT_STATE);
+ }
+ }, []);
+
+ useEffect(() => mouseMove.subscribe(onMouseMove), [mouseMove, onMouseMove]);
+ useEffect(() => mouseLeave.subscribe(onPlotMouseLeave), [
+ mouseLeave,
+ onPlotMouseLeave
+ ]);
+
+ if (
+ [pixelBounds.x, pixelBounds.y, pixelBounds.width, pixelBounds.height].some(
+ isNaN
+ )
+ ) {
+ return null;
+ }
+
+ const {color, opacity = 0.2, style, marginLeft, marginTop} = props;
+
+ return (
+
+ );
+}
+Window.requiresSVG = true;
+
+function getPosition(event) {
+ event = event.nativeEvent ?? event;
+ const x = event.type === 'touchstart' ? event.pageX : event.offsetX;
+ const y = event.type === 'touchstart' ? event.pageY : event.offsetY;
+ return {x, y};
+}
diff --git a/packages/react-vis/src/plot/xy-plot.js b/packages/react-vis/src/plot/xy-plot.js
index ccb503c5a..e8e87478a 100644
--- a/packages/react-vis/src/plot/xy-plot.js
+++ b/packages/react-vis/src/plot/xy-plot.js
@@ -50,6 +50,8 @@ import {
import CanvasWrapper from './series/canvas-wrapper';
+import {Event} from '../utils/events';
+
const ATTRIBUTES = [
'x',
'y',
@@ -140,7 +142,14 @@ class XYPlot extends React.Component {
const data = getStackedData(children, stackBy);
this.state = {
scaleMixins: this._getScaleMixins(data, props),
- data
+ data,
+ events: {
+ mouseMove: new Event('move'),
+ mouseDown: new Event('down'),
+ mouseUp: new Event('up'),
+ mouseLeave: new Event('leave'),
+ mouseEnter: new Event('enter')
+ }
};
}
@@ -222,7 +231,8 @@ class XYPlot extends React.Component {
...scaleMixins,
...child.props,
...XYPlotValues[index],
- ...dataProps
+ ...dataProps,
+ events: this.state.events
});
});
}
@@ -348,6 +358,7 @@ class XYPlot extends React.Component {
component.onParentMouseDown(event);
}
});
+ this.state.events.mouseDown.fire(event);
};
/**
@@ -367,6 +378,7 @@ class XYPlot extends React.Component {
component.onParentMouseEnter(event);
}
});
+ this.state.events.mouseEnter.fire(event);
};
/**
@@ -386,6 +398,7 @@ class XYPlot extends React.Component {
component.onParentMouseLeave(event);
}
});
+ this.state.events.mouseLeave.fire(event);
};
/**
@@ -405,6 +418,7 @@ class XYPlot extends React.Component {
component.onParentMouseMove(event);
}
});
+ this.state.events.mouseMove.fire(event);
};
/**
@@ -424,6 +438,7 @@ class XYPlot extends React.Component {
component.onParentMouseUp(event);
}
});
+ this.state.events.mouseUp.fire(event);
};
/**
diff --git a/packages/react-vis/src/utils/events.js b/packages/react-vis/src/utils/events.js
new file mode 100644
index 000000000..ceb5bc12f
--- /dev/null
+++ b/packages/react-vis/src/utils/events.js
@@ -0,0 +1,20 @@
+export class Event {
+ subscribers = [];
+
+ constructor(name) {
+ this.name = name;
+ }
+
+ fire(...args) {
+ this.subscribers.forEach(cb => cb(...args));
+ }
+
+ subscribe(callback) {
+ this.subscribers.push(callback);
+ return () => this.unsubscribe(callback);
+ }
+
+ unsubscribe(callback) {
+ this.subscribers = this.subscribers.filter(x => x !== callback);
+ }
+}
diff --git a/packages/react-vis/tests/plot/zoom-handler.test.js b/packages/react-vis/tests/plot/zoom-handler.test.js
new file mode 100644
index 000000000..6b9adef14
--- /dev/null
+++ b/packages/react-vis/tests/plot/zoom-handler.test.js
@@ -0,0 +1,91 @@
+import React from 'react';
+
+import {mount} from 'enzyme';
+import Selection from '../../src/plot/selection';
+import XYPlot from '../../src/plot/xy-plot';
+
+describe('zoom-handler', () => {
+ it('should zoom', () => {
+ const onSelected = jest.fn();
+ const wrapper = mount(
+
+
+
+ );
+
+ const svg = wrapper.find('svg');
+ svg.simulate('mousedown', mouseEvent(100, 110));
+ svg.simulate('mouseMove', mouseEvent(150, 160));
+ svg.simulate('mouseUp', mouseEvent(150, 160));
+
+ expect(onSelected).toBeCalledWith({
+ left: 4.8,
+ top: 12,
+ right: 8.8,
+ bottom: 8
+ });
+ });
+
+ it('should render selection', () => {
+ const onSelected = jest.fn();
+ const wrapper = mount(
+
+
+
+ );
+
+ const svg = wrapper.find('svg');
+ svg.simulate('mousedown', mouseEvent(100, 100));
+ svg.simulate('mouseMove', mouseEvent(150, 150));
+
+ const selection = wrapper.find('rect');
+
+ expect(selection.props()).toMatchObject({
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 50
+ });
+ });
+
+ it('should clear selection if plot mouseleave', () => {
+ const onSelected = jest.fn();
+ const wrapper = mount(
+
+
+
+ );
+
+ const svg = wrapper.find('svg');
+ svg.simulate('mousedown', mouseEvent(100, 100));
+ svg.simulate('mousemove', mouseEvent(150, 150));
+
+ expect(wrapper.find('rect')).toHaveLength(1);
+
+ svg.simulate('mouseleave');
+ expect(wrapper.find('rect')).toHaveLength(0);
+ expect(onSelected).not.toBeCalled();
+ });
+});
+
+function mouseEvent(x, y) {
+ return {nativeEvent: {offsetX: x, offsetY: y}};
+}
diff --git a/packages/react-vis/tests/utils/events.test.js b/packages/react-vis/tests/utils/events.test.js
new file mode 100644
index 000000000..cc3616405
--- /dev/null
+++ b/packages/react-vis/tests/utils/events.test.js
@@ -0,0 +1,27 @@
+import {Event} from '../../src/utils/events';
+
+describe('events', () => {
+ it('should subscribe', () => {
+ const event = new Event();
+ const handler = jest.fn();
+ event.subscribe(handler);
+ expect(event.subscribers).toHaveLength(1);
+
+ event.fire('foo');
+ expect(handler).toHaveBeenCalledWith('foo');
+ });
+
+ it('should unsubscribe', () => {
+ const event = new Event();
+ const handler = jest.fn();
+ const unsubscribe = event.subscribe(handler);
+ expect(event.subscribers).toHaveLength(1);
+
+ unsubscribe();
+ expect(event.subscribers).toHaveLength(0);
+
+ event.fire('foo');
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/website/.babelrc b/packages/website/.babelrc
new file mode 100644
index 000000000..9e26dfeeb
--- /dev/null
+++ b/packages/website/.babelrc
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/packages/website/.storybook/main.js b/packages/website/.storybook/main.js
new file mode 100644
index 000000000..a8fbec58d
--- /dev/null
+++ b/packages/website/.storybook/main.js
@@ -0,0 +1,28 @@
+/* eslint-env node */
+const {join} = require('path');
+const isProd = process.env.NODE_ENV === 'production';
+
+// This custom webpack config allows storybook to use the source files from 'react-vis'.
+// Changes can be made and instantly be reflected in storybook to allow quick development
+module.exports = {
+ webpackFinal: config => {
+ if (isProd) { return config;}
+
+ const reactVisPath = join(__dirname, '../../react-vis/src')
+
+ // Add an alias to 'react-vis' so that it looks in its src directory.
+ config.resolve.alias['react-vis'] =reactVisPath
+ config.resolve.modules.push(reactVisPath)
+
+ const jsRule = config.module.rules.find(rule => rule.test.test('test.js'));
+ // Add the react-vis/src folder to the list of files to compile
+ jsRule.include.push(reactVisPath)
+
+ const babelLoader = jsRule.use.find(x => x.loader === 'babel-loader');
+ if (babelLoader) {
+ babelLoader.options.rootMode = 'upward'
+ }
+
+ return config;
+ }
+}
diff --git a/packages/website/.storybook/storybook.css b/packages/website/.storybook/storybook.css
index 7cc411c30..f75cb06fc 100644
--- a/packages/website/.storybook/storybook.css
+++ b/packages/website/.storybook/storybook.css
@@ -1,3 +1,8 @@
-html, body, #root {
- height: 100%;
-}
+html,
+body,
+#root {
+ height: 100%;
+ box-sizing: border-box;
+ position: relative;
+ margin: 0
+}
\ No newline at end of file
diff --git a/packages/website/storybook/misc-story.js b/packages/website/storybook/misc-story.js
index ba14d3c88..6ab65e811 100644
--- a/packages/website/storybook/misc-story.js
+++ b/packages/website/storybook/misc-story.js
@@ -1,17 +1,28 @@
/* eslint-env node */
-import React from 'react';
+import React, {useState, useCallback} from 'react';
import {storiesOf} from '@storybook/react';
-import {withKnobs, boolean} from '@storybook/addon-knobs/react';
+import {withKnobs, boolean, button} from '@storybook/addon-knobs/react';
import {SimpleChartWrapper} from './storybook-utils';
import {generateLinearData} from './storybook-data';
-import {LineSeries, ContentClipPath} from 'react-vis';
+import {LineSeries, ContentClipPath, Selection, Window} from 'react-vis';
const data = generateLinearData({randomFactor: 10});
+const highlightData = generateLinearData({});
+const yDomainHighlightData = [
+ highlightData.reduce(
+ (min, cur) => Math.floor(Math.min(min, cur.y)),
+ Number.MAX_SAFE_INTEGER
+ ),
+ highlightData.reduce(
+ (max, cur) => Math.ceil(Math.max(max, cur.y)),
+ Number.MIN_SAFE_INTEGER
+ )
+];
storiesOf('Misc', module)
.addDecorator(withKnobs)
.addWithJSX('Clip Content', () => {
@@ -28,4 +39,49 @@ storiesOf('Misc', module)
);
+ })
+ .addWithJSX('Zoom', () => {
+ const [zoom, setZoom] = useState();
+ const onSelected = useCallback(area => {
+ setZoom(area);
+ }, []);
+
+ button('Reset Zoom', () => setZoom(null), 'Zoom');
+
+ const xDomain = zoom ? [zoom.left, zoom.right] : undefined;
+
+ return (
+
+
+
+
+ );
+ })
+ .addWithJSX('Window', () => {
+ const [zoom, setZoom] = useState({left: 5, right: 10});
+ const onMoveComplete = useCallback(area => {
+ setZoom(area);
+ }, []);
+
+ const xDomain = [zoom.left, zoom.right];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
});
diff --git a/packages/website/storybook/storybook-utils.js b/packages/website/storybook/storybook-utils.js
index bfd996591..b2372df2b 100644
--- a/packages/website/storybook/storybook-utils.js
+++ b/packages/website/storybook/storybook-utils.js
@@ -39,43 +39,51 @@ export const LINEAR_PALETTE = ['#EF5D28', '#FF9833'];
export function SimpleChartWrapper(props) {
return (
- {({height, width}) => (
-
- {props.noXAxis
- ? null
- : boolean('X Axis', true, 'General chart options') && }
- {props.noYAxis
- ? null
- : boolean('Y Axis', true, 'General chart options') && }
- {props.noVerticalGridLines
- ? null
- : boolean('vertical gridlines', true, 'General chart options') && (
-
- )}
- {props.noHorizontalGridLines
- ? null
- : boolean(
- 'horizontal gridlines',
- true,
- 'General chart options'
- ) && }
- {props.children}
-
- )}
+ {({height, width}) => {
+ if (!width || !height) {
+ return null;
+ }
+ return (
+
+ {props.noXAxis
+ ? null
+ : boolean('X Axis', true, 'General chart options') && }
+ {props.noYAxis
+ ? null
+ : boolean('Y Axis', true, 'General chart options') && }
+ {props.noVerticalGridLines
+ ? null
+ : boolean(
+ 'vertical gridlines',
+ true,
+ 'General chart options'
+ ) && }
+ {props.noHorizontalGridLines
+ ? null
+ : boolean(
+ 'horizontal gridlines',
+ true,
+ 'General chart options'
+ ) && }
+ {props.children}
+
+ );
+ }}
);
}
diff --git a/packages/website/webpack.config.js b/packages/website/webpack.config.js
index 833884df2..6c3420dcb 100644
--- a/packages/website/webpack.config.js
+++ b/packages/website/webpack.config.js
@@ -16,7 +16,8 @@ module.exports = {
},
resolve: {
alias: {
- react: resolve(__dirname, './node_modules/react')
+ react: resolve(__dirname, './node_modules/react'),
+ 'react-vis': resolve('../react-vis/src')
}
}
};
diff --git a/yarn.lock b/yarn.lock
index 52b54f597..500c15f32 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6603,6 +6603,11 @@ eslint-plugin-prettier@^3.1.3:
dependencies:
prettier-linter-helpers "^1.0.0"
+eslint-plugin-react-hooks@^4.0.4:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.4.tgz#aed33b4254a41b045818cacb047b81e6df27fa58"
+ integrity sha1-rtM7QlSkGwRYGMrLBHuB5t8n+lg=
+
eslint-plugin-react@^6.7.1:
version "6.10.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-6.10.3.tgz#c5435beb06774e12c7db2f6abaddcbf900cd3f78"