Wavy line drawing in FabricJS

I use FabricJS to create a canvas for drawing certain lines and shapes. One of the lines is a wavy line with an arrow like this:

enter image description here

I have successfully created a direct version of this with an arrow endpoint, but I cannot find examples of how to create a wavy line. The user can draw a line as long as they want the number of “peaks” and “troughs” in the line to be adapted accordingly (a short line like the image above can have 4 peaks, but a line whose length is twice as long 8 peaks, not just a stretched version of a shorter line).

Here is the code that I use to draw a straight line with the end point of the arrow. Note that the start point of the line is drawn on the mouse trap, and the end point is drawn on the mouse.

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;
+4
source share
2 answers

As we draw from both angles for the string, you can draw a wavy line in a _rendercustom class method. From the end I draw a line to the middle to show it connected with the arrow.

Demo

var line, isDown, evented;
var canvas = new fabric.Canvas('canvas', {
  perPixelTargetFind: true
});
draw();

function selection() {
  changeObjSelection(true);
  canvas.off('mouse:down');
  canvas.off('mouse:move');
  canvas.off('mouse:up');
  evented = false;
}

function draw() {
  changeObjSelection(false);
  if (!evented) {
    canvas.on('mouse:down', onMouseDown);
    canvas.on('mouse:move', onMouseMove);
    canvas.on('mouse:up', onMouseUp);
    evented = true;
  }
}

function clearCanvas() {
 canvas.clear();
}

function changeObjSelection(value) {
  canvas.selection = value;
  canvas.forEachObject(function(obj) {
    obj.selectable = value;
  })
  canvas.requestRenderAll();
}

function onMouseDown(options) {
  isDown = true;
  var pointer = canvas.getPointer(options.e);
  var points = [pointer.x, pointer.y, pointer.x, pointer.y];
  line = selectLine(points);
  canvas.add(line);
}

function onMouseMove(options) {
  if (!isDown) return;
  var pointer = canvas.getPointer(options.e);
  line.set({
    x2: pointer.x,
    y2: pointer.y
  });
  canvas.renderAll();

}

function onMouseUp(options) {
  isDown = false;
  line.setCoords();
  canvas.requestRenderAll();
}

function drawLineWithArrow(points, color) {
  return new fabric.LineWithArrow(points, {
    strokeWidth: 2,
    stroke: color,
    objectCaching: false,
    selectable: false
  })
}

function selectLine(points) {
  return drawLineWithArrow(points, 'black');
}

//Wavy line

(function(global) {
  'use strict';
  if (fabric.LineWithArrow) {
    fabric.warn('fabric.LineWithArrow is already defined.');
    return;
  }
  fabric.LineWithArrow = fabric.util.createClass(fabric.Line, {
    type: 'line_with_arrow',

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

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

    _render: function(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();
      var p = this.calcLinePoints();
      var point = this.pointOnLine(this.point(p.x2, p.y2), this.point(p.x1, p.y1), 10)
      this.wavy(this.point(p.x1, p.y1), point, this.point(p.x2, p.y2), ctx);
      ctx.stroke();
    },

    point: function(x, y) {
      return {
        x: x,
        y: y
      };
    },

    wavy: function(from, to, endPoint, ctx) {
      var cx = 0,
        cy = 0,
        fx = from.x,
        fy = from.y,
        tx = to.x,
        ty = to.y,
        i = 0,
        step = 4,
        waveOffsetLength = 0,

        ang = Math.atan2(ty - fy, tx - fx),
        distance = Math.sqrt((fx - tx) * (fx - tx) + (fy - ty) * (fy - ty)),
        amplitude = -10,
        f = Math.PI * distance / 30;

      for (i; i <= distance; i += step) {
        waveOffsetLength = Math.sin((i / distance) * f) * amplitude;
        cx = from.x + Math.cos(ang) * i + Math.cos(ang - Math.PI / 2) * waveOffsetLength;
        cy = from.y + Math.sin(ang) * i + Math.sin(ang - Math.PI / 2) * waveOffsetLength;
        i > 0 ? ctx.lineTo(cx, cy) : ctx.moveTo(cx, cy);
      }
      ctx.lineTo(to.x, to.y);
      ctx.lineTo(endPoint.x, endPoint.y);
    },

    pointOnLine: function(point1, point2, dist) {
      var len = Math.sqrt(((point2.x - point1.x) * (point2.x - point1.x)) + ((point2.y - point1.y) * (point2.y - point1.y)));
      var t = (dist) / len;
      var x3 = ((1 - t) * point1.x) + (t * point2.x),
        y3 = ((1 - t) * point1.y) + (t * point2.y);
      return new fabric.Point(x3, y3);
    },

    toObject: function() {
      return fabric.util.object.extend(this.callSuper('toObject'), {
        customProps: this.customProps,
      });
    },
  });
})(typeof exports !== 'undefined' ? exports : this);
canvas {
  border: 2px dotted black;
}
<script src="https://rawgit.com/kangax/fabric.js/master/dist/fabric.js"></script>
<button type="button" onclick="selection()">selection</button>
<button type="button" onclick="draw()">draw</button>
<button type="button" onclick="clearCanvas()">clear</button>
<canvas id="canvas" width="400" height="400"></canvas>
Run codeHide result
+3
source

results

I am not an expert, but I tried to implement the wavy lines on my own.

What is the result:

Screenshot of the arrows from codepen.io

Encoding

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

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

Lines are deleted 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));
  }

:

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

, .. .

, , , , ( ).

, , .

, , .

:

Types of arrow screenshots

// 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
]

.
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>
Hide result
+4

Source: https://habr.com/ru/post/1692089/


All Articles