Commit a00432c1 authored by Justin Domingue's avatar Justin Domingue Committed by GitHub

fix: remove chart clamping and show indicator when handle is off screen (#2064)

* initial off screen indicator

* adjust offscreen indicator

* add off screen handle indicator

* hide reset until we get a better behavior

* add svg.tsx
parent a184afa4
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BrushBehavior, brushX, D3BrushEvent, ScaleLinear, select } from 'd3' import { BrushBehavior, brushX, D3BrushEvent, ScaleLinear, select } from 'd3'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { brushHandleAccentPath, brushHandlePath } from 'components/LiquidityChartRangeInput/svg' import { brushHandleAccentPath, brushHandlePath, OffScreenHandle } from 'components/LiquidityChartRangeInput/svg'
import usePrevious from 'hooks/usePrevious' import usePrevious from 'hooks/usePrevious'
const Handle = styled.path<{ color: string }>` const Handle = styled.path<{ color: string }>`
cursor: ew-resize; cursor: ew-resize;
pointer-events: none; pointer-events: none;
stroke-width: 4; stroke-width: 3;
stroke: ${({ color }) => color}; stroke: ${({ color }) => color};
fill: ${({ color }) => color}; fill: ${({ color }) => color};
` `
...@@ -40,6 +40,9 @@ const Tooltip = styled.text` ...@@ -40,6 +40,9 @@ const Tooltip = styled.text`
// flips the handles draggers when close to the container edges // flips the handles draggers when close to the container edges
const FLIP_HANDLE_THRESHOLD_PX = 20 const FLIP_HANDLE_THRESHOLD_PX = 20
// margin to prevent tick snapping from putting the brush off screen
const BRUSH_EXTENT_MARGIN_PX = 2
const compare = (a1: [number, number], a2: [number, number]): boolean => a1[0] !== a2[0] || a1[1] !== a2[1] const compare = (a1: [number, number], a2: [number, number]): boolean => a1[0] !== a2[0] || a1[1] !== a2[1]
export const Brush = ({ export const Brush = ({
...@@ -106,8 +109,8 @@ export const Brush = ({ ...@@ -106,8 +109,8 @@ export const Brush = ({
brushBehavior.current = brushX<SVGGElement>() brushBehavior.current = brushX<SVGGElement>()
.extent([ .extent([
[Math.max(0, xScale(0)), 0], [Math.max(0 + BRUSH_EXTENT_MARGIN_PX, xScale(0)), 0],
[innerWidth, innerHeight], [innerWidth - BRUSH_EXTENT_MARGIN_PX, innerHeight],
]) ])
.handleSize(30) .handleSize(30)
.filter(() => interactive) .filter(() => interactive)
...@@ -136,15 +139,26 @@ export const Brush = ({ ...@@ -136,15 +139,26 @@ export const Brush = ({
brushBehavior.current.move(select(brushRef.current) as any, brushExtent.map(xScale) as any) brushBehavior.current.move(select(brushRef.current) as any, brushExtent.map(xScale) as any)
}, [brushExtent, xScale]) }, [brushExtent, xScale])
// show labels when local brush changes
useEffect(() => { useEffect(() => {
setShowLabels(true) setShowLabels(true)
const timeout = setTimeout(() => setShowLabels(false), 1500) const timeout = setTimeout(() => setShowLabels(false), 1500)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [localBrushExtent]) }, [localBrushExtent])
// variables to help render the SVGs
const flipWestHandle = localBrushExtent && xScale(localBrushExtent[0]) > FLIP_HANDLE_THRESHOLD_PX const flipWestHandle = localBrushExtent && xScale(localBrushExtent[0]) > FLIP_HANDLE_THRESHOLD_PX
const flipEastHandle = localBrushExtent && xScale(localBrushExtent[1]) > innerWidth - FLIP_HANDLE_THRESHOLD_PX const flipEastHandle = localBrushExtent && xScale(localBrushExtent[1]) > innerWidth - FLIP_HANDLE_THRESHOLD_PX
const showWestArrow = localBrushExtent && (xScale(localBrushExtent[0]) < 0 || xScale(localBrushExtent[1]) < 0)
const showEastArrow =
localBrushExtent && (xScale(localBrushExtent[0]) > innerWidth || xScale(localBrushExtent[1]) > innerWidth)
const westHandleInView =
localBrushExtent && xScale(localBrushExtent[0]) >= 0 && xScale(localBrushExtent[0]) <= innerWidth
const eastHandleInView =
localBrushExtent && xScale(localBrushExtent[1]) >= 0 && xScale(localBrushExtent[1]) <= innerWidth
return useMemo( return useMemo(
() => ( () => (
<> <>
...@@ -156,11 +170,7 @@ export const Brush = ({ ...@@ -156,11 +170,7 @@ export const Brush = ({
{/* clips at exactly the svg area */} {/* clips at exactly the svg area */}
<clipPath id={`${id}-brush-clip`}> <clipPath id={`${id}-brush-clip`}>
<rect x="0" y="0" width={innerWidth} height="100%" /> <rect x="0" y="0" width={innerWidth} height={innerHeight} />
</clipPath>
<clipPath id={`${id}-handles-clip`}>
<rect x="0" y="0" width="100%" height="100%" />
</clipPath> </clipPath>
</defs> </defs>
...@@ -176,48 +186,56 @@ export const Brush = ({ ...@@ -176,48 +186,56 @@ export const Brush = ({
{localBrushExtent && ( {localBrushExtent && (
<> <>
{/* west handle */} {/* west handle */}
<g {westHandleInView ? (
transform={`translate(${Math.max(0, xScale(localBrushExtent[0]))}, 0), scale(${ <g
flipWestHandle ? '-1' : '1' transform={`translate(${Math.max(0, xScale(localBrushExtent[0]))}, 0), scale(${
}, 1)`} flipWestHandle ? '-1' : '1'
> }, 1)`}
<g clipPath={`url(#${id}-handles-clip)`}>
<Handle color={westHandleColor} d={brushHandlePath(innerHeight)} />
<HandleAccent d={brushHandleAccentPath()} />
</g>
<LabelGroup
transform={`translate(50,0), scale(${flipWestHandle ? '1' : '-1'}, 1)`}
visible={showLabels || hovering}
> >
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" /> <g>
<Tooltip transform={`scale(-1, 1)`} y="15" dominantBaseline="middle"> <Handle color={westHandleColor} d={brushHandlePath(innerHeight)} />
{brushLabelValue('w', localBrushExtent[0])} <HandleAccent d={brushHandleAccentPath()} />
</Tooltip> </g>
</LabelGroup>
</g> <LabelGroup
transform={`translate(50,0), scale(${flipWestHandle ? '1' : '-1'}, 1)`}
visible={showLabels || hovering}
>
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" />
<Tooltip transform={`scale(-1, 1)`} y="15" dominantBaseline="middle">
{brushLabelValue('w', localBrushExtent[0])}
</Tooltip>
</LabelGroup>
</g>
) : null}
{/* east handle */} {/* east handle */}
<g {eastHandleInView ? (
transform={`translate(${Math.min(xScale(localBrushExtent[1]), innerWidth)}, 0), scale(${ <g transform={`translate(${xScale(localBrushExtent[1])}, 0), scale(${flipEastHandle ? '-1' : '1'}, 1)`}>
flipEastHandle ? '-1' : '1' <g>
}, 1)`} <Handle color={eastHandleColor} d={brushHandlePath(innerHeight)} />
> <HandleAccent d={brushHandleAccentPath()} />
<g clipPath={`url(#${id}-handles-clip)`}> </g>
<Handle color={eastHandleColor} d={brushHandlePath(innerHeight)} />
<HandleAccent d={brushHandleAccentPath()} /> <LabelGroup
transform={`translate(50,0), scale(${flipEastHandle ? '-1' : '1'}, 1)`}
visible={showLabels || hovering}
>
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" />
<Tooltip y="15" dominantBaseline="middle">
{brushLabelValue('e', localBrushExtent[1])}
</Tooltip>
</LabelGroup>
</g> </g>
) : null}
<LabelGroup {showWestArrow && <OffScreenHandle color={westHandleColor} />}
transform={`translate(50,0), scale(${flipEastHandle ? '-1' : '1'}, 1)`}
visible={showLabels || hovering} {showEastArrow && (
> <g transform={`translate(${innerWidth}, 0) scale(-1, 1)`}>
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" /> <OffScreenHandle color={eastHandleColor} />
<Tooltip y="15" dominantBaseline="middle"> </g>
{brushLabelValue('e', localBrushExtent[1])} )}
</Tooltip>
</LabelGroup>
</g>
</> </>
)} )}
</> </>
...@@ -225,6 +243,7 @@ export const Brush = ({ ...@@ -225,6 +243,7 @@ export const Brush = ({
[ [
brushLabelValue, brushLabelValue,
eastHandleColor, eastHandleColor,
eastHandleInView,
flipEastHandle, flipEastHandle,
flipWestHandle, flipWestHandle,
hovering, hovering,
...@@ -232,8 +251,11 @@ export const Brush = ({ ...@@ -232,8 +251,11 @@ export const Brush = ({
innerHeight, innerHeight,
innerWidth, innerWidth,
localBrushExtent, localBrushExtent,
showEastArrow,
showLabels, showLabels,
showWestArrow,
westHandleColor, westHandleColor,
westHandleInView,
xScale, xScale,
] ]
) )
......
...@@ -22,7 +22,7 @@ export function Chart({ ...@@ -22,7 +22,7 @@ export function Chart({
onBrushDomainChange, onBrushDomainChange,
zoomLevels, zoomLevels,
}: LiquidityChartRangeInputProps) { }: LiquidityChartRangeInputProps) {
const svgRef = useRef<SVGSVGElement | null>(null) const zoomRef = useRef<SVGRectElement | null>(null)
const [zoom, setZoom] = useState<ZoomTransform | null>(null) const [zoom, setZoom] = useState<ZoomTransform | null>(null)
...@@ -60,21 +60,21 @@ export function Chart({ ...@@ -60,21 +60,21 @@ export function Chart({
} }
}, [brushDomain, onBrushDomainChange, xScale]) }, [brushDomain, onBrushDomainChange, xScale])
// ensures the brush remains in view and adapts to zooms
xScale.clamp(true)
return ( return (
<> <>
<Zoom <Zoom
svg={svgRef.current} svg={zoomRef.current}
xScale={xScale} xScale={xScale}
setZoom={setZoom} setZoom={setZoom}
innerWidth={innerWidth} width={innerWidth}
innerHeight={innerHeight} height={
showClear={Boolean(zoom && zoom.k !== 1)} // allow zooming inside the x-axis
height
}
showClear={false}
zoomLevels={zoomLevels} zoomLevels={zoomLevels}
/> />
<svg ref={svgRef} width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}> <svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>
<defs> <defs>
<clipPath id={`${id}-chart-clip`}> <clipPath id={`${id}-chart-clip`}>
<rect x="0" y="0" width={innerWidth} height={height} /> <rect x="0" y="0" width={innerWidth} height={height} />
...@@ -117,6 +117,8 @@ export function Chart({ ...@@ -117,6 +117,8 @@ export function Chart({
<AxisBottom xScale={xScale} innerHeight={innerHeight} /> <AxisBottom xScale={xScale} innerHeight={innerHeight} />
</g> </g>
<rect width={innerWidth} height={height} fill="transparent" ref={zoomRef} cursor="grab" />
<Brush <Brush
id={id} id={id}
xScale={xScale} xScale={xScale}
......
...@@ -30,16 +30,16 @@ export default function Zoom({ ...@@ -30,16 +30,16 @@ export default function Zoom({
svg, svg,
xScale, xScale,
setZoom, setZoom,
innerWidth, width,
innerHeight, height,
showClear, showClear,
zoomLevels, zoomLevels,
}: { }: {
svg: SVGSVGElement | null svg: SVGElement | null
xScale: ScaleLinear<number, number> xScale: ScaleLinear<number, number>
setZoom: (transform: ZoomTransform) => void setZoom: (transform: ZoomTransform) => void
innerWidth: number width: number
innerHeight: number height: number
showClear: boolean showClear: boolean
zoomLevels: ZoomLevels zoomLevels: ZoomLevels
}) { }) {
...@@ -80,23 +80,17 @@ export default function Zoom({ ...@@ -80,23 +80,17 @@ export default function Zoom({
zoomBehavior.current = zoom() zoomBehavior.current = zoom()
.scaleExtent([zoomLevels.min, zoomLevels.max]) .scaleExtent([zoomLevels.min, zoomLevels.max])
.translateExtent([
[0, 0],
[innerWidth, innerHeight],
])
.extent([ .extent([
[0, 0], [0, 0],
[innerWidth, innerHeight], [width, height],
]) ])
.on('zoom', ({ transform }: { transform: ZoomTransform }) => setZoom(transform)) .on('zoom', ({ transform }: { transform: ZoomTransform }) => setZoom(transform))
select(svg as Element) select(svg as Element).call(zoomBehavior.current)
.call(zoomBehavior.current) }, [height, width, setZoom, svg, xScale, zoomBehavior, zoomLevels, zoomLevels.max, zoomLevels.min])
.on('mousedown.zoom', null)
}, [innerHeight, innerWidth, setZoom, svg, xScale, zoomBehavior, zoomLevels, zoomLevels.max, zoomLevels.min])
useEffect(() => { useEffect(() => {
// reset zoom to initial on zoomLevel chang // reset zoom to initial on zoomLevel change
initial() initial()
}, [initial, zoomLevels]) }, [initial, zoomLevels])
......
...@@ -20,7 +20,7 @@ export const brushHandlePath = (height: number) => ...@@ -20,7 +20,7 @@ export const brushHandlePath = (height: number) =>
`v ${height}`, // vertical line `v ${height}`, // vertical line
'm 1 0', // move 1px to the right 'm 1 0', // move 1px to the right
`V 0`, // second vertical line `V 0`, // second vertical line
`M 0 2`, // move to origin `M 0 1`, // move to origin
// head // head
'h 12', // horizontal line 'h 12', // horizontal line
...@@ -33,10 +33,29 @@ export const brushHandlePath = (height: number) => ...@@ -33,10 +33,29 @@ export const brushHandlePath = (height: number) =>
export const brushHandleAccentPath = () => export const brushHandleAccentPath = () =>
[ [
'm 6 8', // move to first accent 'm 5 7', // move to first accent
'v 14', // vertical line 'v 14', // vertical line
'M 0 0', // move to origin 'M 0 0', // move to origin
'm 10 8', // move to second accent 'm 9 7', // move to second accent
'v 14', // vertical line 'v 14', // vertical line
'z', 'z',
].join(' ') ].join(' ')
export const OffScreenHandle = ({
color,
size = 10,
margin = 10,
}: {
color: string
size?: number
margin?: number
}) => (
<polygon
points={`0 0, ${size} ${size}, 0 ${size}`}
transform={` translate(${size + margin}, ${margin}) rotate(45) `}
fill={color}
stroke={color}
strokeWidth="4"
strokeLinejoin="round"
/>
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment