Compare commits

..

No commits in common. "pages" and "main" have entirely different histories.
pages ... main

3 changed files with 0 additions and 1049 deletions

173
AGENTS.md
View File

@ -1,173 +0,0 @@
<!-- vibe42-agents-version: v3-guided-2026-06-01 -->
# Vibe42 — учебная песочница для лендингов
Workspace юзера `Ermek_aubakirov`. Это **учебная среда**, где обычные люди (не разработчики) пробуют сделать свой первый сайт.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **рабочим лендингом**, опубликованным по адресу `https://pages.git.vibe42.kz/Ermek_aubakirov/<repo>/`,
2. ощущением «это было легко» — без серверов, БД, токенов, конфигов.
Юзер не разработчик. Ему важен **результат, который видно в браузере**, а не код.
---
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
1. Поздоровайся коротко: «Привет! Тут за 10 минут собираем лендинг и публикуем его в интернете. О чём хочешь сделать?»
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
- Промо хобби (фотография / музыка / спорт)
- Резюме / personal page с контактами
- Афиша мероприятия (концерт, день рождения, мастер-класс)
- Меню заведения / прайс услуг
- Лендинг продукта или будущего проекта (waitlist)
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
4. Сразу делай `./new-project <name>` и собирай страницу. Не спрашивай разрешения на каждый шаг.
---
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
Скажи: «Давай определимся, я задам 3 коротких вопроса:
1. Это для тебя лично, для проекта/бизнеса, или для события?
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
---
## 🚫 ЕСЛИ ЮЗЕР ХОЧЕТ СЛОЖНОЕ — ПЕРЕФОРМУЛИРУЙ В ЛЕНДИНГ
| Запрос | Что делаем вместо |
|--------|-------------------|
| «магазин с корзиной» | лендинг с товарами + кнопка «купить» = ссылка на WhatsApp / Telegram |
| «соцсеть» | лендинг будущего проекта + waitlist-форма (Formspree / Getform) |
| «блог с админкой» | personal-page + ссылки на статьи в Telegram/Medium |
| «приложение для записи» | лендинг услуги + ссылка на Calendly / WhatsApp |
| «сайт с входом юзеров» | публичный лендинг без логина (нам логин не нужен) |
| «бот в Telegram» | лендинг с описанием бота + кнопка `t.me/...` |
**Не говори «это невозможно».** Скажи: «У нас песочница только для статических сайтов. Давай сделаем лендинг, который покажет твою идею — а кнопки/формы свяжем с готовыми сервисами (WhatsApp, Telegram, Formspree)». Юзер счастлив, результат за 15 минут.
---
## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера)
### A — Промо продукта/услуги
**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер).
### B — Personal / резюме
**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки).
### C — Афиша мероприятия
**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт).
### D — Меню / прайс
**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка).
### E — Waitlist для будущего проекта
**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма).
Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического.
---
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
Как только готов первый рабочий вариант (даже грубый):
1. **Сразу запушь:**
```bash
git add -A
git commit -m "v1"
git push origin HEAD:pages
```
2. **ОБЯЗАТЕЛЬНО** дай юзеру ссылку **жирно**:
> 🎉 Готово! Твой лендинг здесь: **https://pages.git.vibe42.kz/Ermek_aubakirov/<repo>/**
3. Скажи: «Открой в новой вкладке, посмотри. Что хочешь поменять?»
4. Дальше короткие итерации: правка → push → новый URL-показ. Каждые 2-3 правки — push.
---
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
1. **Только статика — HTML + CSS + JS в браузере.**
2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
3. **Никакой аутентификации / OAuth / JWT.**
4. **Никакого Docker, nginx, sudo, системных настроек.**
5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
6. **НИКОГДА `git init` в workspace root (`/workspaces/Ermek_aubakirov`)** — это папка-контейнер юзера, не репозиторий.
---
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
```bash
cd /workspaces/Ermek_aubakirov
./new-project NAME # создаёт repo в Gitea + клонит локально в ./NAME/
cd NAME
# теперь создавай index.html / style.css / script.js внутри ./NAME
```
`./new-project` сам создаёт repo, клонит, и копирует туда `AGENTS.md` + `design.md`.
---
## 🌐 Git и публикация
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
- Профиль юзера: https://git.vibe42.kz/Ermek_aubakirov
- Pages (живые лендинги): https://pages.git.vibe42.kz/Ermek_aubakirov/<repo>/
- Креды уже в `/workspaces/Ermek_aubakirov/.git-credentials` — git push/clone работают без пароля
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
### Опубликовать лендинг
```bash
git add -A
git commit -m "site"
git push origin HEAD:pages
```
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно.
---
## 🔧 Когда что-то идёт не так
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
- ❌ Использовать `gh` CLI или GitHub API
- ❌ Вызывать Gitea Pages-API (его нет)
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
- ❌ Говорить «это сложно» — переформулируй в простое
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
---
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.

110
design.md
View File

@ -1,110 +0,0 @@
<!-- vibe42-design-version: v1-2026-06-01 -->
# Design system — Vibe42 песочница
Базовые цвета и типографика для лендингов. Можно отклоняться, но начинай с этого.
## Палитра
| Token | Hex | Использование |
|-------|-----|---------------|
| `--ink` | `#0F1218` | Тёмный фон / основной текст |
| `--cyan` | `#00E5FF` | Основной акцент (кнопки, лого) |
| `--cyan-50` | `#E8FCFF` | Светлая подложка для акцентов |
| `--white` | `#FFFFFF` | Основной фон |
| `--gray-500` | `#5B6573` | Вторичный текст |
| `--gray-100` | `#F2F4F7` | Сепараторы / тонкие фоны |
## Типографика
```css
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
```
| Уровень | Размер | Вес | line-height |
|---------|--------|-----|-------------|
| h1 (hero) | 56px | 800 | 1.05 |
| h2 (section) | 36px | 700 | 1.15 |
| h3 | 22px | 700 | 1.3 |
| body | 17px | 400 | 1.6 |
| small | 14px | 400 | 1.5 |
На мобиле — h1 уменьши до 36px, h2 до 28px.
## Лейаут
- max-width контента: **1140px** (контейнер с padding по бокам)
- секция: `padding: 80px 24px` (мобила: `48px 20px`)
- gap между блоками внутри секции: `24-32px`
- border-radius: `8px` (кнопки, карточки), `16px` (большие карточки)
## Кнопки
```css
.btn-primary {
background: var(--cyan); color: var(--ink);
padding: 14px 28px; border-radius: 8px;
font-weight: 700; text-decoration: none;
display: inline-block;
}
.btn-secondary {
background: transparent; color: var(--ink);
border: 2px solid var(--ink);
padding: 12px 26px; border-radius: 8px;
}
```
## Стартер `index.html`
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Мой проект</title>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:56px;font-weight:800;line-height:1.05;margin-bottom:24px}
.hero p{font-size:20px;color:#9aa3b2;max-width:600px;margin-bottom:32px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:14px 28px;border-radius:8px;font-weight:700;text-decoration:none}
.btn:hover{background:#1be5ff}
.section h2{font-size:36px;font-weight:700;margin-bottom:24px}
.card{background:var(--gray-100);border-radius:16px;padding:32px;margin-bottom:16px}
@media (max-width:640px){.hero h1{font-size:36px}.section h2{font-size:28px}.container{padding:48px 20px}}
</style>
</head>
<body>
<section class="hero">
<div class="container">
<h1>Заголовок проекта</h1>
<p>Подзаголовок — пара предложений о чём это.</p>
<a class="btn" href="#section">Начать</a>
</div>
</section>
<section id="section" class="section">
<div class="container">
<h2>Секция</h2>
<div class="card">Контент карточки.</div>
<div class="card">Контент карточки.</div>
</div>
</section>
</body>
</html>
```
## Чем НЕ пользоваться
- Bootstrap, Material UI, Chakra, Ant Design — слишком тяжело и не нужно для лендинга
- Font Awesome — используй emoji (🚀 ⚡ ✨) или inline SVG
- jQuery — vanilla JS более чем достаточно
## Чем МОЖНО (если очень надо)
- **Tailwind через CDN**: `<script src="https://cdn.tailwindcss.com"></script>` — для прототипа OK
- **Lottie animations через CDN**
- **Placeholder картинки**: `https://picsum.photos/800/600`, `https://placehold.co/600x400`
- **Шрифты Google Fonts через `<link>`** в head

View File

@ -1,766 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Планировщик опор ВЛ — РК</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
:root{--ink:#0F1218;--cyan:#00E5FF}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;margin:0;padding:0}
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;color:var(--ink);background:#F2F4F7;height:100%}
.header{background:var(--ink);padding:4px 8px;display:flex;align-items:center;gap:4px;flex-wrap:wrap}
.header h1{font-size:13px;font-weight:700;color:#fff;white-space:nowrap}
.controls{display:flex;gap:2px;flex-wrap:wrap}
.controls button{padding:3px 6px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:10px;white-space:nowrap}
.btn-primary{background:#00E5FF;color:#0F1218}
.btn-secondary{background:#4CAF50;color:#fff}
.btn-warning{background:#FF9800;color:#fff}
.btn-danger{background:#ff5252;color:#fff}
.btn-export{background:#4caf50;color:#fff}
.btn-active{background:#fff!important;color:#0F1218!important;box-shadow:0 0 0 2px var(--cyan)}
.loc-panel{background:#222;padding:2px 8px;display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:10px;border-bottom:1px solid #333}
.loc-panel label{color:#9aa3b2}
.loc-panel select{padding:1px 4px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:10px;max-width:120px}
.loc-panel input{padding:1px 4px;border:1px solid #555;border-radius:3px;background:#333;color:#fff;font-size:10px;width:100px}
.loc-panel button{padding:1px 6px;border:none;border-radius:3px;background:var(--cyan);color:var(--ink);font-weight:600;cursor:pointer;font-size:10px}
#map{height:100vh;width:100%;min-height:350px;background:#1a1a1a;position:relative;z-index:1}
.settings{background:#1a1a1a;padding:2px 8px;display:flex;gap:4px;align-items:center;font-size:10px;color:#9aa3b2;flex-wrap:wrap;border-bottom:none}
.settings label{display:flex;align-items:center;gap:2px}
.settings input{width:32px;padding:1px 3px;border:1px solid #555;border-radius:2px;background:#222;color:#fff;font-size:10px;text-align:center}
.settings .mode-info{margin-left:auto;font-size:9px;color:#888}
.sidebar{position:absolute;top:34px;right:6px;width:300px;max-height:calc(100vh - 44px);background:#fff;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);overflow:hidden;z-index:1000;display:flex;flex-direction:column;font-size:11px;transition:max-height .2s;border:1px solid rgba(0,0,0,.08)}
.sidebar-header{padding:4px 8px;background:#F2F4F7;font-weight:700;font-size:10px;border-bottom:1px solid #ddd;display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none}
.sidebar table{width:100%;border-collapse:collapse;font-size:10px}
.sidebar th{background:var(--ink);color:#fff;padding:2px 4px;text-align:left;position:sticky;top:0;font-size:9px}
.sidebar td{padding:2px 4px;border-bottom:1px solid #F2F4F7;font-size:9px}
.sidebar td.num{text-align:center;font-weight:700;color:#00E5FF;font-size:10px}
.sidebar td.dist{text-align:right;font-family:monospace;font-size:9px}
.sidebar-footer{padding:3px 8px;background:#F2F4F7;font-size:9px;font-weight:600;border-top:1px solid #ddd}
.pole-label{background:#0F1218;color:#00E5FF;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:9px;border:2px solid #00E5FF;box-shadow:0 2px 6px rgba(0,0,0,.4)}
.entrance-label{background:#FF9800;color:#fff;border-radius:3px;padding:1px 4px;font-size:8px;font-weight:700;border:2px solid #FF9800;box-shadow:0 2px 6px rgba(0,0,0,.3)}
.mode-badge{position:absolute;bottom:30px;right:310px;background:#00E5FF;color:#0F1218;padding:4px 10px;border-radius:4px;font-weight:700;font-size:11px;z-index:1000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,.25)}
.legend{position:absolute;bottom:6px;left:6px;background:rgba(255,255,255,.9);padding:3px 6px;border-radius:4px;box-shadow:0 1px 6px rgba(0,0,0,.1);z-index:1000;font-size:9px;line-height:1.4}
.legend span{display:flex;align-items:center;gap:3px}
.auto-panel{position:absolute;top:34px;left:6px;background:#fff;padding:4px 8px;border-radius:5px;box-shadow:0 2px 12px rgba(0,0,0,.15);z-index:1000;font-size:10px;max-width:240px;display:none}
.auto-panel button{padding:2px 6px;border:none;border-radius:3px;font-weight:600;cursor:pointer;font-size:9px;margin-top:3px}
.auto-panel .tag{display:inline-block;background:#00E5FF;color:#0F1218;padding:1px 5px;border-radius:2px;font-size:8px;margin:1px}
@media(max-width:700px){.sidebar{width:100%;right:0;top:auto;bottom:0;max-height:25vh;border-radius:6px 6px 0 0;font-size:10px}#map{height:calc(100vh - 72px)}.legend{bottom:auto;top:34px}.header h1{font-size:12px}}
</style>
</head>
<body>
<div class="header">
<h1>⚡ Опоры ВЛ-0.4 кВ</h1>
<div class="controls">
<button class="btn-primary" id="btnLine" onclick="setMode('line')">📏 Линия</button>
<button class="btn-active" id="btnManual" onclick="setMode('manual')">🎯 Вручную</button>
<button class="btn-warning" id="btnEntrance" onclick="setMode('entrance')">🚗 Въезд</button>
<button class="btn-danger" onclick="undo()"></button>
<button class="btn-warning" onclick="clearAll()">🗑</button>
<button class="btn-export" onclick="exportSVG()">📥 SVG</button>
<button class="btn-export" onclick="exportDrawIO()">draw.io</button>
<button class="btn-export" onclick="exportCSV()">CSV</button>
</div>
</div>
<div class="loc-panel">
<label>📍 Область:</label>
<select id="regionSelect" onchange="onRegionChange()" style="max-width:110px"><option value="">— выберите —</option></select>
<label>Район:</label>
<select id="districtSelect" onchange="onDistrictSelect()" style="max-width:110px"><option value="">— загрузка —</option></select>
<label>Село:</label>
<select id="villageSelect" onchange="onVillageSelect()" style="max-width:120px"><option value="">— выберите район —</option></select>
<button onclick="autoDetect()" style="background:#FF9800;color:#fff">🔄 Авто</button>
<span id="locStatus" style="color:#888;font-size:9px"></span>
</div>
<div class="settings">
<label>📐 Шаг: <input type="number" id="poleStep" value="50" min="10" max="200"> м</label>
<label>⛔ Отступ: <input type="number" id="entranceMargin" value="5" min="0" max="30"> м</label>
<label>🔒 Привязка: <input type="checkbox" id="snapRoad" checked></label>
<span class="mode-info" id="modeInfo">Режим: ручная расстановка</span>
</div>
<div id="map"></div>
<div class="mode-badge" id="modeBadge" style="display:none">🎯 Кликните для установки</div>
<div class="auto-panel" id="autoPanel">
<b>🔄 Автоопределение</b><br>
<span id="autoStatus">Загрузка данных...</span>
<div id="autoResults"></div>
<button onclick="applyRoadPoles()">📥 Расставить опоры вдоль улиц</button>
</div>
<div class="legend">
<span><span style="display:inline-block;width:11px;height:11px;border-radius:50%;background:#0F1218;border:2px solid #00E5FF"></span> Опора</span>
<span><i style="display:inline-block;width:12px;height:3px;background:#00E5FF;border-radius:2px"></i> ВЛ</span>
<span><span style="background:#FF9800;padding:0 3px;border-radius:2px;color:#fff;font-size:8px">В</span> Въезд</span>
<span><i style="display:inline-block;width:12px;height:3px;background:#e74c3c;border-radius:2px"></i> Асфальт</span>
<span><i style="display:inline-block;width:12px;height:2px;background:#f39c12;border-radius:2px"></i> Грунтовка</span>
<span><i style="display:inline-block;width:12px;height:1px;background:#95a5a6;border-radius:1px"></i> Просёлок/поле</span>
<span><span style="display:inline-block;width:8px;height:8px;background:rgba(255,107,53,.25);border:1px solid #ff6b35"></span> Здание</span>
</div>
<div class="sidebar">
<div class="sidebar-header"><span>📋 Спецификация</span><span id="stats" style="font-weight:400;font-size:10px">0 шт</span></div>
<div style="overflow-y:auto;flex:1">
<table>
<thead><tr><th></th><th>Название</th><th>Улица</th><th>Широта</th><th>Долгота</th><th>Пролёт</th></tr></thead>
<tbody id="poleTable"></tbody>
</table>
</div>
<div class="sidebar-footer" id="totalDist">Итого: 0.0 м | 0 опор</div>
</div>
<div class="toast" id="toast"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// ====================== РЕГИОНЫ КАЗАХСТАНА ======================
const REGIONS = [
{name:'Абай',center:[48.95,79.23]},
{name:'Акмолинская',center:[52.0,69.0]},
{name:'Актюбинская',center:[48.0,57.0]},
{name:'Алматинская',center:[44.5,78.0]},
{name:'Атырауская',center:[47.0,52.0]},
{name:'Восточно-Казахстанская',center:[49.0,82.0]},
{name:'Жамбылская',center:[44.0,72.0]},
{name:'Жетысу',center:[45.0,80.0]},
{name:'Западно-Казахстанская',center:[50.0,51.0]},
{name:'Карагандинская',center:[48.0,71.0]},
{name:'Костанайская',center:[53.0,65.0]},
{name:'Кызылординская',center:[45.0,65.0]},
{name:'Мангистауская',center:[44.0,52.0]},
{name:'Павлодарская',center:[52.0,76.0]},
{name:'Северо-Казахстанская',center:[54.5,69.0]},
{name:'Туркестанская',center:[43.0,68.0]},
{name:'Улытау',center:[48.0,67.0]},
{name:'г.Алматы',center:[43.238,76.946]},
{name:'г.Астана',center:[51.16,71.44]},
{name:'г.Шымкент',center:[42.32,69.6]}
];
// ====================== ГЛОБАЛЬНЫЕ ======================
let map, markers=[], lines=[], entrances=[], entranceCircles=[];
let poleCount=0, linePoints=[], mode='manual';
let roadsLayer=null, buildingsLayer=null;
let loadedRoads=[], loadedBuildings=0;
// ====================== ИНИЦИАЛИЗАЦИЯ ======================
function init(){
const sel=document.getElementById('regionSelect');
REGIONS.forEach(r=>{const o=document.createElement('option');o.value=r.name;o.textContent=r.name;sel.appendChild(o);});
const sat=L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',{maxZoom:20,attribution:'Esri'});
const osm=L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',{maxZoom:20,opacity:0.45,attribution:'OSM'});
map=L.map('map',{zoomControl:true,layers:[sat,osm]}).setView([48.0,68.0],6);
map.on('click',onMapClick);
map.on('moveend',()=>{if(map.getZoom()>10)autoLoadOSM();});
roadsLayer=L.layerGroup().addTo(map);
buildingsLayer=L.layerGroup().addTo(map);
updateModeBadge();
console.log('Map init done, center:',map.getCenter(),'zoom:',map.getZoom(),'size:',map.getSize());
setTimeout(()=>{map.invalidateSize();console.log('invalidateSize, size:',map.getSize());},100);
}
window.addEventListener('load',()=>{setTimeout(()=>{if(map)map.invalidateSize();},200);});
// ====================== ВЫБОР ЛОКАЦИИ ======================
let regionRelId=null;
let districtList=[];
function onRegionChange(){
const name=document.getElementById('regionSelect').value;
const region=REGIONS.find(x=>x.name===name);
const dsel=document.getElementById('districtSelect');
const vsel=document.getElementById('villageSelect');
dsel.innerHTML='<option value="">— загрузка —</option>';
vsel.innerHTML='<option value="">— выберите район —</option>';
districtList=[];
if(!region){document.getElementById('locStatus').textContent='';return;}
map.setView(region.center,9);
const st=document.getElementById('locStatus');
st.textContent='⏳ районы '+name+'...';
// Ищем районы через Nominatim
const queries=[
'районы '+name+' области',
'районы '+name+' облысы',
name+' район',
name+' аудан'
];
let done=false;
for(const q of queries){
if(done)break;
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(q)+'&countrycodes=kz&limit=30&type=administrative')
.then(r=>r.json())
.then(data=>{
if(done||!data||!data.length)return;
done=true;
const seen=new Set();
dsel.innerHTML='<option value="">— все —</option>';
data.forEach(item=>{
let n=item.display_name.split(',')[0].trim();
if(!n||seen.has(n)||n===name||n.toLowerCase().includes('город'))return;
seen.add(n);
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,lat:item.lat,lon:item.lon});
});
st.textContent='✅ '+seen.size+' районов';
});
}
// fallback если ничего не нашли
setTimeout(()=>{
if(!done){
dsel.innerHTML='<option value="">— введите название района —</option>';
st.textContent='⚠ введите район вручную';
}
},3000);
}
function onDistrictSelect(){
const dname=document.getElementById('districtSelect').value;
const vsel=document.getElementById('villageSelect');
vsel.innerHTML='<option value="">⏳ сёла...</option>';
if(!dname||!districtList.length){
vsel.innerHTML='<option value="">— выберите район —</option>';
return;
}
const dist=districtList.find(d=>d.name===dname);
if(!dist){
vsel.innerHTML='<option value="">— ошибка —</option>';
return;
}
st.textContent='⏳ сёла '+dname+'...';
// Ищем сёла через Nominatim
fetch('https://nominatim.openstreetmap.org/search?format=json&q='+encodeURIComponent(dname+' район село')+'&countrycodes=kz&limit=50')
.then(r=>r.json())
.then(data=>{
vsel.innerHTML='<option value="">— выберите село —</option>';
if(!data||!data.length){
vsel.innerHTML='<option value="">— сёла не найдены —</option>';
st.textContent='⚠ сёла не найдены';
return;
}
const villages=[];
data.forEach(item=>{
if(item.type==='village'||item.type==='hamlet'||item.type==='town'||item.type==='settlement'){
villages.push({name:item.display_name.split(',')[0].trim(),lat:item.lat,lon:item.lon});
}
});
villages.sort((a,b)=>a.name.localeCompare(b.name,'ru'));
villages.forEach(v=>{
const o=document.createElement('option');
o.value=v.name;o.dataset.lat=v.lat;o.dataset.lon=v.lon;o.textContent=v.name;
vsel.appendChild(o);
});
st.textContent='✅ '+villages.length+' сёл';
})
.catch(()=>{
vsel.innerHTML='<option value="">— ошибка —</option>';
});
}
function onVillageSelect(){
const vsel=document.getElementById('villageSelect');
const opt=vsel.options[vsel.selectedIndex];
if(!opt||!opt.dataset.lat)return;
const lat=parseFloat(opt.dataset.lat),lon=parseFloat(opt.dataset.lon);
if(!isNaN(lat)&&!isNaN(lon)){
map.setView([lat,lon],16);
showToast('📍 '+opt.value);
setTimeout(autoDetect,500);
}
}
const seen=new Map();
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
let clean=el.tags.name.replace(/\s*ауданы$/i,'').replace(/\s*районы$/i,'').replace(/\s*район$/i,'').trim();
if(clean&&!seen.has(clean))seen.set(clean,el.id);
});
dsel.innerHTML='<option value="">— все —</option>';
const sorted=[...seen.keys()].sort();
sorted.forEach(n=>{
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,osm_id:seen.get(n)});
});
st.textContent='✅ '+sorted.length+' районов из OSM';
})
.catch(e=>{
st.textContent='⚠ ошибка: '+e.message;
});
}
const rel=data.elements[0];
regionRelId=rel.id;
st.textContent='✅ регион: '+rel.id+', ищу районы...';
// ШАГ 2: районы (admin_level=6,7) внутри rel
const q2='[out:json][timeout:20];area('+rel.id+')->.a;(rel(area.a)["boundary"="administrative"]["admin_level"~"6|7"];);out tags id;';
return fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(q2));
})
.then(r=>r&&r.json())
.then(data=>{
if(!data||!data.elements||!data.elements.length){
st.textContent='⚠ районы не найдены через OSM, пробую Nominatim...';
return fetchNominatimDistricts(name);
}
const seen=new Map();
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
let clean=el.tags.name.replace(/\s*ауданы$/i,'').replace(/\s*районы$/i,'').replace(/\s*район$/i,'').trim();
if(clean&&!seen.has(clean))seen.set(clean,el.id);
});
dsel.innerHTML='<option value="">— все —</option>';
const sorted=[...seen.keys()].sort();
sorted.forEach(n=>{
const o=document.createElement('option');
o.value=n;o.textContent=n;
dsel.appendChild(o);
districtList.push({name:n,osm_id:seen.get(n)});
});
st.textContent='✅ '+sorted.length+' районов';
})
.catch(e=>{
st.textContent='⚠ ошибка OSM: '+e.message+', fallback...';
fetchNominatimDistricts(name);
});
}
function onDistrictSelect(){
const dname=document.getElementById('districtSelect').value;
const vsel=document.getElementById('villageSelect');
vsel.innerHTML='<option value="">⏳ загрузка сёл...</option>';
if(!dname||!districtList.length){
vsel.innerHTML='<option value="">— выберите район —</option>';return;
}
const dist=districtList.find(d=>d.name===dname);
if(!dist){
vsel.innerHTML='<option value="">— ошибка —</option>';return;
}
const areaId=3600000000+dist.osm_id;
const q='[out:json];area('+areaId+')->.a;(node(area.a)["place"~"village|hamlet|town"];);out tags;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(q))
.then(r=>r.json())
.then(data=>{
vsel.innerHTML='<option value="">— выберите село —</option>';
if(!data||!data.elements||!data.elements.length){
vsel.innerHTML='<option value="">— сёла не найдены —</option>';return;
}
const villages=[];
data.elements.forEach(el=>{
if(!el.tags||!el.tags.name)return;
villages.push({name:el.tags.name,lat:el.lat,lng:el.lon});
});
villages.sort((a,b)=>a.name.localeCompare(b.name,'ru'));
villages.forEach(v=>{
const o=document.createElement('option');
o.value=v.name;o.dataset.lat=v.lat;o.dataset.lng=v.lng;o.textContent=v.name;
vsel.appendChild(o);
});
document.getElementById('locStatus').textContent='✅ '+villages.length+' сёл';
})
.catch(()=>{
vsel.innerHTML='<option value="">— ошибка —</option>';
});
}
function onVillageSelect(){
const vsel=document.getElementById('villageSelect');
const opt=vsel.options[vsel.selectedIndex];
if(!opt||!opt.dataset.lat)return;
const lat=parseFloat(opt.dataset.lat),lng=parseFloat(opt.dataset.lng);
if(!isNaN(lat)&&!isNaN(lng)){
map.setView([lat,lng],16);
showToast('📍 '+opt.value);
setTimeout(autoDetect,500);
}
}
// ====================== АВТООПРЕДЕЛЕНИЕ УЛИЦ И ЗДАНИЙ (OSM) ======================
function autoDetect(){
document.getElementById('autoPanel').style.display='block';
document.getElementById('autoStatus').textContent='⏳ Загрузка улиц и зданий...';
autoLoadOSM();
}
function autoLoadOSM(){
if(map.getZoom()<12){showToast('Приблизьтесь к селу (зум 13+)');return;}
const b=map.getBounds();
const bbox=b.getSouth()+','+b.getWest()+','+b.getNorth()+','+b.getEast();
document.getElementById('autoResults').innerHTML='';
document.getElementById('autoPanel').style.display='block';
document.getElementById('autoStatus').textContent='⏳ Загрузка...';
// Дороги — ВСЕ типы (асфальт, грунт, просёлок, полевая)
const qRoads='[out:json];(way["highway"~"motorway|trunk|primary|secondary|tertiary|unclassified|residential|service|track|path|living_street|road"]('+bbox+'););out geom;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qRoads))
.then(r=>r.json()).then(data=>{
roadsLayer.clearLayers();
loadedRoads=[];
let roadCount=0, namedCount=0, labelCount=0;
const roadStyles={
motorway:{color:'#e74c3c',weight:4,label:'асфальт'},
trunk:{color:'#e74c3c',weight:3.5,label:'асфальт'},
primary:{color:'#e74c3c',weight:3,label:'асфальт'},
secondary:{color:'#f39c12',weight:2.5,label:'грунт/асфальт'},
tertiary:{color:'#f39c12',weight:2.5,label:'грунт'},
unclassified:{color:'#f39c12',weight:2,label:'грунт'},
residential:{color:'#f39c12',weight:2,label:'грунт'},
living_street:{color:'#f39c12',weight:2,label:'грунт'},
service:{color:'#95a5a6',weight:1.5,label:'просёлок'},
track:{color:'#95a5a6',weight:1.2,label:'полевая/просёлок'},
path:{color:'#95a5a6',weight:1,label:'тропа',dash:'4,4'},
road:{color:'#f39c12',weight:2,label:'грунт'}
};
(data.elements||[]).forEach(el=>{
if(!el.geometry||el.geometry.length<2)return;
const coords=el.geometry.map(g=>[g.lat,g.lon]);
const type=el.tags&&el.tags.highway||'unclassified';
const name=el.tags&&el.tags.name||el.tags&&el.tags['ref']||'';
const style=roadStyles[type]||{color:'#95a5a6',weight:1.5,label:'просёлок'};
const opts={color:style.color,weight:style.weight,opacity:0.75};
if(style.dash)opts.dashArray=style.dash;
const road=L.polyline(coords,opts).addTo(roadsLayer);
// popup с названием и типом
const info=name?name+' ('+style.label+')':style.label;
road.bindPopup('🛣 '+info);
loadedRoads.push({coords,type,name,road,stylename:style.label});
roadCount++;
if(name)namedCount++;
// подпись названия улицы прямо на карте (в середине сегмента)
if(name&&coords.length>1){
const mid=Math.floor(coords.length/2);
const mlat=coords[mid][0],mlng=coords[mid][1];
const labelIcon=L.divIcon({
className:'',
html:'<span style="background:rgba(0,0,0,.75);color:#00E5FF;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:700;white-space:nowrap;border:1px solid #00E5FF;text-shadow:0 1px 3px rgba(0,0,0,.8)">'+name+'</span>',
iconSize:[0,0],iconAnchor:[0,0]
});
L.marker([mlat,mlng],{icon:labelIcon}).addTo(roadsLayer);
labelCount++;
}
});
document.getElementById('autoStatus').textContent='✅ Всего дорог: '+roadCount+' (с названиями: '+namedCount+'), здания: '+loadedBuildings;
let html='<span class="tag" style="background:#e74c3c;color:#fff">дopoг: '+roadCount+'</span><span class="tag" style="background:#f39c12;color:#fff">назв.: '+namedCount+'</span><span class="tag" style="background:#ff6b35;color:#fff">зданий: '+loadedBuildings+'</span>';
document.getElementById('autoResults').innerHTML=html;
if(roadCount===0)document.getElementById('autoStatus').textContent='⚠ Нет дорог в этом районе (проверьте зум)';
});
// Здания
const qBuild='[out:json];(way["building"]('+bbox+');rel["building"]('+bbox+'););out geom;';
fetch('https://overpass-api.de/api/interpreter?data='+encodeURIComponent(qBuild))
.then(r=>r.json()).then(data=>{
buildingsLayer.clearLayers();
loadedBuildings=0;
(data.elements||[]).forEach(el=>{
if(!el.geometry||el.geometry.length<2)return;
const coords=el.geometry.map(g=>[g.lat,g.lon]);
const poly=L.polygon(coords,{color:'#ff6b35',weight:1,fillColor:'#ff6b35',fillOpacity:0.15});
let info=[];
if(el.tags){
if(el.tags.building)info.push('🏠 '+el.tags.building);
if(el.tags['addr:housenumber'])info.push('№'+el.tags['addr:housenumber']);
if(el.tags['addr:street'])info.push(el.tags['addr:street']);
if(el.tags.name)info.push('📌 '+el.tags.name);
}
if(info.length)poly.bindPopup(info.join('<br>'));
poly.addTo(buildingsLayer);
loadedBuildings++;
});
document.getElementById('autoStatus').textContent='✅ Улиц: '+loadedRoads.length+', здания: '+loadedBuildings;
let html='<span class="tag">'+loadedRoads.length+' улиц</span><span class="tag">'+loadedBuildings+' зданий</span>';
document.getElementById('autoResults').innerHTML=html;
});
}
// ====================== РАССТАВИТЬ ОПОРЫ ВДОЛЬ УЛИЦ ======================
function applyRoadPoles(){
if(!loadedRoads.length){showToast('Нет улиц. Нажмите "Авто" сначала');return;}
const step=parseFloat(document.getElementById('poleStep').value)||50;
const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
let totalPoles=0;
loadedRoads.forEach(road=>{
const pts=road.coords;
if(pts.length<2)return;
const len=pathLength2(pts);
if(len<10)return; // короче 10м пропускаем
const n=Math.max(1,Math.round(len/step));
for(let i=0;i<=n;i++){
const pt=pointOnPath2(pts,i/n);
if(!isNearEntrance(pt,margin)){
addPole(L.latLng(pt.lat,pt.lng),road.name||road.stylename||'');
totalPoles++;
}
}
});
document.getElementById('autoPanel').style.display='none';
showToast('✅ Расставлено '+totalPoles+' опор вдоль дорог');
}
function pathLength2(pts){let t=0;for(let i=1;i<pts.length;i++)t+=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);return t;}
function pointOnPath2(pts,t){
const total=pathLength2(pts);
let target=t*total,acc=0;
for(let i=1;i<pts.length;i++){
const seg=haversine(pts[i-1][0],pts[i-1][1],pts[i][0],pts[i][1]);
if(acc+seg>=target||i===pts.length-1){
const frac=(target-acc)/seg;
return{lat:pts[i-1][0]+(pts[i][0]-pts[i-1][0])*frac,lng:pts[i-1][1]+(pts[i][1]-pts[i-1][1])*frac};
}
acc+=seg;
}
return{lat:pts[pts.length-1][0],lng:pts[pts.length-1][1]};
}
// ====================== РЕЖИМЫ ======================
function setMode(newMode){
mode=newMode;
['btnLine','btnManual','btnEntrance'].forEach(id=>document.getElementById(id).className='');
document.getElementById('btnLine').className='btn-primary';
document.getElementById('btnManual').className='btn-secondary';
document.getElementById('btnEntrance').className='btn-warning';
const el=document.getElementById({line:'btnLine',manual:'btnManual',entrance:'btnEntrance'}[mode]);
if(el)el.classList.add('btn-active');
if(mode==='line')linePoints=[];
document.getElementById('modeInfo').textContent={
line:'📏 Линия: клик по улице, Enter = расставить',
manual:'🎯 Клик для установки опоры',
entrance:'🚗 Клик для отметки въезда'}[mode];
updateModeBadge();
}
function updateModeBadge(){
const b=document.getElementById('modeBadge');
b.style.display='block';
b.innerHTML={
manual:'🎯 Кликните для опоры',
line:'📏 Клик точки линии · Enter = расставить',
entrance:'🚗 Клик на въезде'
}[mode];
}
// ====================== КЛИК ======================
function onMapClick(e){
if(mode==='manual'){
const snap=document.getElementById('snapRoad').checked;
if(snap&&loadedRoads.length){const pt=snapToRoad(e.latlng);if(pt)addPole(pt);else addPole(e.latlng);}
else addPole(e.latlng);
return;
}
if(mode==='entrance'){addEntrance(e.latlng);return;}
if(mode==='line'){addLinePoint(e.latlng);return;}
}
// ====================== ПРИВЯЗКА К ДОРОГЕ ======================
function snapToRoad(latlng){
let best=null,bestDist=Infinity;
loadedRoads.forEach(road=>{
for(let i=1;i<road.coords.length;i++){
const a=road.coords[i-1],b=road.coords[i];
const p=closestPointOnSegment(latlng.lat,latlng.lng,a[0],a[1],b[0],b[1]);
if(p.dist<bestDist&&p.dist<30){bestDist=p.dist;best=L.latLng(p.lat,p.lng);}
}
});
return best;
}
function closestPointOnSegment(px,py,ax,ay,bx,by){
const dx=bx-ax,dy=by-ay;
const len2=dx*dx+dy*dy;
let t=len2>0?((px-ax)*dx+(py-ay)*dy)/len2:0;
t=Math.max(0,Math.min(1,t));
const lat=ax+t*dx,lng=ay+t*dy;
return{lat,lng,dist:haversine(px,py,lat,lng)};
}
// ====================== ОПОРЫ ======================
function getRoadName(latlng){
let best=null,bestDist=Infinity;
loadedRoads.forEach(road=>{
for(let i=1;i<road.coords.length;i++){
const a=road.coords[i-1],b=road.coords[i];
const p=closestPointOnSegment(latlng.lat,latlng.lng,a[0],a[1],b[0],b[1]);
if(p.dist<bestDist&&p.dist<20){bestDist=p.dist;best=road.name||road.stylename||'';}
}
});
return best;
}
function addPole(latlng,roadName){
poleCount++;
const rn=roadName||getRoadName(latlng);
const suffix=rn?' ('+rn+')':'';
const name='Опора '+poleCount;
const icon=L.divIcon({className:'',html:'<div class="pole-label">'+poleCount+'</div>',iconSize:[22,22],iconAnchor:[11,11]});
const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('<b>'+name+'</b><br>'+latlng.lat.toFixed(6)+', '+latlng.lng.toFixed(6)+suffix);
markers.push({id:poleCount,lat:latlng.lat,lng:latlng.lng,name,marker,road:rn});
if(markers.length>1){
const p=markers[markers.length-2];
const line=L.polyline([[p.lat,p.lng],[latlng.lat,latlng.lng]],{color:'#00E5FF',weight:2.5,dashArray:'6,5'}).addTo(map);
lines.push(line);
}
updateTable();
showToast('✅ '+name);
}
// ====================== ЛИНИИ ======================
function addLinePoint(latlng){
linePoints.push(latlng);
if(linePoints.length>1){
const p=linePoints[linePoints.length-2];
L.polyline([[p.lat,p.lng],[latlng.lat,latlng.lng]],{color:'#FF9800',weight:2,dashArray:'4,4',opacity:0.6}).addTo(map)._tmpLine=true;
}
L.circleMarker(latlng,{radius:4,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.8}).addTo(map)._tmpPoint=true;
}
function finishLine(){
if(linePoints.length<2){showToast('Нарисуйте минимум 2 точки');return;}
const step=parseFloat(document.getElementById('poleStep').value)||50;
const margin=parseFloat(document.getElementById('entranceMargin').value)||5;
const totalLen=pathLength(linePoints);
const n=Math.max(1,Math.round(totalLen/step));
const poles=[];
for(let i=0;i<=n;i++){
const pt=pointOnPath(linePoints,i/n);
if(!isNearEntrance(pt,margin))poles.push(pt);
}
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
linePoints=[];
if(!poles.length){showToast('❌ Всё перекрыто въездами');setMode('manual');return;}
poles.forEach(p=>addPole(L.latLng(p.lat,p.lng)));
setMode('manual');
}
function pathLength(pts){let t=0;for(let i=1;i<pts.length;i++)t+=haversine(pts[i-1].lat,pts[i-1].lng,pts[i].lat,pts[i].lng);return t;}
function pointOnPath(pts,t){
const total=pathLength(pts);
let target=t*total,acc=0;
for(let i=1;i<pts.length;i++){
const seg=haversine(pts[i-1].lat,pts[i-1].lng,pts[i].lat,pts[i].lng);
if(acc+seg>=target||i===pts.length-1){const frac=(target-acc)/seg;return{lat:pts[i-1].lat+(pts[i].lat-pts[i-1].lat)*frac,lng:pts[i-1].lng+(pts[i].lng-pts[i-1].lng)*frac};}
acc+=seg;
}
return pts[pts.length-1];
}
// ====================== ВЪЕЗДЫ ======================
function addEntrance(latlng){
const icon=L.divIcon({className:'',html:'<div class="entrance-label">🚗 В</div>',iconSize:[26,18],iconAnchor:[13,9]});
const marker=L.marker(latlng,{icon}).addTo(map).bindPopup('🚗 Въезд');
entrances.push({lat:latlng.lat,lng:latlng.lng,marker});
const circle=L.circle(latlng,{radius:5,color:'#FF9800',fillColor:'#FF9800',fillOpacity:0.15,dashArray:'3,3'}).addTo(map);
entranceCircles.push(circle);
showToast('🚗 Въезд отмечен');
}
function isNearEntrance(pt,margin){
for(const e of entrances)if(haversine(pt.lat,pt.lng,e.lat,e.lng)<margin)return true;
return false;
}
// ====================== РАССТОЯНИЕ ======================
function haversine(lat1,lon1,lat2,lon2){
const R=6371000;
const toRad=x=>x*Math.PI/180;
const dLat=toRad(lat2-lat1),dLon=toRad(lon2-lon1);
return R*2*Math.atan2(Math.sqrt(Math.sin(dLat/2)**2+Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2),Math.sqrt(1-(Math.sin(dLat/2)**2+Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2)));
}
// ====================== ТАБЛИЦА ======================
function updateTable(){
const tbody=document.getElementById('poleTable');
tbody.innerHTML='';
let total=0;
markers.forEach((p,i)=>{
const dist=i>0?haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng):0;
total+=dist;
const tr=document.createElement('tr');
tr.innerHTML='<td class="num">'+p.id+'</td><td>'+p.name+'</td><td style="max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(p.road||'')+'</td><td>'+p.lat.toFixed(6)+'</td><td>'+p.lng.toFixed(6)+'</td><td class="dist">'+(i>0?dist.toFixed(1):'—')+'</td>';
tbody.appendChild(tr);
});
const avg=markers.length>1?(total/(markers.length-1)):0;
document.getElementById('totalDist').textContent='Итого: '+total.toFixed(1)+' м | '+markers.length+' опор | ср.пролёт '+avg.toFixed(1)+' м';
document.getElementById('stats').textContent=markers.length+' шт';
}
// ====================== УПРАВЛЕНИЕ ======================
function undo(){
if(entrances.length){
const e=entrances.pop();map.removeLayer(e.marker);
const c=entranceCircles.pop();if(c)map.removeLayer(c);
showToast('↩ Въезд удалён');return;
}
if(!markers.length)return;
const last=markers.pop();map.removeLayer(last.marker);
if(lines.length)map.removeLayer(lines.pop());
poleCount--;updateTable();
}
function clearAll(){
if(!markers.length&&!entrances.length)return;
if(!confirm('Удалить всё?'))return;
markers.forEach(m=>map.removeLayer(m.marker));
lines.forEach(l=>map.removeLayer(l));
entrances.forEach(e=>map.removeLayer(e.marker));
entranceCircles.forEach(c=>map.removeLayer(c));
map.eachLayer(l=>{if(l._tmpLine||l._tmpPoint)map.removeLayer(l);});
markers=[];lines=[];entrances=[];entranceCircles=[];poleCount=0;linePoints=[];
updateTable();
}
document.addEventListener('keydown',e=>{if(e.key==='Enter'&&mode==='line')finishLine();});
// ====================== ЭКСПОРТ ======================
function exportSVG(){
if(!markers.length){showToast('Нет опор');return;}
const first=markers[0];
const step=parseFloat(document.getElementById('poleStep').value)||50;
let svg=`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600">
<style>text{font-family:Arial,sans-serif}.pole{fill:#0F1218;stroke:#00E5FF;stroke-width:2}.pole-label{fill:#00E5FF;font-size:11px;font-weight:bold;text-anchor:middle;dominant-baseline:central}.line{fill:none;stroke:#00E5FF;stroke-width:2;stroke-dasharray:6,5}.entrance{fill:#FF9800}.title{font-size:16px;font-weight:bold}.sub{font-size:11px;fill:#666}</style>
<rect width="800" height="600" fill="#fff"/>`;
const date=new Date().toLocaleDateString('ru-RU');
svg+=`<text x="20" y="28" class="title">Схема расстановки опор ВЛ-0.4 кВ</text>
<text x="20" y="46" class="sub">Дата: ${date} | Шаг: ${step} м | ${markers.length} опор</text>
<text x="20" y="62" class="sub">Центр: ${first.lat.toFixed(4)}, ${first.lng.toFixed(4)}</text>`;
const lats=markers.map(m=>m.lat),lngs=markers.map(m=>m.lng);
const minLat=Math.min(...lats),maxLat=Math.max(...lats);
const minLng=Math.min(...lngs),maxLng=Math.max(...lngs);
const pad=60;
const sx=x=>pad+(x-minLng)/(maxLng-minLng||1)*(760-2*pad);
const sy=y=>560-pad-(y-minLat)/(maxLat-minLat||1)*(560-2*pad-85);
markers.forEach((p,i)=>{if(i>0){const p1=markers[i-1];svg+=`<line x1="${sx(p1.lng)}" y1="${sy(p1.lat)}" x2="${sx(p.lng)}" y2="${sy(p.lat)}" class="line"/>`;}});
markers.forEach(p=>{const x=sx(p.lng),y=sy(p.lat);svg+=`<circle cx="${x}" cy="${y}" r="10" class="pole"/><text x="${x}" y="${y}" class="pole-label">${p.id}</text><text x="${x+12}" y="${y+3}" font-size="9" fill="#333">${p.name}</text>`;});
entrances.forEach(e=>{const x=sx(e.lng),y=sy(e.lat);svg+=`<rect x="${x-8}" y="${y-5}" width="16" height="10" rx="2" class="entrance"/><text x="${x}" y="${y+3}" font-size="8" fill="#fff" text-anchor="middle" font-weight="bold">В</text>`;});
let total=0;markers.forEach((p,i)=>{if(i>0)total+=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);});
svg+=`<text x="20" y="580" class="sub">Всего: ${total.toFixed(1)} м | ${markers.length} опор | ср.пролёт: ${markers.length>1?(total/(markers.length-1)).toFixed(1):0} м</text></svg>`;
download(svg,'opory_vl04.svg','image/svg+xml');
showToast('📥 SVG (Visio/draw.io)');
}
function exportDrawIO(){
if(!markers.length){showToast('Нет опор');return;}
const date=new Date().toLocaleDateString('ru-RU');
let mx='<mxfile><diagram name="Опоры ВЛ-0.4 кВ" id="pole-diagram"><mxGraphModel dx="800" dy="600"><root><mxCell id="0"/><mxCell id="1" parent="0"/>';
mx+=`<mxCell id="t" value="Схема расстановки опор ВЛ-0.4 кВ&nbsp;|&nbsp;${date}&nbsp;|&nbsp;${markers.length} опор" style="text;html=1;fontSize=14;fontWeight=bold;align=left;" vertex="1" parent="1"><mxGeometry x="20" y="10" width="500" height="30" as="geometry"/></mxCell>`;
const lats=markers.map(m=>m.lat),lngs=markers.map(m=>m.lng);
const minLat=Math.min(...lats),maxLat=Math.max(...lats);
const minLng=Math.min(...lngs),maxLng=Math.max(...lngs);
const sx=x=>40+(x-minLng)/(maxLng-minLng||1)*700;
const sy=y=>560-(y-minLat)/(maxLat-minLat||1)*520;
let id=100;
markers.forEach((p,i)=>{if(i>0){const d=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1);id++;mx+=`<mxCell id="l${id}" value="${d} м" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeColor=#00E5FF;strokeWidth=2;dashed=1;fontSize=10;align=center;verticalAlign=bottom;labelBackgroundColor=#ffffff;" edge="1" parent="1" source="p${p1.id}" target="p${p.id}"><mxGeometry relative="1" as="geometry"/></mxCell>`;}});
markers.forEach(p=>{const x=sx(p.lng).toFixed(0),y=sy(p.lat).toFixed(0);mx+=`<mxCell id="p${p.id}" value="&lt;b&gt;${p.id}&lt;/b&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#0F1218;strokeColor=#00E5FF;strokeWidth=2;fontColor=#00E5FF;fontSize=11;fontWeight=bold;" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="24" height="24" as="geometry"/></mxCell><mxCell id="pl${p.id}" value="${p.name}" style="text;html=1;fontSize=9;align=left;" vertex="1" parent="1"><mxGeometry x="${Number(x)+28}" y="${Number(y)+6}" width="200" height="14" as="geometry"/></mxCell>`;});
entrances.forEach((e,i)=>{const x=sx(e.lng).toFixed(0),y=sy(e.lat).toFixed(0);mx+=`<mxCell id="e${i}" value="В" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FF9800;strokeColor=#FF9800;fontColor=#fff;fontSize=9;fontWeight=bold;" vertex="1" parent="1"><mxGeometry x="${Number(x)-8}" y="${Number(y)-6}" width="16" height="12" as="geometry"/></mxCell>`;});
let total=0;markers.forEach((p,i)=>{if(i>0)total+=haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng);});
mx+=`<mxCell id="sum" value="Всего: ${total.toFixed(1)} м | ${markers.length} опор | ср.пролёт ${markers.length>1?(total/(markers.length-1)).toFixed(1):0} м" style="text;html=1;fontSize=11;fontWeight=bold;align=left;fillColor=#F2F4F7;rounded=1;" vertex="1" parent="1"><mxGeometry x="20" y="560" width="300" height="24" as="geometry"/></mxCell>`;
mx+=`</root></mxGraphModel></diagram></mxfile>`;
download(mx,'opory_vl04.drawio','application/xml');
showToast('📥 draw.io');
}
function exportCSV(){
if(!markers.length){showToast('Нет опор');return;}
let csv='№,Название,Улица,Широта,Долгота,Расстояниео_предыдущей_м\n';
markers.forEach((p,i)=>{const d=i>0?haversine(markers[i-1].lat,markers[i-1].lng,p.lat,p.lng).toFixed(1):'';csv+=p.id+','+p.name+','+(p.road||'')+','+p.lat.toFixed(6)+','+p.lng.toFixed(6)+','+d+'\n';});
download(csv,'opory_vl04.csv','text/csv;charset=utf-8');
}
function download(content,filename,type){
const blob=new Blob([content],{type:'application/octet-stream'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);
}
function showToast(msg){
const t=document.getElementById('toast');
t.textContent=msg;t.classList.add('show');
clearTimeout(t._timer);
t._timer=setTimeout(()=>t.classList.remove('show'),2500);
}
init();
</script>
</body>
</html>