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}
+
+
+
+{/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();
+ });
+})();