diff --git a/static/js/keyboard-shortcuts.js b/static/js/keyboard-shortcuts.js
new file mode 100644
index 0000000..38c8ff5
--- /dev/null
+++ b/static/js/keyboard-shortcuts.js
@@ -0,0 +1,346 @@
+/**
+ * Модуль горячих клавиш для карты
+ * Реализует быстрые действия на карте через клавиатуру,
+ * UI-подсказки и пользовательскую настройку.
+ */
+
+const MapKeyboardShortcuts = (() => {
+ // Хранилище по умолчанию
+ const DEFAULT_SHORTCUTS = {
+ 'ZoomIn': { key: '+', label: 'Приблизить' },
+ 'ZoomOut': { key: '-', label: 'Отдалить' },
+ 'FitBounds': { key: 'f', label: 'Показать все поля' },
+ 'ToggleLayers': { key: 'l', label: 'Переключить слои' },
+ 'ToggleLabels': { key: 'k', label: 'Переключить подписи' },
+ 'Fullscreen': { key: 'F11', label: 'Полный экран' },
+ 'ShowShortcuts': { key: '?', label: 'Показать горячие клавиши' },
+ 'PanUp': { key: 'ArrowUp', label: 'Вверх' },
+ 'PanDown': { key: 'ArrowDown', label: 'Вниз' },
+ 'PanLeft': { key: 'ArrowLeft', label: 'Влево' },
+ 'PanRight': { key: 'ArrowRight', label: 'Вправо' },
+ };
+
+ let shortcuts = {};
+ let map = null;
+ let hintPanel = null;
+ let isHintVisible = false;
+
+ /**
+ * Инициализация модуля
+ * @param {L.Map} leafletMap - Экземпляр Leaflet карты
+ */
+ function init(leafletMap) {
+ map = leafletMap;
+ loadUserShortcuts();
+ setupHintPanel();
+ bindGlobalEvents();
+ console.log('[KeyboardShortcuts] Инициализирован');
+ }
+
+ /**
+ * Загрузка пользовательских настроек из localStorage
+ */
+ function loadUserShortcuts() {
+ try {
+ const stored = localStorage.getItem('fm_map_shortcuts');
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ // Мержим с дефолтными, чтобы новые экшены не терялись
+ shortcuts = { ...DEFAULT_SHORTCUTS, ...parsed };
+ } else {
+ shortcuts = { ...DEFAULT_SHORTCUTS };
+ }
+ } catch (e) {
+ console.error('[KeyboardShortcuts] Ошибка загрузки настроек:', e);
+ shortcuts = { ...DEFAULT_SHORTCUTS };
+ }
+ }
+
+ /**
+ * Сохранение настроек пользователя
+ */
+ function saveUserShortcuts() {
+ try {
+ localStorage.setItem('fm_map_shortcuts', JSON.stringify(shortcuts));
+ } catch (e) {
+ console.error('[KeyboardShortcuts] Ошибка сохранения:', e);
+ }
+ }
+
+ /**
+ * Создание UI панели с подсказками
+ */
+ function setupHintPanel() {
+ if (!map) return;
+
+ // Контейнер кнопки подсказок
+ const btnContainer = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
+ const toggleBtn = L.DomUtil.create('a', 'leaflet-control-shortcuts-toggle', btnContainer);
+ toggleBtn.href = '#';
+ toggleBtn.title = 'Горячие клавиши (?)';
+ toggleBtn.innerHTML = '⌨️';
+ toggleBtn.setAttribute('role', 'button');
+ toggleBtn.setAttribute('aria-label', 'Показать горячие клавиши');
+
+ L.DomEvent.on(toggleBtn, 'click', L.DomEvent.stop);
+ L.DomEvent.on(toggleBtn, 'click', toggleHintPanel);
+
+ // Панель подсказок
+ hintPanel = L.DomUtil.create('div', 'map-keyboard-hint', document.body);
+ hintPanel.style.display = 'none';
+
+ // Добавляем контрол на карту
+ const ControlClass = L.Control.extend({
+ onAdd: () => btnContainer
+ });
+ map.addControl(new ControlClass({ position: 'topright' }));
+ }
+
+ /**
+ * Переключение видимости панели подсказок
+ */
+ function toggleHintPanel() {
+ if (!hintPanel) return;
+
+ if (isHintVisible) {
+ hintPanel.style.display = 'none';
+ isHintVisible = false;
+ } else {
+ renderHintContent();
+ hintPanel.style.display = 'block';
+ isHintVisible = true;
+ }
+ }
+
+ /**
+ * Рендер содержимого панели подсказок
+ */
+ function renderHintContent() {
+ let html = `
+
+
+ ${Object.entries(shortcuts).map(([action, data]) => `
+
+ ${escapeHtml(data.key)}
+ ${escapeHtml(data.label)}
+
+
+ `).join('')}
+
+
+ `;
+ hintPanel.innerHTML = html;
+ bindHintEvents();
+ }
+
+ /**
+ * Привязка событий к элементам подсказок
+ */
+ function bindHintEvents() {
+ if (!hintPanel) return;
+
+ // Закрытие
+ const closeBtn = hintPanel.querySelector('.close-hint');
+ if (closeBtn) {
+ L.DomEvent.on(closeBtn, 'click', (e) => {
+ L.DomEvent.stop(e);
+ toggleHintPanel();
+ });
+ }
+
+ // Редактирование
+ const editBtns = hintPanel.querySelectorAll('.edit-key');
+ editBtns.forEach(btn => {
+ L.DomEvent.on(btn, 'click', (e) => {
+ L.DomEvent.stop(e);
+ const row = btn.closest('.hint-row');
+ const action = row.dataset.action;
+ startRebinding(action, row);
+ });
+ });
+
+ // Сброс
+ const resetBtn = hintPanel.querySelector('.reset-defaults');
+ if (resetBtn) {
+ L.DomEvent.on(resetBtn, 'click', (e) => {
+ L.DomEvent.stop(e);
+ shortcuts = { ...DEFAULT_SHORTCUTS };
+ localStorage.removeItem('fm_map_shortcuts');
+ renderHintContent();
+ });
+ }
+ }
+
+ /**
+ * Процесс переназначения клавиши
+ * @param {string} action - Название действия
+ * @param {HTMLElement} row - DOM-элемент строки
+ */
+ function startRebinding(action, row) {
+ const keyBadge = row.querySelector('.key-badge');
+ const originalText = keyBadge.textContent;
+
+ keyBadge.textContent = '...';
+ keyBadge.classList.add('recording');
+
+ const handler = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const newKey = e.key === ' ' ? 'Space' : e.key;
+
+ // Проверяем конфликты
+ const conflict = Object.entries(shortcuts).find(
+ ([act, data]) => act !== action && data.key.toLowerCase() === newKey.toLowerCase()
+ );
+
+ if (conflict) {
+ alert(`Клавиша "${newKey}" уже используется для: "${conflict[1].label}"`);
+ keyBadge.textContent = originalText;
+ } else {
+ shortcuts[action].key = newKey;
+ saveUserShortcuts();
+ keyBadge.textContent = newKey;
+ }
+
+ keyBadge.classList.remove('recording');
+ document.removeEventListener('keydown', handler);
+ };
+
+ document.addEventListener('keydown', handler, { once: true });
+ }
+
+ /**
+ * Привязка глобальных событий клавиатуры
+ */
+ function bindGlobalEvents() {
+ document.addEventListener('keydown', handleKeydown);
+ }
+
+ /**
+ * Обработчик нажатий клавиш
+ * @param {KeyboardEvent} e
+ */
+ function handleKeydown(e) {
+ // Игнорируем события в полях ввода
+ if (e.target.tagName === 'INPUT' ||
+ e.target.tagName === 'TEXTAREA' ||
+ e.target.isContentEditable) {
+ return;
+ }
+
+ const pressedKey = e.key;
+
+ // Поиск действия по нажатой клавише
+ const action = Object.entries(shortcuts).find(
+ ([_, data]) => data.key.toLowerCase() === pressedKey.toLowerCase()
+ );
+
+ if (!action) return;
+
+ const [actionName] = action;
+ e.preventDefault();
+
+ switch (actionName) {
+ case 'ZoomIn':
+ map.zoomIn();
+ break;
+ case 'ZoomOut':
+ map.zoomOut();
+ break;
+ case 'FitBounds':
+ window.dispatchEvent(new CustomEvent('map:fit-bounds'));
+ break;
+ case 'ToggleLayers':
+ window.dispatchEvent(new CustomEvent('map:toggle-layers'));
+ break;
+ case 'ToggleLabels':
+ window.dispatchEvent(new CustomEvent('map:toggle-labels'));
+ break;
+ case 'Fullscreen':
+ toggleFullscreen();
+ break;
+ case 'ShowShortcuts':
+ toggleHintPanel();
+ break;
+ case 'PanUp':
+ case 'PanDown':
+ case 'PanLeft':
+ case 'PanRight':
+ panMap(actionName);
+ break;
+ default:
+ // Пробрасываем кастомное событие для других действий
+ window.dispatchEvent(new CustomEvent(`shortcut:${actionName.toLowerCase()}`));
+ }
+ }
+
+ /**
+ * Панорамирование карты
+ * @param {string} direction - Направление
+ */
+ function panMap(direction) {
+ const panAmount = 100; // пикселей
+ let delta = [0, 0];
+
+ switch (direction) {
+ case 'PanUp': delta = [0, -panAmount]; break;
+ case 'PanDown': delta = [0, panAmount]; break;
+ case 'PanLeft': delta = [-panAmount, 0]; break;
+ case 'PanRight': delta = [panAmount, 0]; break;
+ }
+
+ if (map && delta.some(d => d !== 0)) {
+ map.panBy(delta, { animate: true, duration: 0.3 });
+ }
+ }
+
+ /**
+ * Переключение полноэкранного режима
+ */
+ function toggleFullscreen() {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen().catch(err => {
+ console.warn(`Fullscreen error: ${err.message}`);
+ });
+ } else {
+ document.exitFullscreen();
+ }
+ }
+
+ /**
+ * Утилиты
+ */
+ function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ /**
+ * Публичное API
+ */
+ return {
+ init,
+ getShortcuts: () => ({ ...shortcuts }),
+ setShortcuts: (newShortcuts) => {
+ shortcuts = { ...DEFAULT_SHORTCUTS, ...newShortcuts };
+ saveUserShortcuts();
+ },
+ resetToDefaults: () => {
+ shortcuts = { ...DEFAULT_SHORTCUTS };
+ localStorage.removeItem('fm_map_shortcuts');
+ }
+ };
+})();
+
+// Экспорт для использования в других модулях
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = MapKeyboardShortcuts;
+}
\ No newline at end of file