Spaces:
Running
Running
| (() => { | |
| // 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<pts.length;i++){ | |
| if(pointLineDistance(p, pts[i-1], pts[i]) <= tol) return true; | |
| } | |
| return false; | |
| } | |
| function cubic(p0,p1,p2,p3,t){ | |
| const u = 1 - t; | |
| const tt = t*t, uu = u*u, uuu = uu*u, ttt = tt*t; | |
| return { | |
| x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x, | |
| y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y | |
| }; | |
| } | |
| // Text measurement (approx) | |
| function measureTextWidth(s){ | |
| // approximate width ~ 0.6 * fontSize * chars | |
| const len = (s.text || '').length; | |
| return Math.max(10, 0.6 * s.fontSize * len); | |
| } | |
| // Rendering | |
| function render(){ | |
| const rect = wrap.getBoundingClientRect(); | |
| const dpr = state.dpr; | |
| ctx.setTransform(dpr,0,0,dpr,0,0); | |
| ctx.clearRect(0,0,rect.width,rect.height); | |
| // Draw white sheet (paper) | |
| ctx.fillStyle = '#fff'; | |
| ctx.fillRect(0,0,rect.width,rect.height); | |
| // Apply pan/zoom | |
| ctx.save(); | |
| ctx.translate(state.tx, state.ty); | |
| ctx.scale(state.zoom, state.zoom); | |
| // Draw grid for world (optional subtle) | |
| drawGrid(); | |
| // Draw map image if any | |
| if(state.map.img){ | |
| // already on <img>, 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<s.points.length;i++){ | |
| const p = s.points[i]; | |
| if(i===0) ctx.moveTo(p.x, p.y); | |
| else ctx.lineTo(p.x, p.y); | |
| } | |
| ctx.stroke(); | |
| ctx.restore(); | |
| break; | |
| } | |
| } | |
| } | |
| function drawArrowHead(to, from, color, strokeWidth){ | |
| const headLen = Math.max(6, 6 + strokeWidth); | |
| const angle = Math.atan2(to.y - from.y, to.x - from.x); | |
| ctx.save(); | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.moveTo(to.x, to.y); | |
| ctx.lineTo(to.x - headLen * Math.cos(angle - Math.PI/6), | |
| to.y - headLen * Math.sin(angle - Math.PI/6)); | |
| ctx.lineTo(to.x - headLen * Math.cos(angle + Math.PI/6), | |
| to.y - headLen * Math.sin(angle + Math.PI/6)); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawSelection(id){ | |
| const s = state.shapes.find(x => 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<s.points.length;i++){ | |
| const p = s.points[i]; | |
| if(i===0) c.moveTo(p.x, p.y); | |
| else c.lineTo(p.x, p.y); | |
| } | |
| c.stroke(); | |
| c.restore(); | |
| break; | |
| } | |
| } | |
| } | |
| function drawArrowHeadExport(c, to, from, color, strokeWidth){ | |
| const headLen = Math.max(6, 6 + strokeWidth); | |
| const angle = Math.atan2(to.y - from.y, to.x - from.x); | |
| c.save(); | |
| c.fillStyle = color; | |
| c.beginPath(); | |
| c.moveTo(to.x, to.y); | |
| c.lineTo(to.x - headLen * Math.cos(angle - Math.PI/6), | |
| to.y - headLen * Math.sin(angle - Math.PI/6)); | |
| c.lineTo(to.x - headLen * Math.cos(angle + Math.PI/6), | |
| to.y - headLen * Math.sin(angle + Math.PI/6)); | |
| c.closePath(); | |
| c.fill(); | |
| c.restore(); | |
| } | |
| // Initialization | |
| buildPalette(); | |
| resizeCanvas(); | |
| render(); | |
| })(); |