Ожидание загрузки библиотек...
Данные с внешнего ресурса
Укажите прямую ссылку на файл .xlsx / .csv или опубликованную Google Таблицу в формате CSV.
Демо-данные отобразятся сразу — без загрузки.
Или выберите файл с устройства:
Количество торговых точек
00
<!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> проверь все ливерно
Made on
Tilda