Ra123a commited on
Commit
c3e6da9
·
verified ·
1 Parent(s): 19158f2

Сделай сайт который будет давать возможность загружать png карты включая из буфера обмена и делать направления войс, писать текст, и другие много тулз для управления и обозначения мест.

Browse files

Я также скинул иконку бронетранспорта и иконку танка для размещения.
Снизу должна быть информация что какая метка по цвету значит и иконка бронетранспорта для указания конкретного транспорта.
Это все должно рендерится в белый лист.
Как в учебниках истории, я не просил Легенда:
Просто без легенды именно иконки иконки должны быть которые я загрузил и отображаться с низу редактируемым названием, где такие же стрелки как на фото?
Изгибистые, векторные, лассо?
Где современность?
Почему у меня карта такая сплющенная я должен иметь возможность соотношение менять курсором

Files changed (4) hide show
  1. README.md +8 -5
  2. index.html +127 -19
  3. script.js +1030 -0
  4. style.css +113 -19
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Mapannotator
3
- emoji: 📚
4
- colorFrom: green
5
- colorTo: gray
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
+ title: MapAnnotator
3
+ colorFrom: purple
4
+ colorTo: red
5
+ emoji: 🐳
6
  sdk: static
7
  pinned: false
8
+ tags:
9
+ - deepsite-v3
10
  ---
11
 
12
+ # Welcome to your new DeepSite project!
13
+ This project was created with [DeepSite](https://huggingface.co/deepsite).
index.html CHANGED
@@ -1,19 +1,127 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Карта-редактор: направления, иконки, разметка</title>
6
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
7
+ <link rel="stylesheet" href="style.css" />
8
+ <!-- Optional icon fonts; not required for functionality -->
9
+ </head>
10
+ <body>
11
+ <header class="topbar">
12
+ <div class="brand">
13
+ <span class="logo">🎯</span>
14
+ <span class="title">Карта-редактор</span>
15
+ </div>
16
+
17
+ <div class="toolbar" id="toolbar">
18
+ <div class="tool-group">
19
+ <button class="tool-btn" data-tool="select" title="Выбор/Перемещение (V)" aria-label="Выбор">
20
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="m3 2l7 18l2-7l7-2z"/></svg>
21
+ </button>
22
+ <button class="tool-btn" data-tool="pan" title="Панорамирование (H или ПКМ/Средняя кнопка)" aria-label="Панорамирование">
23
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="m12 2l4 4l-4 4l-4-4zM4 12l4 4l-4 4l-4-4zM12 14l4 4l-4 4l-4-4zM20 12l4 4l-4 4l-4-4z"/></svg>
24
+ </button>
25
+ <button class="tool-btn" data-tool="text" title="Текст (T)" aria-label="Текст">
26
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M5 4v3h5.5v12h3V7H19V4z"/></svg>
27
+ </button>
28
+ <button class="tool-btn" data-tool="arrow" title="Стрелка (A)" aria-label="Стрелка">
29
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M2 12h15l-5 5l1.5 1.5L22.5 13L13.5 4L12 5.5l5 5H2z"/></svg>
30
+ </button>
31
+ <button class="tool-btn" data-tool="curve" title="Изогнутая стрелка (C)" aria-label="Кривая">
32
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3 13c4-8 14-8 18 0c-4 8-14 8-18 0Zm3 4h3v3h3v-6h-3v3h-3z"/></svg>
33
+ </button>
34
+ <button class="tool-btn" data-tool="lasso" title="Лasso/Scribble (L)" aria-label="Лasso">
35
+ <svg width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M9 3l1.5 4.5L15 9l-4.5 1.5L9 15l1.5-4.5L6 9l4.5-1.5L9 3z"/></svg>
36
+ </button>
37
+ </div>
38
+ <div class="tool-group">
39
+ <label class="btn">
40
+ Загрузить карту
41
+ <input type="file" id="mapFile" accept="image/*" />
42
+ </label>
43
+ <button id="pasteBtn" class="btn" title="Вставить из буфера (Ctrl/Cmd+V)">Вставить</button>
44
+ <button id="clearMapBtn" class="btn" title="Очистить карту">Очистить</button>
45
+ </div>
46
+ <div class="tool-group">
47
+ <label class="btn">
48
+ Загрузить иконки
49
+ <input type="file" id="iconsFile" accept="image/*" multiple />
50
+ </label>
51
+ </div>
52
+ <div class="tool-group color-group">
53
+ <span class="color-label">Цвет:</span>
54
+ <div class="colors" id="colorPalette" aria-label="Цвет">
55
+ <!-- colors rendered by script -->
56
+ </div>
57
+ <input type="color" id="colorPicker" value="#e53935" title="Выбрать цвет" />
58
+ </div>
59
+ <div class="tool-group">
60
+ <label class="slider">
61
+ Размер
62
+ <input type="range" id="sizeSlider" min="16" max="256" value="64" />
63
+ </label>
64
+ <label class="slider">
65
+ Ширина линии
66
+ <input type="range" id="strokeSlider" min="1" max="20" value="4" />
67
+ </label>
68
+ </div>
69
+ <div class="tool-group">
70
+ <label class="slider">
71
+ Масштаб
72
+ <input type="range" id="zoomSlider" min="10" max="300" value="100" />
73
+ </label>
74
+ <button id="fitBtn" class="btn" title="Уместить в экран">Уместить</button>
75
+ <button id="resetViewBtn" class="btn" title="Сбросить вид">Сброс</button>
76
+ </div>
77
+ <div class="tool-group">
78
+ <button id="exportBtn" class="btn primary" title="Экспорт PNG">Экспорт PNG</button>
79
+ <button id="clearAllBtn" class="btn danger" title="Очистить всё ( Ctrl/Cmd+Delete )">Очистить всё</button>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="help">
84
+ <details>
85
+ <summary>Горячие клавиши</summary>
86
+ <ul>
87
+ <li>V — выбор/перемещение</li>
88
+ <li>H — панорамирование (или ПКМ/средняя кнопка мыши)</li>
89
+ <li>A — стрелка</li>
90
+ <li>C — изогнутая стрелка</li>
91
+ <li>L — лasso/Scribble</li>
92
+ <li>T — текст</li>
93
+ <li>Ctrl/Cmd+S — экспорт PNG</li>
94
+ <li>Ctrl/Cmd+V — вставить картинку из буфера</li>
95
+ <li>Delete — удалить выбранный объект</li>
96
+ </ul>
97
+ </details>
98
+ </div>
99
+ </header>
100
+
101
+ <main class="stage">
102
+ <div id="canvasWrap" class="canvas-wrap" tabindex="0">
103
+ <img id="mapImage" class="map-img" alt="Карта" />
104
+ <canvas id="overlayCanvas" class="overlay-canvas"></canvas>
105
+
106
+ <!-- Floating text editor -->
107
+ <textarea id="textEditor" class="text-editor" spellcheck="false" style="display:none;"></textarea>
108
+
109
+ <!-- Invisible drop zone for OS-level drops -->
110
+ <div id="dropZone" class="drop-zone">Отпустите изображение для загрузки</div>
111
+ </div>
112
+ </main>
113
+
114
+ <!-- Icon palette -->
115
+ <footer class="palette">
116
+ <div class="palette-inner">
117
+ <div class="palette-title">Иконки (перетащите на карту или кликните, затем кликните по карте для размещения)</div>
118
+ <div id="iconPalette" class="icons" aria-label="Палитра иконок">
119
+ <!-- icons rendered by script -->
120
+ </div>
121
+ </div>
122
+ </footer>
123
+
124
+ <script src="script.js"></script>
125
+ <script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
126
+ </body>
127
+ </html>
script.js ADDED
@@ -0,0 +1,1030 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (() => {
2
+ // Utilities
3
+ const $ = sel => document.querySelector(sel);
4
+ const $$ = sel => Array.from(document.querySelectorAll(sel));
5
+ const clamp = (v,min,max) => Math.max(min, Math.min(max, v));
6
+ const uid = (p='id') => p + '_' + Math.random().toString(36).slice(2,9);
7
+ const isMac = navigator.platform.toUpperCase().indexOf('MAC')>=0;
8
+
9
+ const defaultColors = [
10
+ '#e53935', '#f44336', '#ff7043', '#ffb300', '#ffee58', '#9ccc65', '#26a69a',
11
+ '#26c6da', '#42a5f5', '#5c6bc0', '#7e57c2', '#ec407a', '#8d6e63', '#455a64'
12
+ ];
13
+
14
+ // Elements
15
+ const canvas = $('#overlayCanvas');
16
+ const ctx = canvas.getContext('2d');
17
+ const wrap = $('#canvasWrap');
18
+ const mapImg = $('#mapImage');
19
+ const colorPaletteEl = $('#colorPalette');
20
+ const colorPicker = $('#colorPicker');
21
+ const sizeSlider = $('#sizeSlider');
22
+ const strokeSlider = $('#strokeSlider');
23
+ const zoomSlider = $('#zoomSlider');
24
+ const fitBtn = $('#fitBtn');
25
+ const resetViewBtn = $('#resetViewBtn');
26
+ const exportBtn = $('#exportBtn');
27
+ const clearAllBtn = $('#clearAllBtn');
28
+ const clearMapBtn = $('#clearMapBtn');
29
+ const pasteBtn = $('#pasteBtn');
30
+ const mapFile = $('#mapFile');
31
+ const iconsFile = $('#iconsFile');
32
+ const textEditor = $('#textEditor');
33
+ const dropZone = $('#dropZone');
34
+ const iconPalette = $('#iconPalette');
35
+
36
+ // State
37
+ const state = {
38
+ tool: 'select', // 'select' | 'pan' | 'text' | 'arrow' | 'curve' | 'lasso' | 'placeIcon'
39
+ color: defaultColors[0],
40
+ strokeWidth: 4,
41
+ size: 64,
42
+ zoom: 1,
43
+ tx: 0,
44
+ ty: 0,
45
+ isPanning: false,
46
+ panStart: {x:0,y:0, tx:0,ty:0},
47
+ drawing: null, // current drawing object
48
+ shapes: [],
49
+ selectedId: null,
50
+ map: { img: null, w: 0, h: 0, url: '' },
51
+ icons: [], // {id, src, name, img}
52
+ placeIconId: null,
53
+ dpr: window.devicePixelRatio || 1
54
+ };
55
+
56
+ // Build color palette
57
+ function buildPalette(){
58
+ colorPaletteEl.innerHTML = '';
59
+ defaultColors.forEach(c => {
60
+ const sw = document.createElement('button');
61
+ sw.className = 'swatch';
62
+ sw.style.background = c;
63
+ sw.title = c;
64
+ if(c.toLowerCase() === state.color.toLowerCase()) sw.classList.add('active');
65
+ sw.addEventListener('click', () => {
66
+ state.color = c;
67
+ colorPicker.value = toHex(c);
68
+ $$('.swatch').forEach(el=>el.classList.remove('active'));
69
+ sw.classList.add('active');
70
+ });
71
+ colorPaletteEl.appendChild(sw);
72
+ });
73
+ }
74
+ function toHex(c){
75
+ // Accept rgb(...) or hex; return hex
76
+ if(c.startsWith('#')) return c;
77
+ const m = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
78
+ if(!m) return '#000000';
79
+ const r = (+m[1]).toString(16).padStart(2,'0');
80
+ const g = (+m[2]).toString(16).padStart(2,'0');
81
+ const b = (+m[3]).toString(16).padStart(2,'0');
82
+ return `#${r}${g}${b}`;
83
+ }
84
+
85
+ // Canvas sizing
86
+ function resizeCanvas(){
87
+ const rect = wrap.getBoundingClientRect();
88
+ const dpr = state.dpr = window.devicePixelRatio || 1;
89
+ canvas.width = Math.max(1, Math.floor(rect.width * dpr));
90
+ canvas.height = Math.max(1, Math.floor(rect.height * dpr));
91
+ canvas.style.width = rect.width + 'px';
92
+ canvas.style.height = rect.height + 'px';
93
+ render();
94
+ }
95
+ window.addEventListener('resize', resizeCanvas);
96
+
97
+ // Coordinate transforms
98
+ function worldToScreen(pt){
99
+ return {
100
+ x: (pt.x * state.zoom) + state.tx,
101
+ y: (pt.y * state.zoom) + state.ty
102
+ };
103
+ }
104
+ function screenToWorld(pt){
105
+ return {
106
+ x: (pt.x - state.tx) / state.zoom,
107
+ y: (pt.y - state.ty) / state.zoom
108
+ };
109
+ }
110
+
111
+ // Map loading
112
+ async function setMapFromFile(file){
113
+ if(!file) return;
114
+ const url = URL.createObjectURL(file);
115
+ await setMapFromURL(url, true);
116
+ }
117
+ async function setMapFromURL(url, revokeLater=false){
118
+ const img = new Image();
119
+ img.decoding = 'async';
120
+ img.onload = () => {
121
+ state.map = { img: img, w: img.naturalWidth, h: img.naturalHeight, url };
122
+ mapImg.src = url;
123
+ mapImg.style.width = 'auto';
124
+ mapImg.style.height = 'auto';
125
+ fitToScreen();
126
+ render();
127
+ };
128
+ img.onerror = () => {
129
+ alert('Не удалось загрузить изображение.');
130
+ if(revokeLater) URL.revokeObjectURL(url);
131
+ };
132
+ img.src = url;
133
+ }
134
+
135
+ // Fit/Reset view
136
+ function fitToScreen(){
137
+ const rect = wrap.getBoundingClientRect();
138
+ if(!state.map.img){
139
+ state.zoom = 1; state.tx = rect.width/2; state.ty = rect.height/2;
140
+ zoomSlider.value = Math.round(state.zoom*100);
141
+ return;
142
+ }
143
+ const mw = state.map.w, mh = state.map.h;
144
+ const sx = rect.width / mw;
145
+ const sy = rect.height / mh;
146
+ const scale = Math.min(sx, sy) * 0.98; // margin
147
+ state.zoom = clamp(scale, 0.05, 8);
148
+ state.tx = (rect.width - mw * state.zoom) / 2;
149
+ state.ty = (rect.height - mh * state.zoom) / 2;
150
+ zoomSlider.value = Math.round(state.zoom*100);
151
+ render();
152
+ }
153
+ function resetView(){
154
+ state.tx = 0; state.ty = 0; state.zoom = 1;
155
+ zoomSlider.value = 100;
156
+ render();
157
+ }
158
+
159
+ // Icons
160
+ function addIcons(files){
161
+ const tasks = [];
162
+ for(const file of files){
163
+ if(!file.type.startsWith('image/')) continue;
164
+ const url = URL.createObjectURL(file);
165
+ const id = uid('icon');
166
+ const img = new Image();
167
+ img.decoding = 'async';
168
+ img.src = url;
169
+ tasks.push(new Promise(res => {
170
+ img.onload = () => {
171
+ const icon = { id, src: url, name: file.name.replace(/\.[^.]+$/,''), img };
172
+ state.icons.push(icon);
173
+ renderIconPalette();
174
+ res();
175
+ };
176
+ img.onerror = () => res();
177
+ }));
178
+ }
179
+ Promise.all(tasks);
180
+ }
181
+ function renderIconPalette(){
182
+ iconPalette.innerHTML = '';
183
+ state.icons.forEach(icon => {
184
+ const item = document.createElement('div');
185
+ item.className = 'icon-item';
186
+ item.draggable = true;
187
+ item.dataset.iconId = icon.id;
188
+
189
+ const imgEl = document.createElement('img');
190
+ imgEl.src = icon.src;
191
+ imgEl.alt = icon.name;
192
+ item.appendChild(imgEl);
193
+
194
+ const nameInput = document.createElement('input');
195
+ nameInput.className = 'name';
196
+ nameInput.value = icon.name;
197
+ nameInput.title = 'Название';
198
+ nameInput.addEventListener('change', () => { icon.name = nameInput.value; });
199
+ item.appendChild(nameInput);
200
+
201
+ // Place mode on click
202
+ item.addEventListener('click', (e) => {
203
+ if(state.placeIconId === icon.id){
204
+ state.placeIconId = null;
205
+ setTool('select');
206
+ } else {
207
+ state.placeIconId = icon.id;
208
+ setTool('placeIcon');
209
+ }
210
+ });
211
+
212
+ // Drag&Drop to canvas
213
+ item.addEventListener('dragstart', (e) => {
214
+ e.dataTransfer.setData('text/plain', icon.id);
215
+ e.dataTransfer.effectAllowed = 'copy';
216
+ });
217
+
218
+ iconPalette.appendChild(item);
219
+ });
220
+ }
221
+
222
+ // Shapes API
223
+ function addShape(shape){
224
+ state.shapes.push(shape);
225
+ state.selectedId = shape.id;
226
+ render();
227
+ }
228
+ function removeSelected(){
229
+ if(!state.selectedId) return;
230
+ const idx = state.shapes.findIndex(s => s.id === state.selectedId);
231
+ if(idx >= 0){
232
+ state.shapes.splice(idx,1);
233
+ state.selectedId = null;
234
+ render();
235
+ }
236
+ }
237
+
238
+ // Hit tests
239
+ function hitTest(ptWorld){
240
+ // Return topmost shape id near the point (tolerance in world units)
241
+ const tol = 8 / state.zoom; // 8px tolerance in screen -> world
242
+ for(let i = state.shapes.length - 1; i >= 0; i--){
243
+ const s = state.shapes[i];
244
+ switch(s.type){
245
+ case 'text': {
246
+ const w = measureTextWidth(s) + 12;
247
+ const h = s.fontSize * 1.6;
248
+ if(ptWorld.x >= s.x && ptWorld.x <= s.x + w &&
249
+ ptWorld.y >= s.y - h && ptWorld.y <= s.y){
250
+ return s.id;
251
+ }
252
+ break;
253
+ }
254
+ case 'icon': {
255
+ const w = s.size, h = s.size;
256
+ if(ptWorld.x >= s.x && ptWorld.x <= s.x + w &&
257
+ ptWorld.y >= s.y && ptWorld.y <= s.y + h){
258
+ return s.id;
259
+ }
260
+ break;
261
+ }
262
+ case 'arrow': {
263
+ const d = pointLineDistance(ptWorld, s.start, s.end);
264
+ if(d <= tol) return s.id;
265
+ break;
266
+ }
267
+ case 'curve': {
268
+ if(pointNearCubic(ptWorld, s.start, s.cp1, s.cp2, s.end, tol)) return s.id;
269
+ break;
270
+ }
271
+ case 'lasso': {
272
+ if(pointNearPolyline(ptWorld, s.points, tol)) return s.id;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ return null;
278
+ }
279
+
280
+ function pointLineDistance(p, a, b){
281
+ const A = p.x - a.x, B = p.y - a.y, C = b.x - a.x, D = b.y - a.y;
282
+ const dot = A*C + B*D;
283
+ const len_sq = C*C + D*D;
284
+ let t = len_sq ? dot / len_sq : -1;
285
+ t = Math.max(0, Math.min(1, t));
286
+ const xx = a.x + C*t, yy = a.y + D*t;
287
+ const dx = p.x - xx, dy = p.y - yy;
288
+ return Math.hypot(dx, dy);
289
+ }
290
+ function pointNearCubic(p, p0, p1, p2, p3, tol){
291
+ // sample
292
+ const steps = 32;
293
+ let prev = p0;
294
+ for(let i=1;i<=steps;i++){
295
+ const t = i/steps;
296
+ const c = cubic(p0, p1, p2, p3, t);
297
+ const d = pointLineDistance(p, prev, c);
298
+ if(d <= tol) return true;
299
+ prev = c;
300
+ }
301
+ return false;
302
+ }
303
+ function pointNearPolyline(p, pts, tol){
304
+ if(pts.length < 2) return false;
305
+ for(let i=1;i<pts.length;i++){
306
+ if(pointLineDistance(p, pts[i-1], pts[i]) <= tol) return true;
307
+ }
308
+ return false;
309
+ }
310
+ function cubic(p0,p1,p2,p3,t){
311
+ const u = 1 - t;
312
+ const tt = t*t, uu = u*u, uuu = uu*u, ttt = tt*t;
313
+ return {
314
+ x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x,
315
+ y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y
316
+ };
317
+ }
318
+
319
+ // Text measurement (approx)
320
+ function measureTextWidth(s){
321
+ // approximate width ~ 0.6 * fontSize * chars
322
+ const len = (s.text || '').length;
323
+ return Math.max(10, 0.6 * s.fontSize * len);
324
+ }
325
+
326
+ // Rendering
327
+ function render(){
328
+ const rect = wrap.getBoundingClientRect();
329
+ const dpr = state.dpr;
330
+ ctx.setTransform(dpr,0,0,dpr,0,0);
331
+ ctx.clearRect(0,0,rect.width,rect.height);
332
+
333
+ // Draw white sheet (paper)
334
+ ctx.fillStyle = '#fff';
335
+ ctx.fillRect(0,0,rect.width,rect.height);
336
+
337
+ // Apply pan/zoom
338
+ ctx.save();
339
+ ctx.translate(state.tx, state.ty);
340
+ ctx.scale(state.zoom, state.zoom);
341
+
342
+ // Draw grid for world (optional subtle)
343
+ drawGrid();
344
+
345
+ // Draw map image if any
346
+ if(state.map.img){
347
+ // already on <img>, but for export we need content only. Here we do not draw it on canvas intentionally.
348
+ // Users expect "white page" with only overlays. If you want to draw the map to canvas as well, uncomment below:
349
+ // ctx.drawImage(state.map.img, 0, 0, state.map.w, state.map.h);
350
+ }
351
+
352
+ // Draw shapes
353
+ for(const s of state.shapes){
354
+ drawShape(s);
355
+ }
356
+
357
+ // Draw selection
358
+ if(state.selectedId){
359
+ drawSelection(state.selectedId);
360
+ }
361
+
362
+ ctx.restore();
363
+ }
364
+
365
+ function drawGrid(){
366
+ const step = 64; // world units
367
+ const w = state.map.w || 4096;
368
+ const h = state.map.h || 4096;
369
+ const maxX = Math.ceil((w + Math.abs(state.tx)) / state.zoom) + step;
370
+ const maxY = Math.ceil((h + Math.abs(state.ty)) / state.zoom) + step;
371
+ const minX = -step;
372
+ const minY = -step;
373
+
374
+ ctx.save();
375
+ ctx.lineWidth = 1 / state.zoom;
376
+ ctx.strokeStyle = 'rgba(0,0,0,0.06)';
377
+
378
+ // Vertical lines
379
+ const xStart = Math.floor(minX / step) * step;
380
+ for(let x = xStart; x <= maxX; x += step){
381
+ ctx.beginPath();
382
+ ctx.moveTo(x, minY);
383
+ ctx.lineTo(x, maxY);
384
+ ctx.stroke();
385
+ }
386
+ // Horizontal lines
387
+ const yStart = Math.floor(minY / step) * step;
388
+ for(let y = yStart; y <= maxY; y += step){
389
+ ctx.beginPath();
390
+ ctx.moveTo(minX, y);
391
+ ctx.lineTo(maxX, y);
392
+ ctx.stroke();
393
+ }
394
+ ctx.restore();
395
+ }
396
+
397
+ function drawShape(s){
398
+ switch(s.type){
399
+ case 'text': {
400
+ ctx.save();
401
+ ctx.fillStyle = s.color || '#111';
402
+ ctx.font = `${s.fontSize || 32}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial`;
403
+ ctx.textBaseline = 'top';
404
+ ctx.fillText(s.text || '', s.x, s.y);
405
+ ctx.restore();
406
+ break;
407
+ }
408
+ case 'icon': {
409
+ if(s.icon && s.icon.img){
410
+ ctx.drawImage(s.icon.img, s.x, s.y, s.size, s.size);
411
+ } else {
412
+ ctx.save();
413
+ ctx.fillStyle = '#ddd';
414
+ ctx.fillRect(s.x, s.y, s.size, s.size);
415
+ ctx.restore();
416
+ }
417
+ break;
418
+ }
419
+ case 'arrow': {
420
+ ctx.save();
421
+ ctx.strokeStyle = s.color || '#e53935';
422
+ ctx.lineWidth = (s.strokeWidth || 4) / state.zoom;
423
+ ctx.lineCap = 'round';
424
+ ctx.beginPath();
425
+ ctx.moveTo(s.start.x, s.start.y);
426
+ ctx.lineTo(s.end.x, s.end.y);
427
+ ctx.stroke();
428
+ drawArrowHead(s.end, s.start, s.color || '#e53935', s.strokeWidth || 4);
429
+ ctx.restore();
430
+ break;
431
+ }
432
+ case 'curve': {
433
+ ctx.save();
434
+ ctx.strokeStyle = s.color || '#1e88e5';
435
+ ctx.lineWidth = (s.strokeWidth || 4) / state.zoom;
436
+ ctx.lineCap = 'round';
437
+ ctx.beginPath();
438
+ ctx.moveTo(s.start.x, s.start.y);
439
+ ctx.bezierCurveTo(s.cp1.x, s.cp1.y, s.cp2.x, s.cp2.y, s.end.x, s.end.y);
440
+ ctx.stroke();
441
+ drawArrowHead(s.end, s.cp2, s.color || '#1e88e5', s.strokeWidth || 4);
442
+ ctx.restore();
443
+ break;
444
+ }
445
+ case 'lasso': {
446
+ ctx.save();
447
+ ctx.strokeStyle = s.color || '#7e57c2';
448
+ ctx.lineWidth = (s.strokeWidth || 4) / state.zoom;
449
+ ctx.lineCap = 'round';
450
+ ctx.beginPath();
451
+ for(let i=0;i<s.points.length;i++){
452
+ const p = s.points[i];
453
+ if(i===0) ctx.moveTo(p.x, p.y);
454
+ else ctx.lineTo(p.x, p.y);
455
+ }
456
+ ctx.stroke();
457
+ ctx.restore();
458
+ break;
459
+ }
460
+ }
461
+ }
462
+
463
+ function drawArrowHead(to, from, color, strokeWidth){
464
+ const headLen = Math.max(6, 6 + strokeWidth);
465
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
466
+ ctx.save();
467
+ ctx.fillStyle = color;
468
+ ctx.beginPath();
469
+ ctx.moveTo(to.x, to.y);
470
+ ctx.lineTo(to.x - headLen * Math.cos(angle - Math.PI/6),
471
+ to.y - headLen * Math.sin(angle - Math.PI/6));
472
+ ctx.lineTo(to.x - headLen * Math.cos(angle + Math.PI/6),
473
+ to.y - headLen * Math.sin(angle + Math.PI/6));
474
+ ctx.closePath();
475
+ ctx.fill();
476
+ ctx.restore();
477
+ }
478
+
479
+ function drawSelection(id){
480
+ const s = state.shapes.find(x => x.id === id);
481
+ if(!s) return;
482
+ ctx.save();
483
+ ctx.strokeStyle = '#4f46e5';
484
+ ctx.lineWidth = 1 / state.zoom;
485
+ let rect;
486
+ switch(s.type){
487
+ case 'text': {
488
+ const w = measureTextWidth(s) + 12, h = s.fontSize * 1.6;
489
+ rect = {x: s.x-6, y: s.y-6, w, h};
490
+ break;
491
+ }
492
+ case 'icon': rect = {x: s.x, y: s.y, w: s.size, h: s.size}; break;
493
+ case 'arrow': {
494
+ const minx = Math.min(s.start.x, s.end.x);
495
+ const miny = Math.min(s.start.y, s.end.y);
496
+ const maxx = Math.max(s.start.x, s.end.x);
497
+ const maxy = Math.max(s.start.y, s.end.y);
498
+ rect = {x: minx, y: miny, w: (maxx-minx), h: (maxy-miny)};
499
+ break;
500
+ }
501
+ case 'curve': {
502
+ // bounding box of cubic
503
+ const pts = sampleCubic(s.start, s.cp1, s.cp2, s.end, 24);
504
+ const xs = pts.map(p=>p.x), ys = pts.map(p=>p.y);
505
+ const minx = Math.min(...xs), miny = Math.min(...ys);
506
+ const maxx = Math.max(...xs), maxy = Math.max(...ys);
507
+ rect = {x: minx, y: miny, w: maxx-minx, h: maxy-miny};
508
+ break;
509
+ }
510
+ case 'lasso': {
511
+ const xs = s.points.map(p=>p.x), ys = s.points.map(p=>p.y);
512
+ const minx = Math.min(...xs), miny = Math.min(...ys);
513
+ const maxx = Math.max(...xs), maxy = Math.max(...ys);
514
+ rect = {x: minx, y: miny, w: maxx-minx, h: maxy-miny};
515
+ break;
516
+ }
517
+ }
518
+ if(rect){
519
+ ctx.setLineDash([4 / state.zoom, 4 / state.zoom]);
520
+ ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
521
+ ctx.setLineDash([]);
522
+ }
523
+ ctx.restore();
524
+ }
525
+
526
+ function sampleCubic(p0,p1,p2,p3,steps){
527
+ const pts = [];
528
+ for(let i=0;i<=steps;i++){
529
+ const t = i/steps;
530
+ pts.push(cubic(p0,p1,p2,p3,t));
531
+ }
532
+ return pts;
533
+ }
534
+
535
+ // Interaction
536
+ function setTool(tool){
537
+ state.tool = tool;
538
+ $$('.tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === tool));
539
+ wrap.classList.toggle('pan-mode', tool === 'pan');
540
+ }
541
+ $$('.tool-btn').forEach(btn => btn.addEventListener('click', () => setTool(btn.dataset.tool)));
542
+ // default tool
543
+ setTool('select');
544
+
545
+ // Events
546
+ canvas.addEventListener('pointerdown', (e) => {
547
+ canvas.setPointerCapture(e.pointerId);
548
+ const p = getPointer(e);
549
+ const world = screenToWorld(p);
550
+
551
+ if(state.tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)){
552
+ state.isPanning = true;
553
+ state.panStart = {x: e.clientX, y: e.clientY, tx: state.tx, ty: state.ty};
554
+ return;
555
+ }
556
+
557
+ switch(state.tool){
558
+ case 'select': {
559
+ const id = hitTest(world);
560
+ if(id){
561
+ state.selectedId = id;
562
+ state.dragging = { id, start: world, orig: cloneShapePos(id) };
563
+ } else {
564
+ state.selectedId = null;
565
+ }
566
+ render();
567
+ break;
568
+ }
569
+ case 'text': {
570
+ showTextEditor(world);
571
+ break;
572
+ }
573
+ case 'placeIcon': {
574
+ if(state.placeIconId){
575
+ const icon = state.icons.find(i => i.id === state.placeIconId);
576
+ if(icon){
577
+ addShape({
578
+ type: 'icon',
579
+ id: uid('iconObj'),
580
+ x: world.x, y: world.y,
581
+ size: state.size,
582
+ color: state.color,
583
+ icon: icon
584
+ });
585
+ }
586
+ }
587
+ break;
588
+ }
589
+ case 'arrow': {
590
+ state.drawing = { type: 'arrow', id: uid('arrow'), color: state.color, strokeWidth: state.strokeWidth, start: world, end: world };
591
+ addShape(state.drawing);
592
+ break;
593
+ }
594
+ case 'curve': {
595
+ state.drawing = { type: 'curve', id: uid('curve'), color: state.color, strokeWidth: state.strokeWidth, start: world, cp1: world, cp2: world, end: world };
596
+ addShape(state.drawing);
597
+ break;
598
+ }
599
+ case 'lasso': {
600
+ state.drawing = { type: 'lasso', id: uid('lasso'), color: state.color, strokeWidth: state.strokeWidth, points: [world] };
601
+ addShape(state.drawing);
602
+ break;
603
+ }
604
+ }
605
+ });
606
+
607
+ canvas.addEventListener('pointermove', (e) => {
608
+ const p = getPointer(e);
609
+ const world = screenToWorld(p);
610
+
611
+ if(state.isPanning){
612
+ const dx = e.clientX - state.panStart.x;
613
+ const dy = e.clientY - state.panStart.y;
614
+ state.tx = state.panStart.tx + dx;
615
+ state.ty = state.panStart.ty + dy;
616
+ render();
617
+ return;
618
+ }
619
+
620
+ if(state.dragging){
621
+ const s = state.shapes.find(x => x.id === state.dragging.id);
622
+ if(s){
623
+ const dx = world.x - state.dragging.start.x;
624
+ const dy = world.y - state.dragging.start.y;
625
+ moveShape(s, dx, dy);
626
+ render();
627
+ }
628
+ return;
629
+ }
630
+
631
+ if(state.drawing){
632
+ if(state.drawing.type === 'arrow'){
633
+ state.drawing.end = world;
634
+ } else if(state.drawing.type === 'curve'){
635
+ // second control and end follow pointer for preview; end finalize on up
636
+ state.drawing.cp2 = { x: (state.drawing.start.x + world.x)/2, y: state.drawing.start.y };
637
+ state.drawing.end = world;
638
+ } else if(state.drawing.type === 'lasso'){
639
+ const pts = state.drawing.points;
640
+ const last = pts[pts.length-1];
641
+ // add point if moved enough
642
+ const dist = Math.hypot(world.x - last.x, world.y - last.y);
643
+ if(dist > 2/state.zoom) pts.push(world);
644
+ }
645
+ render();
646
+ return;
647
+ }
648
+ });
649
+
650
+ canvas.addEventListener('pointerup', (e) => {
651
+ canvas.releasePointerCapture(e.pointerId);
652
+ state.isPanning = false;
653
+ state.dragging = null;
654
+ if(state.drawing){
655
+ // finalize
656
+ if(state.drawing.type === 'lasso'){
657
+ if(state.drawing.points.length < 2){
658
+ // too short, remove
659
+ state.shapes = state.shapes.filter(s => s.id !== state.drawing.id);
660
+ }
661
+ }
662
+ if(state.drawing.type === 'curve'){
663
+ // set cp1 mirrored to end for nice S-curve
664
+ const d = { x: state.drawing.end.x - state.drawing.start.x, y: state.drawing.end.y - state.drawing.start.y };
665
+ state.drawing.cp1 = { x: state.drawing.start.x + d.x/3, y: state.drawing.start.y + d.y/3 };
666
+ }
667
+ state.drawing = null;
668
+ render();
669
+ }
670
+ });
671
+
672
+ canvas.addEventListener('dblclick', (e) => {
673
+ if(state.tool === 'select'){
674
+ const p = getPointer(e);
675
+ const w = screenToWorld(p);
676
+ const id = hitTest(w);
677
+ if(id){
678
+ const s = state.shapes.find(x => x.id === id);
679
+ if(s){
680
+ if(s.type === 'text'){
681
+ showTextEditor({x: s.x, y: s.y}, s);
682
+ } else if(s.type === 'icon'){
683
+ // edit size quick
684
+ const nv = prompt('Размер иконки (px):', s.size);
685
+ if(nv){ s.size = clamp(parseInt(nv)||s.size, 8, 1024); render(); }
686
+ }
687
+ }
688
+ }
689
+ }
690
+ });
691
+
692
+ // Prevent context menu to allow right-drag pan
693
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
694
+
695
+ function getPointer(e){
696
+ const rect = canvas.getBoundingClientRect();
697
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
698
+ }
699
+
700
+ function moveShape(s, dx, dy){
701
+ switch(s.type){
702
+ case 'text': s.x += dx; s.y += dy; break;
703
+ case 'icon': s.x += dx; s.y += dy; break;
704
+ case 'arrow': s.start.x += dx; s.start.y += dy; s.end.x += dx; s.end.y += dy; break;
705
+ 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;
706
+ case 'lasso': s.points = s.points.map(p => ({x:p.x+dx, y:p.y+dy})); break;
707
+ }
708
+ }
709
+ function cloneShapePos(id){
710
+ const s = state.shapes.find(x => x.id === id);
711
+ if(!s) return null;
712
+ return JSON.parse(JSON.stringify(s));
713
+ }
714
+
715
+ // Text editor
716
+ function showTextEditor(world, existing=null){
717
+ const scr = worldToScreen(world);
718
+ textEditor.style.left = (scr.x) + 'px';
719
+ textEditor.style.top = (scr.y) + 'px';
720
+ textEditor.style.color = state.color;
721
+ textEditor.value = existing?.text || '';
722
+ textEditor.dataset.editId = existing?.id || '';
723
+ textEditor.style.display = 'block';
724
+ textEditor.focus();
725
+ textEditor.onkeydown = (e) => {
726
+ if(e.key === 'Enter' && (e.ctrlKey || e.metaKey || !e.shiftKey)){
727
+ e.preventDefault();
728
+ commitTextEditor();
729
+ } else if(e.key === 'Escape'){
730
+ e.preventDefault();
731
+ hideTextEditor();
732
+ }
733
+ };
734
+ textEditor.onblur = () => {
735
+ // Commit on blur
736
+ if(textEditor.style.display !== 'none') commitTextEditor();
737
+ };
738
+ }
739
+ function commitTextEditor(){
740
+ const txt = textEditor.value.trim();
741
+ const editId = textEditor.dataset.editId || '';
742
+ hideTextEditor();
743
+ if(!txt) return;
744
+ if(editId){
745
+ const s = state.shapes.find(x => x.id === editId);
746
+ if(s && s.type === 'text'){ s.text = txt; s.color = state.color; s.fontSize = Math.max(10, parseInt(sizeSlider.value)||32); render(); }
747
+ } else {
748
+ // get position back from screen position (we used worldToScreen earlier; store world before hide)
749
+ // We'll reconstruct using inverse transform of saved screen coords
750
+ // Better: keep last world position by reading style left/top, convert to world:
751
+ const left = parseFloat(textEditor.style.left), top = parseFloat(textEditor.style.top);
752
+ const world = screenToWorld({x: left, y: top});
753
+ addShape({
754
+ type: 'text', id: uid('text'), x: world.x, y: world.y,
755
+ text: txt, color: state.color, fontSize: Math.max(10, parseInt(sizeSlider.value)||32)
756
+ });
757
+ }
758
+ }
759
+ function hideTextEditor(){
760
+ textEditor.style.display = 'none';
761
+ textEditor.onblur = null;
762
+ textEditor.onkeydown = null;
763
+ textEditor.dataset.editId = '';
764
+ }
765
+
766
+ // Controls
767
+ colorPicker.addEventListener('input', () => {
768
+ state.color = colorPicker.value;
769
+ $$('.swatch').forEach(el=>el.classList.remove('active'));
770
+ });
771
+ 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(); } }});
772
+ strokeSlider.addEventListener('input', () => { state.strokeWidth = parseInt(strokeSlider.value)||4; });
773
+ zoomSlider.addEventListener('input', () => {
774
+ const val = parseInt(zoomSlider.value)/100;
775
+ const rect = wrap.getBoundingClientRect();
776
+ // Zoom around center
777
+ const cx = rect.width/2, cy = rect.height/2;
778
+ const wx = (cx - state.tx)/state.zoom, wy = (cy - state.ty)/state.zoom;
779
+ state.zoom = clamp(val, 0.05, 8);
780
+ state.tx = cx - wx*state.zoom;
781
+ state.ty = cy - wy*state.zoom;
782
+ render();
783
+ });
784
+ fitBtn.addEventListener('click', fitToScreen);
785
+ resetViewBtn.addEventListener('click', resetView);
786
+
787
+ exportBtn.addEventListener('click', exportPNG);
788
+ clearAllBtn.addEventListener('click', () => {
789
+ if(confirm('Удалить все объекты?')){ state.shapes = []; state.selectedId = null; render(); }
790
+ });
791
+ clearMapBtn.addEventListener('click', () => {
792
+ if(confirm('Удалить карту?')){
793
+ state.map = { img: null, w: 0, h: 0, url: '' };
794
+ mapImg.src = '';
795
+ render();
796
+ }
797
+ });
798
+
799
+ pasteBtn.addEventListener('click', async () => {
800
+ try{
801
+ const items = await navigator.clipboard.read();
802
+ for(const item of items){
803
+ for(const type of item.types){
804
+ if(type.startsWith('image/')){
805
+ const blob = await item.getType(type);
806
+ await setMapFromFile(new File([blob], 'pasted.png', {type: blob.type}));
807
+ return;
808
+ }
809
+ }
810
+ }
811
+ alert('В буфере нет изображения.');
812
+ }catch(err){
813
+ alert('Не удалось получить доступ к буферу обмена. Попробуйте Ctrl/Cmd+V или разрешите доступ.');
814
+ }
815
+ });
816
+
817
+ // File inputs
818
+ mapFile.addEventListener('change', async (e) => {
819
+ const file = e.target.files[0];
820
+ if(file) await setMapFromFile(file);
821
+ mapFile.value = '';
822
+ });
823
+ iconsFile.addEventListener('change', (e) => {
824
+ if(e.target.files?.length) addIcons(e.target.files);
825
+ iconsFile.value = '';
826
+ });
827
+
828
+ // Drag&Drop on wrapper (map or icons)
829
+ ;['dragenter','dragover'].forEach(evt => {
830
+ wrap.addEventListener(evt, (e) => {
831
+ e.preventDefault(); e.stopPropagation();
832
+ dropZone.style.display = 'flex';
833
+ });
834
+ });
835
+ ;['dragleave','drop'].forEach(evt => {
836
+ wrap.addEventListener(evt, (e) => {
837
+ e.preventDefault(); e.stopPropagation();
838
+ dropZone.style.display = 'none';
839
+ });
840
+ });
841
+ wrap.addEventListener('drop', async (e) => {
842
+ const dt = e.dataTransfer;
843
+ const iconId = dt.getData('text/plain');
844
+ if(iconId){
845
+ // Place icon at drop position
846
+ const icon = state.icons.find(i => i.id === iconId);
847
+ if(icon){
848
+ const rect = wrap.getBoundingClientRect();
849
+ const p = { x: e.clientX - rect.left, y: e.clientY - rect.top };
850
+ const w = screenToWorld(p);
851
+ addShape({
852
+ type: 'icon', id: uid('iconObj'),
853
+ x: w.x, y: w.y, size: state.size, color: state.color, icon
854
+ });
855
+ }
856
+ return;
857
+ }
858
+ const file = dt.files && dt.files[0];
859
+ if(file && file.type.startsWith('image/')){
860
+ await setMapFromFile(file);
861
+ }
862
+ });
863
+
864
+ // Clipboard paste (image -> map)
865
+ document.addEventListener('paste', async (e) => {
866
+ const items = e.clipboardData?.items || [];
867
+ for(const it of items){
868
+ if(it.type && it.type.startsWith('image/')){
869
+ const blob = it.getAsFile();
870
+ if(blob){
871
+ await setMapFromFile(new File([blob], 'pasted.png', {type: blob.type}));
872
+ break;
873
+ }
874
+ }
875
+ }
876
+ });
877
+
878
+ // Keyboard
879
+ document.addEventListener('keydown', (e) => {
880
+ if(e.key.toLowerCase() === 'v'){ setTool('select'); }
881
+ if(e.key.toLowerCase() === 'h' || e.key.toLowerCase() === 'p'){ setTool('pan'); }
882
+ if(e.key.toLowerCase() === 'a'){ setTool('arrow'); }
883
+ if(e.key.toLowerCase() === 'c'){ setTool('curve'); }
884
+ if(e.key.toLowerCase() === 'l'){ setTool('lasso'); }
885
+ if(e.key.toLowerCase() === 't'){ setTool('text'); }
886
+
887
+ if((e.key === 'Delete' || e.key === 'Backspace') && !isInputFocused()){
888
+ removeSelected();
889
+ }
890
+ if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's'){
891
+ e.preventDefault();
892
+ exportPNG();
893
+ }
894
+ if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z'){
895
+ if(e.shiftKey){ /* redo - not implemented */ }
896
+ else {
897
+ // undo last shape
898
+ if(state.shapes.length){
899
+ state.shapes.pop(); state.selectedId = null; render();
900
+ }
901
+ }
902
+ }
903
+ });
904
+ function isInputFocused(){
905
+ const a = document.activeElement;
906
+ return a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.isContentEditable);
907
+ }
908
+
909
+ // Export: render map + overlays to a new canvas (white sheet)
910
+ function exportPNG(){
911
+ if(!state.map.img){
912
+ alert('Сначала загрузите карту (или изображение).');
913
+ return;
914
+ }
915
+ const mw = state.map.w, mh = state.map.h;
916
+ const scale = state.zoom;
917
+ const dpr = 2; // high quality
918
+ const out = document.createElement('canvas');
919
+ out.width = Math.round(mw * scale * dpr);
920
+ out.height = Math.round(mh * scale * dpr);
921
+ const c = out.getContext('2d');
922
+ c.scale(dpr, dpr);
923
+
924
+ // White sheet
925
+ c.fillStyle = '#ffffff';
926
+ c.fillRect(0,0, mw*scale, mh*scale);
927
+
928
+ // Optional: draw map image to export too. If you want it, uncomment next line:
929
+ // c.drawImage(state.map.img, 0, 0, mw*scale, mh*scale);
930
+
931
+ // Apply pan/zoom transform
932
+ c.translate(state.tx, state.ty);
933
+ c.scale(scale, scale);
934
+
935
+ // Draw shapes
936
+ for(const s of state.shapes){
937
+ drawShapeExport(c, s, scale);
938
+ }
939
+
940
+ out.toBlob((blob) => {
941
+ const url = URL.createObjectURL(blob);
942
+ const a = document.createElement('a');
943
+ a.href = url;
944
+ a.download = 'map-export.png';
945
+ a.click();
946
+ setTimeout(() => URL.revokeObjectURL(url), 1500);
947
+ }, 'image/png');
948
+ }
949
+
950
+ function drawShapeExport(c, s, scale){
951
+ // same as drawShape but without selection; strokeWidth already in world units; when we scaled context by 'scale', no need to divide
952
+ switch(s.type){
953
+ case 'text': {
954
+ c.save();
955
+ c.fillStyle = s.color || '#111';
956
+ c.font = `${s.fontSize || 32}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Arial`;
957
+ c.textBaseline = 'top';
958
+ c.fillText(s.text || '', s.x, s.y);
959
+ c.restore();
960
+ break;
961
+ }
962
+ case 'icon': {
963
+ if(s.icon && s.icon.img) c.drawImage(s.icon.img, s.x, s.y, s.size, s.size);
964
+ break;
965
+ }
966
+ case 'arrow': {
967
+ c.save();
968
+ c.strokeStyle = s.color || '#e53935';
969
+ c.lineWidth = (s.strokeWidth || 4);
970
+ c.lineCap = 'round';
971
+ c.beginPath();
972
+ c.moveTo(s.start.x, s.start.y);
973
+ c.lineTo(s.end.x, s.end.y);
974
+ c.stroke();
975
+ drawArrowHeadExport(c, s.end, s.start, s.color || '#e53935', s.strokeWidth || 4);
976
+ c.restore();
977
+ break;
978
+ }
979
+ case 'curve': {
980
+ c.save();
981
+ c.strokeStyle = s.color || '#1e88e5';
982
+ c.lineWidth = (s.strokeWidth || 4);
983
+ c.lineCap = 'round';
984
+ c.beginPath();
985
+ c.moveTo(s.start.x, s.start.y);
986
+ c.bezierCurveTo(s.cp1.x, s.cp1.y, s.cp2.x, s.cp2.y, s.end.x, s.end.y);
987
+ c.stroke();
988
+ drawArrowHeadExport(c, s.end, s.cp2, s.color || '#1e88e5', s.strokeWidth || 4);
989
+ c.restore();
990
+ break;
991
+ }
992
+ case 'lasso': {
993
+ c.save();
994
+ c.strokeStyle = s.color || '#7e57c2';
995
+ c.lineWidth = (s.strokeWidth || 4);
996
+ c.lineCap = 'round';
997
+ c.beginPath();
998
+ for(let i=0;i<s.points.length;i++){
999
+ const p = s.points[i];
1000
+ if(i===0) c.moveTo(p.x, p.y);
1001
+ else c.lineTo(p.x, p.y);
1002
+ }
1003
+ c.stroke();
1004
+ c.restore();
1005
+ break;
1006
+ }
1007
+ }
1008
+ }
1009
+ function drawArrowHeadExport(c, to, from, color, strokeWidth){
1010
+ const headLen = Math.max(6, 6 + strokeWidth);
1011
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
1012
+ c.save();
1013
+ c.fillStyle = color;
1014
+ c.beginPath();
1015
+ c.moveTo(to.x, to.y);
1016
+ c.lineTo(to.x - headLen * Math.cos(angle - Math.PI/6),
1017
+ to.y - headLen * Math.sin(angle - Math.PI/6));
1018
+ c.lineTo(to.x - headLen * Math.cos(angle + Math.PI/6),
1019
+ to.y - headLen * Math.sin(angle + Math.PI/6));
1020
+ c.closePath();
1021
+ c.fill();
1022
+ c.restore();
1023
+ }
1024
+
1025
+ // Initialization
1026
+ buildPalette();
1027
+ resizeCanvas();
1028
+ render();
1029
+
1030
+ })();
style.css CHANGED
@@ -1,28 +1,122 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
 
 
 
 
 
 
 
 
4
  }
5
 
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
 
 
 
 
9
  }
10
 
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
 
 
 
 
 
16
  }
 
 
 
17
 
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
 
 
24
  }
 
25
 
26
- .card p:last-child {
27
- margin-bottom: 0;
 
 
 
28
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root{
2
+ --bg: #f7f7f7;
3
+ --paper: #ffffff;
4
+ --ink: #1b1b1b;
5
+ --muted: #666;
6
+ --primary: #2563eb;
7
+ --danger: #c62828;
8
+ --border: #e3e3e3;
9
+ --shadow: rgba(0,0,0,0.08);
10
+ --grid: #e9e9e9;
11
+ --selection: #4f46e5;
12
  }
13
 
14
+ *{ box-sizing: border-box; }
15
+ html,body{ height:100%; }
16
+ body{
17
+ margin:0;
18
+ background: var(--bg);
19
+ color: var(--ink);
20
+ font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
21
  }
22
 
23
+ .topbar{
24
+ display:flex;
25
+ align-items:flex-start;
26
+ gap:16px;
27
+ padding:10px 12px;
28
+ border-bottom:1px solid var(--border);
29
+ background: #fff;
30
+ position: sticky;
31
+ top: 0;
32
+ z-index: 50;
33
  }
34
+ .brand{ display:flex; align-items:center; gap:8px; min-width:180px; }
35
+ .brand .logo{ font-size:20px; }
36
+ .brand .title{ font-weight:700; letter-spacing:0.2px; }
37
 
38
+ .toolbar{ display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
39
+ .tool-group{ display:flex; align-items:center; gap:6px; background:#fff; border:1px solid var(--border); padding:6px; border-radius:8px; }
40
+ .tool-group.color-group{ gap:8px; }
41
+ .color-label{ font-weight:600; color:var(--muted); margin-right:2px; }
42
+ .colors{ display:flex; gap:4px; }
43
+ .swatch{
44
+ width:18px; height:18px; border-radius:50%; border:2px solid #fff; box-shadow: 0 0 0 1px var(--border);
45
+ cursor:pointer;
46
  }
47
+ .swatch.active{ box-shadow: 0 0 0 2px var(--selection); }
48
 
49
+ .btn{
50
+ display:inline-flex; align-items:center; justify-content:center; gap:6px;
51
+ border:1px solid var(--border); background:#fff; color:var(--ink);
52
+ padding:6px 10px; border-radius:8px; cursor:pointer; transition:.15s;
53
+ text-decoration:none; font-weight:600;
54
  }
55
+ .btn:hover{ box-shadow:0 1px 6px var(--shadow); }
56
+ .btn.primary{ background: var(--primary); color:#fff; border-color: var(--primary); }
57
+ .btn.danger{ background: var(--danger); color:#fff; border-color: var(--danger); }
58
+ .btn input{ display:none; }
59
+
60
+ .tool-btn{
61
+ width:34px; height:34px; display:inline-flex; align-items:center; justify-content:center;
62
+ border:1px solid var(--border); border-radius:8px; background:#fff; cursor:pointer;
63
+ }
64
+ .tool-btn.active{ outline: 2px solid var(--selection); }
65
+
66
+ .slider{ display:inline-flex; align-items:center; gap:6px; padding:0 4px; }
67
+ .slider input[type="range"]{ width:140px; }
68
+
69
+ .help details{ background:#fff; border:1px solid var(--border); border-radius:8px; padding:6px 10px; }
70
+ .help summary{ cursor:pointer; font-weight:600; color:var(--muted); }
71
+
72
+ .stage{ padding:12px; }
73
+ .canvas-wrap{
74
+ position: relative; width: 100%; height: calc(100vh - 220px);
75
+ min-height: 420px; background: #fff; border:1px solid var(--border);
76
+ border-radius: 10px; box-shadow: 0 2px 10px var(--shadow); overflow: hidden;
77
+ }
78
+ .map-img{
79
+ position:absolute; inset:0; margin:auto; max-width:none; max-height:none; image-rendering: auto;
80
+ user-select:none; pointer-events:none;
81
+ }
82
+ .overlay-canvas{
83
+ position:absolute; inset:0; width:100%; height:100%;
84
+ cursor: crosshair; outline:none;
85
+ }
86
+ .drop-zone{
87
+ position:absolute; inset:0; display:none; align-items:center; justify-content:center;
88
+ color:#555; font-weight:700; background: rgba(37,99,235,0.06); border:2px dashed #9bb6ff; pointer-events:none;
89
+ }
90
+
91
+ .text-editor{
92
+ position:absolute; min-width: 60px; min-height: 28px; resize: none;
93
+ border:1px dashed var(--primary); background: rgba(255,255,255,0.9);
94
+ padding:4px 6px; border-radius:6px; box-shadow: 0 2px 8px var(--shadow);
95
+ font: 600 16px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial;
96
+ }
97
+
98
+ .palette{
99
+ border-top:1px solid var(--border);
100
+ background:#fff; position: sticky; bottom:0; z-index: 40;
101
+ }
102
+ .palette-inner{ display:flex; align-items:center; gap:14px; padding:10px 12px; }
103
+ .palette-title{ font-weight:700; color:var(--muted); }
104
+ .icons{ display:flex; gap:10px; flex-wrap:wrap; }
105
+ .icon-item{
106
+ width:72px; min-height:72px; background:#fafafa; border:1px solid var(--border); border-radius:10px;
107
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; padding:6px; cursor:grab;
108
+ }
109
+ .icon-item:active{ cursor:grabbing; }
110
+ .icon-item img{ max-width: 42px; max-height: 42px; image-rendering: auto; }
111
+ .icon-item .name{ width:100%; text-align:center; font-size:12px; color:#333; border:none; background:transparent; outline:none; }
112
+ .icon-item .name:focus{ background:#fff; border:1px solid var(--border); border-radius:6px; }
113
+
114
+ .canvas-wrap.pan-mode .overlay-canvas{ cursor: grab; }
115
+ .canvas-wrap.pan-mode .overlay-canvas:active{ cursor: grabbing; }
116
+
117
+ @media (max-width: 980px){
118
+ .toolbar{ gap:6px; }
119
+ .tool-group{ padding:4px; }
120
+ .tool-btn{ width:32px; height:32px; }
121
+ .slider input[type="range"]{ width:100px; }
122
+ }