diff --git a/app/Core/Konstanty.php b/app/Core/Konstanty.php index 8dbe568..8012e0b 100644 --- a/app/Core/Konstanty.php +++ b/app/Core/Konstanty.php @@ -10,6 +10,6 @@ final class Konstanty * Verze aplikace. * @var string */ - public const verze = '1.1'; + public const verze = '1.4'; } \ No newline at end of file diff --git a/app/Model/ObvodFacade.php b/app/Model/ObvodFacade.php index acf2b7d..c8ac852 100644 --- a/app/Model/ObvodFacade.php +++ b/app/Model/ObvodFacade.php @@ -9,6 +9,8 @@ use Nette\Database\Explorer; use Nette\Database\Row; use Nette\Security\User; use Nette\Utils\DateTime; +use Nette\Utils\Json; +use Nette\Utils\JsonException; /** * Třída pro načítání informací o obvodech z DB. @@ -130,4 +132,66 @@ final class ObvodFacade 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], + ]); + } + } \ No newline at end of file diff --git a/app/Presentation/Obvod/ObvodPresenter.php b/app/Presentation/Obvod/ObvodPresenter.php index bb8a124..0d43f3e 100644 --- a/app/Presentation/Obvod/ObvodPresenter.php +++ b/app/Presentation/Obvod/ObvodPresenter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Presentation\Obvod; +use App\Model\LoginFacade; use App\Model\ObvodFacade; use App\Model\ZaznamFacade; use App\Presentation\BasePresenter; @@ -34,7 +35,8 @@ final class ObvodPresenter extends BasePresenter public function __construct( 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); } + 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 { $form = new Form; diff --git a/app/Presentation/Obvod/hranice.latte b/app/Presentation/Obvod/hranice.latte new file mode 100644 index 0000000..f5bb084 --- /dev/null +++ b/app/Presentation/Obvod/hranice.latte @@ -0,0 +1,18 @@ +{block title}Hranice obvodu {$obvod->CISLO}{/block} + +{block content} +
+
+

Modrý je obvod {$obvod->CISLO}. Kolečko táhni, menší kroužek na hraně bod přidá. Vybraný bod odebere tlačítko.

+

{$error}

+
+ + + + Zrušit +
+
+ +{/block} diff --git a/app/Presentation/Obvod/view.latte b/app/Presentation/Obvod/view.latte index 8a0afe4..95dca05 100644 --- a/app/Presentation/Obvod/view.latte +++ b/app/Presentation/Obvod/view.latte @@ -27,6 +27,7 @@ Mapa + Upravit hranice diff --git a/www/css/base.css b/www/css/base.css index afa72b5..34b938e 100644 --- a/www/css/base.css +++ b/www/css/base.css @@ -732,6 +732,11 @@ body:has(#map[data-max]) { justify-content: flex-end; } +.detail .akce { + flex-direction: column; + align-items: stretch; +} + .formular .form-control, .modal .form-control, .modal .form-select, @@ -1001,6 +1006,87 @@ body:has(#map[data-max]) { 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 { color: #1d4f91; font-weight: 800; diff --git a/www/js/hranice.js b/www/js/hranice.js new file mode 100644 index 0000000..be5ae6c --- /dev/null +++ b/www/js/hranice.js @@ -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: '', + 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(); + }); +})();