Floorplan Viewer Documentation
Only for Assessment Use
By Fazeel Ahmed on 2024-09-14
Floor Planner Documentation
Instead of having to run the assessment on your own, I took the liberty of putting it inside my personal project I am working on for easy hosting. Find it here. Floorplan Viewer. I used Next and typescript knowing full well that this is overkill. Raw code for self hosting could be provided. I haven't done it yet since I would have to separate this assessment from existing layouts.
The Entry Point
The given assessment was incorporated into an existing Next Project for my ease. The route for floor-planner starts from here which loads the sample data and passes it on to a canvas component.
//src/app/floorplan-viewer/page.tsx
import Canvas from "@/components/canvas/Canvas";
//Entry Point for Floorplan Viewer Route
//Populates Canvas with Data
export default function FloorplanViewer() {
const sampleData = {
"Regions": [
[
{
"X": 0,
"Y": 0,
"Z": 52.49343832020996
},
{
"X": 38.27436931869327,
"Y": 34.868392433523155,
"Z": 52.49343832020996
}
],
[
{
"X": 0,
"Y": 100,
"Z": 52.49343832020996
},
{
"X": 55.65625908245986,
"Y": 34.86839243352309,
"Z": 52.49343832020996
}
],
[
{
"X": 100,
"Y": 100,
"Z": 52.49343832020996
},
{
"X": 55.656259082459876,
"Y": 44.38282812906108,
"Z": 52.49343832020996
}
],
[
{
"X": 100,
"Y": 0,
"Z": 52.49343832020996
},
{
"X": 38.27436931869315,
"Y": 44.38282812906114,
"Z": 52.49343832020996
}
]
],
"Doors": [
{
"Location": {
"X": 38.11032732394258,
"Y": 37.32902235448528,
"Z": 52.49343832020996
},
"Rotation": 4.712388980384696,
"Width": 4.284776902887138
}
],
"Furnitures": [
{
"MinBound": {
"X": -10,
"Y": -20,
"Z": -2.4868995751603507e-14
},
"MaxBound": {
"X": 10,
"Y": 20,
"Z": 2.7887139107611625
},
"equipName": "Equipment 1",
"xPlacement": 0,
"yPlacement": 0,
"rotation": 1.5707963267948966
},
{
"MinBound": {
"X": -1.416666666666667,
"Y": -1.8501516343696665,
"Z": -2.6645352591003757e-15
},
"MaxBound": {
"X": 1.4166666666666665,
"Y": 1.2500000000000004,
"Z": 7.083333333333304
},
"equipName": "Equipment 2",
"xPlacement": 39.69103598405127,
"yPlacement": 42.96309243717516,
"rotation": 3.141592653589793
},
{
"MinBound": {
"X": -0.6118766404199494,
"Y": -1.2729658792650858,
"Z": -4.440892098500626e-16
},
"MaxBound": {
"X": 0.6118766404199577,
"Y": 0.6364829396325504,
"Z": 3.2972440944882178
},
"equipName": "Equipment 3",
"xPlacement": 42.64820625787592,
"yPlacement": 43.86914569417966,
"rotation": 3.141592653589793
}
]
};
return (
<main className="defaultPage">
<Canvas regions={sampleData.Regions} doors={sampleData.Doors} furnitures={sampleData.Furnitures} />
</main>
);
}
The Canvas Component
The main component responsible for parsing the provided data as well as drawing it.
//src/components/canvas/Canvas.tsx
"use client"
import { useEffect, useRef, useState } from "react";
import PopupMouseFollow from "@/components/popups/PopupMouseFollow";
interface Point {
X: number;
Y: number;
Z: number;
}
interface Region extends Array<Point[]> { }
interface Door {
Location: Point;
Rotation: number;
Width: number;
}
interface Furniture {
MinBound: Point;
MaxBound: Point;
equipName: string;
xPlacement: number;
yPlacement: number;
rotation: number;
}
interface CanvasProps {
regions: Region;
doors: Door[];
furnitures: Furniture[];
}
interface FurnitureItem {
x: number;
y: number;
width: number;
height: number;
name: string;
}
interface TooltipProps {
showTooltip: boolean;
text: string;
}
const Canvas: React.FC<CanvasProps> = ({ regions, doors, furnitures }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [scale, setScale] = useState(1);
const [origin, setOrigin] = useState({ x: 0, y: 0 });
// For hover on equipment
const [tooltip, setTooltip] = useState<TooltipProps>({
showTooltip: false,
text: "",
});
// For displaying coordinate values
const formatCoordinate = (num: number) => num.toFixed(1);
//Sets the initial scale to fit the floorplan in the window
useEffect(() => {
let maxX = 0;
let maxY = 0;
let minX = 0;
let minY = 0;
regions.forEach(([start, end]) => {
const xValues = [start.X, end.X];
const yValues = [start.Y, end.Y];
maxX = Math.max(maxX, ...xValues);
maxY = Math.max(maxY, ...yValues);
minX = Math.min(minX, ...xValues);
minY = Math.min(minY, ...yValues);
});
const windowWidth = window.innerWidth - 150;
const windowHeight = window.innerHeight - 150;
const regionWidth = maxX - minX;
const regionHeight = maxY - minY;
const scaleX = windowWidth / regionWidth;
const scaleY = windowHeight / regionHeight;
const newScale = Math.min(scaleX, scaleY);
setScale(newScale);
}, [regions]);
//Redraws canvas on panning and if data changes
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = window.innerWidth * 0.95;
canvas.height = window.innerHeight * 0.90;
// Set 0 0 on top left corner
ctx.translate(50, 50);
// Stores furniture positions check for hover
let allFurnitures: FurnitureItem[] = [];
// Main Draw Logic
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw regions
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
// Assumes any two opposite points of a diagonal are given as start and end to complete the rectangle.
regions.forEach(([start, end]) => {
// Calculate the other two corners of the rectangle
const topRight = { X: start.X, Y: end.Y };
const bottomLeft = { X: end.X, Y: start.Y };
// Draw the four sides of the rectangle. Could be done with fillRect.
ctx.beginPath();
ctx.moveTo(start.X * scale + origin.x, start.Y * scale + origin.y);
ctx.lineTo(topRight.X * scale + origin.x, topRight.Y * scale + origin.y);
ctx.lineTo(end.X * scale + origin.x, end.Y * scale + origin.y);
ctx.lineTo(bottomLeft.X * scale + origin.x, bottomLeft.Y * scale + origin.y);
ctx.closePath();
ctx.stroke();
// Labelling the corners of the rectangle
ctx.fillStyle = 'black';
ctx.fillText(`(${formatCoordinate(start.X)}, ${formatCoordinate(start.Y)})`, start.X * scale + origin.x + 5, start.Y * scale + origin.y - 5);
ctx.fillText(`(${formatCoordinate(topRight.X)}, ${formatCoordinate(topRight.Y)})`, topRight.X * scale + origin.x + 5, topRight.Y * scale + origin.y - 5);
ctx.fillText(`(${formatCoordinate(end.X)}, ${formatCoordinate(end.Y)})`, end.X * scale + origin.x + 5, end.Y * scale + origin.y - 5);
ctx.fillText(`(${formatCoordinate(bottomLeft.X)}, ${formatCoordinate(bottomLeft.Y)})`, bottomLeft.X * scale + origin.x + 5, bottomLeft.Y * scale + origin.y - 5);
});
// Draw doors
ctx.strokeStyle = 'magenta';
ctx.lineWidth = 5;
// Using single line for simplicity, could be fillRect. Door rotation counter-clockwise. Line thickness of 5 is subjective.
doors.forEach(door => {
ctx.beginPath();
ctx.moveTo(door.Location.X * scale + origin.x, door.Location.Y * scale + origin.y); // Top-left
let endX = door.Location.X * scale + door.Width * scale * Math.cos(door.Rotation) + origin.x;
let endY = door.Location.Y * scale - door.Width * scale * Math.sin(door.Rotation) + origin.y;
ctx.lineTo(endX, endY);
ctx.closePath();
ctx.stroke();
});
// Draw furniture
ctx.fillStyle = 'red';
ctx.strokeStyle = 'blue';
ctx.lineWidth = 1;
furnitures.forEach(furniture => {
ctx.save();
ctx.translate(furniture.xPlacement * scale + origin.x, furniture.yPlacement * scale + origin.y);
ctx.rotate(furniture.rotation);
// Assumes the bounding box is drawn centered on xPlacement and yPlacement. Rotation is counter-clockwise.
// Fills furniture
ctx.fillRect(
furniture.MinBound.X * scale,
furniture.MinBound.Y * scale,
(furniture.MaxBound.X - furniture.MinBound.X) * scale,
(furniture.MaxBound.Y - furniture.MinBound.Y) * scale
);
// Puts Strokes around furniture
ctx.strokeRect(
furniture.MinBound.X * scale,
furniture.MinBound.Y * scale,
(furniture.MaxBound.X - furniture.MinBound.X) * scale,
(furniture.MaxBound.Y - furniture.MinBound.Y) * scale
);
ctx.restore();
// Converts bounding box into actual bounding box matching visuals by applying the rotation
// Could be done opposite, first apply the rotation to calculate new bounding box
// Then draw that new bounding box. Didn't do it because old habit of not transforming initial data.
// If done that way would be better in almost all ways especially since this calculation would not
// be done for every redraw but instead will be done once at data load.
const rotatedBox = getRotatedBoundingBox(
furniture.xPlacement,
furniture.yPlacement,
furniture.MaxBound.X - furniture.MinBound.X,
furniture.MaxBound.Y - furniture.MinBound.Y,
furniture.rotation
);
//Store new bounding box for reference
allFurnitures.push({
...rotatedBox,
name: furniture.equipName,
});
});
};
draw();
// Functions for Zoom and Pan respectively.
const handleZoom = (e: WheelEvent) => {
e.preventDefault();
setScale(prevScale => Math.min(Math.max(1, prevScale + e.deltaY * -0.001), 10)); // Set min zoom at 1 and max at 10. Subjective.
};
const handlePan = (e: MouseEvent) => {
setOrigin(prevOrigin => ({
x: prevOrigin.x + e.movementX,
y: prevOrigin.y + e.movementY
}));
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 0) {
const handleMouseMove = (e: MouseEvent) => handlePan(e);
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
};
canvas.addEventListener('wheel', handleZoom);
canvas.addEventListener('mousedown', handleMouseDown);
// HOVER IMPLEMENTATION
// Checks for potential hover matches on every mouse
// Forgot there was an existing custom hook useMousePosition to get current mouse position. Could be integrated here
canvas.addEventListener('mousemove', onMouseMove);
function onMouseMove(event: MouseEvent): void {
const { x: canvasX, y: canvasY } = convertToCanvasCoords(event.clientX, event.clientY);
for (const furniture of allFurnitures) {
if (isMouseOverFurniture(canvasX, canvasY, furniture)) {
setTooltip({
showTooltip: true,
text: furniture.name,
});
return;
} else {
setTooltip({
showTooltip: false,
text: "",
});
}
}
}
// Converts px input of mouse current position to canvas units
function convertToCanvasCoords(mouseX: number, mouseY: number): { x: number; y: number } {
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect()
const x = (mouseX - 50 - origin.x - rect.left) / scale; // 50 here because of initial translate 50. Should be assigned as variable.
const y = (mouseY - 50 - origin.y - rect.top) / scale;
return { x, y };
}
return () => {
// Remove previous listeners on redraw because of scale and origin
canvas.removeEventListener('wheel', handleZoom);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', onMouseMove);
};
}, [regions, doors, furnitures, scale, origin]); // Redraw on change in scale and pan through origin
return (
<>
<canvas ref={canvasRef} className="floorplan-canvas"></canvas>
<PopupMouseFollow className="floorplan-tooltip" text={tooltip.text} showPopup={tooltip.showTooltip} />
</>
);
};
export default Canvas;
// Used to generate rotated bounding box. Rotates the points around the furniture's provided center counter-clockwise. Takes radians.
function rotatePoint(x: number, y: number, rad: number, originX: number, originY: number): { x: number; y: number } {
const translatedX = x - originX;
const translatedY = y - originY;
return {
x: translatedX * Math.cos(rad) - translatedY * Math.sin(rad) + originX,
y: translatedX * Math.sin(rad) + translatedY * Math.cos(rad) + originY
};
}
// Convert given bounding box with given center and bounding limits into a manageable bounding box of top left corner with only a height and width value
function getRotatedBoundingBox(x: number, y: number, width: number, height: number, angle: number): { x: number; y: number; width: number; height: number } {
const corners = [
rotatePoint(x - width / 2, y - height / 2, angle, x, y),
rotatePoint(x + width / 2, y - height / 2, angle, x, y),
rotatePoint(x + width / 2, y + height / 2, angle, x, y),
rotatePoint(x - width / 2, y + height / 2, angle, x, y)
];
const xs = corners.map(corner => corner.x);
const ys = corners.map(corner => corner.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}
function isMouseOverFurniture(mouseX: number, mouseY: number, furniture: FurnitureItem): boolean {
return mouseX >= furniture.x &&
mouseX <= furniture.x + furniture.width &&
mouseY >= furniture.y &&
mouseY <= furniture.y + furniture.height;
}
Custom Component to Display Tooltip
Creates a div element that follows the mouse around
//src/components/popups/PopupMouseFollow.tsx
import useTooltipPosition from "@/hooks/useTooltipPosition";
import { useRef } from "react";
interface PopupMouseFollowProps {
showPopup: boolean
text: string
className: string
}
// Generates a Popup that should appear above the mouse and follow the mouse until the conditions
// are met as well as handle cases when mouse is too close to edges.
export default function PopupMouseFollow({ showPopup, text, className }: PopupMouseFollowProps) {
const tooltipRef = useRef<HTMLDivElement>(null);
// Custom Hook to get responsive positioning for tooltip.
const { position } = useTooltipPosition(showPopup, tooltipRef);
return (
showPopup && (
<div
className={`${className} popupMouseFollow`}
ref={tooltipRef}
style={{
top: `${position.top}px`,
left: `${position.left}px`,
display: 'block',
}}
>
{text}
</div>
));
}
Custom Hook for Responsive Tooltip
//src/hooks/useTooltipPosition.ts
import { useCallback, useEffect, useState } from "react";
import useMousePosition from "./useMousePosition";
interface Position {
top: number;
left: number;
}
// Only runs when visible to make sure innerWidth is calculatable.
const useTooltipPosition = (visible: boolean, tooltipRef: React.RefObject<HTMLElement>) => {
const [position, setPosition] = useState<Position>({ top: 0, left: 0 });
// Another custom hook to get current mouse position. Abstracts listener logic away.
const { x, y } = useMousePosition();
const updatePosition = useCallback(() => {
if (visible && tooltipRef.current) {
const { innerWidth, innerHeight } = window;
const tooltipRect = tooltipRef.current.getBoundingClientRect();
// Calculate top and left position of tooltip to see if tooltip needs to be moved in case of overflow.
let top = y-tooltipRect.height - 10;
let left = x-tooltipRect.width/2;
if (top < 10) {
top = y + 10;
}
if (left + tooltipRect.width + 10 > innerWidth) {
left = innerWidth - tooltipRect.width - 10;
}
if (left < 10) {
left = 10;
}
setPosition({ top, left });
}
}, [x,y, tooltipRef, visible]);
useEffect(() => {
updatePosition();
}, [updatePosition]);
return { position };
};
export default useTooltipPosition;
Custom Hook for Reading Mouse Position
//src/hooks/useMousePosition.ts
import { useState, useEffect } from 'react';
// Define the type for the mouse position
interface MousePosition {
x: number;
y: number;
}
const useMousePosition = (): MousePosition => {
// State to store mouse position
const [mousePosition, setMousePosition] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
// Handler to update mouse position
const handleMouseMove = (event: MouseEvent) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
// Attach event listener
window.addEventListener('mousemove', handleMouseMove);
// Clean up event listener on unmount
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
return mousePosition;
};
export default useMousePosition;
CSS File For Basic Styles
.floorplan-canvas {
width: 95vw;
height: 90vh;
background-color: white;
margin: 1rem;
font-size: 1.6rem;
&:hover {
cursor: grab;
}
&:active {
cursor: grabbing;
}
}
.floorplan-tooltip {
padding: 1rem 2rem;
background-color: rgb(146, 131, 131);
font-size: 1.6rem;
border: 1px solid black;
color: black;
box-shadow: 5px 5px 5px 5px rgba(68, 58, 58, 0.24);
}