Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Drawing a wavy line in FabricJS

I'm using FabricJS to create a canvas for drawing specific lines and shapes. One of the lines is a wavy line with an arrow similar to this:

enter image description here

I've successfully created a straight version of this with an arrow endpoint but can't find any examples of how to create a wavy line. The user can draw the line as long as they want so the number of 'peaks' and 'troughs' in the line will need to adapt accordingly (a short line like the image above might have 4 peaks but a line twice the length would have 8 peaks, not just be a stretched version of the shorter line).

Here is the code I'm using to draw the straight line with the arrow endpoint. Note that the start point of the line is drawn on mousedown and the endpoint is drawn on mouseup.

import LineWithArrow from './LineWithArrow';

drawLineWithArrow = (item, points, color) => (
  new LineWithArrow(points, {
    customProps: item,
    strokeWidth: 2,
    stroke: color,
  })
)

selectLine = (item, points) => {
  switch (item.type) {
    case 'line_with_arrow':
      return this.drawLineWithArrow(item, points, colors.BLACK);

    case 'wavy_line_with_arrow':
      return this.drawWavyLineWithArrow(item, points);
    // no default
  }
  return null;
}

let line;
let isDown;

fabricCanvas.on('mouse:down', (options) => {
  isDown = true;
  const pointer = fabricCanvas.getPointer(options.e);
  const points = [pointer.x, pointer.y, pointer.x, pointer.y];
  line = this.selectLine(item, points);
  fabricCanvas
    .add(line)
    .setActiveObject(line)
    .renderAll();
});

fabricCanvas.on('mouse:move', (options) => {
  if (!isDown) return;
  const pointer = fabricCanvas.getPointer(options.e);
  line.set({ x2: pointer.x, y2: pointer.y });
  fabricCanvas.renderAll();
});

fabricCanvas.on('mouse:up', () => {
  isDown = false;
  line.setCoords();
  fabricCanvas.setActiveObject(line).renderAll();
});

And the LineWithArrow file:

import { fabric } from 'fabric';

const LineWithArrow = fabric.util.createClass(fabric.Line, {
  type: 'line_with_arrow',

  initialize(element, options) {
    options || (options = {});
    this.callSuper('initialize', element, options);

    // Set default options
    this.set({
      hasBorders: false,
      hasControls: false,
    });
  },

  _render(ctx) {
    this.callSuper('_render', ctx);
    ctx.save();
    const xDiff = this.x2 - this.x1;
    const yDiff = this.y2 - this.y1;
    const angle = Math.atan2(yDiff, xDiff);
    ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2);
    ctx.rotate(angle);
    ctx.beginPath();
    // Move 5px in front of line to start the arrow so it does not have the square line end showing in front (0,0)
    ctx.moveTo(5, 0);
    ctx.lineTo(-5, 5);
    ctx.lineTo(-5, -5);
    ctx.closePath();
    ctx.fillStyle = this.stroke;
    ctx.fill();
    ctx.restore();
  },

  toObject() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      customProps: this.customProps,
    });
  },
});

export default LineWithArrow;
like image 859
GuerillaRadio Avatar asked Jan 12 '18 09:01

GuerillaRadio


1 Answers

Results

I'm not really an expert, but I attempted to implement wavy lines all by myself.

That's the result:

Screenshot of arrows from codepen.io

Coding

I used the fabric.Group class to group lines that make up our wavy line.

const WavyLineWithArrow = fabric.util.createClass(fabric.Group, {
    /* ... */
};

The lines are removed and added to the object after each change:

this.forEachObject(function(o) {
    this.remove(o);
}, this);

for(var i=1;i<polyPoints.length;++i) {
    this.add(new fabric.Line([
      polyPoints[i-1].x,
      polyPoints[i-1].y,
      polyPoints[i].x,
      polyPoints[i].y
    ], options));
  }

Arrow at the end of a line is also an object:

  this.add(new fabric.Polyline([
    {x: len/2, y: -arrowSize/2},
    {x: len/2 + arrowSize/2, y: 0},
    {x: len/2, y: arrowSize/2},
    {x: len/2, y: -arrowSize/2}
  ], arrOptions));

All the hard task was the calculation of function values, scalling etc. but it's just boring geometry.

Disclaimer

I tested my wavy line implementation and it seems to work nicely even if you support other function (that is not a sine).

Only one problem I see that's in your example you rendered lines from corner to corner.

It's not a big deal to rotate the wavy line, but that's all the differences from the ideal solution that I noticed.

Fancy types of arrows

I made the following nice arrows:

Arrow types screenshot

// Default: sine
null

// Custom: tangens
[
    function(x) { return Math.max(-10, Math.min(Math.tan(x/2) / 3, 10)); },
    4 * Math.PI
]

// Custom: Triangle function
[
    function(x) {
      let g = x % 6;
      if(g<=3) return g*5;
      if(g>3) return (6-g)*5;
    },
    6
]

// Custom: Square function
[
    function(x) {
      let g = x % 6;
      if(g<=3) return 15;
      if(g>3) return -15;
    },
    6
]

Full example

Below I attach my snipped with working wavy lines.
You can also view that snippet on codepen.io

var fabricCanvas = this.__canvas = new fabric.Canvas('c');
fabricCanvas.setHeight(300);
fabricCanvas.setWidth(600);

const LineWithArrow = fabric.util.createClass(fabric.Line, {
  type: 'line_with_arrow',

  initialize(element, options) {
    options || (options = {});
    this.callSuper('initialize', element, options);

    // Set default options
    this.set({
      hasBorders: false,
      hasControls: false,
    });
  },

  _render(ctx) {
    this.callSuper('_render', ctx);
    ctx.save();
    const xDiff = this.x2 - this.x1;
    const yDiff = this.y2 - this.y1;
    const angle = Math.atan2(yDiff, xDiff);
    ctx.translate((this.x2 - this.x1) / 2, (this.y2 - this.y1) / 2);
    ctx.rotate(angle);
    ctx.beginPath();
    // Move 5px in front of line to start the arrow so it does not have the square line end showing in front (0,0)
    ctx.moveTo(5, 0);
    ctx.lineTo(-5, 5);
    ctx.lineTo(-5, -5);
    ctx.closePath();
    ctx.fillStyle = this.stroke;
    ctx.fill();
    ctx.restore();
  },

  toObject() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      customProps: this.customProps,
    });
  },
});

/*
 * WavyLineWithArrow
 *
 * It has four coords as normal arrow: x1, x2, y1, y2
 * Plus you can provide custom function for arrow.funct attribute
 *
 * It can be plain javascript function:
 *     arrow.funct = function(x) { return x/10; }
 *   Then the result way be disturbing (line generated by function may lay not in a valid place)
 *
 * For that purpose you do:
 *     arrow.funct = [ function(x) { / periodic function / }, period ];
 *   This will allow the object to caluclate nicely ending arrow.
 *   The function don't have to be periodic (in the mathematical sense).
 *   You just shall meet the assumption:
 *
 *      f(n*T) = 0 for any n = 0, 1, 2, 3...
 *   
 *   And everything will work nicely.
 *
 */
const WavyLineWithArrow = fabric.util.createClass(fabric.Group, {
  type: 'wavy_line_with_arrow',
  
  initialize(points, options) {
    options || (options = {});
    
    // Set initial dimensions of arrow
    this.coord_x1 = points[0];
    this.coord_y1 = points[1];
    this.coord_x2 = points[2];
    this.coord_y2 = points[3];
    this.arrowSize = options.arrowSize || 10;
    
    const selfOptions = fabric.util.object.clone(options);
    selfOptions.top =  this.coord_y1;
    selfOptions.left = this.coord_x1;
    
    // Set initial dimensions of arrow
    this.set({
      width: this.coord_x2 - this.coord_x1,
      height: this.coord_y2 - this.coord_y1,
      top: this.coord_y1,
      left: this.coord_x1
    });
    this.setCoords();
    
    /*
     * Set default values
     */
    
    this._funct_ = selfOptions.funct;
    if(this._funct_ === null || this._funct_ === undefined) {
        this._funct_ = function(x) {
            return Math.sin(x) * 10;
        };
    }
    
    this.period = selfOptions.period;
    if(!this.period) {
        this.period = 1;
    }
    
    // Function for updating coords
    this.updateCoords = () => {
        this.set({
            width: this.coord_x2 - this.coord_x1,
            height: this.coord_y2 - this.coord_y1,
            top: this.coord_y1,
            left: this.coord_x1
        });
        this.setCoords();
    };
    
    /*
     * This section defines hacky getters/setters
     * which enable the object to self update when you do object.funct = function(){ ... } etc.
     */
    
    Object.defineProperty(this, 'x1', {
        set: (x1) => {
            this.coord_x1 = x1;
            this.updateCoords();
            this.updateInternalPointsData();
            this.dirty = true;
        },
        get: () => {
            return this.coord_x1;
        }
    });
    
    Object.defineProperty(this, 'x2', {
        set: (x2) => {
            this.coord_x2 = x2;
            this.updateCoords();
            this.updateInternalPointsData();
            this.dirty = true;
        },
        get: () => {
            return this.coord_x2;
        }
    });
    
    Object.defineProperty(this, 'y1', {
        set: (y1) => {
            this.coord_y1 = y1;
            this.updateCoords();
            this.updateInternalPointsData();
            this.dirty = true;
        },
        get: () => {
            return this.coord_y1;
        }
    });
    
    Object.defineProperty(this, 'y2', {
        set: (y2) => {
            this.coord_y2 = y2;
            this.updateCoords();
            this.updateInternalPointsData();
            this.dirty = true;
        },
        get: () => {
            return this.coord_y2;
        }
    });
    
    Object.defineProperty(this, 'funct', {
        set: (value) => {
            this._funct_ = value;
            if(value) {
                this.period = 1;
                if(value[0]) {
                    this._funct_ = value[0];
                }
                if(value[1]) {
                    this.period = value[1] || 1;
                }
            }
            this.updateInternalPointsData();
            this.dirty = true;
        },
        get: () => {
            return this._funct_;
        }
    });
    
    /*
     * This function generates list of points that are placed inside the Group
     */
    this.updateInternalPointsData = () => {
      
      // Head size is a length of strainght line at the end near arrow
      const headSize = 20;
      // Basic scale factor is a scale factor for the provided "waving" function
      const basicScaleFactorX = 0.2;
      // Scaling factor for y axis
      const scaleFactorY = 1.0;
      // The size of the pointy arrow at the end
      const arrowSize = this.arrowSize || 10;
      
      /*
       * Synchronize coordinates
       */
      this.coord_x1 = this.left;
      this.coord_y1 = this.top;
      this.coord_x2 = this.coord_x1 + this.width;
      this.coord_y2 = this.coord_y1 + this.height;
      
      // Length of the line
      const len = this.width;
      // Generated points array
      const polyPoints = [];
      
      /*
       * Calculate period rescale factor
       * This is additional factor for scalling X that ensures we have only full periods in the line length
       */
      let periodRescaleFactor = this.period/basicScaleFactorX * Math.floor((len-headSize) / (this.period/basicScaleFactorX)) / (len-headSize);
      if(periodRescaleFactor === undefined || periodRescaleFactor < 0.001) {
          periodRescaleFactor = 1;
      }
      
      // Calulate final x scale factor
      const scaleFactorX = basicScaleFactorX * periodRescaleFactor;
      
      // Use default function?
      if(this._funct_ === null || this._funct_ === undefined) {
        this._funct_ = function(x) {
            return Math.sin(x) * 10;
        };
        this.period = Math.PI * 2;
      }
      
      // Use default period?
      if(!this.period) {
          this.period = 1;
      }
      
      // Generate poins:
      //  from [-len/2, 0] up to [len/2, 0]
      var step = 0.5;
      for(var x=0; x<len-headSize-step; x+=step) {
        polyPoints.push({
          x: x-len/2,
          y: this._funct_(x*scaleFactorX)*scaleFactorY
        });
      }
      
      // Push the begin of straing line at the end of arrow
      polyPoints.push({x: len/2-headSize-step, y: 0});
      // Push the end of arrow
      polyPoints.push({x: len/2, y: 0});
      
      // Remove old objects
      this.forEachObject(function(o) {
        this.remove(o);
      }, this);
      
      // Add new one
      for(var i=1;i<polyPoints.length;++i) {
        this.add(new fabric.Line([
          polyPoints[i-1].x,
          polyPoints[i-1].y,
          polyPoints[i].x,
          polyPoints[i].y
        ], options));
      }
      
      // This code creates polyline (little triangle at the arrow end)
      const arrOptions = fabric.util.object.clone(options);
      arrOptions.left = len/2;
      arrOptions.top = -arrowSize/2;
      this.add(new fabric.Polyline([
        {x: len/2, y: -arrowSize/2},
        {x: len/2 + arrowSize/2, y: 0},
        {x: len/2, y: arrowSize/2},
        {x: len/2, y: -arrowSize/2}
      ], arrOptions));
      
    };
  
    // Call super constructor
    this.callSuper('initialize', [], selfOptions);
    
    // Synchronize data
    this.updateInternalPointsData();
    
    // Set default options
    this.set({
      hasBorders: true,
      hasControls: true,
    });
  },

  render(ctx) {
    this.updateInternalPointsData();
    this.callSuper('render', ctx);
  },

  toObject() {
    return fabric.util.object.extend(this.callSuper('toObject'), {
      customProps: this.customProps,
      x1: this.x1,
      x2: this.x2,
      y1: this.y1,
      y2: this.y2,
      arrowSize: this.arrowSize,
      period: this.period,
      funct: this._funct_
    });
  },
});

drawLineWithArrow = (item, points, color) => (
  new LineWithArrow(points, {
    customProps: item,
    strokeWidth: 2,
    stroke: color,
  })
)

drawWavyLineWithArrow = (item, points, color, funct) => (
  new WavyLineWithArrow(points, {
    customProps: item,
    strokeWidth: 2,
    stroke: color,
    funct: funct
  })
)

selectLine = (item, points) => {
  switch (item.type) {
    case 'line_with_arrow':
      return this.drawLineWithArrow(item, points, fabric.Color.fromRgb("rgb(255,0,0)"));

    case 'wavy_line_with_arrow':
      return this.drawWavyLineWithArrow(item, points, fabric.Color.fromRgb("rgb(255,0,0)"));
    // no default
  }
  return null;
}

let line;
let isDown;

let typesOfLinesIter = -1;
const typesOfLines = [
    // Default: sine
    null,
    // Custom: tangens with period marked as 4PI
    [
        function(x) { return Math.max(-10, Math.min(Math.tan(x/2) / 3, 10)); },
        4 * Math.PI
    ]
];

fabricCanvas.on('mouse:down', (options) => {
  isDown = true;
  once = true;
  
  const pointer = fabricCanvas.getPointer(options.e);
  const points = [pointer.x, pointer.y, pointer.x, pointer.y];
  
  const item = {
    type: 'wavy_line_with_arrow'
  };
  
  line = this.selectLine(item, points);
 
  ++typesOfLinesIter;
  typesOfLinesIter %= typesOfLines.length;
  
  // Customize render function of the line
  line.set({ funct: typesOfLines[typesOfLinesIter] });
  
  fabricCanvas
    .add(line)
    .setActiveObject(line)
    .renderAll();
});

fabricCanvas.on('mouse:move', (options) => {
  if (!isDown) return;
  const pointer = fabricCanvas.getPointer(options.e);
  line.set({ x2: pointer.x, y2: pointer.y });
  fabricCanvas.renderAll();
});

fabricCanvas.on('mouse:up', () => {
  isDown = false;
  line.setCoords();
  fabricCanvas.setActiveObject(line).renderAll();
});
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.14.2/TweenMax.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/fabric.js/1.4.8/fabric.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<canvas id="c"></canvas>
like image 98
Piotr Styczyński Avatar answered Sep 21 '22 13:09

Piotr Styczyński