From fd61f055fd7e86b79317120b4e2a5c60de8a1898 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Fri, 22 Jul 2022 18:06:43 +0200 Subject: [PATCH 1/9] First implementation --- .vscode/launch.json | 15 ++ react/src/demo/App.tsx | 9 +- .../RangeFilter/components/Thumb/index.ts | 1 + .../RangeFilter/components/Thumb/thumb.css | 67 ++++++ .../RangeFilter/components/Thumb/thumb.tsx | 198 ++++++++++++++++++ .../RangeFilter/components/index.ts | 1 + react/src/lib/components/RangeFilter/index.ts | 1 + .../components/RangeFilter/range-filter.css | 6 + .../components/RangeFilter/range-filter.tsx | 102 +++++++++ react/src/lib/index.ts | 2 + 10 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 .vscode/launch.json create mode 100644 react/src/lib/components/RangeFilter/components/Thumb/index.ts create mode 100644 react/src/lib/components/RangeFilter/components/Thumb/thumb.css create mode 100644 react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx create mode 100644 react/src/lib/components/RangeFilter/components/index.ts create mode 100644 react/src/lib/components/RangeFilter/index.ts create mode 100644 react/src/lib/components/RangeFilter/range-filter.css create mode 100644 react/src/lib/components/RangeFilter/range-filter.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..5b9841af --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/react/" + } + ] +} diff --git a/react/src/demo/App.tsx b/react/src/demo/App.tsx index 911b09cd..4b2da8a6 100644 --- a/react/src/demo/App.tsx +++ b/react/src/demo/App.tsx @@ -9,7 +9,12 @@ import { Button } from "@material-ui/core"; import React from "react"; -import { WebvizPluginPlaceholder, SmartNodeSelector, Dialog } from "../lib"; +import { + WebvizPluginPlaceholder, + SmartNodeSelector, + Dialog, + RangeFilter, +} from "../lib"; const steps = [ { @@ -48,6 +53,8 @@ const App: React.FC = () => { return (
+ + {currentPage.url.split("#")[1] === "dialog" && ( <>

Dialog

diff --git a/react/src/lib/components/RangeFilter/components/Thumb/index.ts b/react/src/lib/components/RangeFilter/components/Thumb/index.ts new file mode 100644 index 00000000..2b5b1aa4 --- /dev/null +++ b/react/src/lib/components/RangeFilter/components/Thumb/index.ts @@ -0,0 +1 @@ +export { Thumb } from "./thumb"; diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css new file mode 100644 index 00000000..9d33d20a --- /dev/null +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -0,0 +1,67 @@ +.WebvizRangeFilter__Thumb { + position: absolute; + margin-top: -5px; + margin-left: -6px; + display: flex; +} + +.WebvizRangeFilter__Thumb__Handle { + background-color: black; + border-radius: 50%; + width: 12px; + height: 12px; + cursor: pointer; +} + +.WebvizRangeFilter__Thumb__LeftHandle { + background-color: black; + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + width: 6px; + height: 12px; + cursor: pointer; +} + +.WebvizRangeFilter__Thumb__RightHandle { + background-color: black; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + width: 6px; + height: 12px; + cursor: pointer; +} + +.WebvizRangeFilter__Thumb__Bar { + background-color: rgb(91, 82, 255); + height: 12px; +} + +.WebvizRangeFilter__Thumb__Tooltip { + position: absolute; + top: 24px; + background-color: black; + color: white; + padding: 4px; + width: 40px; + text-align: center; +} + +.WebvizRangeFilter__Thumb__Tooltip--left { + left: 0px; + margin-left: -24px; +} +.WebvizRangeFilter__Thumb__Tooltip--right { + right: 0px; + margin-right: -12px; +} + +.WebvizRangeFilter__Thumb__Tooltip::before { + position: absolute; + top: -12px; + border-bottom: 12px black solid; + border-left: 12px transparent solid; + border-right: 12px transparent solid; + content: ""; + left: 50%; + margin-left: -6px; +} diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx new file mode 100644 index 00000000..acfca5b3 --- /dev/null +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -0,0 +1,198 @@ +import { Tooltip } from "@material-ui/core"; +import React from "react"; + +import "./thumb.css"; + +export type ThumbProps = { + minValue: number; + maxValue: number; + fromValue: number; + toValue: number; + left: number; + width: number; + onValuesChange: (fromValue: number, toValue: number) => void; +}; + +export const Thumb: React.FC = (props) => { + const [isSingleValue, setIsSingleValue] = React.useState(true); + const [mouseTarget, setMouseTarget] = + React.useState(null); + const [fromValue, setFromValue] = React.useState(props.fromValue); + const [toValue, setToValue] = React.useState(props.toValue); + const [tooltipVisible, setTooltipVisible] = React.useState(false); + + const thumbRef = React.useRef(null); + const handleRef = React.useRef(null); + const leftHandleRef = React.useRef(null); + const rightHandleRef = React.useRef(null); + + React.useEffect(() => { + setFromValue(props.fromValue); + setToValue(props.toValue); + }, [props.fromValue, props.toValue]); + + const pixelToValue = (px: number) => { + const deltaPixel = px - props.left; + return (deltaPixel / props.width) * (props.maxValue - props.minValue); + }; + + const valueToPixel = (value: number) => { + return ( + (props.width * (value - props.minValue)) / + (props.maxValue - props.minValue) + ); + }; + + React.useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if ( + [ + handleRef.current, + leftHandleRef.current, + rightHandleRef.current, + ].includes(e.target as HTMLDivElement) + ) { + setMouseTarget(e.target as HTMLDivElement); + } + e.preventDefault(); + e.stopPropagation(); + }; + const handleMouseUp = () => { + setMouseTarget(null); + }; + const handleMouseMove = (e: MouseEvent) => { + if (mouseTarget === null) { + return; + } + if (isSingleValue && mouseTarget === handleRef.current) { + setFromValue( + Math.max( + Math.min(pixelToValue(e.clientX), props.maxValue), + props.minValue + ) + ); + setToValue( + Math.max( + Math.min(pixelToValue(e.clientX), props.maxValue), + props.minValue + ) + ); + } else if (mouseTarget === leftHandleRef.current) { + const newValue = Math.max( + Math.min(pixelToValue(e.clientX), props.maxValue), + props.minValue + ); + setFromValue(newValue); + setIsSingleValue(newValue === toValue); + } else if (mouseTarget === rightHandleRef.current) { + const newValue = Math.max( + Math.min(pixelToValue(e.clientX), props.maxValue), + props.minValue + ); + setToValue(newValue); + setIsSingleValue(newValue === fromValue); + } + }; + if (thumbRef.current) { + thumbRef.current.addEventListener("mousedown", handleMouseDown); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("mousemove", handleMouseMove); + } + return () => { + if (thumbRef.current) { + thumbRef.current.removeEventListener( + "mousedown", + handleMouseDown + ); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("mousemove", handleMouseMove); + } + }; + }, [ + thumbRef.current, + handleRef.current, + leftHandleRef.current, + rightHandleRef.current, + mouseTarget, + setMouseTarget, + setFromValue, + setToValue, + props.minValue, + props.maxValue, + ]); + + return ( +
+
+ {mouseTarget === rightHandleRef.current ? toValue : fromValue} +
+ {isSingleValue && ( +
{ + setIsSingleValue(false); + if (toValue < props.maxValue) { + setToValue( + Math.min( + toValue + + (props.maxValue - props.minValue) / 50, + props.maxValue + ) + ); + } else { + setFromValue( + Math.max( + fromValue - + (props.maxValue - props.minValue) / 50, + props.minValue + ) + ); + } + }} + >
+ )} + {!isSingleValue && ( + <> +
+ + + +
+
+
+ + + +
+ + )} +
+ ); +}; diff --git a/react/src/lib/components/RangeFilter/components/index.ts b/react/src/lib/components/RangeFilter/components/index.ts new file mode 100644 index 00000000..7adba600 --- /dev/null +++ b/react/src/lib/components/RangeFilter/components/index.ts @@ -0,0 +1 @@ +export { Thumb } from "./Thumb"; diff --git a/react/src/lib/components/RangeFilter/index.ts b/react/src/lib/components/RangeFilter/index.ts new file mode 100644 index 00000000..d436c7d2 --- /dev/null +++ b/react/src/lib/components/RangeFilter/index.ts @@ -0,0 +1 @@ +export { RangeFilter } from "./range-filter"; diff --git a/react/src/lib/components/RangeFilter/range-filter.css b/react/src/lib/components/RangeFilter/range-filter.css new file mode 100644 index 00000000..a000f6bb --- /dev/null +++ b/react/src/lib/components/RangeFilter/range-filter.css @@ -0,0 +1,6 @@ +.WebvizRangeFilter__Track { + position: relative; + background-color: #898989; + height: 3px; + width: 300px; +} diff --git a/react/src/lib/components/RangeFilter/range-filter.tsx b/react/src/lib/components/RangeFilter/range-filter.tsx new file mode 100644 index 00000000..1eff3bca --- /dev/null +++ b/react/src/lib/components/RangeFilter/range-filter.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { Thumb } from "./components"; + +import "./range-filter.css"; + +export type RangeFilterProps = { + minValue: number; + maxValue: number; +}; + +type Thumb = { + fromValue: number; + toValue: number; +}; + +export const RangeFilter: React.FC = (props) => { + const [thumbs, setThumbs] = React.useState([]); + const [trackLeft, setTrackLeft] = React.useState(0); + const [trackWidth, setTrackWidth] = React.useState(0); + const trackRef = React.useRef(null); + const resizeObserver = React.useRef(null); + + React.useEffect(() => { + resizeObserver.current = new ResizeObserver((entries) => { + entries.forEach((entry) => { + if (entry.contentRect) { + const contentRect: DOMRect = Array.isArray( + entry.contentRect + ) + ? entry.contentRect[0] + : entry.contentRect; + setTrackWidth(contentRect.width); + setTrackLeft( + trackRef.current?.getBoundingClientRect().left || 0 + ); + } + }); + }); + }, [setTrackWidth, setTrackLeft, trackRef.current]); + + React.useEffect(() => { + if (trackRef.current && resizeObserver.current) { + resizeObserver.current.observe(trackRef.current); + } + return () => { + if (trackRef.current && resizeObserver.current) { + resizeObserver.current.unobserve(trackRef.current); + } + }; + }, [trackRef.current, resizeObserver.current]); + + const pixelToValue = (px: number) => { + const deltaPixel = px - trackLeft; + return (deltaPixel / trackWidth) * (props.maxValue - props.minValue); + }; + + const handleMouseClick = (e: React.MouseEvent) => { + if (e.target === trackRef.current) { + setThumbs([ + ...thumbs, + { + fromValue: pixelToValue(e.clientX), + toValue: pixelToValue(e.clientX), + }, + ]); + } + }; + + return ( +
+
handleMouseClick(e)} + > + {thumbs.map((thumb, index) => ( + + setThumbs( + thumbs.map((el, idx) => + index === idx + ? { + fromValue: fromValue, + toValue: toValue, + } + : el + ) + ) + } + /> + ))} +
+
+ ); +}; diff --git a/react/src/lib/index.ts b/react/src/lib/index.ts index a07b0a3c..b47acd46 100644 --- a/react/src/lib/index.ts +++ b/react/src/lib/index.ts @@ -31,6 +31,7 @@ import { WebvizSettingsGroup } from "./components/WebvizSettingsGroup"; import { WebvizPluginLayoutColumn } from "./components/WebvizPluginLayoutColumn/WebvizPluginLayoutColumn"; import { WebvizPluginLayoutRow } from "./components/WebvizPluginLayoutRow/WebvizPluginLayoutRow"; import { WebvizPluginLoadingIndicator } from "./components/WebvizPluginLoadingIndicator"; +import { RangeFilter } from "./components/RangeFilter"; import "./components/FlexBox/flexbox.css"; import "./components/Layout"; @@ -60,4 +61,5 @@ export { Overlay, ScrollArea, Dialog, + RangeFilter, }; From a53d07a3aebd62073df06c067108d2f227413adb Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Mon, 25 Jul 2022 09:38:10 +0200 Subject: [PATCH 2/9] Removed MUI Tooltip --- .../RangeFilter/components/Thumb/thumb.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index acfca5b3..5ad6e103 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -1,4 +1,3 @@ -import { Tooltip } from "@material-ui/core"; import React from "react"; import "./thumb.css"; @@ -19,7 +18,6 @@ export const Thumb: React.FC = (props) => { React.useState(null); const [fromValue, setFromValue] = React.useState(props.fromValue); const [toValue, setToValue] = React.useState(props.toValue); - const [tooltipVisible, setTooltipVisible] = React.useState(false); const thumbRef = React.useRef(null); const handleRef = React.useRef(null); @@ -171,11 +169,7 @@ export const Thumb: React.FC = (props) => {
- - - -
+ >
= (props) => {
- - - -
+ >
)} From 05268f5eae61e7577ea77bafd21fdf951376ff8f Mon Sep 17 00:00:00 2001 From: Ruben Date: Tue, 26 Jul 2022 17:18:47 +0200 Subject: [PATCH 3/9] Improved design and animations --- .../RangeFilter/components/Thumb/thumb.css | 41 +++++--- .../RangeFilter/components/Thumb/thumb.tsx | 48 +++++++++- .../components/RangeFilter/range-filter.css | 21 +++- .../components/RangeFilter/range-filter.tsx | 96 ++++++++++++++++--- 4 files changed, 177 insertions(+), 29 deletions(-) diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css index 9d33d20a..36593383 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -1,39 +1,49 @@ .WebvizRangeFilter__Thumb { position: absolute; - margin-top: -5px; - margin-left: -6px; + margin-top: -6px; + margin-left: -7px; display: flex; + z-index: 10; } .WebvizRangeFilter__Thumb__Handle { - background-color: black; + background-color: white; + border: 1px rgb(119, 119, 119) solid; border-radius: 50%; - width: 12px; - height: 12px; + width: 14px; + height: 14px; cursor: pointer; + -webkit-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); + box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); } .WebvizRangeFilter__Thumb__LeftHandle { - background-color: black; + background-color: white; + border: 1px rgb(119, 119, 119) solid; border-top-left-radius: 6px; border-bottom-left-radius: 6px; width: 6px; - height: 12px; + height: 14px; cursor: pointer; } .WebvizRangeFilter__Thumb__RightHandle { - background-color: black; + background-color: white; + border: 1px rgb(119, 119, 119) solid; border-top-right-radius: 6px; border-bottom-right-radius: 6px; width: 6px; - height: 12px; + height: 14px; cursor: pointer; } .WebvizRangeFilter__Thumb__Bar { - background-color: rgb(91, 82, 255); - height: 12px; + background-color: #246e78; + border-top: 1px rgb(119, 119, 119) solid; + border-bottom: 1px rgb(119, 119, 119) solid; + height: 14px; + animation: 1s width forwards; } .WebvizRangeFilter__Thumb__Tooltip { @@ -65,3 +75,12 @@ left: 50%; margin-left: -6px; } + +@keyframes width { + 0% { + transform: scaleX(0); + } + 100% { + transform: scaleX(100%); + } +} diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index 5ad6e103..0a2a6fe0 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -2,6 +2,8 @@ import React from "react"; import "./thumb.css"; +import "animate.css"; + export type ThumbProps = { minValue: number; maxValue: number; @@ -10,19 +12,33 @@ export type ThumbProps = { left: number; width: number; onValuesChange: (fromValue: number, toValue: number) => void; + onMouseOver: () => void; + onMouseLeave: () => void; }; export const Thumb: React.FC = (props) => { const [isSingleValue, setIsSingleValue] = React.useState(true); + const [wasRange, setWasRange] = React.useState(false); const [mouseTarget, setMouseTarget] = React.useState(null); const [fromValue, setFromValue] = React.useState(props.fromValue); const [toValue, setToValue] = React.useState(props.toValue); + const [animation, setAnimation] = React.useState(""); const thumbRef = React.useRef(null); const handleRef = React.useRef(null); const leftHandleRef = React.useRef(null); const rightHandleRef = React.useRef(null); + const animationRef = + React.useRef | null>(null); + + React.useEffect(() => { + return () => { + if (animationRef.current) { + clearTimeout(animationRef.current); + } + }; + }, []); React.useEffect(() => { setFromValue(props.fromValue); @@ -41,6 +57,30 @@ export const Thumb: React.FC = (props) => { ); }; + React.useEffect(() => { + if (isSingleValue && wasRange) { + setAnimation(" animate__animated animate__rubberBand"); + setTimeout(() => setAnimation(""), 1000); + setWasRange(false); + } else if (isSingleValue) { + setAnimation(" animate__animated animate__bounceIn"); + setTimeout(() => setAnimation(""), 1000); + } + + return () => { + if (animationRef.current) { + clearTimeout(animationRef.current); + } + }; + }, [ + isSingleValue, + wasRange, + animationRef, + setAnimation, + setTimeout, + setWasRange, + ]); + React.useEffect(() => { const handleMouseDown = (e: MouseEvent) => { if ( @@ -121,12 +161,14 @@ export const Thumb: React.FC = (props) => { return (
props.onMouseOver()} + onMouseLeave={() => props.onMouseLeave()} >
= (props) => {
{isSingleValue && (
{ setIsSingleValue(false); diff --git a/react/src/lib/components/RangeFilter/range-filter.css b/react/src/lib/components/RangeFilter/range-filter.css index a000f6bb..08276941 100644 --- a/react/src/lib/components/RangeFilter/range-filter.css +++ b/react/src/lib/components/RangeFilter/range-filter.css @@ -1,6 +1,25 @@ .WebvizRangeFilter__Track { position: relative; background-color: #898989; - height: 3px; + height: 4px; width: 300px; + border-radius: 2px; + -webkit-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); + -moz-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); + box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); +} + +.WebvizRangeFilter__HoverIndicator { + position: absolute; + background-color: white; + border: 1px rgb(119, 119, 119) solid; + border-radius: 50%; + width: 14px; + height: 14px; + margin-top: -6px; + margin-left: -7px; + opacity: 0.5; + -webkit-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); + -moz-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); + box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); } diff --git a/react/src/lib/components/RangeFilter/range-filter.tsx b/react/src/lib/components/RangeFilter/range-filter.tsx index 1eff3bca..cb8880f8 100644 --- a/react/src/lib/components/RangeFilter/range-filter.tsx +++ b/react/src/lib/components/RangeFilter/range-filter.tsx @@ -1,3 +1,5 @@ +import { Point } from "../../shared-types/point"; +import { ORIGIN } from "../../utils/geometry"; import React from "react"; import { Thumb } from "./components"; @@ -19,6 +21,8 @@ export const RangeFilter: React.FC = (props) => { const [trackWidth, setTrackWidth] = React.useState(0); const trackRef = React.useRef(null); const resizeObserver = React.useRef(null); + const [cursorPosition, setCursorPosition] = React.useState(ORIGIN); + const [hovered, setHovered] = React.useState(false); React.useEffect(() => { resizeObserver.current = new ResizeObserver((entries) => { @@ -38,6 +42,66 @@ export const RangeFilter: React.FC = (props) => { }); }, [setTrackWidth, setTrackLeft, trackRef.current]); + React.useEffect(() => { + const handleMouseOver = () => { + setHovered(true); + }; + const handleMouseLeave = () => { + setHovered(false); + }; + const handleMouseMove = (e: MouseEvent) => { + if (trackRef.current) { + const boundingClientRect = + trackRef.current.getBoundingClientRect(); + setCursorPosition({ + x: Math.min( + trackWidth, + Math.max(0, e.clientX - boundingClientRect.left) + ), + y: e.clientY - boundingClientRect.top, + }); + } + }; + + if (trackRef.current) { + trackRef.current.addEventListener( + "mouseover", + handleMouseOver, + false + ); + trackRef.current.addEventListener( + "mouseleave", + handleMouseLeave, + false + ); + trackRef.current.addEventListener( + "mousemove", + handleMouseMove, + false + ); + } + + return () => { + if (trackRef.current) { + trackRef.current.removeEventListener( + "mouseover", + handleMouseOver, + false + ); + trackRef.current.removeEventListener( + "mouseleave", + handleMouseLeave, + false + ); + trackRef.current.removeEventListener( + "mousemove", + handleMouseMove, + false + ); + } + }; + }, [setHovered, setCursorPosition, trackRef.current, trackWidth]); + React.useEffect(() => { if (trackRef.current && resizeObserver.current) { resizeObserver.current.observe(trackRef.current); @@ -55,24 +119,18 @@ export const RangeFilter: React.FC = (props) => { }; const handleMouseClick = (e: React.MouseEvent) => { - if (e.target === trackRef.current) { - setThumbs([ - ...thumbs, - { - fromValue: pixelToValue(e.clientX), - toValue: pixelToValue(e.clientX), - }, - ]); - } + setThumbs([ + ...thumbs, + { + fromValue: pixelToValue(e.clientX), + toValue: pixelToValue(e.clientX), + }, + ]); }; return (
-
handleMouseClick(e)} - > +
{thumbs.map((thumb, index) => ( = (props) => { ) ) } + onMouseLeave={() => setHovered(true)} + onMouseOver={() => setHovered(false)} /> ))} +
handleMouseClick(e)} + />
); From ada201ec07614ee87ee2a71d2f41c922b79acba1 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Fri, 29 Jul 2022 10:03:27 +0200 Subject: [PATCH 4/9] Improved implementation --- react/src/demo/App.tsx | 2 +- .../RangeFilter/components/Thumb/thumb.css | 69 +++----- .../RangeFilter/components/Thumb/thumb.tsx | 155 ++++++++++++++---- .../components/RangeFilter/range-filter.css | 78 +++++++-- .../components/RangeFilter/range-filter.tsx | 150 ++++++++++++++--- 5 files changed, 344 insertions(+), 110 deletions(-) diff --git a/react/src/demo/App.tsx b/react/src/demo/App.tsx index 4b2da8a6..9f6de9ff 100644 --- a/react/src/demo/App.tsx +++ b/react/src/demo/App.tsx @@ -53,7 +53,7 @@ const App: React.FC = () => { return (
- + {currentPage.url.split("#")[1] === "dialog" && ( <> diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css index 36593383..c2c3f1ba 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -1,79 +1,58 @@ .WebvizRangeFilter__Thumb { position: absolute; - margin-top: -6px; - margin-left: -7px; display: flex; z-index: 10; + height: 100%; } .WebvizRangeFilter__Thumb__Handle { - background-color: white; - border: 1px rgb(119, 119, 119) solid; - border-radius: 50%; - width: 14px; - height: 14px; - cursor: pointer; + background-color: black; + width: 1px; + height: 100%; + cursor: ew-resize; -webkit-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); -moz-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); } -.WebvizRangeFilter__Thumb__LeftHandle { - background-color: white; - border: 1px rgb(119, 119, 119) solid; - border-top-left-radius: 6px; - border-bottom-left-radius: 6px; - width: 6px; - height: 14px; - cursor: pointer; -} - -.WebvizRangeFilter__Thumb__RightHandle { - background-color: white; - border: 1px rgb(119, 119, 119) solid; - border-top-right-radius: 6px; - border-bottom-right-radius: 6px; - width: 6px; - height: 14px; - cursor: pointer; +.WebvizRangeFilter__Thumb__Handle:hover, +.WebvizRangeFilter__Thumb__Handle--active { + transform: scaleX(5) scaleY(1.2); } .WebvizRangeFilter__Thumb__Bar { background-color: #246e78; - border-top: 1px rgb(119, 119, 119) solid; - border-bottom: 1px rgb(119, 119, 119) solid; - height: 14px; - animation: 1s width forwards; + height: 100%; + -webkit-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); + -moz-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); + box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); + border-radius: 4px; + cursor: ew-resize; +} +.WebvizRangeFilter__Thumb__Bar:hover { + background-color: #35a0ae; + transform: scaleY(1.2); } .WebvizRangeFilter__Thumb__Tooltip { position: absolute; - top: 24px; - background-color: black; - color: white; padding: 4px; width: 40px; text-align: center; + background-color: white; + border-radius: 4px; + top: 26px; } .WebvizRangeFilter__Thumb__Tooltip--left { left: 0px; margin-left: -24px; + display: block; } .WebvizRangeFilter__Thumb__Tooltip--right { right: 0px; - margin-right: -12px; -} - -.WebvizRangeFilter__Thumb__Tooltip::before { - position: absolute; - top: -12px; - border-bottom: 12px black solid; - border-left: 12px transparent solid; - border-right: 12px transparent solid; - content: ""; - left: 50%; - margin-left: -6px; + margin-right: -24px; + display: block; } @keyframes width { diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index 0a2a6fe0..4a810e22 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -7,13 +7,17 @@ import "animate.css"; export type ThumbProps = { minValue: number; maxValue: number; + minRangeValue: number; + maxRangeValue: number; fromValue: number; toValue: number; - left: number; + trackBoundingClientRect: DOMRect; width: number; + step: number; onValuesChange: (fromValue: number, toValue: number) => void; onMouseOver: () => void; onMouseLeave: () => void; + onRemove: () => void; }; export const Thumb: React.FC = (props) => { @@ -24,11 +28,13 @@ export const Thumb: React.FC = (props) => { const [fromValue, setFromValue] = React.useState(props.fromValue); const [toValue, setToValue] = React.useState(props.toValue); const [animation, setAnimation] = React.useState(""); + const [mouseDownLeft, setMouseDownLeft] = React.useState(0); const thumbRef = React.useRef(null); const handleRef = React.useRef(null); const leftHandleRef = React.useRef(null); const rightHandleRef = React.useRef(null); + const barRef = React.useRef(null); const animationRef = React.useRef | null>(null); @@ -46,10 +52,14 @@ export const Thumb: React.FC = (props) => { }, [props.fromValue, props.toValue]); const pixelToValue = (px: number) => { - const deltaPixel = px - props.left; + const deltaPixel = px - props.trackBoundingClientRect.x; return (deltaPixel / props.width) * (props.maxValue - props.minValue); }; + const closestValidValue = (value: number) => { + return Math.round(value / props.step) * props.step; + }; + const valueToPixel = (value: number) => { return ( (props.width * (value - props.minValue)) / @@ -88,15 +98,27 @@ export const Thumb: React.FC = (props) => { handleRef.current, leftHandleRef.current, rightHandleRef.current, + barRef.current, ].includes(e.target as HTMLDivElement) ) { setMouseTarget(e.target as HTMLDivElement); + setMouseDownLeft(e.clientX); } e.preventDefault(); e.stopPropagation(); }; - const handleMouseUp = () => { + const handleMouseUp = (e: MouseEvent) => { setMouseTarget(null); + if ( + [ + handleRef.current, + leftHandleRef.current, + rightHandleRef.current, + barRef.current, + ].includes(e.target as HTMLDivElement) + ) { + //props.onValuesChange(fromValue, toValue); + } }; const handleMouseMove = (e: MouseEvent) => { if (mouseTarget === null) { @@ -105,30 +127,79 @@ export const Thumb: React.FC = (props) => { if (isSingleValue && mouseTarget === handleRef.current) { setFromValue( Math.max( - Math.min(pixelToValue(e.clientX), props.maxValue), - props.minValue + Math.min( + closestValidValue(pixelToValue(e.clientX)), + props.maxRangeValue + ), + props.minRangeValue ) ); setToValue( Math.max( - Math.min(pixelToValue(e.clientX), props.maxValue), - props.minValue + Math.min( + closestValidValue(pixelToValue(e.clientX)), + props.maxRangeValue + ), + props.minRangeValue ) ); } else if (mouseTarget === leftHandleRef.current) { const newValue = Math.max( - Math.min(pixelToValue(e.clientX), props.maxValue), - props.minValue + Math.min( + closestValidValue(pixelToValue(e.clientX)), + props.maxRangeValue + ), + props.minRangeValue ); setFromValue(newValue); setIsSingleValue(newValue === toValue); } else if (mouseTarget === rightHandleRef.current) { const newValue = Math.max( - Math.min(pixelToValue(e.clientX), props.maxValue), - props.minValue + Math.min( + closestValidValue(pixelToValue(e.clientX)), + props.maxRangeValue + ), + props.minRangeValue ); setToValue(newValue); setIsSingleValue(newValue === fromValue); + } else if (mouseTarget === barRef.current) { + const range = toValue - fromValue; + const newFromValue = Math.max( + Math.min( + props.maxRangeValue - range, + fromValue + + closestValidValue( + pixelToValue(e.clientX) - + pixelToValue(mouseDownLeft) + ) + ), + props.minRangeValue + ); + const newToValue = Math.min( + Math.max( + props.minRangeValue + range, + toValue + + closestValidValue( + pixelToValue(e.clientX) - + pixelToValue(mouseDownLeft) + ) + ), + props.maxRangeValue + ); + setFromValue(newFromValue); + setToValue(newToValue); + } + if (mouseTarget) { + if ( + Math.abs( + e.clientY - + (props.trackBoundingClientRect.y + + props.trackBoundingClientRect.height / 2) + ) > props.trackBoundingClientRect.height + ) { + props.onRemove(); + } } }; if (thumbRef.current) { @@ -149,6 +220,7 @@ export const Thumb: React.FC = (props) => { }, [ thumbRef.current, handleRef.current, + barRef.current, leftHandleRef.current, rightHandleRef.current, mouseTarget, @@ -157,6 +229,10 @@ export const Thumb: React.FC = (props) => { setToValue, props.minValue, props.maxValue, + props.minRangeValue, + props.maxRangeValue, + mouseDownLeft, + setMouseDownLeft, ]); return ( @@ -165,40 +241,50 @@ export const Thumb: React.FC = (props) => { ref={thumbRef} style={{ left: valueToPixel(fromValue), - width: valueToPixel(toValue) - valueToPixel(fromValue) + 16, + width: Math.max( + valueToPixel(toValue) - valueToPixel(fromValue), + 1 + ), }} onMouseOver={() => props.onMouseOver()} onMouseLeave={() => props.onMouseLeave()} > -
- {mouseTarget === rightHandleRef.current ? toValue : fromValue} -
+ {mouseTarget && + (mouseTarget === barRef.current || + mouseTarget === leftHandleRef.current || + mouseTarget === handleRef.current) && ( +
+ {fromValue} +
+ )} + {mouseTarget && + (mouseTarget === barRef.current || + mouseTarget === rightHandleRef.current) && ( +
+ {toValue} +
+ )} {isSingleValue && (
{ setIsSingleValue(false); - if (toValue < props.maxValue) { + if (toValue < props.maxValue - props.step * 10) { setToValue( Math.min( - toValue + - (props.maxValue - props.minValue) / 50, + toValue + props.step * 10, props.maxValue ) ); } else { setFromValue( Math.max( - fromValue - - (props.maxValue - props.minValue) / 50, + fromValue - props.step * 10, props.minValue ) ); @@ -209,18 +295,27 @@ export const Thumb: React.FC = (props) => { {!isSingleValue && ( <>
diff --git a/react/src/lib/components/RangeFilter/range-filter.css b/react/src/lib/components/RangeFilter/range-filter.css index 08276941..cf1f2ee8 100644 --- a/react/src/lib/components/RangeFilter/range-filter.css +++ b/react/src/lib/components/RangeFilter/range-filter.css @@ -1,9 +1,14 @@ +.WebvizRangeFilter { + padding-top: 16px; + width: 300px; + padding: 16px; +} + .WebvizRangeFilter__Track { position: relative; background-color: #898989; - height: 4px; - width: 300px; - border-radius: 2px; + height: 20px; + border-radius: 4px; -webkit-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); -moz-box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); @@ -11,15 +16,66 @@ .WebvizRangeFilter__HoverIndicator { position: absolute; - background-color: white; - border: 1px rgb(119, 119, 119) solid; - border-radius: 50%; - width: 14px; - height: 14px; - margin-top: -6px; - margin-left: -7px; - opacity: 0.5; + background-color: rgb(0, 159, 3); + width: 1px; + height: 100%; + opacity: 0.75; -webkit-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); -moz-box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); box-shadow: 0px 0px 7px -3px rgba(0, 0, 0, 0.75); } + +.WebvizRangeFilter__Ticks { + height: 10px; + position: relative; + margin-top: 8px; + display: flex; +} + +.WebvizRangeFilter__Ticks__Value { + position: absolute; + margin-top: 2px; + width: 40px; + text-align: center; +} + +.WebvizRangeFilter__Ticks__Value { + position: absolute; + margin-top: 2px; + width: 40px; + text-align: center; +} + +.WebvizRangeFilter__Ticks__Value--left { + margin-left: -20px; + left: 0px; +} + +.WebvizRangeFilter__Ticks__Value--right { + margin-right: -20px; + right: 0px; +} + +.WebvizRangeFilter__Tick { + width: 1px; + background-color: transparent; + height: 10px; + position: absolute; +} + +.WebvizRangeFilter__ValueIndicator { + position: absolute; + z-index: 100; + text-align: center; + width: 40px; + margin-left: -24px; + color: rgb(0, 159, 3); + background-color: white; + padding: 4px; + border-radius: 4px; + top: -2px; +} + +.WebvizRangeFilter__ValueIndicatorTrack { + position: relative; +} diff --git a/react/src/lib/components/RangeFilter/range-filter.tsx b/react/src/lib/components/RangeFilter/range-filter.tsx index cb8880f8..58b87f38 100644 --- a/react/src/lib/components/RangeFilter/range-filter.tsx +++ b/react/src/lib/components/RangeFilter/range-filter.tsx @@ -4,25 +4,84 @@ import React from "react"; import { Thumb } from "./components"; import "./range-filter.css"; +import { uniqueId } from "lodash"; export type RangeFilterProps = { minValue: number; maxValue: number; + step: number; + showTicks?: boolean; }; -type Thumb = { +export class ThumbInstance { + id: string; fromValue: number; toValue: number; + constructor(from: number, to: number) { + this.id = uniqueId(); + this.fromValue = from; + this.toValue = to; + } + + setFromValue(toValue: number) { + this.fromValue = toValue; + } + + setToValue(toValue: number) { + this.toValue = toValue; + } + + setRange(from: number, to: number) { + this.fromValue = from; + this.toValue = to; + } +} + +type Tick = { + value: number; + left: number; }; export const RangeFilter: React.FC = (props) => { - const [thumbs, setThumbs] = React.useState([]); - const [trackLeft, setTrackLeft] = React.useState(0); + const [thumbs, setThumbs] = React.useState([]); + const [trackBoundingClientRect, setTrackBoundingClientRect] = + React.useState(new DOMRect(0, 0, 0, 0)); const [trackWidth, setTrackWidth] = React.useState(0); const trackRef = React.useRef(null); const resizeObserver = React.useRef(null); const [cursorPosition, setCursorPosition] = React.useState(ORIGIN); const [hovered, setHovered] = React.useState(false); + const [tickPositions, setTickPositions] = React.useState([]); + + const closestValidValue = (value: number) => { + return Math.round(value / props.step) * props.step; + }; + + React.useEffect(() => { + if (props.showTicks && trackWidth > 0) { + const range = props.maxValue - props.minValue; + let step = props.step; + const maxNumberTicks = Math.floor(trackWidth / 2); + while (range / step > maxNumberTicks) { + step += props.step; + } + const dStep = trackWidth / (range / step); + + const ticks = []; + for (let i = 0; i < range / step; i++) { + ticks.push({ value: i * step, left: i * dStep }); + } + + setTickPositions(ticks); + } + }, [ + trackWidth, + props.step, + props.showTicks, + props.minValue, + props.maxValue, + setTickPositions, + ]); React.useEffect(() => { resizeObserver.current = new ResizeObserver((entries) => { @@ -34,13 +93,14 @@ export const RangeFilter: React.FC = (props) => { ? entry.contentRect[0] : entry.contentRect; setTrackWidth(contentRect.width); - setTrackLeft( - trackRef.current?.getBoundingClientRect().left || 0 + setTrackBoundingClientRect( + trackRef.current?.getBoundingClientRect() || + trackBoundingClientRect ); } }); }); - }, [setTrackWidth, setTrackLeft, trackRef.current]); + }, [setTrackWidth, setTrackBoundingClientRect, trackRef.current]); React.useEffect(() => { const handleMouseOver = () => { @@ -114,17 +174,24 @@ export const RangeFilter: React.FC = (props) => { }, [trackRef.current, resizeObserver.current]); const pixelToValue = (px: number) => { - const deltaPixel = px - trackLeft; + const deltaPixel = px - trackBoundingClientRect.x; return (deltaPixel / trackWidth) * (props.maxValue - props.minValue); }; + const valueToPixel = (value: number) => { + return ( + (trackWidth * (value - props.minValue)) / + (props.maxValue - props.minValue) + ); + }; + const handleMouseClick = (e: React.MouseEvent) => { setThumbs([ ...thumbs, - { - fromValue: pixelToValue(e.clientX), - toValue: pixelToValue(e.clientX), - }, + new ThumbInstance( + closestValidValue(pixelToValue(e.clientX)), + closestValidValue(pixelToValue(e.clientX)) + ), ]); }; @@ -133,27 +200,34 @@ export const RangeFilter: React.FC = (props) => {
{thumbs.map((thumb, index) => ( el.toValue < thumb.fromValue) + .map((el) => el.toValue + 1), + props.minValue + )} + maxRangeValue={Math.min( + ...thumbs + .filter((el) => el.fromValue > thumb.toValue) + .map((el) => el.fromValue - 1), + props.maxValue + )} fromValue={thumb.fromValue} toValue={thumb.toValue} - left={trackLeft} + step={props.step} + trackBoundingClientRect={trackBoundingClientRect} width={trackWidth} onValuesChange={(fromValue, toValue) => - setThumbs( - thumbs.map((el, idx) => - index === idx - ? { - fromValue: fromValue, - toValue: toValue, - } - : el - ) - ) + thumbs.at(index)?.setRange(fromValue, toValue) } onMouseLeave={() => setHovered(true)} onMouseOver={() => setHovered(false)} + onRemove={() => + setThumbs(thumbs.filter((_, i) => i !== index)) + } /> ))}
= (props) => { onClick={(e) => handleMouseClick(e)} />
+
+ {tickPositions.map((tick) => ( +
+ ))} +
+ {props.minValue} +
+
+ {props.maxValue} +
+
+ {closestValidValue( + pixelToValue( + cursorPosition.x + trackBoundingClientRect.left + ) + )} +
+
); }; From f95f0e343812717d0a812cae9d3d3971769ad8d3 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Fri, 29 Jul 2022 17:04:10 +0200 Subject: [PATCH 5/9] Finished first implementation --- react/src/demo/App.tsx | 12 +- .../RangeFilter/components/Thumb/thumb.css | 8 +- .../RangeFilter/components/Thumb/thumb.tsx | 146 ++++++++++-------- .../components/RangeFilter/range-filter.tsx | 109 +++++++------ react/src/lib/utils/geometry.ts | 12 ++ 5 files changed, 163 insertions(+), 124 deletions(-) diff --git a/react/src/demo/App.tsx b/react/src/demo/App.tsx index 9f6de9ff..9fcd075b 100644 --- a/react/src/demo/App.tsx +++ b/react/src/demo/App.tsx @@ -45,6 +45,8 @@ const App: React.FC = () => { selectedTags: [], }); + const [selectedValues, setSelectedValues] = React.useState([]); + const [currentPage, setCurrentPage] = React.useState({ url: "", }); @@ -53,8 +55,14 @@ const App: React.FC = () => { return (
- - + setSelectedValues(values)} + /> + Selected Values are: {selectedValues.join(", ")} {currentPage.url.split("#")[1] === "dialog" && ( <>

Dialog

diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css index c2c3f1ba..2a713490 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -17,7 +17,10 @@ .WebvizRangeFilter__Thumb__Handle:hover, .WebvizRangeFilter__Thumb__Handle--active { - transform: scaleX(5) scaleY(1.2); + transform: scaleX(8) scaleY(1.2); + border-radius: 4px; + outline: 1px #35a0ae9b solid; + transition: outline 0.1s ease-in-out; } .WebvizRangeFilter__Thumb__Bar { @@ -28,10 +31,13 @@ box-shadow: inset 0px -2px 5px -3px rgba(0, 0, 0, 0.75); border-radius: 4px; cursor: ew-resize; + transition: outline 0.1s ease-in-out; } .WebvizRangeFilter__Thumb__Bar:hover { background-color: #35a0ae; transform: scaleY(1.2); + outline: 5px #35a0ae9b solid; + transition: outline 0.1s ease-in-out; } .WebvizRangeFilter__Thumb__Tooltip { diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index 4a810e22..6bfb145c 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -3,21 +3,21 @@ import React from "react"; import "./thumb.css"; import "animate.css"; +import { ThumbInstance } from "../../range-filter"; export type ThumbProps = { minValue: number; maxValue: number; minRangeValue: number; maxRangeValue: number; - fromValue: number; - toValue: number; trackBoundingClientRect: DOMRect; width: number; step: number; - onValuesChange: (fromValue: number, toValue: number) => void; + thumb: ThumbInstance; onMouseOver: () => void; onMouseLeave: () => void; onRemove: () => void; + updateProps: () => void; }; export const Thumb: React.FC = (props) => { @@ -25,10 +25,9 @@ export const Thumb: React.FC = (props) => { const [wasRange, setWasRange] = React.useState(false); const [mouseTarget, setMouseTarget] = React.useState(null); - const [fromValue, setFromValue] = React.useState(props.fromValue); - const [toValue, setToValue] = React.useState(props.toValue); const [animation, setAnimation] = React.useState(""); - const [mouseDownLeft, setMouseDownLeft] = React.useState(0); + const [mouseDownValueDelta, setMouseDownValueDelta] = + React.useState(0); const thumbRef = React.useRef(null); const handleRef = React.useRef(null); @@ -46,11 +45,6 @@ export const Thumb: React.FC = (props) => { }; }, []); - React.useEffect(() => { - setFromValue(props.fromValue); - setToValue(props.toValue); - }, [props.fromValue, props.toValue]); - const pixelToValue = (px: number) => { const deltaPixel = px - props.trackBoundingClientRect.x; return (deltaPixel / props.width) * (props.maxValue - props.minValue); @@ -102,30 +96,24 @@ export const Thumb: React.FC = (props) => { ].includes(e.target as HTMLDivElement) ) { setMouseTarget(e.target as HTMLDivElement); - setMouseDownLeft(e.clientX); + setMouseDownValueDelta( + closestValidValue(pixelToValue(e.clientX)) - + props.thumb.fromValue + ); + props.onMouseOver(); } e.preventDefault(); e.stopPropagation(); }; - const handleMouseUp = (e: MouseEvent) => { + const handleMouseUp = () => { setMouseTarget(null); - if ( - [ - handleRef.current, - leftHandleRef.current, - rightHandleRef.current, - barRef.current, - ].includes(e.target as HTMLDivElement) - ) { - //props.onValuesChange(fromValue, toValue); - } }; const handleMouseMove = (e: MouseEvent) => { if (mouseTarget === null) { return; } if (isSingleValue && mouseTarget === handleRef.current) { - setFromValue( + props.thumb.setFromValue( Math.max( Math.min( closestValidValue(pixelToValue(e.clientX)), @@ -134,7 +122,7 @@ export const Thumb: React.FC = (props) => { props.minRangeValue ) ); - setToValue( + props.thumb.setToValue( Math.max( Math.min( closestValidValue(pixelToValue(e.clientX)), @@ -151,8 +139,8 @@ export const Thumb: React.FC = (props) => { ), props.minRangeValue ); - setFromValue(newValue); - setIsSingleValue(newValue === toValue); + props.thumb.setFromValue(newValue); + setIsSingleValue(newValue === props.thumb.toValue); } else if (mouseTarget === rightHandleRef.current) { const newValue = Math.max( Math.min( @@ -161,36 +149,37 @@ export const Thumb: React.FC = (props) => { ), props.minRangeValue ); - setToValue(newValue); - setIsSingleValue(newValue === fromValue); + props.thumb.setToValue(newValue); + setIsSingleValue(newValue === props.thumb.fromValue); } else if (mouseTarget === barRef.current) { - const range = toValue - fromValue; + const range = props.thumb.toValue - props.thumb.fromValue; const newFromValue = Math.max( Math.min( props.maxRangeValue - range, - fromValue + - closestValidValue( - pixelToValue(e.clientX) - - pixelToValue(mouseDownLeft) - ) + closestValidValue(pixelToValue(e.clientX)) - + mouseDownValueDelta ), props.minRangeValue ); const newToValue = Math.min( Math.max( props.minRangeValue + range, - toValue + - closestValidValue( - pixelToValue(e.clientX) - - pixelToValue(mouseDownLeft) - ) + closestValidValue(pixelToValue(e.clientX)) + + (range - mouseDownValueDelta) ), props.maxRangeValue ); - setFromValue(newFromValue); - setToValue(newToValue); + props.thumb.setFromValue(newFromValue); + props.thumb.setToValue(newToValue); } - if (mouseTarget) { + if ( + [ + handleRef.current, + leftHandleRef.current, + rightHandleRef.current, + barRef.current, + ].includes(mouseTarget) + ) { if ( Math.abs( e.clientY - @@ -199,6 +188,7 @@ export const Thumb: React.FC = (props) => { ) > props.trackBoundingClientRect.height ) { props.onRemove(); + setMouseTarget(null); } } }; @@ -225,43 +215,60 @@ export const Thumb: React.FC = (props) => { rightHandleRef.current, mouseTarget, setMouseTarget, - setFromValue, - setToValue, + props.thumb.fromValue, + props.thumb.toValue, + props.thumb, props.minValue, props.maxValue, props.minRangeValue, props.maxRangeValue, - mouseDownLeft, - setMouseDownLeft, + mouseDownValueDelta, + setMouseDownValueDelta, + props.onMouseOver, + props.onMouseLeave, ]); + const width = Math.max( + valueToPixel(props.thumb.toValue) - valueToPixel(props.thumb.fromValue), + 1 + ); + return (
props.onMouseOver()} - onMouseLeave={() => props.onMouseLeave()} + onMouseLeave={() => { + if (mouseTarget === null) props.onMouseLeave(); + }} > {mouseTarget && (mouseTarget === barRef.current || mouseTarget === leftHandleRef.current || mouseTarget === handleRef.current) && ( -
- {fromValue} +
+ {props.thumb.fromValue}
)} {mouseTarget && (mouseTarget === barRef.current || mouseTarget === rightHandleRef.current) && ( -
- {toValue} +
+ {props.thumb.toValue}
)} {isSingleValue && ( @@ -274,21 +281,29 @@ export const Thumb: React.FC = (props) => { ref={handleRef} onDoubleClick={() => { setIsSingleValue(false); - if (toValue < props.maxValue - props.step * 10) { - setToValue( + if ( + props.thumb.toValue < + props.maxValue - props.step * 10 && + props.thumb.toValue <= + props.maxRangeValue - props.step * 3 + ) { + props.thumb.setToValue( Math.min( - toValue + props.step * 10, - props.maxValue + props.thumb.toValue + props.step * 10, + props.maxValue, + props.maxRangeValue ) ); } else { - setFromValue( + props.thumb.setFromValue( Math.max( - fromValue - props.step * 10, - props.minValue + props.thumb.fromValue - props.step * 10, + props.minValue, + props.minRangeValue ) ); } + props.updateProps(); }} >
)} @@ -307,7 +322,8 @@ export const Thumb: React.FC = (props) => { ref={barRef} style={{ width: - valueToPixel(toValue) - valueToPixel(fromValue), + valueToPixel(props.thumb.toValue) - + valueToPixel(props.thumb.fromValue), }} >
void; }; export class ThumbInstance { @@ -37,11 +46,6 @@ export class ThumbInstance { } } -type Tick = { - value: number; - left: number; -}; - export const RangeFilter: React.FC = (props) => { const [thumbs, setThumbs] = React.useState([]); const [trackBoundingClientRect, setTrackBoundingClientRect] = @@ -51,38 +55,11 @@ export const RangeFilter: React.FC = (props) => { const resizeObserver = React.useRef(null); const [cursorPosition, setCursorPosition] = React.useState(ORIGIN); const [hovered, setHovered] = React.useState(false); - const [tickPositions, setTickPositions] = React.useState([]); const closestValidValue = (value: number) => { return Math.round(value / props.step) * props.step; }; - React.useEffect(() => { - if (props.showTicks && trackWidth > 0) { - const range = props.maxValue - props.minValue; - let step = props.step; - const maxNumberTicks = Math.floor(trackWidth / 2); - while (range / step > maxNumberTicks) { - step += props.step; - } - const dStep = trackWidth / (range / step); - - const ticks = []; - for (let i = 0; i < range / step; i++) { - ticks.push({ value: i * step, left: i * dStep }); - } - - setTickPositions(ticks); - } - }, [ - trackWidth, - props.step, - props.showTicks, - props.minValue, - props.maxValue, - setTickPositions, - ]); - React.useEffect(() => { resizeObserver.current = new ResizeObserver((entries) => { entries.forEach((entry) => { @@ -102,6 +79,30 @@ export const RangeFilter: React.FC = (props) => { }); }, [setTrackWidth, setTrackBoundingClientRect, trackRef.current]); + const updateParentProps = React.useCallback( + (givenThumbs?: ThumbInstance[]) => { + const values = []; + const currentThumbs = givenThumbs || thumbs; + for (let i = props.minValue; i <= props.maxValue; i += props.step) { + if ( + currentThumbs.some( + (el) => i >= el.fromValue && i <= el.toValue + ) + ) { + values.push(i); + } + } + props.setProps({ + values: values, + selections: currentThumbs.map((el) => ({ + fromValue: el.fromValue, + toValue: el.toValue, + })), + }); + }, + [thumbs, props.setProps] + ); + React.useEffect(() => { const handleMouseOver = () => { setHovered(true); @@ -123,6 +124,10 @@ export const RangeFilter: React.FC = (props) => { } }; + const handleMouseUp = () => { + updateParentProps(); + }; + if (trackRef.current) { trackRef.current.addEventListener( "mouseover", @@ -139,6 +144,7 @@ export const RangeFilter: React.FC = (props) => { handleMouseMove, false ); + document.addEventListener("mouseup", handleMouseUp); } return () => { @@ -158,9 +164,17 @@ export const RangeFilter: React.FC = (props) => { handleMouseMove, false ); + document.removeEventListener("mouseup", handleMouseUp); } }; - }, [setHovered, setCursorPosition, trackRef.current, trackWidth]); + }, [ + thumbs, + setHovered, + setCursorPosition, + trackRef.current, + trackWidth, + props.setProps, + ]); React.useEffect(() => { if (trackRef.current && resizeObserver.current) { @@ -178,21 +192,16 @@ export const RangeFilter: React.FC = (props) => { return (deltaPixel / trackWidth) * (props.maxValue - props.minValue); }; - const valueToPixel = (value: number) => { - return ( - (trackWidth * (value - props.minValue)) / - (props.maxValue - props.minValue) - ); - }; - const handleMouseClick = (e: React.MouseEvent) => { - setThumbs([ + const newThumbs = [ ...thumbs, new ThumbInstance( closestValidValue(pixelToValue(e.clientX)), closestValidValue(pixelToValue(e.clientX)) ), - ]); + ]; + setThumbs(newThumbs); + updateParentProps(newThumbs); }; return ( @@ -201,6 +210,7 @@ export const RangeFilter: React.FC = (props) => { {thumbs.map((thumb, index) => ( = (props) => { .map((el) => el.fromValue - 1), props.maxValue )} - fromValue={thumb.fromValue} - toValue={thumb.toValue} step={props.step} trackBoundingClientRect={trackBoundingClientRect} width={trackWidth} - onValuesChange={(fromValue, toValue) => - thumbs.at(index)?.setRange(fromValue, toValue) - } onMouseLeave={() => setHovered(true)} onMouseOver={() => setHovered(false)} onRemove={() => setThumbs(thumbs.filter((_, i) => i !== index)) } + updateProps={() => updateParentProps()} /> ))}
= (props) => { />
- {tickPositions.map((tick) => ( -
- ))}
{props.minValue}
diff --git a/react/src/lib/utils/geometry.ts b/react/src/lib/utils/geometry.ts index 0badc95d..05060cff 100644 --- a/react/src/lib/utils/geometry.ts +++ b/react/src/lib/utils/geometry.ts @@ -68,6 +68,18 @@ export const pointIsContained = ( ); }; +export const pointIsContainedInBoundingClientRect = ( + point: Point, + clientRect: DOMRect +): boolean => { + return ( + point.x >= clientRect.x && + point.x <= clientRect.x + clientRect.width && + point.y >= clientRect.y && + point.y <= clientRect.y + clientRect.height + ); +}; + export const sizeSum = (size1: Size, size2: Size): Size => { return { width: size1.width + size2.width, From f3fbff20d4662cb4e4a18e03a2fd54d57104d1fa Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Fri, 29 Jul 2022 17:39:15 +0200 Subject: [PATCH 6/9] Added tooltip for doubleclick expansion --- .../RangeFilter/components/Thumb/thumb.css | 9 + .../RangeFilter/components/Thumb/thumb.tsx | 207 ++++++++++-------- 2 files changed, 120 insertions(+), 96 deletions(-) diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css index 2a713490..0fcec44f 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -6,6 +6,7 @@ } .WebvizRangeFilter__Thumb__Handle { + position: relative; background-color: black; width: 1px; height: 100%; @@ -23,6 +24,14 @@ transition: outline 0.1s ease-in-out; } +.WebvizRangeFilter__Thumb--Tooltip { + position: absolute; + top: -24px; + width: 100%; + color: rgba(0, 0, 0, 0.75); + text-align: center; +} + .WebvizRangeFilter__Thumb__Bar { background-color: #246e78; height: 100%; diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index 6bfb145c..1a726ac6 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -28,6 +28,7 @@ export const Thumb: React.FC = (props) => { const [animation, setAnimation] = React.useState(""); const [mouseDownValueDelta, setMouseDownValueDelta] = React.useState(0); + const [hovered, setHovered] = React.useState(false); const thumbRef = React.useRef(null); const handleRef = React.useRef(null); @@ -234,108 +235,122 @@ export const Thumb: React.FC = (props) => { ); return ( -
props.onMouseOver()} - onMouseLeave={() => { - if (mouseTarget === null) props.onMouseLeave(); - }} - > - {mouseTarget && - (mouseTarget === barRef.current || - mouseTarget === leftHandleRef.current || - mouseTarget === handleRef.current) && ( -
- {props.thumb.fromValue} -
- )} - {mouseTarget && - (mouseTarget === barRef.current || - mouseTarget === rightHandleRef.current) && ( -
- {props.thumb.toValue} -
- )} - {isSingleValue && ( -
{ - setIsSingleValue(false); - if ( - props.thumb.toValue < - props.maxValue - props.step * 10 && - props.thumb.toValue <= - props.maxRangeValue - props.step * 3 - ) { - props.thumb.setToValue( - Math.min( - props.thumb.toValue + props.step * 10, - props.maxValue, - props.maxRangeValue - ) - ); - } else { - props.thumb.setFromValue( - Math.max( - props.thumb.fromValue - props.step * 10, - props.minValue, - props.minRangeValue - ) - ); - } - props.updateProps(); - }} - >
- )} - {!isSingleValue && ( - <> + <> +
+ Double-click to expand to range +
+
{ + props.onMouseOver(); + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + if (mouseTarget === null) props.onMouseLeave(); + }} + > + {mouseTarget && + (mouseTarget === barRef.current || + mouseTarget === leftHandleRef.current || + mouseTarget === handleRef.current) && ( +
+ {props.thumb.fromValue} +
+ )} + {mouseTarget && + (mouseTarget === barRef.current || + mouseTarget === rightHandleRef.current) && ( +
+ {props.thumb.toValue} +
+ )} + {isSingleValue && (
-
{ + setIsSingleValue(false); + if ( + props.thumb.toValue < + props.maxValue - props.step * 10 && + props.thumb.toValue <= + props.maxRangeValue - props.step * 3 + ) { + props.thumb.setToValue( + Math.min( + props.thumb.toValue + props.step * 10, + props.maxValue, + props.maxRangeValue + ) + ); + } else { + props.thumb.setFromValue( + Math.max( + props.thumb.fromValue - props.step * 10, + props.minValue, + props.minRangeValue + ) + ); + } + props.updateProps(); }} >
-
- - )} -
+ )} + {!isSingleValue && ( + <> +
+
+
+ + )} +
+ ); }; From 4153c09e33be4c1aee1d04e5a3cca2064f35d2c1 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Mon, 1 Aug 2022 09:54:56 +0200 Subject: [PATCH 7/9] Adjusted linebreaks in CSS file. --- react/src/lib/components/RangeFilter/components/Thumb/thumb.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css index 0fcec44f..79e37728 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.css +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.css @@ -42,6 +42,7 @@ cursor: ew-resize; transition: outline 0.1s ease-in-out; } + .WebvizRangeFilter__Thumb__Bar:hover { background-color: #35a0ae; transform: scaleY(1.2); @@ -64,6 +65,7 @@ margin-left: -24px; display: block; } + .WebvizRangeFilter__Thumb__Tooltip--right { right: 0px; margin-right: -24px; From 988b5eb0088031cdd338fe5e13f4f0901c2abde1 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Tue, 2 Aug 2022 09:15:11 +0200 Subject: [PATCH 8/9] Adjusted changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d490cf..2f9e0fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [#225](https://github.com/equinor/webviz-core-components/pull/225) - Added `RangeFilter` component which allows for a quick selection of integer ranges. - [#207](https://github.com/equinor/webviz-core-components/pull/207) - Added `storybook` and stories for each component. Added publishment of `storybook` to GitHub workflow. Added `storybook` link to README. - [#219](https://github.com/equinor/webviz-core-components/pull/219) - Implemented components required by the new Webviz Layout Framework (WLF) From ca6d4ae36d8b1867d156b0f508bfe6fe5f141f33 Mon Sep 17 00:00:00 2001 From: Ruben Date: Fri, 5 Aug 2022 18:25:16 +0200 Subject: [PATCH 9/9] Added input field. --- .../RangeFilter/components/Thumb/thumb.tsx | 4 + .../components/RangeFilter/range-filter.css | 11 +- .../components/RangeFilter/range-filter.tsx | 117 +++++++++++++++--- 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx index 1a726ac6..c63d66ef 100644 --- a/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx +++ b/react/src/lib/components/RangeFilter/components/Thumb/thumb.tsx @@ -38,6 +38,10 @@ export const Thumb: React.FC = (props) => { const animationRef = React.useRef | null>(null); + React.useEffect(() => { + setIsSingleValue(props.thumb.fromValue === props.thumb.toValue); + }, [props.thumb.fromValue, props.thumb.toValue]); + React.useEffect(() => { return () => { if (animationRef.current) { diff --git a/react/src/lib/components/RangeFilter/range-filter.css b/react/src/lib/components/RangeFilter/range-filter.css index cf1f2ee8..7aa085c8 100644 --- a/react/src/lib/components/RangeFilter/range-filter.css +++ b/react/src/lib/components/RangeFilter/range-filter.css @@ -56,13 +56,6 @@ right: 0px; } -.WebvizRangeFilter__Tick { - width: 1px; - background-color: transparent; - height: 10px; - position: absolute; -} - .WebvizRangeFilter__ValueIndicator { position: absolute; z-index: 100; @@ -79,3 +72,7 @@ .WebvizRangeFilter__ValueIndicatorTrack { position: relative; } + +.WebvizRangeFilter__Input { + margin-top: 16px; +} diff --git a/react/src/lib/components/RangeFilter/range-filter.tsx b/react/src/lib/components/RangeFilter/range-filter.tsx index 96a604ab..9c9e8103 100644 --- a/react/src/lib/components/RangeFilter/range-filter.tsx +++ b/react/src/lib/components/RangeFilter/range-filter.tsx @@ -1,25 +1,30 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { uniqueId } from "lodash"; +import { TextField } from "@material-ui/core"; + import { Point } from "../../shared-types/point"; import { ORIGIN } from "../../utils/geometry"; -import React from "react"; import { Thumb } from "./components"; import "./range-filter.css"; -import { uniqueId } from "lodash"; + +type Selection = { + fromValue: number; + toValue: number; +}; export type ParentProps = { values: number[]; - selections: { - fromValue: number; - toValue: number; - }[]; + selections: Selection[]; }; export type RangeFilterProps = { minValue: number; maxValue: number; step: number; - showTicks?: boolean; - setProps: (props: ParentProps) => void; + selections?: Selection[]; + setProps?: (props: ParentProps) => void; }; export class ThumbInstance { @@ -32,15 +37,15 @@ export class ThumbInstance { this.toValue = to; } - setFromValue(toValue: number) { + setFromValue(toValue: number): void { this.fromValue = toValue; } - setToValue(toValue: number) { + setToValue(toValue: number): void { this.toValue = toValue; } - setRange(from: number, to: number) { + setRange(from: number, to: number): void { this.fromValue = from; this.toValue = to; } @@ -55,6 +60,9 @@ export const RangeFilter: React.FC = (props) => { const resizeObserver = React.useRef(null); const [cursorPosition, setCursorPosition] = React.useState(ORIGIN); const [hovered, setHovered] = React.useState(false); + const [changedByUser, setChangedByUser] = React.useState(true); + const [inputError, setInputError] = React.useState(null); + const inputRef = React.useRef(null); const closestValidValue = (value: number) => { return Math.round(value / props.step) * props.step; @@ -80,7 +88,8 @@ export const RangeFilter: React.FC = (props) => { }, [setTrackWidth, setTrackBoundingClientRect, trackRef.current]); const updateParentProps = React.useCallback( - (givenThumbs?: ThumbInstance[]) => { + (givenThumbs?: ThumbInstance[], userChange = false) => { + setChangedByUser(userChange); const values = []; const currentThumbs = givenThumbs || thumbs; for (let i = props.minValue; i <= props.maxValue; i += props.step) { @@ -92,15 +101,26 @@ export const RangeFilter: React.FC = (props) => { values.push(i); } } - props.setProps({ - values: values, - selections: currentThumbs.map((el) => ({ - fromValue: el.fromValue, - toValue: el.toValue, - })), - }); + if (props.setProps) { + props.setProps({ + values: values, + selections: currentThumbs.map((el) => ({ + fromValue: el.fromValue, + toValue: el.toValue, + })), + }); + } + if (!userChange && inputRef.current) { + inputRef.current.value = currentThumbs + .map((el) => + el.fromValue === el.toValue + ? el.fromValue.toString() + : `${el.fromValue}-${el.toValue}` + ) + .join(";"); + } }, - [thumbs, props.setProps] + [thumbs, props.setProps, inputRef.current, changedByUser] ); React.useEffect(() => { @@ -204,6 +224,40 @@ export const RangeFilter: React.FC = (props) => { updateParentProps(newThumbs); }; + const handleInputChange = (e: React.ChangeEvent) => { + if (!changedByUser) { + setChangedByUser(true); + return; + } + const regex = + /^(([0-9]{1,}(-[0-9]{1,})?)([;,]{1}[0-9]{1,}(-[0-9]{1,})?){0,})?$/; + const value = (e.target as HTMLInputElement | undefined)?.value || ""; + if (!regex.test(value)) { + setInputError("Invalid input"); + return; + } + setInputError(null); + let newThumbs: ThumbInstance[] = []; + if (value.length > 0) { + newThumbs = value.split(/,|;/).map((el) => { + const split = el.split("-"); + if (split.length === 1) { + return new ThumbInstance( + parseInt(split[0]), + parseInt(split[0]) + ); + } else { + return new ThumbInstance( + parseInt(split[0]), + parseInt(split[1]) + ); + } + }); + } + setThumbs(newThumbs); + updateParentProps(newThumbs, true); + }; + return (
@@ -266,6 +320,29 @@ export const RangeFilter: React.FC = (props) => { )}
+
+ handleInputChange(e)} + inputRef={inputRef} + error={inputError !== null} + helperText={inputError} + variant="outlined" + style={{ width: "100%" }} + /> +
); }; + +RangeFilter.propTypes = { + minValue: PropTypes.number.isRequired, + maxValue: PropTypes.number.isRequired, + step: PropTypes.number.isRequired, + selections: PropTypes.arrayOf( + PropTypes.shape({ + fromValue: PropTypes.number.isRequired, + toValue: PropTypes.number.isRequired, + }).isRequired + ), + setProps: PropTypes.func, +};