diff --git a/app/Core/Konstanty.php b/app/Core/Konstanty.php index 8012e0b..1f84d1f 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.4'; + public const verze = '1.8'; } \ No newline at end of file diff --git a/app/Model/SborFacade.php b/app/Model/SborFacade.php new file mode 100644 index 0000000..2392828 --- /dev/null +++ b/app/Model/SborFacade.php @@ -0,0 +1,208 @@ +database->fetch('SELECT TOP 1 NAZEV, GEO FROM SBOR ORDER BY ID'); + if ($radek === null) { + return null; + } + + try { + $geo = Json::decode((string) $radek->GEO, forceArrays: true); + } catch (JsonException) { + $geo = []; + } + + return [ + 'nazev' => (string) $radek->NAZEV, + 'bodu' => is_array($geo) ? $this->pocetBodu($geo) : 0, + ]; + } + + /** + * GeoJSON geometrie jediné uložené hranice sboru. + */ + public function getGeoJson(): ?string + { + $geo = $this->database->fetchField('SELECT TOP 1 GEO FROM SBOR ORDER BY ID'); + if (!is_string($geo) || trim($geo) === '') { + return null; + } + + return $geo; + } + + public function nahradZKml(string $kml): void + { + $nazev = $this->nazevZKml($kml); + $geo = Json::encode($this->geometrieZKml($kml)); + + $id = $this->database->fetchField('SELECT TOP 1 ID FROM SBOR ORDER BY ID'); + if ($id === null) { + $this->database->query('INSERT INTO SBOR (NAZEV, GEO) VALUES (?, ?)', $nazev, $geo); + return; + } + + $this->database->query('UPDATE SBOR SET NAZEV = ?, GEO = ? WHERE ID = ?', $nazev, $geo, $id); + $this->database->query('DELETE FROM SBOR WHERE ID <> ?', $id); + } + + private function nazevZKml(string $kml): string + { + $xml = $this->nactiKml($kml); + $nazvy = $xml->xpath('//k:Document/k:name'); + if ($nazvy === false || $nazvy === []) { + $nazvy = $xml->xpath('//k:Placemark/k:name'); + } + $nazev = trim((string) ($nazvy[0] ?? '')); + if ($nazev === '') { + throw new \InvalidArgumentException('V souboru KML chybí název sboru.'); + } + if (mb_strlen($nazev) > 50) { + throw new \InvalidArgumentException('Název sboru v souboru je delší než 50 znaků.'); + } + + return $nazev; + } + + /** + * @return array{type: string, coordinates: array} + */ + private function geometrieZKml(string $kml): array + { + $xml = $this->nactiKml($kml); + $uzly = $xml->xpath('//k:Polygon/k:outerBoundaryIs/k:LinearRing/k:coordinates'); + if ($uzly === false || $uzly === []) { + throw new \InvalidArgumentException('V souboru KML není polygon s hranicí.'); + } + + $prstence = []; + foreach ($uzly as $uzel) { + $prstence[] = $this->prstenec((string) $uzel); + } + + if (count($prstence) === 1) { + return [ + 'type' => 'Polygon', + 'coordinates' => [$prstence[0]], + ]; + } + + return [ + 'type' => 'MultiPolygon', + 'coordinates' => array_map( + static fn(array $prstenec): array => [$prstenec], + $prstence, + ), + ]; + } + + private function nactiKml(string $kml): SimpleXMLElement + { + $kml = trim($kml); + if ($kml === '' || !str_contains($kml, 'registerXPathNamespace('k', 'http://www.opengis.net/kml/2.2'); + return $xml; + } + + /** + * @return list + */ + private function prstenec(string $text): array + { + $kruh = []; + foreach (preg_split('/\s+/', trim($text)) ?: [] as $token) { + if ($token === '') { + continue; + } + $casti = explode(',', $token); + if (count($casti) < 2 || !is_numeric($casti[0]) || !is_numeric($casti[1])) { + throw new \InvalidArgumentException('Bod hranice v KML nemá souřadnice.'); + } + $lng = round((float) $casti[0], 7); + $lat = round((float) $casti[1], 7); + if ($lng < 12.0 || $lng > 19.0 || $lat < 48.0 || $lat > 52.0) { + throw new \InvalidArgumentException('Bod hranice sboru 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 sboru musí mít aspoň tři body.'); + } + if (count($kruh) > 50000) { + throw new \InvalidArgumentException('Hranice sboru má příliš mnoho bodů.'); + } + + $kruh[] = $kruh[0]; + return $kruh; + } + + /** + * @param array $geo + */ + private function pocetBodu(array $geo): int + { + $prstence = []; + if (($geo['type'] ?? '') === 'Polygon') { + $prstence[] = $geo['coordinates'][0] ?? []; + } elseif (($geo['type'] ?? '') === 'MultiPolygon') { + foreach ($geo['coordinates'] ?? [] as $polygon) { + $prstence[] = $polygon[0] ?? []; + } + } + + $pocet = 0; + foreach ($prstence as $kruh) { + if (!is_array($kruh)) { + continue; + } + $delka = count($kruh); + if ($delka > 1 && $kruh[0] === $kruh[$delka - 1]) { + $delka--; + } + $pocet += $delka; + } + + return $pocet; + } +} diff --git a/app/Presentation/Administrace/AdministracePresenter.php b/app/Presentation/Administrace/AdministracePresenter.php index e5698f5..298c9d7 100644 --- a/app/Presentation/Administrace/AdministracePresenter.php +++ b/app/Presentation/Administrace/AdministracePresenter.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace App\Presentation\Administrace; use App\Model\LoginFacade; +use App\Model\SborFacade; use App\Presentation\BasePresenter; use Nette; use Nette\Application\UI\Form; use Nette\Application\UI\Multiplier; use Nette\Database\Row; +use Nette\Http\FileUpload; use Nette\Utils\ArrayHash; @@ -19,6 +21,7 @@ final class AdministracePresenter extends BasePresenter public function __construct( private LoginFacade $uzivatele, + private SborFacade $sbor, ) { } @@ -52,6 +55,50 @@ final class AdministracePresenter extends BasePresenter $this->template->stav = $this->stav; $this->template->uzivatele = $uzivatele; + $this->template->sbor = $this->sbor->getSbor(); + } + + protected function createComponentSborForm(): Form + { + $form = new Form; + $form->addUpload('soubor', 'Soubor KML') + ->setRequired('Vyberte soubor KML.') + ->setHtmlAttribute('accept', '.kml,application/vnd.google-earth.kml+xml'); + $form->addSubmit('send', 'Nahrát'); + $form->addProtection('Platnost formuláře vypršela, odešlete ho znovu.'); + $form->onSuccess[] = $this->sborFormSucceeded(...); + return $form; + } + + private function sborFormSucceeded(Form $form, ArrayHash $values): void + { + $soubor = $values->soubor; + if (!$soubor instanceof FileUpload || !$soubor->isOk()) { + $form->addError('Soubor se nepodařilo nahrát.'); + return; + } + + $pripona = strtolower(pathinfo($soubor->getUntrustedName(), PATHINFO_EXTENSION)); + if ($pripona !== 'kml') { + $form->addError('Nahrajte soubor ve formátu KML.'); + return; + } + + $obsah = $soubor->getContents(); + if (!is_string($obsah) || $obsah === '') { + $form->addError('Soubor KML je prázdný.'); + return; + } + + try { + $this->sbor->nahradZKml($obsah); + } catch (\InvalidArgumentException $e) { + $form->addError($e->getMessage()); + return; + } + + $this->flashMessage('Hranice sboru byly uloženy.'); + $this->redirect('this'); } protected function createComponentNovyForm(): Form diff --git a/app/Presentation/Administrace/default.latte b/app/Presentation/Administrace/default.latte index d3e0326..ab326da 100644 --- a/app/Presentation/Administrace/default.latte +++ b/app/Presentation/Administrace/default.latte @@ -51,6 +51,20 @@

V tomto filtru není žádný uživatel.

+ +
+

Hranice sboru

+

Uloženo: {$sbor['nazev']}. Hranice má {$sbor['bodu']} bodů. Nový soubor KML tyhle body nahradí.

+

Hranice sboru zatím nejsou nahrané. Nahrajte soubor KML.

+
+
{$error}
+ +
+ + +
+
+
-

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

+

Modrý je obvod {$obvod->CISLO}.{if $sborJson !== 'null'} Hnědá čára je hranice sboru.{/if} Kolečko táhni, menší kroužek na hraně bod přidá. Vybraný bod odebere tlačítko.

{$error}

@@ -14,5 +14,6 @@ Zrušit
+ {/block} diff --git a/www/css/base.css b/www/css/base.css index 34b938e..403ccf5 100644 --- a/www/css/base.css +++ b/www/css/base.css @@ -765,6 +765,21 @@ body:has(#map[data-max]) { margin-top: 0.4rem; } +.sbor-formular .form-label { + color: #1d4f91; + font-weight: 700; +} + +.sbor-formular input[type="file"] { + max-width: 32rem; + padding: 0.45rem 0.6rem; + border: 2px solid #1d4f91; + border-radius: 0.8rem; + background: #fff; + color: #1d4f91; + font-weight: 700; +} + .podklady { border: 0; margin: 0 0 0.85rem; diff --git a/www/js/hranice.js b/www/js/hranice.js index be5ae6c..82a544b 100644 --- a/www/js/hranice.js +++ b/www/js/hranice.js @@ -216,6 +216,25 @@ }, }).addTo(map); + var sborEl = document.getElementById('sbor-data'); + if (sborEl && sborEl.textContent && sborEl.textContent.trim() !== 'null') { + try { + var sbor = L.geoJSON(JSON.parse(sborEl.textContent), { + interactive: false, + style: { + color: '#8a3b12', + weight: 4, + opacity: 1, + fillColor: '#f4d7b0', + fillOpacity: 0.2, + }, + }).addTo(map); + sbor.bringToBack(); + } catch (e) { + // hranice sboru zůstane nezobrazená + } + } + var vlastni = null; (data.features || []).some(function (feature) { if (feature.properties && String(feature.properties.number) === String(obvodId)) { diff --git a/www/js/stari.js b/www/js/stari.js index dadbd0b..6da495e 100644 --- a/www/js/stari.js +++ b/www/js/stari.js @@ -12,10 +12,20 @@ var obvodId = mapaEl.getAttribute('data-obvod'); var prihlaseny = mapaEl.getAttribute('data-prihlaseny') === '1'; var rezim = mapaEl.getAttribute('data-filtr'); - if (!prihlaseny || (rezim !== 'stari' && rezim !== 'nedokoncene')) { + if (!prihlaseny || (rezim !== 'stari' && rezim !== 'nedokoncene' && rezim !== 'sbor')) { rezim = 'obvody'; } + var sborData = null; + var sborEl = document.getElementById('sbor-data'); + if (sborEl && sborEl.textContent && sborEl.textContent.trim() !== 'null') { + try { + sborData = JSON.parse(sborEl.textContent); + } catch (e) { + sborData = null; + } + } + var ZELENA = [27, 158, 62]; var ZLUTA = [230, 184, 0]; var CERVENA = [198, 40, 40]; @@ -67,7 +77,7 @@ function style(feature) { var info = infoObvodu(feature); - if (rezim === 'obvody') { + if (rezim === 'obvody' || rezim === 'sbor') { return { fillColor: MODRA_PLOCHA, color: '#1d4f91', @@ -146,7 +156,8 @@ div.innerHTML = '' + '' + - ''; + '' + + ''; L.DomEvent.disableClickPropagation(div); filtrTlacitka = div.querySelectorAll('button'); filtrTlacitka.forEach(function (tlacitko) { @@ -186,7 +197,7 @@ } function htmlZnacky(cislo, info) { - if (rezim === 'obvody') { + if (rezim === 'obvody' || rezim === 'sbor') { return '
' + cislo + '
'; } if (rezim === 'nedokoncene') { @@ -221,7 +232,7 @@ disableClusteringAtZoom: 17, iconCreateFunction: function (cluster) { var pocet = cluster.getChildCount(); - if (rezim === 'obvody') { + if (rezim === 'obvody' || rezim === 'sbor') { return L.divIcon({ className: 'obvod-skupina', html: '
' + pocet + 'x
', @@ -531,6 +542,27 @@ var geojson = null; var geoData = null; + var sborVrstva = null; + + function nakresliSbor() { + if (sborVrstva) { + map.removeLayer(sborVrstva); + sborVrstva = null; + } + if (rezim !== 'sbor' || !sborData) { + return; + } + sborVrstva = L.geoJSON(sborData, { + interactive: false, + style: { + color: '#8a3b12', + weight: 4, + opacity: 1, + fillColor: '#f4d7b0', + fillOpacity: 0.2, + }, + }).addTo(map); + } function filtrObvodu(feature) { if (!obvodId) { @@ -556,8 +588,8 @@ tlacitko.classList.toggle('is-active', tlacitko.getAttribute('data-rezim') === rezim); }); if (legendaEl) { - legendaEl.style.display = rezim === 'obvody' ? 'none' : ''; - if (rezim !== 'obvody') { + legendaEl.style.display = rezim === 'obvody' || rezim === 'sbor' ? 'none' : ''; + if (rezim !== 'obvody' && rezim !== 'sbor') { legendaEl.innerHTML = obsahLegendy(); } } @@ -571,20 +603,26 @@ map.removeLayer(znacky); } znacky = vytvorSkupinu(); + nakresliSbor(); geojson = L.geoJSON(geoData, { style: style, filter: filtrObvodu, onEachFeature: onEachFeature, }).addTo(map); + if (sborVrstva) { + sborVrstva.bringToBack(); + } znacky.addTo(map); oznacFiltr(); - if (poprve && geojson.getLayers().length) { + if (rezim === 'sbor' && sborVrstva && sborVrstva.getBounds().isValid() && !obvodId) { + map.fitBounds(sborVrstva.getBounds(), { padding: [12, 12], animate: !poprve }); + } else if (poprve && geojson.getLayers().length) { map.fitBounds(geojson.getBounds(), { padding: [12, 12], animate: false }); } } function nastavRezim(novy) { - if (!prihlaseny || (novy !== 'stari' && novy !== 'nedokoncene')) { + if (!prihlaseny || (novy !== 'stari' && novy !== 'nedokoncene' && novy !== 'sbor')) { novy = 'obvody'; } if (novy === rezim) {