diff --git a/src/components/HistogramSlider/Histogram/Histogram.tsx b/src/components/HistogramSlider/Histogram/Histogram.tsx new file mode 100644 index 000000000..0882d1e88 --- /dev/null +++ b/src/components/HistogramSlider/Histogram/Histogram.tsx @@ -0,0 +1,149 @@ +import React, { Component } from "react"; + +interface HistogramProps { + data: number[]; + value: [number, number]; + widthPx: number; + heightPx: number; + min: number; + max: number; + colors: { + in: string; + out: string; + }; +} + +interface HistogramState { + data: number[]; + prevPropData: number[]; +} + +let maskCount = 0; + +export class Histogram extends Component { + maskHighlightID: string = `sf-histogram-mask-${maskCount++}`; + maskID: string = `sf-histogram-mask-${maskCount++}`; + + state: HistogramState = { + prevPropData: [], + data: [] + }; + + static getDerivedStateFromProps( + nextProps: HistogramProps, + prevState: HistogramState + ) { + if (nextProps.data !== prevState.prevPropData) { + const viewBoxWidth = nextProps.data.length; + const viewBoxHeight = + (nextProps.heightPx * viewBoxWidth) / nextProps.widthPx; + const max = Math.max(...nextProps.data); + const heightPxPerUnit = viewBoxHeight / max; + + return { + data: nextProps.data.map(v => + parseFloat((heightPxPerUnit * v).toFixed(2)) + ) + }; + } + + return null; + } + + getViewboxSize = () => { + const viewBoxWidth = this.props.data.length; + const viewBoxHeight = + (this.props.heightPx * viewBoxWidth) / this.props.widthPx; + return { viewBoxHeight, viewBoxWidth }; + }; + + render() { + const { min, max, value, colors } = this.props; + const [vMin, vMax] = value; + const range = max - min; + const { viewBoxWidth, viewBoxHeight } = this.getViewboxSize(); + const start = ((vMin - min) * viewBoxWidth) / range; + const end = start + ((vMax - vMin) * viewBoxWidth) / range; + + return ( + + + + + > + + + + + + + > + + + + {this.state.data.map((height, index) => ( + + + + + ))} + + + ); + } +} diff --git a/src/components/HistogramSlider/Histogram/index.ts b/src/components/HistogramSlider/Histogram/index.ts new file mode 100644 index 000000000..3c001d6cf --- /dev/null +++ b/src/components/HistogramSlider/Histogram/index.ts @@ -0,0 +1 @@ +export { Histogram } from "./Histogram"; diff --git a/src/components/HistogramSlider/HistogramSlider.tsx b/src/components/HistogramSlider/HistogramSlider.tsx new file mode 100644 index 000000000..1fbd944d4 --- /dev/null +++ b/src/components/HistogramSlider/HistogramSlider.tsx @@ -0,0 +1,216 @@ +import React, { Component } from "react"; +import { Histogram } from "./Histogram"; +import { RangeSlider } from "./RangeSlider"; +import { css } from "emotion"; + +interface HistogramSliderProps { + /** Array of numbers used to render graph */ + data: number[]; + /** Default value [start, end] */ + value: [number, number]; + min: number; + max: number; + step: number; + /** Minimum range between `start` and `end` */ + distance: number; + widthPx?: number; + heightPx?: number; + /** `in` color for the selected part */ + colors?: { + in: string; + out: string; + }; + /** Custom component for render UI to reflect [start, end], pass `null` if you don't want to show the part */ + InfoRenderComponent?: + | null + | (( + props: { value: [number, number]; min: number; max: number } + ) => JSX.Element); + /** Custom component for render drag button UI, pass `null` if you don't want to show the part */ + ButtonRenderComponent?: + | null + | ((props: { focused?: boolean }) => JSX.Element); + /** Showing an `apply` & `reset` button if a function was passed to the prop*/ + onApply?: (value: [number, number]) => void; + /** Callback function while the range changed */ + onChange?: (value: [number, number]) => void; + /** The delay time for `onChange` get called from the last */ + debounceDelay?: number; +} + +interface HistogramSliderState { + value: [number, number]; + prevPropValue: [number, number]; +} + +const DefaultInfoRenderComponent = ({ value }: { value: [number, number] }) => ( +
+ {value[0]} - {value[1]} +
+); + +export class HistogramSlider extends Component< + HistogramSliderProps, + HistogramSliderState +> { + static defaultProps = { + widthPx: 300, + heightPx: 70, + colors: { + in: "#D7D8D8", + out: "#EEEEEE" + }, + InfoRenderComponent: DefaultInfoRenderComponent + }; + + constructor(props: HistogramSliderProps) { + super(props); + + if (this.props.min >= this.props.max) { + console.error( + `The prop "min" should not be greater than the props "max".` + ); + } + + if (this.props.value[0] >= this.props.value[1]) { + console.error( + `The [0] of the prop "value" should not be greater than the [1].` + ); + } + + this.state = { + value: [this.props.value[0], this.props.value[1]], + prevPropValue: [this.props.value[0], this.props.value[1]] + }; + } + + timeout: number | undefined; + + static getDerivedStateFromProps( + nextProps: HistogramSliderProps, + prevState: HistogramSliderState + ) { + if ( + nextProps.value[0] !== prevState.prevPropValue[0] || + nextProps.value[1] !== prevState.prevPropValue[1] + ) { + return { + value: [nextProps.value[0], nextProps.value[1]], + prevPropValue: [nextProps.value[0], nextProps.value[1]] + }; + } + + return null; + } + + reset = (e: React.MouseEvent) => { + e.preventDefault(); + this.setState({ value: [this.props.min, this.props.max] }, () => { + if (typeof this.props.onApply === "function") { + this.props.onApply(this.state.value); + } else if (typeof this.props.onChange === "function") { + this.props.onChange(this.state.value); + } + }); + }; + + isDisabled = () => { + return ( + this.state.value[0] === this.props.min && + this.state.value[1] === this.props.max + ); + }; + + handleSliderChange = (value: [number, number]) => { + this.setState({ value }, () => { + if (typeof this.props.onChange === "function") { + if (this.timeout) { + window.clearTimeout(this.timeout); + } + this.timeout = window.setTimeout(() => { + //@ts-ignore: has been checked outsite + this.props.onChange(value); + }, this.props.debounceDelay || 500); + } + }); + }; + + handleApply = (e: React.MouseEvent) => { + e.preventDefault(); + if (typeof this.props.onApply === "function") { + this.props.onApply(this.state.value); + } + }; + + render() { + const isDisabled = this.isDisabled(); + const { + data, + widthPx, + InfoRenderComponent, + ButtonRenderComponent, + ...rangeSliderProps + } = this.props; + + return ( +
+ + + +
+ {InfoRenderComponent && ( + + )} + + {typeof this.props.onApply === "function" && ( +
+ {!isDisabled && ( + + )} + +
+ )} +
+
+ ); + } +} diff --git a/src/components/HistogramSlider/RangeSlider/RangeSlider.tsx b/src/components/HistogramSlider/RangeSlider/RangeSlider.tsx new file mode 100644 index 000000000..7661128fe --- /dev/null +++ b/src/components/HistogramSlider/RangeSlider/RangeSlider.tsx @@ -0,0 +1,425 @@ +import React, { Component, PureComponent } from "react"; +import { css } from "emotion"; + +interface RangeSliderProps { + min: number; + max: number; + step: number; + value: [number, number]; + distance: number; + onChange: (value: [number, number]) => void; + ButtonRenderComponent?: + | null + | ((props: { focused?: boolean }) => JSX.Element); + colors: { + in: string; + out: string; + }; +} + +export class RangeSlider extends Component { + ref = React.createRef(); + + getRange = () => { + return this.props.max - this.props.min; + }; + + getKeyboardStep = () => { + let step = Math.floor(this.props.max / 100); + return step < this.props.step ? this.props.step : step; + }; + + triggerMouseMin = () => { + document.addEventListener("mousemove", this.mouseMoveMin); + document.addEventListener("mouseup", this.clearDocumentEvents); + }; + + triggerMouseMax = () => { + document.addEventListener("mousemove", this.mouseMoveMax); + document.addEventListener("mouseup", this.clearDocumentEvents); + }; + + triggerTouchMin = () => { + document.addEventListener("touchmove", this.touchMoveMin); + document.addEventListener("touchend", this.clearDocumentEvents); + document.addEventListener("touchcancel", this.clearDocumentEvents); + }; + + triggerTouchMax = () => { + document.addEventListener("touchmove", this.touchMoveMax); + document.addEventListener("touchend", this.clearDocumentEvents); + document.addEventListener("touchcancel", this.clearDocumentEvents); + }; + + getCordsProperties = () => { + // @ts-ignore + const { x, width } = this.ref.current.getBoundingClientRect(); + return { minX: x, maxX: x + width, width }; + }; + + touchMoveMax = (e: TouchEvent) => { + const { clientX } = e.changedTouches[0]; + this.dragMax(clientX); + }; + + mouseMoveMax = (e: MouseEvent) => { + const { clientX } = e; + this.dragMax(clientX); + }; + + touchMoveMin = (e: TouchEvent) => { + const { clientX } = e.changedTouches[0]; + this.dragMin(clientX); + }; + + mouseMoveMin = (e: MouseEvent) => { + const { clientX } = e; + this.dragMin(clientX); + }; + + dragMin = (clientX: number) => { + const { minX, width } = this.getCordsProperties(); + const percent = clientX < minX ? 0 : (clientX - minX) / width; + const { + onChange, + value: [prevStateMin, prevStateMax], + min, + step, + distance + } = this.props; + let nextStateMin = percent * this.getRange(); + let value: [number, number]; + + if (clientX <= minX) { + value = [min, prevStateMax]; + } else { + const delta = (nextStateMin - prevStateMin + min) / step; + let addition = 0; + if (Math.abs(delta) >= 1) { + addition = Math.floor(delta / step) * step; + } + nextStateMin = prevStateMin + addition; + if (nextStateMin + distance > prevStateMax) { + nextStateMin = prevStateMax - distance; + } + value = [nextStateMin, prevStateMax]; + } + + this.setState({ value }, () => { + onChange(value); + }); + }; + + dragMax = (clientX: number) => { + const { maxX, minX, width } = this.getCordsProperties(); + const percent = clientX > maxX ? 1 : (clientX - minX) / width; + let nextStateMax = percent * this.getRange(); + + const { + onChange, + value: [prevStateMin, prevStateMax], + step, + min, + max, + distance + } = this.props; + + let value: [number, number]; + if (clientX >= maxX) { + value = [prevStateMin, max]; + } else { + const delta = (nextStateMax - prevStateMax + min) / step; + let addition = 0; + if (Math.abs(delta) >= 1) { + addition = Math.ceil(delta / step) * step; + } + nextStateMax = prevStateMax + addition; + if (nextStateMax - distance < prevStateMin) { + nextStateMax = prevStateMin + distance; + } + value = [prevStateMin, nextStateMax]; + } + + this.setState({ value }, () => { + onChange(value); + }); + }; + + handleMinKeydown = (e: React.KeyboardEvent) => { + const { key } = e; + if (key === "Enter" || key === " ") { + e.preventDefault(); + return; + } + const { + distance, + min, + value: [prevStateMin, prevStateMax], + onChange + } = this.props; + let value: [number, number]; + + if (key === "ArrowRight") { + const nextStateMin = + prevStateMin + distance >= prevStateMax + ? prevStateMax - distance + : prevStateMin + this.getKeyboardStep(); + + value = [nextStateMin, prevStateMax]; + this.setState({ value }, () => { + onChange(value); + }); + } else if (key === "ArrowLeft") { + const nextStateMin = + prevStateMin <= min ? min : prevStateMin - this.getKeyboardStep(); + + value = [nextStateMin, prevStateMax]; + this.setState({ value }, () => { + onChange(value); + }); + } + }; + + handleMaxKeydown = (e: React.KeyboardEvent) => { + const { key } = e; + if (key === "Enter" || key === " ") { + e.preventDefault(); + return; + } + const { + distance, + max, + value: [prevStateMin, prevStateMax], + onChange + } = this.props; + let value: [number, number]; + + if (key === "ArrowRight") { + const nextStateMax = + prevStateMax >= max ? max : prevStateMax + this.getKeyboardStep(); + value = [prevStateMin, nextStateMax]; + + this.setState({ value }, () => { + onChange(value); + }); + } else if (key === "ArrowLeft") { + const nextStateMax = + prevStateMax - distance <= prevStateMin + ? prevStateMin + distance + : prevStateMax - this.getKeyboardStep(); + value = [prevStateMin, nextStateMax]; + + this.setState({ value }, () => { + onChange(value); + }); + } + }; + + handleBarClick = (e: React.MouseEvent) => { + let point = e.clientX; + const { minX, maxX, width } = this.getCordsProperties(); + if (point < minX) { + point = minX; + } else if (point > maxX) { + point = maxX; + } + const { + onChange, + value: [prevStateMin, prevStateMax], + min + } = this.props; + const range = Math.round(((point - minX) * this.getRange()) / width) + min; + + let value: [number, number]; + const { distance } = this.props; + if (range <= prevStateMin) { + value = [range, prevStateMax]; + } else if (range >= prevStateMax) { + value = [prevStateMin, range]; + } else if ( + Math.abs(range - prevStateMin) >= Math.abs(range - prevStateMax) + ) { + const nextMaxState = + range - prevStateMin < distance ? prevStateMin + distance : range; + value = [prevStateMin, nextMaxState]; + } else { + const nextMinState = + prevStateMax - range < distance ? prevStateMax - distance : range; + value = [nextMinState, prevStateMax]; + } + + this.setState({ value }, () => { + onChange(value); + }); + }; + + clearDocumentEvents = () => { + document.removeEventListener("mouseup", this.clearDocumentEvents); + document.removeEventListener("mousemove", this.mouseMoveMin); + document.removeEventListener("mousemove", this.mouseMoveMax); + document.removeEventListener("touchmove", this.touchMoveMin); + document.removeEventListener("touchmove", this.touchMoveMax); + document.removeEventListener("touchend", this.clearDocumentEvents); + document.removeEventListener("touchcancel", this.clearDocumentEvents); + }; + + componentWillUnmount() { + this.clearDocumentEvents(); + } + + preventDefaultClick = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + render() { + const range = this.getRange(); + const { + min, + max, + colors, + value: [minState, maxState], + ButtonRenderComponent + } = this.props; + const right = 100 - ((maxState - min) * 100) / range; + const left = ((minState - min) * 100) / range; + const ButtonInnerComponent = + ButtonRenderComponent || DefaultRenderButtonComponent; + + return ( +
+
+
+
+
+ ); + } +} + +const DefaultRenderButtonComponent = () => ( + + {[1, 2, 3].map(index => ( + + ))} + +); + +class Button extends React.PureComponent< + { + ButtonRenderComponent: ((props: { focused?: boolean }) => JSX.Element); + } & React.HTMLAttributes +> { + state = { + focused: false + }; + + handleFocus = () => { + this.setState({ focused: true }); + }; + + handleBlur = () => { + this.setState({ focused: false }); + }; + + render() { + const { ButtonRenderComponent, ...rest } = this.props; + return ( + + ); + } +} diff --git a/src/components/HistogramSlider/RangeSlider/index.ts b/src/components/HistogramSlider/RangeSlider/index.ts new file mode 100644 index 000000000..d14672f0b --- /dev/null +++ b/src/components/HistogramSlider/RangeSlider/index.ts @@ -0,0 +1 @@ +export { RangeSlider } from './RangeSlider'; diff --git a/src/components/HistogramSlider/histogramSlider.mdx b/src/components/HistogramSlider/histogramSlider.mdx new file mode 100644 index 000000000..2bfbcb7f3 --- /dev/null +++ b/src/components/HistogramSlider/histogramSlider.mdx @@ -0,0 +1,80 @@ +--- +name: Histogram Slider +menu: Components +route: /components/histogram-slider +--- + +import { PropsTable, Playground } from "docz"; +import { HistogramSlider } from "./HistogramSlider.tsx"; +import { HistogramSlider as PropsHistogramSlider } from "./HistogramSlider.tsx"; +import sampleData from "./sampleData.ts"; + +# Histogram Slider + +```javascript +// Import the HistogramSlider component +import { HistogramSlider } from "@sajari/sdk-react"; +``` + +## Usage + +### With default width & height + + + + + +### Custom width, height & colors + + + + + +### Custom Info Render & opApply call + + + ( +
+ From {value[0]} to {value[1]} +
+ )} + onApply={value => { + if(value[0] !== 0 || value[1] !== 1000){ + alert(value); + } + }} + +/> + +
+ +## Props + + diff --git a/src/components/HistogramSlider/index.ts b/src/components/HistogramSlider/index.ts new file mode 100644 index 000000000..d91a77994 --- /dev/null +++ b/src/components/HistogramSlider/index.ts @@ -0,0 +1 @@ +export { HistogramSlider } from './HistogramSlider'; diff --git a/src/components/HistogramSlider/sampleData.ts b/src/components/HistogramSlider/sampleData.ts new file mode 100644 index 000000000..d70d9b101 --- /dev/null +++ b/src/components/HistogramSlider/sampleData.ts @@ -0,0 +1,52 @@ +export default [ + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 4, + 9, + 16, + 44, + 31, + 40, + 42, + 32, + 23, + 17, + 26, + 39, + 14, + 23, + 14, + 17, + 14, + 13, + 18, + 18, + 20, + 11, + 7, + 15, + 11, + 8, + 8, + 8, + 8, + 8, + 5, + 6, + 6, + 3, + 5, + 3, + 6, + 2, + 4, + 0, + 5, + 63 +]; diff --git a/src/components/index.ts b/src/components/index.ts index 14658bee6..2d7bd7120 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,3 +24,5 @@ export { Checkbox } from "./Checkbox"; export { Radio } from "./Radio"; export { Search } from "./Search"; + +export { HistogramSlider } from "./HistogramSlider"; diff --git a/src/index.ts b/src/index.ts index 1e8326c2c..ad20bec3e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,8 @@ export { Select, Checkbox, Radio, - Search + Search, + HistogramSlider } from "./components"; export { Overlay } from "./containers";