wikimedia/mediawiki-extensions-Wikibase

View on GitHub
lib/includes/Formatters/CachingKartographerEmbeddingHandler.php

Summary

Maintainability
A
0 mins
Test Coverage
<?php

namespace Wikibase\Lib\Formatters;

use DataValues\Geo\Values\GlobeCoordinateValue;
use InvalidArgumentException;
use MapCacheLRU;
use MediaWiki\Context\RequestContext;
use MediaWiki\Html\Html;
use MediaWiki\Json\FormatJson;
use MediaWiki\Language\Language;
use MediaWiki\Parser\Parser;
use MediaWiki\Parser\ParserOptions;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Title\Title;

/**
 * Service for embedding Kartographer mapframes for GlobeCoordinateValues.
 *
 * Use getParserOutput with ALL GlobeCoordinateValues on a page to get metadata
 * needed to display the mapframes properly.
 * Use getHtml for getting the HTML for a specific GlobeCoordinateValue.
 *
 * @license GPL-2.0-or-later
 * @author Marius Hoch
 */
class CachingKartographerEmbeddingHandler {

    /**
     * @var Parser
     */
    private $parser;

    /**
     * @var MapCacheLRU
     */
    private $cache;

    /**
     * @param Parser $parser
     */
    public function __construct( Parser $parser ) {
        $this->parser = $parser;
        $this->cache = new MapCacheLRU( 100 );
    }

    /**
     * @param GlobeCoordinateValue $value
     * @param Language $language
     *
     * @throws InvalidArgumentException
     * @return string|bool Html, false if the given value could not be rendered
     */
    public function getHtml( GlobeCoordinateValue $value, Language $language ) {
        if ( $value->getGlobe() !== GlobeCoordinateValue::GLOBE_EARTH ) {
            return false;
        }

        $cacheKey = $this->getCacheKey( $value, $language );
        if ( !$this->cache->has( $cacheKey ) ) {
            $parserOutput = $this->parser->parse(
                $this->getWikiText( $value ),
                RequestContext::getMain()->getTitle() ?? Title::makeTitle( NS_SPECIAL, 'BlankPage' ),
                $this->getParserOptions( $language )
            );
            $this->cache->set( $this->getCacheKey( $value, $language ), $parserOutput->getText() );
        }
        return $this->cache->get( $cacheKey );
    }

    /**
     * Get HTML for a Kartographer map, that can be injected into a MediaWiki page on
     * demand (for live previews).
     *
     * @param GlobeCoordinateValue $value
     * @param Language $language
     *
     * @throws InvalidArgumentException
     * @return string|bool Html, false if the given value could not be rendered
     */
    public function getPreviewHtml( GlobeCoordinateValue $value, Language $language ) {
        if ( $value->getGlobe() !== GlobeCoordinateValue::GLOBE_EARTH ) {
            return false;
        }

        $parserOutput = $this->getParserOutput( [ $value ], $language );

        $containerDivId = 'wb-globeCoordinateValue-preview-' . base_convert( (string)mt_rand( 1, PHP_INT_MAX ), 10, 36 );

        $html = '<div id="' . $containerDivId . '">' . $parserOutput->getText() . '</div>';
        $html .= $this->getMapframeInitJS(
            $containerDivId,
            $parserOutput->getModules(),
            (array)( $parserOutput->getJsConfigVars()['wgKartographerLiveData'] ?? [] )
        );

        return $html;
    }

    /**
     * Get a ParserOutput with metadata for all the given GlobeCoordinateValues.
     *
     * ATTENTION: This ParserOutput will generally only contain useable metadata, for
     * getting the html for a certain GlobeCoordinateValue, please use self::getHtml().
     *
     * @param GlobeCoordinateValue[] $values
     * @param Language $language
     * @return ParserOutput
     */
    public function getParserOutput( array $values, Language $language ) {
        // Parse all mapframes at once, to get metadata for all of them
        $wikiText = '';
        foreach ( $values as $value ) {
            if ( $value->getGlobe() !== GlobeCoordinateValue::GLOBE_EARTH ) {
                continue;
            }
            $wikiText .= $this->getWikiText( $value );
        }

        return $this->parser->parse(
            $wikiText,
            RequestContext::getMain()->getTitle() ?? Title::makeTitle( NS_SPECIAL, 'BlankPage' ),
            $this->getParserOptions( $language )
        );
    }

    private function getParserOptions( Language $language ): ParserOptions {
        // Cannot use $this->parser->getUser(), because that relies on the parser
        // having either a User or ParserOptions set, causing failures:
        // Error: Call to a member function getUser() on null
        return new ParserOptions(
            RequestContext::getMain()->getUser(),
            $language
        );
    }

    /**
     * @param GlobeCoordinateValue $value
     * @param Language $language
     * @return string
     */
    private function getCacheKey( GlobeCoordinateValue $value, Language $language ) {
        return $value->getHash() . '#' . $language->getCode();
    }

    /**
     * Get a <script> code block that initializes a mapframe.
     *
     * @param string $mapPreviewId Id of the container containing the map
     * @param string[] $rlModules RL modules to load
     * @param array $kartographerLiveData
     * @return string HTML
     */
    private function getMapframeInitJS( $mapPreviewId, array $rlModules, array $kartographerLiveData ) {
        $javaScript = $this->getMWConfigJS( $kartographerLiveData );

        // ext.kartographer.frame contains initMapframeFromElement (which we use below)
        $rlModules[] = 'ext.kartographer.frame';
        $rlModulesArr = array_unique( $rlModules );

        $rlModulesJson = FormatJson::encode( $rlModulesArr );
        $jsMapPreviewId = FormatJson::encode( '#' . $mapPreviewId );

        // Require all needed RL modules, then call initMapframeFromElement with the injected mapframe HTML
        $javaScript .= "mw.loader.using( $rlModulesJson ).then( " .
                "function( require ) { require( 'ext.kartographer.frame' ).initMapframeFromElement( " .
                "\$( $jsMapPreviewId ).find( '.mw-kartographer-map[data-mw-kartographer]' ).get( 0 ) ); } );";

        return Html::inlineScript( $javaScript );
    }

    /**
     * Get JavaScript code to update/init "wgKartographerLiveData" with the given data.
     *
     * @param array $kartographerLiveData
     * @return string JavaScript code
     */
    private function getMWConfigJS( array $kartographerLiveData ) {
        // Create an empty wgKartographerLiveData, if needed
        $javaScript = "if ( !mw.config.exists( 'wgKartographerLiveData' ) ) { mw.config.set( 'wgKartographerLiveData', {} ); }";

        // Append $kartographerLiveData to wgKartographerLiveData, as we can't overwrite wgKartographerLiveData
        // here, as it is already referenced, also we probably don't want to loose other entries
        foreach ( $kartographerLiveData as $key => $value ) {
            $jsKey = FormatJson::encode( (string)$key );
            $jsValue = FormatJson::encode( $value );

            $javaScript .= "mw.config.get( 'wgKartographerLiveData' )[$jsKey] = $jsValue;";
        }

        return $javaScript;
    }

    /**
     * Get the mapframe wikitext for a given GlobeCoordinateValue.
     *
     * @param GlobeCoordinateValue $value
     * @return string wikitext
     */
    private function getWikiText( GlobeCoordinateValue $value ) {
        $long = $this->formatNumber( $value->getLongitude() );
        $lat = $this->formatNumber( $value->getLatitude() );

        return '<mapframe width="310" height="180" zoom="13" latitude="' .
            $lat . '" longitude="' . $long . '" frameless align="left">
            {
            "type": "Feature",
            "geometry": { "type": "Point", "coordinates": [' . $long . ', ' . $lat . '] },
            "properties": {
              "marker-symbol": "marker",
              "marker-size": "large",
              "marker-color": "0050d0"
            }
          }
          </mapframe>';
    }

    /**
     * @param float $number
     * @return string
     */
    private function formatNumber( float $number ) {
        // 12 decimal places are equivalent to <0.01 mm, more than enough for everything
        return rtrim( rtrim( number_format( $number, 12, '.', '' ), '0' ), '.' );
    }

}