src/Controller/GisserverController.php line 123
<?php//src/Controller/GisserverController.phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Doctrine\DBAL\DriverManager;use Doctrine\DBAL\Driver\PDO\PgSQL;use Psr\Log\LoggerInterface;use PDO;class GisserverController extends AbstractController{private const TIMEOUT = 240;private LoggerInterface $logger;public function __construct(LoggerInterface $logger){$this->logger = $logger;}/*** Obtener conexión DBAL*/private function getConnection(){$databaseUrl = $_ENV['DATABASE_URL_GIS'];$connectionParams = ['url' => $databaseUrl,'driverOptions' => [PDO::ATTR_TIMEOUT => intval(self::TIMEOUT)]];return DriverManager::getConnection($connectionParams);}public function geoJson(Request $request){$layerTable = $request->get('typeName');$SrsName = $request->get('SrsName', 'EPSG:4326');$fields = $request->get('PropertyName');$srsCode = $this->extractSRID($SrsName);$fieldsKey = empty($fields) ? 'all' : md5($fields);$cacheDir = $this->getParameter('kernel.project_dir') . "/var/cache/geojson";$cacheFile = "{$cacheDir}/{$layerTable}_{$srsCode}_{$fieldsKey}.json";if (file_exists($cacheFile)){$response = new Response(file_get_contents($cacheFile));$response->headers->set('Content-Type', 'application/json');$response->headers->set('Access-Control-Allow-Origin', '*');$response->headers->set('Cache-Control', 'public, max-age=3600');$response->headers->set('X-Cache', 'HIT');return $response;}if ($fields == null || $fields == '') {$fields = '*';}else{$fields = 'id,geom,'.$fields;}try{$this->connection = $this->getConnection();$sql = "SELECT jsonb_build_object('type', 'FeatureCollection','features', jsonb_agg(features.feature)) as geojsonFROM (SELECT jsonb_build_object('type', 'Feature','id', id,'geometry', ST_AsGeoJSON(ST_Transform(geom, {$srsCode}))::jsonb,'properties', to_jsonb(row) - 'geom') AS featureFROM (SELECT {$fields}FROM \"{$layerTable}\") row) features";$result = $this->connection->executeQuery($sql);$data = $result->fetchAssociative();$geojson = $data['geojson'] ?? '{"type":"FeatureCollection","features":[]}';if (!is_dir($cacheDir)) {mkdir($cacheDir, 0755, true);}file_put_contents($cacheFile, $geojson);$response = new Response($geojson);$response->headers->set('Content-Type', 'application/json');$response->headers->set('Access-Control-Allow-Origin', '*');$response->headers->set('Cache-Control', 'public, max-age=3600');$response->headers->set('X-Cache', 'MISS');return $response;} catch (\Exception $e) {return $this->json(['error' => 'Error al obtener GeoJSON','message' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);}}#[Route('/gis/tiles/{tabla}/{z}/{x}/{y}.pbf', name: 'gis_tiles', methods: ['GET'])]public function tiles(string $tabla, int $z, int $x, int $y, Request $request): Response{$fields = $request->get('fields');$arrFields = explode(',',$fields);$comarca = $request->get('comarca'); // Obtener el parámetro comarcaforeach ($arrFields as $i => $f) {$arrFields[$i] = '"' . trim($f) . '"';}$fieldsQuoted = implode(', ', $arrFields);$tabla = urldecode($tabla);// Determinar qué materialized view usar según el zoom$materializedView = $this->getMaterializedViewName($tabla, $z);$this->logger->info("Preparando generación de tiles", ['tabla_solicitada' => $tabla,'zoom' => $z,'x' => $x,'y' => $y,'vista_candidata' => $materializedView,'comarca_filtro' => $comarca]);try{$conDbal = $this->getConnection();// Verificar si existe la materialized view$viewExists = $this->checkMaterializedViewExists($conDbal, $materializedView);if (!$viewExists) {$this->logger->warning("Materialized view {$materializedView} no existe, usando tabla original {$tabla}");$materializedView = $tabla;}$this->logger->info("Vista/Tabla que se usará para executeQuery", ['fuente_datos' => $materializedView,'es_vista_materializada' => $viewExists,'zoom' => $z]);// Construir la cláusula WHERE con el filtro de comarca si se proporciona$whereClause = "ST_Intersects(geom_3857, ST_TileEnvelope(:z, :x, :y))";if (!empty($comarca)) {$whereClause .= " AND comarca = :comarca";}$sql = "WITH mvtgeom AS (SELECTid,{$fieldsQuoted},ST_AsMVTGeom(geom_3857,ST_TileEnvelope(:z, :x, :y),4096,64,true) AS geomFROM \"{$materializedView}\"WHERE {$whereClause})SELECT ST_AsMVT(mvtgeom, :layer, 4096, 'geom') as mvtFROM mvtgeomWHERE geom IS NOT NULL";$this->logger->debug("SQL generado", ['sql' => str_replace([':z', ':x', ':y', ':layer', ':comarca'], [$z, $x, $y, $tabla, $comarca ?? NULL], $sql),'fuente' => $materializedView,'comarca' => $comarca ?? NULL]);$stmt = $conDbal->prepare($sql);$stmt->bindValue('layer', $tabla);$stmt->bindValue('z', $z);$stmt->bindValue('x', $x);$stmt->bindValue('y', $y);// Bindear el parámetro comarca si existeif (!empty($comarca)) {$stmt->bindValue('comarca', $comarca, \PDO::PARAM_STR);}$result = $stmt->executeQuery();$data = $result->fetchAssociative();$this->logger->info("Resultado MVT", ['fuente_usada' => $materializedView,'has_mvt' => isset($data['mvt']),'mvt_empty' => empty($data['mvt']),'mvt_type' => gettype($data['mvt'] ?? null),'comarca_aplicada' => $comarca ?? 'N/A']);if ($data && !empty($data['mvt'])){$mvtField = $data['mvt'];if (is_resource($mvtField)) {$mvt = stream_get_contents($mvtField);} elseif (is_string($mvtField) && str_starts_with($mvtField, '\\x')) {$mvt = hex2bin(substr($mvtField, 2));} else {$mvt = (string)$mvtField;}if ($mvt === '' || $mvt === null) {$this->logger->warning("MVT vacío", ['fuente' => $materializedView, 'comarca' => $comarca ?? 'N/A']);return new Response('', 204, ['Content-Type' => 'application/x-protobuf']);}$response = new Response($mvt);$response->headers->set('Content-Type', 'application/x-protobuf');$response->headers->set('Content-Length', strlen($mvt));$response->headers->set('Cache-Control', 'public, max-age=86400');$response->headers->set('Access-Control-Allow-Origin', '*');$response->headers->set('X-Cache', 'MISS');$response->headers->set('X-Source', $materializedView);$this->logger->info("Tile generado exitosamente", ['fuente' => $materializedView,'bytes' => strlen($mvt),'comarca' => $comarca ?? 'N/A']);return $response;}$this->logger->warning("No hay datos para el tile", ['fuente' => $materializedView,'zoom' => $z,'tile' => "{$x}/{$y}",'comarca' => $comarca ?? 'N/A']);return new Response('', 204, ['Content-Type' => 'application/x-protobuf']);} catch (\Exception $e) {$this->logger->error("Error en tiles", ['tabla' => $tabla,'fuente_intentada' => $materializedView ?? 'desconocida','error' => $e->getMessage(),'comarca' => $comarca ?? 'N/A']);return new Response('Error: ' . $e->getMessage(),Response::HTTP_INTERNAL_SERVER_ERROR);}}/*** Determina qué materialized view usar según el zoom level*/private function getMaterializedViewName(string $tabla, int $z): string{if ($z <= 8) {return "{$tabla}_mvt_z0_8"; // Tolerancia 200m} elseif ($z <= 10) {return "{$tabla}_mvt_z9_10"; // Tolerancia 75m} elseif ($z <= 12) {return "{$tabla}_mvt_z11_12"; // Tolerancia 30m} elseif ($z <= 14) {return "{$tabla}_mvt_z13_14"; // Tolerancia 10m} elseif ($z <= 16) {return "{$tabla}_mvt_z15_16"; // Tolerancia 3m} elseif ($z <= 18) {return "{$tabla}_mvt_z17_18"; // Tolerancia 1m} else {return $tabla; // Original sin simplificar}}/*** Verifica si existe una materialized view*/private function checkMaterializedViewExists($connection, string $viewName): bool{$sql = "SELECT EXISTS (SELECT 1FROM pg_matviewsWHERE matviewname = :viewName) as exists";try {$stmt = $connection->prepare($sql);$stmt->bindValue('viewName', $viewName);$result = $stmt->executeQuery();$data = $result->fetchAssociative();return (bool) $data['exists'];} catch (\Exception $e) {$this->logger->error("Error verificando materialized view: " . $e->getMessage());return false;}}private function extractSRID(string $srsName): int{$parts = explode(':', $srsName);return isset($parts[1]) ? intval($parts[1]) : 4326;}}