<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Карта торговых точек России</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f5f5f5; }
#map-container { position: relative; width: 100%; height: 100vh; }
.region { stroke: #888; stroke-width: 0.5px; cursor: pointer; transition: opacity 0.2s; }
.region:hover { opacity: 0.8; stroke: #000; stroke-width: 1.5px; }
.region-label { font-size: 9px; pointer-events: none; fill: #222; text-anchor: middle; }
.tooltip {
position: absolute; display: none; padding: 10px 14px;
background: #fff; border: 1px solid #ccc; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 13px;
max-width: 360px; pointer-events: none; z-index: 100;
}
.tooltip h3 { margin: 0 0 6px 0; font-size: 15px; color: #c84a00; }
.tooltip .total { font-weight: bold; margin-bottom: 6px; }
.tooltip .city-block { margin: 6px 0; padding-left: 8px; border-left: 2px solid #ff6600; }
.tooltip .city-name { font-weight: bold; color: #555; }
.tooltip ul { margin: 2px 0 0 0; padding-left: 18px; }
.tooltip li { margin: 1px 0; }
.legend { position: absolute; bottom: 20px; right: 20px; background: #fff; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
.legend-bar { width: 200px; height: 14px; background: linear-gradient(to right, #ffffff, #ff6600); border: 1px solid #999; }
#status { position: absolute; top: 10px; left: 10px; background: #fff; padding: 8px 12px; border-radius: 4px; border: 1px solid #ccc; font-size: 13px; z-index: 50; }
</style>
</head>
<body>
<div id="map-container">
<div id="status">Загрузка данных...</div>
<svg id="map" width="100%" height="100%"></svg>
<div class="tooltip" id="tooltip"></div>
<div class="legend">
<div>Количество торговых точек</div>
<div class="legend-bar"></div>
<div id="legend-labels" style="display:flex;justify-content:space-between;font-size:11px;"></div>
</div>
</div>
<script>
// =====================================================================
// >>> НАСТРОЙКИ: ВСТАВЬТЕ СЮДА ССЫЛКУ ИЗ GOOGLE DRIVE <<<
// =====================================================================
// Вариант 1: Google Таблица (рекомендуется)
// Скопируйте полную ссылку вида:
// https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/edit?usp=sharing
const GOOGLE_SHEETS_URL = "ВСТАВЬТЕ_ССЫЛКУ_НА_GOOGLE_ТАБЛИЦУ";
// Вариант 2: Загруженный .xlsx файл на Google Drive
// Скопируйте ссылку вида:
// https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz/view?usp=sharing
const GOOGLE_DRIVE_FILE_URL = ""; // оставьте пустым, если используете Google Таблицу
// =====================================================================
let regionData = {};
let maxPoints = 1;
let geoData = null;
const statusEl = document.getElementById('status');
// ---------- Извлечение ID из ссылки Google ----------
function extractGoogleId(url, type) {
if (!url || url.startsWith("ВСТАВЬТЕ")) return null;
if (type === 'sheets') {
const m = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
return m ? m[1] : null;
} else {
const m = url.match(/\/file\/d\/([a-zA-Z0-9-_]+)/);
return m ? m[1] : null;
}
}
// ---------- Загрузка Excel из Google ----------
async function loadExcelFromGoogle() {
let downloadUrl = null;
// Приоритет 1: Google Таблица
const sheetsId = extractGoogleId(GOOGLE_SHEETS_URL, 'sheets');
if (sheetsId) {
statusEl.textContent = "Загрузка Google Таблицы...";
downloadUrl = `https://docs.google.com/spreadsheets/d/${sheetsId}/export?format=xlsx`;
}
// Приоритет 2: .xlsx файл на Google Drive
else {
const fileId = extractGoogleId(GOOGLE_DRIVE_FILE_URL, 'drive');
if (!fileId) throw new Error("Не удалось извлечь ID из ссылки. Проверьте формат.");
statusEl.textContent = "Загрузка файла с Google Drive...";
downloadUrl = `https://drive.google.com/uc?export=download&id=${fileId}`;
}
const resp = await fetch(downloadUrl);
if (!resp.ok) throw new Error(`Ошибка загрузки: ${resp.status}. Проверьте, что файл доступен по ссылке.`);
const buffer = await resp.arrayBuffer();
statusEl.textContent = "Обработка таблицы...";
const workbook = XLSX.read(new Uint8Array(buffer), { type: 'array' });
return XLSX.utils.sheet_to_json(workbook.Sheets[workbook.SheetNames[0]], { defval: '' });
}
// ---------- Умное определение столбцов ----------
// Ищет столбец по любому из ключевых слов в названии
function findColumn(headers, keywords) {
const lowerHeaders = headers.map(h => String(h).toLowerCase().trim());
for (const kw of keywords) {
const idx = lowerHeaders.findIndex(h => h.includes(kw.toLowerCase()));
if (idx !== -1) return headers[idx];
}
return null;
}
// ---------- Словарь регионов (сопоставление с GeoJSON) ----------
const REGION_ALIASES = {
"москва": "Moskva", "санкт-петербург": "Sankt-Peterburg", "севастополь": "Sevastopol",
"адыгея": "Adygeya", "алтай": "Altay", "башкортостан": "Bashkortostan",
"бурятия": "Buryatiya", "дагестан": "Dagestan", "ингушетия": "Ingushetiya",
"кабардино-балкария": "Kabardino-Balkariya", "калмыкия": "Kalmykiya",
"карачаево-черкесия": "Karachay-Cherkessiya", "карелия": "Kareliya",
"коми": "Komi", "крым": "Krym", "марий эл": "Mariy-El", "мордовия": "Mordoviya",
"саха": "Sakha", "якутия": "Sakha", "северная осетия": "North Ossetia",
"татарстан": "Tatarstan", "тыва": "Tyva", "удмуртия": "Udmurtiya",
"хакасия": "Khakasiya", "чечня": "Chechnya", "чувашия": "Chuvashiya",
"алтайский край": "Altay Kray", "забайкальский край": "Zabaykalskiy Kray",
"камчатский край": "Kamchatskiy Kray", "краснодарский край": "Krasnodarskiy Kray",
"красноярский край": "Krasnoyarskiy Kray", "пермский край": "Permskiy Kray",
"приморский край": "Primorskiy Kray", "ставропольский край": "Stavropolskiy Kray",
"хабаровский край": "Khabarovskiy Kray", "амурская": "Amur",
"архангельская": "Arkhangelsk", "астраханская": "Astrakhan", "белгородская": "Belgorod",
"брянская": "Bryansk", "владимирская": "Vladimir", "волгоградская": "Volgograd",
"вологодская": "Vologda", "воронежская": "Voronezh", "ивановская": "Ivanovo",
"иркутская": "Irkutsk", "калужская": "Kaluga", "кемеровская": "Kemerovo",
"кировская": "Kirov", "костромская": "Kostroma", "курганская": "Kurgan",
"курская": "Kursk", "ленинградская": "Leningradskaya", "липецкая": "Lipetsk",
"магаданская": "Magadan", "московская": "Moskovskaya", "мурманская": "Murmansk",
"нижегородская": "Nizhegorodskaya", "новгородская": "Novgorod",
"новосибирская": "Novosibirsk", "омская": "Omsk", "оренбургская": "Orenburg",
"орловская": "Orel", "пензенская": "Penza", "псковская": "Pskov",
"ростовская": "Rostov", "рязанская": "Ryazan", "самарская": "Samara",
"саратовская": "Saratov", "сахалинская": "Sakhalin", "свердловская": "Sverdlovsk",
"смоленская": "Smolensk", "тамбовская": "Tambov", "тверская": "Tver",
"томская": "Tomsk", "тульская": "Tula", "тюменская": "Tyumen",
"ульяновская": "Ulyanovsk", "челябинская": "Chelyabinsk", "ярославская": "Yaroslavl",
"еврейская": "Jewish", "ненецкий": "Nenets", "ханты-мансийский": "Khanty-Mansi",
"чукотский": "Chukotka", "ямало-ненецкий": "Yamalo-Nenets"
};
function normalizeRegionName(name) {
if (!name) return null;
const clean = name.toString().trim().toLowerCase().replace(/ё/g, 'е')
.replace(/^(республика|г\.\s*|город\s*)/i, '').trim();
if (REGION_ALIASES[clean]) return REGION_ALIASES[clean];
for (const [alias, key] of Object.entries(REGION_ALIASES)) {
if (clean.includes(alias) || alias.includes(clean)) return key;
}
return null;
}
// ---------- Парсинг с автоопределением столбцов ----------
function parseTable(rows) {
if (!rows.length) throw new Error("Таблица пуста");
const headers = Object.keys(rows[0]);
const colRegion = findColumn(headers, ['регион', 'region', 'субъект', 'область']);
const colCity = findColumn(headers, ['город', 'city', 'населен', 'пункт']);
const colNetwork = findColumn(headers, ['сеть', 'network', 'бренд', 'ритейл']);
const colCount = findColumn(headers, ['количество', 'count', 'точек', 'точка', 'число', 'qty']);
console.log("Найденные столбцы:", { colRegion, colCity, colNetwork, colCount });
if (!colRegion || !colCount) {
throw new Error(`Не найдены обязательные столбцы. Доступные: ${headers.join(', ')}`);
}
regionData = {};
let lastRegion = null, lastCity = null;
const unmatched = new Set();
rows.forEach(r => {
const regionRaw = r[colRegion];
const cityRaw = colCity ? r[colCity] : '';
const network = colNetwork ? String(r[colNetwork] || '').trim() : 'Без названия';
const points = parseInt(r[colCount]) || 0;
const region = (regionRaw && String(regionRaw).trim()) || lastRegion;
const city = (cityRaw && String(cityRaw).trim()) || lastCity;
if (regionRaw) lastRegion = String(regionRaw).trim();
if (cityRaw) lastCity = String(cityRaw).trim();
if (!region || !network) return;
const key = normalizeRegionName(region);
if (!key) { unmatched.add(region); return; }
if (!regionData[key]) regionData[key] = { total: 0, displayName: region, cities: {} };
if (!regionData[key].cities[city || '—']) regionData[key].cities[city || '—'] = {};
const cities = regionData[key].cities[city || '—'];
cities[network] = (cities[network] || 0) + points;
regionData[key].total += points;
});
maxPoints = Math.max(1, ...Object.values(regionData).map(d => d.total));
document.getElementById('legend-labels').innerHTML = `<span>0</span><span>${maxPoints}</span>`;
if (unmatched.size > 0) console.warn("Несопоставленные регионы:", [...unmatched]);
return { matched: Object.keys(regionData).length, unmatched: unmatched.size };
}
// ---------- Цветовая шкала ----------
function colorScale(value) {
const t = Math.min(value / maxPoints, 1);
return `rgb(255, ${Math.round(255 - t * 153)}, ${Math.round(255 - t * 255)})`;
}
// ---------- Отрисовка карты ----------
const svg = d3.select("#map");
const tooltip = d3.select("#tooltip");
const projection = d3.geoConicEqualArea().center([0, 62]).rotate([-100, 0]).parallels([50, 70]);
const pathGen = d3.geoPath().projection(projection);
function drawMap() {
svg.selectAll("*").remove();
if (!geoData) return;
const w = window.innerWidth, h = window.innerHeight;
svg.attr("width", w).attr("height", h);
projection.fitSize([w * 0.98, h * 0.95], geoData);
const featureDataMap = new Map();
for (const [key, data] of Object.entries(regionData)) {
const feature = geoData.features.find(f => {
const n = (f.properties.name || '').toLowerCase();
const k = key.toLowerCase();
return n.includes(k) || k.includes(n.replace(/\s+/g, ''));
});
if (feature) featureDataMap.set(feature, data);
}
const g = svg.append("g");
g.selectAll("path").data(geoData.features).enter().append("path")
.attr("class", "region").attr("d", pathGen)
.attr("fill", d => {
const data = featureDataMap.get(d);
return data ? colorScale(data.total) : "#f0f0f0";
})
.on("mousemove", (event, d) => {
const data = featureDataMap.get(d);
if (!data) { tooltip.style("display", "none"); return; }
let html = `<h3>${data.displayName}</h3><div class="total">Всего точек: ${data.total}</div>`;
for (const [city, networks] of Object.entries(data.cities)) {
html += `<div class="city-block"><div class="city-name">📍 ${city}</div><ul>`;
Object.entries(networks).sort((a,b) => b[1]-a[1])
.forEach(([net, cnt]) => html += `<li>${net}: <b>${cnt}</b></li>`);
html += `</ul></div>`;
}
tooltip.html(html).style("display", "block")
.style("left", (event.pageX + 15) + "px")
.style("top", (event.pageY + 15) + "px");
})
.on("mouseout", () => tooltip.style("display", "none"));
g.selectAll("text").data(geoData.features).enter().append("text")
.attr("class", "region-label")
.attr("x", d => pathGen.centroid(d)[0])
.attr("y", d => pathGen.centroid(d)[1])
.text(d => {
const data = featureDataMap.get(d);
if (!data) return "";
const name = (d.properties.name || '').replace(/^(Республика|г\.)\s*/i, '').trim();
return `${name} (${data.total})`;
});
statusEl.textContent = `✓ Загружено регионов: ${Object.keys(regionData).length}`;
}
// ---------- Запуск ----------
(async () => {
try {
const [rows, geo] = await Promise.all([
loadExcelFromGoogle(),
d3.json("https://raw.githubusercontent.com/codeforamerica/click_that_hood/master/public/data/russia.geojson")
]);
geoData = geo;
const stats = parseTable(rows);
drawMap();
console.log("Статистика:", stats);
} catch (err) {
console.error(err);
statusEl.textContent = "❌ " + err.message;
statusEl.style.background = "#ffe5e5";
}
})();
window.addEventListener('resize', () => { if (geoData) drawMap(); });
</script>
</body>
</html> проверь все ливерно