diff options
Diffstat (limited to 'maps-frontend/src/components/Canvas.js')
-rw-r--r-- | maps-frontend/src/components/Canvas.js | 571 |
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; + |