DEV Community

Cover image for Build indoor maps with Fabric.js and React
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Build indoor maps with Fabric.js and React

Written by Martin Kimani✏️

Indoor mapping is a novel concept that uses a digital 2D or 3D map to visualize an indoor venue or geographic data. By displaying places, people, and assets on a digital map, you can recreate indoor locations with navigation functionality, allowing for many business use cases that improve workflows and efficiencies.

For example, you can use indoor mapping to provide deeper insights into visitor behavior, improving managers’ capacity to discover and identify assets quickly and easily. Managers then have the option to use this knowledge to restructure for more efficient operations.

To build indoor maps, developers can use Fabric.js with React to grasp the basic functionalities of the grid system, zooming, panning, and annotations. In this article, we’ll cover how to use Fabric.js inside the component's render method.

To follow along with this article, you'll need basic knowledge of React, HTML, CSS, and JavaScript. You’ll also need a canvas element with an ID and the function that returns the fabric.Canvas object. Finally, you need a basic understanding of how to use npm.

To follow along with this article, you can find the full code for this project on GitHub. Let’s get started!

Indoor Mapping Gif

Table of contents

What is Fabric.js?

A powerful and simple JavaScript library that provides an interactive platform to work with React, Fabric.js allows you to create various objects and shapes on a canvas, ranging from simple geometric shapes to more complex ones.

With Fabric.js, you can work with both images and animations. Fabric.js allows you to drag, scale, and rotate images; you can also group shapes and objects to be manipulated together. Fabric.js even provides functionality to serialize the canvas to SVG or JSON and reuse it as and when needed. With the help of node-canvas libraries, Fabric.js is supported by Node.js.

Populating objects on the canvas

To create objects on the Fabric.js canvas, first create the Canvas class before populating the required objects into it. Use the createElement function to upload the canvas into the document and its container. Now, create the different objects that will be populated on the canvas, as shown below. Populate them using the necessary functions:

import Base from '../core/Base';
import { Arrow } from './Arrow';

const Modes = {
  SELECT: 'select',
  DRAWING: 'drawing',
  ARROW: 'arrow',
  TEXT: 'text'
};

export class Canvas extends Base {
  constructor(container, options) {
    super(options);

    this.container = container;

    const canvas = document.createElement('canvas');
    this.container.appendChild(canvas);
    canvas.setAttribute('id', 'indoorjs-canvas');

    canvas.width = this.width || this.container.clientWidth;
    canvas.height = this.height || this.container.clientHeight;

    this.currentColor = this.currentColor || 'black';
    this.fontFamily = this.fontFamily || 'Roboto';

    this.canvas = new fabric.Canvas(canvas, {
      freeDrawingCursor: 'none',
      freeDrawingLineWidth: this.lineWidth
    });
    this.arrows = [];

    this.setLineWidth(this.lineWidth || 10);
    this.addCursor();
    this.addListeners();

    this.setModeAsArrow();
  }

  setModeAsDrawing() {
    this.mode = Modes.DRAWING;
    this.canvas.isDrawingMode = true;
    this.canvas.selection = false;
    this.onModeChanged();
  }

  isDrawingMode() {
    return this.mode === Modes.DRAWING;
  }

 setModeAsSelect() {
    this.mode = Modes.SELECT;
    this.canvas.isDrawingMode = false;
    this.canvas.selection = true;
    this.onModeChanged();
  }

  isSelectMode() {
    return this.mode === Modes.SELECT;
  }

  setModeAsArrow() {
    this.mode = Modes.ARROW;
    this.canvas.isDrawingMode = false;
    this.canvas.selection = false;
    this.onModeChanged();
  }

  isArrowMode() {
    return this.mode === Modes.ARROW;
  }

  setModeAsText() {
    this.mode = Modes.TEXT;
    this.canvas.isDrawingMode = false;
    this.canvas.selection = false;
    this.onModeChanged();
  }
Enter fullscreen mode Exit fullscreen mode

Creating the gradient of objects

Since the gradient is essential for the measurement of objects on the canvas, use the measurement class to implement the x and y-axis. The code below shows how to use the x and y-axis and the onMouseMove function to create the gradient of objects:

import Measurer from './Measurer';

class Measurement {
  constructor(map) {
    this.map = map;
    this.measurer = null;
  }

  onMouseMove(e) {
    const point = {
      x: e.absolutePointer.x,
      y: e.absolutePointer.y,
    };

    if (this.measurer && !this.measurer.completed) {
     this.measurer.setEnd(point);
      this.map.canvas.requestRenderAll();
    }
  }

  onClick(e) {
    const point = {
      x: e.absolutePointer.x,
      y: e.absolutePointer.y,
    };
    if (!this.measurer) {
      this.measurer = new Measurer({
        start: point,
        end: point,
       map: this.map,
      });

      // this.map.canvas.add(this.measurer);
    } else if (!this.measurer.completed) {
      this.measurer.setEnd(point);
      this.measurer.complete();
    }
  }
}

export default Measurement;
Enter fullscreen mode Exit fullscreen mode

Building the grid system

Import alpha, grid-style, Axis, and Point from Geometry. Before proceeding to the next step, create a constructor of the canvas inside the Grid class. Use the getCenterCoords function to get the coordinates, width, height, and states of the different shapes.

Reevaluate the lines with the x and y-axis to calculate the options for renderer and recalculate their state. Get state object with calculated parameters ready for rendering. Finally, calculate the real offset/range:

import alpha from '../lib/color-alpha';
import Base from '../core/Base';
import {
  clamp, almost, len, parseUnit, toPx, isObj
} from '../lib/mumath/index';
import gridStyle from './gridStyle';
import Axis from './Axis';
import { Point } from '../geometry/Point';

// constructor
class Grid extends Base {
  constructor(canvas, opts) {
    super(opts);
    this.canvas = canvas;
    this.context = this.canvas.getContext('2d');
    this.state = {};
    this.setDefaults();
    this.update(opts);
  }

  render() {
    this.draw();
    return this;
  }

  getCenterCoords() {
    let state = this.state.x;
    let [width, height] = state.shape;
    let axisCoords = state.opposite.coordinate.getCoords(
      [state.coordinate.axisOrigin],
      state.opposite
    );
    const y = pt + axisCoords[1] * (height - pt - pb);
    state = this.state.y;
    [width, height] = state.shape;
    [pt, pr, pb, pl] = state.padding;
    axisCoords = state.opposite.coordinate.getCoords([state.coordinate.axisOrigin], state.opposite);
    const x = pl + axisCoords[0] * (width - pr - pl);
    return { x, y };
  }

  setSize(width, height) {
    this.setWidth(width);
    this.setHeight(height);
  }

  setWidth(width) {
    this.canvas.width = width;
  }

  setHeight(height) {
    this.canvas.height = height;
  }

  update(opts) {
    if (!opts) opts = {};
    const shape = [this.canvas.width, this.canvas.height];

    // recalc state
    this.state.x = this.calcCoordinate(this.axisX, shape, this);
    this.state.y = this.calcCoordinate(this.axisY, shape, this);
    this.state.x.opposite = this.state.y;
    this.state.y.opposite = this.state.x;
    this.emit('update', opts);
    return this;
  }

  // re-evaluate lines,
  update2(center) {
    const shape = [this.canvas.width, this.canvas.height];
    Object.assign(this.center, center);
    // recalc state
    this.state.x = this.calcCoordinate(this.axisX, shape, this);
    this.state.y = this.calcCoordinate(this.axisY, shape, this);
    this.state.x.opposite = this.state.y;
    this.state.y.opposite = this.state.x;
    this.emit('update', center);

    this.axisX.offset = center.x;
    this.axisX.zoom = 1 / center.zoom;

    this.axisY.offset = center.y;
    this.axisY.zoom = 1 / center.zoom;
  }

  calcCoordinate(coord, shape) {
    const state = {
      coordinate: coord,
      shape,
      grid: this
    };
    // calculate real offset/range
    state.range = coord.getRange(state);
    state.offset = clamp(
      Math.max(coord.min, -Number.MAX_VALUE + 1),
      Math.min(coord.max, Number.MAX_VALUE) - state.range
    );
Enter fullscreen mode Exit fullscreen mode

Implementing zoom and panning

Since there are a few zoom features in the previous code, we’ll implement zoom and panning features inside the grid. The stub methods use the visible range parameters, labels, line, and axis parameters to return coordinates for the values redefined by the axes.

Now, declare the Zoom function with important variables like height, width, minimum, and maximum zoom positions. At this point, it’s also critical to declare the pan and its features. Finally, to return the screen to default features after zooming and panning, use the reset function as shown below:

setZoom(zoom) {
    const { width, height } = this.canvas;
    this.zoom = clamp(zoom, this.minZoom, this.maxZoom);
    this.dx = 0;
    this.dy = 0;
    this.x = width / 2.0;
    this.y = height / 2.0;
    this.update();
    process.nextTick(() => {
      this.update();
    });
  }
  this.zoom = Math.min(scaleX, scaleY);

    this.canvas.setZoom(this.zoom);

    this.canvas.absolutePan({
      x: this.originX + this.center.x * this.zoom,
      y: this.originY - this.center.y * this.zoom
    });

reset() {
    const { width, height } = this.canvas;
    this.zoom = this._options.zoom || 1;
    this.center = new Point();
    this.originX = -this.canvas.width / 2;
    this.originY = -this.canvas.height / 2;
    this.canvas.absolutePan({
      x: this.originX,
      y: this.originY
    });
 const objects = canvas.getObjects();
    let hasKeepZoom = false;
    for (let i = 0; i < objects.length; i += 1) {
      const object = objects[i];
      if (object.keepOnZoom) {
        object.set('scaleX', 1.0 / this.zoom);
        object.set('scaleY', 1.0 / this.zoom);
        object.setCoords();
        hasKeepZoom = true;
  this.emit(`${object.class}scaling`, object);
      }
    }
    if (hasKeepZoom) canvas.requestRenderAll();
  }

  panzoom(e) {
    // enable interactions
    const { width, height } = this.canvas;

    const prevZoom = 1 / this.zoom;
    let curZoom = prevZoom * (1 - zoom);
    curZoom = clamp(curZoom, this.minZoom, this.maxZoom);

    // pan
    const oX = 0.5;
    const oY = 0.5;
    if (this.isGrabMode() || e.isRight) {
      x -= prevZoom * e.dx;
      y += prevZoom * e.dy;
      this.setCursor('grab');
    } else {
      this.setCursor('pointer');
    }

    if (this.zoomEnabled) {
      x -= width * (curZoom - prevZoom) * tx;
      y -= height * (curZoom - prevZoom) * ty;
    }
    this.center.setX(x);
    this.center.setY(y);
    this.zoom = 1 / curZoom;
    this.dx = e.dx;
    this.dy = e.dy;
    this.x = e.x0;
    this.y = e.y0;
    this.isRight = e.isRight;
    this.update();
  }
Enter fullscreen mode Exit fullscreen mode

Adding annotations

Annotation refers to labeling text or images. When the default label options don’t fit our needs, we can use annotation to improve the taxonomy. To annotate our code, we’ll first import the image annotation tools into the component. To use a nested array of objects, the labels must start with the coordinates of the labels or annotations.

Finally, we convert the hashmap labels or annotations to lines and colors, making them visible when the application is running:

    let labels;
    if (coord.labels === true) labels = state.lines;
    else if (coord.labels instanceof Function) {
      labels = coord.labels(state);
    } else if (Array.isArray(coord.labels)) {
      labels = coord.labels;
    } else if (isObj(coord.labels)) {
      labels = coord.labels;
    } else {
      labels = Array(state.lines.length).fill(null);
    }
    state.labels = labels;
    // convert hashmap labels to lines
    if (isObj(ticks)) {
      state.ticks = Array(lines.length).fill(0);
    }
    if (isObj(labels)) {
      state.labels = Array(lines.length).fill(null);
    }
    if (isObj(ticks)) {
      // eslint-disable-next-line
      Object.keys(ticks).forEach((value, tick) => {
        state.ticks.push(tick);
       state.lines.push(parseFloat(value));
      state.lineColors.push(null);
       state.labels.push(null);
      });
    }

    if (isObj(labels)) {
      Object.keys(labels).forEach((label, value) => {
        state.labels.push(label);
        state.lines.push(parseFloat(value));
        state.lineColors.push(null);
        state.ticks.push(null);
      });
    }

    return state;
  }
Enter fullscreen mode Exit fullscreen mode

Conclusion

Fabric.js is one of the best drawing libraries on the market at the time of writing. In this article, we learned how to wrap a complex library into an uncontrolled component of React. Hopefully, Fabric.js will implement other components as well. I'm eager to check out from the comments if there's a better alternative to Fabric.js. I've used Fabric.js with great success in the past despite it being under development at the time of writing. Thanks for reading!


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.

Top comments (1)

Collapse
 
nihaojob profile image
秦少卫

Your project is very cool. I also use fabric.js to develop a picture editor.

github.com/nihaojob/vue-fabric-editor

Image description