287 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// drawing.js — canvas drawing module
/** @type {HTMLCanvasElement} */
let canvas;
/** @type {CanvasRenderingContext2D} */
let ctx;
let isDrawing = false;
/** @type {{ x: number, y: number }[]} */
let currentStroke = [];
/** @type {{ x: number, y: number }[][]} */
let strokes = [];
/** @type {{ name: string, x: number, y: number }[]} */
let cities = [];
/** @type {{ rings: { x: number, y: number }[][] } | null} */
let referenceOutline = null;
const STROKE_COLOR = "#1a7fc4";
const STROKE_WIDTH = 2.5;
/**
* Initialise the drawing module on a canvas element.
* @param {HTMLCanvasElement} canvasEl
*/
export 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);
currentStroke = [p];
strokes.push(currentStroke);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}
/** @param {PointerEvent} e */
function onMove(e) {
if (!isDrawing) return;
e.preventDefault();
const p = pos(e);
currentStroke.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;
currentStroke = [];
e.preventDefault();
}
/** Clear the canvas and redraw city markers. */
export function clear() {
strokes = [];
currentStroke = [];
referenceOutline = null;
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 (0100).
*/
export function setCities(cityList) {
cities = cityList || [];
drawCities();
}
/** Render all city markers with labels. */
function drawCities() {
if (!ctx || !cities.length) return;
const viewport = getCanvasViewport();
cities.forEach((city) => {
const { x: cx, y: cy } = toCanvasPoint(city, viewport);
// 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 all stored strokes. */
function redraw() {
drawCities();
for (const stroke of strokes) {
if (!stroke.length) continue;
ctx.beginPath();
ctx.strokeStyle = STROKE_COLOR;
ctx.lineWidth = STROKE_WIDTH;
ctx.lineJoin = "round";
ctx.lineCap = "round";
stroke.forEach((p, i) => {
if (i === 0) {
ctx.moveTo(p.x, p.y);
} else {
ctx.lineTo(p.x, p.y);
}
});
ctx.stroke();
}
drawReferenceOutline();
}
/**
* Return a flattened copy of all drawn points.
* @returns {{ x: number, y: number }[]}
*/
export function getPoints() {
return strokes.flat();
}
/**
* Return drawn points in the same 0-100 space used by country outlines.
* @returns {{ x: number, y: number }[]}
*/
export function getNormalizedPoints() {
return getNormalizedRings().flat();
}
/**
* Return drawn strokes in the same 0-100 space used by country outlines.
* @returns {{ x: number, y: number }[][]}
*/
export function getNormalizedRings() {
if (!canvas) return [];
const viewport = getCanvasViewport();
if (viewport.size === 0) return [];
return strokes
.map((stroke) => stroke.map((point) => toNormalizedPoint(point, viewport)))
.filter((stroke) => stroke.length > 1);
}
/**
* Show the reference outline over the player's drawing.
* @param {{ rings: { x: number, y: number }[][] } | null} outline
*/
export function showReferenceOutline(outline) {
referenceOutline = outline;
redraw();
}
/** Draw the current reference outline if one is set. */
function drawReferenceOutline() {
if (!ctx || !referenceOutline?.rings?.length) return;
const viewport = getCanvasViewport();
if (viewport.size === 0) return;
ctx.save();
ctx.strokeStyle = "rgba(224,92,92,0.95)";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.setLineDash([8, 5]);
for (const ring of referenceOutline.rings) {
if (!ring.length) continue;
ctx.beginPath();
ring.forEach((point, index) => {
const { x, y } = toCanvasPoint(point, viewport);
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
}
ctx.restore();
}
/**
* Return the square drawing viewport used for normalized 0-100 country space.
* @returns {{ x: number, y: number, size: number }}
*/
function getCanvasViewport() {
const { width, height } = canvas.getBoundingClientRect();
const size = Math.min(width, height);
return {
x: (width - size) / 2,
y: (height - size) / 2,
size,
};
}
/**
* @param {{ x: number, y: number }} point
* @param {{ x: number, y: number, size: number }} viewport
* @returns {{ x: number, y: number }}
*/
function toCanvasPoint(point, viewport) {
return {
x: viewport.x + (point.x / 100) * viewport.size,
y: viewport.y + (point.y / 100) * viewport.size,
};
}
/**
* @param {{ x: number, y: number }} point
* @param {{ x: number, y: number, size: number }} viewport
* @returns {{ x: number, y: number }}
*/
function toNormalizedPoint(point, viewport) {
return {
x: clampPercent(((point.x - viewport.x) / viewport.size) * 100),
y: clampPercent(((point.y - viewport.y) / viewport.size) * 100),
};
}
/** @param {number} value */
function clampPercent(value) {
return Math.max(0, Math.min(100, Math.round(value * 100) / 100));
}
/** Remove event listeners and clean up. */
export function destroy() {
window.removeEventListener("resize", resize);
}