<canvas id="canvas"></canvas>
body {
  background: #111;
  overflow: hidden;
  &.recording {
    pointer-events: none;
    &::before {
      content: '📹 recording…';
      position: fixed;
      z-index: 999;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      margin: auto;
      display: inline-block;
      width: 15ch;
      height: 2em;
      line-height: 2em;
      text-align: center;
      white-space: nowrap;
      color: white;
      background: rgba(0,0,0,.5);
      padding: 1em;
      border-radius: 50vh;
      font-size: 2vw;
    }
    canvas {
      opacity: .66;
    }
  }
}
canvas {
  width: 100vw;
  height: 100vh;
}


// dat.GUI customisation
.dg li.title {
  font-weight: bold;
  font-size: 14px;
  line-height: 30px;
  height: 30px;
  margin-left: -7px;
}
// BUG
.dg .cr.function .property-name {
  width: 100%;
}
View Compiled
// # Default state
let settings = {
  background: '#111',
  rotationX: 30,
  treeShape: 't => t',
  'video length [s]': 10
}
let chains = [
  { bulbRadius: 2, bulbsCount: 100, endColor: "#FFC", glowOffset: 0, opacity: 1, startAngle: 0, startColor: "#FFC", turnsCount: 14},
  { bulbRadius: 50, bulbsCount: 20, endColor: "#0FF", glowOffset: 0, opacity: 0.3, startAngle: 120, startColor: "#FF0", turnsCount: 3},
  { bulbRadius: 12, bulbsCount: 50, endColor: "#FF0", glowOffset: 0, opacity: 0.68, startAngle: 240, startColor: "#0FF", turnsCount: -3}
];


// # Global vars
const pixelRatio = window.devicePixelRatio;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let gui = null;
let guiFirstFolder = null;
let guiLastFolder = null;
let rotationZ = 0;


// # Customisation via dat.GUI
function getRandomChain() {
  return {
    bulbsCount: Math.round(Math.random() * (100 - 10) + 10),
    bulbRadius: Math.round(Math.random() * (20 - 1) + 1),
    glowOffset: Math.random() < 0.5 ? 0 : Math.round(Math.random() * (20 - 10) + 10),
    turnsCount: Math.round(Math.random() * (10 - 3) + 3) * (Math.random() < 0.5 ? -1 : 1),
    startAngle: Math.round(Math.random() * 360),
    startColor: '#FF0',
    endColor: '#0FF',
    opacity: Math.round(Math.random() * (100 - 60) + 60) / 100
  };
}

const guiMethods = {
  'ADD CHAIN': () => {
    chains.push(getRandomChain());
    updateDatGui();
    guiLastFolder.open();
  },
  'REMOVE CHAIN': null,
  removeChain: () => {
    const index = guiMethods['REMOVE CHAIN'];
    if (!Number.isNaN(parseInt(index))) {
      chains.splice(index, 1);
      guiMethods['REMOVE CHAIN'] = null;
      updateDatGui();
    }
  },
  '📷 Save as image': () => {
    CGL.saveAs.PNG(canvas, 'my-christmas-tree');
  },
  '🎥 Save as video': () => {
    const recorder = CGL.saveAs.WEBM(canvas, 'my-christmas-tree');
    recorder.start();
    document.body.classList.add('recording');
    setTimeout(() => {
      recorder.stop();
      document.body.classList.remove('recording');
    }, settings['video length [s]'] * 1000);
  }
};

function updateDatGui() {
  if (gui) {
    gui.destroy();
  }
  gui = new dat.GUI();
  
  chains.forEach((chain, i) => {
    const guiChain = gui.addFolder('🎄 Chain ' + (i+1));
    guiChain.add(chains[i], 'bulbsCount', 10, 500, 1);
    guiChain.add(chains[i], 'bulbRadius', 1, 100, 1);
    guiChain.add(chains[i], 'glowOffset', 0, 100, 1);
    guiChain.add(chains[i], 'turnsCount', -50, 50, 1);
    guiChain.add(chains[i], 'startAngle', 0, 360, 1);
    guiChain.addColor(chains[i], 'startColor');
    guiChain.addColor(chains[i], 'endColor');
    guiChain.add(chains[i], 'opacity', 0, 1, .01);
    if (i === 0) {
      guiFirstFolder = guiChain;
    } else if (i === chains.length - 1) {
      guiLastFolder = guiChain;
    }
  });
  
  let folders = {...guiMethods.removePlaceholder};
  chains.forEach((chain, i) => folders[`Chain ${i+1}`] = i);
  const shapes = {
    linear: 't => t',
    easeInQuad: 't => t*t',
    easeOutQuad: 't => t*(2-t)',
    easeInOutQuad: 't => (t<.5 ? 2*t*t : -1+(4-2*t)*t)',
    easeInCubic: 't => t*t*t'
  };
  const guiOptions = gui.addFolder('⚙️ Options');
  guiOptions.addColor(settings, 'background');
  guiOptions.add(settings, 'rotationX', 0, 75, 1);
  guiOptions.add(settings, 'treeShape', shapes);
  guiOptions.add(guiMethods, 'ADD CHAIN');
  guiOptions.add(guiMethods, 'REMOVE CHAIN', folders).onChange(guiMethods.removeChain);
  guiOptions.open();

  const guiExport = gui.addFolder('💾 Export');
  guiExport.add(guiMethods, '📷 Save as image');
  guiExport.add(guiMethods, '🎥 Save as video');
  guiExport.add(settings, 'video length [s]', 1, 60, 1);

  return gui;
}
updateDatGui();
guiFirstFolder.open();


// # Rendering of the tree
function updateScene() {
  let {innerWidth: canvasWidth, innerHeight: canvasHeight} = window;
  window.tiltAngle = settings.rotationX / 180 * Math.PI;
  window.treeHeight = Math.min(canvasWidth, canvasHeight) * .8;
  window.baseRadius = treeHeight * .3;
  window.baseCenter = {x: canvasWidth/2, y: canvasHeight/2 + treeHeight/2 * Math.cos(tiltAngle) - baseRadius/2 * Math.sin(tiltAngle)};
  ctx.canvas.width  = canvasWidth * pixelRatio;
  ctx.canvas.height = canvasHeight * pixelRatio;
  ctx.scale(pixelRatio, pixelRatio);
  ctx.fillStyle = settings.background;
  ctx.rect(0, 0, canvasWidth, canvasHeight);
  ctx.fill();
  ctx.lineWidth = 1.1;  
}

function renderChain(props) {
  for (let i = 0; i < props.bulbsCount; i++) {
    let progress = i / (props.bulbsCount - 1);
    progress = Math.pow((progress), Math.sqrt(progress) + 1); // just an approximate amendment of the distances between lights
    const turnProgress = (progress * props.turnsCount) % 1;
    const easing = eval(settings.treeShape); // dat.GUI seems unable to handle functions as values
    const sectionRadius = baseRadius * (1 - easing(progress));
    const sectionAngle = ((turnProgress * 360 + props.startAngle + rotationZ) / 180 * Math.PI) % (Math.PI*2);
    const opacity = Math.min(1, Math.max(0, Math.cos(sectionAngle)) + .2);
    const X = baseCenter.x + (Math.sin(sectionAngle) * sectionRadius);
    const Y = baseCenter.y - progress * treeHeight * Math.sin((90 - settings.rotationX) / 180 * Math.PI)
      + sectionRadius * Math.sin(tiltAngle) * Math.cos(sectionAngle);
    const bulbRadius = props.bulbRadius * treeHeight/1000;
    const glowRadius = (props.bulbRadius + props.glowOffset) * treeHeight/1000;
    const currentColor = CGL.colorConvert.opacity(
      CGL.colorConvert.mixBlendColors(props.startColor, props.endColor, progress), opacity
    );
    
    // opacity
    ctx.globalAlpha = props.opacity;

    // glow circles
    if (props.glowOffset > 0) {
      const gradient = ctx.createRadialGradient(X, Y, bulbRadius, X, Y, glowRadius);
      gradient.addColorStop(0, CGL.colorConvert.opacity(CGL.colorConvert.mixBlendColors(currentColor, '#fff', .3), .5));
      gradient.addColorStop(.25, CGL.colorConvert.opacity(currentColor, .6));
      gradient.addColorStop(.5, CGL.colorConvert.opacity(currentColor, .3));
      gradient.addColorStop(.75, CGL.colorConvert.opacity(currentColor, .125));
      gradient.addColorStop(1, CGL.colorConvert.opacity(currentColor, 0));
      ctx.fillStyle = gradient;    
      ctx.beginPath();
      ctx.arc(X, Y, glowRadius, 0, 2 * Math.PI);
      ctx.fill();
    }

    // bulbs
    ctx.fillStyle = currentColor;
    ctx.beginPath();
    ctx.arc(X, Y, bulbRadius, 0, 2 * Math.PI);
    ctx.fill();
  }
}

function render() {
  updateScene();
  chains.forEach(chain => renderChain(chain));
}

function rotate() {
  rotationZ = (rotationZ - 1) % 360;
  render();
  
  window.requestAnimationFrame(rotate);
}
rotate();

window.addEventListener('resize', render);
window.addEventListener('orientationchange', render);

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://codepen.io/ThiemelJiri/pen/RwNqZKZ
  2. https://codepen.io/ThiemelJiri/pen/eYmQEpa
  3. https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js
  4. https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.js