plox 0.3.7

Turn messy logs into clean graphs. Plot fields or regex matches over time, mark events, count occurrences — all from your terminal.
Documentation
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Plox Graph</title>
    <script src="https://cdn.plot.ly/plotly-2.32.0.min.js"></script>
    <style>
      body { font-family: sans-serif; margin: 20px; }
      html, body {
        height: auto;
        overflow-y: scroll;
      }   
      .plot-panel { margin-bottom: 10px; }
      .plot-controls {
        display: flex;
        justify-content: space-between;
        align-items: center;
        font-family: sans-serif;
        margin-bottom: 5px;
      }

      .plot-title {
        font-weight: bold;
        font-size: 14px;
        white-space: nowrap;
      }

      .plot-toolbar {
        display: flex;
        gap: 4px;
      }

      .plot-toolbar button {
        background: white;
        border: 1px solid black;
        padding: 3px 6px;
        font-family: monospace;
        font-size: 12px;
        cursor: pointer;
        transition: background 0.2s ease;
      }

      .plot-toolbar button:hover {
        background: black;
        color: white;
      }
    </style>
  </head>
  <body>
    <div id="plot-container">
      
      <div class="plot-wrapper" data-panel-id="plot0">
        <div class="plot-controls">
          <div class="plot-title"></div>
          <div class="plot-toolbar">
            <button onclick="toggleScale('plot0', this)" title="Toggle Y axis scale (log or lin)">Lin</button>
            <button onclick="moveUp('plot0')" title="Move this plot up">↑</button>
            <button onclick="moveDown('plot0')" title="Move this plot down">↓</button>
            <button onclick="toggleVisibility('plot0', this)" title="Hide or show this plot">Hide</button>

          </div>
        </div>
        <div id="plot0" class="plot-panel"></div>
      </div>
      
    </div>

    <script>
      function toggleVisibility(id, buttonEl) {
        const plotEl = document.getElementById(id);
        const isVisible = plotEl.style.display !== 'none';

        plotEl.style.display = isVisible ? 'none' : '';
        buttonEl.textContent = isVisible ? 'Show' : 'Hide';
      }

      function toggleScale(id, buttonEl) {
        const current = buttonEl.textContent.toLowerCase();
        const newScale = current === 'lin' ? 'log' : 'linear';

        Plotly.relayout(id, {
          'yaxis.type': newScale,
          'yaxis.autorange': true
        }).then(() => {
          buttonEl.textContent = newScale === 'linear' ? 'Lin' : 'Log';
        }).catch(err => {
          console.error("Scale toggle failed:", err);
          alert("Could not switch scale. Ensure all Y values are > 0 for log.");
        });
      }

      function moveDown(id) {
        const wrapper = document.querySelector(`[data-panel-id="${id}"]`);
        const container = document.getElementById("plot-container");

        if (!wrapper || wrapper.parentNode !== container) return;

        const next = wrapper.nextElementSibling;
        if (!next) return;

        container.insertBefore(wrapper, next.nextElementSibling);
      }

      function moveUp(id) {
        const wrapper = document.querySelector(`[data-panel-id="${id}"]`);
        const container = document.getElementById("plot-container");

        if (!wrapper || wrapper.parentNode !== container) return;

        const prev = wrapper.previousElementSibling;
        if (prev) container.insertBefore(wrapper, prev);
      }

      function makeLayout(title, scale, xrange) {
        const layout =  {
          // title: { text: title, font: { size: 14 } },
          margin: { l: 50, r: 30, t: 30, b: 30 },
          height: 400,
          showlegend: true,
          legend: { orientation: "h", x: 0, y: -0.3 },
          hoverlabel: { namelength: -1  },

          yaxis: {
            title: "Y Axis 1",
            type: scale,
            side: "left"
          },
          yaxis2: {
            title: "Y Axis 2",
            type: scale,
            overlaying: "y",
            side: "right"
          }
        };

        if (xrange) {
          layout.xaxis = { range: xrange };
        }
        return layout;
      }

      // OMG :)
      function enableGlobalCursorLine(plotIds) {
        console.log("enableGlobalCursorLine called with plots:", plotIds);

        let currentLineX = null;

        plotIds.forEach(sourceId => {
          const sourceEl = document.getElementById(sourceId);
          if (!sourceEl) {
            console.warn(`Plot element with id "${sourceId}" not found.`);
            return;
          }

          sourceEl.addEventListener('mousemove', event => {
            const bbox = sourceEl.getBoundingClientRect();
            const xPixel = event.clientX - bbox.left;
            const yPixel = event.clientY - bbox.top;

            const layout = sourceEl._fullLayout;
            if (!layout) {
              console.warn(`_fullLayout not available for plot "${sourceId}"`);
              return;
            }

            const xaxis = layout.xaxis;
            if (!xaxis) {
              console.warn(`xaxis not found in _fullLayout for plot "${sourceId}"`);
              return;
            }

            // Check if cursor is inside the x axis range in pixels
            if (xPixel < xaxis._offset || xPixel > xaxis._offset + xaxis._length) {
              // Outside plot x axis area
              // console.log(`Cursor outside x axis range on plot "${sourceId}": xPixel=${xPixel}`);
              return;
            }

            // Convert pixel to data coordinate
            let xValue;
            try {
              // Use Plotly's internal methods to convert pixel to data
              // c2d = coordinate to data, p2c = pixel to coordinate
              if (typeof xaxis.p2c === 'function' && typeof xaxis.c2d === 'function') {
                const coord = xaxis.p2c(xPixel - xaxis._offset);
                xValue = xaxis.c2d(coord);
              } else {
                xValue = xaxis.p2d ? xaxis.p2d(xPixel - xaxis._offset) : null;
              }

            } catch (err) {
              console.error("Error converting pixel to x value:", err);
              return;
            }

            if (!xValue) {
              console.warn(`Could not determine xValue under cursor for plot "${sourceId}"`);
              return;
            }

            // For date axes, xValue is milliseconds since epoch or Date object
            // Normalize to ISO string for Plotly shape x0/x1
            let xISO;
            if (typeof xValue === "number") {
              xISO = new Date(xValue).toISOString();
            } else if (xValue instanceof Date) {
              xISO = xValue.toISOString();
            } else if (typeof xValue === "string") {
              xISO = xValue; // assume ISO string
            } else {
              console.warn(`Unexpected xValue type for plot "${sourceId}":`, xValue);
              return;
            }

            // Avoid redundant relayout calls if line is already at this position
            if (currentLineX === xISO) {
              //console.log("Line already at this position:", xISO);
              return;
            }

            currentLineX = xISO;

            // console.log(`Cursor at pixel ${xPixel}, xValue: ${xValue}, xISO: ${xISO}`);

            plotIds.forEach(id => {
              Plotly.relayout(id, {
                shapes: [{
                  type: 'line',
                  x0: xISO,
                  x1: xISO,
                  yref: 'paper',
                  y0: 0,
                  y1: 1,
                  line: { color: 'black', width: 1, dash: 'dot' },
                  layer: 'above'
                }]
              }).catch(err => {
                console.error(`Error updating shape on plot "${id}":`, err);
              });
            });
          });

          sourceEl.addEventListener('mouseleave', () => {
            // console.log(`Mouse left plot "${sourceId}", removing vertical line.`);
            plotIds.forEach(id => {
              Plotly.relayout(id, { shapes: [] }).catch(err => {
                console.error(`Error removing shapes on plot "${id}":`, err);
              });
            });
            currentLineX = null;
          });
        });
      }

      const config = {
        displaylogo: false,
        modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d'],
        responsive: true
      };

      const plotPromises = [];
      
      plotPromises.push(Plotly.newPlot(
        "plot0", 
        [{"type":"scatter","name":"value of om_module x","mode":"lines+markers","x":["2020-01-01 00:00:00.000","2020-01-01 00:00:34.000","2020-01-01 00:00:50.000","2020-01-01 00:01:22.000","2020-01-01 00:01:34.000","2020-01-01 00:02:20.000","2020-01-01 00:02:46.000","2020-01-01 00:03:10.000","2020-01-01 00:04:17.000","2020-01-01 00:04:25.000","2020-01-01 00:04:30.000","2020-01-01 00:05:38.000","2020-01-01 00:05:46.000","2020-01-01 00:05:56.000","2020-01-01 00:06:31.000","2020-01-01 00:06:38.000","2020-01-01 00:06:59.000","2020-01-01 00:08:43.000","2020-01-01 00:08:54.000","2020-01-01 00:09:46.000","2020-01-01 00:10:00.000","2020-01-01 00:13:19.000","2020-01-01 00:14:46.000","2020-01-01 00:15:57.000","2020-01-01 00:16:12.000"],"y":[1000.0,965.97,949.95,917.92,905.91,859.86,833.83,809.81,742.74,734.73,729.73,661.66,653.65,643.64,608.61,601.6,580.58,476.48,465.47,413.41,399.4,200.2,113.11,42.04,27.03],"yaxis":"y","marker":{"size":4},"line":{"width":0.5}}], 
        makeLayout(
          "", 
          "linear",
          ["2020-01-01 00:00:00","2020-01-01 00:16:12"]
        ),

        config
      ));
      

      const plots = ["plot0"];
      Promise.all(plotPromises).then(() => {
        enableGlobalCursorLine(plots);
      });

      const isSyncing = {};
      plots.forEach(id => isSyncing[id] = false);

      plots.forEach(sourceId => {
        const sourceEl = document.getElementById(sourceId);
        const others = plots.filter(id => id !== sourceId);

        sourceEl.on('plotly_relayout', e => {
          if (isSyncing[sourceId]) return;
          // console.log(`Relayout event on ${sourceId}:`, e);
          let range = null;

          if (Array.isArray(e['xaxis.range'])) {
            // Case: Reset axes
            range = e['xaxis.range'];
          } else if (e['xaxis.range[0]'] && e['xaxis.range[1]']) {
            // Case: manual zoom/pan
            range = [e['xaxis.range[0]'], e['xaxis.range[1]']];
          }

          if (range) {
            others.forEach(targetId => {
              isSyncing[targetId] = true;
              Plotly.relayout(targetId, { 'xaxis.range': range }).then(() => {
                isSyncing[targetId] = false;
              });
            });
          }

          if (e['xaxis.autorange']) {
            console.log(`Relayout event on ${sourceId}:`, e);
            others.forEach(targetId => {
              isSyncing[targetId] = true;
              Plotly.relayout(targetId, { 'xaxis.autorange': true }).then(() => {
                isSyncing[targetId] = false;
              });
            });
          }
        });

      });
    </script>
  </body>
</html>