wterm 0.3.4

Serial port to WebSocket bridge with embedded web terminal
<script>
  import Smoothie from "smoothie";
  import { SettingsIcon } from "svelte-feather-icons";

  const CHART_SPEEDS = [
    { name: "Slow", value: 333 },
    { name: "Moderate", value: 100 },
    { name: "Normal", value: 50 },
    { name: "Quick", value: 25 },
    { name: "Fast", value: 10 },
    { name: "Brief", value: 5 },
  ];

  const LEGEND = {
    colors: [
      "#ff3860",
      "#22d15f",
      "#1f9cee",
      "#f5f5f5",
      "#ffdd56",
      "#ea80fc",
      "#ff5722",
      "#69f0ae",
      "#b388ff",
      "#76ff03",
    ],
    lineWidth: 2,
  };

  const DefaultExtractRegExp = "[.\\d]+";
  let pointExtractRegEx = new RegExp(DefaultExtractRegExp, "gi");
  let regExpError = null;
  let decoder = new TextDecoder("utf-8");
  let accumulator = "";
  let settingsOpen = false;
  let speed = 2;

  const { TimeSeries, SmoothieChart } = Smoothie;
  const chart = new SmoothieChart({
    millisPerPixel: CHART_SPEEDS[speed].value,
    tooltip: true,
    grid: {
      strokeStyle: "#202020",
      borderVisible: false,
      millisPerLine: CHART_SPEEDS[speed].value * 100,
      verticalSections: 4,
      sharpLines: true,
    },
  });

  $: {
    chart.options.grid.millisPerLine = CHART_SPEEDS[speed].value * 100;
    chart.options.millisPerPixel = CHART_SPEEDS[speed].value;
  }

  const series = [];
  for (let index = 0; index < LEGEND.colors.length; index++) {
    const ds = new TimeSeries();
    series.push(ds);
    chart.addTimeSeries(ds, {
      strokeStyle: LEGEND.colors[index],
      lineWidth: LEGEND.lineWidth,
    });
  }

  export function clear() {
    for (const ds of series) {
      ds.clear();
    }
    decoder = new TextDecoder("utf-8");
    accumulator = "";
  }

  export function pushData(buffer) {
    accumulator += decoder.decode(buffer, { stream: true });
    for (const line of readLine()) {
      const points = [...line.matchAll(pointExtractRegEx)]
        .map((match) => {
          if (match.groups) {
            return Object.entries(match.groups)
              .sort((a, b) => a[0].localeCompare(b[0]))
              .map((entry) => entry[1]);
          }
          return match.length == 1 ? [match[0]] : match.slice(1);
        })
        .flatMap((x) => x)
        .map((x) => parseFloat(x))
        .filter(Boolean);
      for (let index = 0; index < points.length; index++) {
        pushPoints(index, points[index]);
      }
    }
  }

  function pushPoints(index, point) {
    const ds = series[index];
    ds && ds.append(Date.now(), point);
  }

  function* readLine() {
    while (true) {
      const index = accumulator.indexOf("\n");
      if (index < 0) {
        return;
      }
      yield accumulator.slice(0, index + 1);
      accumulator = accumulator.slice(index + 1);
    }
  }

  function openSettings() {
    settingsOpen = true;
  }

  function closeSettings() {
    settingsOpen = false;
  }

  function updateRegExp(ev) {
    try {
      pointExtractRegEx = new RegExp(
        ev.target.value || DefaultExtractRegExp,
        "gi"
      );
      regExpError = null;
    } catch (err) {
      console.log(err);
      regExpError = err.message;
    }
  }

  function graph(node) {
    chart.streamTo(node, 300);
    node.width = node.parentElement.clientWidth;
  }
</script>

<div class="graph-pane">
  <canvas height="250" width="400" use:graph />
  <button
    class="button is-small is-primary is-outlined"
    title="Graph Settings"
    on:click={openSettings}
  >
    <span class="icon is-small">
      <SettingsIcon />
    </span>
  </button>
  <div class="modal" class:is-active={settingsOpen}>
    <div class="modal-background" />
    <div class="modal-card">
      <header class="modal-card-head">
        <p class="modal-card-title">Graph Settings</p>
        <button class="delete" aria-label="close" on:click={closeSettings} />
      </header>
      <section class="modal-card-body">
        <div class="field is-horizontal">
          <div class="field-label is-small">
            <div class="label">Scroll Speed</div>
          </div>
          <div class="field-body">
            <div class="field">
              <div class="control">
                <div class="select is-fullwidth">
                  <select bind:value={speed}>
                    {#each CHART_SPEEDS as speed, i}
                      <option value={i}>{speed.name}</option>
                    {/each}
                  </select>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="field is-horizontal">
          <div class="field-label is-small">
            <div class="label">Points RegExp</div>
          </div>
          <div class="field-body">
            <div class="field">
              <div class="control">
                <input
                  class="input"
                  type="text"
                  class:is-danger={regExpError}
                  on:change={updateRegExp}
                  value={pointExtractRegEx.source}
                />
              </div>
              {#if regExpError}
                <p class="help is-danger">{regExpError}</p>
              {/if}
            </div>
          </div>
        </div>
      </section>
      <footer class="modal-card-foot" />
    </div>
  </div>
</div>

<style>
  canvas {
    border-bottom: 1px dashed #202020;
  }
  :global(div.smoothie-chart-tooltip) {
    background: #363636;
    padding: 1em;
    margin-top: 20px;
    color: white;
    font-size: 10px;
    pointer-events: none;
  }
  .graph-pane {
    position: relative;
  }
  .graph-pane .button {
    position: absolute;
    left: 8px;
    top: 8px;
  }
  .modal-background {
    background-color: rgba(10, 10, 10, 0.4);
  }
  .modal-card input {
    font-family: "Courier New", Courier, monospace;
  }
</style>