qjs-nanovg

Examples

Every file below lives in examples/ and is shown here as it ships.

curve-editor.js

Interactive polyline / bezier curve editor.

source on GitHub

// Interactive polyline / bezier curve editor.
//
// Shows a set of paths whose vertices are draggable handles. Each path has a
// type — straight line, quadratic bezier, or cubic bezier — chosen with the
// on-screen toolbar buttons (the buttons retype the *active* path, the one you
// last touched). Quad/cubic paths also expose draggable control-point handles
// (small squares) joined to their anchors by a faint control polygon.
//
// Drag a handle to move it, left-click empty canvas to append a point to the
// active path, right-click an anchor to delete it. Save to `polylines.json`
// with the 's' key or the on-screen Save button.
//
// Run: qjsm curve-editor.js

import * as glfw from 'glfw';
import * as std from 'std';
import { ALIGN_CENTER, ALIGN_LEFT, ALIGN_MIDDLE, ALIGN_RIGHT, ANTIALIAS, CreateGL3, DeleteGL3, RGB, STENCIL_STROKES } from 'nanovg';

// glfw.so does not export the mouse-button / action enums, so spell them out.
// (GLFW_MOUSE_BUTTON_LEFT = 0, GLFW_MOUSE_BUTTON_RIGHT = 1; GLFW_PRESS = 1.)
const MB_LEFT = 0;
const MB_RIGHT = 1;
const PRESS = 1;

const HANDLE_R = 6; // drawn anchor-handle radius
const HIT_R = 10; // click tolerance around a handle
const SAVE_PATH = 'polylines.json';

const TYPES = ['line', 'quad', 'cubic'];
const LABELS = { line: 'Line', quad: 'Quad', cubic: 'Cubic' };

const COL = {
  bg: RGB(24, 28, 36),
  line: RGB(90, 140, 180), // inactive path
  lineActive: RGB(120, 200, 255), // active path
  handle: RGB(40, 90, 160), // anchor
  ctrl: RGB(200, 140, 70), // bezier control point
  ctrlLine: RGB(120, 125, 135), // control polygon
  hover: RGB(90, 150, 230),
  drag: RGB(245, 200, 70),
  outline: RGB(235, 240, 248),
  text: RGB(170, 180, 195),
  btn: RGB(50, 90, 160),
  btnHot: RGB(70, 120, 200),
  btnSel: RGB(90, 160, 235),
  btnText: RGB(235, 240, 248),
  ok: RGB(120, 220, 140),
};

// Control points for the segment ending at pts[i] depend on the path type and
// are recomputed (placed along the segment) whenever the type changes.
function initSegment(p, i) {
  const a = p.pts[i - 1];
  const b = p.pts[i];
  if(p.type === 'quad') {
    b.c = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
  } else if(p.type === 'cubic') {
    b.c1 = { x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 };
    b.c2 = { x: a.x + ((b.x - a.x) * 2) / 3, y: a.y + ((b.y - a.y) * 2) / 3 };
  }
}

function setType(p, type) {
  p.type = type;
  for(let i = 1; i < p.pts.length; i++) initSegment(p, i);
}

function main() {
  glfw.Window.hint(glfw.CONTEXT_VERSION_MAJOR, 3);
  glfw.Window.hint(glfw.CONTEXT_VERSION_MINOR, 2);
  glfw.Window.hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE);
  glfw.Window.hint(glfw.OPENGL_FORWARD_COMPAT, true);
  glfw.Window.hint(glfw.RESIZABLE, false);
  glfw.Window.hint(glfw.SAMPLES, 4);

  const window = (glfw.context.current = new glfw.Window(1024, 768, 'Curve editor'));
  const { width, height } = window.size;

  const nvg = CreateGL3(STENCIL_STROKES | ANTIALIAS);

  let font = -1;
  for(let path of ['/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/TTF/DejaVuSans.ttf', '/usr/share/fonts/dejavu/DejaVuSans.ttf']) {
    if((font = nvg.CreateFont('sans', path)) >= 0) break;
  }
  const hasText = font >= 0;

  // Two seed paths: a straight polyline and a cubic bezier to show the feature.
  let polylines = [
    {
      type: 'line',
      pts: [
        { x: 180, y: 200 },
        { x: 360, y: 140 },
        { x: 520, y: 260 },
        { x: 700, y: 180 },
      ],
    },
    {
      type: 'cubic',
      pts: [
        { x: 240, y: 520 },
        { x: 430, y: 460 },
        { x: 600, y: 560 },
        { x: 800, y: 480 },
      ],
    },
  ];
  for(const p of polylines) setType(p, p.type); // seed control points

  const saveBtn = { x: width - 116, y: 16, w: 100, h: 34 };
  const typeBtns = TYPES.map((type, i) => ({ type, x: 16 + i * 84, y: 16, w: 76, h: 34 }));

  let mouse = { x: width / 2, y: height / 2 };
  let drag = null; // { pl, pt, ctrl } currently being dragged
  let active = 0; // path that left-clicks append to / buttons retype
  let savedAt = 0; // timestamp of last save, for on-screen feedback
  let running = true;

  const inRect = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;

  // Coordinate object a handle reference points at (anchor or control point).
  function ptOf(h) {
    const pt = polylines[h.pl].pts[h.pt];
    return h.ctrl ? pt[h.ctrl] : pt;
  }

  // Visit every handle. ctrl is null for anchors, or 'c' / 'c1' / 'c2' for the
  // control points of the segment ending at pts[pt]. Control points only exist
  // on curved paths and never on the first point (it has no incoming segment).
  function eachHandle(fn, anchorsOnly) {
    for(let pl = 0; pl < polylines.length; pl++) {
      const p = polylines[pl];
      for(let pt = 0; pt < p.pts.length; pt++) {
        fn(pl, pt, null);
        if(anchorsOnly || pt === 0) continue;
        if(p.type === 'quad') fn(pl, pt, 'c');
        else if(p.type === 'cubic') {
          fn(pl, pt, 'c1');
          fn(pl, pt, 'c2');
        }
      }
    }
  }

  // Nearest handle within HIT_R of (mx, my), or null.
  function hitHandle(mx, my, anchorsOnly) {
    let best = null;
    let bestD2 = HIT_R * HIT_R;
    eachHandle((pl, pt, ctrl) => {
      const o = ctrl ? polylines[pl].pts[pt][ctrl] : polylines[pl].pts[pt];
      const dx = o.x - mx;
      const dy = o.y - my;
      const d2 = dx * dx + dy * dy;
      if(d2 <= bestD2) {
        bestD2 = d2;
        best = { pl, pt, ctrl };
      }
    }, anchorsOnly);
    return best;
  }

  function save() {
    const f = std.open(SAVE_PATH, 'w');
    if(!f) {
      console.log('save: could not open', SAVE_PATH);
      return;
    }
    f.puts(JSON.stringify(polylines, null, 2));
    f.close();
    savedAt = Date.now();
    console.log(
      'saved',
      polylines.reduce((n, p) => n + p.pts.length, 0),
      'points to',
      SAVE_PATH,
    );
  }

  Object.assign(window, {
    handleCursorPos(x, y) {
      mouse = { x, y };
      if(drag) {
        const o = ptOf(drag);
        o.x = x;
        o.y = y;
      }
    },
    handleMouseButton(button, action) {
      if(action !== PRESS) {
        if(button === MB_LEFT) drag = null;
        return;
      }
      if(button === MB_LEFT) {
        if(inRect(mouse, saveBtn)) {
          save();
          return;
        }
        for(const b of typeBtns) {
          if(inRect(mouse, b)) {
            setType(polylines[active], b.type);
            return;
          }
        }
        const hit = hitHandle(mouse.x, mouse.y);
        if(hit) {
          drag = hit;
          active = hit.pl;
        } else {
          const p = polylines[active];
          p.pts.push({ x: mouse.x, y: mouse.y });
          if(p.pts.length >= 2) initSegment(p, p.pts.length - 1);
        }
      } else if(button === MB_RIGHT) {
        const hit = hitHandle(mouse.x, mouse.y, true); // anchors only
        if(hit && polylines[hit.pl].pts.length > 2) polylines[hit.pl].pts.splice(hit.pt, 1);
      }
    },
    handleKey(keyCode, scancode, action) {
      if(!action) return;
      if(keyCode === glfw.KEY_ESCAPE || keyCode === glfw.KEY_Q) running = false;
      if(keyCode === glfw.KEY_S) save();
    },
  });

  function text(x, y, str, { size = 14, color = COL.text, align = ALIGN_LEFT | ALIGN_MIDDLE } = {}) {
    if(!hasText) return;
    nvg.FontFace('sans');
    nvg.FontSize(size);
    nvg.FillColor(color);
    nvg.TextAlign(align);
    nvg.Text(x, y, str);
  }

  // Trace a path's outline (line / quad / cubic) into the current NanoVG path.
  function tracePath(p) {
    const pts = p.pts;
    nvg.MoveTo(pts[0].x, pts[0].y);
    for(let i = 1; i < pts.length; i++) {
      const b = pts[i];
      if(p.type === 'quad') nvg.QuadTo(b.c.x, b.c.y, b.x, b.y);
      else if(p.type === 'cubic') nvg.BezierTo(b.c1.x, b.c1.y, b.c2.x, b.c2.y, b.x, b.y);
      else nvg.LineTo(b.x, b.y);
    }
  }

  function draw() {
    nvg.BeginPath();
    nvg.Rect(0, 0, width, height);
    nvg.FillColor(COL.bg);
    nvg.Fill();

    const hover = drag || hitHandle(mouse.x, mouse.y);

    // Path outlines.
    for(let pl = 0; pl < polylines.length; pl++) {
      const p = polylines[pl];
      if(p.pts.length < 2) continue;
      nvg.BeginPath();
      tracePath(p);
      nvg.StrokeColor(pl === active ? COL.lineActive : COL.line);
      nvg.StrokeWidth(2);
      nvg.Stroke();
    }

    // Control polygons, drawn under the handles.
    for(const p of polylines) {
      if(p.type === 'line') continue;
      nvg.BeginPath();
      for(let i = 1; i < p.pts.length; i++) {
        const a = p.pts[i - 1];
        const b = p.pts[i];
        if(p.type === 'quad') {
          nvg.MoveTo(a.x, a.y);
          nvg.LineTo(b.c.x, b.c.y);
          nvg.LineTo(b.x, b.y);
        } else {
          nvg.MoveTo(a.x, a.y);
          nvg.LineTo(b.c1.x, b.c1.y);
          nvg.MoveTo(b.x, b.y);
          nvg.LineTo(b.c2.x, b.c2.y);
        }
      }
      nvg.StrokeColor(COL.ctrlLine);
      nvg.StrokeWidth(1);
      nvg.Stroke();
    }

    // Handles: anchors as circles, control points as small squares.
    eachHandle((pl, pt, ctrl) => {
      const o = ctrl ? polylines[pl].pts[pt][ctrl] : polylines[pl].pts[pt];
      const isDrag = drag && drag.pl === pl && drag.pt === pt && drag.ctrl === ctrl;
      const isHover = hover && hover.pl === pl && hover.pt === pt && hover.ctrl === ctrl;
      const r = ctrl ? HANDLE_R - 1 : HANDLE_R;
      nvg.BeginPath();
      if(ctrl) nvg.Rect(o.x - r, o.y - r, r * 2, r * 2);
      else nvg.Circle(o.x, o.y, r);
      nvg.FillColor(isDrag ? COL.drag : isHover ? COL.hover : ctrl ? COL.ctrl : COL.handle);
      nvg.Fill();
      nvg.StrokeColor(COL.outline);
      nvg.StrokeWidth(1.5);
      nvg.Stroke();
    });

    // Toolbar: type buttons (left), Save (right).
    for(const b of typeBtns) {
      const sel = polylines[active].type === b.type;
      const hot = inRect(mouse, b);
      nvg.BeginPath();
      nvg.RoundedRect(b.x, b.y, b.w, b.h, 6);
      nvg.FillColor(sel ? COL.btnSel : hot ? COL.btnHot : COL.btn);
      nvg.Fill();
      text(b.x + b.w / 2, b.y + b.h / 2, LABELS[b.type], { color: COL.btnText, align: ALIGN_CENTER | ALIGN_MIDDLE });
    }
    text(typeBtns[2].x + typeBtns[2].w + 16, typeBtns[0].y + typeBtns[0].h / 2, 'active: path ' + (active + 1), { size: 12 });

    const hot = inRect(mouse, saveBtn);
    nvg.BeginPath();
    nvg.RoundedRect(saveBtn.x, saveBtn.y, saveBtn.w, saveBtn.h, 6);
    nvg.FillColor(hot ? COL.btnHot : COL.btn);
    nvg.Fill();
    text(saveBtn.x + saveBtn.w / 2, saveBtn.y + saveBtn.h / 2, 'Save (s)', {
      color: COL.btnText,
      align: ALIGN_CENTER | ALIGN_MIDDLE,
    });

    if(Date.now() - savedAt < 1200) text(saveBtn.x - 12, saveBtn.y + saveBtn.h / 2, 'saved ✓', { color: COL.ok, align: ALIGN_RIGHT | ALIGN_MIDDLE });

    text(16, height - 18, 'drag handle: move    left-click: add point    right-click: delete    Line/Quad/Cubic: retype active path    s: save    q/esc: quit', { size: 12 });
  }

  while((running &&= !window.shouldClose)) {
    nvg.BeginFrame(width, height, 1);
    draw();
    nvg.EndFrame();

    window.swapBuffers();
    glfw.poll();
  }

  DeleteGL3(nvg);
  window.destroy();
}

main();

forex.js

Realtime Forex candlestick chart.

source on GitHub

// Realtime Forex candlestick chart.
//
// There is no live data feed here, so a streaming price is simulated with a
// random walk; ticks are aggregated into OHLC candles (one per `CANDLE_MS`),
// exactly as a real feed would be. Run with: qjsm test-graph.js
//
// Press 's' to save the current frame to a PPM image (forex-<timestamp>.ppm).

import * as glfw from 'glfw';
import * as std from 'std';
import { ALIGN_BASELINE, ALIGN_BOTTOM, ALIGN_LEFT, ALIGN_MIDDLE, ALIGN_RIGHT, ALIGN_TOP, ANTIALIAS, CreateGL3, DeleteGL3, ReadPixels, RGB, RGBA, STENCIL_STROKES } from 'nanovg';

const SYMBOL = 'EUR/USD';
const CANDLE_MS = 1000; // duration of one candle
const MAX_CANDLES = 80; // visible history
const TICK_VOL = 0.0006; // per-tick price step magnitude

// Layout margins (px) around the plot area.
const M = { top: 64, right: 84, bottom: 28, left: 12 };

// Palette
const COL = {
  bg: RGB(18, 22, 30),
  grid: RGBA(255, 255, 255, 18),
  axis: RGBA(255, 255, 255, 40),
  text: RGB(150, 162, 176),
  title: RGB(225, 230, 238),
  bull: RGB(38, 166, 154),
  bear: RGB(239, 83, 80),
  last: RGB(245, 200, 70),
};

function newCandle(open) {
  return { open, high: open, low: open, close: open };
}

function tick(candle, price) {
  candle.close = price;
  if(price > candle.high) candle.high = price;
  if(price < candle.low) candle.low = price;
}

function main() {
  glfw.Window.hint(glfw.CONTEXT_VERSION_MAJOR, 3);
  glfw.Window.hint(glfw.CONTEXT_VERSION_MINOR, 2);
  glfw.Window.hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE);
  glfw.Window.hint(glfw.OPENGL_FORWARD_COMPAT, true);
  glfw.Window.hint(glfw.RESIZABLE, false);
  glfw.Window.hint(glfw.SAMPLES, 4);

  const window = (glfw.context.current = new glfw.Window(1024, 640, `Forex — ${SYMBOL}`));
  const { width, height } = window.size;

  const nvg = CreateGL3(STENCIL_STROKES | ANTIALIAS);

  // Load a font for the axis/HUD labels; degrade gracefully if unavailable.
  let font = -1;
  for(let path of ['/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/TTF/DejaVuSans.ttf', '/usr/share/fonts/dejavu/DejaVuSans.ttf']) {
    if((font = nvg.CreateFont('sans', path)) >= 0) break;
  }
  const hasText = font >= 0;

  // ---- Simulated price feed -------------------------------------------------
  let price = 1.1;
  const candles = [];

  // Seed some history so the chart starts populated.
  for(let k = 0; k < MAX_CANDLES * 0.5; k++) {
    const c = newCandle(price);
    for(let t = 0; t < 20; t++) {
      price += (Math.random() - 0.5) * TICK_VOL;
      tick(c, price);
    }
    candles.push(c);
  }

  let live = newCandle(price);
  candles.push(live);
  let candleStart = Date.now();

  let paused = false;
  let wantShot = false; // set on 's', consumed in the render loop

  Object.assign(window, {
    handleKey(keyCode, scancode, action, mods) {
      if(!action) return;
      if(keyCode === glfw.KEY_ESCAPE || keyCode === glfw.KEY_Q) running = false;
      if(keyCode === glfw.KEY_S) wantShot = true;
    },
    handleCharMods(charCode) {
      if(String.fromCodePoint(charCode) === ' ') paused = !paused;
    },
  });

  let running = true;

  function text(x, y, str, { size = 13, color = COL.text, align = ALIGN_LEFT | ALIGN_BASELINE } = {}) {
    if(!hasText) return;
    nvg.FontFace('sans');
    nvg.FontSize(size);
    nvg.FillColor(color);
    nvg.TextAlign(align);
    nvg.Text(x, y, str);
  }

  // Grab the rendered frame and write it as a binary PPM (P6). ReadPixels
  // returns RGBA bytes with a bottom-left origin, so rows are flipped here.
  function saveScreenshot() {
    const rgba = new Uint8Array(ReadPixels(width, height));
    const body = new Uint8Array(width * height * 3);

    for(let y = 0; y < height; y++) {
      const srcRow = (height - 1 - y) * width;
      const dstRow = y * width;
      for(let x = 0; x < width; x++) {
        const s = (srcRow + x) * 4;
        const d = (dstRow + x) * 3;
        body[d] = rgba[s];
        body[d + 1] = rgba[s + 1];
        body[d + 2] = rgba[s + 2];
      }
    }

    const name = `forex-${Date.now()}.ppm`;
    const f = std.open(name, 'wb');
    if(!f) {
      console.log('screenshot: could not open', name);
      return;
    }
    f.puts(`P6\n${width} ${height}\n255\n`);
    f.write(body.buffer, 0, body.byteLength);
    f.close();
    console.log('screenshot saved:', name);
  }

  function draw() {
    // Clear by painting the background (no glClear binding is exposed).
    nvg.BeginPath();
    nvg.Rect(0, 0, width, height);
    nvg.FillColor(COL.bg);
    nvg.Fill();

    const plotW = width - M.left - M.right;
    const plotH = height - M.top - M.bottom;
    const slotW = plotW / MAX_CANDLES;
    const bodyW = Math.max(2, slotW * 0.6);

    // Price range over the visible candles, with padding.
    let lo = Infinity,
      hi = -Infinity;
    for(let c of candles) {
      if(c.low < lo) lo = c.low;
      if(c.high > hi) hi = c.high;
    }
    const pad = (hi - lo) * 0.08 || 0.0005;
    lo -= pad;
    hi += pad;

    const yOf = p => M.top + (1 - (p - lo) / (hi - lo)) * plotH;
    const xOf = i => M.left + (i + 0.5) * slotW;

    // Horizontal grid + price scale (right axis).
    const divs = 5;
    for(let k = 0; k <= divs; k++) {
      const p = lo + ((hi - lo) * k) / divs;
      const y = yOf(p);
      nvg.BeginPath();
      nvg.MoveTo(M.left, y);
      nvg.LineTo(M.left + plotW, y);
      nvg.StrokeColor(COL.grid);
      nvg.StrokeWidth(1);
      nvg.Stroke();
      text(M.left + plotW + 6, y, p.toFixed(5), { align: ALIGN_LEFT | ALIGN_MIDDLE });
    }

    // Candles.
    for(let i = 0; i < candles.length; i++) {
      const c = candles[i];
      const x = xOf(i);
      const bull = c.close >= c.open;
      const color = bull ? COL.bull : COL.bear;

      // Wick (high -> low).
      nvg.BeginPath();
      nvg.MoveTo(x, yOf(c.high));
      nvg.LineTo(x, yOf(c.low));
      nvg.StrokeColor(color);
      nvg.StrokeWidth(1);
      nvg.Stroke();

      // Body (open <-> close).
      const yo = yOf(c.open);
      const yc = yOf(c.close);
      const top = Math.min(yo, yc);
      const h = Math.max(1, Math.abs(yc - yo));
      nvg.BeginPath();
      nvg.Rect(x - bodyW / 2, top, bodyW, h);
      nvg.FillColor(color);
      nvg.Fill();
    }

    // Last-price marker line + label.
    const lastY = yOf(price);
    nvg.BeginPath();
    nvg.MoveTo(M.left, lastY);
    nvg.LineTo(M.left + plotW, lastY);
    nvg.StrokeColor(COL.last);
    nvg.StrokeWidth(1);
    nvg.Stroke();

    nvg.BeginPath();
    nvg.Rect(M.left + plotW, lastY - 9, M.right, 18);
    nvg.FillColor(COL.last);
    nvg.Fill();
    text(M.left + plotW + 6, lastY, price.toFixed(5), {
      color: RGB(20, 20, 20),
      align: ALIGN_LEFT | ALIGN_MIDDLE,
    });

    // HUD: symbol + current price + OHLC of the live candle.
    const dir = live.close >= live.open ? COL.bull : COL.bear;
    text(M.left + 4, 30, SYMBOL, { size: 22, color: COL.title });
    text(M.left + 150, 30, price.toFixed(5), { size: 22, color: dir });
    text(M.left + 4, 50, `O ${live.open.toFixed(5)}   H ${live.high.toFixed(5)}   L ${live.low.toFixed(5)}   C ${live.close.toFixed(5)}`, { size: 12 });
    text(width - M.right, 50, paused ? 'PAUSED' : 'LIVE', {
      color: paused ? COL.last : COL.bull,
      align: ALIGN_RIGHT | ALIGN_TOP,
    });
    text(M.left + 4, height - 8, 'space: pause/resume    s: screenshot    q/esc: quit', { size: 11 });
  }

  while((running &&= !window.shouldClose)) {
    if(!paused) {
      price += (Math.random() - 0.5) * TICK_VOL;
      tick(live, price);

      if(Date.now() - candleStart >= CANDLE_MS) {
        candleStart = Date.now();
        live = newCandle(price);
        candles.push(live);
        if(candles.length > MAX_CANDLES) candles.shift();
      }
    }

    nvg.BeginFrame(width, height, 1);
    draw();
    nvg.EndFrame();

    if(wantShot) {
      saveScreenshot();
      wantShot = false;
    }

    window.swapBuffers();
    glfw.poll();
  }

  DeleteGL3(nvg);
  window.destroy();
}

main();

planets.js

source on GitHub

import * as glfw from 'glfw';
import { ANTIALIAS, BindFramebuffer, CreateFramebuffer, CreateGL3, CreateImageFromHandleGL3, DegToRad, ReadPixels, RGB, RGBA, STENCIL_STROKES, Transform, TransformPoint } from 'nanovg';

const C = console.config({ compact: true });

const log = (...args) => console.log(C, ...args);

let window;

export function DrawCircle(radius, stroke = RGB(255, 255, 255), fill = RGBA(255, 0, 0, 96)) {
  nvg.BeginPath();
  nvg.StrokeColor(stroke);
  nvg.StrokeWidth(3);
  if(fill) nvg.FillColor(fill);
  nvg.Circle(0, 0, radius);
  if(fill) nvg.Fill();
  nvg.Stroke();
}

export function Clear(color = RGB(0, 0, 0)) {
  const { size } = window;

  nvg.Save();
  nvg.BeginPath();
  nvg.Rect(0, 0, ...size);
  nvg.FillColor(color);
  nvg.Fill();
  nvg.Restore();
}

function main(...args) {
  let i = 0;
  let running = true;

  glfw.Window.hint(glfw.CONTEXT_VERSION_MAJOR, 3);
  glfw.Window.hint(glfw.CONTEXT_VERSION_MINOR, 2);
  glfw.Window.hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE);
  glfw.Window.hint(glfw.OPENGL_FORWARD_COMPAT, true);
  glfw.Window.hint(glfw.RESIZABLE, false);
  glfw.Window.hint(glfw.SAMPLES, 4);

  window = glfw.context.current = new glfw.Window(1024, 768, scriptArgs[0]);

  const context = glfw.context;
  log('context', context);

  const { size } = window;
  const { width, height } = size;

  let paused = false;
  let rate = 1.0;

  Object.assign(context, {
    begin(color = RGB(255, 255, 255)) {
      Clear(color);
      nvg.BeginFrame(width, height, 1);
    },
    end() {
      nvg.EndFrame();
      window.swapBuffers();
      glfw.poll();
    },
  });

  Object.assign(window, {
    handleSize(width, height) {
      log('resized', { width, height });
    },
    handleKey(keyCode, scancode, action, mods) {
      let char = String.fromCodePoint(keyCode);

      log(`handleKey`, console.config({ numberBase: 16 }), {
        keyCode,
        char,
        scancode,
        action,
        mods,
      });

      if(action) {
        let handler = {
          [glfw.KEY_ESCAPE]: () => (running = false),
          [glfw.KEY_Q]: () => (running = false),
          [glfw.KEY_LEFT]: () => ((paused = true), (i -= rate)),
          [glfw.KEY_RIGHT]: () => ((paused = true), (i += rate)),
          [glfw.KEY_UP]: () => (i = 0),
          [glfw.KEY_DOWN]: () => (i = 0),
        }[keyCode];

        if(handler) return handler();
      }
    },
    handleCharMods(charCode, mods) {
      let char = String.fromCodePoint(charCode);
      log(`handleCharMods`, { charCode, char, mods });

      let handler = {
        ' ': () => (paused = !paused),
        '+': () => ((paused = false), (rate += 0.2)),
        '-': () => ((paused = false), (rate -= 0.2)),
      }[char];

      if(handler) handler();
    },
    handleMouseButton(button, action, mods) {
      log(`handleMouseButton`, { button, action, mods });
    },
    /*handleCursorPos(x, y) {
      log(`handleCursorPos`, { x, y });
    },*/
  });

  const nvg = CreateGL3(STENCIL_STROKES | ANTIALIAS);

  Object.assign(globalThis, { nvg, glfw, Transform, TransformPoint, RGB, RGBA });

  const images = [new Image('Architektur.png', 0), new Image('Muehleberg.png', 0)];

  function Image(path) {
    const id = nvg.CreateImage(path, 0);
    const [width, height] = nvg.ImageSize(id);
    const pattern = nvg.ImagePattern(0, 0, width, height, 0, id, 1);

    return {
      id,
      width,
      height,
      draw() {
        nvg.Save();
        nvg.Translate(-width / 2, -height / 2);
        nvg.BeginPath();
        nvg.Rect(0, 0, width, height);
        nvg.FillPaint(pattern);
        nvg.Fill();
        nvg.Restore();
      },
    };
  }

  /* let img3Id = nvg.CreateImageRGBA(width, height, 0, (imgBuf = new ArrayBuffer(width * height)));*/

  log(`main`, { images });

  /*const fb = CreateFramebuffer(nvg, width, height, 0);
  const { fbo, rbo, texture, image } = fb;
  console.log('fb', fb, { fbo, rbo, texture, image });
  BindFramebuffer(fb);
  console.log('fb', fb, { fbo, rbo, texture, image });*/

  while((running &&= !window.shouldClose)) {
    let time = +new Date() / 1000;
    let index = Math.floor((time * 360) / 30);
    let color = RGB(30, 30, 30);

    context.begin(color);

    let m = nvg.CurrentTransform();
    let t = Transform.Translate(10, 20);
    let s = Transform.Scale(3, 3);
    let p = Transform.Multiply(m, t, s);

    let center = [width / 2, height / 2];
    let phi = a => DegToRad(a % 360);

    let vec = (x, y, angle) => Transform.Scale(x, y).Rotate(angle).Translate(1, 1);

    function Planet(radius, stroke, fill, getAngle, getPrecession, [x, y]) {
      return Object.assign(this, {
        radius,
        stroke,
        fill,
        get angle() {
          return getAngle();
        },
        get precession() {
          return getPrecession();
        },
        get position() {
          return vec(x, y, this.angle);
        },
        draw() {
          nvg.Save();
          nvg.Rotate(this.precession);
          nvg.Translate(...this.position);

          DrawCircle(this.radius, this.stroke, this.fill);
          nvg.Restore();
        },
        drawOrbit() {
          nvg.Save();
          nvg.Rotate(this.precession);
          nvg.BeginPath();
          nvg.StrokeColor(stroke);
          nvg.FillColor(fill);
          nvg.StrokeWidth(1);
          nvg.Ellipse(0, 0, x, y);
          nvg.Stroke();
          nvg.Restore();
        },
        get zpos() {
          const [x, y] = this.position;

          return y;
        },
      });
    }

    let planets = [
      new Planet(
        50,
        RGB(255, 255, 224),
        RGBA(255, 192, 0, 255),
        () => phi(i + 240),
        () => phi(i * 0.01),
        [20, 10],
      ),
      new Planet(
        20,
        RGB(255, 180, 180),
        RGBA(255, 10, 0, 0.8 * 255),
        () => phi(i),
        () => phi(i * -0.01),
        [300, 100],
      ),
      new Planet(
        30,
        RGB(160, 220, 255),
        RGBA(0, 120, 255, 0.8 * 255),
        () => phi(i * 0.8 + 120),
        () => phi(i * 0.02),
        [180, 40],
      ),
      new Planet(
        10,
        RGB(220, 160, 255),
        RGBA(120, 0, 255, 0.8 * 255),
        () => phi(i * 0.4 - 120),
        () => phi(i * 0.001),
        [320, 200],
      ),
    ];

    planets.sort((a, b) => a.zpos - b.zpos);

    nvg.Save();

    nvg.Translate(...center);
    nvg.Scale(0.85, 0.85);

    for(let planet of planets) planet.drawOrbit();

    for(let planet of planets) {
      planet.draw();
    }

    nvg.Restore();

    context.end();

    if(!paused) i += rate;
  }

  /*console.log('imgBuf', imgBuf);

  let fbimg = CreateImageFromHandleGL3(nvg, fb.texture, width, height, 0);
  console.log('fbimg', fbimg);*/

  const pixels = ReadPixels(width, height);

  console.log('pixels', pixels);

  window.destroy();
}

main(...scriptArgs.slice(1));

polyline-editor.js

Interactive polyline editor.

source on GitHub

// Interactive polyline editor.
//
// Shows a set of polylines whose vertices are draggable handles. Drag a handle
// to move a point, left-click empty canvas to append a point to the active
// polyline, right-click a handle to delete it. Save the result to
// `polylines.json` with the 's' key or the on-screen Save button.
//
// Run: qjsm polyline-editor.js

import * as glfw from 'glfw';
import * as std from 'std';
import { ALIGN_CENTER, ALIGN_LEFT, ALIGN_MIDDLE, ALIGN_RIGHT, ANTIALIAS, CreateGL3, DeleteGL3, RGB, STENCIL_STROKES } from 'nanovg';

// glfw.so does not export the mouse-button / action enums, so spell them out.
// (GLFW_MOUSE_BUTTON_LEFT = 0, GLFW_MOUSE_BUTTON_RIGHT = 1; GLFW_PRESS = 1.)
const MB_LEFT = 0;
const MB_RIGHT = 1;
const PRESS = 1;

const HANDLE_R = 6; // drawn handle radius
const HIT_R = 10; // click tolerance around a handle
const SAVE_PATH = 'polylines.json';

const COL = {
  bg: RGB(24, 28, 36),
  line: RGB(120, 200, 255),
  handle: RGB(40, 90, 160),
  hover: RGB(90, 150, 230),
  drag: RGB(245, 200, 70),
  outline: RGB(235, 240, 248),
  text: RGB(170, 180, 195),
  btn: RGB(50, 90, 160),
  btnHot: RGB(70, 120, 200),
  btnText: RGB(235, 240, 248),
  ok: RGB(120, 220, 140),
};

function main() {
  glfw.Window.hint(glfw.CONTEXT_VERSION_MAJOR, 3);
  glfw.Window.hint(glfw.CONTEXT_VERSION_MINOR, 2);
  glfw.Window.hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE);
  glfw.Window.hint(glfw.OPENGL_FORWARD_COMPAT, true);
  glfw.Window.hint(glfw.RESIZABLE, false);
  glfw.Window.hint(glfw.SAMPLES, 4);

  const window = (glfw.context.current = new glfw.Window(1024, 768, 'Polyline editor'));
  const { width, height } = window.size;

  const nvg = CreateGL3(STENCIL_STROKES | ANTIALIAS);

  let font = -1;
  for(let path of ['/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/TTF/DejaVuSans.ttf', '/usr/share/fonts/dejavu/DejaVuSans.ttf']) {
    if((font = nvg.CreateFont('sans', path)) >= 0) break;
  }
  const hasText = font >= 0;

  // Two seed polylines. Each point is an editable handle.
  let polylines = [
    [
      { x: 180, y: 200 },
      { x: 360, y: 140 },
      { x: 520, y: 260 },
      { x: 700, y: 180 },
    ],
    [
      { x: 240, y: 520 },
      { x: 430, y: 460 },
      { x: 600, y: 560 },
      { x: 800, y: 480 },
    ],
  ];

  const saveBtn = { x: width - 116, y: 16, w: 100, h: 34 };

  let mouse = { x: width / 2, y: height / 2 };
  let drag = null; // { pl, pt } currently being dragged
  let active = 0; // polyline that left-clicks append to
  let savedAt = 0; // timestamp of last save, for on-screen feedback
  let running = true;

  const inRect = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;

  // Nearest handle within HIT_R of (mx, my), or null.
  function hitHandle(mx, my) {
    let best = null;
    let bestD2 = HIT_R * HIT_R;
    for(let pl = 0; pl < polylines.length; pl++) {
      const pts = polylines[pl];
      for(let pt = 0; pt < pts.length; pt++) {
        const dx = pts[pt].x - mx;
        const dy = pts[pt].y - my;
        const d2 = dx * dx + dy * dy;
        if(d2 <= bestD2) {
          bestD2 = d2;
          best = { pl, pt };
        }
      }
    }
    return best;
  }

  function save() {
    const f = std.open(SAVE_PATH, 'w');
    if(!f) {
      console.log('save: could not open', SAVE_PATH);
      return;
    }
    f.puts(JSON.stringify(polylines, null, 2));
    f.close();
    savedAt = Date.now();
    console.log(
      'saved',
      polylines.reduce((n, p) => n + p.length, 0),
      'points to',
      SAVE_PATH,
    );
  }

  Object.assign(window, {
    handleCursorPos(x, y) {
      mouse = { x, y };
      if(drag) polylines[drag.pl][drag.pt] = { x, y };
    },
    handleMouseButton(button, action) {
      if(action !== PRESS) {
        if(button === MB_LEFT) drag = null;
        return;
      }
      if(button === MB_LEFT) {
        if(inRect(mouse, saveBtn)) {
          save();
          return;
        }
        const hit = hitHandle(mouse.x, mouse.y);
        if(hit) {
          drag = hit;
          active = hit.pl;
        } else {
          polylines[active].push({ x: mouse.x, y: mouse.y });
        }
      } else if(button === MB_RIGHT) {
        const hit = hitHandle(mouse.x, mouse.y);
        if(hit && polylines[hit.pl].length > 2) polylines[hit.pl].splice(hit.pt, 1);
      }
    },
    handleKey(keyCode, scancode, action) {
      if(!action) return;
      if(keyCode === glfw.KEY_ESCAPE || keyCode === glfw.KEY_Q) running = false;
      if(keyCode === glfw.KEY_S) save();
    },
  });

  function text(x, y, str, { size = 14, color = COL.text, align = ALIGN_LEFT | ALIGN_MIDDLE } = {}) {
    if(!hasText) return;
    nvg.FontFace('sans');
    nvg.FontSize(size);
    nvg.FillColor(color);
    nvg.TextAlign(align);
    nvg.Text(x, y, str);
  }

  function draw() {
    nvg.BeginPath();
    nvg.Rect(0, 0, width, height);
    nvg.FillColor(COL.bg);
    nvg.Fill();

    const hover = drag || hitHandle(mouse.x, mouse.y);

    // Polyline segments.
    for(const pts of polylines) {
      if(pts.length < 2) continue;
      nvg.BeginPath();
      nvg.MoveTo(pts[0].x, pts[0].y);
      for(let i = 1; i < pts.length; i++) nvg.LineTo(pts[i].x, pts[i].y);
      nvg.StrokeColor(COL.line);
      nvg.StrokeWidth(2);
      nvg.Stroke();
    }

    // Handles, drawn on top of the lines.
    for(let pl = 0; pl < polylines.length; pl++) {
      const pts = polylines[pl];
      for(let pt = 0; pt < pts.length; pt++) {
        const isDrag = drag && drag.pl === pl && drag.pt === pt;
        const isHover = hover && hover.pl === pl && hover.pt === pt;
        nvg.BeginPath();
        nvg.Circle(pts[pt].x, pts[pt].y, HANDLE_R);
        nvg.FillColor(isDrag ? COL.drag : isHover ? COL.hover : COL.handle);
        nvg.Fill();
        nvg.StrokeColor(COL.outline);
        nvg.StrokeWidth(1.5);
        nvg.Stroke();
      }
    }

    // Save button.
    const hot = inRect(mouse, saveBtn);
    nvg.BeginPath();
    nvg.RoundedRect(saveBtn.x, saveBtn.y, saveBtn.w, saveBtn.h, 6);
    nvg.FillColor(hot ? COL.btnHot : COL.btn);
    nvg.Fill();
    text(saveBtn.x + saveBtn.w / 2, saveBtn.y + saveBtn.h / 2, 'Save (s)', {
      color: COL.btnText,
      align: ALIGN_CENTER | ALIGN_MIDDLE,
    });

    if(Date.now() - savedAt < 1200) text(saveBtn.x - 12, saveBtn.y + saveBtn.h / 2, 'saved ✓', { color: COL.ok, align: ALIGN_RIGHT | ALIGN_MIDDLE });

    text(16, height - 18, 'drag handle: move    left-click: add point    right-click: delete    s: save    q/esc: quit', { size: 12 });
  }

  while((running &&= !window.shouldClose)) {
    nvg.BeginFrame(width, height, 1);
    draw();
    nvg.EndFrame();

    window.swapBuffers();
    glfw.poll();
  }

  DeleteGL3(nvg);
  window.destroy();
}

main();