// 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); }