mapannotator / script.js
Ra123a's picture
Сделай сайт который будет давать возможность загружать png карты включая из буфера обмена и делать направления войс, писать текст, и другие много тулз для управления и обозначения мест.
c3e6da9 verified
(() => {
// 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();
})();