diff --git a/DefaultSettings.php b/DefaultSettings.php index 9320905cd..b05088c7f 100644 --- a/DefaultSettings.php +++ b/DefaultSettings.php @@ -252,6 +252,43 @@ 'NASAGIBS.ModisTerraChlorophyll' => true ], + // Custom named tile layers that can be used as base layers or overlays, in addition to the + // stock leaflet-providers layers listed above. The name of each definition becomes a valid + // value for the layers and overlays parameters. Only the definitions actually used by a map + // are sent to the browser. + // + // Each definition has a 'url' (required, an XYZ tile template or, for WMS, the service + // endpoint), an optional 'options' array passed straight to Leaflet, and an optional 'wms' + // flag. Definitions without a non-empty url string are ignored. + // + // A definition whose name matches a stock layer (e.g. 'OpenStreetMap') overrides that stock + // layer. The 'options' are passed to Leaflet as-is; note that 'attribution' is rendered as HTML. + // + // Example: + // 'egMapsLeafletLayerDefinitions' => [ + // 'Historic 1904' => [ + // 'url' => 'https://tiles.example.org/historic1904/{z}/{x}/{y}.png', + // 'options' => [ + // 'attribution' => 'Historic map tiles', + // 'minZoom' => 1, + // 'maxZoom' => 18, + // 'subdomains' => 'abc', + // 'bounds' => [ [ 49.5, 5.8 ], [ 53.6, 15.2 ] ], + // ], + // ], + // 'Weather WMS' => [ + // 'wms' => true, + // 'url' => 'https://example.org/geoserver/wms', + // 'options' => [ + // 'layers' => 'weather:precipitation', + // 'format' => 'image/png', + // 'transparent' => true, + // 'attribution' => 'Weather service', + // ], + // ], + // ], + 'egMapsLeafletLayerDefinitions' => [], + 'egMapsLeafletLayersApiKeys' => [ 'MapBox' => '', 'MapQuestOpen' => '', diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 5cd487a49..d02ef1222 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -3,6 +3,12 @@ different releases and which versions of PHP and MediaWiki they support, see the [platform compatibility tables](INSTALL.md#platform-compatibility-and-release-status). +## Maps 13.1.0 + +Released on TBD. + +* Added the `$egMapsLeafletLayerDefinitions` setting to define custom named Leaflet tile and WMS layers, which can then be used as values of the `layers` and `overlays` parameters + ## Maps 13.0.2 Released on July 14th, 2026. diff --git a/resources/leaflet/jquery.leaflet.js b/resources/leaflet/jquery.leaflet.js index 99a0e4d23..709e366a2 100644 --- a/resources/leaflet/jquery.leaflet.js +++ b/resources/leaflet/jquery.leaflet.js @@ -249,6 +249,11 @@ }; this.newBaseLayerFromName = function(layerName) { + let definition = this.getLayerDefinition(layerName); + if (definition !== null) { + return this.newLayerFromDefinition(definition); + } + if (layerName === 'MapQuestOpen') { return new window.MQ.TileLayer(); } @@ -264,6 +269,24 @@ return new L.tileLayer.provider(layerName, layerOptions); }; + // Returns the custom layer definition for the given name, or null when there is none. + // Definitions are supplied per map via options.layerDefinitions (see LeafletService). + this.getLayerDefinition = function(layerName) { + if (options.layerDefinitions && Object.prototype.hasOwnProperty.call(options.layerDefinitions, layerName)) { + return options.layerDefinitions[layerName]; + } + + return null; + }; + + this.newLayerFromDefinition = function(definition) { + if (definition.wms) { + return L.tileLayer.wms(definition.url, definition.options); + } + + return L.tileLayer(definition.url, definition.options); + }; + this.getImageBaseLayers = function() { let layers = new Map(); @@ -291,7 +314,12 @@ let overlays = {}; $.each(options.overlays, function(index, overlayName) { - overlays[mw.html.escape(overlayName)] = new L.tileLayer.provider(overlayName).addTo(_this.map); + let definition = _this.getLayerDefinition(overlayName); + let overlay = definition !== null + ? _this.newLayerFromDefinition(definition) + : new L.tileLayer.provider(overlayName); + + overlays[mw.html.escape(overlayName)] = overlay.addTo(_this.map); }); return overlays; diff --git a/src/LeafletLayerDefinitions.php b/src/LeafletLayerDefinitions.php new file mode 100644 index 000000000..5f127b3c7 --- /dev/null +++ b/src/LeafletLayerDefinitions.php @@ -0,0 +1,83 @@ + + */ + private array $definitions; + + /** + * @param array $rawDefinitions Layer name to raw definition, as configured via + * $egMapsLeafletLayerDefinitions. Invalid definitions are skipped. + */ + public function __construct( array $rawDefinitions ) { + $this->definitions = $this->normalizeAll( $rawDefinitions ); + } + + private function normalizeAll( array $rawDefinitions ): array { + $definitions = []; + + foreach ( $rawDefinitions as $name => $rawDefinition ) { + $definition = $this->normalize( $rawDefinition ); + + if ( $definition !== null ) { + $definitions[$name] = $definition; + } + } + + return $definitions; + } + + /** + * @return array{url: string, options: array, wms: bool}|null + */ + private function normalize( mixed $rawDefinition ): ?array { + if ( !is_array( $rawDefinition ) ) { + return null; + } + + $url = $rawDefinition['url'] ?? null; + + if ( !is_string( $url ) || $url === '' ) { + return null; + } + + return [ + 'url' => $url, + 'options' => is_array( $rawDefinition['options'] ?? null ) ? $rawDefinition['options'] : [], + 'wms' => (bool)( $rawDefinition['wms'] ?? false ), + ]; + } + + /** + * @return string[] + */ + public function getLayerNames(): array { + return array_keys( $this->definitions ); + } + + /** + * @param string[] $names + * @return array Normalized definitions + * for the requested names that are defined. Unknown names are omitted. + */ + public function getDefinitions( array $names ): array { + return array_intersect_key( + $this->definitions, + array_fill_keys( $names, true ) + ); + } + +} diff --git a/src/LeafletService.php b/src/LeafletService.php index 1436de57c..6a7b978ef 100644 --- a/src/LeafletService.php +++ b/src/LeafletService.php @@ -16,10 +16,12 @@ class LeafletService implements MappingService { private ImageRepository $imageFinder; + private LeafletLayerDefinitions $layerDefinitions; private array $addedDependencies = []; - public function __construct( ImageRepository $imageFinder ) { + public function __construct( ImageRepository $imageFinder, LeafletLayerDefinitions $layerDefinitions ) { $this->imageFinder = $imageFinder; + $this->layerDefinitions = $layerDefinitions; } public function getName(): string { @@ -51,7 +53,7 @@ public function getParameterInfo(): array { 'aliases' => 'layer', 'type' => 'string', 'islist' => true, - 'values' => array_keys( $GLOBALS['egMapsLeafletAvailableLayers'], true, true ), + 'values' => $this->availableLayerNames( $GLOBALS['egMapsLeafletAvailableLayers'] ), 'default' => $GLOBALS['egMapsLeafletLayers'], 'message' => 'maps-leaflet-par-layers', ]; @@ -68,7 +70,7 @@ public function getParameterInfo(): array { 'aliases' => [ 'overlaylayers' ], 'type' => ParameterTypes::STRING, 'islist' => true, - 'values' => array_keys( $GLOBALS['egMapsLeafletAvailableOverlayLayers'], true, true ), + 'values' => $this->availableLayerNames( $GLOBALS['egMapsLeafletAvailableOverlayLayers'] ), 'default' => $GLOBALS['egMapsLeafletOverlayLayers'], 'message' => 'maps-leaflet-par-overlaylayers', ]; @@ -145,6 +147,24 @@ public function getParameterInfo(): array { return $params; } + /** + * The enabled stock layer names plus the names of the custom layer definitions, which are + * valid values for both the layers and overlays parameters. + * + * @param array $availableStockLayers + * @return string[] + */ + private function availableLayerNames( array $availableStockLayers ): array { + return array_values( + array_unique( + array_merge( + array_keys( $availableStockLayers, true, true ), + $this->layerDefinitions->getLayerNames() + ) + ) + ); + } + /** * @since 3.0 */ @@ -243,13 +263,15 @@ public function newMapDataFromParameters( array $params ): MapData { $params['overlays'] = $this->filterToAvailable( $params['overlays'], - $GLOBALS['egMapsLeafletAvailableOverlayLayers'] + $this->availableWithDefinitions( $GLOBALS['egMapsLeafletAvailableOverlayLayers'] ) ); $params['layers'] = $this->filterToAvailable( $params['layers'], - $GLOBALS['egMapsLeafletAvailableLayers'] + $this->availableWithDefinitions( $GLOBALS['egMapsLeafletAvailableLayers'] ) ); + $params = $this->addUsedLayerDefinitions( $params ); + return new MapData( $params ); } @@ -271,6 +293,33 @@ private function filterToAvailable( array $values, array $available ): array { ); } + /** + * @param array $available + * @return array + */ + private function availableWithDefinitions( array $available ): array { + // Union rather than array_merge: array_merge renumbers integer-like keys, which would drop + // a custom layer whose name is purely numeric (e.g. "1904"). Custom names go on the left so + // they take precedence over a same-named stock layer. + return array_fill_keys( $this->layerDefinitions->getLayerNames(), true ) + $available; + } + + /** + * Serializes the custom layer definitions actually used by this map into the map data, so only + * the relevant definitions are shipped to the client rather than the whole catalog. + */ + private function addUsedLayerDefinitions( array $params ): array { + $usedDefinitions = $this->layerDefinitions->getDefinitions( + array_merge( $params['layers'], $params['overlays'] ) + ); + + if ( $usedDefinitions !== [] ) { + $params['layerDefinitions'] = $usedDefinitions; + } + + return $params; + } + private function getJsImageLayers( array $imageLayers ) { $jsImageLayers = []; diff --git a/src/MapsFactory.php b/src/MapsFactory.php index 498f7d495..030f37943 100644 --- a/src/MapsFactory.php +++ b/src/MapsFactory.php @@ -191,12 +191,17 @@ private function getGoogleMapsService(): GoogleMapsService { private function getLeafletService(): LeafletService { $this->leafletService ??= new LeafletService( - $this->getImageRepository() + $this->getImageRepository(), + $this->getLeafletLayerDefinitions() ); return $this->leafletService; } + public function getLeafletLayerDefinitions(): LeafletLayerDefinitions { + return new LeafletLayerDefinitions( $this->settings['egMapsLeafletLayerDefinitions'] ?? [] ); + } + public function getDisplayMapFunction(): DisplayMapFunction { return new DisplayMapFunction( $this->getMappingServices() diff --git a/tests/Integration/Parser/LeafletTest.php b/tests/Integration/Parser/LeafletTest.php index ad3649fe3..9b992195d 100644 --- a/tests/Integration/Parser/LeafletTest.php +++ b/tests/Integration/Parser/LeafletTest.php @@ -4,13 +4,36 @@ namespace Maps\Tests\Integration\Parser; +use Maps\LeafletLayerDefinitions; +use Maps\LeafletService; use Maps\Tests\MapsTestFactory; use Maps\Tests\TestDoubles\ImageValueObject; +use Maps\Tests\TestDoubles\InMemoryImageRepository; use Maps\Tests\Util\TestFactory; use PHPUnit\Framework\TestCase; class LeafletTest extends TestCase { + private array $originalLayerDefinitions; + + protected function setUp(): void { + parent::setUp(); + + $this->originalLayerDefinitions = $GLOBALS['egMapsLeafletLayerDefinitions'] ?? []; + $this->setLayerDefinitions( [] ); + } + + protected function tearDown(): void { + $GLOBALS['egMapsLeafletLayerDefinitions'] = $this->originalLayerDefinitions; + + parent::tearDown(); + } + + private function setLayerDefinitions( array $definitions ): void { + $GLOBALS['egMapsLeafletLayerDefinitions'] = $definitions; + MapsTestFactory::newTestInstance(); + } + private function parse( string $textToParse ): string { return TestFactory::newInstance()->parse( $textToParse ); } @@ -53,4 +76,50 @@ public function testLeafletImageLayer() { $this->assertStringContainsData( '"height":50', $html ); } + public function testCustomLayerNameIsAValidLayerValue() { + $service = new LeafletService( + new InMemoryImageRepository(), + new LeafletLayerDefinitions( [ 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ] ] ) + ); + + $values = $service->getParameterInfo()['layers']['values']; + + $this->assertContains( 'Historic', $values ); + $this->assertContains( 'OpenStreetMap', $values ); + } + + public function testCustomLayerNameIsAValidOverlayValue() { + $service = new LeafletService( + new InMemoryImageRepository(), + new LeafletLayerDefinitions( [ 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ] ] ) + ); + + $values = $service->getParameterInfo()['overlays']['values']; + + $this->assertContains( 'Historic', $values ); + $this->assertContains( 'OpenSeaMap', $values ); + } + + public function testUsedCustomLayerDefinitionIsSerializedIntoMapData() { + $this->setLayerDefinitions( [ + 'Historic' => [ + 'url' => 'https://tiles.example/historic/{z}/{x}/{y}.png', + 'options' => [ 'attribution' => 'Historic tiles' ], + ], + ] ); + + $html = $this->parse( '{{#leaflet:layers=Historic}}' ); + + $this->assertStringContainsData( '"layerDefinitions":', $html ); + $this->assertStringContainsData( '"url":"https://tiles.example/historic/{z}/{x}/{y}.png"', $html ); + $this->assertStringContainsData( '"attribution":"Historic tiles"', $html ); + $this->assertStringContainsData( '"wms":false', $html ); + } + + public function testMapWithoutCustomLayersHasNoLayerDefinitions() { + $html = $this->parse( '{{#leaflet:}}' ); + + $this->assertStringNotContainsString( 'layerDefinitions', $html ); + } + } diff --git a/tests/Unit/LeafletLayerDefinitionsTest.php b/tests/Unit/LeafletLayerDefinitionsTest.php new file mode 100644 index 000000000..50016114b --- /dev/null +++ b/tests/Unit/LeafletLayerDefinitionsTest.php @@ -0,0 +1,145 @@ + [ + 'url' => 'https://tiles.example/{z}/{x}/{y}.png', + 'options' => [ 'attribution' => 'Example', 'maxZoom' => 18 ], + ], + ] ); + + $this->assertSame( + [ + 'Historic' => [ + 'url' => 'https://tiles.example/{z}/{x}/{y}.png', + 'options' => [ 'attribution' => 'Example', 'maxZoom' => 18 ], + 'wms' => false, + ], + ], + $definitions->getDefinitions( [ 'Historic' ] ) + ); + } + + public function testWmsDefinitionKeepsWmsFlag() { + $definitions = new LeafletLayerDefinitions( [ + 'Weather' => [ + 'wms' => true, + 'url' => 'https://example/wms', + 'options' => [ 'layers' => 'hist1904', 'format' => 'image/png', 'transparent' => true ], + ], + ] ); + + $this->assertSame( + [ + 'Weather' => [ + 'url' => 'https://example/wms', + 'options' => [ 'layers' => 'hist1904', 'format' => 'image/png', 'transparent' => true ], + 'wms' => true, + ], + ], + $definitions->getDefinitions( [ 'Weather' ] ) + ); + } + + public function testOptionsDefaultToEmptyArray() { + $definitions = new LeafletLayerDefinitions( [ + 'Bare' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ); + + $this->assertSame( + [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png', 'options' => [], 'wms' => false ], + $definitions->getDefinitions( [ 'Bare' ] )['Bare'] + ); + } + + public function testDefinitionWithoutUrlIsSkipped() { + $definitions = new LeafletLayerDefinitions( [ + 'NoUrl' => [ 'options' => [ 'attribution' => 'x' ] ], + ] ); + + $this->assertSame( [], $definitions->getLayerNames() ); + $this->assertSame( [], $definitions->getDefinitions( [ 'NoUrl' ] ) ); + } + + public function testDefinitionWithEmptyUrlIsSkipped() { + $definitions = new LeafletLayerDefinitions( [ + 'EmptyUrl' => [ 'url' => '' ], + ] ); + + $this->assertSame( [], $definitions->getLayerNames() ); + } + + public function testDefinitionWithNonStringUrlIsSkipped() { + $definitions = new LeafletLayerDefinitions( [ + 'NumberUrl' => [ 'url' => 123 ], + ] ); + + $this->assertSame( [], $definitions->getLayerNames() ); + } + + public function testNonArrayDefinitionIsSkipped() { + $definitions = new LeafletLayerDefinitions( [ + 'Weird' => 'not-an-array', + ] ); + + $this->assertSame( [], $definitions->getLayerNames() ); + } + + public function testUnknownKeysAreIgnored() { + $definitions = new LeafletLayerDefinitions( [ + 'Extra' => [ + 'url' => 'https://tiles.example/{z}/{x}/{y}.png', + 'unknown' => 'ignored', + ], + ] ); + + $this->assertSame( + [ 'url', 'options', 'wms' ], + array_keys( $definitions->getDefinitions( [ 'Extra' ] )['Extra'] ) + ); + } + + public function testGetLayerNamesListsOnlyValidDefinitions() { + $definitions = new LeafletLayerDefinitions( [ + 'First' => [ 'url' => 'https://tiles.example/1/{z}/{x}/{y}.png' ], + 'Invalid' => [ 'options' => [] ], + 'Last' => [ 'url' => 'https://tiles.example/2/{z}/{x}/{y}.png' ], + ] ); + + $this->assertSame( [ 'First', 'Last' ], $definitions->getLayerNames() ); + } + + public function testGetDefinitionsSelectsRequestedName() { + $definitions = new LeafletLayerDefinitions( [ + 'Before' => [ 'url' => 'https://tiles.example/b/{z}/{x}/{y}.png' ], + 'Wanted' => [ 'url' => 'https://tiles.example/w/{z}/{x}/{y}.png' ], + 'After' => [ 'url' => 'https://tiles.example/a/{z}/{x}/{y}.png' ], + ] ); + + $this->assertSame( + [ 'Wanted' ], + array_keys( $definitions->getDefinitions( [ 'Wanted' ] ) ) + ); + } + + public function testGetDefinitionsOmitsUnknownNames() { + $definitions = new LeafletLayerDefinitions( [ + 'Known' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ); + + $this->assertSame( [], $definitions->getDefinitions( [ 'Unknown' ] ) ); + } + +} diff --git a/tests/Unit/LeafletServiceTest.php b/tests/Unit/LeafletServiceTest.php index df794678d..de3c92cf8 100644 --- a/tests/Unit/LeafletServiceTest.php +++ b/tests/Unit/LeafletServiceTest.php @@ -4,6 +4,7 @@ namespace Maps\Tests\Unit; +use Maps\LeafletLayerDefinitions; use Maps\LeafletService; use Maps\Map\MapData; use Maps\Tests\TestDoubles\ImageValueObject; @@ -89,7 +90,136 @@ public function testNonWhitelistedLayersAreRemoved() { ); } - private function newLeafletMapData( array $overrides ): MapData { + public function testCustomLayerDefinitionSurvivesFiltering() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'OpenStreetMap', 'Historic', '' ] ], + new LeafletLayerDefinitions( [ + 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertSame( + [ 'OpenStreetMap', 'Historic' ], + $mapData->getParameters()['layers'] + ); + } + + public function testNumericallyNamedCustomLayerSurvivesFiltering() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'OpenStreetMap', '1904' ] ], + new LeafletLayerDefinitions( [ + '1904' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertSame( + [ 'OpenStreetMap', '1904' ], + $mapData->getParameters()['layers'] + ); + } + + public function testCustomOverlayDefinitionSurvivesFiltering() { + $mapData = $this->newLeafletMapData( + [ 'overlays' => [ 'OpenSeaMap', 'Historic', '' ] ], + new LeafletLayerDefinitions( [ + 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertSame( + [ 'OpenSeaMap', 'Historic' ], + $mapData->getParameters()['overlays'] + ); + } + + public function testUsedLayerDefinitionIsSerializedIntoMapData() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'OpenStreetMap', 'Historic' ] ], + new LeafletLayerDefinitions( [ + 'Historic' => [ + 'url' => 'https://tiles.example/{z}/{x}/{y}.png', + 'options' => [ 'attribution' => 'Example' ], + ], + ] ) + ); + + $this->assertSame( + [ + 'Historic' => [ + 'url' => 'https://tiles.example/{z}/{x}/{y}.png', + 'options' => [ 'attribution' => 'Example' ], + 'wms' => false, + ], + ], + $mapData->getParameters()['layerDefinitions'] + ); + } + + public function testDefinitionUsedAsOverlayIsSerializedIntoMapData() { + $mapData = $this->newLeafletMapData( + [ 'overlays' => [ 'OpenSeaMap', 'Historic' ] ], + new LeafletLayerDefinitions( [ + 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertArrayHasKey( 'Historic', $mapData->getParameters()['layerDefinitions'] ); + } + + public function testOnlyUsedLayerDefinitionsAreSerialized() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'Used' ] ], + new LeafletLayerDefinitions( [ + 'Unused' => [ 'url' => 'https://tiles.example/unused/{z}/{x}/{y}.png' ], + 'Used' => [ 'url' => 'https://tiles.example/used/{z}/{x}/{y}.png' ], + 'AlsoUnused' => [ 'url' => 'https://tiles.example/also/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertSame( + [ 'Used' ], + array_keys( $mapData->getParameters()['layerDefinitions'] ) + ); + } + + public function testLayerDefinitionsKeyIsAbsentWhenNoneAreConfigured() { + $mapData = $this->newLeafletMapData( [ 'layers' => [ 'OpenStreetMap' ] ] ); + + $this->assertArrayNotHasKey( 'layerDefinitions', $mapData->getParameters() ); + } + + public function testLayerDefinitionsKeyIsAbsentWhenConfiguredDefinitionsAreUnused() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'OpenStreetMap' ] ], + new LeafletLayerDefinitions( [ + 'Historic' => [ 'url' => 'https://tiles.example/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertArrayNotHasKey( 'layerDefinitions', $mapData->getParameters() ); + } + + public function testDefinitionShadowingStockLayerIsSerialized() { + $mapData = $this->newLeafletMapData( + [ 'layers' => [ 'OpenStreetMap' ] ], + new LeafletLayerDefinitions( [ + 'OpenSeaMap' => [ 'url' => 'https://tiles.example/before/{z}/{x}/{y}.png' ], + 'OpenStreetMap' => [ 'url' => 'https://tiles.example/shadow/{z}/{x}/{y}.png' ], + 'OpenTopoMap' => [ 'url' => 'https://tiles.example/after/{z}/{x}/{y}.png' ], + ] ) + ); + + $this->assertSame( + [ 'OpenStreetMap' ], + array_keys( $mapData->getParameters()['layerDefinitions'] ) + ); + $this->assertSame( + 'https://tiles.example/shadow/{z}/{x}/{y}.png', + $mapData->getParameters()['layerDefinitions']['OpenStreetMap']['url'] + ); + } + + private function newLeafletMapData( array $overrides, ?LeafletLayerDefinitions $layerDefinitions = null ): MapData { $params = array_merge( [ 'geojson' => '', @@ -100,11 +230,22 @@ private function newLeafletMapData( array $overrides ): MapData { $overrides ); - return ( new LeafletService( new InMemoryImageRepository() ) )->newMapDataFromParameters( $params ); + return $this->newService( new InMemoryImageRepository(), $layerDefinitions ) + ->newMapDataFromParameters( $params ); + } + + private function newService( + InMemoryImageRepository $imageRepo, + ?LeafletLayerDefinitions $layerDefinitions = null + ): LeafletService { + return new LeafletService( + $imageRepo, + $layerDefinitions ?? new LeafletLayerDefinitions( [] ) + ); } private function getJsImageLayers( InMemoryImageRepository $imageRepo, array $imageLayers ): array { - $service = new LeafletService( $imageRepo ); + $service = $this->newService( $imageRepo ); $method = new ReflectionMethod( LeafletService::class, 'getJsImageLayers' ); $method->setAccessible( true ); diff --git a/tests/Unit/Map/CargoFormat/CargoOutputBuilderTest.php b/tests/Unit/Map/CargoFormat/CargoOutputBuilderTest.php index fdca83c1f..b86e8f217 100644 --- a/tests/Unit/Map/CargoFormat/CargoOutputBuilderTest.php +++ b/tests/Unit/Map/CargoFormat/CargoOutputBuilderTest.php @@ -7,6 +7,7 @@ use DataValues\Geo\Values\LatLongValue; use Maps\Map\CargoFormat\CargoOutputBuilder; use Maps\Map\Marker; +use Maps\LeafletLayerDefinitions; use Maps\LeafletService; use Maps\Map\MapOutputBuilder; use Maps\MappingServices; @@ -52,7 +53,7 @@ public function testSetIconUrlWithEmptyParameterDoesNothing() { } private function callSetIconUrl( InMemoryImageRepository $imageRepo, array $markers, string $iconParameter ): void { - $leafletService = new LeafletService( new InMemoryImageRepository() ); + $leafletService = new LeafletService( new InMemoryImageRepository(), new LeafletLayerDefinitions( [] ) ); $builder = new CargoOutputBuilder( new MapOutputBuilder(), diff --git a/tests/js/leaflet/JQueryLeafletTest.js b/tests/js/leaflet/JQueryLeafletTest.js index e42b737ac..e437666f2 100644 --- a/tests/js/leaflet/JQueryLeafletTest.js +++ b/tests/js/leaflet/JQueryLeafletTest.js @@ -246,4 +246,194 @@ jqueryMap.map.remove(); } ); + QUnit.test( 'getLayerDefinition returns null when options.layerDefinitions is absent', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions(); + + var jqueryMap = createTestMap( $div, options ); + + assert.strictEqual( + jqueryMap.getLayerDefinition( 'OpenStreetMap' ), + null, + 'Absent layerDefinitions yields null without error' + ); + + jqueryMap.map.remove(); + } ); + + QUnit.test( 'getLayerDefinition returns the definition only for a matching name', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions( { + layerDefinitions: { + Historic: { url: 'https://tiles.example/{z}/{x}/{y}.png', options: {}, wms: false } + } + } ); + + var jqueryMap = createTestMap( $div, options ); + + assert.strictEqual( jqueryMap.getLayerDefinition( 'OpenStreetMap' ), null, 'Unknown name yields null' ); + assert.strictEqual( + jqueryMap.getLayerDefinition( 'Historic' ).url, + 'https://tiles.example/{z}/{x}/{y}.png', + 'Known name yields its definition' + ); + + jqueryMap.map.remove(); + } ); + + QUnit.test( 'newBaseLayerFromName builds a tile layer from a custom definition', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions( { + layers: [ 'Historic' ], + layerDefinitions: { + Historic: { + url: 'https://tiles.example/{z}/{x}/{y}.png', + options: { attribution: 'Example', maxZoom: 18 }, + wms: false + } + } + } ); + + var jqueryMap = createTestMap( $div, options ); + + var capturedUrl = null; + var capturedOptions = null; + var sentinel = {}; + var originalTileLayer = L.tileLayer; + L.tileLayer = function ( url, opts ) { + capturedUrl = url; + capturedOptions = opts; + return sentinel; + }; + L.tileLayer.provider = originalTileLayer.provider; + L.tileLayer.wms = originalTileLayer.wms; + + var layer; + try { + layer = jqueryMap.newBaseLayerFromName( 'Historic' ); + } finally { + L.tileLayer = originalTileLayer; + jqueryMap.map.remove(); + } + + assert.strictEqual( layer, sentinel, 'Returns the layer built from the definition' ); + assert.strictEqual( capturedUrl, 'https://tiles.example/{z}/{x}/{y}.png', 'Uses the definition url' ); + assert.deepEqual( + capturedOptions, + { attribution: 'Example', maxZoom: 18 }, + 'Passes the definition options through to Leaflet' + ); + } ); + + QUnit.test( 'newBaseLayerFromName uses L.tileLayer.wms for a WMS definition', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions( { + layers: [ 'Weather' ], + layerDefinitions: { + Weather: { + url: 'https://example/wms', + options: { layers: 'hist1904', transparent: true }, + wms: true + } + } + } ); + + var jqueryMap = createTestMap( $div, options ); + + var capturedUrl = null; + var capturedOptions = null; + var sentinel = {}; + var originalWms = L.tileLayer.wms; + L.tileLayer.wms = function ( url, opts ) { + capturedUrl = url; + capturedOptions = opts; + return sentinel; + }; + + var layer; + try { + layer = jqueryMap.newBaseLayerFromName( 'Weather' ); + } finally { + L.tileLayer.wms = originalWms; + jqueryMap.map.remove(); + } + + assert.strictEqual( layer, sentinel, 'Returns the WMS layer built from the definition' ); + assert.strictEqual( capturedUrl, 'https://example/wms', 'Uses the definition url' ); + assert.deepEqual( + capturedOptions, + { layers: 'hist1904', transparent: true }, + 'Passes the WMS options through to Leaflet' + ); + } ); + + QUnit.test( 'newBaseLayerFromName falls back to the provider catalog without a matching definition', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions( { + layers: [ 'OpenStreetMap' ], + layerDefinitions: { + Historic: { url: 'https://tiles.example/{z}/{x}/{y}.png', options: {}, wms: false } + } + } ); + + var jqueryMap = createTestMap( $div, options ); + + var capturedName = null; + var sentinel = { addTo: function () { return this; } }; + var originalProvider = L.tileLayer.provider; + L.tileLayer.provider = function ( name ) { + capturedName = name; + return sentinel; + }; + + try { + jqueryMap.newBaseLayerFromName( 'OpenStreetMap' ); + } finally { + L.tileLayer.provider = originalProvider; + jqueryMap.map.remove(); + } + + assert.strictEqual( capturedName, 'OpenStreetMap', 'Names without a definition use the provider catalog' ); + } ); + + QUnit.test( 'addOverlays builds a custom overlay from its definition', function ( assert ) { + var $div = $( '
' ).css( { width: '400px', height: '300px' } ).appendTo( '#qunit-fixture' ); + var options = makeDefaultOptions( { + overlays: [ 'HistoricOverlay' ], + layerDefinitions: { + HistoricOverlay: { url: 'https://tiles.example/{z}/{x}/{y}.png', options: {}, wms: false } + } + } ); + + var jqueryMap = createTestMap( $div, options ); + + var capturedUrl = null; + var sentinel = { addTo: function () { return this; } }; + var originalTileLayer = L.tileLayer; + L.tileLayer = function ( url ) { + capturedUrl = url; + return sentinel; + }; + L.tileLayer.provider = originalTileLayer.provider; + L.tileLayer.wms = originalTileLayer.wms; + + var overlays; + try { + overlays = jqueryMap.addOverlays(); + } finally { + L.tileLayer = originalTileLayer; + jqueryMap.map.remove(); + } + + assert.strictEqual( + capturedUrl, + 'https://tiles.example/{z}/{x}/{y}.png', + 'Custom overlay is built from its definition url' + ); + assert.true( + Object.prototype.hasOwnProperty.call( overlays, 'HistoricOverlay' ), + 'Overlay is keyed by its name' + ); + } ); + }( window.jQuery, window.mediaWiki ) );