(() => { // Utilities const $ = sel => document.querySelector(sel); const $$ = sel => Array.from(document.querySelectorAll(sel)); const clamp = (v,min,max) => Math.max(min, Math.min(max, v)); const uid = (p='id') => p + '_' + Math.random().toString(36).slice(2,9); const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0; const defaultColors = [ '#e53935', '#f44336', '#ff7043', '#ffb300', '#ffee58', '#9ccc65', '#26a69a', '#26c6da', '#42a5f5', '#5c6bc0', '#7e57c2', '#ec407a', '#8d6e63', '#455a64' ]; // Elements const canvas = $('#overlayCanvas'); const ctx = canvas.getContext('2d'); const wrap = $('#canvasWrap'); const mapImg = $('#mapImage'); const colorPaletteEl = $('#colorPalette'); const colorPicker = $('#colorPicker'); const sizeSlider = $('#sizeSlider'); const strokeSlider = $('#strokeSlider'); const zoomSlider = $('#zoomSlider'); const fitBtn = $('#fitBtn'); const resetViewBtn = $('#resetViewBtn'); const exportBtn = $('#exportBtn'); const clearAllBtn = $('#clearAllBtn'); const clearMapBtn = $('#clearMapBtn'); const pasteBtn = $('#pasteBtn'); const mapFile = $('#mapFile'); const iconsFile = $('#iconsFile'); const textEditor = $('#textEditor'); const dropZone = $('#dropZone'); const iconPalette = $('#iconPalette'); // State const state = { tool: 'select', // 'select' | 'pan' | 'text' | 'arrow' | 'curve' | 'lasso' | 'placeIcon' color: defaultColors[0], strokeWidth: 4, size: 64, zoom: 1, tx: 0, ty: 0, isPanning: false, panStart: {x:0,y:0, tx:0,ty:0}, drawing: null, // current drawing object shapes: [], selectedId: null, map: { img: null, w: 0, h: 0, url: '' }, icons: [], // {id, src, name, img} placeIconId: null, dpr: window.devicePixelRatio || 1 }; // Build color palette function buildPalette(){ colorPaletteEl.innerHTML = ''; defaultColors.forEach(c => { const sw = document.createElement('button'); sw.className = 'swatch'; sw.style.background = c; sw.title = c; if(c.toLowerCase() === state.color.toLowerCase()) sw.classList.add('active'); sw.addEventListener('click', () => { state.color = c; colorPicker.value = toHex(c); $$('.swatch').forEach(el=>el.classList.remove('active')); sw.classList.add('active'); }); colorPaletteEl.appendChild(sw); }); } function toHex(c){ // Accept rgb(...) or hex; return hex if(c.startsWith('#')) return c; const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if(!m) return '#000000'; const r = (+m[1]).toString(16).padStart(2,'0'); const g = (+m[2]).toString(16).padStart(2,'0'); const b = (+m[3]).toString(16).padStart(2,'0'); return `#${r}${g}${b}`; } // Canvas sizing function resizeCanvas(){ const rect = wrap.getBoundingClientRect(); const dpr = state.dpr = window.devicePixelRatio || 1; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; render(); } window.addEventListener('resize', resizeCanvas); // Coordinate transforms function worldToScreen(pt){ return { x: (pt.x * state.zoom) + state.tx, y: (pt.y * state.zoom) + state.ty }; } function screenToWorld(pt){ return { x: (pt.x - state.tx) / state.zoom, y: (pt.y - state.ty) / state.zoom }; } // Map loading async function setMapFromFile(file){ if(!file) return; const url = URL.createObjectURL(file); await setMapFromURL(url, true); } async function setMapFromURL(url, revokeLater=false){ const img = new Image(); img.decoding = 'async'; img.onload = () => { state.map = { img: img, w: img.naturalWidth, h: img.naturalHeight, url }; mapImg.src = url; mapImg.style.width = 'auto'; mapImg.style.height = 'auto'; fitToScreen(); render(); }; img.onerror = () => { alert('Не удалось загрузить изображение.'); if(revokeLater) URL.revokeObjectURL(url); }; img.src = url; } // Fit/Reset view function fitToScreen(){ const rect = wrap.getBoundingClientRect(); if(!state.map.img){ state.zoom = 1; state.tx = rect.width/2; state.ty = rect.height/2; zoomSlider.value = Math.round(state.zoom*100); return; } const mw = state.map.w, mh = state.map.h; const sx = rect.width / mw; const sy = rect.height / mh; const scale = Math.min(sx, sy) * 0.98; // margin state.zoom = clamp(scale, 0.05, 8); state.tx = (rect.width - mw * state.zoom) / 2; state.ty = (rect.height - mh * state.zoom) / 2; zoomSlider.value = Math.round(state.zoom*100); render(); } function resetView(){ state.tx = 0; state.ty = 0; state.zoom = 1; zoomSlider.value = 100; render(); } // Icons function addIcons(files){ const tasks = []; for(const file of files){ if(!file.type.startsWith('image/')) continue; const url = URL.createObjectURL(file); const id = uid('icon'); const img = new Image(); img.decoding = 'async'; img.src = url; tasks.push(new Promise(res => { img.onload = () => { const icon = { id, src: url, name: file.name.replace(/\.[^.]+$/,''), img }; state.icons.push(icon); renderIconPalette(); res(); }; img.onerror = () => res(); })); } Promise.all(tasks); } function renderIconPalette(){ iconPalette.innerHTML = ''; state.icons.forEach(icon => { const item = document.createElement('div'); item.className = 'icon-item'; item.draggable = true; item.dataset.iconId = icon.id; const imgEl = document.createElement('img'); imgEl.src = icon.src; imgEl.alt = icon.name; item.appendChild(imgEl); const nameInput = document.createElement('input'); nameInput.className = 'name'; nameInput.value = icon.name; nameInput.title = 'Название'; nameInput.addEventListener('change', () => { icon.name = nameInput.value; }); item.appendChild(nameInput); // Place mode on click item.addEventListener('click', (e) => { if(state.placeIconId === icon.id){ state.placeIconId = null; setTool('select'); } else { state.placeIconId = icon.id; setTool('placeIcon'); } }); // Drag&Drop to canvas item.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', icon.id); e.dataTransfer.effectAllowed = 'copy'; }); iconPalette.appendChild(item); }); } // Shapes API function addShape(shape){ state.shapes.push(shape); state.selectedId = shape.id; render(); } function removeSelected(){ if(!state.selectedId) return; const idx = state.shapes.findIndex(s => s.id === state.selectedId); if(idx >= 0){ state.shapes.splice(idx,1); state.selectedId = null; render(); } } // Hit tests function hitTest(ptWorld){ // Return topmost shape id near the point (tolerance in world units) const tol = 8 / state.zoom; // 8px tolerance in screen -> world for(let i = state.shapes.length - 1; i >= 0; i--){ const s = state.shapes[i]; switch(s.type){ case 'text': { const w = measureTextWidth(s) + 12; const h = s.fontSize * 1.6; if(ptWorld.x >= s.x && ptWorld.x <= s.x + w && ptWorld.y >= s.y - h && ptWorld.y <= s.y){ return s.id; } break; } case 'icon': { const w = s.size, h = s.size; if(ptWorld.x >= s.x && ptWorld.x <= s.x + w && ptWorld.y >= s.y && ptWorld.y <= s.y + h){ return s.id; } break; } case 'arrow': { const d = pointLineDistance(ptWorld, s.start, s.end); if(d <= tol) return s.id; break; } case 'curve': { if(pointNearCubic(ptWorld, s.start, s.cp1, s.cp2, s.end, tol)) return s.id; break; } case 'lasso': { if(pointNearPolyline(ptWorld, s.points, tol)) return s.id; break; } } } return null; } function pointLineDistance(p, a, b){ const A = p.x - a.x, B = p.y - a.y, C = b.x - a.x, D = b.y - a.y; const dot = A*C + B*D; const len_sq = C*C + D*D; let t = len_sq ? dot / len_sq : -1; t = Math.max(0, Math.min(1, t)); const xx = a.x + C*t, yy = a.y + D*t; const dx = p.x - xx, dy = p.y - yy; return Math.hypot(dx, dy); } function pointNearCubic(p, p0, p1, p2, p3, tol){ // sample const steps = 32; let prev = p0; for(let i=1;i<=steps;i++){ const t = i/steps; const c = cubic(p0, p1, p2, p3, t); const d = pointLineDistance(p, prev, c); if(d <= tol) return true; prev = c; } return false; } function pointNearPolyline(p, pts, tol){ if(pts.length < 2) return false; for(let i=1;i, but for export we need content only. Here we do not draw it on canvas intentionally. // Users expect "white page" with only overlays. If you want to draw the map to canvas as well, uncomment below: // ctx.drawImage(state.map.img, 0, 0, state.map.w, state.map.h); } // Draw shapes for(const s of state.shapes){ drawShape(s); } // Draw selection if(state.selectedId){ drawSelection(state.selectedId); } ctx.restore(); } function drawGrid(){ const step = 64; // world units const w = state.map.w || 4096; const h = state.map.h || 4096; const maxX = Math.ceil((w + Math.abs(state.tx)) / state.zoom) + step; const maxY = Math.ceil((h + Math.abs(state.ty)) / state.zoom) + step; const minX = -step; const minY = -step; ctx.save(); ctx.lineWidth = 1 / state.zoom; ctx.strokeStyle = 'rgba(0,0,0,0.06)'; // Vertical lines const xStart = Math.floor(minX / step) * step; for(let x = xStart; x <= maxX; x += step){ ctx.beginPath(); ctx.moveTo(x, minY); ctx.lineTo(x, maxY); ctx.stroke(); } // Horizontal lines const yStart = Math.floor(minY / step) * step; for(let y = yStart; y <= maxY; y += step){ ctx.beginPath(); ctx.moveTo(minX, y); ctx.lineTo(maxX, y); ctx.stroke(); } ctx.restore(); } function drawShape(s){ switch(s.type){ case 'text': { ctx.save(); ctx.fillStyle = s.color || '#111'; ctx.font = `${s.fontSize || 32}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial`; ctx.textBaseline = 'top'; ctx.fillText(s.text || '', s.x, s.y); ctx.restore(); break; } case 'icon': { if(s.icon && s.icon.img){ ctx.drawImage(s.icon.img, s.x, s.y, s.size, s.size); } else { ctx.save(); ctx.fillStyle = '#ddd'; ctx.fillRect(s.x, s.y, s.size, s.size); ctx.restore(); } break; } case 'arrow': { ctx.save(); ctx.strokeStyle = s.color || '#e53935'; ctx.lineWidth = (s.strokeWidth || 4) / state.zoom; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(s.start.x, s.start.y); ctx.lineTo(s.end.x, s.end.y); ctx.stroke(); drawArrowHead(s.end, s.start, s.color || '#e53935', s.strokeWidth || 4); ctx.restore(); break; } case 'curve': { ctx.save(); ctx.strokeStyle = s.color || '#1e88e5'; ctx.lineWidth = (s.strokeWidth || 4) / state.zoom; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(s.start.x, s.start.y); ctx.bezierCurveTo(s.cp1.x, s.cp1.y, s.cp2.x, s.cp2.y, s.end.x, s.end.y); ctx.stroke(); drawArrowHead(s.end, s.cp2, s.color || '#1e88e5', s.strokeWidth || 4); ctx.restore(); break; } case 'lasso': { ctx.save(); ctx.strokeStyle = s.color || '#7e57c2'; ctx.lineWidth = (s.strokeWidth || 4) / state.zoom; ctx.lineCap = 'round'; ctx.beginPath(); for(let i=0;i x.id === id); if(!s) return; ctx.save(); ctx.strokeStyle = '#4f46e5'; ctx.lineWidth = 1 / state.zoom; let rect; switch(s.type){ case 'text': { const w = measureTextWidth(s) + 12, h = s.fontSize * 1.6; rect = {x: s.x-6, y: s.y-6, w, h}; break; } case 'icon': rect = {x: s.x, y: s.y, w: s.size, h: s.size}; break; case 'arrow': { const minx = Math.min(s.start.x, s.end.x); const miny = Math.min(s.start.y, s.end.y); const maxx = Math.max(s.start.x, s.end.x); const maxy = Math.max(s.start.y, s.end.y); rect = {x: minx, y: miny, w: (maxx-minx), h: (maxy-miny)}; break; } case 'curve': { // bounding box of cubic const pts = sampleCubic(s.start, s.cp1, s.cp2, s.end, 24); const xs = pts.map(p=>p.x), ys = pts.map(p=>p.y); const minx = Math.min(...xs), miny = Math.min(...ys); const maxx = Math.max(...xs), maxy = Math.max(...ys); rect = {x: minx, y: miny, w: maxx-minx, h: maxy-miny}; break; } case 'lasso': { const xs = s.points.map(p=>p.x), ys = s.points.map(p=>p.y); const minx = Math.min(...xs), miny = Math.min(...ys); const maxx = Math.max(...xs), maxy = Math.max(...ys); rect = {x: minx, y: miny, w: maxx-minx, h: maxy-miny}; break; } } if(rect){ ctx.setLineDash([4 / state.zoom, 4 / state.zoom]); ctx.strokeRect(rect.x, rect.y, rect.w, rect.h); ctx.setLineDash([]); } ctx.restore(); } function sampleCubic(p0,p1,p2,p3,steps){ const pts = []; for(let i=0;i<=steps;i++){ const t = i/steps; pts.push(cubic(p0,p1,p2,p3,t)); } return pts; } // Interaction function setTool(tool){ state.tool = tool; $$('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool)); wrap.classList.toggle('pan-mode', tool === 'pan'); } $$('.tool-btn').forEach(btn => btn.addEventListener('click', () => setTool(btn.dataset.tool))); // default tool setTool('select'); // Events canvas.addEventListener('pointerdown', (e) => { canvas.setPointerCapture(e.pointerId); const p = getPointer(e); const world = screenToWorld(p); if(state.tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)){ state.isPanning = true; state.panStart = {x: e.clientX, y: e.clientY, tx: state.tx, ty: state.ty}; return; } switch(state.tool){ case 'select': { const id = hitTest(world); if(id){ state.selectedId = id; state.dragging = { id, start: world, orig: cloneShapePos(id) }; } else { state.selectedId = null; } render(); break; } case 'text': { showTextEditor(world); break; } case 'placeIcon': { if(state.placeIconId){ const icon = state.icons.find(i => i.id === state.placeIconId); if(icon){ addShape({ type: 'icon', id: uid('iconObj'), x: world.x, y: world.y, size: state.size, color: state.color, icon: icon }); } } break; } case 'arrow': { state.drawing = { type: 'arrow', id: uid('arrow'), color: state.color, strokeWidth: state.strokeWidth, start: world, end: world }; addShape(state.drawing); break; } case 'curve': { state.drawing = { type: 'curve', id: uid('curve'), color: state.color, strokeWidth: state.strokeWidth, start: world, cp1: world, cp2: world, end: world }; addShape(state.drawing); break; } case 'lasso': { state.drawing = { type: 'lasso', id: uid('lasso'), color: state.color, strokeWidth: state.strokeWidth, points: [world] }; addShape(state.drawing); break; } } }); canvas.addEventListener('pointermove', (e) => { const p = getPointer(e); const world = screenToWorld(p); if(state.isPanning){ const dx = e.clientX - state.panStart.x; const dy = e.clientY - state.panStart.y; state.tx = state.panStart.tx + dx; state.ty = state.panStart.ty + dy; render(); return; } if(state.dragging){ const s = state.shapes.find(x => x.id === state.dragging.id); if(s){ const dx = world.x - state.dragging.start.x; const dy = world.y - state.dragging.start.y; moveShape(s, dx, dy); render(); } return; } if(state.drawing){ if(state.drawing.type === 'arrow'){ state.drawing.end = world; } else if(state.drawing.type === 'curve'){ // second control and end follow pointer for preview; end finalize on up state.drawing.cp2 = { x: (state.drawing.start.x + world.x)/2, y: state.drawing.start.y }; state.drawing.end = world; } else if(state.drawing.type === 'lasso'){ const pts = state.drawing.points; const last = pts[pts.length-1]; // add point if moved enough const dist = Math.hypot(world.x - last.x, world.y - last.y); if(dist > 2/state.zoom) pts.push(world); } render(); return; } }); canvas.addEventListener('pointerup', (e) => { canvas.releasePointerCapture(e.pointerId); state.isPanning = false; state.dragging = null; if(state.drawing){ // finalize if(state.drawing.type === 'lasso'){ if(state.drawing.points.length < 2){ // too short, remove state.shapes = state.shapes.filter(s => s.id !== state.drawing.id); } } if(state.drawing.type === 'curve'){ // set cp1 mirrored to end for nice S-curve const d = { x: state.drawing.end.x - state.drawing.start.x, y: state.drawing.end.y - state.drawing.start.y }; state.drawing.cp1 = { x: state.drawing.start.x + d.x/3, y: state.drawing.start.y + d.y/3 }; } state.drawing = null; render(); } }); canvas.addEventListener('dblclick', (e) => { if(state.tool === 'select'){ const p = getPointer(e); const w = screenToWorld(p); const id = hitTest(w); if(id){ const s = state.shapes.find(x => x.id === id); if(s){ if(s.type === 'text'){ showTextEditor({x: s.x, y: s.y}, s); } else if(s.type === 'icon'){ // edit size quick const nv = prompt('Размер иконки (px):', s.size); if(nv){ s.size = clamp(parseInt(nv)||s.size, 8, 1024); render(); } } } } } }); // Prevent context menu to allow right-drag pan canvas.addEventListener('contextmenu', (e) => e.preventDefault()); function getPointer(e){ const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } function moveShape(s, dx, dy){ switch(s.type){ case 'text': s.x += dx; s.y += dy; break; case 'icon': s.x += dx; s.y += dy; break; case 'arrow': s.start.x += dx; s.start.y += dy; s.end.x += dx; s.end.y += dy; break; case 'curve': s.start.x += dx; s.start.y += dy; s.cp1.x += dx; s.cp1.y += dy; s.cp2.x += dx; s.cp2.y += dy; s.end.x += dx; s.end.y += dy; break; case 'lasso': s.points = s.points.map(p => ({x:p.x+dx, y:p.y+dy})); break; } } function cloneShapePos(id){ const s = state.shapes.find(x => x.id === id); if(!s) return null; return JSON.parse(JSON.stringify(s)); } // Text editor function showTextEditor(world, existing=null){ const scr = worldToScreen(world); textEditor.style.left = (scr.x) + 'px'; textEditor.style.top = (scr.y) + 'px'; textEditor.style.color = state.color; textEditor.value = existing?.text || ''; textEditor.dataset.editId = existing?.id || ''; textEditor.style.display = 'block'; textEditor.focus(); textEditor.onkeydown = (e) => { if(e.key === 'Enter' && (e.ctrlKey || e.metaKey || !e.shiftKey)){ e.preventDefault(); commitTextEditor(); } else if(e.key === 'Escape'){ e.preventDefault(); hideTextEditor(); } }; textEditor.onblur = () => { // Commit on blur if(textEditor.style.display !== 'none') commitTextEditor(); }; } function commitTextEditor(){ const txt = textEditor.value.trim(); const editId = textEditor.dataset.editId || ''; hideTextEditor(); if(!txt) return; if(editId){ const s = state.shapes.find(x => x.id === editId); if(s && s.type === 'text'){ s.text = txt; s.color = state.color; s.fontSize = Math.max(10, parseInt(sizeSlider.value)||32); render(); } } else { // get position back from screen position (we used worldToScreen earlier; store world before hide) // We'll reconstruct using inverse transform of saved screen coords // Better: keep last world position by reading style left/top, convert to world: const left = parseFloat(textEditor.style.left), top = parseFloat(textEditor.style.top); const world = screenToWorld({x: left, y: top}); addShape({ type: 'text', id: uid('text'), x: world.x, y: world.y, text: txt, color: state.color, fontSize: Math.max(10, parseInt(sizeSlider.value)||32) }); } } function hideTextEditor(){ textEditor.style.display = 'none'; textEditor.onblur = null; textEditor.onkeydown = null; textEditor.dataset.editId = ''; } // Controls colorPicker.addEventListener('input', () => { state.color = colorPicker.value; $$('.swatch').forEach(el=>el.classList.remove('active')); }); sizeSlider.addEventListener('input', () => { state.size = parseInt(sizeSlider.value)||64; if(state.selectedId){ const s = state.shapes.find(x=>x.id===state.selectedId); if(s?.type==='icon'){ s.size = state.size; render(); } }}); strokeSlider.addEventListener('input', () => { state.strokeWidth = parseInt(strokeSlider.value)||4; }); zoomSlider.addEventListener('input', () => { const val = parseInt(zoomSlider.value)/100; const rect = wrap.getBoundingClientRect(); // Zoom around center const cx = rect.width/2, cy = rect.height/2; const wx = (cx - state.tx)/state.zoom, wy = (cy - state.ty)/state.zoom; state.zoom = clamp(val, 0.05, 8); state.tx = cx - wx*state.zoom; state.ty = cy - wy*state.zoom; render(); }); fitBtn.addEventListener('click', fitToScreen); resetViewBtn.addEventListener('click', resetView); exportBtn.addEventListener('click', exportPNG); clearAllBtn.addEventListener('click', () => { if(confirm('Удалить все объекты?')){ state.shapes = []; state.selectedId = null; render(); } }); clearMapBtn.addEventListener('click', () => { if(confirm('Удалить карту?')){ state.map = { img: null, w: 0, h: 0, url: '' }; mapImg.src = ''; render(); } }); pasteBtn.addEventListener('click', async () => { try{ const items = await navigator.clipboard.read(); for(const item of items){ for(const type of item.types){ if(type.startsWith('image/')){ const blob = await item.getType(type); await setMapFromFile(new File([blob], 'pasted.png', {type: blob.type})); return; } } } alert('В буфере нет изображения.'); }catch(err){ alert('Не удалось получить доступ к буферу обмена. Попробуйте Ctrl/Cmd+V или разрешите доступ.'); } }); // File inputs mapFile.addEventListener('change', async (e) => { const file = e.target.files[0]; if(file) await setMapFromFile(file); mapFile.value = ''; }); iconsFile.addEventListener('change', (e) => { if(e.target.files?.length) addIcons(e.target.files); iconsFile.value = ''; }); // Drag&Drop on wrapper (map or icons) ;['dragenter','dragover'].forEach(evt => { wrap.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.style.display = 'flex'; }); }); ;['dragleave','drop'].forEach(evt => { wrap.addEventListener(evt, (e) => { e.preventDefault(); e.stopPropagation(); dropZone.style.display = 'none'; }); }); wrap.addEventListener('drop', async (e) => { const dt = e.dataTransfer; const iconId = dt.getData('text/plain'); if(iconId){ // Place icon at drop position const icon = state.icons.find(i => i.id === iconId); if(icon){ const rect = wrap.getBoundingClientRect(); const p = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const w = screenToWorld(p); addShape({ type: 'icon', id: uid('iconObj'), x: w.x, y: w.y, size: state.size, color: state.color, icon }); } return; } const file = dt.files && dt.files[0]; if(file && file.type.startsWith('image/')){ await setMapFromFile(file); } }); // Clipboard paste (image -> map) document.addEventListener('paste', async (e) => { const items = e.clipboardData?.items || []; for(const it of items){ if(it.type && it.type.startsWith('image/')){ const blob = it.getAsFile(); if(blob){ await setMapFromFile(new File([blob], 'pasted.png', {type: blob.type})); break; } } } }); // Keyboard document.addEventListener('keydown', (e) => { if(e.key.toLowerCase() === 'v'){ setTool('select'); } if(e.key.toLowerCase() === 'h' || e.key.toLowerCase() === 'p'){ setTool('pan'); } if(e.key.toLowerCase() === 'a'){ setTool('arrow'); } if(e.key.toLowerCase() === 'c'){ setTool('curve'); } if(e.key.toLowerCase() === 'l'){ setTool('lasso'); } if(e.key.toLowerCase() === 't'){ setTool('text'); } if((e.key === 'Delete' || e.key === 'Backspace') && !isInputFocused()){ removeSelected(); } if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's'){ e.preventDefault(); exportPNG(); } if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){ if(e.shiftKey){ /* redo - not implemented */ } else { // undo last shape if(state.shapes.length){ state.shapes.pop(); state.selectedId = null; render(); } } } }); function isInputFocused(){ const a = document.activeElement; return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable); } // Export: render map + overlays to a new canvas (white sheet) function exportPNG(){ if(!state.map.img){ alert('Сначала загрузите карту (или изображение).'); return; } const mw = state.map.w, mh = state.map.h; const scale = state.zoom; const dpr = 2; // high quality const out = document.createElement('canvas'); out.width = Math.round(mw * scale * dpr); out.height = Math.round(mh * scale * dpr); const c = out.getContext('2d'); c.scale(dpr, dpr); // White sheet c.fillStyle = '#ffffff'; c.fillRect(0,0, mw*scale, mh*scale); // Optional: draw map image to export too. If you want it, uncomment next line: // c.drawImage(state.map.img, 0, 0, mw*scale, mh*scale); // Apply pan/zoom transform c.translate(state.tx, state.ty); c.scale(scale, scale); // Draw shapes for(const s of state.shapes){ drawShapeExport(c, s, scale); } out.toBlob((blob) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'map-export.png'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1500); }, 'image/png'); } function drawShapeExport(c, s, scale){ // same as drawShape but without selection; strokeWidth already in world units; when we scaled context by 'scale', no need to divide switch(s.type){ case 'text': { c.save(); c.fillStyle = s.color || '#111'; c.font = `${s.fontSize || 32}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial`; c.textBaseline = 'top'; c.fillText(s.text || '', s.x, s.y); c.restore(); break; } case 'icon': { if(s.icon && s.icon.img) c.drawImage(s.icon.img, s.x, s.y, s.size, s.size); break; } case 'arrow': { c.save(); c.strokeStyle = s.color || '#e53935'; c.lineWidth = (s.strokeWidth || 4); c.lineCap = 'round'; c.beginPath(); c.moveTo(s.start.x, s.start.y); c.lineTo(s.end.x, s.end.y); c.stroke(); drawArrowHeadExport(c, s.end, s.start, s.color || '#e53935', s.strokeWidth || 4); c.restore(); break; } case 'curve': { c.save(); c.strokeStyle = s.color || '#1e88e5'; c.lineWidth = (s.strokeWidth || 4); c.lineCap = 'round'; c.beginPath(); c.moveTo(s.start.x, s.start.y); c.bezierCurveTo(s.cp1.x, s.cp1.y, s.cp2.x, s.cp2.y, s.end.x, s.end.y); c.stroke(); drawArrowHeadExport(c, s.end, s.cp2, s.color || '#1e88e5', s.strokeWidth || 4); c.restore(); break; } case 'lasso': { c.save(); c.strokeStyle = s.color || '#7e57c2'; c.lineWidth = (s.strokeWidth || 4); c.lineCap = 'round'; c.beginPath(); for(let i=0;i