1<?php
2
3/*
4 * @author Báthory Péter
5 * @since 2016-02-27
6 *
7 * This code is open-source and licenced under the Modified BSD License.
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11namespace geoPHP\Adapter;
12
13use geoPHP\Geometry\Collection;
14use geoPHP\Geometry\Geometry;
15use geoPHP\Geometry\GeometryCollection;
16use geoPHP\Geometry\Point;
17use geoPHP\Geometry\MultiPoint;
18use geoPHP\Geometry\LineString;
19use geoPHP\Geometry\MultiLineString;
20use geoPHP\Geometry\Polygon;
21use geoPHP\Geometry\MultiPolygon;
22
23/**
24 * PHP Geometry <-> OpenStreetMap XML encoder/decoder
25 *
26 * This adapter is not ready yet. It lacks a relation writer, and the reader has problems with invalid multipolygons
27 * Since geoPHP doesn't support metadata, it cannot read and write OSM tags.
28 */
29class OSM implements GeoAdapter
30{
31    const OSM_COORDINATE_PRECISION = '%.7f';
32    const OSM_API_URL = 'http://openstreetmap.org/api/0.6/';
33
34    /** @var  \DOMDocument $xmlObj */
35    protected $xmlObj;
36
37    protected $nodes = [];
38
39    protected $ways = [];
40
41    protected $idCounter = 0;
42
43    /**
44     * Read OpenStreetMap XML string into geometry objects
45     *
46     * @param string $osm An OSM XML string
47     *
48     * @return Geometry|GeometryCollection
49     * @throws \Exception
50     */
51    public function read($osm)
52    {
53        // Load into DOMDocument
54        $xmlobj = new \DOMDocument();
55        $xmlobj->loadXML($osm);
56        if ($xmlobj === false) {
57            throw new \Exception("Invalid OSM XML: " . substr($osm, 0, 100));
58        }
59
60        $this->xmlObj = $xmlobj;
61        try {
62            $geom = $this->geomFromXML();
63        } catch (\Exception $e) {
64            throw new \Exception("Cannot read geometries from OSM XML: " . $e->getMessage());
65        }
66
67        return $geom;
68    }
69
70    protected function geomFromXML()
71    {
72        $geometries = [];
73
74        // Processing OSM Nodes
75        $nodes = [];
76        foreach ($this->xmlObj->getElementsByTagName('node') as $node) {
77            /** @var \DOMElement $node */
78            $lat = $node->attributes->getNamedItem('lat')->nodeValue;
79            $lon = $node->attributes->getNamedItem('lon')->nodeValue;
80            $id = intval($node->attributes->getNamedItem('id')->nodeValue);
81            $tags = [];
82            foreach ($node->getElementsByTagName('tag') as $tag) {
83                $key = $tag->attributes->getNamedItem('k')->nodeValue;
84                if ($key === 'source' || $key === 'fixme' || $key === 'created_by') {
85                    continue;
86                }
87                $tags[$key] = $tag->attributes->getNamedItem('v')->nodeValue;
88            }
89            $nodes[$id] = [
90                    'point' => new Point($lon, $lat),
91                    'assigned' => false,
92                    'tags' => $tags
93            ];
94        }
95        if (empty($nodes)) {
96            return new GeometryCollection();
97        }
98
99        // Processing OSM Ways
100        $ways = [];
101        foreach ($this->xmlObj->getElementsByTagName('way') as $way) {
102            /** @var \DOMElement $way */
103            $id = intval($way->attributes->getNamedItem('id')->nodeValue);
104            $wayNodes = [];
105            foreach ($way->getElementsByTagName('nd') as $node) {
106                $ref = intval($node->attributes->getNamedItem('ref')->nodeValue);
107                if (isset($nodes[$ref])) {
108                    $nodes[$ref]['assigned'] = true;
109                    $wayNodes[] = $ref;
110                }
111            }
112            $tags = [];
113            foreach ($way->getElementsByTagName('tag') as $tag) {
114                $key = $tag->attributes->getNamedItem('k')->nodeValue;
115                if ($key === 'source' || $key === 'fixme' || $key === 'created_by') {
116                    continue;
117                }
118                $tags[$key] = $tag->attributes->getNamedItem('v')->nodeValue;
119            }
120            if (count($wayNodes) >= 2) {
121                $ways[$id] = [
122                        'nodes' => $wayNodes,
123                        'assigned' => false,
124                        'tags' => $tags,
125                        'isRing' => ($wayNodes[0] === $wayNodes[count($wayNodes) - 1])
126                ];
127            }
128        }
129
130
131        // Processing OSM Relations
132        foreach ($this->xmlObj->getElementsByTagName('relation') as $relation) {
133            /** @var \DOMElement $relation */
134            /** @var Point[] */
135            $relationPoints = [];
136            /** @var LineString[] */
137            $relationLines = [];
138            /** @var Polygon[] */
139            $relationPolygons = [];
140
141            static $polygonalTypes = ['multipolygon', 'boundary'];
142            static $linearTypes = ['route', 'waterway'];
143            $relationType = null;
144            foreach ($relation->getElementsByTagName('tag') as $tag) {
145                if ($tag->attributes->getNamedItem('k')->nodeValue == 'type') {
146                    $relationType = $tag->attributes->getNamedItem('v')->nodeValue;
147                }
148            }
149
150            // Collect relation members
151            /** @var array[] $relationWays */
152            $relationWays = [];
153            foreach ($relation->getElementsByTagName('member') as $member) {
154                $memberType = $member->attributes->getNamedItem('type')->nodeValue;
155                $ref = $member->attributes->getNamedItem('ref')->nodeValue;
156
157                if ($memberType === 'node' &&  isset($nodes[$ref])) {
158                    $nodes[$ref]['assigned'] = true;
159                    $relationPoints[] = $nodes[$ref]['point'];
160                }
161                if ($memberType === 'way' &&  isset($ways[$ref])) {
162                    $ways[$ref]['assigned'] = true;
163                    $relationWays[$ref] = $ways[$ref]['nodes'];
164                }
165            }
166
167            if (in_array($relationType, $polygonalTypes)) {
168                $relationPolygons = $this->processMultipolygon($relationWays, $nodes);
169            }
170            if (in_array($relationType, $linearTypes)) {
171                $relationLines = $this->processRoutes($relationWays, $nodes);
172            }
173
174            // Assemble relation geometries
175            $geometryCollection = [];
176            if (!empty($relationPolygons)) {
177                $geometryCollection[] = count($relationPolygons) == 1 ? $relationPolygons[0] : new MultiPolygon($relationPolygons);
178            }
179            if (!empty($relationLines)) {
180                $geometryCollection[] = count($relationLines) == 1 ? $relationLines[0] : new MultiLineString($relationLines);
181            }
182            if (!empty($relationPoints)) {
183                $geometryCollection[] = count($relationPoints) == 1 ? $relationPoints[0] : new MultiPoint($relationPoints);
184            }
185
186            if (!empty($geometryCollection)) {
187                $geometries[] = count($geometryCollection) == 1 ? $geometryCollection[0] : new GeometryCollection($geometryCollection);
188            }
189        }
190
191        // Process ways
192        foreach ($ways as $way) {
193            if (
194                (!$way['assigned'] || !empty($way['tags']))
195                && !isset($way['tags']['boundary'])
196                && (!isset($way['tags']['natural'])  || $way['tags']['natural'] !== 'mountain_range')
197            ) {
198                $linePoints = [];
199                foreach ($way['nodes'] as $wayNode) {
200                    $linePoints[] = $nodes[$wayNode]['point'];
201                }
202                $line = new LineString($linePoints);
203                if ($way['isRing']) {
204                    $polygon = new Polygon([$line]);
205                    if ($polygon->isSimple()) {
206                        $geometries[] = $polygon;
207                    } else {
208                        $geometries[] = $line;
209                    }
210                } else {
211                    $geometries[] = $line;
212                }
213            }
214        }
215
216        foreach ($nodes as $node) {
217            if (!$node['assigned'] || !empty($node['tags'])) {
218                $geometries[] = $node['point'];
219            }
220        }
221
222        //var_dump($geometries);
223        return count($geometries) == 1 ? $geometries[0] : new GeometryCollection($geometries);
224    }
225
226    protected function processRoutes(&$relationWays, &$nodes)
227    {
228
229        // Construct lines
230        /** @var LineString[] $lineStrings */
231        $lineStrings = [];
232        while (count($relationWays) > 0) {
233            $line = array_shift($relationWays);
234            if ($line[0] !== $line[count($line) - 1]) {
235                do {
236                    $waysAdded = 0;
237                    foreach ($relationWays as $id => $wayNodes) {
238                        // Last node of ring = first node of way => put way to the end of ring
239                        if ($line[count($line) - 1] === $wayNodes[0]) {
240                            $line = array_merge($line, array_slice($wayNodes, 1));
241                            unset($relationWays[$id]);
242                            $waysAdded++;
243                        // Last node of ring = last node of way => reverse way and put to the end of ring
244                        } elseif ($line[count($line) - 1] === $wayNodes[count($wayNodes) - 1]) {
245                            $line = array_merge($line, array_slice(array_reverse($wayNodes), 1));
246                            unset($relationWays[$id]);
247                            $waysAdded++;
248                        // First node of ring = last node of way => put way to the beginning of ring
249                        } elseif ($line[0] === $wayNodes[count($wayNodes) - 1]) {
250                            $line = array_merge(array_slice($wayNodes, 0, count($wayNodes) - 1), $line);
251                            unset($relationWays[$id]);
252                            $waysAdded++;
253                        // First node of ring = first node of way => reverse way and put to the beginning of ring
254                        } elseif ($line[0] === $wayNodes[0]) {
255                            $line = array_merge(array_reverse(array_slice($wayNodes, 1)), $line);
256                            unset($relationWays[$id]);
257                            $waysAdded++;
258                        }
259                    }
260                // If line members are not ordered, we need to repeat end matching some times
261                } while ($waysAdded > 0);
262            }
263
264            // Create the new LineString
265            $linePoints = [];
266            foreach ($line as $lineNode) {
267                $linePoints[] = $nodes[$lineNode]['point'];
268            }
269            $lineStrings[] = new LineString($linePoints);
270        }
271
272        return $lineStrings;
273    }
274
275    protected function processMultipolygon(&$relationWays, &$nodes)
276    {
277        /* TODO: what to do with broken rings?
278         * I propose to force-close if start -> end point distance is less then 10% of line length, otherwise drop it.
279         * But if dropped, its inner ring will be outers, which is not good.
280         * We should save the role for each ring (outer, inner, mixed) during ring creation and check it during ring grouping
281         */
282
283        // Construct rings
284        /** @var Polygon[] $rings */
285        $rings = [];
286        while (!empty($relationWays)) {
287            $ring = array_shift($relationWays);
288            if ($ring[0] !== $ring[count($ring) - 1]) {
289                do {
290                    $waysAdded = 0;
291                    foreach ($relationWays as $id => $wayNodes) {
292                        // Last node of ring = first node of way => put way to the end of ring
293                        if ($ring[count($ring) - 1] === $wayNodes[0]) {
294                            $ring = array_merge($ring, array_slice($wayNodes, 1));
295                            unset($relationWays[$id]);
296                            $waysAdded++;
297                        // Last node of ring = last node of way => reverse way and put to the end of ring
298                        } elseif ($ring[count($ring) - 1] === $wayNodes[count($wayNodes) - 1]) {
299                            $ring = array_merge($ring, array_slice(array_reverse($wayNodes), 1));
300                            unset($relationWays[$id]);
301                            $waysAdded++;
302                        // First node of ring = last node of way => put way to the beginning of ring
303                        } elseif ($ring[0] === $wayNodes[count($wayNodes) - 1]) {
304                            $ring = array_merge(array_slice($wayNodes, 0, count($wayNodes) - 1), $ring);
305                            unset($relationWays[$id]);
306                            $waysAdded++;
307                        // First node of ring = first node of way => reverse way and put to the beginning of ring
308                        } elseif ($ring[0] === $wayNodes[0]) {
309                            $ring = array_merge(array_reverse(array_slice($wayNodes, 1)), $ring);
310                            unset($relationWays[$id]);
311                            $waysAdded++;
312                        }
313                    }
314                // If ring members are not ordered, we need to repeat end matching some times
315                } while ($waysAdded > 0 && $ring[0] !== $ring[count($ring) - 1]);
316            }
317
318            // Create the new Polygon
319            if ($ring[0] === $ring[count($ring) - 1]) {
320                $ringPoints = [];
321                foreach ($ring as $ringNode) {
322                    $ringPoints[] = $nodes[$ringNode]['point'];
323                }
324                $newPolygon = new Polygon([new LineString($ringPoints)]);
325                if ($newPolygon->isSimple()) {
326                    $rings[] = $newPolygon;
327                }
328            }
329        }
330
331        // Calculate containment
332        $containment = array_fill(0, count($rings), array_fill(0, count($rings), false));
333        foreach ($rings as $i => $ring) {
334            foreach ($rings as $j => $ring2) {
335                if ($i !== $j && $ring->contains($ring2)) {
336                    $containment[$i][$j] = true;
337                }
338            }
339        }
340        $containmentCount = count($containment);
341
342        /*
343        print '&nbsp; &nbsp;';
344        for($i=0; $i<count($rings); $i++) {
345            print $rings[$i]->getNumberOfPoints() . ' ';
346        }
347        print "<br>";
348        for($i=0; $i<count($rings); $i++) {
349            print $rings[$i]->getNumberOfPoints() . ' ';
350            for($j=0; $j<count($rings); $j++) {
351                print ($containment[$i][$j] ? '1' : '0') . ' ';
352            }
353            print "<br>";
354        }*/
355
356        // Group rings (outers and inners)
357
358        /** @var boolean[] $found */
359        $found = array_fill(0, $containmentCount, false);
360        $foundCount = 0;
361        $round = 0;
362        /** @var int[][] $polygonsRingIds */
363        $polygonsRingIds = [];
364        /** @var Polygon[] $relationPolygons */
365        $relationPolygons = [];
366        while ($foundCount < $containmentCount && $round < 100) {
367            $ringsFound = [];
368            for ($i = 0; $i < $containmentCount; $i++) {
369                if ($found[$i]) {
370                    continue;
371                }
372                $containCount = 0;
373                for ($j = 0; $j < count($containment[$i]); $j++) {
374                    if (!$found[$j]) {
375                        $containCount += $containment[$j][$i];
376                    }
377                }
378                if ($containCount === 0) {
379                    $ringsFound[] = $i;
380                }
381            }
382            if ($round % 2 === 0) {
383                $polygonsRingIds = [];
384            }
385            foreach ($ringsFound as $ringId) {
386                $found[$ringId] = true;
387                $foundCount++;
388                if ($round % 2 === 1) {
389                    foreach ($polygonsRingIds as $outerId => $polygon) {
390                        if ($containment[$outerId][$ringId]) {
391                            $polygonsRingIds[$outerId][] = $ringId;
392                        }
393                    }
394                } else {
395                    $polygonsRingIds[$ringId] = [0 => $ringId];
396                }
397            }
398            if ($round % 2 === 1 || $foundCount === $containmentCount) {
399                foreach ($polygonsRingIds as $k => $ringGroup) {
400                    $linearRings = [];
401                    foreach ($ringGroup as $polygonRing) {
402                        $linearRings[] = $rings[$polygonRing]->exteriorRing();
403                    }
404                    $relationPolygons[] = new Polygon($linearRings);
405                }
406            }
407            ++$round;
408        }
409
410        return $relationPolygons;
411    }
412
413
414
415    public function write(Geometry $geometry)
416    {
417
418        $this->processGeometry($geometry);
419
420        $osm = "<?xml version='1.0' encoding='UTF-8'?>\n<osm version='0.6' upload='false' generator='geoPHP'>\n";
421        foreach ($this->nodes as $latlon => $node) {
422            $latlon = explode('_', $latlon);
423            $osm .= "  <node id='{$node['id']}' visible='true' lat='$latlon[0]' lon='$latlon[1]' />\n";
424        }
425        foreach ($this->ways as $wayId => $way) {
426            $osm .= "  <way id='{$wayId}' visible='true'>\n";
427            foreach ($way as $nodeId) {
428                $osm .= "    <nd ref='{$nodeId}' />\n";
429            }
430            $osm .= "  </way>\n";
431        }
432
433        $osm .= "</osm>";
434        return $osm;
435    }
436
437    /**
438     * @param Geometry $geometry
439     */
440    protected function processGeometry($geometry)
441    {
442        if (!$geometry->isEmpty()) {
443            switch ($geometry->geometryType()) {
444                case Geometry::POINT:
445                    /** @var Point $geometry */
446                    $this->processPoint($geometry);
447                    break;
448                case Geometry::LINE_STRING:
449                    /** @var LineString $geometry */
450                    $this->processLineString($geometry);
451                    break;
452                case Geometry::POLYGON:
453                    /** @var Polygon $geometry */
454                    $this->processPolygon($geometry);
455                    break;
456                case Geometry::MULTI_POINT:
457                case Geometry::MULTI_LINE_STRING:
458                case Geometry::MULTI_POLYGON:
459                case Geometry::GEOMETRY_COLLECTION:
460                    /** @var Collection $geometry */
461                    $this->processCollection($geometry);
462                    break;
463            }
464        }
465    }
466
467    /**
468     * @param Point $point
469     * @param bool|false $isWayPoint
470     * @return int
471     */
472    protected function processPoint($point, $isWayPoint = false)
473    {
474        $nodePosition = sprintf(self::OSM_COORDINATE_PRECISION . '_' . self::OSM_COORDINATE_PRECISION, $point->y(), $point->x());
475        if (!isset($this->nodes[$nodePosition])) {
476            $this->nodes[$nodePosition] = ['id' => --$this->idCounter, "used" => $isWayPoint];
477            return $this->idCounter;
478        } else {
479            if ($isWayPoint) {
480                $this->nodes[$nodePosition]['used'] = true;
481            }
482            return $this->nodes[$nodePosition]['id'];
483        }
484    }
485
486    /**
487     * @param LineString $line
488     */
489    protected function processLineString($line)
490    {
491        $nodes = [];
492        foreach ($line->getPoints() as $point) {
493            $nodes[] = $this->processPoint($point, true);
494        }
495        $this->ways[--$this->idCounter] = $nodes;
496    }
497
498    /**
499     * @param Polygon $polygon
500     */
501    protected function processPolygon($polygon)
502    {
503        // TODO: Support interior rings
504        $this->processLineString($polygon->exteriorRing());
505    }
506
507    /**
508     * @param Collection $collection
509     */
510    protected function processCollection($collection)
511    {
512        // TODO: multi geometries should be converted to relations
513        foreach ($collection->getComponents() as $component) {
514            $this->processGeometry($component);
515        }
516    }
517
518    public static function downloadFromOSMByBbox($left, $bottom, $right, $top)
519    {
520        /** @noinspection PhpUnusedParameterInspection */
521        set_error_handler(
522            function ($errNO, $errStr, $errFile, $errLine, $errContext) {
523                if (isset($errContext['http_response_header'])) {
524                    foreach ($errContext['http_response_header'] as $line) {
525                        if (strpos($line, 'Error: ') > -1) {
526                            throw new \Exception($line);
527                        }
528                    }
529                }
530                throw new \Exception('unknown error');
531            },
532            E_WARNING
533        );
534
535        try {
536            $osmFile = file_get_contents(self::OSM_API_URL . "map?bbox={$left},{$bottom},{$right},{$top}");
537            restore_error_handler();
538            return $osmFile;
539        } catch (\Exception $e) {
540            restore_error_handler();
541            throw new \Exception("Failed to download from OSM. " . $e->getMessage());
542        }
543    }
544}
545