Hranice sboru

master
Kowalski 2026-10-12 18:27:06 +02:00
parent 6d22e07ccf
commit 5185e5efb0
11 changed files with 361 additions and 12 deletions

View File

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

208
app/Model/SborFacade.php Normal file
View File

@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Nette\Database\Explorer;
use Nette\Utils\Json;
use Nette\Utils\JsonException;
use SimpleXMLElement;
/**
* Hranice sboru. V tabulce je jeden záznam, nový KML ho nahradí.
*/
final class SborFacade
{
public function __construct(
private Explorer $database,
) {
}
/**
* @return array{nazev: string, bodu: int}|null
*/
public function getSbor(): ?array
{
$radek = $this->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<int, mixed>}
*/
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, '<kml')) {
throw new \InvalidArgumentException('Soubor není ve formátu KML.');
}
$predchozi = libxml_use_internal_errors(true);
try {
$xml = simplexml_load_string($kml, SimpleXMLElement::class, LIBXML_NONET);
} finally {
libxml_clear_errors();
libxml_use_internal_errors($predchozi);
}
if (!$xml instanceof SimpleXMLElement) {
throw new \InvalidArgumentException('Soubor KML se nepodařilo přečíst.');
}
$xml->registerXPathNamespace('k', 'http://www.opengis.net/kml/2.2');
return $xml;
}
/**
* @return list<array{0: float, 1: float}>
*/
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<string, mixed> $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;
}
}

View File

@ -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

View File

@ -51,6 +51,20 @@
</table>
<p class="prazdno" n:if="count($uzivatele) === 0">V tomto filtru není žádný uživatel.</p>
</section>
<section class="panel">
<h2>Hranice sboru</h2>
<p class="lista-popis" n:if="$sbor">Uloženo: {$sbor['nazev']}. Hranice má {$sbor['bodu']} bodů. Nový soubor KML tyhle body nahradí.</p>
<p class="lista-popis" n:if="!$sbor">Hranice sboru zatím nejsou nahrané. Nahrajte soubor KML.</p>
<form n:name="sborForm" class="sbor-formular">
<div class="alert alert-danger" n:if="$form->hasErrors()" n:foreach="$form->getErrors() as $error">{$error}</div>
<label n:name="soubor" class="form-label">Soubor KML</label>
<div class="formular-akce">
<input n:name="soubor">
<button n:name="send" class="tlacitko is-aktivni">{if $sbor}Nahradit{else}Nahrát{/if}</button>
</div>
</form>
</section>
</div>
<div class="modal fade" id="modalNovy" tabindex="-1" aria-labelledby="modalNovyLabel" aria-hidden="true" data-show-errors="{if $presenter['novyForm']->hasErrors()}1{else}0{/if}">

View File

@ -6,6 +6,7 @@ namespace App\Presentation\Mapa;
use App\Core\Konstanty;
use App\Model\ObvodFacade;
use App\Model\SborFacade;
use App\Model\ZaznamFacade;
use Nette;
use Nette\Application\Responses\TextResponse;
@ -23,6 +24,7 @@ final class MapaPresenter extends Nette\Application\UI\Presenter
public function __construct(
private ZaznamFacade $zaznam,
private ObvodFacade $obvod,
private SborFacade $sbor,
) {
}
@ -50,7 +52,7 @@ final class MapaPresenter extends Nette\Application\UI\Presenter
public function renderDefault(?int $obvod = null, ?string $filtr = null): void
{
$prihlaseny = $this->getUser()->isLoggedIn();
$povolene = ['obvody', 'stari', 'nedokoncene'];
$povolene = ['obvody', 'sbor', 'stari', 'nedokoncene'];
if (!$prihlaseny || !in_array($filtr, $povolene, true)) {
$filtr = 'obvody';
}
@ -70,5 +72,6 @@ final class MapaPresenter extends Nette\Application\UI\Presenter
$this->template->maxMesicu = self::STUPNICE_MESICU;
$this->template->maxPrideleni = self::STUPNICE_PRIDELENI;
$this->template->stariJson = Json::encode($stari, htmlSafe: true, forceObjects: true);
$this->template->sborJson = $this->sbor->getGeoJson() ?? 'null';
}
}

View File

@ -11,5 +11,6 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script type="application/json" id="stari-data">{$stariJson|noescape}</script>
<script type="application/json" id="sbor-data">{$sborJson|noescape}</script>
<script src="{$basePath}/js/stari.js?v={$appVersion ?? '1.0'}"></script>
{/block}

View File

@ -6,6 +6,7 @@ namespace App\Presentation\Obvod;
use App\Model\LoginFacade;
use App\Model\ObvodFacade;
use App\Model\SborFacade;
use App\Model\ZaznamFacade;
use App\Presentation\BasePresenter;
use App\Presentation\Mapa\MapaPresenter;
@ -37,6 +38,7 @@ final class ObvodPresenter extends BasePresenter
private ObvodFacade $obvod,
private ZaznamFacade $zaznam,
private LoginFacade $uzivatele,
private SborFacade $sbor,
) {
}
@ -118,6 +120,7 @@ final class ObvodPresenter extends BasePresenter
public function renderHranice(int $id): void
{
$this->template->obvod = $this->obvod->getObvod($id);
$this->template->sborJson = $this->sbor->getGeoJson() ?? 'null';
}
protected function createComponentHraniceForm(): Form

View File

@ -5,7 +5,7 @@
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-napoveda">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.</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">
@ -14,5 +14,6 @@
<a n:href="view id: $obvod->ID" class="tlacitko tlacitko-zrusit">Zrušit</a>
</form>
</div>
<script type="application/json" id="sbor-data">{$sborJson|noescape}</script>
<script src="{$basePath}/js/hranice.js?v={$appVersion ?? '1.0'}"></script>
{/block}

View File

@ -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;

View File

@ -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)) {

View File

@ -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 =
'<button type="button" data-rezim="obvody">Obvody</button>' +
'<button type="button" data-rezim="stari">Nepropracované</button>' +
'<button type="button" data-rezim="nedokoncene">Rozpracované</button>';
'<button type="button" data-rezim="nedokoncene">Rozpracované</button>' +
'<button type="button" data-rezim="sbor">Sbor</button>';
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 '<div class="obvod-znacka-telo"><div class="obvod-kolecko" style="background:' + MODRA + ';border-color:#1d4f91;color:#fff">' + cislo + '</div></div>';
}
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: '<div class="obvod-skupina-telo"><div class="obvod-kolecko" style="background:#1b9e3e;border-color:#146b2b;color:#fff">' + pocet + 'x</div></div>',
@ -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) {