Рисуем карту выборов

Для проекта ЛФ-Выборы я давно мучился вопросом визуализации результатов больших выборов. С городскими, сельскими, и зачастую даже с региональными всё довольно просто, а вот выборы федерального значения нарисовать красиво и информативно — это проблема.

Реально очень красивый, и как мне кажется, весьма информативный вариант получился у Daniel Marcus но есть у этого варианта несколько технических и одна экономическая проблема. Собственно экономическая проблема привлекла моё внимание и мотивировала начать писать этот пост.

Сделал человек хорошее дело, за которое многие ему сказали спасибо, не меньше сказали дякую, вот только при 2.3 млн. просмотров карты получился счет от MapBox на сумму более тысячи баксов, которую «спасибами» и «дякованиями» не оплатишь. По слухам MapBox повел себя весьма лояльно и пересчитал по тарифам для НКО, но я полагаю нервов разработчику это стоило.

Плюс к тому, карта Даниеля при всей своей красоте, мягко говоря тяжелая по трафику — 16.4 мб первоначальной загрузки это много, для сравнения первоначальная загрузка maps.google.com в десять раз меньше. Это не критично если у Вас оптика на 100мбит, и в общем ради такой красоты можно и на сельских 10 мегабитах подождать, но на мобильном инете можно недождаться, а если мобильный инет в роуминге, то и не расплатиться…

Третья проблема — это нагрузка на браузер. Даже на не самом дохлом i7-4710HQ с 24Гб мозгов не самый медленный Google Chrome тупит 15 секунд пытаясь интерпретировать Javascript для отображения украинских президентских выборов, соответственно на бюджетном планшете под андроид он скорее всего тупить будет до следующих выборов, а в случае с выборами в России, где при равном количестве избирателей на участок, участков втрое больше, учитывая что нагрузка идет по экспоненте — помрет даже i9.

Этот пост я решил сделать для того чтобы помочь, Даниелю в частности, а может и другим активистам, которые у себя в стране заходят сделать что-то подобное. Сразу оговорюсь — пост будет добавляться.

При всём уважении к MapBox, GoogleMaps и т.п. — нафиг их всех

Сегодня на рынке коммерческих картографических сервисов есть несколько сильных игроков, описывая функционал которых проще сказать чего они не могут, нежели перечислять их возможности, но для визуализации результатов выборов 99.99% их функционала не нужно.

Есть замечательная бесплатная библиотека https://leafletjs.com/ с кучей плагинов, о которых позже, которая позволяет у себя на сайте сделать, применив плагин «256 оттенков серого», то есть Leaflet.TileLayer.Grayscale и в итоге получаем нечто вроде https://www.leftfront.org/elections/map/example1.php

«Свои карты»

Проблема в том, пример №1 не дает независимость, так как берет слои по ссылке http://tile.openstreetmap.org/{z}/{x}/{y}.png

OpenStreetMap, живущий на донаты имеет ограничения, так как для мелких проектов они могут себе позволить коммунизм, а для сайтов с трафиком в миллионы пользователей — оплачивая трафик, не могут.

Есть готовые решения на тему TileCache, но лично мне, чем с ними разбираться проще написать свой вариант. Который на PHP выглядит проще простого:

<?php
#Все косяки просим сообщить в браузер (один хрен все не сообщит, но иногда полезно)
ini_set('display_errors', 1);
ini_set('error_reporting', 2047);
#Проверяем если ли вообще запрос
$CacheTime=3600; #В секундах хранения файла
$CacheTime=600; #В секундах хранения в памяти
if (!(isset($_GET['x']))) {exit;}
if (!(isset($_GET['y']))) {exit;}
if (!(isset($_GET['z']))) {exit;}
#Если есть то пихаем х, у, и еще одну пременную в переменные :)
$x=$_GET['x']; $y=$_GET['y']; $z=$_GET['z'];
$dir='cache/'.$z.'/'.$x;
#Подключаемся к memcached - мозги в Германии стоят копейки, я не о тех что программируют, а о тех что на серверах стоят в Hetzner, за 70 евро 32Гб в месяц...
$memcached = new Memcached;
$memcached->addServer('localhost', 11211) or die ("Could not connect");
#Смотрим есть ли в наших мозгах нужный нам файлик png 256х256
if (($Out=$memcached->get('GetTileMap'.$z.'/'.$x.'/'.$y)) === false)
{
#Смотрим есть ли файлик и не просрочка ли у нас тут как в магизинах?
if (file_exists($dir.'/'.$y.'.png')) {if (time()-filemtime($dir.'/'.$y.'.png')<$CacheTime) {unlink($dir.'/'.$y.'.png');}}
#Если файла нету...
if (!(file_exists($dir.'/'.$y.'.png')))
{
#Пытаемся делать директорию которую нынче принято называть папочкой
if (!(file_exists($dir))) {mkdir($dir,0777,true);}
#Тут можно было бы поизголяться но мы просто сохраним что получили
file_put_contents($dir.'/'.$y.'.png', file_get_contents('http://tile.openstreetmap.org/'.$z.'/'.$x.'/'.$y.'.png'));
}
#Читаем что получилось
$handle = fopen($dir.'/'.$y.'.png', "rb");
$Out = stream_get_contents($handle);
#Мотаем на ус, точнее записываем в память memcached
$memcached->set('GetTileMap'.$z.'/'.$x.'/'.$y, $Out, $CacheTime);
}
header('Content-Type: image/png');
header('Cache-Control: max-age=900');
echo $Out;
?>

К этому делу останется переписать вариант на пример №2 https://www.leftfront.org/elections/map/example2.php и не париться за финансы, которые с тебя попросят, понимая что теперь у тебя всё своё родное, лишь эпиздически обновляемое, причем обновляется изредка, 90% запросов держа в памяти, даже к диску не обращаясь…

Возможен вариант что с местом проблема, хотя нынче место на диске не стоит почти ничего, и по слухам полный кэш всего шарика во всех зумах весит около 2Гб, но можно дописать скриптец который будет бегать по директориям и удалять устаревшие файлы — это уже задачка для третьего класса средней школы.

Отрисовка на карте

На выборах преЗЕдента Украины было 29989 участков, на выборах Путина в 2018ом в России их было 97695. Такой объем данных разом отображенный на карте просто не может не тупить, потому что их надо оформить на стороне сервера, потом протащить до клиента, у которого вполне возможно не 100мбит, а потом в браузере создать десятки тысяч объектов, что тоже время + последующий тупняк не только этой, но и всех других страниц браузера.

При этом очевидно, что попасть мышью в конкретный участок невозможно, поэтому намного эффективнее собственный графический слой, благо leafletjs без проблем поддерживает несколько слоев.

        L.tileLayer.grayscale('tile.php?z={z}&x={x}&y={y}', {
            attribution: 'Map data © OpenStreetMap contributors',
            maxZoom: 14, minZoom: 3
        }).addTo(map);
        L.tileLayer('tileres.php?eid=82094&z={z}&x={x}&y={y}', {
            attribution: 'Election results',
            maxZoom: 8, minZoom: 3
        }).addTo(map);

При таких настройках фоновая карта будет работать от 3 до 14 зума, это начиная от целиком вся Россия на экране, заканчивая детализацией до дома, а вот второй слой будет работать лишь до 8 зума, то есть до уровня города, так как при большей приближении нужно будет выставлять кликабельные маркеры с красивыми результатами.

В данном случае скрипт tileres.php принимает кроме стандартных параметров еще и ID выборов, в моем случае выборы Путина 2018 значатся в базе под номером 82094 🙂

Чтобы скрипт не работал впустую из-за шутников, внутри первым делом прописываем условия по допустимому зуму, и если он запредельный то делаем ExitPNG которая выглядит вот так:

function ExitPNG()
{
	header('Content-type: image/png');
	echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=');	
	exit;
}

Дальше находим крайние координаты где у нас выборы были, чтобы не пытаться напрягать машину поиском участков в Африке, и если запрошенный квадрат за пределами наших выборов то тот же ExitPNG, при этом заграничные участки, коих пара сотен рисуем кликабельными маркерами отдельно, нагрузки они не создадут, да и попасть мышкой в Германию вполне реально.

В принципе нужно всего две функции. Первая — преобразования XYZ в координаты, чтобы понять какую область выгребать для визуализации в базе.

	$n = pow(2, $zoom);
	$tlon1 = ($xtile-0.1) / $n * 360.0 - 180.0;
	$tlon2 = ($xtile+1.1) / ($n) * 360.0 - 180.0;
	$tlat1 = rad2deg(atan(sinh(pi() * (1 - 2 * ($ytile-0.1) / $n))));
	$tlat2 = rad2deg(atan(sinh(pi() * (1 - 2 * ($ytile+1.1) / $n))));
	#Дальше из базы выгребаем по запросу типа
	$SQL='SELECT .... FROM .... WHERE Lat<'.$tlat1.' AND Lat>'.$tlat2.' and Lng>'.$tlon1.' and Lng<'.$tlon2;

Небольшой нюанс, надо выгребать нужно немного за пределами, отсюда "-0.1", потому что бывает, что координата участка слегка за пределами плитки, а вот её метка по уму должна присутствовать на нем за счет радиуса отрисовки, и в итоге получаем круг с радиусом 5 пикселей в коодинатах -3, -3 получая отрисовку части круга который начинается в другой плитке.

Вторая функция - преобразование координаты в пиксельное положение на плитке.

	$dx = ((($lng + 180) / 360) * pow(2, $zoom));
	$dx = round(($dx-$xtile)*255);
	$dy = ((1 - log(tan(deg2rad($lat)) + 1 / cos(deg2rad($lat))) / pi()) /2 * pow(2, $zoom));	
	$dy = round(($dy-$ytile)*255);

При этом как минимум ImageMagick спокойно относится к команде рисования круга, центр которого за пределами изображения.