This commit is contained in:
John Gatward
2026-03-18 15:24:08 +00:00
parent 213cf5f535
commit 3cb8d5a14e
17 changed files with 1525 additions and 331 deletions

19
projects/tsp/canvas.js Normal file
View File

@@ -0,0 +1,19 @@
export function setupCanvas(gl, canvas) {
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
return {
canvas,
gl,
aspectRatio: canvas.width / canvas.height
};
}
export function resizeCanvas(gl, canvas, program) {
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.useProgram(program);
gl.uniform2f(gl.getUniformLocation(program, "u_resolution"), canvas.width, canvas.height);
}

74
projects/tsp/index.html Normal file
View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travelling Salesman Problem</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="container">
<a href="/#projects" class="back-button">← Back</a>
<header>
<h1>Travelling Salesman Problem</h1>
<p class="subtitle">An interactive route visualiser built with WebGL and WebAssembly.</p>
</header>
<section>
<p>
After writing one of my first tutorials on the Travelling Salesman Problem, I wanted to revisit it with a slightly more technical stack.
The backend is written in Zig (compiled to WebAssembly), and the rendering is done directly in WebGL instead of p5.js or Raylib.
</p>
<p>
This project was also inspired by <a href="https://www.youtube.com/@sphaerophoria">sphaerophoria</a> who started a <a href="https://www.youtube.com/watch?v=RiLjPBci6Og&list=PL980gcR1LE3L8RoIMSNBFfw4dFfS3rrsk">series</a> on creating his own map.
</p>
<div class="info-box">
<p>
Drag points around the canvas and watch the route update in real time.
</p>
</div>
</section>
<div class="canvas-container">
<div class="canvas-toolbar">
<div class="control-group" aria-label="TSP controls">
<label for="pointCountInput">Points</label>
<input id="pointCountInput" type="number" min="5" max="100" step="1" value="36" />
<button id="applyPointCountButton" class="action-button" type="button">Regenerate</button>
</div>
</div>
<div class="canvas-shell">
<canvas id="tsp-canvas" aria-label="Travelling Salesman Problem canvas">
HTML5 canvas not supported in browser
</canvas>
</div>
<div class="status-row">
<div id="pointCountStatus" class="status">36 points</div>
<p class="canvas-note">Tip: click and drag a point to recompute the route.</p>
</div>
</div>
<section>
<h3>Technical Details</h3>
<p>
The backend is written in Zig, and the route is built in two passes: a nearest-neighbour pass followed by a 2-opt local search.
</p>
<p>
Point coordinates are stored in WebAssembly memory, and the route order is recalculated whenever the layout changes.
Rendering is handled in WebGL to keep interaction smooth as the point count increases.
</p>
</section>
<footer>
<p>Built with Zig, WebGL2 and WebAssembly</p>
</footer>
</div>
<script src="./point.js"></script>
<script type="module" src="./main.js"></script>
</body>
</html>

245
projects/tsp/main.js Normal file
View File

@@ -0,0 +1,245 @@
import { initWasm } from "./wasm.js";
import { setupCanvas, resizeCanvas } from "./canvas.js";
import { createShaderProgram } from "./shaders.js";
import { draw } from "./tsp-gl.js";
import { initPoints } from "./utils.js";
const DEFAULT_POINT_COUNT = 36;
const MIN_POINTS = 4;
const MAX_POINTS = 96;
const RADIUS_SIZE = 0.006;
function clampPointCount(value) {
if (Number.isNaN(value)) {
return DEFAULT_POINT_COUNT;
}
return Math.max(MIN_POINTS, Math.min(MAX_POINTS, value));
}
function createPointFragmentShader(pointCount) {
return `
precision mediump float;
uniform vec2 u_resolution;
uniform vec2 u_centers[${pointCount}];
void main() {
vec2 position = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
vec2 scaledPosition = vec2(position.x * aspect, position.y);
float radius = ${RADIUS_SIZE};
for (int i = 0; i < ${pointCount}; i++) {
vec2 center = u_centers[i];
vec2 scaledCenter = vec2(center.x * aspect, center.y);
float distance = length(scaledPosition - scaledCenter) * (1.0 / aspect);
if (distance <= radius) {
gl_FragColor = vec4(0.5, center, 1.0);
return;
}
}
gl_FragColor = vec4(0.1, 0.1, 0.1, 1.0);
}
`;
}
function createLineFragmentShader() {
return `
precision mediump float;
void main(void) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
}
`;
}
async function run() {
const canvas = document.getElementById("tsp-canvas");
const pointCountInput = document.getElementById("pointCountInput");
const applyPointCountButton = document.getElementById("applyPointCountButton");
const pointCountStatus = document.getElementById("pointCountStatus");
if (!canvas || !pointCountInput || !applyPointCountButton || !pointCountStatus) {
return;
}
const gl = canvas.getContext("webgl2");
if (!gl) {
pointCountStatus.textContent = "WebGL2 unavailable in this browser.";
return;
}
gl.clearColor(0.1, 0.1, 0.1, 1);
const {
initialisePoints,
getPointOrder,
memory
} = await initWasm();
const vertexCode = `
attribute vec2 a_position;
void main(void) {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
setupCanvas(gl, canvas);
const positionBuffer = gl.createBuffer();
const lineBuffer = gl.createBuffer();
if (!positionBuffer || !lineBuffer) {
pointCountStatus.textContent = "Unable to initialise WebGL buffers.";
return;
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 3, -1, -1, 3];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const state = {
scene: null,
draggingIndex: -1
};
function updateStatus(pointCount) {
pointCountStatus.textContent = `${pointCount} point${pointCount === 1 ? "" : "s"}`;
}
function recalculateRoute(scene) {
const pointOrderPtr = getPointOrder(scene.pointsPtr, scene.pointCount);
scene.pointOrder = Array.from(new Uint32Array(memory.buffer, pointOrderPtr, scene.pointCount));
}
function syncPointsToMemory(scene) {
scene.pointArray = new Float32Array(memory.buffer, scene.pointsPtr, scene.pointCount * 2);
for (let i = 0; i < scene.points.length; i++) {
scene.pointArray[i * 2] = scene.points[i].cx;
scene.pointArray[i * 2 + 1] = scene.points[i].cy;
}
}
function initialiseScene(rawPointCount) {
const pointCount = clampPointCount(rawPointCount);
if (state.scene) {
gl.deleteProgram(state.scene.program);
gl.deleteProgram(state.scene.lineProgram);
}
const pointsPtr = initialisePoints(pointCount, Date.now());
const pointArray = new Float32Array(memory.buffer, pointsPtr, pointCount * 2);
const points = initPoints(pointArray);
const program = createShaderProgram(gl, vertexCode, createPointFragmentShader(pointCount));
const lineProgram = createShaderProgram(gl, vertexCode, createLineFragmentShader());
state.scene = {
pointCount,
pointsPtr,
pointArray,
points,
pointOrder: [],
program,
lineProgram
};
recalculateRoute(state.scene);
resizeCanvas(gl, canvas, program);
updateStatus(pointCount);
pointCountInput.value = String(pointCount);
}
canvas.addEventListener("mousedown", (e) => {
if (!state.scene) {
return;
}
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = 1 - ((e.clientY - rect.top) / rect.height) * 2;
for (let i = 0; i < state.scene.points.length; i++) {
const point = state.scene.points[i];
const dx = (0.5 * x + 0.5) - point.cx;
const dy = (0.5 * y + 0.5) - point.cy;
const distance = Math.hypot(dx, dy);
if (distance <= RADIUS_SIZE) {
state.draggingIndex = i;
break;
}
}
});
canvas.addEventListener("mousemove", (e) => {
if (!state.scene || state.draggingIndex === -1) {
return;
}
const rect = canvas.getBoundingClientRect();
state.scene.points[state.draggingIndex].cx = (e.clientX - rect.left) / rect.width;
state.scene.points[state.draggingIndex].cy = 1 - (e.clientY - rect.top) / rect.height;
syncPointsToMemory(state.scene);
recalculateRoute(state.scene);
});
function stopDragging() {
state.draggingIndex = -1;
}
canvas.addEventListener("mouseup", stopDragging);
canvas.addEventListener("mouseleave", stopDragging);
function applyPointCount() {
const nextPointCount = clampPointCount(Number.parseInt(pointCountInput.value, 10));
initialiseScene(nextPointCount);
}
applyPointCountButton.addEventListener("click", applyPointCount);
pointCountInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
applyPointCount();
}
});
pointCountInput.addEventListener("blur", () => {
pointCountInput.value = String(clampPointCount(Number.parseInt(pointCountInput.value, 10)));
});
window.addEventListener("resize", () => {
if (!state.scene) {
return;
}
resizeCanvas(gl, canvas, state.scene.program);
});
initialiseScene(DEFAULT_POINT_COUNT);
function render() {
if (state.scene) {
draw(
gl,
state.scene.points,
state.scene.program,
state.scene.lineProgram,
positionBuffer,
lineBuffer,
state.scene.pointOrder,
1
);
}
requestAnimationFrame(render);
}
render();
}
await run();

BIN
projects/tsp/main.wasm Executable file

Binary file not shown.

6
projects/tsp/point.js Normal file
View File

@@ -0,0 +1,6 @@
class Point {
constructor(x, y) {
this.cx = x;
this.cy = y;
}
}

20
projects/tsp/shaders.js Normal file
View File

@@ -0,0 +1,20 @@
// shaders.js
export function loadShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
export function createShaderProgram(gl, vertexCode, fragCode) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexCode);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragCode);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
return program;
}

202
projects/tsp/style.css Normal file
View File

@@ -0,0 +1,202 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: white;
color: black;
font-family: Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #ccc;
padding-bottom: 20px;
}
h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
h2 {
font-size: 1.8rem;
margin: 20px 0 15px;
}
h3 {
font-size: 1.3rem;
margin: 15px 0;
}
p {
margin-bottom: 15px;
color: #333;
}
.subtitle {
color: #666;
}
.back-button {
display: inline-block;
margin-bottom: 20px;
padding: 10px 15px;
background: #f0f0f0;
color: black;
text-decoration: none;
border: 1px solid #ccc;
border-radius: 4px;
}
.back-button:hover,
.action-button:hover {
background: #e0e0e0;
}
.info-box {
background: #f9f9f9;
border-left: 4px solid #333;
padding: 15px;
margin: 20px 0;
}
.canvas-container {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
margin: 30px 0;
}
.canvas-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.canvas-toolbar p {
margin: 0;
color: #666;
font-size: 0.95rem;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.control-group label {
color: #555;
font-size: 0.95rem;
}
.control-group input {
width: 90px;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font: inherit;
background: white;
color: black;
}
.action-button {
padding: 8px 12px;
background: #f0f0f0;
color: black;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.canvas-shell {
display: flex;
justify-content: center;
align-items: center;
min-height: 500px;
overflow: auto;
background: white;
border: 1px solid #ddd;
}
#tsp-canvas {
display: block;
margin: 0 auto;
width: 100%;
height: 560px;
max-width: 100%;
background: black;
outline: none;
image-rendering: crisp-edges;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 15px;
}
.status {
min-height: 24px;
color: #666;
margin: 0;
}
.canvas-note {
margin: 0;
color: #666;
font-size: 0.95rem;
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ccc;
font-size: 0.85rem;
color: #666;
}
@media (max-width: 768px) {
body {
padding: 16px;
}
h1 {
font-size: 2rem;
}
.canvas-container {
padding: 15px;
}
.canvas-shell {
min-height: 360px;
}
#tsp-canvas {
height: 400px;
}
}

46
projects/tsp/tsp-gl.js Normal file
View File

@@ -0,0 +1,46 @@
// tsp-gl.js
import { updateLines } from "./utils.js";
export function draw(gl, points, program, lineProgram, positionBuffer, lineBuffer, pointOrder, aspectRatio) {
gl.clear(gl.COLOR_BUFFER_BIT);
// Draw Circles
updateCircleUniforms(gl, points, program, positionBuffer);
// Draw Lines
const lineVertices = updateLines(points, pointOrder);
drawLines(gl, lineProgram, lineBuffer, lineVertices);
}
function updateCircleUniforms(gl, points, program, positionBuffer) {
const centerArray = new Float32Array(points.length * 2);
points.forEach((point, i) => {
centerArray[i * 2] = point.cx;
centerArray[i * 2 + 1] = point.cy;
});
gl.useProgram(program);
gl.uniform2fv(gl.getUniformLocation(program, "u_centers"), centerArray);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positionAttribLoc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttribLoc);
gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 3);
}
function drawLines(gl, program, buffer, lineVertices) {
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, lineVertices, gl.DYNAMIC_DRAW);
const positionAttribLoc = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttribLoc);
gl.vertexAttribPointer(positionAttribLoc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.LINES, 0, lineVertices.length / 2);
}

19
projects/tsp/utils.js Normal file
View File

@@ -0,0 +1,19 @@
// utils.js
export function initPoints(points) {
let cs = [];
for (let i = 0; i < points.length; i += 2) {
cs.push(new Point(points[i], points[i + 1]));
}
return cs;
}
export function updateLines(points, pointOrder) {
const lineVertices = [];
for (let i = 0; i < pointOrder.length - 1; i++) {
const j = pointOrder[i];
const k = pointOrder[i + 1];
lineVertices.push(points[j].cx * 2 - 1, points[j].cy * 2 - 1);
lineVertices.push(points[k].cx * 2 - 1, points[k].cy * 2 - 1);
}
return new Float32Array(lineVertices);
}

24
projects/tsp/wasm.js Normal file
View File

@@ -0,0 +1,24 @@
export async function initWasm() {
let wasmMemory = new WebAssembly.Memory({
initial: 256,
maximum: 256
});
let importObject = {
env: {
memory: wasmMemory,
}
};
const response = await fetch("./main.wasm");
const wasmModule = await WebAssembly.instantiateStreaming(response, importObject);
const { initialisePoints, getPointOrder, memory } = wasmModule.instance.exports;
return {
initialisePoints,
getPointOrder,
memory,
wasmMemory
}
}