GisVisualization.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. <?php
  2. /**
  3. * Handles visualization of GIS data
  4. */
  5. declare(strict_types=1);
  6. namespace PhpMyAdmin\Gis;
  7. use PhpMyAdmin\Core;
  8. use PhpMyAdmin\Image\ImageWrapper;
  9. use PhpMyAdmin\Sanitize;
  10. use PhpMyAdmin\Util;
  11. use TCPDF;
  12. use function array_merge;
  13. use function base64_encode;
  14. use function count;
  15. use function htmlspecialchars;
  16. use function intval;
  17. use function is_finite;
  18. use function is_numeric;
  19. use function is_string;
  20. use function mb_strlen;
  21. use function mb_strpos;
  22. use function mb_strtolower;
  23. use function mb_substr;
  24. use function ob_get_clean;
  25. use function ob_start;
  26. use function rtrim;
  27. use const PNG_ALL_FILTERS;
  28. /**
  29. * Handles visualization of GIS data
  30. */
  31. class GisVisualization
  32. {
  33. /** @var array Raw data for the visualization */
  34. private $data;
  35. /** @var string */
  36. private $modifiedSql;
  37. /** @var array Set of default settings values are here. */
  38. private $settings = [
  39. // Array of colors to be used for GIS visualizations.
  40. 'colors' => [
  41. '#B02EE0',
  42. '#E0642E',
  43. '#E0D62E',
  44. '#2E97E0',
  45. '#BCE02E',
  46. '#E02E75',
  47. '#5CE02E',
  48. '#E0B02E',
  49. '#0022E0',
  50. '#726CB1',
  51. '#481A36',
  52. '#BAC658',
  53. '#127224',
  54. '#825119',
  55. '#238C74',
  56. '#4C489B',
  57. '#87C9BF',
  58. ],
  59. // Hex values for abovementioned colours
  60. 'colors_hex' => [
  61. [176, 46, 224],
  62. [224, 100, 46],
  63. [224, 214, 46],
  64. [46, 151, 224],
  65. [188, 224, 46],
  66. [224, 46, 117],
  67. [92, 224, 46],
  68. [224, 176, 46],
  69. [0, 34, 224],
  70. [114, 108, 177],
  71. [72, 26, 54],
  72. [186, 198, 88],
  73. [18, 114, 36],
  74. [130, 81, 25],
  75. [35, 140, 116],
  76. [76, 72, 155],
  77. [135, 201, 191],
  78. ],
  79. // The width of the GIS visualization.
  80. 'width' => 600,
  81. // The height of the GIS visualization.
  82. 'height' => 450,
  83. ];
  84. /** @var array Options that the user has specified. */
  85. private $userSpecifiedSettings = null;
  86. /**
  87. * Returns the settings array
  88. *
  89. * @return array the settings array
  90. */
  91. public function getSettings()
  92. {
  93. return $this->settings;
  94. }
  95. /**
  96. * Factory
  97. *
  98. * @param string $sql_query SQL to fetch raw data for visualization
  99. * @param array $options Users specified options
  100. * @param int $row number of rows
  101. * @param int $pos start position
  102. *
  103. * @return GisVisualization
  104. */
  105. public static function get($sql_query, array $options, $row, $pos)
  106. {
  107. return new GisVisualization($sql_query, $options, $row, $pos);
  108. }
  109. /**
  110. * Get visualization
  111. *
  112. * @param array $data Raw data, if set, parameters other than $options will be
  113. * ignored
  114. * @param array $options Users specified options
  115. *
  116. * @return GisVisualization
  117. */
  118. public static function getByData(array $data, array $options)
  119. {
  120. return new GisVisualization(null, $options, null, null, $data);
  121. }
  122. /**
  123. * Check if data has SRID
  124. */
  125. public function hasSrid(): bool
  126. {
  127. foreach ($this->data as $row) {
  128. if ($row['srid'] != 0) {
  129. return true;
  130. }
  131. }
  132. return false;
  133. }
  134. /**
  135. * Stores user specified options.
  136. *
  137. * @param string $sql_query SQL to fetch raw data for visualization
  138. * @param array $options Users specified options
  139. * @param int $row number of rows
  140. * @param int $pos start position
  141. * @param array|null $data raw data. If set, parameters other than $options
  142. * will be ignored
  143. */
  144. private function __construct($sql_query, array $options, $row, $pos, $data = null)
  145. {
  146. $this->userSpecifiedSettings = $options;
  147. if (isset($data)) {
  148. $this->data = $data;
  149. } else {
  150. $this->modifiedSql = $this->modifySqlQuery($sql_query, $row, $pos);
  151. $this->data = $this->fetchRawData();
  152. }
  153. }
  154. /**
  155. * All the variable initialization, options handling has to be done here.
  156. */
  157. protected function init(): void
  158. {
  159. $this->handleOptions();
  160. }
  161. /**
  162. * Returns sql for fetching raw data
  163. *
  164. * @param string $sql_query The SQL to modify.
  165. * @param int $rows Number of rows.
  166. * @param int $pos Start position.
  167. *
  168. * @return string the modified sql query.
  169. */
  170. private function modifySqlQuery($sql_query, $rows, $pos)
  171. {
  172. $isMariaDb = $this->userSpecifiedSettings['isMariaDB'] === true;
  173. $modified_query = 'SELECT ';
  174. $spatialAsText = 'ASTEXT';
  175. $spatialSrid = 'SRID';
  176. $axisOrder = '';
  177. if ($this->userSpecifiedSettings['mysqlVersion'] >= 50600) {
  178. $spatialAsText = 'ST_ASTEXT';
  179. $spatialSrid = 'ST_SRID';
  180. }
  181. // If MYSQL version >= 8.0.1 override default axis order
  182. if ($this->userSpecifiedSettings['mysqlVersion'] >= 80001 && ! $isMariaDb) {
  183. $axisOrder = ', \'axis-order=long-lat\'';
  184. }
  185. // If label column is chosen add it to the query
  186. if (! empty($this->userSpecifiedSettings['labelColumn'])) {
  187. $modified_query .= Util::backquote($this->userSpecifiedSettings['labelColumn'])
  188. . ', ';
  189. }
  190. // Wrap the spatial column with 'ST_ASTEXT()' function and add it
  191. $modified_query .= $spatialAsText . '('
  192. . Util::backquote($this->userSpecifiedSettings['spatialColumn'])
  193. . $axisOrder . ') AS ' . Util::backquote($this->userSpecifiedSettings['spatialColumn'])
  194. . ', ';
  195. // Get the SRID
  196. $modified_query .= $spatialSrid . '('
  197. . Util::backquote($this->userSpecifiedSettings['spatialColumn'])
  198. . ') AS ' . Util::backquote('srid') . ' ';
  199. // Append the original query as the inner query
  200. $modified_query .= 'FROM (' . rtrim($sql_query, ';') . ') AS '
  201. . Util::backquote('temp_gis');
  202. // LIMIT clause
  203. if (is_numeric($rows) && $rows > 0) {
  204. $modified_query .= ' LIMIT ';
  205. if (is_numeric($pos) && $pos >= 0) {
  206. $modified_query .= $pos . ', ' . $rows;
  207. } else {
  208. $modified_query .= $rows;
  209. }
  210. }
  211. return $modified_query;
  212. }
  213. /**
  214. * Returns raw data for GIS visualization.
  215. *
  216. * @return array the raw data.
  217. */
  218. private function fetchRawData(): array
  219. {
  220. global $dbi;
  221. $modified_result = $dbi->tryQuery($this->modifiedSql);
  222. if ($modified_result === false) {
  223. return [];
  224. }
  225. return $modified_result->fetchAllAssoc();
  226. }
  227. /**
  228. * A function which handles passed parameters. Useful if desired
  229. * chart needs to be a little bit different from the default one.
  230. */
  231. private function handleOptions(): void
  232. {
  233. if ($this->userSpecifiedSettings === null) {
  234. return;
  235. }
  236. $this->settings = array_merge($this->settings, $this->userSpecifiedSettings);
  237. }
  238. /**
  239. * Sanitizes the file name.
  240. *
  241. * @param string $file_name file name
  242. * @param string $ext extension of the file
  243. *
  244. * @return string the sanitized file name
  245. */
  246. private function sanitizeName($file_name, $ext)
  247. {
  248. $file_name = Sanitize::sanitizeFilename($file_name);
  249. // Check if the user already added extension;
  250. // get the substring where the extension would be if it was included
  251. $required_extension = '.' . $ext;
  252. $extension_length = mb_strlen($required_extension);
  253. $user_extension = mb_substr($file_name, -$extension_length);
  254. if (mb_strtolower($user_extension) != $required_extension) {
  255. $file_name .= $required_extension;
  256. }
  257. return $file_name;
  258. }
  259. /**
  260. * Handles common tasks of writing the visualization to file for various formats.
  261. *
  262. * @param string $file_name file name
  263. * @param string $type mime type
  264. * @param string $ext extension of the file
  265. */
  266. private function writeToFile($file_name, $type, $ext): void
  267. {
  268. $file_name = $this->sanitizeName($file_name, $ext);
  269. Core::downloadHeader($file_name, $type);
  270. }
  271. /**
  272. * Generate the visualization in SVG format.
  273. *
  274. * @return string the generated image resource
  275. */
  276. private function svg()
  277. {
  278. $this->init();
  279. $output = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
  280. . "\n"
  281. . '<svg version="1.1" xmlns:svg="http://www.w3.org/2000/svg"'
  282. . ' xmlns="http://www.w3.org/2000/svg"'
  283. . ' width="' . intval($this->settings['width']) . '"'
  284. . ' height="' . intval($this->settings['height']) . '">'
  285. . '<g id="groupPanel">';
  286. $scale_data = $this->scaleDataSet($this->data);
  287. $output .= $this->prepareDataSet($this->data, $scale_data, 'svg', '');
  288. $output .= '</g></svg>';
  289. return $output;
  290. }
  291. /**
  292. * Get the visualization as a SVG.
  293. *
  294. * @return string the visualization as a SVG
  295. */
  296. public function asSVG()
  297. {
  298. return $this->svg();
  299. }
  300. /**
  301. * Saves as a SVG image to a file.
  302. *
  303. * @param string $file_name File name
  304. */
  305. public function toFileAsSvg($file_name): void
  306. {
  307. $img = $this->svg();
  308. $this->writeToFile($file_name, 'image/svg+xml', 'svg');
  309. echo $img;
  310. }
  311. /**
  312. * Generate the visualization in PNG format.
  313. *
  314. * @return ImageWrapper|null the generated image resource
  315. */
  316. private function png(): ?ImageWrapper
  317. {
  318. $this->init();
  319. $image = ImageWrapper::create(
  320. $this->settings['width'],
  321. $this->settings['height'],
  322. ['red' => 229, 'green' => 229, 'blue' => 229]
  323. );
  324. if ($image === null) {
  325. return null;
  326. }
  327. $scale_data = $this->scaleDataSet($this->data);
  328. /** @var ImageWrapper $image */
  329. $image = $this->prepareDataSet($this->data, $scale_data, 'png', $image);
  330. return $image;
  331. }
  332. /**
  333. * Get the visualization as a PNG.
  334. *
  335. * @return string the visualization as a PNG
  336. */
  337. public function asPng()
  338. {
  339. $image = $this->png();
  340. if ($image === null) {
  341. return '';
  342. }
  343. // render and save it to variable
  344. ob_start();
  345. $image->png(null, 9, PNG_ALL_FILTERS);
  346. $image->destroy();
  347. $output = ob_get_clean();
  348. // base64 encode
  349. $encoded = base64_encode((string) $output);
  350. return '<img src="data:image/png;base64,' . $encoded . '">';
  351. }
  352. /**
  353. * Saves as a PNG image to a file.
  354. *
  355. * @param string $file_name File name
  356. */
  357. public function toFileAsPng($file_name): void
  358. {
  359. $image = $this->png();
  360. if ($image === null) {
  361. return;
  362. }
  363. $this->writeToFile($file_name, 'image/png', 'png');
  364. $image->png(null, 9, PNG_ALL_FILTERS);
  365. $image->destroy();
  366. }
  367. /**
  368. * Get the code for visualization with OpenLayers.
  369. *
  370. * @return string the code for visualization with OpenLayers
  371. *
  372. * @todo Should return JSON to avoid eval() in gis_data_editor.js
  373. */
  374. public function asOl()
  375. {
  376. $this->init();
  377. $scale_data = $this->scaleDataSet($this->data);
  378. $output = 'function drawOpenLayers() {'
  379. . 'if (typeof ol !== "undefined") {'
  380. . 'var olCss = "js/vendor/openlayers/theme/ol.css";'
  381. . '$(\'head\').append(\'<link rel="stylesheet" type="text/css" href=\'+olCss+\'>\');'
  382. . 'var vectorLayer = new ol.source.Vector({});'
  383. . 'var map = new ol.Map({'
  384. . 'target: \'openlayersmap\','
  385. . 'layers: ['
  386. . 'new ol.layer.Tile({'
  387. . 'source: new ol.source.OSM()'
  388. . '}),'
  389. . 'new ol.layer.Vector({'
  390. . 'source: vectorLayer'
  391. . '})'
  392. . '],'
  393. . 'view: new ol.View({'
  394. . 'center: ol.proj.fromLonLat([37.41, 8.82]),'
  395. . 'zoom: 4'
  396. . '}),'
  397. . 'controls: [new ol.control.MousePosition({'
  398. . 'coordinateFormat: ol.coordinate.createStringXY(4),'
  399. . 'projection: \'EPSG:4326\'}),'
  400. . 'new ol.control.Zoom,'
  401. . 'new ol.control.Attribution]'
  402. . '});';
  403. $output .= $this->prepareDataSet($this->data, $scale_data, 'ol', '')
  404. . 'return map;'
  405. . '}'
  406. . 'return undefined;'
  407. . '}';
  408. return $output;
  409. }
  410. /**
  411. * Saves as a PDF to a file.
  412. *
  413. * @param string $file_name File name
  414. */
  415. public function toFileAsPdf($file_name): void
  416. {
  417. $this->init();
  418. // create pdf
  419. $pdf = new TCPDF('', 'pt', $GLOBALS['cfg']['PDFDefaultPageSize'], true, 'UTF-8', false);
  420. // disable header and footer
  421. $pdf->setPrintHeader(false);
  422. $pdf->setPrintFooter(false);
  423. //set auto page breaks
  424. $pdf->setAutoPageBreak(false);
  425. // add a page
  426. $pdf->AddPage();
  427. $scale_data = $this->scaleDataSet($this->data);
  428. $pdf = $this->prepareDataSet($this->data, $scale_data, 'pdf', $pdf);
  429. // sanitize file name
  430. $file_name = $this->sanitizeName($file_name, 'pdf');
  431. $pdf->Output($file_name, 'D');
  432. }
  433. /**
  434. * Convert file to image
  435. *
  436. * @param string $format Output format
  437. *
  438. * @return string File
  439. */
  440. public function toImage($format)
  441. {
  442. if ($format === 'svg') {
  443. return $this->asSVG();
  444. }
  445. if ($format === 'png') {
  446. return $this->asPng();
  447. }
  448. if ($format === 'ol') {
  449. return $this->asOl();
  450. }
  451. return '';
  452. }
  453. /**
  454. * Convert file to given format
  455. *
  456. * @param string $filename Filename
  457. * @param string $format Output format
  458. */
  459. public function toFile($filename, $format): void
  460. {
  461. if ($format === 'svg') {
  462. $this->toFileAsSvg($filename);
  463. } elseif ($format === 'png') {
  464. $this->toFileAsPng($filename);
  465. } elseif ($format === 'pdf') {
  466. $this->toFileAsPdf($filename);
  467. }
  468. }
  469. /**
  470. * Calculates the scale, horizontal and vertical offset that should be used.
  471. *
  472. * @param array $data Row data
  473. *
  474. * @return array an array containing the scale, x and y offsets
  475. */
  476. private function scaleDataSet(array $data)
  477. {
  478. $min_max = GisGeometry::EMPTY_EXTENT;
  479. $border = 15;
  480. // effective width and height of the plot
  481. $plot_width = $this->settings['width'] - 2 * $border;
  482. $plot_height = $this->settings['height'] - 2 * $border;
  483. foreach ($data as $row) {
  484. // Figure out the data type
  485. $ref_data = $row[$this->settings['spatialColumn']];
  486. if (! is_string($ref_data)) {
  487. continue;
  488. }
  489. $type_pos = mb_strpos($ref_data, '(');
  490. if ($type_pos === false) {
  491. continue;
  492. }
  493. $type = mb_substr($ref_data, 0, $type_pos);
  494. $gis_obj = GisFactory::factory($type);
  495. if (! $gis_obj) {
  496. continue;
  497. }
  498. $scale_data = $gis_obj->scaleRow($row[$this->settings['spatialColumn']]);
  499. // Update minimum/maximum values for x and y coordinates.
  500. $c_maxX = (float) $scale_data['maxX'];
  501. if ($c_maxX > $min_max['maxX']) {
  502. $min_max['maxX'] = $c_maxX;
  503. }
  504. $c_minX = (float) $scale_data['minX'];
  505. if ($c_minX < $min_max['minX']) {
  506. $min_max['minX'] = $c_minX;
  507. }
  508. $c_maxY = (float) $scale_data['maxY'];
  509. if ($c_maxY > $min_max['maxY']) {
  510. $min_max['maxY'] = $c_maxY;
  511. }
  512. $c_minY = (float) $scale_data['minY'];
  513. if ($c_minY >= $min_max['minY']) {
  514. continue;
  515. }
  516. $min_max['minY'] = $c_minY;
  517. }
  518. if (! is_finite($min_max['minX']) || ! is_finite($min_max['minY'])) {
  519. $min_max['maxX'] = 0.0;
  520. $min_max['maxY'] = 0.0;
  521. $min_max['minX'] = 0.0;
  522. $min_max['minY'] = 0.0;
  523. }
  524. // scale the visualization
  525. $x_ratio = ($min_max['maxX'] - $min_max['minX']) / $plot_width;
  526. $y_ratio = ($min_max['maxY'] - $min_max['minY']) / $plot_height;
  527. $ratio = $x_ratio > $y_ratio ? $x_ratio : $y_ratio;
  528. $scale = $ratio != 0 ? 1 / $ratio : 1;
  529. // Center plot
  530. $x = $ratio == 0 || $x_ratio < $y_ratio
  531. ? ($min_max['maxX'] + $min_max['minX'] - $this->settings['width'] / $scale) / 2
  532. : $min_max['minX'] - ($border / $scale);
  533. $y = $ratio == 0 || $x_ratio >= $y_ratio
  534. ? ($min_max['maxY'] + $min_max['minY'] - $this->settings['height'] / $scale) / 2
  535. : $min_max['minY'] - ($border / $scale);
  536. return [
  537. 'scale' => $scale,
  538. 'x' => $x,
  539. 'y' => $y,
  540. 'minX' => $min_max['minX'],
  541. 'maxX' => $min_max['maxX'],
  542. 'minY' => $min_max['minY'],
  543. 'maxY' => $min_max['maxY'],
  544. 'height' => $this->settings['height'],
  545. ];
  546. }
  547. /**
  548. * Prepares and return the dataset as needed by the visualization.
  549. *
  550. * @param array $data Raw data
  551. * @param array $scale_data Data related to scaling
  552. * @param string $format Format of the visualization
  553. * @param ImageWrapper|TCPDF|string|false $results Image object in the case of png
  554. * TCPDF object in the case of pdf
  555. *
  556. * @return mixed the formatted array of data
  557. */
  558. private function prepareDataSet(array $data, array $scale_data, $format, $results)
  559. {
  560. $color_number = 0;
  561. // loop through the rows
  562. foreach ($data as $row) {
  563. $index = $color_number % count($this->settings['colors']);
  564. // Figure out the data type
  565. $ref_data = $row[$this->settings['spatialColumn']];
  566. if (! is_string($ref_data)) {
  567. continue;
  568. }
  569. $type_pos = mb_strpos($ref_data, '(');
  570. if ($type_pos === false) {
  571. continue;
  572. }
  573. $type = mb_substr($ref_data, 0, $type_pos);
  574. $gis_obj = GisFactory::factory($type);
  575. if (! $gis_obj) {
  576. continue;
  577. }
  578. $label = '';
  579. if (isset($this->settings['labelColumn'], $row[$this->settings['labelColumn']])) {
  580. $label = $row[$this->settings['labelColumn']];
  581. }
  582. if ($format === 'svg') {
  583. $results .= $gis_obj->prepareRowAsSvg(
  584. $row[$this->settings['spatialColumn']],
  585. htmlspecialchars($label),
  586. $this->settings['colors'][$index],
  587. $scale_data
  588. );
  589. } elseif ($format === 'png') {
  590. $results = $gis_obj->prepareRowAsPng(
  591. $row[$this->settings['spatialColumn']],
  592. $label,
  593. $this->settings['colors'][$index],
  594. $scale_data,
  595. $results
  596. );
  597. } elseif ($format === 'pdf' && $results instanceof TCPDF) {
  598. $results = $gis_obj->prepareRowAsPdf(
  599. $row[$this->settings['spatialColumn']],
  600. $label,
  601. $this->settings['colors'][$index],
  602. $scale_data,
  603. $results
  604. );
  605. } elseif ($format === 'ol') {
  606. $results .= $gis_obj->prepareRowAsOl(
  607. $row[$this->settings['spatialColumn']],
  608. (int) $row['srid'],
  609. $label,
  610. $this->settings['colors_hex'][$index],
  611. $scale_data
  612. );
  613. }
  614. $color_number++;
  615. }
  616. return $results;
  617. }
  618. /**
  619. * Set user specified settings
  620. *
  621. * @param array $userSpecifiedSettings User specified settings
  622. */
  623. public function setUserSpecifiedSettings(array $userSpecifiedSettings): void
  624. {
  625. $this->userSpecifiedSettings = $userSpecifiedSettings;
  626. }
  627. }