Initial commit: interactive Kazakhstan internet speed map with lazy-loaded providers
This commit is contained in:
commit
48e63b12da
313
PRD.md
Normal file
313
PRD.md
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
# PRD: Интерактивная карта скорости интернета по провайдерам Казахстана
|
||||||
|
|
||||||
|
**Версия:** 1.3
|
||||||
|
**Дата:** 25 мая 2026
|
||||||
|
**Статус:** Draft — уточнён после Q&A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Обзор продукта
|
||||||
|
|
||||||
|
### 1.1 Цель
|
||||||
|
|
||||||
|
Создать интерактивную веб-карту Казахстана, которая визуализирует данные Speedtest Intelligence по средним скоростям download/upload фиксированной сети в разрезе интернет-провайдеров. Карта должна позволять аналитикам и руководству быстро сравнивать качество покрытия и скорости разных провайдеров на уровне страны, области и города.
|
||||||
|
|
||||||
|
### 1.2 Контекст данных
|
||||||
|
|
||||||
|
Исходные данные — агрегация из Speedtest Intelligence, **формат: CSV файл**. Загружается в браузере через PapaParse без серверной части.
|
||||||
|
|
||||||
|
Структура CSV:
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `provider` | string | Название провайдера фиксированной сети |
|
||||||
|
| `latitude` | float | Широта точки измерения |
|
||||||
|
| `longitude` | float | Долгота точки измерения |
|
||||||
|
| `avg_download_mbps` | float | Средняя скорость скачивания, Мбит/с |
|
||||||
|
| `avg_upload_mbps` | float | Средняя скорость загрузки, Мбит/с |
|
||||||
|
|
||||||
|
Одна строка = один уникальный кластер `(provider, lat, lon)` с агрегированными метриками.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Целевая аудитория
|
||||||
|
|
||||||
|
**Продукт предназначен для внутреннего использования командой.** Публичный доступ и авторизация не требуются в рамках V1.
|
||||||
|
|
||||||
|
| Пользователь | Сценарий использования |
|
||||||
|
|---|---|
|
||||||
|
| Аналитик команды (основной) | Исследование качества покрытия, подготовка отчётов |
|
||||||
|
| Руководство | Быстрый обзор конкурентной картины на встречах |
|
||||||
|
| Смежные команды | Ситуативный доступ к карте для контекста |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Пользовательские сценарии (User Stories)
|
||||||
|
|
||||||
|
**US-01.** Как аналитик, я хочу видеть зоны покрытия каждого провайдера на карте Казахстана, чтобы сразу понимать географию присутствия.
|
||||||
|
|
||||||
|
**US-02.** Как аналитик, я хочу приблизить карту до уровня города и увидеть отдельные точки измерений, чтобы изучить детализированную картину внутри региона.
|
||||||
|
|
||||||
|
**US-03.** Как пользователь, я хочу навести мышь на зону или точку и увидеть всплывающую плашку с именем провайдера и средними скоростями, чтобы не переключаться между экранами.
|
||||||
|
|
||||||
|
**US-04.** Как аналитик, я хочу включать и отключать отдельных провайдеров, чтобы сравнивать двух-трёх игроков без визуального шума.
|
||||||
|
|
||||||
|
**US-05.** Как пользователь, я хочу видеть границы областей и городов Казахстана, чтобы соотносить данные с административным делением страны.
|
||||||
|
|
||||||
|
**US-06.** Как аналитик, я хочу кликнуть на область/город и увидеть сводку по всем провайдерам внутри этой административной единицы, чтобы делать региональные сравнения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Функциональные требования
|
||||||
|
|
||||||
|
### 4.1 Карта (Map Layer)
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-01 | Базовая карта Казахстана на основе тайлов OpenStreetMap (или Mapbox/MapTiler) | Must |
|
||||||
|
| F-02 | Административные границы: области (14 ед.) и города республиканского значения | Must |
|
||||||
|
| F-03 | GeoJSON границ загружается из публичного источника (GADM, Natural Earth) или хранится локально | Must |
|
||||||
|
| F-04 | Границы отображаются полупрозрачным контуром, не перекрывая данные провайдеров | Must |
|
||||||
|
| F-05 | При клике на область — боковая панель со сводкой провайдеров внутри | Should |
|
||||||
|
|
||||||
|
### 4.2 Зоны покрытия (Cluster Layer — низкий zoom)
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-10 | При zoom ≤ 8 данные отображаются как зоны покрытия провайдера | Must |
|
||||||
|
| F-11 | Перед построением hull точки провайдера **группируются в географические кластеры** по расстоянию: две точки попадают в один кластер, если расстояние между ними ≤ 20 км (настраиваемый порог) | Must |
|
||||||
|
| F-12 | Hull строится **отдельно для каждого кластера** одного провайдера, а не по всем точкам сразу. Это предотвращает ситуацию, когда крупный провайдер (Казахтелеком) закрывает всю карту одним полигоном | Must |
|
||||||
|
| F-13 | Алгоритм кластеризации точек для hull: **DBSCAN** или жадный radius-grouping с eps = 20 км | Must |
|
||||||
|
| F-14 | Провайдеры с числом точек < 3 в кластере **исключаются** из отображения hull (hull по 1–2 точкам геометрически некорректен). Провайдеры с единственной точкой в датасете исключены полностью | Must |
|
||||||
|
| F-15 | Каждый провайдер имеет свой уникальный цвет; цвет консистентен между всеми слоями | Must |
|
||||||
|
| F-16 | Полигоны hull отображаются с прозрачностью заливки ~30–40%, чтобы перекрытия соседних зон были видимы | Must |
|
||||||
|
| F-17 | Heatmap по плотности точек — опция V2 | Could |
|
||||||
|
|
||||||
|
### 4.3 Точки измерений (Point Layer — высокий zoom)
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-20 | При zoom ≥ 9 hull-полигоны скрываются, вместо них отображаются кружки по координатам каждой точки | Must |
|
||||||
|
| F-21 | Размер кружка кодирует avg_download_mbps (больше скорость — больше кружок), нормализация по всему датасету | Should |
|
||||||
|
| F-22 | Цвет кружка — цвет провайдера (совпадает с цветом hull-зоны) | Must |
|
||||||
|
| F-23 | Плавный переход (opacity fade) между слоями при пересечении порога zoom | Should |
|
||||||
|
| F-24 | Провайдеры с единственной точкой в датасете **не отображаются** ни на одном zoom-уровне | Must |
|
||||||
|
|
||||||
|
### 4.4 Tooltip (всплывающая плашка)
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-30 | Tooltip появляется при наведении (hover) на зону или точку | Must |
|
||||||
|
| F-31 | Содержимое tooltip: название провайдера, avg download Мбит/с, avg upload Мбит/с | Must |
|
||||||
|
| F-32 | Для зон — в tooltip агрегированные значения по всем точкам провайдера в видимой зоне | Must |
|
||||||
|
| F-33 | Tooltip не перекрывает элементы управления картой | Should |
|
||||||
|
| F-34 | При клике на точку — зафиксированный popup с расширенными данными (кол-во измерений, если есть) | Could |
|
||||||
|
|
||||||
|
### 4.5 Фильтрация и легенда
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-40 | Легенда со списком провайдеров и их цветами — фиксированная панель | Must |
|
||||||
|
| F-41 | Чекбокс рядом с каждым провайдером — включить/выключить слой | Must |
|
||||||
|
| F-42 | Кнопка «выбрать все» / «снять все» | Should |
|
||||||
|
| F-43 | Поиск по названию провайдера в легенде (если провайдеров > 10) | Could |
|
||||||
|
|
||||||
|
### 4.6 Экспорт данных региона
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-60 | Кнопка «Экспорт CSV» доступна после клика на область/регион на карте | Must |
|
||||||
|
| F-61 | Экспортируется CSV со всеми точками провайдеров, попадающими в выбранную административную единицу (область или город) | Must |
|
||||||
|
| F-62 | Состав экспортируемых полей: `provider`, `latitude`, `longitude`, `avg_download_mbps`, `avg_upload_mbps` | Must |
|
||||||
|
| F-63 | Имя файла включает название региона и дату экспорта: `kz_{region}_{YYYY-MM-DD}.csv` | Should |
|
||||||
|
| F-64 | Если активны фильтры по провайдерам (чекбоксы), экспортируются только видимые провайдеры | Should |
|
||||||
|
| F-65 | Экспорт реализуется на стороне браузера (Blob API) — без серверной части | Must |
|
||||||
|
|
||||||
|
### 4.7 Цветовая шкала скоростей
|
||||||
|
|
||||||
|
| ID | Требование | Приоритет |
|
||||||
|
|----|-----------|-----------|
|
||||||
|
| F-70 | Диапазон значений: **0 — 1000 Мбит/с** для download и upload | Must |
|
||||||
|
| F-71 | Шкала **логарифмическая** (log10): большинство точек сосредоточено в нижнем диапазоне (1–100 Мбит/с), линейная шкала сделает их неразличимыми. Логарифм выравнивает визуальный вес | Must |
|
||||||
|
| F-72 | Цветовой градиент шкалы download: красный (≈0) → жёлтый (≈50 Мбит/с) → зелёный (≥300 Мбит/с) | Should |
|
||||||
|
| F-73 | В режиме «по скорости» в легенде отображается градиентная шкала с отметками: 1 / 10 / 50 / 100 / 300 / 1000 Мбит/с | Must |
|
||||||
|
| F-74 | Размер кружков на zoom ≥ 9 нормализуется по той же log-шкале: min-radius = 4px, max-radius = 18px | Should |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Нефункциональные требования
|
||||||
|
|
||||||
|
| Категория | Требование |
|
||||||
|
|-----------|-----------|
|
||||||
|
| **Производительность** | Первоначальная загрузка карты ≤ 3 с при типичном наборе данных (< 50 000 точек) |
|
||||||
|
| **Масштабируемость** | При > 50 000 точек — включение кластеризации (MarkerCluster или supercluster) |
|
||||||
|
| **Браузерная поддержка** | Chrome, Firefox, Edge — последние 2 версии |
|
||||||
|
| **Адаптивность** | Desktop-first; базовая работоспособность на планшете |
|
||||||
|
| **Доступность данных** | Данные загружаются из CSV/JSON файла или REST API-эндпоинта |
|
||||||
|
| **Автономность** | Карта работает без внешних платных API-ключей (OSM тайлы бесплатны) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Технический стек (рекомендуемый)
|
||||||
|
|
||||||
|
| Компонент | Вариант | Обоснование |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| Картографическая библиотека | **Leaflet.js** | Легковесный, бесплатный, широкая экосистема плагинов |
|
||||||
|
| Базовые тайлы | OpenStreetMap / CartoDB Positron | Без платного API-ключа |
|
||||||
|
| GeoJSON границ КЗ | GADM v4.1 (`gadm41_KAZ_1.json`) | Публичный, точные границы областей |
|
||||||
|
| Загрузка CSV | **PapaParse** | Парсинг CSV прямо в браузере, без бэкенда |
|
||||||
|
| Кластеризация для hull | **Turf.js** `@turf/clusters-dbscan` | DBSCAN с настраиваемым radius (eps = 20 км) прямо в браузере |
|
||||||
|
| Построение hull | **Turf.js** `@turf/convex` | Convex hull по точкам каждого кластера |
|
||||||
|
| Цветовые шкалы | D3.js (`d3-scale`, `d3-scale-chromatic`) | Лёгкий импорт, нет нужды в полном D3 |
|
||||||
|
| Деплой | **Локально в браузере** — статический HTML+JS файл, открывается напрямую (`file://`) или через `npx serve` | Без сервера, без авторизации |
|
||||||
|
| Авторизация | **Не требуется** | Внутренний инструмент |
|
||||||
|
| Обновление данных | **Вручную** — пользователь заменяет CSV файл и перезагружает страницу | Нет scheduled refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. UX-описание интерфейса
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [Заголовок: Скорость интернета по провайдерам КЗ] [Режим ▾] │
|
||||||
|
├──────────────┬──────────────────────────────────────────────────┤
|
||||||
|
│ ЛЕГЕНДА │ │
|
||||||
|
│ │ │
|
||||||
|
│ ☑ Казахтелеком │ [ К А Р Т А ] │
|
||||||
|
│ ☑ Beeline │ │
|
||||||
|
│ ☑ Tele2 │ Зоны покрытия (zoom ≤ 8) │
|
||||||
|
│ ☑ Kcell │ → точки (zoom ≥ 9) │
|
||||||
|
│ ☑ Altel │ │
|
||||||
|
│ ... │ [Tooltip при hover] │
|
||||||
|
│ │ ┌─────────────────┐ │
|
||||||
|
│ [Выбрать все] │ Казахтелеком │ │
|
||||||
|
│ [Снять все] │ │ ↓ 87.4 Мбит/с │ │
|
||||||
|
│ │ │ ↑ 42.1 Мбит/с │ │
|
||||||
|
│ ─────────── │ └─────────────────┘ │
|
||||||
|
│ Окраска: │ │
|
||||||
|
│ ○ Провайдер │ │
|
||||||
|
│ ○ Скорость │ │
|
||||||
|
└──────────────┴──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Логика переключения слоёв
|
||||||
|
|
||||||
|
```
|
||||||
|
zoom ≤ 8 → показать: Hull-полигоны (один hull на географический кластер провайдера)
|
||||||
|
скрыть: точки
|
||||||
|
tooltip при hover: название провайдера + агрегированные avg по кластеру
|
||||||
|
|
||||||
|
zoom 8–9 → плавный crossfade между слоями (opacity transition)
|
||||||
|
|
||||||
|
zoom ≥ 9 → скрыть: hull-полигоны
|
||||||
|
показать: кружки по (lat, lon)
|
||||||
|
размер кружка = f(avg_download_mbps), нормализация по датасету
|
||||||
|
tooltip: данные конкретной точки
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логика построения hull-кластеров
|
||||||
|
|
||||||
|
```
|
||||||
|
Для каждого провайдера:
|
||||||
|
1. Взять все точки провайдера
|
||||||
|
2. Исключить провайдера, если точек < 3 (hull невозможен)
|
||||||
|
3. Запустить DBSCAN с eps = 20 км, minPoints = 3
|
||||||
|
4. Для каждого кластера (не шум):
|
||||||
|
→ построить convex hull по точкам кластера
|
||||||
|
→ назначить цвет провайдера
|
||||||
|
→ добавить полигон на карту
|
||||||
|
5. Точки, отнесённые к «шуму» (изолированные):
|
||||||
|
→ не отображаются на zoom ≤ 8
|
||||||
|
→ отображаются как кружки на zoom ≥ 9
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Параметр eps = 20 км** — настраиваемый. При необходимости аналитик может изменить порог
|
||||||
|
> через конфиг-переменную в начале JS-файла без правки основного кода.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Административное деление Казахстана
|
||||||
|
|
||||||
|
Источник GeoJSON для границ — **GADM v4.1**:
|
||||||
|
|
||||||
|
- Уровень 1 (`KAZ_1`): 17 областей + города Алматы, Астана, Шымкент
|
||||||
|
- Уровень 2 (`KAZ_2`): районы (используется опционально для детализации)
|
||||||
|
|
||||||
|
URL для загрузки: `https://gadm.org/download_country.html` → выбрать Kazakhstan, GeoJSON.
|
||||||
|
|
||||||
|
Альтернатива — **Natural Earth** (упрощённые границы, меньше детализации, но легче по весу).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Tooltip: спецификация содержимого
|
||||||
|
|
||||||
|
### При наведении на зону (zoom ≤ 8)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 🌐 Казахтелеком │
|
||||||
|
│ Область: Алматинская │
|
||||||
|
│ ────────────────────────── │
|
||||||
|
│ ↓ Скачивание: 87.4 Мбит/с │
|
||||||
|
│ ↑ Загрузка: 42.1 Мбит/с │
|
||||||
|
│ Точек данных: 142 │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### При наведении на точку (zoom ≥ 9)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 🌐 Казахтелеком │
|
||||||
|
│ 43.2561°N, 76.9285°E │
|
||||||
|
│ ────────────────────────── │
|
||||||
|
│ ↓ Скачивание: 91.2 Мбит/с │
|
||||||
|
│ ↑ Загрузка: 44.7 Мбит/с │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Открытые вопросы
|
||||||
|
|
||||||
|
Все вопросы закрыты. PRD готов к передаче в разработку.
|
||||||
|
|
||||||
|
| # | Вопрос | Статус | Решение |
|
||||||
|
|---|--------|--------|---------|
|
||||||
|
| Q1 | Формат входных данных | ✅ Закрыт | CSV, загрузка через PapaParse |
|
||||||
|
| Q2 | Нужна ли авторизация? | ✅ Закрыт | Нет — внутренний инструмент |
|
||||||
|
| Q3 | Обновление данных | ✅ Закрыт | Вручную — замена CSV файла + перезагрузка страницы |
|
||||||
|
| Q4 | Провайдеры с одной точкой | ✅ Закрыт | Исключены полностью из отображения |
|
||||||
|
| Q5 | Экспорт | ✅ Закрыт | CSV региона через Blob API в браузере (см. F-60–F-65) |
|
||||||
|
| Q6 | Тип зон покрытия | ✅ Закрыт | Convex hull с DBSCAN-кластеризацией (eps = 20 км) |
|
||||||
|
| Q7 | Деплой | ✅ Закрыт | Локально в браузере, статический HTML файл |
|
||||||
|
| Q8 | Диапазон скоростей | ✅ Закрыт | 0–1000 Мбит/с, логарифмическая шкала (см. F-70–F-74) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Роадмап
|
||||||
|
|
||||||
|
### V1 (MVP)
|
||||||
|
- Базовая карта + OSM тайлы
|
||||||
|
- Загрузка CSV данных через PapaParse (drag & drop или file picker)
|
||||||
|
- DBSCAN-кластеризация точек провайдера (eps = 20 км) + convex hull на каждый кластер
|
||||||
|
- Точки при высоком zoom (≥ 9), размер по log-шкале download
|
||||||
|
- Hover tooltip: провайдер, download, upload
|
||||||
|
- Легенда с чекбоксами по провайдерам
|
||||||
|
- GeoJSON границы областей КЗ (GADM v4.1)
|
||||||
|
- Клик на область → выбор региона
|
||||||
|
- Экспорт CSV точек выбранного региона (с учётом активных фильтров)
|
||||||
|
|
||||||
|
### V2
|
||||||
|
- Режим окраски по скорости (градиент красный→зелёный) с переключателем
|
||||||
|
- Тепловая карта плотности (heatmap) как альтернатива hull
|
||||||
|
- Поиск провайдера в легенде
|
||||||
|
- Кластеризация маркеров при большом кол-ве точек (Leaflet.markercluster)
|
||||||
|
- Сводная боковая панель при клике на область: таблица провайдеров с их avg скоростями
|
||||||
|
|
||||||
|
### V3
|
||||||
|
- Сравнительный вид двух провайдеров (split или overlay)
|
||||||
|
- Исторические тренды по периодам (если данные будут содержать временну́ю метку)
|
||||||
|
- Настройка eps порога прямо из UI
|
||||||
97
README.md
Normal file
97
README.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# Интерактивная карта скорости интернета по провайдерам Казахстана
|
||||||
|
|
||||||
|
Визуализация данных Speedtest Intelligence по средним скоростям download/upload фиксированной сети в разрезе интернет-провайдеров Казахстана.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Клонировать / перейти в директорию проекта
|
||||||
|
cd speedtestMap
|
||||||
|
|
||||||
|
# 2. Поместить CSV с данными
|
||||||
|
# Скопируйте ваш speedtest_aggregated.csv в data/speedtest.csv
|
||||||
|
|
||||||
|
# 3. Скачать GeoJSON границ областей Казахстана
|
||||||
|
# https://gadm.org/download_country.html → Kazakhstan → GeoJSON (Level 1)
|
||||||
|
# Сохранить как data/kaz_regions.geojson
|
||||||
|
|
||||||
|
# 4. Запустить локальный сервер (решает CORS для fetch)
|
||||||
|
npx serve .
|
||||||
|
|
||||||
|
# 5. Открыть в браузере: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
speedtestMap/
|
||||||
|
├── index.html # Единственный файл приложения (весь JS и CSS внутри)
|
||||||
|
├── data/
|
||||||
|
│ ├── speedtest.csv # Данные провайдеров (заменяется вручную)
|
||||||
|
│ └── kaz_regions.geojson # Границы областей КЗ (GADM KAZ_1)
|
||||||
|
├── plans/
|
||||||
|
│ └── implementation-plan.md # План реализации
|
||||||
|
└── README.md # Этот файл
|
||||||
|
```
|
||||||
|
|
||||||
|
## Формат CSV
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `provider` | string | Название провайдера |
|
||||||
|
| `latitude` | float | Широта |
|
||||||
|
| `longitude` | float | Долгота |
|
||||||
|
| `avg_download_mbps` | float | Средняя скорость скачивания (Мбит/с) |
|
||||||
|
| `avg_upload_mbps` | float | Средняя скорость загрузки (Мбит/с) |
|
||||||
|
|
||||||
|
## Функциональность
|
||||||
|
|
||||||
|
### V1 (MVP)
|
||||||
|
- Базовая карта Казахстана (CartoDB Positron тайлы)
|
||||||
|
- Загрузка CSV данных через PapaParse
|
||||||
|
- DBSCAN-кластеризация точек провайдера (eps = 20 км) + convex hull
|
||||||
|
- Точки при высоком zoom (≥ 9), размер по log-шкале download
|
||||||
|
- Hover tooltip: провайдер, download, upload
|
||||||
|
- Легенда с чекбоксами по провайдерам
|
||||||
|
- GeoJSON границы областей КЗ (GADM v4.1)
|
||||||
|
- Клик на область → выбор региона
|
||||||
|
- Экспорт CSV точек выбранного региона
|
||||||
|
|
||||||
|
### Переключение слоёв
|
||||||
|
- **zoom ≤ 8**: полигоны зон покрытия (hull)
|
||||||
|
- **zoom 8–9**: плавный crossfade
|
||||||
|
- **zoom ≥ 9**: точки измерений (WebGL)
|
||||||
|
|
||||||
|
## Технический стек
|
||||||
|
|
||||||
|
| Компонент | Библиотека |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Карта | Leaflet.js 1.9.x |
|
||||||
|
| Тайлы | CartoDB Positron (бесплатно) |
|
||||||
|
| Рендеринг точек | Leaflet.glify (WebGL) |
|
||||||
|
| Парсинг CSV | PapaParse 5.x |
|
||||||
|
| Кластеризация | DBSCAN (собственная реализация в Web Worker) |
|
||||||
|
| Пространственный индекс | rbush 3.x |
|
||||||
|
| Цветовые шкалы | chroma.js 2.x |
|
||||||
|
|
||||||
|
## Обновление данных
|
||||||
|
|
||||||
|
1. Замените `data/speedtest.csv` новым файлом
|
||||||
|
2. Перезагрузите страницу (F5)
|
||||||
|
|
||||||
|
## Развёртывание
|
||||||
|
|
||||||
|
### Вариант A — Локальный сервер (рекомендуется)
|
||||||
|
```bash
|
||||||
|
npx serve .
|
||||||
|
```
|
||||||
|
Откройте `http://localhost:3000`
|
||||||
|
|
||||||
|
### Вариант B — Прямое открытие
|
||||||
|
Откройте `index.html` в браузере через `file://`
|
||||||
|
> **Внимание:** Некоторые браузеры блокируют `fetch()` для локальных файлов (CORS).
|
||||||
|
|
||||||
|
## Требования к браузеру
|
||||||
|
|
||||||
|
- Chrome, Firefox, Edge — последние 2 версии
|
||||||
|
- WebGL (для отображения > 10 000 точек)
|
||||||
459
TRD.md
Normal file
459
TRD.md
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
# TRD: Интерактивная карта скорости интернета по провайдерам Казахстана
|
||||||
|
|
||||||
|
**Версия:** 1.0
|
||||||
|
**Дата:** 25 мая 2026
|
||||||
|
**Связанный PRD:** PRD v1.3
|
||||||
|
**Статус:** Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Цели и ограничения
|
||||||
|
|
||||||
|
### 1.1 Технические цели
|
||||||
|
|
||||||
|
- Обработать и отобразить **300 000+ точек** без зависания браузера
|
||||||
|
- Работать как **единый статический HTML-файл** — без сервера, без сборщиков, без npm install
|
||||||
|
- Минимальный стек: только то, что необходимо для задачи
|
||||||
|
- Первый рендер карты — не позднее **2 секунд** после загрузки CSV
|
||||||
|
|
||||||
|
### 1.2 Ключевое ограничение
|
||||||
|
|
||||||
|
Leaflet с обычными `L.circleMarker` не справится с 300к точками — каждый маркер создаёт отдельный DOM-элемент. При таком масштабе единственный работающий подход — **рендеринг через Canvas**, где все точки рисуются как пиксели на одном элементе, а не как 300 000 DOM-узлов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Технический стек
|
||||||
|
|
||||||
|
### 2.1 Итоговый стек
|
||||||
|
|
||||||
|
| Компонент | Библиотека | Версия | Подключение | Обоснование |
|
||||||
|
|-----------|-----------|--------|-------------|-------------|
|
||||||
|
| Карта | **Leaflet.js** | 1.9.x | CDN | Стандарт для статических карт, нет lock-in |
|
||||||
|
| Тайлы | **CartoDB Positron** | — | CDN (бесплатно) | Нейтральный фон, не перебивает данные провайдеров |
|
||||||
|
| Рендеринг точек | **Leaflet.glify** | 2.x | CDN | WebGL-рендеринг: 300к+ точек при 60 fps |
|
||||||
|
| GeoJSON границ | **GADM v4.1** | KAZ\_1 | Локальный файл | Точные границы областей КЗ |
|
||||||
|
| Парсинг CSV | **PapaParse** | 5.x | CDN | Потоковый парсинг, не блокирует UI |
|
||||||
|
| Геопространственные операции | **Turf.js** (только нужные модули) | 6.x | CDN (esm.sh) | DBSCAN + convex hull |
|
||||||
|
| Пространственный индекс | **rbush** | 3.x | CDN | R-tree для point-in-polygon при экспорте |
|
||||||
|
| Цветовые шкалы | **chroma.js** | 2.x | CDN | Лёгче D3-scale, достаточно для log-шкалы |
|
||||||
|
|
||||||
|
> **Итого внешних зависимостей: 7.** Все подключаются через CDN-теги в HTML.
|
||||||
|
> Фреймворк (React, Vue) — отсутствует. Vanilla JS.
|
||||||
|
|
||||||
|
### 2.2 Почему не Leaflet.markercluster
|
||||||
|
|
||||||
|
MarkerCluster агрегирует маркеры визуально, но по-прежнему создаёт DOM-элементы для каждого видимого кластера и каждой развёрнутой точки. При 300к точках и высоком zoom он всё равно зависает. Leaflet.glify рисует через WebGL — все 300к точек занимают один `<canvas>`, браузер рендерит их за один draw call.
|
||||||
|
|
||||||
|
### 2.3 Почему не Mapbox GL JS / Deck.gl
|
||||||
|
|
||||||
|
Mapbox требует API-ключ и аккаунт. Deck.gl — избыточен для одного слоя точек и требует сборщика. Цель — минимальный стек без внешних зависимостей от платных сервисов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Архитектура
|
||||||
|
|
||||||
|
### 3.1 Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├── index.html # Единственный файл приложения (весь JS и CSS внутри)
|
||||||
|
├── data/
|
||||||
|
│ ├── speedtest.csv # Данные провайдеров (заменяется вручную при обновлении)
|
||||||
|
│ └── kaz_regions.geojson # Границы областей КЗ (GADM KAZ_1, однократная загрузка)
|
||||||
|
└── README.md # Инструкция: как обновить CSV и открыть карту
|
||||||
|
```
|
||||||
|
|
||||||
|
Всё приложение живёт в `index.html`. Пользователь открывает файл в браузере — карта работает.
|
||||||
|
|
||||||
|
### 3.2 Модули внутри index.html
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html
|
||||||
|
├── <head> CDN-теги: Leaflet, glify, PapaParse, Turf, rbush, chroma
|
||||||
|
├── <style> CSS: layout, легенда, tooltip, панель экспорта
|
||||||
|
└── <script>
|
||||||
|
├── CONFIG Константы: EPS_KM, ZOOM_THRESHOLD, MIN_CLUSTER_POINTS, цвета провайдеров
|
||||||
|
├── DataLoader Загрузка и потоковый парсинг CSV → массив объектов
|
||||||
|
├── Preprocessor DBSCAN-кластеризация + построение hull-полигонов (Web Worker)
|
||||||
|
├── MapController Инициализация Leaflet, управление zoom, переключение слоёв
|
||||||
|
├── GlifyLayer Управление Leaflet.glify (точечный WebGL-слой)
|
||||||
|
├── HullLayer Управление Leaflet GeoJSON-слоями для hull-полигонов
|
||||||
|
├── AdminLayer Загрузка и отображение GeoJSON границ областей
|
||||||
|
├── TooltipCtrl Hover-логика для hull и точек
|
||||||
|
├── LegendCtrl Список провайдеров, чекбоксы, фильтрация
|
||||||
|
└── ExportCtrl Point-in-polygon (rbush) + генерация CSV через Blob API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Производительность: стратегия работы с 300к+ точками
|
||||||
|
|
||||||
|
Это центральная техническая проблема. Решается на трёх уровнях.
|
||||||
|
|
||||||
|
### 4.1 Уровень 1 — Парсинг CSV не блокирует UI
|
||||||
|
|
||||||
|
PapaParse запускается в режиме **streaming** с `worker: true`. CSV читается чанками по 10 000 строк в фоновом потоке. Главный поток остаётся отзывчивым, прогресс-бар обновляется через callback `step`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
Papa.parse('data/speedtest.csv', {
|
||||||
|
worker: true, // парсинг в Web Worker
|
||||||
|
header: true,
|
||||||
|
dynamicTyping: true, // latitude/longitude сразу как float
|
||||||
|
chunk: (results) => {
|
||||||
|
appendToBuffer(results.data); // накапливаем чанки
|
||||||
|
updateProgressBar();
|
||||||
|
},
|
||||||
|
complete: () => onDataReady()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Уровень 2 — Предобработка в Web Worker
|
||||||
|
|
||||||
|
DBSCAN и построение hull — вычислительно тяжёлые операции. Они запускаются в отдельном **Web Worker** сразу после окончания парсинга, не блокируя карту.
|
||||||
|
|
||||||
|
```
|
||||||
|
Main thread Worker thread
|
||||||
|
───────────────────── ─────────────────────────────
|
||||||
|
CSV готов → postMessage ──────→ Получает массив точек
|
||||||
|
DBSCAN по каждому провайдеру
|
||||||
|
Строит hull-полигоны
|
||||||
|
Получает hulls ←────────────── postMessage(hulls GeoJSON)
|
||||||
|
Рендерит hull-слой
|
||||||
|
```
|
||||||
|
|
||||||
|
Пока Worker считает, карта уже отображена с тайлами и границами областей. Hull появляется поверх через 1–3 секунды в зависимости от объёма данных.
|
||||||
|
|
||||||
|
### 4.3 Уровень 3 — WebGL-рендеринг точек через Leaflet.glify
|
||||||
|
|
||||||
|
На zoom ≥ 9 точки рендерятся через `L.glify.points()`. Весь датасет (300к+ записей) передаётся в GPU как Float32Array один раз при инициализации. При последующем зуме/пане браузер перерисовывает canvas без обращения к JS.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Подготовка данных: один раз, при загрузке
|
||||||
|
const latLngs = points.map(p => [p.latitude, p.longitude]);
|
||||||
|
const colors = points.map(p => providerColor(p.provider)); // [r, g, b] 0..1
|
||||||
|
const sizes = points.map(p => logScale(p.avg_download_mbps, 4, 18));
|
||||||
|
|
||||||
|
L.glify.points({
|
||||||
|
map,
|
||||||
|
data: latLngs,
|
||||||
|
color: (i) => colors[i],
|
||||||
|
size: (i) => sizes[i],
|
||||||
|
opacity: 0.8,
|
||||||
|
click: onPointClick,
|
||||||
|
hover: onPointHover,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** 300к точек = один WebGL draw call. Браузер рендерит за ~16 мс (60 fps).
|
||||||
|
|
||||||
|
### 4.4 Видимость слоёв по zoom
|
||||||
|
|
||||||
|
Никакого удаления/добавления DOM-элементов при зуме — только `show/hide` через opacity и `map.on('zoomend')`.
|
||||||
|
|
||||||
|
```
|
||||||
|
zoom < 8 → hull visible, glify opacity = 0 (точки скрыты)
|
||||||
|
zoom 8–9 → hull opacity анимируется 1→0, glify 0→0.8 (crossfade)
|
||||||
|
zoom ≥ 9 → hull hidden, glify opacity = 0.8 (точки видны)
|
||||||
|
```
|
||||||
|
|
||||||
|
Crossfade реализован через CSS-transition на canvas-элементе glify.
|
||||||
|
|
||||||
|
### 4.5 Viewport culling для hull
|
||||||
|
|
||||||
|
Hull-полигоны (GeoJSON) рендерит Leaflet SVG-слой. При 300к точках и десятках провайдеров полигонов будет не более нескольких сотен — SVG справится без дополнительных оптимизаций. Leaflet автоматически не рендерит полигоны вне viewport.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Алгоритм DBSCAN для hull-кластеров
|
||||||
|
|
||||||
|
### 5.1 Параметры
|
||||||
|
|
||||||
|
| Параметр | Значение | Где задаётся |
|
||||||
|
|----------|----------|--------------|
|
||||||
|
| `EPS_KM` | 20 км | `CONFIG` в начале JS |
|
||||||
|
| `MIN_POINTS` | 3 | `CONFIG` |
|
||||||
|
| Метрика расстояния | Haversine | Turf.js встроено |
|
||||||
|
|
||||||
|
### 5.2 Псевдокод (выполняется в Web Worker)
|
||||||
|
|
||||||
|
```
|
||||||
|
function buildHulls(points):
|
||||||
|
result = []
|
||||||
|
byProvider = groupBy(points, 'provider')
|
||||||
|
|
||||||
|
for each provider, providerPoints in byProvider:
|
||||||
|
if providerPoints.length < MIN_POINTS:
|
||||||
|
continue // полностью исключаем провайдера
|
||||||
|
|
||||||
|
clusters = dbscan(providerPoints, eps=EPS_KM, minPts=MIN_POINTS)
|
||||||
|
// clusters = { '0': [pt, pt, ...], '1': [...], '-1': [noise] }
|
||||||
|
|
||||||
|
for each clusterId, clusterPoints in clusters:
|
||||||
|
if clusterId == '-1': continue // шум — hull не строим
|
||||||
|
|
||||||
|
fc = turf.featureCollection(clusterPoints.map(toTurfPoint))
|
||||||
|
hull = turf.convex(fc)
|
||||||
|
if hull == null: continue // коллинеарные точки — пропускаем
|
||||||
|
|
||||||
|
hull.properties = {
|
||||||
|
provider: provider,
|
||||||
|
count: clusterPoints.length,
|
||||||
|
avg_download: mean(clusterPoints, 'avg_download_mbps'),
|
||||||
|
avg_upload: mean(clusterPoints, 'avg_upload_mbps'),
|
||||||
|
}
|
||||||
|
result.push(hull)
|
||||||
|
|
||||||
|
return turf.featureCollection(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Шумовые точки (DBSCAN label = -1)
|
||||||
|
|
||||||
|
Точки, не вошедшие ни в один кластер (изолированные), не отображаются на zoom ≤ 8. На zoom ≥ 9 они отображаются как обычные кружки в glify-слое — они уже включены в общий массив `latLngs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Пространственный индекс для экспорта
|
||||||
|
|
||||||
|
При клике на область нужно быстро найти все точки внутри её полигона из 300к записей. Наивный перебор (point-in-polygon для каждой точки) займёт ~500 мс. Решение — **R-tree индекс** через rbush.
|
||||||
|
|
||||||
|
### 6.1 Инициализация (однократно, после парсинга CSV)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const tree = new RBush();
|
||||||
|
const items = points.map((p, i) => ({
|
||||||
|
minX: p.longitude, minY: p.latitude,
|
||||||
|
maxX: p.longitude, maxY: p.latitude,
|
||||||
|
index: i
|
||||||
|
}));
|
||||||
|
tree.load(items); // bulk insert O(n log n)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Поиск точек при клике на область
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function getPointsInRegion(regionPolygon, activeProviders) {
|
||||||
|
const bbox = turf.bbox(regionPolygon);
|
||||||
|
|
||||||
|
// 1. Быстрый bbox-фильтр через R-tree: O(log n + k)
|
||||||
|
const candidates = tree.search({
|
||||||
|
minX: bbox[0], minY: bbox[1],
|
||||||
|
maxX: bbox[2], maxY: bbox[3]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Точная проверка point-in-polygon только для кандидатов
|
||||||
|
return candidates
|
||||||
|
.map(c => points[c.index])
|
||||||
|
.filter(p => activeProviders.has(p.provider))
|
||||||
|
.filter(p => turf.booleanPointInPolygon(
|
||||||
|
turf.point([p.longitude, p.latitude]),
|
||||||
|
regionPolygon
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Типичный bbox Алматинской области охватит ~5–15% всех точек как кандидатов. Точная проверка только по ним — вместо 300к итераций получаем ~15–45к, результат за < 50 мс.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Экспорт CSV
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function exportRegionCSV(regionName, filteredPoints) {
|
||||||
|
const header = 'provider,latitude,longitude,avg_download_mbps,avg_upload_mbps\n';
|
||||||
|
const rows = filteredPoints.map(p =>
|
||||||
|
`"${p.provider}",${p.latitude},${p.longitude},${p.avg_download_mbps},${p.avg_upload_mbps}`
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
const name = `kz_${regionName.toLowerCase().replace(/\s+/g, '_')}_${date}.csv`;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = name; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Цветовая шкала
|
||||||
|
|
||||||
|
### 8.1 Цвета провайдеров
|
||||||
|
|
||||||
|
Цвета назначаются автоматически из фиксированной палитры при первом появлении провайдера в данных. Палитра из 20 различимых цветов достаточна для типичного числа провайдеров в КЗ.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const PALETTE = [
|
||||||
|
'#e6194b','#3cb44b','#4363d8','#f58231','#911eb4',
|
||||||
|
'#42d4f4','#f032e6','#bfef45','#fabed4','#469990',
|
||||||
|
'#dcbeff','#9A6324','#fffac8','#800000','#aaffc3',
|
||||||
|
'#808000','#ffd8b1','#000075','#a9a9a9','#ffffff'
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerColorMap = new Map();
|
||||||
|
let colorIndex = 0;
|
||||||
|
|
||||||
|
function providerColor(name) {
|
||||||
|
if (!providerColorMap.has(name)) {
|
||||||
|
providerColorMap.set(name, PALETTE[colorIndex++ % PALETTE.length]);
|
||||||
|
}
|
||||||
|
return providerColorMap.get(name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Логарифмическая шкала для размера кружков
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// chroma.js используется только для градиента легенды
|
||||||
|
// Для размера — ручной log10
|
||||||
|
|
||||||
|
const LOG_MIN = Math.log10(1); // = 0
|
||||||
|
const LOG_MAX = Math.log10(1000); // = 3
|
||||||
|
|
||||||
|
function logScale(mbps, minPx, maxPx) {
|
||||||
|
const clamped = Math.max(1, Math.min(1000, mbps));
|
||||||
|
const t = (Math.log10(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN); // 0..1
|
||||||
|
return minPx + t * (maxPx - minPx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Отметки на шкале легенды: `[1, 5, 10, 50, 100, 300, 1000]` Мбит/с — равномерно распределены в log-пространстве.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Инициализация и порядок загрузки
|
||||||
|
|
||||||
|
```
|
||||||
|
1. DOMContentLoaded
|
||||||
|
└── Инициализация Leaflet карты (центр: 48.0°N 68.0°E, zoom: 5)
|
||||||
|
└── Загрузка тайлов CartoDB Positron
|
||||||
|
|
||||||
|
2. Параллельно:
|
||||||
|
├── fetch('data/kaz_regions.geojson') → AdminLayer.init()
|
||||||
|
│ └── Рендер границ областей (L.geoJSON, clickable)
|
||||||
|
└── PapaParse.parse('data/speedtest.csv', {worker: true})
|
||||||
|
└── По завершении: onDataReady(points)
|
||||||
|
|
||||||
|
3. onDataReady(points):
|
||||||
|
├── Строим rbush-индекс (main thread, ~200 мс для 300к)
|
||||||
|
├── Инициализируем GlifyLayer (передаём все точки в GPU)
|
||||||
|
│ └── Слой скрыт (opacity=0) до zoom ≥ 9
|
||||||
|
├── Строим LegendCtrl (список провайдеров из данных)
|
||||||
|
└── postMessage → Web Worker: запуск DBSCAN + hull
|
||||||
|
|
||||||
|
4. Worker завершил работу → onHullsReady(hullsGeoJSON):
|
||||||
|
└── Рендер HullLayer (L.geoJSON полигоны)
|
||||||
|
└── Слой виден при zoom ≤ 8
|
||||||
|
|
||||||
|
5. Карта полностью готова к работе
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ожидаемые тайминги (300к точек, современный ноутбук):**
|
||||||
|
|
||||||
|
| Этап | Время |
|
||||||
|
|------|-------|
|
||||||
|
| Карта + тайлы | ~0.5 с |
|
||||||
|
| Парсинг CSV (Worker) | ~1.5–2 с |
|
||||||
|
| rbush bulk insert | ~0.2 с |
|
||||||
|
| GlifyLayer init (GPU upload) | ~0.3 с |
|
||||||
|
| DBSCAN + hull (Worker) | ~2–4 с |
|
||||||
|
| **Карта интерактивна (без hull)** | **~2 с** |
|
||||||
|
| **Hull появляется** | **~4–6 с** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Конфигурационный блок
|
||||||
|
|
||||||
|
В начале `<script>` выделен явный блок CONFIG — аналитик меняет параметры здесь, не трогая логику:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const CONFIG = {
|
||||||
|
// Кластеризация для hull
|
||||||
|
EPS_KM: 20, // радиус DBSCAN в километрах
|
||||||
|
MIN_CLUSTER_POINTS: 3, // минимум точек для кластера
|
||||||
|
|
||||||
|
// Zoom-порог переключения слоёв
|
||||||
|
ZOOM_THRESHOLD: 9, // ≤8 = hull, ≥9 = точки
|
||||||
|
|
||||||
|
// Шкала размеров кружков (px)
|
||||||
|
POINT_MIN_PX: 4,
|
||||||
|
POINT_MAX_PX: 18,
|
||||||
|
|
||||||
|
// Шкала скоростей (Мбит/с)
|
||||||
|
SPEED_MIN: 1,
|
||||||
|
SPEED_MAX: 1000,
|
||||||
|
|
||||||
|
// Данные
|
||||||
|
CSV_PATH: 'data/speedtest.csv',
|
||||||
|
GEOJSON_PATH: 'data/kaz_regions.geojson',
|
||||||
|
|
||||||
|
// Начальный вид карты
|
||||||
|
MAP_CENTER: [48.0, 68.0],
|
||||||
|
MAP_ZOOM: 5,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Обработка граничных случаев
|
||||||
|
|
||||||
|
| Случай | Поведение |
|
||||||
|
|--------|-----------|
|
||||||
|
| Провайдер с < 3 точками в любом кластере | Полностью исключён из обоих слоёв |
|
||||||
|
| Коллинеарные точки (hull = null) | Кластер пропускается без ошибки |
|
||||||
|
| avg_download_mbps = 0 или null | `logScale` зажимает до min=1, кружок min-размера |
|
||||||
|
| CSV с отсутствующими полями | PapaParse `skipEmptyLines: true`, строки с null lat/lon фильтруются |
|
||||||
|
| Браузер без WebGL | Leaflet.glify выводит предупреждение; fallback — `L.circleMarker` с лимитом 10к точек |
|
||||||
|
| GeoJSON границ недоступен | AdminLayer не рендерится, остальное работает |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Нефункциональные требования (технические детали)
|
||||||
|
|
||||||
|
| Требование из PRD | Техническое решение |
|
||||||
|
|---|---|
|
||||||
|
| 300к+ точек без зависания | WebGL через Leaflet.glify, один draw call |
|
||||||
|
| Дальние масштабы не нагружают систему | Hull-слой: SVG-полигоны (их сотни, не тысячи). Glify opacity=0, но данные уже в GPU |
|
||||||
|
| Первый рендер ≤ 2 с | Карта и границы рендерятся до окончания парсинга CSV |
|
||||||
|
| Без сервера | Все операции в браузере: Worker, rbush, Blob API |
|
||||||
|
| Обновление данных вручную | Замена `data/speedtest.csv`, перезагрузка страницы |
|
||||||
|
| Chrome, Firefox, Edge | WebGL поддерживается везде. Протестировать Worker + glify в Firefox (known issues с some glify versions) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Риски и митигация
|
||||||
|
|
||||||
|
| Риск | Вероятность | Митигация |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| Leaflet.glify не поддерживает hover на 300к точек с приемлемой задержкой | Средняя | Протестировать на реальных данных; fallback — hover только при zoom ≥ 11 (меньше точек в viewport) |
|
||||||
|
| DBSCAN на 300к точках в Worker занимает > 10 с | Низкая (работает на subset по провайдерам) | Если медленно — заменить Turf DBSCAN на собственный grid-based radius grouping (O(n) вместо O(n²)) |
|
||||||
|
| GADM GeoJSON слишком тяжёлый (> 5 МБ) | Средняя | Упростить через mapshaper.org до tolerance=0.01°, целевой размер < 500 КБ |
|
||||||
|
| WebGL недоступен на корпоративных машинах (старые драйверы) | Низкая | Добавить детект `L.glify.supported()` и fallback-предупреждение |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Инструкция по развёртыванию
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Структура готова — скачать GADM GeoJSON
|
||||||
|
# https://gadm.org/download_country.html → Kazakhstan → GeoJSON (Level 1)
|
||||||
|
# Сохранить как data/kaz_regions.geojson
|
||||||
|
|
||||||
|
# 2. Поместить CSV с данными
|
||||||
|
cp speedtest_aggregated.csv data/speedtest.csv
|
||||||
|
|
||||||
|
# 3. Открыть карту
|
||||||
|
# Вариант A — прямо в браузере (если браузер разрешает file://)
|
||||||
|
open index.html
|
||||||
|
|
||||||
|
# Вариант B — через локальный сервер (рекомендуется, решает CORS для fetch)
|
||||||
|
npx serve .
|
||||||
|
# → открыть http://localhost:3000
|
||||||
|
|
||||||
|
# 4. Обновление данных
|
||||||
|
cp new_speedtest_aggregated.csv data/speedtest.csv
|
||||||
|
# Перезагрузить страницу в браузере — F5
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Примечание:** Браузеры блокируют `fetch()` для локальных файлов по соображениям безопасности (`file://` CORS). Вариант B через `npx serve` решает это одной командой без установки зависимостей.
|
||||||
1
data/kaz_regions.geojson
Normal file
1
data/kaz_regions.geojson
Normal file
File diff suppressed because one or more lines are too long
76
data/speedtest.csv
Normal file
76
data/speedtest.csv
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
provider,latitude,longitude,avg_download_mbps,avg_upload_mbps
|
||||||
|
Казахтелеком,43.2561,76.9285,87.4,42.1
|
||||||
|
Казахтелеком,43.2350,76.9100,91.2,44.7
|
||||||
|
Казахтелеком,43.2700,76.9500,78.5,38.2
|
||||||
|
Казахтелеком,43.2200,76.8900,95.0,46.0
|
||||||
|
Казахтелеком,43.2900,76.9700,82.3,40.5
|
||||||
|
Казахтелеком,43.2100,76.8800,88.1,43.0
|
||||||
|
Казахтелеком,43.2450,76.9150,76.4,36.8
|
||||||
|
Казахтелеком,43.2600,76.9350,93.7,45.2
|
||||||
|
Казахтелеком,43.2750,76.9550,79.9,39.1
|
||||||
|
Казахтелеком,43.2250,76.8950,85.6,41.3
|
||||||
|
Казахтелеком,51.1800,71.4450,72.5,35.0
|
||||||
|
Казахтелеком,51.1600,71.4250,68.3,32.1
|
||||||
|
Казахтелеком,51.2000,71.4650,74.1,36.2
|
||||||
|
Казахтелеком,51.1400,71.4050,70.8,34.5
|
||||||
|
Казахтелеком,51.2200,71.4850,76.2,37.8
|
||||||
|
Казахтелеком,49.8000,73.1200,65.4,31.0
|
||||||
|
Казахтелеком,49.8200,73.1400,62.1,29.8
|
||||||
|
Казахтелеком,49.7800,73.1000,67.8,32.5
|
||||||
|
Казахтелеком,49.8400,73.1600,63.5,30.2
|
||||||
|
Казахтелеком,49.7600,73.0800,69.2,33.1
|
||||||
|
Beeline,43.2561,76.9285,120.5,58.3
|
||||||
|
Beeline,43.2350,76.9100,115.2,55.1
|
||||||
|
Beeline,43.2700,76.9500,130.8,62.4
|
||||||
|
Beeline,43.2200,76.8900,108.6,52.0
|
||||||
|
Beeline,43.2900,76.9700,125.3,60.1
|
||||||
|
Beeline,43.2100,76.8800,112.4,54.7
|
||||||
|
Beeline,43.2450,76.9150,118.9,57.2
|
||||||
|
Beeline,43.2600,76.9350,135.1,64.8
|
||||||
|
Beeline,43.2750,76.9550,122.7,59.3
|
||||||
|
Beeline,43.2250,76.8950,110.3,53.5
|
||||||
|
Beeline,51.1800,71.4450,95.2,45.0
|
||||||
|
Beeline,51.1600,71.4250,88.7,42.3
|
||||||
|
Beeline,51.2000,71.4650,92.4,44.1
|
||||||
|
Beeline,51.1400,71.4050,85.6,40.8
|
||||||
|
Beeline,51.2200,71.4850,98.1,46.5
|
||||||
|
Beeline,49.8000,73.1200,78.3,37.0
|
||||||
|
Beeline,49.8200,73.1400,82.5,39.2
|
||||||
|
Beeline,49.7800,73.1000,75.1,35.8
|
||||||
|
Beeline,49.8400,73.1600,80.4,38.5
|
||||||
|
Beeline,49.7600,73.0800,76.8,36.4
|
||||||
|
Tele2,43.2561,76.9285,45.2,21.0
|
||||||
|
Tele2,43.2350,76.9100,42.8,19.5
|
||||||
|
Tele2,43.2700,76.9500,48.1,22.3
|
||||||
|
Tele2,43.2200,76.8900,40.5,18.8
|
||||||
|
Tele2,43.2900,76.9700,46.7,21.8
|
||||||
|
Tele2,43.2100,76.8800,43.4,20.1
|
||||||
|
Tele2,43.2450,76.9150,44.9,20.8
|
||||||
|
Tele2,43.2600,76.9350,49.2,22.9
|
||||||
|
Tele2,43.2750,76.9550,41.6,19.2
|
||||||
|
Tele2,43.2250,76.8950,47.3,22.0
|
||||||
|
Tele2,51.1800,71.4450,38.5,17.2
|
||||||
|
Tele2,51.1600,71.4250,35.2,16.0
|
||||||
|
Tele2,51.2000,71.4650,40.1,18.5
|
||||||
|
Tele2,51.1400,71.4050,36.8,16.8
|
||||||
|
Tele2,51.2200,71.4850,39.4,18.1
|
||||||
|
Kcell,43.2561,76.9285,65.3,31.2
|
||||||
|
Kcell,43.2350,76.9100,62.1,29.8
|
||||||
|
Kcell,43.2700,76.9500,68.5,32.7
|
||||||
|
Kcell,43.2200,76.8900,60.4,28.5
|
||||||
|
Kcell,43.2900,76.9700,66.8,31.9
|
||||||
|
Kcell,43.2100,76.8800,63.2,30.1
|
||||||
|
Kcell,43.2450,76.9150,64.7,30.9
|
||||||
|
Kcell,43.2600,76.9350,70.1,33.5
|
||||||
|
Kcell,43.2750,76.9550,61.5,29.2
|
||||||
|
Kcell,43.2250,76.8950,67.2,32.0
|
||||||
|
Altel,43.2561,76.9285,55.8,26.4
|
||||||
|
Altel,43.2350,76.9100,52.3,24.8
|
||||||
|
Altel,43.2700,76.9500,58.1,27.5
|
||||||
|
Altel,43.2200,76.8900,50.6,23.9
|
||||||
|
Altel,43.2900,76.9700,56.4,26.8
|
||||||
|
Altel,43.2100,76.8800,53.7,25.2
|
||||||
|
Altel,43.2450,76.9150,54.9,26.0
|
||||||
|
Altel,43.2600,76.9350,59.2,28.1
|
||||||
|
Altel,43.2750,76.9550,51.8,24.5
|
||||||
|
Altel,43.2250,76.8950,57.1,27.0
|
||||||
|
9
glify_check.js
Normal file
9
glify_check.js
Normal file
File diff suppressed because one or more lines are too long
1267
index.html
Normal file
1267
index.html
Normal file
File diff suppressed because it is too large
Load Diff
187
plans/implementation-plan.md
Normal file
187
plans/implementation-plan.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# Implementation Plan: Интерактивная карта скорости интернета по провайдерам Казахстана
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Single static HTML file application that visualizes Speedtest Intelligence data on an interactive map of Kazakhstan. No build tools, no server, no npm — everything runs in the browser from a single `index.html`.
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph "Data Layer"
|
||||||
|
CSV["data/speedtest.csv"] --> PP["PapaParse streaming<br/>Web Worker"]
|
||||||
|
PP --> Points["Points Array<br/>{provider, lat, lon, dl, ul}"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Processing Layer"
|
||||||
|
Points --> RBUSH["rbush R-tree<br/>spatial index"]
|
||||||
|
Points --> WORKER["Web Worker: DBSCAN + convex hull"]
|
||||||
|
WORKER --> Hulls["Hulls GeoJSON<br/>FeatureCollection"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Rendering Layer"
|
||||||
|
MAP["Leaflet Map<br/>CartoDB Positron tiles"]
|
||||||
|
GEOJSON["data/kaz_regions.geojson"] --> ADMIN["AdminLayer<br/>L.geoJSON regions"]
|
||||||
|
Hulls --> HULL["HullLayer<br/>L.geoJSON polygons"]
|
||||||
|
Points --> GLIFY["GlifyLayer<br/>Leaflet.glify WebGL"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "UI Controls"
|
||||||
|
LEGEND["LegendCtrl<br/>checkboxes, colors"]
|
||||||
|
TOOLTIP["TooltipCtrl<br/>hover info"]
|
||||||
|
EXPORT["ExportCtrl<br/>CSV via Blob API"]
|
||||||
|
end
|
||||||
|
|
||||||
|
MAP --> ADMIN
|
||||||
|
MAP --> HULL
|
||||||
|
MAP --> GLIFY
|
||||||
|
MAP --> LEGEND
|
||||||
|
MAP --> TOOLTIP
|
||||||
|
MAP --> EXPORT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zoom-based Layer Switching
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Z5["zoom ≤ 8"] --> HULL_VIEW["Hull polygons visible<br/>Glify points hidden"]
|
||||||
|
Z8["zoom 8-9"] --> FADE["Crossfade transition<br/>opacity 1→0 / 0→0.8"]
|
||||||
|
Z9["zoom ≥ 9"] --> POINT_VIEW["Glify points visible<br/>Hull polygons hidden"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
c:/Users/1/speedtestMap/
|
||||||
|
├── index.html # Single-file application (all JS + CSS inline)
|
||||||
|
├── data/
|
||||||
|
│ ├── speedtest.csv # Provider speed data (user replaces manually)
|
||||||
|
│ └── kaz_regions.geojson # Kazakhstan admin boundaries (GADM KAZ_1)
|
||||||
|
├── plans/
|
||||||
|
│ └── implementation-plan.md # This file
|
||||||
|
└── README.md # Deployment & usage instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps (in order)
|
||||||
|
|
||||||
|
### Step 1: Project Scaffolding
|
||||||
|
- Create `data/` directory
|
||||||
|
- Create `index.html` with HTML5 boilerplate
|
||||||
|
- Add CDN links for all 7 dependencies:
|
||||||
|
- Leaflet 1.9.x CSS + JS
|
||||||
|
- Leaflet.glify 2.x
|
||||||
|
- PapaParse 5.x
|
||||||
|
- Turf.js 6.x (from esm.sh or bundle)
|
||||||
|
- rbush 3.x
|
||||||
|
- chroma.js 2.x
|
||||||
|
- Add inline `<style>` block for layout
|
||||||
|
- Add inline `<script>` block structure with all modules
|
||||||
|
|
||||||
|
### Step 2: CONFIG Block
|
||||||
|
Place at top of `<script>`:
|
||||||
|
- `EPS_KM: 20` — DBSCAN radius
|
||||||
|
- `MIN_CLUSTER_POINTS: 3` — min points for cluster
|
||||||
|
- `ZOOM_THRESHOLD: 9` — layer switch zoom level
|
||||||
|
- `POINT_MIN_PX: 4`, `POINT_MAX_PX: 18` — circle size range
|
||||||
|
- `SPEED_MIN: 1`, `SPEED_MAX: 1000` — speed range
|
||||||
|
- `CSV_PATH`, `GEOJSON_PATH` — data file paths
|
||||||
|
- `MAP_CENTER: [48.0, 68.0]`, `MAP_ZOOM: 5`
|
||||||
|
- `PALETTE` — 20 distinguishable colors
|
||||||
|
|
||||||
|
### Step 3: DataLoader Module
|
||||||
|
- `Papa.parse()` with `worker: true`, streaming mode
|
||||||
|
- Chunk callback: accumulate data, update progress bar
|
||||||
|
- Complete callback: call `onDataReady(points)`
|
||||||
|
- Filter out rows with null lat/lon or missing provider
|
||||||
|
- Filter out providers with < 3 total points (per PRD F-24)
|
||||||
|
|
||||||
|
### Step 4: Preprocessor (Web Worker)
|
||||||
|
- Inline Web Worker (blob URL) for DBSCAN + hull
|
||||||
|
- Receive points array via `postMessage`
|
||||||
|
- Group by provider
|
||||||
|
- For each provider with ≥ 3 points:
|
||||||
|
- Run `@turf/clusters-dbscan` with `eps=EPS_KM`, `minPoints=MIN_CLUSTER_POINTS`
|
||||||
|
- For each cluster (excluding noise/-1):
|
||||||
|
- Build `turf.convex()`
|
||||||
|
- Attach properties: provider, count, avg_download, avg_upload
|
||||||
|
- Return `turf.featureCollection(hulls)` via `postMessage`
|
||||||
|
|
||||||
|
### Step 5: MapController
|
||||||
|
- Initialize Leaflet map at `MAP_CENTER`, `MAP_ZOOM`
|
||||||
|
- Add CartoDB Positron tile layer (no API key needed)
|
||||||
|
- Handle `zoomend` event for layer switching
|
||||||
|
- Manage crossfade opacity between hull and glify layers
|
||||||
|
|
||||||
|
### Step 6: AdminLayer
|
||||||
|
- `fetch('data/kaz_regions.geojson')` → parse GeoJSON
|
||||||
|
- Render with `L.geoJSON()`, style with semi-transparent borders
|
||||||
|
- Make regions clickable → trigger region selection
|
||||||
|
- On click: highlight region, show export button, compute point-in-polygon
|
||||||
|
|
||||||
|
### Step 7: HullLayer
|
||||||
|
- Receive hulls GeoJSON from Worker
|
||||||
|
- Render with `L.geoJSON()`, each polygon colored by provider
|
||||||
|
- Fill opacity ~30-40%, stroke for visibility
|
||||||
|
- Show/hide based on zoom level
|
||||||
|
- Attach hover handlers for tooltip
|
||||||
|
|
||||||
|
### Step 8: GlifyLayer
|
||||||
|
- Prepare data arrays: `latLngs`, `colors`, `sizes`
|
||||||
|
- Initialize `L.glify.points()` once with all points
|
||||||
|
- Set initial opacity = 0 (hidden at low zoom)
|
||||||
|
- On zoom ≥ 9: opacity = 0.8
|
||||||
|
- Handle `hover` and `click` events from glify
|
||||||
|
|
||||||
|
### Step 9: TooltipCtrl
|
||||||
|
- Custom tooltip div (not Leaflet default)
|
||||||
|
- On hull hover: show provider name, region, avg speeds, point count
|
||||||
|
- On point hover: show provider name, coordinates, speeds
|
||||||
|
- Position tooltip near cursor, avoid overlapping map controls
|
||||||
|
- On point click: show fixed popup with extended data
|
||||||
|
|
||||||
|
### Step 10: LegendCtrl
|
||||||
|
- Fixed panel with provider list
|
||||||
|
- Each provider: colored circle + name + checkbox
|
||||||
|
- Checkbox toggles visibility of both hull and glify layers for that provider
|
||||||
|
- "Select all" / "Deselect all" buttons
|
||||||
|
- Built from unique providers in loaded data
|
||||||
|
|
||||||
|
### Step 11: ExportCtrl
|
||||||
|
- On region click: build rbush spatial index query
|
||||||
|
- Filter by active providers (from LegendCtrl checkboxes)
|
||||||
|
- Generate CSV string with header + rows
|
||||||
|
- Create Blob, trigger download via `<a>` click
|
||||||
|
- Filename: `kz_{region}_{YYYY-MM-DD}.csv`
|
||||||
|
|
||||||
|
### Step 12: Crossfade & Polish
|
||||||
|
- CSS transition on glify canvas opacity
|
||||||
|
- Smooth fade between hull and point layers at zoom 8-9
|
||||||
|
- Progress bar during CSV loading
|
||||||
|
- WebGL support detection with fallback message
|
||||||
|
|
||||||
|
### Step 13: README.md
|
||||||
|
- Project description
|
||||||
|
- How to obtain GADM GeoJSON
|
||||||
|
- How to prepare CSV data
|
||||||
|
- Deployment options (file:// vs npx serve)
|
||||||
|
- How to update data
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Point rendering | Leaflet.glify (WebGL) | 300k+ points at 60 fps, single draw call |
|
||||||
|
| Clustering algorithm | DBSCAN via Turf.js | Built-in Haversine distance, configurable eps |
|
||||||
|
| Spatial index | rbush | Fast bbox search for point-in-polygon |
|
||||||
|
| Color scale | chroma.js | Lighter than D3-scale, sufficient for log gradient |
|
||||||
|
| Deployment | Static file + npx serve | No build step, no API keys |
|
||||||
|
| Data loading | PapaParse streaming Worker | Non-blocking UI, handles large CSVs |
|
||||||
|
|
||||||
|
## Edge Cases Handled
|
||||||
|
|
||||||
|
- Providers with < 3 points: excluded entirely
|
||||||
|
- Collinear points (hull = null): cluster skipped gracefully
|
||||||
|
- Null/zero speeds: clamped to min=1 Mbps
|
||||||
|
- Missing CSV fields: filtered during parse
|
||||||
|
- No WebGL: detection + fallback message
|
||||||
|
- GeoJSON unavailable: map works without admin boundaries
|
||||||
637956
speedtest_providers_avg_speeds_by_location.csv
Normal file
637956
speedtest_providers_avg_speeds_by_location.csv
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user