Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Histogram slider component UI #100

Closed
wants to merge 10 commits into from
149 changes: 149 additions & 0 deletions src/components/HistogramSlider/Histogram/Histogram.tsx
Original file line number Diff line number Diff line change
@@ -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<HistogramProps, HistogramState> {
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 (
<React.Fragment>
<svg
display="block"
width="100%"
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${viewBoxWidth} ${viewBoxHeight}`}
>
<defs>
<mask
id={this.maskID}
x="0"
y="0"
width={viewBoxWidth}
height={viewBoxHeight}
>
>
<rect
x="0"
y="0"
fill="white"
width={start}
height={viewBoxHeight}
/>
<rect
x={start}
y="0"
fill="black"
width={end - start}
height={viewBoxHeight}
/>
<rect
x={end}
y="0"
fill="white"
width={viewBoxWidth - end}
height={viewBoxHeight}
/>
</mask>

<mask
id={this.maskHighlightID}
x="0"
y="0"
width={viewBoxWidth}
height={viewBoxHeight}
>
>
<rect
x={start}
y="0"
fill="white"
width={end - start}
height={viewBoxHeight}
/>
</mask>
</defs>
{this.state.data.map((height, index) => (
<React.Fragment key={index}>
<rect
mask={`url(#${this.maskID})`}
x={index}
y={viewBoxHeight! - height}
width="1"
height={height}
fill={colors.out}
/>
<rect
mask={`url(#${this.maskHighlightID})`}
x={index}
y={viewBoxHeight! - height}
width="1"
fill={colors.in}
height={height}
/>
</React.Fragment>
))}
</svg>
</React.Fragment>
);
}
}
1 change: 1 addition & 0 deletions src/components/HistogramSlider/Histogram/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Histogram } from "./Histogram";
216 changes: 216 additions & 0 deletions src/components/HistogramSlider/HistogramSlider.tsx
Original file line number Diff line number Diff line change
@@ -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] }) => (
<div
className={css({
marginBottom: "10px",
fontSize: "16px",
color: "#666666"
})}
>
{value[0]} - {value[1]}
</div>
);

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 (
<div
className={css({
maxWidth: `${widthPx}px`,
width: "100%",
padding: "10px",
boxSizing: "content-box"
})}
>
<Histogram
colors={this.props.colors!}
data={this.props.data}
value={this.state.value}
min={this.props.min}
max={this.props.max}
widthPx={this.props.widthPx!}
heightPx={this.props.heightPx!}
/>
<RangeSlider
{...rangeSliderProps}
colors={this.props.colors!}
value={this.state.value}
onChange={this.handleSliderChange}
ButtonRenderComponent={ButtonRenderComponent}
/>

<div className={css({ marginTop: "20px" })}>
{InfoRenderComponent && (
<InfoRenderComponent
value={this.state.value}
min={this.props.min}
max={this.props.max}
/>
)}

{typeof this.props.onApply === "function" && (
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: isDisabled ? "flex-end" : "space-between"
})}
>
{!isDisabled && (
<button onClick={this.reset} disabled={isDisabled}>
Reset
</button>
)}
<button onClick={this.handleApply}>Apply</button>
</div>
)}
</div>
</div>
);
}
}
Loading