Examples
Every file below lives in examples/ and is shown here as it ships.
curve-editor.js
Interactive polyline / bezier curve editor.
// 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.
// 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
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.
// 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();