Passed
Push — master ( dd9386...b707ea )
by Swen
03:37
created

OSM::processLineString()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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