Shapes 2.0

const Shapes = (function () {

"use strict";

/*************/
/* UTILITIES */
/*************/

/**
 * isDigit
 */
function isDigit(string, i) {
  return CharCode.isDigit(string.charCodeAt(i));
}

/**
 * indexOfLongestSuffixSansDigits
 */
function indexOfLongestSuffixSansDigits(string) {
  let i = string.length - 1;
  while (i > 0 && !isDigit(string, i)) {
    i--;
  }
  return i + 1;
}

/**
 * CharCode
 */
const CharCode = {
  ZERO: '0'.charCodeAt(0),
  NINE: '9'.charCodeAt(0),
  isDigit: function(code) {
    return CharCode.ZERO <= code && code <= CharCode.NINE;
  }
};

/**
 * Measure
 */
const Measure = {
  indexOfUnits: function (string) {
    return indexOfLongestSuffixSansDigits(string);
  },
  parseMagnitude: function (value) {
    const number = parseFloat(value);
    if (isFinite(number))
      return number < 0 ? 0 : number;
    else
      return undefined;
  },
  separate: function (string) {
    const i = Measure.indexOfUnits(string);
    return {
      magnitude: Measure.parseMagnitude(string.slice(0, i)),
      units: string.slice(i)
    };
  }
};

/**
 * FunctionCall
 */
const FunctionCall = {
  arguments: {
    value: function (string, fname) {
      if (fname === undefined) {
        fname = "";
      }
      const s = fname + '(';
      const i = string.indexOf(s);
      const j = string.indexOf(')', i);
      return i < 0 || j < 0 ?
        "" : string.substring(i + s.length, j);
    }
  }
};

/********************************/
/* FACADE CONSTRUCTOR FUNCTIONS */
/********************************/

/**
 * FunctionCall.Facade
 */
FunctionCall.Facade = function (descriptor) {

  Facade.call(this, getArgs, setArgs);
  const fname = descriptor.functionName;

  function getArgs() {
    return FunctionCall.arguments(descriptor.mapFrom(), fname);
  }
  function setArgs(value) {
    descriptor.mapTo(fname + '(' + value + ')');
  }
}

/**
 * Measure.Facade
 */
Measure.Facade = function (descriptor) {

  Facade.call(this, getMagnitude, setMeasure);

  function getMagnitude() {
    return Measure.separate(stringifyProperty()).magnitude;
  }
  function getUnits() {
    return Measure.separate(stringifyProperty()).units;
  }
  function setMeasure(value) {
    const fork = Measure.separate(value.toString());
    if (fork.units.length > 0)
      setProperties(fork.magnitude + fork.units);
    else
      setProperties(fork.magnitude + getUnits());
  }
  function setProperties(value) {
    descriptor.mapTo(value);
  }
  function getProperty() {
    return descriptor.mapFrom();
  }
  function stringifyProperty() {
    const value = getProperty();
    if (value === undefined || value === null)
      return "";
    else
      return value.toString();
  }
}

/**
 * Facade
 */
function Facade(getter, setter) {
  this.defineProperty = function (obj, prop) {
    return Object.defineProperty(obj, prop, {
      get: getter,
      set: setter
    });    
  };
}

/********************/
/* FACADE UTILITIES */
/********************/

/**
 * FunctionCall.Facade.defineProperty
 */
FunctionCall.Facade.defineProperty = function (obj, prop, descriptor) {
  Facade.defineProperty.call(new FunctionCall.Facade(descriptor),
    obj, prop, descriptor);
};

/**
 * FunctionCall.Facade.defineProperties
 */
FunctionCall.Facade.defineProperties = function (obj, descriptors) {
  for (let prop in descriptors)
    FunctionCall.Facade.defineProperty(obj, prop, descriptors[prop]);
};

/**
 * Measure.Facade.defineProperty
 */
Measure.Facade.defineProperty = function (obj, prop, descriptor) {
  Facade.defineProperty.call(new Measure.Facade(descriptor),
    obj, prop, descriptor);
};

/**
 * Measure.Facade.defineProperties
 */
Measure.Facade.defineProperties = function (obj, descriptors) {
  for (let prop in descriptors)
    Measure.Facade.defineProperty(obj, prop, descriptors[prop]);
};

/**
 * Facade.defineProperty
 */
Facade.defineProperty = function (obj, prop, descriptor) {
  if (descriptor.mapFrom === undefined)
    descriptor.mapFrom = () => descriptor.source[prop];
  if (descriptor.mapTo === undefined)
    descriptor.mapTo = (value) => descriptor.source[prop] = value;
  this.defineProperty(obj, prop);
};

/********************/
/* SHAPE PROTOTYPES */
/********************/

/**
 * Shape.prototype
 */
Shape.prototype =
Object.assign(Object.create(Object.prototype), {
  constructor: Shape,
  scrubStyle: function (object) {
    return typeof object === 'object' ?
      Object.assign({}, object) : {};
  },
  defaultStyle: {
    backgroundColor: 'black',
    position: "absolute",
    top: '0px',
    left: '0px',
    width: '100px',
    height: '100px',
    rotation: '0deg'
  },
  draw: function () {
    this.appendTo();
    return this;
  },
  drawOn: function(element) {
    this.appendTo(element);
    return this;
  }
});

/**
 * Polygon.prototype
 */
Polygon.prototype = Object.create(Shape.prototype);

/**
 * Triangle.prototype
 */
Triangle.prototype =
Object.assign(Object.create(Polygon.prototype), {
  constructor: Triangle,
  defaultStyle: Object.assign({}, Shape.prototype.defaultStyle, {
    path: '0% 100%, 50% 0%, 100% 100%'
  })
});

/**
 * Rectangle.prototype
 */
Rectangle.prototype =
Object.assign(Object.create(Polygon.prototype), {
  constructor: Rectangle
});

/**
 * Square.prototype
 */
Square.prototype =
Object.assign(Object.create(Rectangle.prototype), {
  constructor: Square,
  scrubStyle: function (object) {
    const obj = Shape.prototype.scrubStyle(object);
    if (obj.width !== undefined) obj.height = obj.width;
    if (obj.height !== undefined) obj.width = obj.height;
    return obj;
  }
});

/**
 * Oval.prototype
 */
Oval.prototype =
Object.assign(Object.create(Shape.prototype), {
  constructor: Oval,
  scrubStyle: function (object) {
    const obj = Shape.prototype.scrubStyle(object);
    obj.borderRadius = "50%";
    return obj;
  }
});

/**
 * Circle.prototype
 */
Circle.prototype =
Object.assign(Object.create(Oval.prototype), {
  constructor: Circle,
  scrubStyle: function (object) {
    const obj = Oval.prototype.scrubStyle(object);
    if (obj.width !== undefined) obj.height = obj.width;
    if (obj.height !== undefined) obj.width = obj.height;
    return obj;
  }
});

/****************************************/
/* ABSTRACT SHAPE CONSTRUCTOR FUNCTIONS */
/****************************************/

/**
 * Polygon
 */
function Polygon(n, descriptor) {

  /* Private Fields */

  const style = Rectangle.call(this, descriptor, {isParent: true});
  const vertexCount = n;
  const thisPolygon = this;

  /* Public Properties and Methods */

  Object.defineProperties(this, {
    x: { value: (i) => thisPolygon[toX(i)] },
    y: { value: (i) => thisPolygon[toY(i)] }
  });
  FunctionCall.Facade.defineProperty(this, 'path', {
    source: style,
    functionName: 'polygon',
    mapTo: function (value) {
      style.clipPath = value;
    },
    mapFrom: function () {
      return style.clipPath;
    }
  });
  for (let i = 1; i <= vertexCount; i++) {
    Measure.Facade.defineProperty(this, toX(i), {
      source: this,
      mapFrom: () => getX(i),
      mapTo: (value) => thisPolygon.path = setX(i, value)
    });
    Measure.Facade.defineProperty(this, toY(i), {
      source: this,
      mapFrom: () => getY(i),
      mapTo: (value) => thisPolygon.path = setY(i, value)
    });
  }

  return (this.constructor === Polygon) ?
    Object.freeze(this.style.reset()) : style;

  /* Private Functions */

  function toX(i) { return 'x' + i; }
  function toY(i) { return 'y' + i; }
  function getX(i) {
    const coords = getCoordsArray();
    return coords[0][i-1];
  }
  function getY(i) {
    const coords = getCoordsArray();
    return coords[1][i-1];
  }
  function scrubCoord(value, prop) {
    value = value.toString();
    const numValue = parseFloat(value);
    return Number.isFinite(numValue) ?
      numValue.toString() + prop.units : value;
  }
  function setX(i, value) {
    const coords = getCoordsArray();
    const prop = thisPolygon[toX(i)];
    coords[0][i-1] = scrubCoord(value, prop);
    return coordsArrayToString(coords);
  }
  function setY(i, value) {
    const coords = getCoordsArray();
    const prop = thisPolygon[toY(i)];
    coords[1][i-1] = scrubCoord(value, prop);
    return coordsArrayToString(coords);
  }
  function getCoordsArray() {
    const xCoords = [], yCoords = [];
    const coords = [xCoords, yCoords];
    const xyCoords = thisPolygon.path.split(', ');
    for (let i = 0; i < vertexCount; i++) {
      const xy = xyCoords[i].split(' ');
      const len = xy.length;
      xCoords[i] = len > 0 && xy[0].length > 0 ? xy[0] : "0%";
      yCoords[i] = len > 1 && xy[1].length > 0 ? xy[1] : "0%";
    }
    return coords;
  }
  function coordsArrayToString(coords) {
    const xCoords = coords[0], yCoords = coords[1];
    let string = xCoords[0] + ' ' + yCoords[0];
    for (let i = 1; i < vertexCount; i++) {
      string += ', ' + xCoords[i] + ' ' + yCoords[i];
    }
    return string;
  }
}

/**
 * Shape
 */
function Shape(descriptor) {

  /* Private and Protected Fields */

  const thisShape = this;
  const fauxStyle = Object.create(null);
  const element = document.createElement('div');
  const style = element.style; // protected

  /* Public Properties */

  Object.defineProperty(this, 'style', {
    get: getStyle,
    set: setStyle
  });
  Shape.simpleMeasureProperties.forEach(function (propName) {
    Measure.Facade.defineProperty(thisShape, propName, {source: style});
  });
  FunctionCall.Facade.defineProperty(fauxStyle, 'transform', {
    source: style,
    functionName: 'rotate'
  });
  Measure.Facade.defineProperties(this, {
    rotation: {
      source: fauxStyle,
      mapTo: function (value) { fauxStyle.transform = value; },
      mapFrom: function () { return fauxStyle.transform; }
    }
  });

  /* Public Methods */

  this.appendTo = function (other) {
    if (other === undefined)
      other = document.body;
    other.append(element);
    return thisShape;
  };
  this.append = function (other) {
    if (other instanceof Shape)
      other.appendTo(element);
    else
      element.append(other);
    return thisShape;
  };
  this.remove = function () {
    element.remove();
  };
  this.toString = function () {
    return thisShape.constructor.name;
  };
  Shape.simpleProperties.forEach(function (propName) {
      Object.defineProperty(thisShape, propName, {
        get: () => style[propName],
        set: (value) => style[propName] = value
      });
  });

  /* Return this or style. */

  return (this.constructor === Shape) ?
    Object.freeze(this.style.reset()) : style;

  /* Private Functions */

  function updateStyle(object) {
    const obj = thisShape.scrubStyle(object);
    for (let prop in obj) {
      if (style.hasOwnProperty(prop))
        style[prop] = obj[prop];
      if (thisShape.hasOwnProperty(prop))
        thisShape[prop] = obj[prop];
    }
    return thisShape;
  }
  function resetStyle() {
    clearStyle();
    thisShape.style = thisShape.defaultStyle;    
    thisShape.style.update(descriptor);
    return thisShape;
  }
  function clearStyle() {
    const n = style.length;
    for (let i = 0; i < n; i++) {
      style[i] = "";
    }
    return thisShape;
  }
  function setStyle(object) {
    clearStyle();
    updateStyle(object);
    return thisShape;
  }
  function getStyle() {
    return {
      update: updateStyle,
      clear: clearStyle,
      reset: resetStyle,
      toString: function () {
        return style.cssText;
      },
      valueOf: function (prop) {
        if (prop === undefined)          
          return Object.assign({}, style);
        else
          return style[prop];
      }
    };
  }
}
Shape.simpleProperties = [
  'backgroundColor',
  'borderStyle',
  'borderColor'
];
Shape.simpleMeasureProperties = [
  'top',
  'left',
  'borderWidth'
];

/****************************************/
/* CONCRETE SHAPE CONSTRUCTOR FUNCTIONS */
/****************************************/

/**
 * Triangle
 */
function Triangle(descriptor) {
  const style = Polygon.call(this, 3, descriptor);
  return (this.constructor === Triangle) ?
    Object.freeze(this.style.reset()) : style;
}

/**
 * Oval
 */
function Oval(descriptor) {
  const style = Rectangle.call(this, descriptor);
  return (this.constructor === Oval) ?
    Object.freeze(this.style.reset()) : style;
}

/**
 * Circle
 */
function Circle(descriptor) {

  const style = Square.call(this, descriptor);

  Measure.Facade.defineProperties(this, {
    diameter: {
      mapTo: function (value) {
        style.width = value;
        style.height = value;
      },
      mapFrom: function () { return style.width; }
    },
    radius: {
      mapTo: (function (value) {
        this.diameter = value;
        const mag = this.diameter.magnitude;
        if (Number.isFinite(mag))
          this.diameter.magnitude = mag * 2;
      }).bind(this),
      mapFrom: (function () {
        const mag = this.diameter.magnitude;
        return (Number.isFinite(mag)) ?
          (mag / 2) + this.diameter.units : "";      
      }).bind(this)
    }
  });

  return (this.constructor === Circle) ?
    Object.freeze(this.style.reset()) : style;
}

/**
 * Rectangle
 */
function Rectangle(descriptor) {
  const style = Shape.call(this, descriptor);
  Measure.Facade.defineProperties(this, {
    width: {source: style},
    height: {source: style}
  });
  return (this.constructor === Rectangle) ?
    Object.freeze(this.style.reset()) : style;
}

/**
 * Square
 */
function Square(descriptor) {
  const style = Shape.call(this, descriptor);
  Measure.Facade.defineProperties(this, {
    width: {
      mapTo: function (value) {
        style.width = value;
        style.height = value;
      },
      mapFrom: function () { return style.width; }
    },
    height: {
      mapTo: function (value) {
        style.width = value;
        style.height = value;
      },
      mapFrom: function () { return style.height; }
    }
  });
  return (this.constructor === Square) ?
    Object.freeze(this.style.reset()) : style;
}

/**************************/
/* FREEZE PRIVATE OBJECTS */
/**************************/

Object.freeze(CharCode);
Object.freeze(Facade)
Object.freeze(FunctionCall);
Object.freeze(FunctionCall.Facade);
Object.freeze(Measure);
Object.freeze(Measure.Facade);
Object.freeze(Shape);
Object.freeze(Polygon);

/************************************/
/* FREEZE AND RETURN PUBLIC OBJECTS */
/************************************/

return Object.freeze({
  Triangle: Object.freeze(Triangle),
  Rectangle: Object.freeze(Rectangle),
  Square: Object.freeze(Square),
  Circle: Object.freeze(Circle),
  Oval: Object.freeze(Oval)
});

})();

/***************/
/* SHORT NAMES */
/***************/

const
  Triangle = Shapes.Triangle,
  Rectangle = Shapes.Rectangle,
  Square = Shapes.Square,
  Circle = Shapes.Circle,
  Oval = Shapes.Oval;