287 lines
6.9 KiB
JavaScript
287 lines
6.9 KiB
JavaScript
// 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 (0–100).
|
||
*/
|
||
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);
|
||
}
|