diff --git a/packages/vx-demo/src/components/Gallery/SplitLinePathTile.tsx b/packages/vx-demo/src/components/Gallery/SplitLinePathTile.tsx new file mode 100644 index 0000000000..e285ac34ea --- /dev/null +++ b/packages/vx-demo/src/components/Gallery/SplitLinePathTile.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import SplitLinePath, { + SplitLinePathProps, + backgroundLight, +} from '../../sandboxes/vx-shape-splitlinepath/Example'; +import GalleryTile from '../GalleryTile'; + +export { default as packageJson } from '../../sandboxes/vx-area/package.json'; + +const tileStyles = { background: backgroundLight }; +const detailsStyles = { color: 'white' }; + +export default function SplitLinePathTile() { + return ( + + title="SplitLinePath" + description="" + exampleRenderer={SplitLinePath} + exampleUrl="/splitlinepath" + tileStyles={tileStyles} + detailsStyles={detailsStyles} + detailsHeight={0} + /> + ); +} diff --git a/packages/vx-demo/src/components/Gallery/index.tsx b/packages/vx-demo/src/components/Gallery/index.tsx index fc15ce75d5..034c1a9db4 100644 --- a/packages/vx-demo/src/components/Gallery/index.tsx +++ b/packages/vx-demo/src/components/Gallery/index.tsx @@ -33,6 +33,7 @@ import * as PiesTile from './PiesTile'; import * as PolygonsTile from './PolygonsTile'; import * as RadarTile from './RadarTile'; import * as ResponsiveTile from './ResponsiveTile'; +import * as SplitLinePathTile from './SplitLinePathTile'; import * as StackedAreasTile from './StackedAreasTile'; import * as StatsPlotTile from './StatsPlotTile'; import * as StreamGraphTile from './StreamGraphTile'; @@ -80,6 +81,7 @@ const tiles = [ PolygonsTile, RadarTile, ResponsiveTile, + SplitLinePathTile, StackedAreasTile, StatsPlotTile, StreamGraphTile, diff --git a/packages/vx-demo/src/pages/splitlinepath.tsx b/packages/vx-demo/src/pages/splitlinepath.tsx new file mode 100644 index 0000000000..20e9b2513d --- /dev/null +++ b/packages/vx-demo/src/pages/splitlinepath.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Show from '../components/Show'; +import SplitLinePath from '../sandboxes/vx-shape-splitlinepath/Example'; +import StatsPlotSource from '!!raw-loader!../sandboxes/vx-shape-splitlinepath/Example'; +import packageJson from '../sandboxes/vx-shape-splitlinepath/package.json'; + +export default () => ( + + {StatsPlotSource} + +); diff --git a/packages/vx-demo/src/sandboxes/exampleToVxDependencyLookup.ts b/packages/vx-demo/src/sandboxes/exampleToVxDependencyLookup.ts index ed33c6a92d..2f197d6683 100644 --- a/packages/vx-demo/src/sandboxes/exampleToVxDependencyLookup.ts +++ b/packages/vx-demo/src/sandboxes/exampleToVxDependencyLookup.ts @@ -27,6 +27,7 @@ import radarPackageJson from './vx-radar/package.json'; import responsivePackageJson from './vx-responsive/package.json'; import lineRadialPackageJson from './vx-shape-line-radial/package.json'; import piePackageJson from './vx-shape-pie/package.json'; +import splitLinePathPackageJson from './vx-shape-splitlinepath/package.json'; import stackedAreasPackageJson from './vx-stacked-areas/package.json'; import statsPackageJson from './vx-stats/package.json'; import streamgraphPackageJson from './vx-streamgraph/package.json'; @@ -43,33 +44,34 @@ import { VxPackage } from '../types'; const examples = [ areaPackageJson, axisPackageJson, - bargroupPackageJson, bargroupHorizontalPackageJson, + bargroupPackageJson, barsPackageJson, - barstackPackageJson, barstackHorizontalPackageJson, + barstackPackageJson, brushPackageJson, chordPackageJson, curvePackageJson, dendrogramPackageJson, dotsPackageJson, - dragIPackageJson, dragIIPackageJson, + dragIPackageJson, geoCustomPackageJson, geoMercatorPackageJson, glyphPackageJson, gradientPackageJson, heatmapPackageJson, legendPackageJson, + lineRadialPackageJson, linktypesPackageJson, networkPackageJson, packPackageJson, patternPackageJson, + piePackageJson, polygonsPackageJson, radarPackageJson, responsivePackageJson, - lineRadialPackageJson, - piePackageJson, + splitLinePathPackageJson, stackedAreasPackageJson, statsPackageJson, streamgraphPackageJson, diff --git a/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/Example.tsx b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/Example.tsx new file mode 100644 index 0000000000..013852001a --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/Example.tsx @@ -0,0 +1,127 @@ +import React, { useMemo } from 'react'; +import { scaleLinear } from '@vx/scale'; +import { curveCardinal } from '@vx/curve'; +import { LinePath, SplitLinePath } from '@vx/shape'; +import { LinearGradient } from '@vx/gradient'; + +import generateSinPoints from './generateSinPoints'; + +type Point = { x: number; y: number }; +const getX = (d: Point) => d.x; +const getY = (d: Point) => d.y; +export const background = '#045275'; +export const backgroundLight = '#089099'; +export const foreground = '#b7e6a5'; + +export type SplitLinePathProps = { + width: number; + height: number; + margin?: { top: number; right: number; bottom: number; left: number }; + numberOfWaves?: number; + pointsPerWave?: number; + numberOfSegments?: number; +}; + +export default function SplitPath({ + width, + height, + numberOfWaves = 10, + pointsPerWave = 100, + numberOfSegments = 8, +}: SplitLinePathProps) { + const data = useMemo(() => generateSinPoints({ width, height, numberOfWaves, pointsPerWave }), [ + width, + height, + numberOfWaves, + pointsPerWave, + ]); + + const dividedData = useMemo(() => { + const segmentLength = Math.floor(data.length / numberOfSegments); + return new Array(numberOfSegments) + .fill(null) + .map((_, i) => data.slice(i * segmentLength, (i + 1) * segmentLength)); + }, [numberOfSegments, data]); + + const getScaledX = useMemo(() => { + const xScale = scaleLinear({ range: [0, width], domain: [0, width] }); + return (d: Point) => xScale(getX(d)); + }, [width]); + + const getScaledY = useMemo(() => { + const yScale = scaleLinear({ range: [0, height], domain: [height, 0] }); + return (d: Point) => yScale(getY(d)); + }, [height]); + + return width < 10 ? null : ( +
+ + + + + + + + + {({ segment, styles, index }) => + /** overlay circles to a couple of the segments */ + index === numberOfSegments - 1 || index === 2 ? ( + segment.map(({ x, y }, i) => + i % 8 === 0 ? ( + + ) : null, + ) + ) : ( + d.x || 0} + y={(d: Point) => d.y || 0} + {...styles} + /> + ) + } + + + +
+ ); +} diff --git a/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/generateSinPoints.ts b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/generateSinPoints.ts new file mode 100644 index 0000000000..93f387d2da --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/generateSinPoints.ts @@ -0,0 +1,33 @@ +/** generates points along a sin wave, with increasing height toward the center. */ +export default function generateSinPoints({ + width, + height, + numberOfWaves = 10, + pointsPerWave = 10, +}: { + width: number; + height: number; + numberOfWaves?: number; + pointsPerWave?: number; +}) { + const waveLength = width / numberOfWaves; + const distanceBetweenPoints = waveLength / pointsPerWave; + const sinPoints: { x: number; y: number }[] = []; + + for (let waveIndex = 0; waveIndex <= numberOfWaves; waveIndex += 1) { + const waveDistFromStart = waveIndex * waveLength; + + for (let pointIndex = 0; pointIndex <= pointsPerWave; pointIndex += 1) { + const waveXFraction = pointIndex / pointsPerWave; + const waveX = pointIndex * distanceBetweenPoints; + const globalX = waveDistFromStart + waveX; + // scale height based x position + const globalXFraction = (width - globalX) / width; + const waveHeight = Math.min(globalXFraction, 1 - globalXFraction) * height; + + sinPoints.push({ x: globalX, y: waveHeight * Math.sin(waveXFraction * (2 * Math.PI)) }); + } + } + + return sinPoints; +} diff --git a/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/index.tsx b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/index.tsx new file mode 100644 index 0000000000..3313ec317e --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/index.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render } from 'react-dom'; +import ParentSize from '@vx/responsive/lib/components/ParentSize'; + +import Example from './Example'; +import './sandbox-styles.css'; + +render( + {({ width, height }) => }, + document.getElementById('root'), +); diff --git a/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/package.json b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/package.json new file mode 100644 index 0000000000..fff89516b9 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/package.json @@ -0,0 +1,27 @@ +{ + "name": "@vx/demo-shape-splitlinepath", + "description": "Standalone vx splitlinepath demo.", + "main": "index.tsx", + "private": true, + "dependencies": { + "@babel/runtime": "^7.8.4", + "@types/react": "^16", + "@types/react-dom": "^16", + "@vx/curve": "latest", + "@vx/gradient": "latest", + "@vx/responsive": "latest", + "@vx/scale": "latest", + "@vx/shape": "latest", + "react": "^16", + "react-dom": "^16", + "react-scripts-ts": "3.1.0", + "typescript": "^3" + }, + "keywords": [ + "visualization", + "d3", + "react", + "vx", + "splitline" + ] +} diff --git a/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/sandbox-styles.css b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/sandbox-styles.css new file mode 100644 index 0000000000..b919937230 --- /dev/null +++ b/packages/vx-demo/src/sandboxes/vx-shape-splitlinepath/sandbox-styles.css @@ -0,0 +1,8 @@ +html, +body, +#root { + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, + 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 2em; +} diff --git a/packages/vx-shape/package.json b/packages/vx-shape/package.json index eddd2c3419..0332e2186a 100644 --- a/packages/vx-shape/package.json +++ b/packages/vx-shape/package.json @@ -24,6 +24,7 @@ "@types/classnames": "^2.2.9", "@types/d3-path": "^1.0.8", "@types/d3-shape": "^1.3.1", + "@types/lodash": "^4.14.146", "@types/react": "*", "@vx/curve": "0.0.198", "@vx/group": "0.0.198", @@ -31,6 +32,7 @@ "classnames": "^2.2.5", "d3-path": "^1.0.5", "d3-shape": "^1.2.0", + "lodash": "^4.17.15", "prop-types": "^15.5.10" }, "peerDependencies": { diff --git a/packages/vx-shape/src/index.ts b/packages/vx-shape/src/index.ts index 2f3f095e4b..7a875c8757 100644 --- a/packages/vx-shape/src/index.ts +++ b/packages/vx-shape/src/index.ts @@ -45,6 +45,7 @@ export { default as LinkVerticalStep, pathVerticalStep } from './shapes/link/ste export { default as LinkRadialStep, pathRadialStep } from './shapes/link/step/LinkRadialStep'; export { default as Polygon, getPoints, getPoint } from './shapes/Polygon'; export { default as Circle } from './shapes/Circle'; +export { default as SplitLinePath } from './shapes/SplitLinePath'; // Export factory functions export * from './types/D3ShapeConfig'; diff --git a/packages/vx-shape/src/shapes/SplitLinePath.tsx b/packages/vx-shape/src/shapes/SplitLinePath.tsx new file mode 100644 index 0000000000..f536e60b34 --- /dev/null +++ b/packages/vx-shape/src/shapes/SplitLinePath.tsx @@ -0,0 +1,73 @@ +import React, { useMemo } from 'react'; +import LinePath from './LinePath'; +import getSplitLineSegments from '../util/getSplitLineSegments'; +import { LinePathConfig } from '../types'; + +interface Point { + x: number; + y: number; +} + +type SplitLinePathProps = { + /** Array of data segments, where each segment will be a separate path in the rendered line. */ + segments: Datum[][]; + /** Styles to apply to each segment. If fewer styles are specified than the number of segments, they will be re-used. */ + styles: Omit, 'x' | 'y' | 'children'>[]; + /** Override render function which is passed the configured path generator as input. */ + children?: (renderProps: { + index: number; + segment: { x: number; y: number }[]; + styles?: Omit, 'x' | 'y' | 'children'>; + }) => React.ReactNode; + /** className applied to path element. */ + className?: string; + /** Optionally specify the sample rate for interpolating line segments. */ + sampleRate?: number; +} & LinePathConfig; + +export default function SplitLinePath({ + children, + className, + curve, + defined, + sampleRate, + segments, + x, + y, + styles, +}: SplitLinePathProps) { + // combine data to first draw entire path + const combinedSegments: Datum[] = useMemo( + () => segments.reduce((flat, segmentData) => flat.concat([...segmentData]), []), + [segments], + ); + + return ( + + {({ path }) => { + // use entire path to interpolate individual segments + const entirePath = path(combinedSegments); + const computedLineSegments = getSplitLineSegments({ + path: entirePath || '', + segments, + sampleRate, + }); + + return computedLineSegments.map((segment, index) => + children ? ( + children({ index, segment, styles: styles[index] || styles[index % styles.length] }) + ) : ( + d.x || 0} + y={(d: Point) => d.y || 0} + {...(styles[index] || styles[index % styles.length])} + /> + ), + ); + }} + + ); +} diff --git a/packages/vx-shape/src/util/getSplitLineSegments.ts b/packages/vx-shape/src/util/getSplitLineSegments.ts new file mode 100644 index 0000000000..e4d7de3096 --- /dev/null +++ b/packages/vx-shape/src/util/getSplitLineSegments.ts @@ -0,0 +1,80 @@ +import memoize from 'lodash/memoize'; + +const MEASUREMENT_ELEMENT_ID = '__vx_splitpath_svg_path_measurement_id'; +const SVG_NAMESPACE_URL = 'http://www.w3.org/2000/svg'; + +export interface GetLineSegmentsConfig { + /** Full path `d` attribute to be broken up into `n` segments. */ + path: string; + /** + * Array of length `n`, where `n` is the number of resulting line segments. + * For each segment of length `m`, `m / sampleRate` evenly spaced points will be returned. + */ + segments: Datum[][]; + /** For each segment of length `m`, `m / sampleRate` evenly spaced points will be returned. */ + sampleRate?: number; +} + +type LineSegments = { x: number; y: number }[][]; + +export function getSplitLineSegments({ + path, + segments, + sampleRate = 0.25, +}: GetLineSegmentsConfig): LineSegments { + try { + let pathElement = document.getElementById(MEASUREMENT_ELEMENT_ID) as SVGPathElement | null; + + // create a single path element if not done already + if (!pathElement) { + const svg = document.createElementNS(SVG_NAMESPACE_URL, 'svg'); + // not visible + svg.style.opacity = '0'; + svg.style.width = '0'; + svg.style.height = '0'; + // off screen + svg.style.position = 'absolute'; + svg.style.top = '-100%'; + svg.style.left = '-100%'; + // no mouse events + svg.style.pointerEvents = 'none'; + pathElement = document.createElementNS(SVG_NAMESPACE_URL, 'path'); + pathElement.setAttribute('id', MEASUREMENT_ELEMENT_ID); + svg.appendChild(pathElement); + document.body.appendChild(svg); + } + + pathElement.setAttribute('d', path); + + const totalPathLength = pathElement.getTotalLength(); + const totalPieces = segments.reduce((sum, curr) => sum + curr.length, 0); + const pieceSize = totalPathLength / totalPieces; + + let cumulativeSize = 0; + + const lineSegments = segments.map(segment => { + const segmentPointCount = segment.length; + const coords: { x: number; y: number }[] = []; + + for (let i = 0; i < segmentPointCount + sampleRate; i += sampleRate) { + const distance = (cumulativeSize + i) * pieceSize; + const point = pathElement!.getPointAtLength(distance); + coords.push(point); + } + + cumulativeSize += segmentPointCount; + + return coords; + }); + + return lineSegments; + } catch (e) { + return []; + } +} + +export default memoize( + getSplitLineSegments, + ({ path, segments, sampleRate }: GetLineSegmentsConfig) => + `${path}_${segments.length}_${segments.map(segment => segment.length).join('-')}_${sampleRate}`, +);