From ed359d58f4841057f5b478252b2b0fa9a91544af Mon Sep 17 00:00:00 2001 From: romer8 Date: Sat, 9 Nov 2024 15:35:48 -0700 Subject: [PATCH] added panning and zooming with constrains to the chart --- package-lock.json | 39 +- package.json | 5 + .../features/hydroFabric/components/chart.js | 489 ++++++++++++------ 3 files changed, 360 insertions(+), 173 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55a5ade..3efdd9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@visx/axis": "^3.12.0", + "@visx/brush": "^3.12.0", "@visx/event": "^3.12.0", "@visx/glyph": "^3.12.0", "@visx/gradient": "^3.12.0", @@ -20,11 +21,15 @@ "@visx/shape": "^3.12.0", "@visx/tooltip": "^3.12.0", "@visx/visx": "^3.12.0", + "@visx/zoom": "^3.12.0", "axios": "^0.27.2", "bootstrap": "^5.1.3", "chartist": "^1.3.0", "color": "^4.2.3", "css-loader": "^6.5.1", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "d3-time-format": "^4.1.0", "date-fns": "^4.1.0", "dotenv": "^16.0.1", "eslint-config-react-app": "^7.0.1", @@ -4958,6 +4963,18 @@ "internmap": "2.0.3" } }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@visx/visx": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/@visx/visx/-/visx-3.12.0.tgz", @@ -6972,9 +6989,9 @@ "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "node_modules/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -20531,6 +20548,16 @@ "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" + }, + "dependencies": { + "d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "requires": { + "internmap": "1 - 2" + } + } } }, "@visx/visx": { @@ -22087,9 +22114,9 @@ "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==" }, "d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", "requires": { "internmap": "1 - 2" } diff --git a/package.json b/package.json index 2116c3a..97d7cc3 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "license": "ISC", "dependencies": { "@visx/axis": "^3.12.0", + "@visx/brush": "^3.12.0", "@visx/event": "^3.12.0", "@visx/glyph": "^3.12.0", "@visx/gradient": "^3.12.0", @@ -24,11 +25,15 @@ "@visx/shape": "^3.12.0", "@visx/tooltip": "^3.12.0", "@visx/visx": "^3.12.0", + "@visx/zoom": "^3.12.0", "axios": "^0.27.2", "bootstrap": "^5.1.3", "chartist": "^1.3.0", "color": "^4.2.3", "css-loader": "^6.5.1", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "d3-time-format": "^4.1.0", "date-fns": "^4.1.0", "dotenv": "^16.0.1", "eslint-config-react-app": "^7.0.1", diff --git a/reactapp/features/hydroFabric/components/chart.js b/reactapp/features/hydroFabric/components/chart.js index 8d3b694..e161a9d 100644 --- a/reactapp/features/hydroFabric/components/chart.js +++ b/reactapp/features/hydroFabric/components/chart.js @@ -1,14 +1,20 @@ -import { useCallback } from "react"; +import React, { useCallback, useRef } from "react"; +import { Zoom, applyMatrixToPoint } from "@visx/zoom"; import { Group } from "@visx/group"; import { scaleLinear, scaleTime } from "@visx/scale"; import { AxisLeft, AxisBottom } from "@visx/axis"; import { LinePath, Line } from "@visx/shape"; import { extent, bisector } from "d3-array"; import { GridRows, GridColumns } from "@visx/grid"; -import { useTooltip, TooltipWithBounds, defaultStyles } from "@visx/tooltip"; +import { + useTooltip, + TooltipWithBounds, + defaultStyles, +} from "@visx/tooltip"; import { localPoint } from "@visx/event"; import { GlyphCircle } from "@visx/glyph"; import { timeParse, timeFormat } from "d3-time-format"; +import { RectClipPath } from "@visx/clip-path"; // Import ClipPath function LineChart({ width, height, data }) { // Tooltip parameters @@ -21,7 +27,7 @@ function LineChart({ width, height, data }) { } = useTooltip(); // Define margins - const margin = { top: 40, right: 40, bottom: 40, left: 40 }; + const margin = { top: 40, right: 40, bottom: 40, left: 60 }; // Inner dimensions const innerWidth = width - margin.left - margin.right; @@ -37,7 +43,7 @@ function LineChart({ width, height, data }) { const getDate = (d) => parseDate(d.x.trim()); const getYValue = (d) => d.y; - // Define scales + // Define initial scales const xScale = scaleTime({ range: [0, innerWidth], domain: extent(allData, getDate), @@ -59,7 +65,7 @@ function LineChart({ width, height, data }) { minWidth: 60, backgroundColor: "rgba(0,0,0,0.9)", color: "white", - position: "absolute", // Ensure tooltip is absolutely positioned + position: "absolute", }; // Date formatter @@ -68,11 +74,39 @@ function LineChart({ width, height, data }) { // Bisector for finding closest data point const bisectDate = bisector((d) => getDate(d)).left; + // Reference for the SVG element + const svgRef = useRef(null); + + // Function to rescale x-axis based on zoom + const rescaleXAxis = (scale, transformMatrix) => { + const newDomain = scale + .range() + .map((r) => + scale.invert( + (r - transformMatrix.translateX) / transformMatrix.scaleX + ) + ); + return scale.copy().domain(newDomain); + }; + + // Function to rescale y-axis based on zoom + const rescaleYAxis = (scale, transformMatrix) => { + const newDomain = scale + .range() + .map((r) => + scale.invert( + (r - transformMatrix.translateY) / transformMatrix.scaleY + ) + ); + return scale.copy().domain(newDomain); + }; + // Tooltip handler const handleTooltip = useCallback( - (event) => { - const { x } = localPoint(event) || { x: 0 }; - const x0 = xScale.invert(x - margin.left); + (event, zoom) => { + const point = localPoint(event) || { x: 0, y: 0 }; + const x = point.x - margin.left; + const x0 = rescaleXAxis(xScale, zoom.transformMatrix).invert(x); const tooltipDataArray = []; @@ -94,15 +128,15 @@ function LineChart({ width, height, data }) { }); }); - // Calculate the tooltip's y-position (e.g., average or min y-position) + // Calculate the tooltip's y-position const yPositions = tooltipDataArray.map((d) => - yScale(getYValue(d.dataPoint)) + rescaleYAxis(yScale, zoom.transformMatrix)(getYValue(d.dataPoint)) ); const tooltipTopPosition = Math.min(...yPositions) + margin.top; showTooltip({ tooltipData: tooltipDataArray, - tooltipLeft: x, + tooltipLeft: point.x, tooltipTop: tooltipTopPosition, }); }, @@ -113,172 +147,293 @@ function LineChart({ width, height, data }) { data, getDate, getYValue, + bisectDate, margin.left, margin.top, ] ); + // Updated constrain function + const constrain = (transformMatrix, prevTransformMatrix) => { + const { scaleX, scaleY, translateX, translateY } = transformMatrix; + + // Fix constrain scale + if (scaleX < 1) transformMatrix.scaleX = 1; + if (scaleY < 1) transformMatrix.scaleY = 1; + + // Fix constrain translate [left, top] position + if (translateX > 0) transformMatrix.translateX = 0; + if (translateY > 0) transformMatrix.translateY = 0; + + // Fix constrain translate [right, bottom] position + const max = applyMatrixToPoint(transformMatrix, { + x: innerWidth, + y: innerHeight, + }); + if (max.x < innerWidth) { + transformMatrix.translateX += innerWidth - max.x; + } + if (max.y < innerHeight) { + transformMatrix.translateY += innerHeight - max.y; + } + + // Return the constrained transform matrix + return transformMatrix; + }; + return (
- - - - - - ({ - fill: "#0a100d", - fontSize: 11, - textAnchor: "end", - })} - /> - - Y-axis Label - - ({ - fill: "#0a100d", - fontSize: 11, - textAnchor: "middle", - })} - /> - {/* Render multiple lines */} - {data.map((series, index) => ( - xScale(getDate(d)) ?? 0} - y={(d) => yScale(getYValue(d)) ?? 0} - /> - ))} - {/* Tooltip components */} - {tooltipData && ( - - - {tooltipData.map((d, i) => ( - + {(zoom) => { + // Apply zoom transformations to scales + const newXScale = rescaleXAxis(xScale, zoom.transformMatrix); + const newYScale = rescaleYAxis(yScale, zoom.transformMatrix); + + return ( + <> + + {/* Define a clip path */} + - ))} - - )} - {/* Overlay for capturing mouse events */} - hideTooltip()} - /> - - - {/* Render tooltip before the legend to prevent layout shifts */} - {tooltipData && ( - -
- Date: - {formatDate(getDate(tooltipData[0].dataPoint))} -
- {tooltipData.map((d, i) => ( -
- + + + + ({ + fill: "#0a100d", + fontSize: 11, + textAnchor: "end", + })} + /> + + Y-axis Label + + ({ + fill: "#0a100d", + fontSize: 11, + textAnchor: "middle", + })} + /> + {/* Apply the clip path to the chart elements */} + + {/* Render multiple lines */} + {data.map((series, index) => ( + newXScale(getDate(d)) ?? 0} + y={(d) => newYScale(getYValue(d)) ?? 0} + /> + ))} + {/* Tooltip components */} + {tooltipData && ( + + + {tooltipData.map((d, i) => ( + + ))} + + )} + + {/* Zoom overlay */} + { + zoom.dragMove(event); + handleTooltip(event, zoom); + }} + onMouseUp={zoom.dragEnd} + onMouseLeave={(event) => { + if (zoom.isDragging) zoom.dragEnd(); + hideTooltip(); + }} + onTouchStart={zoom.dragStart} + onTouchMove={zoom.dragMove} + onTouchEnd={zoom.dragEnd} + onDoubleClick={(event) => { + const point = localPoint(event) || { x: 0, y: 0 }; + zoom.scale({ scaleX: 1.5, scaleY: 1.5, point }); + }} + onWheel={(event) => { + event.preventDefault(); + const point = localPoint(event) || { x: 0, y: 0 }; + const delta = -event.deltaY / 500; // Adjust sensitivity + const scale = 1 + delta; + zoom.scale({ scaleX: scale, scaleY: scale, point }); + }} + style={{ + cursor: zoom.isDragging ? "grabbing" : "grab", + }} + /> + + + {/* Tooltip */} + {tooltipData && ( + +
+ Date: + {formatDate(getDate(tooltipData[0].dataPoint))} +
+ {tooltipData.map((d, i) => ( +
+ + {d.seriesLabel}:{" "} + + {getYValue(d.dataPoint)} +
+ ))} +
+ )} + {/* Legend and Controls */} +
- {d.seriesLabel}:{" "} - - {getYValue(d.dataPoint)} -
- ))} - - )} - {/* Optional Legend */} -
+ {data.map((series, index) => ( +
+
+
+ {series.label} +
+
+ ))} +
+ +
+ + ); }} - > - {data.map((series, index) => ( -
-
-
- {series.label} -
-
- ))} -
+
); }