Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URL's added here will be added as <link>s in order, and before the CSS in the editor. If you link to another Pen, it will include the CSS from that Pen. If the preprocessor matches, it will attempt to combine them before processing.

+ add another resource

JavaScript

Babel is required to process package imports. If you need a different preprocessor remove all packages first.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Behavior

Save Automatically?

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <div id="controls">
  Number of harmonics
  <input type="range" id="slider" min="1" max="40" step="1" value="1">
  <span id="harmonic-count">1</span>
</div>
<canvas id="cnvs"></canvas>
              
            
!

CSS

              
                @font-face {
  font-family: 'Lato';
  src: url('https://s3-us-west-2.amazonaws.com/s.cdpn.io/969699/lato-latin-400.woff');
}

html,
body {
  margin: 0;
  padding: 0;
  background: #fefefa;
  font-family: 'Lato';
}

#controls {
  position: absolute;
  left: 0;
  right: 0;

  display: flex;
  flex-direction: row;
  align-items: center;
  font-size: 18px;
}

#controls input {
  margin: 0 10px;
}

canvas {
  width: 100%;
}

              
            
!

JS

              
                const SAMPLE_POINTS = 300;
const MAX_HARMONICS = 50;
const POINTS_TO_RENDER = 750;
const BASE_UNIT = 50;
const FONT_SIZE = 2;
const LEADING = 1;
const MARGIN = 1;
const CHARS = [
  'a',
  'b',
  'c',
  'ë',
  'f',
  'g',
  'h',
  'i',
  'j',
  'õ',
  'p',
  '1',
  '2',
  '3',
  'A',
  'Q'
];
const CANVAS_WIDTH = 1500;
const CANVAS_HEIGHT = 700;
const FONTS = [
  'https://s3-us-west-2.amazonaws.com/s.cdpn.io/969699/lato-latin-400.woff',
  'https://s3-us-west-2.amazonaws.com/s.cdpn.io/969699/cormorant-garamond-latin-400.woff',
  'https://s3-us-west-2.amazonaws.com/s.cdpn.io/969699/rochester-latin-400.woff',
  'https://s3-us-west-2.amazonaws.com/s.cdpn.io/969699/jacques-francois-shadow-latin-400.woff'
];



// Point-at-length implementation based on https://github.com/substack/point-at-length
function Points(path) {
  this._path = path;
  this._path = zvhToL(this._path);
}

Points.prototype.at = function(pos, opts) {
  return this._walk(pos, opts);
};

Points.prototype.length = function() {
  return this._walk(null).length;
};

Points.prototype._walk = function(pos, opts) {
  var cur = [0, 0];
  var prev = [0, 0, 0];
  var p0 = [0, 0];
  var len = 0;

  for (var i = 0; i < this._path.length; i++) {
    var p = this._path[i];
    if (p.type === 'M') {
      cur[0] = p.x;
      cur[1] = p.y;
      if (pos === 0) {
        return { length: len, pos: cur, cmd: i };
      }
    } else if (p.type === 'C') {
      prev[0] = p0[0] = cur[0];
      prev[1] = p0[1] = cur[1];
      prev[2] = len;

      var n = 100;
      for (var j = 0; j <= n; j++) {
        var t = j / n;
        var x = xof_C(p, t);
        var y = yof_C(p, t);
        len += dist(cur[0], cur[1], x, y);

        cur[0] = x;
        cur[1] = y;

        if (typeof pos === 'number' && len >= pos) {
          var dv = (len - pos) / (len - prev[2]);

          var npos = [
            cur[0] * (1 - dv) + prev[0] * dv,
            cur[1] * (1 - dv) + prev[1] * dv
          ];
          return { length: len, pos: npos, cmd: i };
        }
        prev[0] = cur[0];
        prev[1] = cur[1];
        prev[2] = len;
      }
    } else if (p.type === 'Q') {
      prev[0] = p0[0] = cur[0];
      prev[1] = p0[1] = cur[1];
      prev[2] = len;

      var n = 100;
      for (var j = 0; j <= n; j++) {
        var t = j / n;
        var x = xof_Q(p, t);
        var y = yof_Q(p, t);
        len += dist(cur[0], cur[1], x, y);

        cur[0] = x;
        cur[1] = y;

        if (typeof pos === 'number' && len >= pos) {
          var dv = (len - pos) / (len - prev[2]);

          var npos = [
            cur[0] * (1 - dv) + prev[0] * dv,
            cur[1] * (1 - dv) + prev[1] * dv
          ];
          return { length: len, pos: npos, cmd: i };
        }
        prev[0] = cur[0];
        prev[1] = cur[1];
        prev[2] = len;
      }
    } else if (p.type === 'L') {
      prev[0] = cur[0];
      prev[1] = cur[1];
      prev[2] = len;

      len += dist(cur[0], cur[1], p.x, p.y);
      cur[0] = p.x;
      cur[1] = p.y;

      if (typeof pos === 'number' && len >= pos) {
        var dv = (len - pos) / (len - prev[2]);
        var npos = [
          cur[0] * (1 - dv) + prev[0] * dv,
          cur[1] * (1 - dv) + prev[1] * dv
        ];
        return { length: len, pos: npos, cmd: i };
      }
      prev[0] = cur[0];
      prev[1] = cur[1];
      prev[2] = len;
    }
  }

  return { length: len, pos: cur };
  function xof_C(p, t) {
    return (
      Math.pow(1 - t, 3) * p0[0] +
      3 * Math.pow(1 - t, 2) * t * p.x1 +
      3 * (1 - t) * Math.pow(t, 2) * p.x2 +
      Math.pow(t, 3) * p.x
    );
  }
  function yof_C(p, t) {
    return (
      Math.pow(1 - t, 3) * p0[1] +
      3 * Math.pow(1 - t, 2) * t * p.y1 +
      3 * (1 - t) * Math.pow(t, 2) * p.y2 +
      Math.pow(t, 3) * p.y
    );
  }

  function xof_Q(p, t) {
    return (
      Math.pow(1 - t, 2) * p0[0] + 2 * (1 - t) * t * p.x1 + Math.pow(t, 2) * p.x
    );
  }
  function yof_Q(p, t) {
    return (
      Math.pow(1 - t, 2) * p0[1] + 2 * (1 - t) * t * p.y1 + Math.pow(t, 2) * p.y
    );
  }
};

function dist(ax, ay, bx, by) {
  var x = ax - bx;
  var y = ay - by;
  return Math.sqrt(x * x + y * y);
}

// Convert 'Z', 'V' and 'H' segments to 'L' segments
function zvhToL(path) {
  var ret = [];
  var startPoint = { type: 'L', x: 0, y: 0 };
  var last_point;

  for (var i = 0, len = path.length; i < len; i++) {
    var pt = path[i];
    switch (pt.type) {
      case 'M':
        startPoint = { type: 'L', x: pt.x, y: pt.y };
        ret.push(pt);
        break;
      case 'Z':
        ret.push(startPoint);
        break;
      case 'H':
        last_point = ret[ret.length - 1] || { type: 'L', x: 0, y: 0 };
        ret.push({ type: 'L', x: pt.x, y: last_point.y });
        break;
      case 'V':
        last_point = ret[ret.length - 1] || { type: 'L', x: 0, y: 0 };
        ret.push({ type: 'L', x: last_point.x, y: pt.y });
        break;
      default:
        ret.push(pt);
    }
  }
  return ret;
}

function polygonisePath(commands) {
  let pts = new Points(commands);
  let length = pts.length();

  let result = [];
  let lastCmd = 0;
  if (commands.length > SAMPLE_POINTS) {
    return null;
  }
  let numSamples = SAMPLE_POINTS - commands.length + 1;
  for (let i = 0; i < numSamples; i++) {
    let pt = pts.at((i / numSamples) * length);
    if (pt.cmd !== lastCmd) {
      for (let c = lastCmd; c < pt.cmd; c++) {
        result.push([pts._path[c].x, pts._path[c].y]);
      }
      lastCmd = pt.cmd;
    }
    result.push(pt.pos);
  }
  for (let c = lastCmd; c < commands.length - 1; c++) {
    result.push([pts._path[c].x, pts._path[c].y]);
  }
  return _.uniqWith(result, _.isEqual);
}

function isClockwise(line) {
  let sum = 0;
  for (let i = 0; i < line.length - 1; i++) {
    let [x1, y1] = line[i];
    let [x2, y2] = line[i + 1];
    sum += (x2 - x1) * (y2 + y1);
  }
  return sum > 0;
}

function makeClockwise(line) {
  if (isClockwise(line)) {
    return line;
  } else {
    return _.reverse(line);
  }
}

function getDistance([x1, y1], [x2, y2]) {
  return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}

function makePolyLinks(points) {
  let links = [];
  for (let i = 0; i < points.length; i++) {
    let from = points[i];
    let to = i === points.length - 1 ? points[0] : points[i + 1];
    links.push({
      from,
      to,
      length: getDistance(from, to),
      xDelta: to[0] - from[0],
      yDelta: to[1] - from[1]
    });
  }
  return links;
}

function getLength(links) {
  let total = 0;
  for (let { length } of links) {
    total += length;
  }
  return total;
}

// Fourier approximation based on Kuhl & Giardina: Elliptic Fourier Features of a Closed Contour
// http://www.sci.utah.edu/~gerig/CS7960-S2010/handouts/Kuhl-Giardina-CGIP1982.pdf

function getDCComponents(links, totalLength) {
  let aSum = 0,
    cSum = 0,
    lengthTraversed = 0;
  for (let p = 0; p < links.length; p++) {
    let { length, xDelta, yDelta } = links[p];
    let nextLengthTaversed = lengthTraversed + length;
    let aCoef = 0,
      cCoef = 0;
    if (p > 0) {
      let xDeltaSoFar = 0,
        yDeltaSoFar = 0,
        deltaSoFar = 0;
      for (let j = 0; j < p; j++) {
        xDeltaSoFar += links[j].xDelta;
        yDeltaSoFar += links[j].yDelta;
        deltaSoFar += links[j].length;
      }
      aCoef = xDeltaSoFar - (xDelta / length) * deltaSoFar;
      cCoef = yDeltaSoFar - (yDelta / length) * deltaSoFar;
    }

    aSum +=
      (xDelta / (2 * length)) *
        (nextLengthTaversed ** 2 - lengthTraversed ** 2) +
      aCoef * (nextLengthTaversed - lengthTraversed);
    cSum +=
      (yDelta / (2 * length)) *
        (nextLengthTaversed ** 2 - lengthTraversed ** 2) +
      cCoef * (nextLengthTaversed - lengthTraversed);
  }
  return {
    A0: aSum / totalLength,
    C0: cSum / totalLength
  };
}

function getCoefficients(n, links, totalLength) {
  let aSum = 0,
    bSum = 0,
    cSum = 0,
    dSum = 0,
    lengthTraversed = 0;
  for (let { length, xDelta, yDelta } of links) {
    let nextLengthTaversed = lengthTraversed + length;
    let prev = (2 * n * Math.PI * lengthTraversed) / totalLength;
    let next = (2 * n * Math.PI * nextLengthTaversed) / totalLength;

    aSum += (xDelta / length) * (Math.cos(next) - Math.cos(prev));
    bSum += (xDelta / length) * (Math.sin(next) - Math.sin(prev));
    cSum += (yDelta / length) * (Math.cos(next) - Math.cos(prev));
    dSum += (yDelta / length) * (Math.sin(next) - Math.sin(prev));

    lengthTraversed = nextLengthTaversed;
  }
  let totalCoefficient = totalLength / (2 * n ** 2 * Math.PI ** 2);
  return {
    a: totalCoefficient * aSum,
    b: totalCoefficient * bSum,
    c: totalCoefficient * cSum,
    d: totalCoefficient * dSum
  };
}

function getCapHeight(font) {
  const chars = 'HIKLEFJMNTZBDPRAGOQSUVWXY';
  for (let a = 0, al = chars.length; a < al; a++) {
    let idx = font.charToGlyphIndex(chars[a]);
    if (idx <= 0) continue;
    return font.glyphs.get(idx).getMetrics().yMax;
  }
}

function scaleFontSizeForGrid(fontSize, font) {
  let capHeight = getCapHeight(font);
  let scale = capHeight / font.unitsPerEm;
  return fontSize / scale;
}

function loadFont(file) {
  return new Promise((res, rej) =>
    opentype.load(file, (err, font) => (err ? rej(err) : res(font)))
  );
}

function getPoint(A0, C0, coefficients, harmonics, t) {
  let x = A0 / 2,
    y = C0 / 2;
  for (let n = 1; n <= harmonics; n++) {
    let { a, b, c, d } = coefficients[n - 1];
    let m = 2 * Math.PI * n;
    x += a * Math.cos(m * t) + b * Math.sin(m * t);
    y += c * Math.cos(m * t) + d * Math.sin(m * t);
  }
  return { x, y };
}

function getTangent(coefficients, harmonics, t) {
  let x = 0,
    y = 0;
  for (let n = 1; n <= harmonics; n++) {
    let { a, b, c, d } = coefficients[n - 1];
    let m = 2 * Math.PI * n;
    x += m * b * Math.cos(m * t) - m * a * Math.sin(m * t);
    y += m * d * Math.cos(m * t) - m * c * Math.sin(m * t);
  }
  return y / x;
}

function getNormal(coefficients, harmonics, t) {
  let x = 0,
    y = 0;
  for (let n = 1; n <= harmonics; n++) {
    let { a, b, c, d } = coefficients[n - 1];
    let m = 2 * Math.PI * n;
    x += m * b * Math.cos(m * t) - m * a * Math.sin(m * t);
    y += m * d * Math.cos(m * t) - m * c * Math.sin(m * t);
  }
  let coef = 1 / Math.sqrt(x ** 2 + y ** 2);
  return { x: coef * x, y: coef * y };
}

function getCurvature(coefficients, harmonics, t) {
  let xp = 0,
    xpp = 0,
    yp = 0,
    ypp = 0;
  for (let n = 1; n <= harmonics; n++) {
    let { a, b, c, d } = coefficients[n - 1];
    let m = 2 * Math.PI * n;
    xp += m * b * Math.cos(m * t) - m * a * Math.sin(m * t);
    yp += m * d * Math.cos(m * t) - m * c * Math.sin(m * t);
    xpp += m ** 2 * b * -Math.sin(m * t) - m ** 2 * a * Math.cos(m * t);
    ypp += m ** 2 * d * -Math.sin(m * t) - m ** 2 * c * Math.cos(m * t);
  }
  return (xp * ypp - yp * xpp) / (xp ** 2 + yp ** 2) ** (3 / 2);
}

let canvas = document.querySelector('#cnvs');
let controls = document.querySelector('#controls');
let slider = document.querySelector('#slider');
let harmonicCount = document.querySelector('#harmonic-count');

let ctx = canvas.getContext('2d');
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;

canvas.style.marginTop = `${BASE_UNIT * 2}px`;
controls.style.height = `${BASE_UNIT * 2}px`;
controls.style.paddingLeft = `${BASE_UNIT}px`;

Promise.all(FONTS.map(loadFont)).then(fonts => {
  let harmonics = 1,
    samplePoints = [],
    nearestSamplePoint,
    curves = [];
  let chars = _.flatMap(fonts, (font, fontIdx) => {
    let fontSize = scaleFontSizeForGrid(BASE_UNIT * FONT_SIZE, font);
    let y =
      BASE_UNIT * (MARGIN + FONT_SIZE * (fontIdx + 1) + LEADING * fontIdx);
    let advanceStr = '';
    return _.map(CHARS, char => {
      let x = BASE_UNIT * MARGIN + font.getAdvanceWidth(advanceStr, fontSize);
      let fontPath = font.getPath(char, x, y, fontSize);
      let commands = fontPath.commands;
      let results = [],
        startIndex = 0;
      while (startIndex < commands.length) {
        let endIndex = _.findIndex(
          commands,
          cmd => cmd.type === 'Z',
          startIndex
        );
        let links = makePolyLinks(
          makeClockwise(
            polygonisePath(commands.slice(startIndex, endIndex + 1))
          )
        );
        let length = getLength(links);
        let { A0, C0 } = getDCComponents(links, length);
        let coefficients = [];
        for (let n = 1; n <= MAX_HARMONICS; n++) {
          coefficients.push(getCoefficients(n, links, length));
        }
        results.push({ char, commands, links, length, A0, C0, coefficients });
        startIndex = endIndex + 1;
      }
      advanceStr += char;
      return results;
    });
  });

  function makeSamplePoints() {
    samplePoints = [];
    curves = [];
    for (let charGroup of chars) {
      let curveGroup = [];
      for (let { links, A0, C0, coefficients } of charGroup) {
        let curve = [];
        for (let i = 0; i < POINTS_TO_RENDER; i++) {
          let t = i / (POINTS_TO_RENDER - 1);
          let { x, y } = getPoint(A0, C0, coefficients, harmonics, t);
          let shapeStartPoint = links[0].from;
          x += shapeStartPoint[0];
          y += shapeStartPoint[1];
          let samplePoint = {
            x,
            y,
            t,
            A0,
            C0,
            coefficients,
            harmonics,
            shapeStartPoint
          };
          curve.push(samplePoint);
          samplePoints.push(samplePoint);
        }
        curveGroup.push(curve);
      }
      curves.push(curveGroup);
    }
  }

  function render() {
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    ctx.strokeStyle = 'lightblue';
    ctx.lineWidth = 2;
    ctx.beginPath();
    for (let i = 0; i < 20; i += 2) {
      ctx.moveTo(0, i * BASE_UNIT);
      ctx.lineTo(CANVAS_WIDTH, i * BASE_UNIT);
    }
    ctx.stroke();
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let i = 1; i < 20; i += 2) {
      ctx.moveTo(0, i * BASE_UNIT);
      ctx.lineTo(CANVAS_WIDTH, i * BASE_UNIT);
    }
    ctx.stroke();

    ctx.fillStyle = 'black';
    for (let curveGroup of curves) {
      ctx.beginPath();
      for (let curve of curveGroup) {
        let [frst, ...rest] = curve;
        ctx.moveTo(frst.x, frst.y);
        for (let { x, y } of rest) {
          ctx.lineTo(x, y);
        }
      }
      ctx.fill('evenodd');
    }

    if (nearestSamplePoint) {
      ctx.fillStyle = 'red';
      ctx.strokeStyle = 'red';
      let { A0, C0, coefficients, shapeStartPoint, t } = nearestSamplePoint;
      let { x, y } = getPoint(A0, C0, coefficients, harmonics, t);
      let tangentSlope = getTangent(coefficients, harmonics, t);
      let normalVector = getNormal(coefficients, harmonics, t);
      let actualNormalVector = { x: -normalVector.y, y: normalVector.x };
      let curvature = getCurvature(coefficients, harmonics, t);

      x += shapeStartPoint[0];
      y += shapeStartPoint[1];
      ctx.beginPath();
      ctx.arc(x, y, 3, 0, Math.PI * 2);
      ctx.fill();

      let x0 = 0,
        x1 = CANVAS_WIDTH;
      let y0 = y - tangentSlope * (x - x0);
      let y1 = y + tangentSlope * (x1 - x);
      ctx.beginPath();
      ctx.moveTo(x0, y0);
      ctx.lineTo(x1, y1);
      ctx.stroke();

      x1 = x + (1 / curvature) * actualNormalVector.x;
      y1 = y + (1 / curvature) * actualNormalVector.y;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(x1, y1);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(x1, y1, Math.abs(1 / curvature), 0, 2 * Math.PI);
      ctx.stroke();
    }
  }

  makeSamplePoints();
  render();

  slider.addEventListener('input', () => {
    harmonics = +slider.value;
    harmonicCount.textContent = harmonics;
    makeSamplePoints();
    render();
  });

  canvas.addEventListener('mousemove', evt => {
    let xRelCanvas = evt.pageX - canvas.offsetLeft;
    let yRelCanvas = evt.pageY - canvas.offsetTop;
    let xScaled = (xRelCanvas / canvas.offsetWidth) * CANVAS_WIDTH;
    let yScaled = (yRelCanvas / canvas.offsetHeight) * CANVAS_HEIGHT;

    nearestSamplePoint = _.minBy(
      samplePoints,
      ({ x, y }) => (xScaled - x) ** 2 + (yScaled - y) ** 2
    );
    render();
  });
});

              
            
!
999px

Console