Skip to content

Commit

Permalink
Merge pull request #656 from devsoc-unsw/feature/pan-zoom
Browse files Browse the repository at this point in the history
Pan and zoom
  • Loading branch information
dqna64 authored Jun 24, 2024
2 parents 18b4eab + 1b2ef53 commit 9a1fab9
Show file tree
Hide file tree
Showing 20 changed files with 288 additions and 98 deletions.
38 changes: 38 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]

**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]

**Additional context**
Add any other context about the problem here.
10 changes: 10 additions & 0 deletions .github/ISSUE_TEMPLATE/custom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''

---


20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context or screenshots about the feature request here.
1 change: 1 addition & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Docker

on:
push:
branches:
Expand Down
1 change: 1 addition & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"classnames": "^2.3.2",
"d3": "^7.8.5",
"framer-motion": "^10.16.4",
"gl-matrix": "^3.4.3",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
"react": "^18.2.0",
Expand Down
253 changes: 176 additions & 77 deletions client/src/components/Visualiser/VisualiserCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,116 +1,215 @@
import React, { PointerEvent, useEffect, useRef, useState } from 'react';
import React, { PointerEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import { mat3, vec2 } from 'gl-matrix';
import { VISUALISER_CANVAS_ID, VISUALISER_WORKSPACE_ID } from 'visualiser-src/common/constants';

const ZoomableSvg = styled('svg')(({ scale }) => ({
transition: 'transform 0.2s linear',
transformOrigin: 'center',
transform: `scale(${scale})`,
const ZoomableSvg = styled('svg')<{ transformMat: mat3 }>(({ transformMat }) => ({
width: '100%',
height: '100%',
// transition: 'transform 0.2s linear',
// transformOrigin: 'center',
transform: `matrix(${transformMat[0]}, ${transformMat[1]}, ${transformMat[3]}, ${transformMat[4]}, ${transformMat[6]}, ${transformMat[7]})`,
}));

const ZOOM_SPEED = 0.0002;
const MAX_SCALE = 4;
const MIN_SCALE = 0.5;
const DEBUG = false;

const getCentre = (rect: DOMRect) => {
return vec2.fromValues((rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2);
};

/* -------------------------------------------------------------------------- */
/* Visualiser-Specific Canvases */
/* -------------------------------------------------------------------------- */

/**
* The React component that renders the DOM elements that the visualiser
* attaches itself to.
*
* Uses affine transformations to implement pan and zoom functionality.
* See the following article for a beginner-friendly intro:
* https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/Matrix_math_for_the_web#
*/
const VisualiserCanvas: React.FC = () => {
const [scale, setScale] = useState(1);
const svgRef = useRef(null);
const [height, setHeight] = useState(1000);
const [width, setWidth] = useState(1000);

const ZOOM_SPEED = 0.05;
const MAX_SCALE = 2;
const MIN_SCALE = 0.5;
const onScroll = (e: React.WheelEvent) => {
if (e.deltaY < 0) {
setScale(Math.min(scale + ZOOM_SPEED, MAX_SCALE));
} else {
setScale(Math.max(scale - ZOOM_SPEED, MIN_SCALE));
}
};

const [isPointerDown, setIsPointerDown] = useState(false);

const [pointerOrigin, setPointerOrigin] = useState({
x: 0,
y: 0,
});

const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
setIsPointerDown(true);

setPointerOrigin({
x: event.clientX,
y: event.clientY,
});
};

const [viewBox, setViewBox] = useState({
x: 0,
y: 0,
});
// Element ref to the visualiser "canvas" svg (not actually a HTMLCanvas)
// Note: the visualiser canvas is not always same size as the visualiser workspace.
// See this image: https://imgur.com/a/EK242BQ
const svgRef = useRef<SVGSVGElement | null>(null);

// top, left, bottom, right of workspace relative to viewport top-left
const [workspaceRect, setWorkspaceRect] = useState<DOMRect>(
new DOMRect(0, 0, window.innerWidth, window.innerHeight)
);

const [newViewBox, setNewViewBox] = useState({
x: 0,
y: 0,
});
// transform matrix to represent accumulative panning and zooming.
// The visualiser canvas position will be transformed by this matrix
// and updated on scroll and drag events.
const [transform, setTransform] = useState<mat3>(mat3.create());

const onScroll = useCallback(
(e: React.WheelEvent) => {
setTransform((prevTransform) => {
// === Zoom in/out at mouse position on scroll ===
// Uses scaling matrix transform. See following link for explanation:
// https://math.stackexchange.com/questions/3245481/rotate-and-scale-a-point-around-different-origins

// Calculate mouse position relative to workspace origin,
// in viewport space
const mouseFromWorkspaceOrigin = vec2.subtract(
vec2.create(),
vec2.fromValues(e.clientX, e.clientY),
getCentre(workspaceRect)
);

// Transformed mouse position using the previous pan-zoom transform
// into transformed workspace space (taking into account accumulative
// pan and zoom transformations)
const mouseFromWorkspaceOriginTransformed = vec2.transformMat3(
vec2.create(),
mouseFromWorkspaceOrigin,
mat3.invert(mat3.create(), prevTransform)
);

const newTransform = mat3.clone(prevTransform);

// Translate the transform to the mouse position
mat3.translate(newTransform, newTransform, mouseFromWorkspaceOriginTransformed);

// Scale the transform by scroll amount, same factor in both x and y directions
// Constrain the scaled transform to a minimum and maximum scaling factor
const scaleMag = -ZOOM_SPEED * e.deltaY;
const scaleFactor = 1 + scaleMag;
if (scaleFactor * newTransform[0] > MAX_SCALE) {
mat3.scale(
newTransform,
newTransform,
vec2.fromValues(MAX_SCALE / newTransform[0], MAX_SCALE / newTransform[4])
);
} else if (scaleFactor * newTransform[0] < MIN_SCALE) {
mat3.scale(
newTransform,
newTransform,
vec2.fromValues(MIN_SCALE / newTransform[0], MIN_SCALE / newTransform[4])
);
} else {
const scaleVec = vec2.fromValues(scaleFactor, scaleFactor);
mat3.scale(newTransform, newTransform, scaleVec);
}

// Undo the translation to the mouse position
mat3.translate(
newTransform,
newTransform,
vec2.negate(vec2.create(), mouseFromWorkspaceOriginTransformed)
);

return newTransform;
});
},
[workspaceRect]
);

const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!isPointerDown) {
return;
}
const [isPointerDown, setIsPointerDown] = useState(false);

event.preventDefault();
const handlePointerDown = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
setIsPointerDown(true);

const mouseFromWorkspaceOrigin = vec2.subtract(
vec2.create(),
vec2.fromValues(e.clientX, e.clientY),
getCentre(workspaceRect)
);

if (DEBUG)
console.log(
`Clicked pos relative to workspace origin: ${mouseFromWorkspaceOrigin[0]}, ${mouseFromWorkspaceOrigin[1]}`
);
},
[workspaceRect]
);

// Ensure x is between -width and width and y is between -height and height
setNewViewBox({
x: Math.min(width, Math.max(-width, viewBox.x - (event.clientX - pointerOrigin.x))),
y: Math.min(height, Math.max(-height, viewBox.y - (event.clientY - pointerOrigin.y))),
});
};
const handlePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!isPointerDown) {
return;
}

// If the mouse is outside the workspace, don't do translation
if (
workspaceRect &&
(event.clientX < workspaceRect.left ||
event.clientY < workspaceRect.top ||
event.clientX > workspaceRect.right ||
event.clientY > workspaceRect.bottom)
) {
if (DEBUG) console.log('Pointer left workspace');
setIsPointerDown(false);
return;
}

// Is this needed for anything?
// event.preventDefault();

setTransform((prev) => {
const translateVec = vec2.fromValues(event.movementX / prev[0], event.movementY / prev[4]);
return mat3.translate(mat3.create(), prev, translateVec);
});
},
[isPointerDown, workspaceRect]
);

const handlePointerUp = () => {
setIsPointerDown(false);

setViewBox({
x: newViewBox.x,
y: newViewBox.y,
});
};

useEffect(() => {
setHeight(svgRef.current.clientHeight);
setWidth(svgRef.current.clientWidth);
setViewBox((prevViewBox) => ({
...prevViewBox,
height,
width,
}));
// Callback for a ResizeObserver constructor
const handleWorkspaceResize: ResizeObserverCallback = (entries) => {
if (entries.length > 0 && entries[0].target.id === VISUALISER_WORKSPACE_ID) {
const workspaceEntry = entries[0];
const boundingClientRect = workspaceEntry.target.getBoundingClientRect();
if (DEBUG)
console.log(
`Workspace resized. Setting new workspace rect left: ${boundingClientRect.left}, right: ${boundingClientRect.right}, top: ${boundingClientRect.top}, bottom: ${boundingClientRect.bottom} and updating transform...`
);
setWorkspaceRect(boundingClientRect);
}

if (entries.length !== 1) {
// Note: I don't know why there would be none or multiple entries, I just assume
// there is one entry passed to the resize handler when it is called by
// the resize observer. So if this is not the case, maybe investigate\
// what to do.
console.warn(
'Warning: Unexpected number of entries in ResizeObserver callback (more than 1).'
);
}
};

const visualiserWorkspaceEle = document.getElementById(VISUALISER_WORKSPACE_ID);
if (visualiserWorkspaceEle) {
// https://developer.mozilla.org/en-US/docs/Web/API/Resize_Observer_API
new ResizeObserver(handleWorkspaceResize).observe(visualiserWorkspaceEle);
}
}, []);

return (
<Box
onWheel={onScroll}
id="visualiser-container"
id={VISUALISER_WORKSPACE_ID}
margin="auto"
width="100vw"
height="100vh"
width={window.screen.width}
onWheel={onScroll}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerUp}
>
<ZoomableSvg
ref={svgRef}
id="visualiser-canvas"
scale={scale}
viewBox={`${newViewBox.x} ${newViewBox.y} ${width} ${height}`}
/>
<ZoomableSvg ref={svgRef} id={VISUALISER_CANVAS_ID} transformMat={transform} />
</Box>
);
};
Expand Down
2 changes: 1 addition & 1 deletion client/src/styles/DevelopmentMode.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ body {
color: var(--slate-12);
}

.layout>* {
.layout > * {
min-width: 0;
min-height: 0;
}
Expand Down
Loading

0 comments on commit 9a1fab9

Please sign in to comment.