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);
}