Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.10% covered (success)
97.10%
67 / 69
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MapTilerMapProvider
97.06% covered (success)
97.06%
66 / 68
90.00% covered (success)
90.00%
9 / 10
33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTileUrlTemplate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 supportsRegion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 geocode
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 reverseGeocode
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 isAvailable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLatencyMs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 requestJson
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
8.04
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Map\Infrastructure\Provider;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Map\Domain\Model\GeoPoint;
12use App\Modules\Map\Domain\Repository\MapProviderInterface;
13
14/**
15 * Provider adapter for MapTiler Cloud services (Global raster tiles & geocoding).
16 *
17 * @package App\Modules\Map\Infrastructure\Provider
18 */
19final class MapTilerMapProvider implements MapProviderInterface
20{
21    public const string DEFAULT_KEY = 'aEjVOngDInwVL8wubhb6';
22    private const string BASE_TILE_LIGHT = 'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=%s';
23    private const string BASE_TILE_DARK = 'https://api.maptiler.com/maps/streets-v2-dark/{z}/{x}/{y}.png?key=%s';
24    private const string GEOCODE_URL = 'https://api.maptiler.com/geocoding/%s.json?key=%s&limit=%d';
25
26    /**
27     * @var callable|null
28     */
29    private $httpRequester;
30    private float $lastLatencyMs = 25.0;
31
32    public function __construct(
33        private readonly string $apiKey = self::DEFAULT_KEY,
34        ?callable $httpRequester = null
35    ) {
36        $this->httpRequester = $httpRequester;
37    }
38
39    public function getName(): string
40    {
41        return 'maptiler';
42    }
43
44    public function getLabel(): string
45    {
46        return 'MapTiler Cloud (Global)';
47    }
48
49    public function getTileUrlTemplate(bool $darkMode = false): string
50    {
51        $template = $darkMode ? self::BASE_TILE_DARK : self::BASE_TILE_LIGHT;
52        return sprintf($template, urlencode($this->apiKey));
53    }
54
55    public function supportsRegion(float $latitude, float $longitude): bool
56    {
57        return $latitude >= -90.0 && $latitude <= 90.0 && $longitude >= -180.0 && $longitude <= 180.0;
58    }
59
60    public function geocode(string $query, int $limit = 5): array
61    {
62        $clean = trim($query);
63        if ($clean === '' || !$this->isAvailable()) {
64            return [];
65        }
66
67        $url = sprintf(self::GEOCODE_URL, rawurlencode($clean), urlencode($this->apiKey), max(1, min($limit, 20)));
68        $start = microtime(true);
69        $data = $this->requestJson($url);
70        $this->lastLatencyMs = (microtime(true) - $start) * 1000.0;
71
72        if (!is_array($data) || empty($data['features']) || !is_array($data['features'])) {
73            return [];
74        }
75
76        $points = [];
77        foreach ($data['features'] as $feature) {
78            $coords = $feature['geometry']['coordinates'] ?? null;
79            if (!is_array($coords) || count($coords) < 2) {
80                continue;
81            }
82            $points[] = new GeoPoint(
83                latitude:  (float) $coords[1],
84                longitude: (float) $coords[0],
85                label:     (string) ($feature['text'] ?? $feature['place_name'] ?? $clean),
86                address:   (string) ($feature['place_name'] ?? ''),
87                metadata:  is_array($feature['properties'] ?? null) ? $feature['properties'] : []
88            );
89        }
90
91        return $points;
92    }
93
94    public function reverseGeocode(float $latitude, float $longitude): ?GeoPoint
95    {
96        $query = sprintf('%.6f,%.6f', $longitude, $latitude);
97        $url = sprintf(self::GEOCODE_URL, rawurlencode($query), urlencode($this->apiKey), 1);
98        $start = microtime(true);
99        $data = $this->requestJson($url);
100        $this->lastLatencyMs = (microtime(true) - $start) * 1000.0;
101
102        if (!is_array($data) || empty($data['features'][0]['geometry']['coordinates'])) {
103            return null;
104        }
105
106        $feature = $data['features'][0];
107        $coords = $feature['geometry']['coordinates'];
108
109        return new GeoPoint(
110            latitude:  (float) $coords[1],
111            longitude: (float) $coords[0],
112            label:     (string) ($feature['text'] ?? $feature['place_name'] ?? 'Location'),
113            address:   (string) ($feature['place_name'] ?? ''),
114            metadata:  is_array($feature['properties'] ?? null) ? $feature['properties'] : []
115        );
116    }
117
118    public function isAvailable(): bool
119    {
120        return trim($this->apiKey) !== '';
121    }
122
123    public function getLatencyMs(): float
124    {
125        return $this->lastLatencyMs;
126    }
127
128    private function requestJson(string $url): ?array
129    {
130        if ($this->httpRequester !== null) {
131            $raw = ($this->httpRequester)($url);
132            return is_string($raw) && trim($raw) !== '' ? json_decode($raw, true) : null;
133        }
134
135        if (function_exists('curl_init')) {
136            $ch = curl_init($url);
137            curl_setopt_array($ch, [
138                CURLOPT_RETURNTRANSFER => true,
139                CURLOPT_TIMEOUT        => 3,
140                CURLOPT_CONNECTTIMEOUT => 2,
141                CURLOPT_HTTPHEADER     => [
142                    'User-Agent: Ammonly-Map-Engine/1.0',
143                    'Referer: https://app-admin.ammonly.com/',
144                    'Accept: application/json',
145                ],
146                CURLOPT_FOLLOWLOCATION => true,
147                CURLOPT_SSL_VERIFYPEER => false,
148            ]);
149            $response = curl_exec($ch);
150            curl_close($ch);
151            if (is_string($response) && trim($response) !== '') {
152                $decoded = json_decode($response, true);
153                return is_array($decoded) ? $decoded : null;
154            }
155        }
156
157        return null;
158    }
159}