Logo Forge in JavaScript

Using ApexCharts with JavaScript

This Logo Forge example uses ApexCharts.js directly in JavaScript, with no wrapper component.

Install it with npm install apexcharts, then mount the chart with new ApexCharts(element, options).render().

JavaScript installation guide
// Drop a company logo on the page and it becomes a unit chart. Nothing is
// uploaded and nothing is stored: the file is read in the page with FileReader,
// turned into an outline, and handed to `ApexUnitShapes.shapeFrom`, which packs
// it with the same engine the 39 built-in shapes use. These definitions are
// shared by the vanilla-js, React and Vue builds.

// ---------------------------------------------------------------------------
// Turning "some SVG someone exported from a design tool" into ONE outline.
//
// Real logo files are not a tidy path in a 0-100 box. They are groups nested
// four deep, `transform="matrix(...)"` on half of them, circles and rects
// alongside paths, and often a stroke-only monoline mark with no fill at all.
// Rather than re-implement a transform stack and an element-to-path converter,
// this hands the problem to the geometry engine already in the browser:
//
//   * every drawable SVG element is an SVGGeometryElement, so it answers
//     `getTotalLength()` and `getPointAtLength()`. One loop covers path,
//     circle, ellipse, rect, polygon, polyline and line, and arbitrary curves
//     come back already flattened.
//   * `getCTM()` returns the matrix from the element to the root viewport, with
//     every ancestor transform and the viewBox already folded in, so a sampled
//     point maps to root coordinates with one `matrixTransform`.
//
// The result is a polygon soup in a single coordinate space, which is exactly
// what the packer wants: `fitBox` normalises whatever box it arrives in, so the
// numbers themselves do not matter.
// ---------------------------------------------------------------------------
var SAMPLE_BUDGET = 2600 // points spread across the whole mark

function svgOutline(markup) {
  var host = document.createElement('div')
  // Rendered but off-screen: getCTM() and getPointAtLength() need a live layout,
  // and `display:none` would return null matrices.
  host.setAttribute(
    'style',
    'position:absolute;left:-99999px;top:0;width:1000px;height:1000px;overflow:hidden',
  )
  host.innerHTML = markup
  document.body.appendChild(host)
  try {
    var svg = host.querySelector('svg')
    if (!svg) return { error: 'No <svg> element found in that file.' }

    var nodes = svg.querySelectorAll(
      'path,circle,ellipse,rect,polygon,polyline,line',
    )
    var geo = []
    for (var i = 0; i < nodes.length; i++) {
      var el = nodes[i]
      if (typeof el.getTotalLength !== 'function') continue
      var len = 0
      try {
        len = el.getTotalLength()
      } catch (e) {
        continue // a degenerate element (zero-radius circle, empty d)
      }
      if (!(len > 0)) continue
      var cs = window.getComputedStyle(el)
      if (cs.display === 'none' || Number(cs.opacity) === 0) continue
      var fill = cs.fill
      geo.push({
        el: el,
        len: len,
        filled: !!fill && fill !== 'none' && fill !== 'transparent',
        // A real logo file usually SAYS which rule it wants. Reading it beats
        // making someone guess with the toggle.
        rule: cs.fillRule === 'evenodd' ? 'evenodd' : 'nonzero',
        strokeWidth: parseFloat(cs.strokeWidth) || 0,
      })
    }
    if (!geo.length) return { error: 'That SVG has no drawable shapes in it.' }

    // A logo is either filled artwork or a monoline drawn in strokes. If
    // anything is filled, the filled parts ARE the mark and the strokes are
    // trim; if nothing is, the strokes are the mark and their centrelines go to
    // `strokeFrom` instead.
    var filled = geo.filter(function (g) {
      return g.filled
    })
    var use = filled.length ? filled : geo
    var stroked = !filled.length
    var totalLen = use.reduce(function (s, g) {
      return s + g.len
    }, 0)

    var parts = []
    var widths = []
    use.forEach(function (g) {
      // Sample each element in proportion to its length, so a long swooping
      // curve gets the points and a tiny dot does not.
      var n = Math.max(10, Math.round((g.len / totalLen) * SAMPLE_BUDGET))
      var step = g.len / n
      var m = g.el.getCTM()
      var k2 = m ? Math.sqrt(Math.abs(m.a * m.d - m.b * m.c)) || 1 : 1

      // One `d` can hold several subpaths (the ring and its counter, a dotted
      // i, every letter of an outlined wordmark), and `getPointAtLength` walks
      // them end to end without saying where one stops. Sampling straight
      // through therefore draws a chord ACROSS the gap, and the packer, which
      // cannot tell that edge from a real one, fills a spoke of dots along it.
      //
      // It does not need to be told: arc length does not accrue across the
      // gap, so the jump appears as a chord far longer than the sampling step.
      // For any real curve the chord between two samples is at MOST the arc
      // length between them, so anything past a few steps is a subpath
      // boundary and starts a new polygon. Distances are compared after the
      // transform, so the step is scaled by the matrix too.
      var jump = step * k2 * 3 + 1e-6
      var poly = []
      var prev = null

      var flush = function () {
        // Two points cannot bound an area, and a stray one is noise.
        if (poly.length < 3) return
        var d = ''
        poly.forEach(function (p, i) {
          d += (i === 0 ? 'M ' : ' L ') + round2(p.x) + ' ' + round2(p.y)
        })
        // Close a filled subpath; leave a centreline open, or the stroke
        // doubles back on itself from finish to start.
        parts.push(stroked ? d : d + ' Z')
      }

      for (var k = 0; k <= n; k++) {
        var p = g.el.getPointAtLength((k / n) * g.len)
        if (m) p = p.matrixTransform(m)
        if (prev && Math.hypot(p.x - prev.x, p.y - prev.y) > jump) {
          flush()
          poly = []
        }
        poly.push({ x: p.x, y: p.y })
        prev = p
      }
      flush()

      if (stroked && g.strokeWidth) {
        // The stroke width has to travel into the same space as the points.
        widths.push(g.strokeWidth * k2)
      }
    })
    if (!parts.length)
      return { error: 'That SVG has no drawable shapes in it.' }

    return {
      path: parts.join(' '),
      stroked: stroked,
      width: widths.length ? median(widths) : 8,
      // Elements found vs polygons recovered: one <path> holding a ring and its
      // counter is 1 element and 2 subpaths, and it is the second number that
      // decides whether the mark has a hole in it.
      elements: use.length,
      subpaths: parts.length,
      // The rule the artwork itself declares, for `apply` to adopt on load.
      rule: use[0].rule,
      skipped: geo.length - use.length,
    }
  } catch (e) {
    return { error: 'That file could not be read as SVG (' + e.message + ').' }
  } finally {
    document.body.removeChild(host)
  }
}

function round2(v) {
  return Math.round(v * 100) / 100
}

function median(a) {
  var s = a.slice().sort(function (x, y) {
    return x - y
  })
  return s[Math.floor(s.length / 2)]
}

// ---------------------------------------------------------------------------
// Two marks to start with, both drawn for this sample. The first is the Halo
// ring from the storyboard sample, nested circles under even-odd; the second is
// stroke-only, so it takes the `strokeFrom` branch above.
// ---------------------------------------------------------------------------
var PRESETS = {
  // Halo's own mark (the storyboard sample's brand): eight overlapping subpaths
  // under the DEFAULT nonzero rule, where the handle's finger hole is the loop's
  // ellipse wound backwards. Try the fillRule toggle on this one: even-odd
  // treats every overlap as a hole and takes the cup apart.
  Cup:
    '<svg viewBox="0 0 100 100"><path fill="#5C3A21" d="' +
    'M 72 42 L 62 72 C 62 76, 55 77, 46 77 C 37 77, 30 76, 30 72 L 20 42 Z ' +
    'M 20 42 A 26 7 0 0 1 72 42 A 26 7 0 0 1 20 42 Z ' +
    'M 38 75 L 54 75 L 57 81.5 L 35 81.5 Z ' +
    'M 4 86.5 A 42 6.5 0 0 1 88 86.5 A 42 6.5 0 0 1 4 86.5 Z ' +
    'M 64 58 A 14 11 0 0 1 92 58 A 14 11 0 0 1 64 58 Z ' +
    'M 74 58 A 7 5 0 0 0 88 58 A 7 5 0 0 0 74 58 Z ' +
    'M 48 37 C 36 27, 56 19, 47 5 C 50 4, 52 5, 54 7 C 60 19, 44 26, 54 37 ' +
    'C 52 38, 50 38, 48 37 Z ' +
    'M 30 36 C 21 28, 35 21, 28 9 C 30 8, 32 9, 34 11 C 39 21, 28 27, 36 36 ' +
    'C 34 37, 32 37, 30 36 Z' +
    '" /></svg>',
  // Three NESTED circles, which is the case even-odd exists for. Under nonzero
  // they are all wound the same way, so they union into a plain disc: the
  // counter and the bean vanish. That is the single most common reason a pasted
  // logo comes out wrong, and the toggle is the fix.
  'Ring (evenodd)':
    '<svg viewBox="0 0 100 100">' +
    '<path fill="#5C3A21" fill-rule="evenodd" d="' +
    'M 4 50 A 46 46 0 1 1 96 50 A 46 46 0 1 1 4 50 Z ' +
    'M 20 50 A 30 30 0 1 1 80 50 A 30 30 0 1 1 20 50 Z ' +
    'M 34.44 60.9 A 19 12 -35 1 1 65.56 39.1 A 19 12 -35 1 1 34.44 60.9 Z' +
    '" /></svg>',
  // No fill anywhere, so this one arrives as a centreline plus a width.
  Current:
    '<svg viewBox="0 0 100 100">' +
    '<g transform="translate(2 4) rotate(-4 50 50)">' +
    '<path fill="none" stroke="#2E8B9A" stroke-width="9" ' +
    'd="M 6 62 C 26 26, 42 82, 58 46 S 78 20, 92 40" />' +
    '<path fill="none" stroke="#2E8B9A" stroke-width="9" d="M 20 84 L 82 84" />' +
    '</g></svg>',
}

// ---------------------------------------------------------------------------
// The chart's data is deliberately anonymous: four parts of a whole, so the
// bands read as bands whatever mark is on screen. The dot-count slider scales
// these to a target total, which is the honest way to show a repack (more data,
// not a redrawn shape).
// ---------------------------------------------------------------------------
var SPLIT = [0.4, 0.27, 0.15, 0.18]
var PART_LABELS = ['Part A', 'Part B', 'Part C', 'Part D']
var PART_COLORS = ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A']

function seriesFor(total) {
  var out = SPLIT.map(function (f) {
    return Math.round(total * f)
  })
  // Put the rounding error on the biggest part, so the total is exactly asked.
  out[0] +=
    total -
    out.reduce(function (s, v) {
      return s + v
    }, 0)
  return out
}

// What the packer actually did, measured from the positions it returned rather
// than guessed: dots land on shared row baselines, so grouping by y recovers the
// rows, and the thinnest rows say whether any part of the mark has thinned to a
// single file of dots.
function inspect(shape, count) {
  var objects = []
  for (var i = 0; i < count; i++) {
    objects.push({
      id: 'u' + i,
      index: i,
      seriesIndex: 0,
      dataPointIndex: i,
      label: '',
      datum: null,
      r: 2,
    })
  }
  var pos = shape(objects, { x: 0, y: 0, width: 620, height: 400 })
  if (!pos.length) return { placed: 0, rows: 0, thin: 0 }
  var asc = function (a, b) {
    return a - b
  }

  // Dots land on shared row baselines, so grouping by y recovers the rows the
  // packer cut the outline into.
  var rows = {}
  pos.forEach(function (p) {
    var k = Math.round(p.y * 2) / 2
    if (!rows[k]) rows[k] = []
    rows[k].push(p.x)
  })
  var keys = Object.keys(rows)
  keys.forEach(function (k) {
    rows[k].sort(asc)
  })

  // Counting dots per ROW is not the measure wanted, and a ring is why: a row
  // across the middle of one holds a handful on the left and a handful on the
  // right, and their sum says the mark is comfortably thick when in fact it is
  // two hairlines. So find the typical dot pitch first, then break each row
  // where the gap exceeds it, and measure the CONTIGUOUS runs.
  var gaps = []
  keys.forEach(function (k) {
    var xs = rows[k]
    for (var i = 1; i < xs.length; i++) gaps.push(xs[i] - xs[i - 1])
  })
  gaps.sort(asc)
  var pitch = gaps.length ? gaps[Math.floor(gaps.length * 0.5)] : 0

  var runs = []
  keys.forEach(function (k) {
    var xs = rows[k]
    var run = 1
    for (var i = 1; i < xs.length; i++) {
      if (pitch > 0 && xs[i] - xs[i - 1] > pitch * 1.8) {
        runs.push(run)
        run = 1
      } else {
        run++
      }
    }
    runs.push(run)
  })
  runs.sort(asc)

  return {
    placed: pos.length,
    rows: keys.length,
    // 10th percentile, not the minimum: the top and bottom row of any rounded
    // mark legitimately holds one dot, so the minimum is always 1 and says
    // nothing.
    thin: runs[Math.floor(runs.length * 0.1)] || 0,
  }
}

var options = {
  series: [480, 324, 180, 216],
  chart: {
    id: 'logoForge',
    type: 'unit',
    height: 400,
    fontFamily: 'Helvetica, Arial, sans-serif',
    animations: {
      enabled: true,
      speed: 700,
    },
    toolbar: { show: false },
  },
  labels: ['Part A', 'Part B', 'Part C', 'Part D'],
  colors: ['#5C3A21', '#C77B30', '#6F9B4A', '#2E8B9A'],
  legend: {
    position: 'bottom',
  },
  plotOptions: {
    unit: {
      layout: 'custom',
      // Replaced on every change by the forge below; the initial value is the
      // Halo preset, built in the same way a dropped file is.
      positions: undefined,
      // 'identity' would need per-datum ids; 'flow' keys the dots by global order,
      // so the same crowd migrates from one mark into the next instead of being
      // rebuilt, which is what makes swapping logos feel like one continuous
      // object rather than a slideshow.
      transition: 'flow',
      size: 2.2,
      clusterLabels: {
        show: false,
      },
    },
  },
}

var chart = new ApexCharts(document.querySelector('#chart'), options)
chart.render()

// (svgOutline, PRESETS, seriesFor and inspect live in the shared head script.)
var state = {
  markup: PRESETS.Cup,
  source: 'Cup (preset)',
  count: 1200,
  rule: 'nonzero',
  order: 'rows',
  hollow: false,
  adoptRule: true,
}

var el = {
  drop: document.getElementById('fg-drop'),
  file: document.getElementById('fg-file'),
  paste: document.getElementById('fg-paste'),
  count: document.getElementById('fg-count'),
  countVal: document.getElementById('fg-count-val'),
  order: document.getElementById('fg-order'),
  rule: document.getElementById('fg-rule'),
  hollow: document.getElementById('fg-hollow'),
  src: document.getElementById('fg-src'),
  els: document.getElementById('fg-els'),
  placed: document.getElementById('fg-placed'),
  rows: document.getElementById('fg-rows'),
  thin: document.getElementById('fg-thin'),
  warn: document.getElementById('fg-warn'),
  code: document.getElementById('fg-code'),
  copy: document.getElementById('fg-copy'),
}

function commas(n) {
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}

function esc(s) {
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
}

var lastSnippet = ''

// Build the shape from the current markup + controls, push it into the chart,
// and report what the packer made of it.
function apply() {
  var out = svgOutline(state.markup)
  if (out.error) {
    el.warn.className = 'fg-warn is-bad'
    el.warn.textContent = out.error
    return
  }

  // A newly loaded mark adopts the fill rule its own artwork declares; an
  // explicit click on the toggle is never overridden.
  if (state.adoptRule) {
    state.adoptRule = false
    if (out.rule && !out.stroked) {
      state.rule = out.rule
      el.rule.querySelectorAll('button').forEach(function (x) {
        x.classList.toggle(
          'is-active',
          x.getAttribute('data-rule') === state.rule,
        )
      })
    }
  }

  var opts = { name: 'mine' }
  var shape
  if (out.stroked) {
    // No fill anywhere in the file: the strokes ARE the mark.
    opts.width = out.width
    opts.order = state.order
    shape = ApexUnitShapes.strokeFrom(out.path, opts)
  } else {
    opts.fillRule = state.rule
    opts.order = state.order
    shape = ApexUnitShapes.shapeFrom(out.path, opts)
    if (state.hollow) shape = ApexUnitShapes.outlined(shape, 7)
  }

  // A stroke-only mark has no interior, so fillRule and outlined() have
  // nothing to act on. Dim them rather than leave them live and inert.
  el.rule.classList.toggle('is-off', out.stroked)
  el.hollow.classList.toggle('is-off', out.stroked)

  var stats = inspect(shape, state.count)

  var chart = ApexCharts.getChartByID('logoForge')
  chart.updateOptions(
    {
      series: seriesFor(state.count),
      plotOptions: { unit: { layout: 'custom', positions: shape } },
    },
    false,
    true,
  )

  el.src.textContent = state.source
  el.els.textContent =
    commas(out.subpaths) +
    ' from ' +
    commas(out.elements) +
    (out.stroked ? ' stroke el.' : ' el.')
  el.placed.textContent = commas(stats.placed)
  el.rows.textContent = commas(stats.rows)
  el.thin.textContent = commas(stats.thin)

  // The readability warning, from the measurement rather than from taste.
  if (stats.placed < state.count) {
    el.warn.className = 'fg-warn is-bad'
    el.warn.textContent =
      'Only ' +
      commas(stats.placed) +
      ' of ' +
      commas(state.count) +
      ' dots found a slot. The outline is probably not closed, or it is so thin' +
      ' at this dot count that whole rows hold nothing.'
  } else if (stats.thin <= 1) {
    el.warn.className = 'fg-warn'
    el.warn.textContent =
      'Parts of this mark are down to a single file of dots. Raise the dot' +
      ' count, or set minUnits to about ' +
      commas(Math.round(state.count * 2.2)) +
      ' so the chart warns instead of drawing mush.'
  } else if (stats.thin <= 2) {
    el.warn.className = 'fg-warn'
    el.warn.textContent =
      'Thin, but still reading: two dots across at the narrowest. This is about' +
      ' the floor for this mark, so it is a fair value for minUnits.'
  } else {
    el.warn.className = 'fg-warn fg-hide'
  }

  emit(out, stats)
}

// The snippet is the real thing, including the sampled outline, because that is
// what actually got packed.
function emit(out, stats) {
  var d = out.path
  var shown = d.length > 200 ? d.slice(0, 200) + ' ...' : d
  var fn = out.stroked ? 'strokeFrom' : 'shapeFrom'
  var opts = out.stroked
    ? '  width: ' + round2(out.width) + ',\n'
    : "  fillRule: '" + state.rule + "',\n"
  var minU = Math.max(
    100,
    Math.round(state.count * (stats.thin <= 2 ? 1 : 0.5)),
  )

  lastSnippet =
    "import ApexCharts from 'apexcharts'\n" +
    'import { ' +
    fn +
    " } from 'apexcharts/unit-shapes'\n\n" +
    '// ' +
    out.subpaths +
    ' subpath(s) sampled from ' +
    state.source +
    '\n' +
    'const MY_LOGO = ' +
    JSON.stringify(shown) +
    '\n\n' +
    'const mine = ' +
    fn +
    '(MY_LOGO, {\n' +
    opts +
    "  order: '" +
    state.order +
    "',\n" +
    '  minUnits: ' +
    minU +
    ',\n' +
    '})\n\n' +
    'new ApexCharts(host, {\n' +
    "  chart: { type: 'unit' },\n" +
    '  series: [' +
    seriesFor(state.count).join(', ') +
    '],\n' +
    '  labels: ' +
    JSON.stringify(PART_LABELS) +
    ',\n' +
    "  plotOptions: { unit: { layout: 'custom', positions: mine } },\n" +
    '}).render()'

  el.code.innerHTML = esc(lastSnippet)
    .replace(/^(\/\/.*)$/gm, '<span class="c">$1</span>')
    .replace(/(&quot;|')((?:[^'&]|&(?!quot;))*)\1/g, function (m) {
      return '<span class="s">' + m + '</span>'
    })
}

function load(markup, label) {
  state.markup = markup
  state.source = label
  state.adoptRule = true
  document.querySelectorAll('[data-preset]').forEach(function (b) {
    b.classList.toggle('is-active', b.getAttribute('data-preset') === label)
  })
  apply()
}

// --- the drop gesture -------------------------------------------------------
function readFile(file) {
  if (!file) return
  var reader = new FileReader()
  reader.onload = function () {
    load(String(reader.result), file.name)
  }
  reader.readAsText(file)
}

;['dragenter', 'dragover'].forEach(function (ev) {
  el.drop.addEventListener(ev, function (e) {
    e.preventDefault()
    el.drop.classList.add('is-over')
  })
})
;['dragleave', 'drop'].forEach(function (ev) {
  el.drop.addEventListener(ev, function (e) {
    e.preventDefault()
    el.drop.classList.remove('is-over')
  })
})
el.drop.addEventListener('drop', function (e) {
  var dt = e.dataTransfer
  if (dt && dt.files && dt.files.length) readFile(dt.files[0])
})
// The whole panel is the target, but the textarea inside it has to stay usable.
el.drop.addEventListener('click', function (e) {
  if (e.target !== el.paste) el.file.click()
})
el.file.addEventListener('change', function () {
  readFile(el.file.files[0])
})
el.paste.addEventListener('input', function () {
  var v = el.paste.value.trim()
  if (v.indexOf('<svg') !== -1) load(v, 'pasted markup')
})

// --- the controls -----------------------------------------------------------
el.count.addEventListener('input', function () {
  state.count = Number(el.count.value)
  el.countVal.textContent = commas(state.count)
  apply()
})
el.order.addEventListener('change', function () {
  state.order = el.order.value
  apply()
})
el.rule.addEventListener('click', function (e) {
  var b = e.target.closest('[data-rule]')
  if (!b) return
  state.rule = b.getAttribute('data-rule')
  el.rule.querySelectorAll('button').forEach(function (x) {
    x.classList.toggle('is-active', x === b)
  })
  apply()
})
el.hollow.addEventListener('click', function (e) {
  var b = e.target.closest('[data-hollow]')
  if (!b) return
  state.hollow = b.getAttribute('data-hollow') === '1'
  el.hollow.querySelectorAll('button').forEach(function (x) {
    x.classList.toggle('is-active', x === b)
  })
  apply()
})
document.querySelectorAll('[data-preset]').forEach(function (b) {
  b.addEventListener('click', function () {
    var name = b.getAttribute('data-preset')
    load(PRESETS[name], name)
  })
})
el.copy.addEventListener('click', function () {
  if (navigator.clipboard) navigator.clipboard.writeText(lastSnippet)
  el.copy.textContent = 'Copied'
  window.setTimeout(function () {
    el.copy.textContent = 'Copy'
  }, 1400)
})

// The chart mounts with `positions: undefined`, so the forge supplies the first
// shape as soon as it is on screen.
apply()