Úpravy hranic obvodů

master
Kowalski 2026-10-12 18:04:56 +02:00
parent e20e0d45b1
commit 6d22e07ccf
7 changed files with 461 additions and 2 deletions

View File

@ -10,6 +10,6 @@ final class Konstanty
* Verze aplikace. * Verze aplikace.
* @var string * @var string
*/ */
public const verze = '1.1'; public const verze = '1.4';
} }

View File

@ -9,6 +9,8 @@ use Nette\Database\Explorer;
use Nette\Database\Row; use Nette\Database\Row;
use Nette\Security\User; use Nette\Security\User;
use Nette\Utils\DateTime; use Nette\Utils\DateTime;
use Nette\Utils\Json;
use Nette\Utils\JsonException;
/** /**
* Třída pro načítání informací o obvodech z DB. * Třída pro načítání informací o obvodech z DB.
@ -130,4 +132,66 @@ final class ObvodFacade
return '{"type":"FeatureCollection","features":[' . implode(',', $features) . ']}'; return '{"type":"FeatureCollection","features":[' . implode(',', $features) . ']}';
} }
/**
* Uloží hranici obvodu. GEO je GeoJSON Polygon s jedním prstencem.
*/
public function ulozHranici(int $id, string $geo): void
{
if ($this->database->fetchField('SELECT ID FROM OBVOD WHERE ID = ?', $id) === null) {
throw new \InvalidArgumentException('Obvod nenalezen.');
}
$this->database->query(
'UPDATE OBVOD SET GEO = ? WHERE ID = ?',
$this->overHranici($geo),
$id,
);
}
private function overHranici(string $geo): string
{
try {
$data = Json::decode($geo, forceArrays: true);
} catch (JsonException) {
throw new \InvalidArgumentException('Hranice nejsou platný JSON.');
}
if (!is_array($data) || ($data['type'] ?? '') !== 'Polygon' || !isset($data['coordinates']) || !is_array($data['coordinates'])) {
throw new \InvalidArgumentException('Hranice musí být polygon.');
}
if (count($data['coordinates']) !== 1 || !is_array($data['coordinates'][0])) {
throw new \InvalidArgumentException('Polygon musí mít jeden prstenec bez děr.');
}
$kruh = [];
foreach ($data['coordinates'][0] as $bod) {
if (!is_array($bod) || count($bod) < 2 || !is_numeric($bod[0]) || !is_numeric($bod[1])) {
throw new \InvalidArgumentException('Bod hranice nemá souřadnice.');
}
$lng = round((float) $bod[0], 7);
$lat = round((float) $bod[1], 7);
if ($lng < 12.0 || $lng > 19.0 || $lat < 48.0 || $lat > 52.0) {
throw new \InvalidArgumentException('Bod hranice je mimo oblast obvodů.');
}
$kruh[] = [$lng, $lat];
}
if (count($kruh) > 1 && $kruh[0] === $kruh[count($kruh) - 1]) {
array_pop($kruh);
}
if (count($kruh) < 3) {
throw new \InvalidArgumentException('Hranice musí mít aspoň tři body.');
}
if (count($kruh) > 2000) {
throw new \InvalidArgumentException('Hranice má příliš mnoho bodů.');
}
$kruh[] = $kruh[0];
return Json::encode([
'type' => 'Polygon',
'coordinates' => [$kruh],
]);
}
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Presentation\Obvod; namespace App\Presentation\Obvod;
use App\Model\LoginFacade;
use App\Model\ObvodFacade; use App\Model\ObvodFacade;
use App\Model\ZaznamFacade; use App\Model\ZaznamFacade;
use App\Presentation\BasePresenter; use App\Presentation\BasePresenter;
@ -34,7 +35,8 @@ final class ObvodPresenter extends BasePresenter
public function __construct( public function __construct(
private ObvodFacade $obvod, private ObvodFacade $obvod,
private ZaznamFacade $zaznam private ZaznamFacade $zaznam,
private LoginFacade $uzivatele,
) { ) {
} }
@ -105,6 +107,54 @@ final class ObvodPresenter extends BasePresenter
$this->template->otevrene = $id === null ? [] : $this->zaznam->getOtevrene($id); $this->template->otevrene = $id === null ? [] : $this->zaznam->getOtevrene($id);
} }
public function actionHranice(int $id): void
{
$this->overSpravce();
if ($this->obvod->getObvod($id) === null) {
$this->error('Obvod nenalezen.', Nette\Http\IResponse::S404_NotFound);
}
}
public function renderHranice(int $id): void
{
$this->template->obvod = $this->obvod->getObvod($id);
}
protected function createComponentHraniceForm(): Form
{
$form = new Form;
$form->addHidden('geo');
$form->addSubmit('send', 'Uložit');
$form->addProtection('Platnost formuláře vypršela, odešlete ho znovu.');
$form->onSuccess[] = $this->hraniceFormSucceeded(...);
return $form;
}
private function hraniceFormSucceeded(Form $form, ArrayHash $values): void
{
$this->overSpravce();
$id = $this->obvodId();
try {
$this->obvod->ulozHranici($id, (string) $values->geo);
} catch (\InvalidArgumentException $e) {
$form->addError($e->getMessage());
return;
}
$this->flashMessage('Hranice obvodu byly uloženy.');
$this->redirect('view', ['id' => $id]);
}
private function overSpravce(): void
{
if ($this->uzivatele->jeAdmin((int) $this->getUser()->getId())) {
return;
}
$this->flashMessage('Hranice obvodu může upravovat jen správce.', 'error');
$this->redirect('view', ['id' => $this->getParameter('id')]);
}
protected function createComponentPridelitForm(): Form protected function createComponentPridelitForm(): Form
{ {
$form = new Form; $form = new Form;

View File

@ -0,0 +1,18 @@
{block title}Hranice obvodu {$obvod->CISLO}{/block}
{block content}
<div id="map"
data-geojson="{link :Mapa:geojson}"
data-obvod="{$obvod->CISLO}"></div>
<div class="hranice-lista">
<p class="hranice-napoveda">Modrý je obvod {$obvod->CISLO}. Kolečko táhni, menší kroužek na hraně bod přidá. Vybraný bod odebere tlačítko.</p>
<p class="hranice-chyba" n:if="$presenter['hraniceForm']->hasErrors()" n:foreach="$presenter['hraniceForm']->getErrors() as $error">{$error}</p>
<form n:name="hraniceForm" class="hranice-akce">
<input n:name="geo">
<button type="button" id="hranice-odebrat" class="tlacitko" disabled>Odebrat bod</button>
<button n:name="send" class="tlacitko is-aktivni" disabled>Uložit</button>
<a n:href="view id: $obvod->ID" class="tlacitko tlacitko-zrusit">Zrušit</a>
</form>
</div>
<script src="{$basePath}/js/hranice.js?v={$appVersion ?? '1.0'}"></script>
{/block}

View File

@ -27,6 +27,7 @@
<a n:href=":Mapa: obvod:$obvod->CISLO" class="tlacitko">Mapa</a> <a n:href=":Mapa: obvod:$obvod->CISLO" class="tlacitko">Mapa</a>
<button n:if="count($otevrene) === 0" type="button" class="tlacitko is-aktivni" data-bs-toggle="modal" data-bs-target="#modalPridelit">Přidělit</button> <button n:if="count($otevrene) === 0" type="button" class="tlacitko is-aktivni" data-bs-toggle="modal" data-bs-target="#modalPridelit">Přidělit</button>
<button n:if="count($otevrene) > 0" type="button" class="tlacitko" data-bs-toggle="modal" data-bs-target="#modalVratit">Vrátit</button> <button n:if="count($otevrene) > 0" type="button" class="tlacitko" data-bs-toggle="modal" data-bs-target="#modalVratit">Vrátit</button>
<a n:if="$jeAdmin ?? false" n:href="hranice id: $obvod->ID" class="tlacitko">Upravit hranice</a>
</div> </div>
</header> </header>

View File

@ -732,6 +732,11 @@ body:has(#map[data-max]) {
justify-content: flex-end; justify-content: flex-end;
} }
.detail .akce {
flex-direction: column;
align-items: stretch;
}
.formular .form-control, .formular .form-control,
.modal .form-control, .modal .form-control,
.modal .form-select, .modal .form-select,
@ -1001,6 +1006,87 @@ body:has(#map[data-max]) {
border-color: #78909c; border-color: #78909c;
} }
body:has(.hranice-lista) {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
body:has(.hranice-lista) #map {
flex: 1 1 auto;
height: auto;
min-height: 0;
}
.hranice-lista {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #c5ddf6;
}
.hranice-napoveda {
margin: 0;
flex: 1 1 18rem;
color: #3d5a80;
font-size: 0.95rem;
}
.hranice-chyba {
flex: 1 0 100%;
margin: 0;
color: #842029;
font-weight: 700;
}
.hranice-akce {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
}
.hranice-akce .tlacitko-zrusit {
margin-left: 0;
}
.hranice-bod,
.hranice-vlozit {
background: transparent;
border: none;
}
.hranice-bod span,
.hranice-vlozit span {
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 50%;
}
.hranice-bod span {
background: #fff;
border: 3px solid #1d4f91;
box-shadow: 0 1px 4px rgba(29, 79, 145, 0.4);
cursor: grab;
}
.hranice-bod.is-vybrany span {
background: #2652a8;
}
.hranice-vlozit span {
background: #fff;
border: 2px solid #78909c;
cursor: copy;
}
.modal-title { .modal-title {
color: #1d4f91; color: #1d4f91;
font-weight: 800; font-weight: 800;

240
www/js/hranice.js Normal file
View File

@ -0,0 +1,240 @@
// Úprava hranice jednoho obvodu. Ostatní obvody jsou jen na pozadí.
(function () {
var mapaEl = document.getElementById('map');
var form = document.getElementById('frm-hraniceForm');
var geoInput = form ? form.querySelector('[name="geo"]') : null;
var odebrat = document.getElementById('hranice-odebrat');
var ulozit = form ? form.querySelector('[name="send"]') : null;
if (!mapaEl || !form || !geoInput || typeof L === 'undefined') {
return;
}
var obvodId = mapaEl.getAttribute('data-obvod');
var geojsonUrl = mapaEl.getAttribute('data-geojson');
var podklady = MapovePodklady.vytvor();
var map = L.map(mapaEl, {
center: [50.853501146, 14.8425],
zoom: 14,
layers: [MapovePodklady.vychozi(podklady)],
doubleClickZoom: false,
});
L.control.layers(MapovePodklady.popisky(podklady)).addTo(map);
MapovePodklady.logo(map, podklady);
var seda = {
fillColor: '#d5d5d5',
color: '#757575',
weight: 1.5,
opacity: 1,
fillOpacity: 0.35,
};
var modra = {
fillColor: '#c5ddf6',
color: '#1d4f91',
weight: 3,
opacity: 1,
fillOpacity: 0.4,
};
var body = [];
var vybrany = null;
var polygon = L.polygon([], modra).addTo(map);
var vrcholy = L.layerGroup().addTo(map);
var prostredky = L.layerGroup().addTo(map);
function stejne(a, b) {
return Math.abs(a[0] - b[0]) < 1e-9 && Math.abs(a[1] - b[1]) < 1e-9;
}
function nactiKruh(geometrie) {
if (!geometrie || geometrie.type !== 'Polygon' || !geometrie.coordinates || !geometrie.coordinates[0]) {
return [];
}
var kruh = geometrie.coordinates[0].map(function (bod) {
return [Number(bod[0]), Number(bod[1])];
});
if (kruh.length > 1 && stejne(kruh[0], kruh[kruh.length - 1])) {
kruh.pop();
}
return kruh;
}
function latlng(kruh) {
return kruh.map(function (bod) {
return [bod[1], bod[0]];
});
}
function zapisPole() {
var kruh = body.map(function (bod) {
return [bod[0], bod[1]];
});
if (kruh.length) {
kruh.push(kruh[0]);
}
geoInput.value = JSON.stringify({
type: 'Polygon',
coordinates: [kruh],
});
}
function nastavOdebrat() {
if (!odebrat) {
return;
}
odebrat.disabled = vybrany === null || body.length <= 3;
}
function ikona(trida, velikost) {
return L.divIcon({
className: trida,
html: '<span></span>',
iconSize: [velikost, velikost],
iconAnchor: [velikost / 2, velikost / 2],
});
}
function prekresli() {
polygon.setLatLngs(latlng(body));
vrcholy.clearLayers();
prostredky.clearLayers();
body.forEach(function (bod, i) {
var marker = L.marker([bod[1], bod[0]], {
draggable: true,
icon: ikona('hranice-bod', 22),
zIndexOffset: 1000,
keyboard: false,
});
marker.poradi = i;
marker.on('add', function () {
var el = marker.getElement();
if (!el) {
return;
}
el.title = 'Táhni pro posun. Kliknutím bod vybereš.';
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Bod hranice ' + (i + 1));
el.classList.toggle('is-vybrany', i === vybrany);
});
marker.on('click', function (e) {
L.DomEvent.stop(e);
vybrany = i;
oznacVyber();
nastavOdebrat();
});
marker.on('drag', function (e) {
var ll = e.target.getLatLng();
body[i] = [ll.lng, ll.lat];
polygon.setLatLngs(latlng(body));
});
marker.on('dragend', function () {
vybrany = i;
prekresli();
});
marker.on('dblclick', function (e) {
L.DomEvent.stop(e);
odeber(i);
});
vrcholy.addLayer(marker);
});
body.forEach(function (bod, i) {
var dalsi = body[(i + 1) % body.length];
var stred = [(bod[0] + dalsi[0]) / 2, (bod[1] + dalsi[1]) / 2];
var marker = L.marker([stred[1], stred[0]], {
icon: ikona('hranice-vlozit', 16),
zIndexOffset: 500,
keyboard: false,
});
marker.on('add', function () {
var el = marker.getElement();
if (el) {
el.title = 'Přidat bod';
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Přidat bod');
}
});
marker.on('click', function (e) {
L.DomEvent.stop(e);
body.splice(i + 1, 0, stred);
vybrany = i + 1;
prekresli();
});
prostredky.addLayer(marker);
});
zapisPole();
nastavOdebrat();
}
function oznacVyber() {
vrcholy.eachLayer(function (marker) {
var el = marker.getElement();
if (el) {
el.classList.toggle('is-vybrany', marker.poradi === vybrany);
}
});
}
function odeber(index) {
if (body.length <= 3 || index < 0 || index >= body.length) {
return;
}
body.splice(index, 1);
vybrany = null;
prekresli();
}
if (odebrat) {
odebrat.addEventListener('click', function () {
if (vybrany !== null) {
odeber(vybrany);
}
});
}
form.addEventListener('submit', function () {
zapisPole();
});
$.getJSON(geojsonUrl, function (data) {
var ulozena = null;
if (geoInput.value) {
try {
ulozena = JSON.parse(geoInput.value);
} catch (e) {
ulozena = null;
}
}
L.geoJSON(data, {
interactive: false,
style: seda,
filter: function (feature) {
return feature.properties && String(feature.properties.number) !== String(obvodId);
},
}).addTo(map);
var vlastni = null;
(data.features || []).some(function (feature) {
if (feature.properties && String(feature.properties.number) === String(obvodId)) {
vlastni = feature.geometry;
return true;
}
return false;
});
body = nactiKruh(ulozena || vlastni);
if (!body.length) {
return;
}
prekresli();
polygon.bringToFront();
map.fitBounds(polygon.getBounds(), { padding: [80, 80], maxZoom: 17 });
if (ulozit) {
ulozit.disabled = false;
}
map.invalidateSize();
});
})();