OEE example widget, Machine Table API example

Hey All,

Stealing a super slick widget shamelessly to share with you. This is one of the widgets that came from our internal Hackathon with the feature.

Note - While this widget was made by experienced developers, it hasnt gone through our normal code review process, use it as your own risk

This is a good example because it shows how to hit machine tables through the api and a custom widget too. 2 things you will need to do to get it up and running:

  1. Update the authenticationKey to match one for a bot in your instance
  2. Adjust the url of the table it is looking at to match the machine in your instance you want oee insight on.

This post goes into more detail on the process of hitting the api in a custom widget (Getting Table Records from the API in JavaScript)

Hope this helps,
Pete

UPDATE: This json keeps failing to attach to this post, below is the contents of that json. just copy it to a text file and save it with the .json extension and you should be able to import it into your instance.

{
    "version": 1,
    "customWidget": {
        "_id": "Pi2bD6nQ9HaACekFS",
        "name": "Machine OEE Widget (sharable)",
        "props": [{
            "name": "Machine",
            "type": "machine",
            "requireWriteable": false,
            "id": "DjLysJs6PmaYAnRBn"
        }],
        "events": [],
        "html": "<!DOCTYPE html>\n<head>\n  <script\n    type=\"text/javascript\"\n    src=\"https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js\"\n  ></script>\n  <script\n    src=\"https://code.jquery.com/jquery-3.6.0.min.js\"\n    integrity=\"sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=\"\n    crossorigin=\"anonymous\"\n  ></script>\n  <script\n    src=\"https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js\"\n    integrity=\"sha256-ErZ09KkZnzjpqcane4SCyyHsKAXMvID9/xwbl/Aq1pc=\"\n    crossorigin=\"anonymous\"\n  ></script>\n  <script type=\"text/javascript\" src=\"./index.js\"></script>\n  <link rel=\"stylesheet\" href=\"styles.css\">\n  <script src=\"https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js\" integrity=\"sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n</head>\n\n<body>\n  <div class=\"widget-container\">\n    <div class=\"left-pannel\">\n      <div class=\"headerWrapper\">\n        <div class=\"calculationWrapper headerItem\">\n          <h3>Performance</h3>\n          <div class=\"bordered\">\n            <h1 id=\"performanceCalculationValue\">--%</h1>\n          </div>\n        </div>\n        <div class=\"calculationWrapper headerItem\">\n          <h3>Quality</h3>\n          <div class=\"bordered\">\n            <h1 id=\"qualityCalculationValue\">--%</h1>\n          </div>\n        </div>\n        <div class=\"calculationWrapper headerItem\">\n          <h3>Availability</h3>\n          <div class=\"bordered\">\n            <h1 id=\"availabilityCalculationValue\">--%</h1>\n          </div>\n        </div>\n        <div class=\"calculationWrapper headerItem\">\n          <h3>OEE</h3>\n          <div class=\"bordered\">\n            <h1 id=\"oeeCalculationValue\">--%</h1>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"chart-container\">\n        <canvas id=\"myChart\"></canvas>\n      </div>\n    </div>\n\n    <div class=\"right-pannel\">\n      <div class=\"keyWrapper\">\n        <h3>Key</h3>\n        <div class=\"key bordered\">\n          <div class=\"keyEntry\">\n            <span class=\"keyColor a-loss\"></span>\n            <p class=\"keyName\">Availability loss</p>\n          </div>\n          <div class=\"keyEntry\">\n            <span class=\"keyColor p-loss\"></span>\n            <p class=\"keyName\">Performance loss</p>\n          </div>\n          <div class=\"keyEntry\">\n            <span class=\"keyColor q-loss\"></span>\n            <p class=\"keyName\">Quality loss</p>\n          </div>\n          <div class=\"keyEntry\">\n            <span class=\"keyColor ideal\"></span>\n            <p class=\"keyName\">Ideal run time</p>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"settings\">\n        <section>\n          <label for=\"time-range\">Time range:</label>\n          <select name=\"Time Range\" id=\"time-range\">\n            <option value=\"Last 24hrs\">Last 15 minutes</option>\n            <option value=\"Last 60 minutes\">Last 60 minutes</option>\n            <option value=\"Last 4 hours\">Last 4 hours</option>\n            <option value=\"Last 24 hours\">Last 24 hours</option>\n            <option value=\"Last 7 days\" selected>Last 7 days</option>\n            <option value=\"Last 30 days\">Last 30 days</option>\n          </select>\n        </section>\n\n        <section>\n          <label for=\"time-interval\">Time interval:</label>\n          <select name=\"Time Range\" id=\"time-interval\">\n            <option value=\"1 minute\">1 minute</option>\n            <option value=\"2 minutes\">2 minutes</option>\n            <option value=\"5 minutes\">5 minutes</option>\n            <option value=\"10 minutes\">10 minutes</option>\n            <option value=\"60 minutes\">60 minutes</option>\n            <option value=\"4 hours\">4 hours</option>\n            <option value=\"1 day\" selected>1 day</option>\n            <option value=\"1 week\">1 week</option>\n          </select>\n        </section>\n      </div>\n    </div>\n  </div>\n</body>\n",
        "javascript": "const state = {\n  machineID: \"NEProzkcZnfakytgB\",\n  bucketUnit: \"day\",\n};\n\nif (typeof getValue !== \"undefined\") {\n  getValue(\"Machine\", (machine) => {\n    if (machine == null) {\n      return;\n    }\n    state.machineID = machine.id;\n    handleMachineChange(machine.id);\n  });\n}\n\nfunction toPercent(n) {\n  return `${(n * 100).toFixed(2)}%`;\n}\n\nfunction now() {\n  // Fixes time for the demo\n  return moment(\"2022-02-15T17:00:00\");\n}\n\nasync function setCurrentCalculationValues(\n  performance,\n  availability,\n  quality,\n  oee\n) {\n  document.getElementById(\"performanceCalculationValue\").innerHTML = toPercent(\n    performance\n  );\n  document.getElementById(\"availabilityCalculationValue\").innerHTML = toPercent(\n    availability\n  );\n  document.getElementById(\"qualityCalculationValue\").innerHTML = toPercent(\n    quality\n  );\n  document.getElementById(\"oeeCalculationValue\").innerHTML = toPercent(oee);\n}\n\nasync function fetchMachineData(machineID) {\n  const authorizationBasic =\n    \"Basic YXBp...\";\n\n  let myHeaders = new Headers();\n  myHeaders.append(\"Accept\", \"application/json\");\n  myHeaders.append(\"Authorization\", authorizationBasic);\n  myHeaders.append(\n    \"Content-Type\",\n    \"application/x-www-form-urlencoded; charset=UTF-8\"\n  );\n\n  const myInit = {\n    method: \"GET\",\n    headers: myHeaders,\n    mode: \"cors\",\n    cache: \"default\",\n  };\n\n  let myRequest = new Request(\n    `https://customwidgets.tulip.co/api/v3/tables/YocNLtbgCtcdBRSdG/records?sortOptions=%5B%7B%22sortBy%22%3A%22_sequenceNumber%22%2C%22sortDir%22%3A%22asc%22%7D%5D&filters=%5B%7B%22field%22%3A%22mm_machine_id%22%2C%22arg%22%3A%22${machineID}%22%2C%22functionType%22%3A%22equal%22%7D%5D&filterAggregator=%22all%22&limit=100&offset=0`\n  );\n\n  const response = await fetch(myRequest, myInit);\n  const machineData = await response.json();\n  return machineData;\n}\n\nconst UPTIME = [\"running_machine_state\"];\n\nclass MetricsBuilder {\n  constructor(rawMachineData) {\n    this.cleanUp(rawMachineData);\n    this.startTime = now().subtract(8, \"days\"); // now().subtract(10,'minutes')\n    this.endTime = now();\n    this.bucketSize = moment.duration(1, \"days\");\n    this.machineData = rawMachineData;\n    this.updateTimeBuckets();\n  }\n\n  cleanUp(rawMachineData) {\n    _.map(this.machineData, (machineRow) => {\n      _.update(machineRow, \"mm_start_time\", (obj) => {\n        return moment(obj);\n      });\n      _.update(machineRow, \"mm_end_time\", (obj) => {\n        return moment(obj);\n      });\n      _.update(machineRow, \"_createdAt\", (obj) => {\n        return moment(obj);\n      });\n      _.update(machineRow, \"_updatedAt\", (obj) => {\n        return moment(obj);\n      });\n    });\n  }\n\n  updateTimeRange(startTime, endTime) {\n    this.startTime = startTime;\n    this.endTime = endTime;\n    this.updateTimeBuckets();\n  }\n\n  updateBucketSize(val, unit) {\n    this.bucketSize = moment.duration(val, unit);\n    this.updateTimeBuckets();\n  }\n\n  updateTimeBuckets() {\n    const rawBuckets = _.range(this.startTime, this.endTime, this.bucketSize);\n    this.buckets = _.map(rawBuckets, (bucket) => {\n      return moment(bucket).format();\n    });\n    this.splitMachineStatesOverBuckets();\n  }\n\n  splitMachineStateOverBucket(machineRow) {\n    const bucketBoundaries = _.zip(\n      _.slice(this.buckets, 0, this.buckets.length - 1),\n      _.slice(this.buckets, 1)\n    );\n    const newRows = [machineRow];\n    // TODO: make this more efficient\n    _.each(bucketBoundaries, (bucketBoundary) => {\n      const [start, end] = bucketBoundary;\n      // the start of the machine row interval falls into the bucket, the end falls outside of the bucket\n      if (\n        !moment(machineRow.mm_start_time).isBefore(start) &&\n        moment(machineRow.mm_start_time).isBefore(end) &&\n        moment(machineRow.mm_end_time).isAfter(end)\n      ) {\n        // make a copy of the row\n        const machineRowCopy = _.cloneDeep(machineRow);\n        // set the end time of the original interval to the end of the bucket\n        machineRow.mm_end_time = moment(end);\n        machineRow.mm_duration =\n          moment(machineRow.mm_end_time).diff(machineRow.mm_start_time) / 1000;\n        // set the start of the new interval to the end of the bucket\n        machineRowCopy.mm_start_time = moment(end);\n        machineRowCopy.mm_duration =\n          moment(machineRowCopy.mm_end_time).diff(\n            machineRowCopy.mm_start_time\n          ) / 1000;\n        newRows.push(machineRowCopy);\n      }\n    });\n    return newRows;\n  }\n\n  splitMachineStatesOverBuckets() {\n    for (let i = 0; i < this.machineData.length; i++) {\n      // split the current row into what falls into the bucket and what falls outside the bucket\n      const newRows = this.splitMachineStateOverBucket(this.machineData[i]);\n      // delete the current row and insert the new rows\n      // syntax: splice(start, deleteCount, item1, item2, itemN)\n      this.machineData.splice(i, 1, ...newRows);\n      // now we resume the loop at the next row, which may be one of the rows we inserted, since\n      // that new row may also need to be split up further\n    }\n  }\n\n  bucketMachineRows() {\n    return _.chain(this.machineData)\n      .groupBy((machineRow) => {\n        const bucketBoundaries = _.zip(\n          _.slice(this.buckets, 0, this.buckets.length - 1),\n          _.slice(this.buckets, 1)\n        );\n        // TODO: make this more efficient\n        return _.find(bucketBoundaries, (bucketBoundary) => {\n          const [start, end] = bucketBoundary;\n          // the machine row fits entirely into the bucket\n          if (\n            !moment(machineRow.mm_start_time).isBefore(start) &&\n            moment(machineRow.mm_start_time).isBefore(end) &&\n            !moment(machineRow.mm_end_time).isAfter(end)\n          ) {\n            return start;\n          }\n        });\n      })\n      .values()\n      .value();\n  }\n\n  // Set of metrics for a single bucket\n\n  calculateAvailabilityLossSingleBucket(machineRows, totalTime) {\n    const downtime = _.chain(machineRows)\n      .filter((machineRow) => {\n        return !UPTIME.includes(machineRow.mm_machine_state);\n      })\n      .sumBy(\"mm_duration\")\n      .value();\n    return totalTime - moment.duration(downtime, \"seconds\");\n  }\n\n  calculatePerformanceLossSingleBucket(machineRows) {\n    const perfLoss = _.chain(machineRows)\n      .filter((machineRow) => {\n        return (\n          machineRow.mm_defect_count != null &&\n          machineRow.mm_ideal_run_rate != null &&\n          machineRow.mm_ideal_run_rate > 0\n        );\n      })\n      .map((machineRow) => {\n        return machineRow.mm_defect_count / machineRow.mm_ideal_run_rate;\n      })\n      .mean()\n      .value();\n    return moment.duration(perfLoss, \"hours\").asMilliseconds();\n  }\n\n  calculateQualityLossSingleBucket(machineRows) {\n    const qualityLoss = _.chain(machineRows)\n      .filter((machineRow) => {\n        return (\n          machineRow.mm_defect_count != null &&\n          machineRow.mm_part_count != null &&\n          machineRow.mm_part_count > 0\n        );\n      })\n      .map((machineRow) => {\n        return machineRow.mm_defect_count / machineRow.mm_part_count;\n      })\n      .mean()\n      .value();\n    return moment.duration(qualityLoss, \"hours\").asMilliseconds();\n  }\n\n  // Set of metrics over time\n\n  calculateMetricOverTime(func) {\n    const bucketedRows = this.bucketMachineRows();\n    return _.map(bucketedRows, func);\n  }\n\n  calculateAvailabilityLossOverTime() {\n    return this.calculateMetric(\n      _.partial(this.calculateAvailabilityLossSingleBucket, _, this.bucketSize)\n    );\n  }\n\n  calculatePerformanceLossOverTime() {\n    return this.calculateMetric(this.calculatePerformanceLossSingleBucket);\n  }\n\n  calculateQualityLossOverTime() {\n    return this.calculateMetric(this.calculateQualityLossSingleBucket);\n  }\n\n  // Set of metric for data as a whole\n\n  calculateMetric(func) {\n    const bucketedRows = this.bucketMachineRows();\n    return _.map(bucketedRows, func);\n  }\n\n  calculateAvailability() {\n    const totalTime = this.bucketSize * this.buckets.length;\n    const avail =\n      1 -\n      this.calculateAvailabilityLossSingleBucket(this.machineData, totalTime) /\n        totalTime;\n\n    return avail;\n  }\n\n  calculatePerformance() {\n    const totalTime = this.bucketSize * this.buckets.length;\n    const perf =\n      1 -\n      this.calculatePerformanceLossSingleBucket(this.machineData) / totalTime;\n\n    return perf;\n  }\n\n  calculateQuality() {\n    const totalTime = this.bucketSize * this.buckets.length;\n    const qual =\n      1 - this.calculateQualityLossSingleBucket(this.machineData) / totalTime;\n\n    return qual;\n  }\n\n  calculateOEE(...metrics) {\n    return _.reduce(\n      metrics,\n      (acc, item) => {\n        return acc * item;\n      },\n      1\n    );\n  }\n\n  reportMetrics() {\n    const p = this.calculatePerformance();\n    const q = this.calculateQuality();\n    const a = this.calculateAvailability();\n\n    return [p, q, a, this.calculateOEE(p, q, a)];\n  }\n\n  reportMetricsOverTime() {\n    const als = this.calculateAvailabilityLossOverTime();\n    const pls = this.calculatePerformanceLossOverTime();\n    const qls = this.calculateQualityLossOverTime();\n    const dateFormat =\n      {\n        day: \"MMM DD\",\n        week: \"MMM DD\",\n      }[state.bucketUnit] || \"lll\";\n    const times = _.map(this.buckets, (bucket) =>\n      moment(bucket).format(dateFormat)\n    );\n\n    const zipped = _.zip(als, pls, qls);\n    const vals = _.map(zipped, ([al, pl, ql]) => {\n      return { al, pl, ql, idl: this.bucketSize - al - pl - ql };\n    });\n\n    return {\n      times,\n      vals,\n    };\n  }\n}\n\nlet metricsBuilder;\n\nasync function initWidget() {\n  const rawMachineData = await fetchMachineData(state.machineID);\n  metricsBuilder = new MetricsBuilder(rawMachineData);\n  initChart(metricsBuilder.reportMetricsOverTime());\n}\n\nasync function refreshWidget() {\n  updateChart(metricsBuilder.reportMetricsOverTime());\n  setCurrentCalculationValues(...metricsBuilder.reportMetrics());\n}\n\nfunction formatBucket(x) {\n  return x / 1000 / 3600;\n}\n\nfunction initChart(machineData) {\n  const ctx = document.getElementById(\"myChart\").getContext(\"2d\");\n  const mockMachineData = {\n    times: [\"Feb 1\", \"Feb 2\", \"Feb 3\", \"Feb 4\", \"Feb 5\", \"Feb 6\", \"Feb 7\"],\n    vals: [\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n      genFakeDataPoint(24),\n    ],\n  };\n\n  const dataToUse = machineData || mockMachineData;\n\n  const labels = dataToUse.times;\n  const data = {\n    labels: labels,\n    datasets: [\n      {\n        id: \"al\",\n        label: \"Availability loss\",\n        data: _.map(_.map(dataToUse.vals, \"al\"), formatBucket),\n        backgroundColor: \"rgba(255, 206, 86, 0.5)\",\n        borderColor: \"rgba(255, 206, 86, 1)\",\n        borderWidth: 2,\n      },\n      {\n        id: \"pl\",\n        label: \"Performance loss\",\n        data: _.map(_.map(dataToUse.vals, \"pl\"), formatBucket),\n        backgroundColor: \"rgba(255, 159, 64, 0.5)\",\n        borderColor: \"rgba(255, 159, 64, 1)\",\n        borderWidth: 2,\n      },\n      {\n        id: \"ql\",\n        label: \"Quality loss\",\n        data: _.map(_.map(dataToUse.vals, \"ql\"), formatBucket),\n        backgroundColor: \"rgba(255, 99, 132, 0.5)\",\n        borderColor: \"rgba(255, 99, 132, 1)\",\n        borderWidth: 2,\n      },\n      {\n        id: \"idl\",\n        label: \"Ideal run time\",\n        data: _.map(_.map(dataToUse.vals, \"idl\"), formatBucket),\n        backgroundColor: \"rgba(75, 192, 192, 0.5)\",\n        borderColor: \"rgba(75, 192, 192, 1)\",\n        borderWidth: 2,\n      },\n    ].reverse(),\n  };\n  const config = {\n    type: \"bar\",\n    data: data,\n    options: {\n      plugins: {\n        title: {\n          display: false,\n          text: \"...\",\n        },\n        legend: {\n          display: false,\n        },\n      },\n      responsive: true,\n      scales: {\n        x: {\n          stacked: true,\n        },\n        y: {\n          stacked: true,\n        },\n      },\n    },\n  };\n\n  const DATA_COUNT = 7;\n  const NUMBER_CFG = { count: DATA_COUNT, min: -100, max: 100 };\n\n  state.chart = new Chart(ctx, config);\n}\n\nfunction updateChart(machineData) {\n  const fakeNewData = {\n    times: [\"13:00\", \"14:00\", \"15:00\", \"16:00\"],\n    vals: [\n      genFakeDataPoint(60),\n      genFakeDataPoint(60),\n      genFakeDataPoint(60),\n      genFakeDataPoint(60),\n    ],\n  };\n  const dataToUse = machineData || fakeNewData;\n  state.chart.data.labels = dataToUse.times;\n  state.chart.data.datasets.forEach((dataset) => {\n    dataset.data = _.map(_.map(dataToUse.vals, dataset.id), formatBucket);\n  });\n  state.chart.update();\n}\n\nfunction genFakeDataPoint(total) {\n  const seeds = _.range(4).map(() => Math.random());\n  seeds[3] *= 2; // Boost OEE, let's be optimistic about these machines\n  const coeff = total / _.sum(seeds);\n  const al = Math.floor(seeds[0] * coeff);\n  const pl = Math.floor(seeds[1] * coeff);\n  const ql = Math.floor(seeds[2] * coeff);\n  const idl = total - al - pl - ql;\n  return { al, pl, ql, idl };\n}\n\nfunction handleTimeRangeChange(newVal) {\n  const [_, val, unit] = newVal.split(\" \");\n  metricsBuilder.updateTimeRange(now().subtract(val, unit), now());\n  refreshWidget();\n}\n\nfunction handleIntervalChange(newVal) {\n  const [val, unit] = newVal.split(\" \");\n  state.bucketUnit = unit;\n  metricsBuilder.updateBucketSize(val, unit);\n  refreshWidget();\n}\n\nasync function handleMachineChange(newVal) {\n  const rawMachineData = await fetchMachineData(state.machineID);\n  metricsBuilder = new MetricsBuilder(rawMachineData);\n  refreshWidget();\n}\n\n// TODO: properly handle instantiating metricsBuilder\nfunction ready() {\n  if (typeof metricsBuilder !== \"undefined\") {\n    setCurrentCalculationValues(...metricsBuilder.reportMetrics());\n  } else {\n    setTimeout(ready, 100);\n  }\n}\n\n$(\"document\").ready(initWidget);\n$(\"document\").ready(ready);\n$(\"document\").ready(() => {\n  const timeRange = document.getElementById(\"time-range\");\n  const timeInterval = document.getElementById(\"time-interval\");\n  timeRange.addEventListener(\"change\", (e) => {\n    handleTimeRangeChange(e.target.value);\n  });\n  timeInterval.addEventListener(\"change\", (e) => {\n    handleIntervalChange(e.target.value);\n  });\n});\n",
        "defaultWidth": 800,
        "defaultHeight": 500,
        "createdAt": 1647623356947,
        "createdBy": "uCq5LQBjpcXife8SE",
        "updatedAt": 1647623415678,
        "updatedBy": "uCq5LQBjpcXife8SE",
        "description": "",
        "css": "body {\n\tbackground-color: white;\n}\n\nbody {\n  font-family: \"Helvetica Neue\", \"Helvetica\", \"Arial\", sans-serif;\n}\n.headerWrapper {\n  display: flex;\n  flex-direction: row;\n  align-content: space-around;\n}\n\n.headerItem {\n  display: flex;\n  width: 25%;\n  padding-left: 2%;\n  padding-right: 2%;\n  text-align: center;\n}\n\n.keyWrapper {\n  flex-direction: column;\n  align-content: space-around;\n  margin-bottom: 10px;\n}\n.key {\n  flex-direction: column;\n  padding: 10px;\n}\n\n.keyColor {\n  display: flex;\n  height: 15px;\n  width: 15px;\n  background-color: blue;\n  margin-right: 5px;\n  border-radius: 5px;\n  border-width: 1px;\n  border-color: black;\n  border-style: solid;\n}\n\n.keyEntry {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  padding: 0px;\n  height: 25px;\n}\n\n.calculationWrapper {\n  display: flex;\n  flex-direction: column;\n}\n\n.bordered {\n  border-width: 1px;\n  border-color: black;\n  border-radius: 5px;\n  border-style: solid;\n}\n\n.left-pannel {\n  width: 70%;\n}\n\n.right-pannel {\n  width: 25%;\n  border-left: gray solid 1px;\n  padding-left: 20px;\n  padding-right: 20px;\n}\n\n.widget-container {\n  display: flex;\n  flex-direction: row;\n  justify-content: space-between;\n}\n\n.a-loss {\n  background-color: rgba(255, 206, 86, 0.5);\n  border: solid 1px rgba(255, 206, 86, 1);\n}\n\n.p-loss {\n  background-color: rgba(255, 159, 64, 0.5);\n  border: solid 1px rgba(255, 159, 64, 1);\n}\n\n.q-loss {\n  background-color: rgba(255, 99, 132, 0.5);\n  border: solid 1px rgba(255, 99, 132, 1);\n}\n\n.ideal {\n  background-color: rgba(75, 192, 192, 0.5);\n  border: solid 1px rgba(75, 192, 192, 1);\n}\n\nbutton.refresh {\n  margin-top: 15px;\n}\n\nsection {\n  flex-direction: column;\n  display: flex;\n}\n\nsection label {\n  margin-bottom: 5px;\n  margin-top: 10px;\n}\n",
        "thirdPartyLibraries": ["lodash", "jQuery", "chartJS"],
        "icon": "timeline"
    }
}