aboutsummaryrefslogtreecommitdiff
path: root/maps-frontend/src/components/Canvas.js
diff options
context:
space:
mode:
Diffstat (limited to 'maps-frontend/src/components/Canvas.js')
-rw-r--r--maps-frontend/src/components/Canvas.js571
1 files changed, 571 insertions, 0 deletions
diff --git a/maps-frontend/src/components/Canvas.js b/maps-frontend/src/components/Canvas.js
new file mode 100644
index 0000000..166967e
--- /dev/null
+++ b/maps-frontend/src/components/Canvas.js
@@ -0,0 +1,571 @@
+// JS module imports
+import { useEffect, useRef, useState } from "react";
+import axios from 'axios';
+
+// CSS imports
+import '../css/Canvas.css';
+
+/**
+ * This function renders and mantains thhe canvas.
+ * @param {Object} props The props for the canvas.
+ * @returns {import("react").HtmlHTMLAttributes} The canvas to be retured.
+ */
+function Canvas(props) {
+ // instance variables
+ const CANVAS_WIDTH = window.innerWidth;
+ const CANVAS_HEIGHT = window.innerHeight;
+
+ const SCALE_RATIO = .035/.015;
+ const START_LON_SCALE = .005;
+ const MIN_LON_SCALE = .001;
+ const MAX_LON_SCALE = 1;
+ const SCALE_INTERVAL = .0007;
+ const TILE_SIZE = .1;
+
+ // Ways processing. Long object...
+ const A_TIER_MAX_SCALE = .175;
+ const B_TIER_MAX_SCALE = .08;
+ const WAYS_INFO = {
+ primary: {
+ color: 'darkorange',
+ weight: 2,
+ maxScale: MAX_LON_SCALE
+ },
+ motorway: {
+ color: 'darkorange',
+ weight: 1.85,
+ maxScale: MAX_LON_SCALE
+ },
+ secondary: {
+ color: 'orange',
+ weight: 1.75,
+ maxScale: MAX_LON_SCALE
+ },
+ tertiary: {
+ color: 'orange',
+ weight: 1.75,
+ maxScale: MAX_LON_SCALE
+ },
+ residential: {
+ color: 'white',
+ weight: 1.75,
+ maxScale: MAX_LON_SCALE
+ },
+
+ primary_link: {
+ color: 'yellow',
+ weight: 1.6,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ motorway_link: {
+ color: 'yellow',
+ weight: 1.5,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ secondary_link: {
+ color: 'yellow',
+ weight: 1.5,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ tertiary_link: {
+ color: 'yellow',
+ weight: 1.3,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ footway: {
+ color: 'mediumorchid',
+ weight: 1.25,
+ maxScale: .25
+ },
+ cycleway: {
+ color: 'blue',
+ weight: 1.35,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ trunk: {
+ color: 'amber',
+ weight: 1.25,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ trunk_link: {
+ color: 'amber',
+ weight: 1,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ road: {
+ color: 'white',
+ weight: 1.25,
+ maxScale: A_TIER_MAX_SCALE
+ },
+ living_street: {
+ color: 'white',
+ weight: 1.25,
+ maxScale: A_TIER_MAX_SCALE
+ },
+
+ service: {
+ color: 'red',
+ weight: 1,
+ maxScale: B_TIER_MAX_SCALE
+ },
+ construction: {
+ color: 'red',
+ weight: 1.25,
+ maxScale: B_TIER_MAX_SCALE
+ },
+ pedestrian: {
+ color: 'purple',
+ weight: 1,
+ maxScale: B_TIER_MAX_SCALE
+ },
+ track: {
+ color: 'pink',
+ weight: 1.2,
+ maxScale: B_TIER_MAX_SCALE
+ },
+ steps: {
+ color: 'purple',
+ weight: 1.1,
+ maxScale: B_TIER_MAX_SCALE
+ },
+ path: {
+ color: 'purple',
+ weight: 1.1,
+ maxScale: B_TIER_MAX_SCALE
+ }
+ }
+
+ const START_COORD = {
+ lat: 41.825,
+ lon: -71.406
+ }
+
+ // React ref to canvas
+ const ctxRef = useRef();
+
+ //Reach states
+ const [canvasWidth, setCanvasWidth] = useState(window.innerWidth);
+ const [canvasHeight, setCanvasHeight] = useState(window.innerHeight);
+ const [dragStart, setDragStart] = useState({});
+ const [scale, setScale] = useState(START_LON_SCALE);
+ const [referencePoint, setReferencePoint] = useState(START_COORD);
+ const [tiles, setTiles] = useState({});
+ const [requests, setRequests] = useState(new Set());
+ const [mouseDown, setMouseDown] = useState(false);
+ const [canvasImg, setCanvasImg] = useState();
+
+ // The following four methods map the latitude, longitude to the x,y of the grid.
+
+ /**
+ * The method mapping x to lon.
+ * @param {Number} x The x pixel difference to be converted.
+ * @returns {Number} The the corresponding longitude .
+ */
+ const xToLon = x => {
+ return x*(scale/canvasWidth);
+ }
+
+ /**
+ * The method mapping y to lat.
+ * @param {Number} y The y pixel difference to be converted.
+ * @returns {Number} The the corresponding latitude.
+ * It's reversed due to origin being at top left (not bottom left).
+ */
+ const yToLat = y => {
+ const latScale = scale/SCALE_RATIO;
+ return -y*(latScale/canvasHeight);
+ }
+
+ /**
+ * The method mapping lon to x.
+ * @param {Number} lon The longitude to be coverted to x pixel difference.
+ * @returns {Number} The the corresponding x pixel difference.
+ */
+ const lonToX = lon => {
+ return lon/(scale/canvasWidth);
+ }
+
+
+ /**
+ * @param {Number} lat The latitude to be converted to y pixel difference.
+ * @returns {Number} The the corresponding y value.
+ * It's reversed due to the difference origins.
+ */
+ const latToY = lat => {
+ const latScale = scale/SCALE_RATIO;
+ return -lat/(latScale/canvasHeight);
+ }
+
+ /**
+ * Calculated the current grid's corners to use in the ways command.
+ * @returns {Object} The top left and bottom right coordinates in terms of the lat and lon.
+ */
+ const getLatLonBounds = () => {
+ const lat1 = referencePoint.lat + TILE_SIZE;
+ const long1 = referencePoint.lon - TILE_SIZE;
+ const lat2 = referencePoint.lat + yToLat(canvasHeight) - TILE_SIZE;
+ const long2 = referencePoint.lon + xToLon(canvasWidth) + TILE_SIZE;
+ const topLeft = {
+ lat1: +(Math.round((lat1 * 1000)/10)/100).toFixed(1),
+ long1: +(Math.round((long1 * 1000)/10)/100).toFixed(1)
+ }
+ const botRight = {
+ lat2: +(Math.round((lat2 * 1000)/10)/100).toFixed(1),
+ long2: +(Math.round((long2 * 1000)/10)/100).toFixed(1)
+ }
+
+ return {...topLeft, ...botRight};
+ }
+
+ /**
+ * Method the gets a tile and puts it into the cache.
+ * @param {Object} topLeft The topleft coordinate of the tile.
+ * @returns The ways within the tile.
+ */
+ const getTile = async topLeft => {
+ // TODO: implement cache
+
+ const toSend = {
+ lat1: topLeft[0],
+ long1: topLeft[1],
+ lat2: +(topLeft[0] - TILE_SIZE).toFixed(1),
+ long2: +(topLeft[1] + TILE_SIZE).toFixed(1)
+ }
+ //console.log(toSend);
+
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ 'Access-Control-Allow-Origin': '*',
+ }
+ };
+
+ //Install and import this!
+ //TODO: Fill in 1) location for request 2) your data 3) configuration
+ const res = await axios.post(
+ "http://localhost:4567/maps/ways",
+ JSON.stringify(toSend),
+ config
+ );
+
+ setTiles(prevState => ({
+ ...prevState,
+ [`lat${topLeft[0]}lon${topLeft[1]}`] : res.data["ways"]
+ }));
+
+ return res.data["ways"];
+ };
+
+ /**
+ * Method that draws a single way onto the canvas.
+ * @param {import("react").HtmlHTMLAttributes} ctx The context of the canvas.
+ * @param {Object} wayInfo The information of the way as an object.
+ */
+ const drawWay = (ctx, wayInfo) => {
+ // TODO: implement cache
+ ctx.beginPath();
+
+ const startY = latToY(wayInfo.startLat - referencePoint.lat);
+ const startX = lonToX(wayInfo.startLong - referencePoint.lon);
+ ctx.moveTo(startX, startY);
+
+ const endY = latToY(wayInfo.destLat - referencePoint.lat);
+ const endX = lonToX(wayInfo.destLong - referencePoint.lon);
+ ctx.lineTo(endX, endY);
+
+ if (wayInfo.type && WAYS_INFO[wayInfo.type]) {
+ if (WAYS_INFO[wayInfo.type].maxScale >= scale) {
+ ctx.strokeStyle = WAYS_INFO[wayInfo.type].color;
+ ctx.lineWidth = WAYS_INFO[wayInfo.type].weight;
+ ctx.stroke();
+ }
+ } else {
+ if (B_TIER_MAX_SCALE >= scale) {
+ ctx.strokeStyle = 'grey';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ }
+ }
+
+ if (props.route.includes(wayInfo.id)) {
+ ctx.strokeStyle = 'lightgreen';
+ ctx.lineWidth = 10;
+ ctx.stroke();
+ }
+ }
+
+ /**
+ * Handles the timeout from multiple quick requests to the api to ease the server.
+ * @param {CallableFunction} func The pending function call.
+ * @param {Number} delay The time in milliseconds to delay.
+ * @returns {CallableFunction} The function that runs the code after the delay.
+ */
+ const debounce = (func, delay) => {
+ let debounceTimer
+ return () => {
+ const context = this
+ const args = arguments
+ clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => func.apply(context, args), delay)
+ }
+ }
+
+ /**
+ * Determines whether the a tile is loaded on the canvas.
+ * @param {Object} topLeft The topLeft point of the tile.
+ * @returns True if any section of it is loaded on the canvas.
+ */
+ const tileOnScreen = topLeft => {
+ const tileLatBounds = [+(topLeft[0] - TILE_SIZE).toFixed(1), topLeft[0]];
+ const tileLongBounds = [topLeft[1], +(topLeft[1] + TILE_SIZE).toFixed(1)];
+ const screenLatBounds = [referencePoint.lat, referencePoint.lat + yToLat(canvasHeight)]
+ const screenLongBounds = [referencePoint.lon, referencePoint.lon + xToLon(canvasWidth)]
+ //by checking if the following for things are true, we can determine if the rectangles intersect
+ const tileLeftScreen = tileLatBounds[1] < screenLatBounds[1];
+ const tileRightScreen = tileLatBounds[0] > screenLatBounds[0];
+ const tileAboveScreen = tileLongBounds[0] > screenLongBounds[1];
+ const tileBelowScreen = tileLongBounds[1] < screenLongBounds[0];
+ return !( tileLeftScreen || tileRightScreen || tileAboveScreen || tileBelowScreen );
+ }
+
+ /**
+ * Method used to update the ways on the canvas.
+ * It draws in the new tiles and translates the old.
+ */
+ const updateWaysOnCanvas = debounce(() => {
+ const ctx = ctxRef.current.getContext("2d");
+ const bounds = getLatLonBounds();
+ ctx.canvas.width = canvasWidth;
+ ctx.fillStyle = "#121212";
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ for (let i = +(bounds.lat2 + TILE_SIZE).toFixed(1); i <= bounds.lat1; i = +(i + TILE_SIZE).toFixed(1)) {
+ for (let j = bounds.long1; j < bounds.long2; j = +(j + TILE_SIZE).toFixed(1)) {
+ const topLeft = [i, j];
+ const pointId = `lat${i}lon${j}`;
+ if (tiles[pointId] && tileOnScreen(topLeft)) {
+ //('accessing cache!');
+ Object.keys(tiles[pointId]).forEach(key => {
+ drawWay(ctx, tiles[pointId][key]);
+ });
+ } else {
+ if (!requests.has(pointId)) {
+ setRequests(requests.add(pointId));
+ getTile(topLeft).then((data) => {
+ if (tileOnScreen(topLeft)) {
+ Object.keys(data).forEach(key => {
+ drawWay(ctx, data[key]);
+ });
+ if (!props.hasLoaded) {
+ props.setHasLoaded(true);
+ }
+ }
+ });
+ }
+ }
+ }
+ }
+
+ // draws the routing points if they have been set
+ drawPoints();
+ }, 0);
+
+ /**
+ * Stores the starting point of the drah.
+ * @param {Object} e The event from the mouseDown callback.
+ */
+ const storeDragStart = e => {
+ setMouseDown(true);
+ const canvas = ctxRef.current;
+ setCanvasImg(canvas);
+
+ setDragStart({
+ x: e.pageX - canvas.offsetLeft,
+ y: e.pageY - canvas.offsetTop
+ });
+
+ props.setCursor('grabbing');
+ }
+
+ /**
+ * Makes a call to the server to determine the nearest neighbor.
+ * @param {Number} x The x pixel value of the point inputted.
+ * @param {Number} y The y pixel value of the point inputted.
+ * @returns {Object} The lat and lon coordinates of the nearest.
+ */
+ const getNearest = async (x, y) => {
+ const toSend = {
+ lat: yToLat(y) + referencePoint.lat,
+ long: xToLon(x) + referencePoint.lon
+ };
+
+ //console.log(toSend);
+
+ let config = {
+ headers: {
+ "Content-Type": "application/json",
+ 'Access-Control-Allow-Origin': '*',
+ }
+ };
+
+ //Install and import this!
+ //TODO: Fill in 1) location for request 2) your data 3) configuration
+ const res = await axios.post(
+ "http://localhost:4567/maps/nearest",
+ JSON.stringify(toSend),
+ config
+ )
+
+ return res.data;
+ }
+
+ /**
+ * This method draws the start and end points for the routing.
+ */
+ const drawPoints = () => {
+ const ctx = ctxRef.current.getContext('2d');
+
+ const startY = latToY(props.startLat - referencePoint.lat);
+ const startX = lonToX(props.startLon - referencePoint.lon);
+
+ const endY = latToY(props.endLat - referencePoint.lat);
+ const endX = lonToX(props.endLon - referencePoint.lon);
+
+ const drawStart = () => {
+ ctx.beginPath();
+ ctx.arc(startX, startY, 15, 0, Math.PI * 2, true);
+
+ ctx.strokeStyle = 'pink';
+ ctx.lineWidth = 10;
+ ctx.stroke();
+ }
+
+ const drawEnd = () => {
+ ctx.beginPath();
+ ctx.arc(endX, endY, 15, 0, Math.PI * 2, true);
+
+ ctx.strokeStyle = 'lightblue';
+ ctx.lineWidth = 10;
+ ctx.stroke();
+ }
+
+ const startNotChosen = props.startLat === 0 && props.startLon === 0;
+ const endNotChosen = props.endLat === 0 && props.endLon === 0;
+
+ if (!startNotChosen && !endNotChosen) {
+ drawStart();
+ drawEnd();
+ } else if (!startNotChosen) {
+ drawStart();
+ } else if (!endNotChosen) {
+ drawEnd();
+ }
+ }
+
+ /**
+ * Drags the canvas and also responds to a solo click for inputting the route coordinate.
+ * @param {Object} e The event from the callback click handler.
+ */
+ const dragCanvas = e => {
+ const canvas = ctxRef.current;
+
+ const endX = e.pageX - canvas.offsetLeft;
+ const endY = e.pageY - canvas.offsetTop;
+
+ const distX = endX - dragStart.x;
+ const distY = endY - dragStart.y;
+
+ setDragStart({
+ x: e.pageX - canvas.offsetLeft,
+ y: e.pageY - canvas.offsetTop
+ });
+
+ if ((distX === 0 || distY === 0) && props.selector !== '') {
+ getNearest(endX, endY).then((data) => {
+ props.setCoord(data);
+ });
+ } else {
+ setReferencePoint((refPoint) => {
+ return {
+ lat: refPoint.lat - yToLat(distY),
+ lon: refPoint.lon - xToLon(distX)
+ }
+ });
+ }
+ }
+
+ /**
+ * Makes dragging smmother by calling the drag command frame by frame.
+ * @param {Object} e The event from the callback mouseMove hanlder.
+ */
+ const smoothDrag = e => {
+ if (mouseDown) {
+ requestAnimationFrame(() => dragCanvas(e));
+ props.setCursor('grabbing');
+ }
+ }
+
+ /**
+ * Releases the drag by setting the cursor back and setting the state.
+ * @param {Object} e The event from the callback mouseDown handler.
+ */
+ const releaseDrag = e => {
+ props.setCursor('grab');
+ setMouseDown(false);
+ }
+
+ // The following are the react hooks that act as handlers and rerender on change.
+
+ useEffect(() => {
+ updateWaysOnCanvas();
+ }, [referencePoint, props.route]);
+
+ useEffect(() => {
+ updateWaysOnCanvas();
+ }, [props.selector]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ updateWaysOnCanvas();
+ props.setCursor('grab');
+ }, 750);
+ return () => clearTimeout(timer);
+ }, [scale]);
+
+ useEffect(() => {
+ const canvas = ctxRef.current;
+
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+
+ const zoomCanvas = e => {
+ e.preventDefault();
+
+ let direction = (e.deltaY > 0) ? 1 : -1;
+ props.setCursor(direction === 1 ? 'zoom-out' : 'zoom-in');
+ setScale((s) => {
+ const newScale = s + direction*SCALE_INTERVAL;
+ return Math.min(MAX_LON_SCALE, Math.max(newScale, MIN_LON_SCALE));
+ });
+ }
+ canvas.addEventListener('wheel', zoomCanvas, false);
+
+ window.addEventListener('resize', () => {
+ setCanvasWidth(window.innerWidth);
+ setCanvasHeight(window.innerHeight);
+ });
+ }, []);
+
+ useEffect(() => {
+ const canvas = ctxRef.current;
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+ updateWaysOnCanvas();
+ }, [canvasWidth, canvasHeight])
+
+
+ return <canvas ref={ctxRef} onMouseDown={(e) => storeDragStart(e)} onClick={(e) => dragCanvas(e)}
+ onMouseMove={(e) => smoothDrag(e)} onMouseUp={() => releaseDrag()} className="Map-canvas"></canvas>;
+}
+
+export default Canvas;
+