173 lines
4.2 KiB
JavaScript
173 lines
4.2 KiB
JavaScript
// drawing.js — canvas drawing module
|
||
|
||
const Drawing = (() => {
|
||
/** @type {HTMLCanvasElement} */
|
||
let canvas;
|
||
/** @type {CanvasRenderingContext2D} */
|
||
let ctx;
|
||
|
||
let isDrawing = false;
|
||
/** @type {{ x: number, y: number }[]} */
|
||
let points = [];
|
||
/** @type {{ name: string, x: number, y: number }[]} */
|
||
let cities = [];
|
||
|
||
const STROKE_COLOR = "#1a7fc4";
|
||
const STROKE_WIDTH = 2.5;
|
||
|
||
/**
|
||
* Initialise the drawing module on a canvas element.
|
||
* @param {HTMLCanvasElement} canvasEl
|
||
*/
|
||
function init(canvasEl) {
|
||
canvas = canvasEl;
|
||
ctx = canvas.getContext("2d");
|
||
|
||
canvas.addEventListener("pointerdown", onDown);
|
||
canvas.addEventListener("pointermove", onMove);
|
||
canvas.addEventListener("pointerup", onUp);
|
||
canvas.addEventListener("pointerleave", onUp);
|
||
canvas.style.touchAction = "none";
|
||
|
||
_resize();
|
||
window.addEventListener("resize", _resize);
|
||
}
|
||
|
||
/** Resize canvas to match its CSS size, accounting for device pixel ratio. */
|
||
function _resize() {
|
||
if (!canvas) return;
|
||
const { width, height } = canvas.getBoundingClientRect();
|
||
canvas.width = width * window.devicePixelRatio;
|
||
canvas.height = height * window.devicePixelRatio;
|
||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||
_redraw();
|
||
}
|
||
|
||
/**
|
||
* Convert a pointer event to canvas-local coordinates.
|
||
* @param {PointerEvent} e
|
||
* @returns {{ x: number, y: number }}
|
||
*/
|
||
function _pos(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
}
|
||
|
||
/** @param {PointerEvent} e */
|
||
function onDown(e) {
|
||
e.preventDefault();
|
||
isDrawing = true;
|
||
const p = _pos(e);
|
||
points.push(p);
|
||
ctx.beginPath();
|
||
ctx.moveTo(p.x, p.y);
|
||
}
|
||
|
||
/** @param {PointerEvent} e */
|
||
function onMove(e) {
|
||
if (!isDrawing) return;
|
||
e.preventDefault();
|
||
const p = _pos(e);
|
||
points.push(p);
|
||
ctx.lineTo(p.x, p.y);
|
||
ctx.strokeStyle = STROKE_COLOR;
|
||
ctx.lineWidth = STROKE_WIDTH;
|
||
ctx.lineJoin = "round";
|
||
ctx.lineCap = "round";
|
||
ctx.stroke();
|
||
}
|
||
|
||
/** @param {PointerEvent} e */
|
||
function onUp(e) {
|
||
if (!isDrawing) return;
|
||
isDrawing = false;
|
||
e.preventDefault();
|
||
}
|
||
|
||
/** Clear the canvas and redraw city markers. */
|
||
function clear() {
|
||
points = [];
|
||
if (!ctx) return;
|
||
const { width, height } = canvas.getBoundingClientRect();
|
||
ctx.clearRect(0, 0, width, height);
|
||
_drawCities();
|
||
}
|
||
|
||
/**
|
||
* Set city markers to display on the canvas.
|
||
* @param {{ name: string, x: number, y: number }[]} cityList - Coords in percent (0–100).
|
||
*/
|
||
function setCities(cityList) {
|
||
cities = cityList || [];
|
||
_drawCities();
|
||
}
|
||
|
||
/** Render all city markers with labels. */
|
||
function _drawCities() {
|
||
if (!ctx || !cities.length) return;
|
||
const { width, height } = canvas.getBoundingClientRect();
|
||
|
||
cities.forEach((city) => {
|
||
const cx = (city.x / 100) * width;
|
||
const cy = (city.y / 100) * height;
|
||
|
||
// Dot
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
||
ctx.fillStyle = "rgba(240,180,40,0.9)";
|
||
ctx.fill();
|
||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
|
||
// White pill label background
|
||
ctx.font = "bold 11px 'DM Sans', sans-serif";
|
||
const textW = ctx.measureText(city.name).width + 10;
|
||
const textH = 16;
|
||
const tx = cx;
|
||
const ty = cy - 14;
|
||
|
||
ctx.save();
|
||
ctx.fillStyle = "rgba(255,255,255,0.88)";
|
||
ctx.beginPath();
|
||
ctx.roundRect(tx - textW / 2, ty - textH / 2 - 1, textW, textH, 4);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
ctx.fillStyle = "#0b1f2a";
|
||
ctx.textAlign = "center";
|
||
ctx.fillText(city.name, tx, ty + 4);
|
||
});
|
||
}
|
||
|
||
/** Redraw the full stroke from stored points. */
|
||
function _redraw() {
|
||
_drawCities();
|
||
if (!points.length) return;
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = STROKE_COLOR;
|
||
ctx.lineWidth = STROKE_WIDTH;
|
||
ctx.lineJoin = "round";
|
||
ctx.lineCap = "round";
|
||
points.forEach((p, i) =>
|
||
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y),
|
||
);
|
||
ctx.stroke();
|
||
}
|
||
|
||
/**
|
||
* Return a copy of the current drawn points.
|
||
* @returns {{ x: number, y: number }[]}
|
||
*/
|
||
function getPoints() {
|
||
return [...points];
|
||
}
|
||
|
||
/** Remove event listeners and clean up. */
|
||
function destroy() {
|
||
window.removeEventListener("resize", _resize);
|
||
}
|
||
|
||
return { init, clear, setCities, getPoints, destroy };
|
||
})();
|