Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to load rtl-text-plugin lazily #8865

Merged
merged 14 commits into from
Oct 24, 2019
37 changes: 31 additions & 6 deletions debug/rtl.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,29 @@
<style>
body { margin: 0; padding: 0; }
html, body, #map { height: 100%; }
#buttons {
position: absolute;
background: white;
top: 0px;
left: 0px;
padding: 10px;
}
</style>
</head>

<body>
<div id='map'></div>
<div id="buttons">
<button id="loadSync">Load rtlTextPlugin Sync</button><br>
<button id="loadAsync">Load rtlTextPlugin Async</button><br>
Plugin Status:<br>
<span id="pluginStatus"></span>
</div>

<script src='/dist/mapbox-gl-dev.js'></script>
<script src='/debug/access_token_generated.js'></script>
<script>
mapboxgl.setRTLTextPlugin('https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js');
var pluginURL = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js';
var map = window.map = new mapboxgl.Map({
container: 'map',
zoom: 16.5,
Expand All @@ -26,11 +39,23 @@
hash: true
});

setTimeout(function() {
if (['loading', 'loaded'].indexOf(mapboxgl.getRTLTextPluginStatus()) === -1) {
mapboxgl.setRTLTextPlugin('https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js');
}
}, 2000);
document.getElementById('loadSync').onclick = function() {
mapboxgl.setRTLTextPlugin(pluginURL);
};

document.getElementById('loadAsync').onclick = function() {
mapboxgl.setRTLTextPlugin(pluginURL, function(err) {
if (err) {
throw err;
} else {
console.log('rtl-text-plugin loaded successfully');
}
}, true);
};

setInterval(function() {
document.getElementById('pluginStatus').innerHTML = mapboxgl.getRTLTextPluginStatus();
}, 400);
</script>
</body>
</html>
18 changes: 14 additions & 4 deletions src/data/bucket/symbol_bucket.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {register} from '../../util/web_worker_transfer';
import EvaluationParameters from '../../style/evaluation_parameters';
import Formatted from '../../style-spec/expression/types/formatted';
import ResolvedImage from '../../style-spec/expression/types/resolved_image';
import {plugin as globalRTLTextPlugin, getRTLTextPluginStatus} from '../../source/rtl_text_plugin';

import type {
Bucket,
Expand Down Expand Up @@ -303,6 +304,7 @@ class SymbolBucket implements Bucket {
writingModes: Array<number>;
allowVerticalPlacement: boolean;
hasPaintOverrides: boolean;
hasRTLText: boolean;

constructor(options: BucketParameters<SymbolStyleLayer>) {
this.collisionBoxArray = options.collisionBoxArray;
Expand All @@ -315,6 +317,7 @@ class SymbolBucket implements Bucket {
this.sourceLayerIndex = options.sourceLayerIndex;
this.hasPattern = false;
this.hasPaintOverrides = false;
this.hasRTLText = false;

const layer = this.layers[0];
const unevaluatedLayoutValues = layer._unevaluatedLayout._values;
Expand Down Expand Up @@ -407,10 +410,17 @@ class SymbolBucket implements Bucket {
// but plain string token evaluation skips that pathway so do the
// conversion here.
const resolvedTokens = layer.getValueAndResolveTokens('text-field', feature, availableImages);
text = transformText(resolvedTokens instanceof Formatted ?
resolvedTokens :
Formatted.fromString(resolvedTokens),
layer, feature);
const formattedText = Formatted.factory(resolvedTokens);
if (formattedText.containsRTLText()) {
this.hasRTLText = true;
}
if (
!this.hasRTLText || // non-rtl text so can proceed safely
getRTLTextPluginStatus() === 'unavailable' || // We don't intend to lazy-load the rtl text plugin, so proceed with incorrect shaping
this.hasRTLText && globalRTLTextPlugin.isParsed() // Use the rtlText plugin to shape text
) {
text = transformText(formattedText, layer, feature);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be a warning emitted here so that customers don't just see a map with no labels and wonder what the heck is going on?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed the behavior now so that only when lazy loading, does it skip rendering the text.

}
}

let icon: ResolvedImage | void;
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ const exported = {
* @function setRTLTextPlugin
* @param {string} pluginURL URL pointing to the Mapbox RTL text plugin source.
* @param {Function} callback Called with an error argument if there is an error.
* @param {boolean} lazy If set to `true`, mapboxgl will defer loading the plugin until rtl text is encountered,
* rtl text will then be rendered only after the plugin finishes loading.
* @example
* mapboxgl.setRTLTextPlugin('https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-rtl-text/v0.2.0/mapbox-gl-rtl-text.js');
* @see [Add support for right-to-left scripts](https://www.mapbox.com/mapbox-gl-js/example/mapbox-gl-rtl-text/)
Expand Down
132 changes: 99 additions & 33 deletions src/source/rtl_text_plugin.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,143 @@
// @flow

import {Event, Evented} from '../util/evented';
import {getArrayBuffer} from '../util/ajax';
import browser from '../util/browser';
import window from '../util/window';
import assert from 'assert';
import {isWorker} from '../util/util';

const status = {
unavailable: 'unavailable',
loading: 'loading',
unavailable: 'unavailable', // Not loaded
deferred: 'deferred', // The plugin URL has been specified, but loading has been deferred
loading: 'loading', // request in-flight
loaded: 'loaded',
error: 'error'
};

export type PluginState = {
pluginStatus: $Values<typeof status>;
pluginURL: ?string,
pluginBlobURL: ?string
};

type ErrorCallback = (error: ?Error) => void;
type PluginStateSyncCallback = (state: PluginState) => void;
let _completionCallback = null;

//Variables defining the current state of the plugin
let pluginStatus = status.unavailable;
let pluginURL = null;
let pluginBlobURL = null;

export const evented = new Evented();
export const triggerPluginCompletionEvent = function(error: ?Error) {
if (_completionCallback) {
_completionCallback(error);
}
};

type CompletionCallback = (error?: Error) => void;
type ErrorCallback = (error: Error) => void;
function sendPluginStateToWorker() {
evented.fire(new Event('pluginStateChange', {pluginStatus, pluginURL, pluginBlobURL}));
}

let _completionCallback;
export const evented = new Evented();

export const getRTLTextPluginStatus = function () {
return pluginStatus;
};

export const registerForPluginAvailability = function(
callback: (args: {pluginURL: string, completionCallback: CompletionCallback}) => void
) {
if (pluginURL) {
callback({pluginURL, completionCallback: _completionCallback});
} else {
evented.once('pluginAvailable', callback);
}
export const registerForPluginStateChange = function(callback: PluginStateSyncCallback) {
// Do an initial sync of the state
callback({pluginStatus, pluginURL, pluginBlobURL});
// Listen for all future state changes
evented.on('pluginStateChange', callback);
return callback;
};

export const clearRTLTextPlugin = function() {
pluginStatus = status.unavailable;
pluginURL = null;
if (pluginBlobURL) {
window.URL.revokeObjectURL(pluginBlobURL);
}
pluginBlobURL = null;
};

export const setRTLTextPlugin = function(url: string, callback: ErrorCallback) {
if (pluginStatus === status.loading || pluginStatus === status.loaded) {
export const setRTLTextPlugin = function(url: string, callback: ?ErrorCallback, deferred: boolean = false) {
if (pluginStatus === status.deferred || pluginStatus === status.loading || pluginStatus === status.loaded) {
throw new Error('setRTLTextPlugin cannot be called multiple times.');
}
pluginStatus = status.loading;
pluginURL = browser.resolveURL(url);
_completionCallback = (error?: Error) => {
if (error) {
// Clear loaded state to allow retries
clearRTLTextPlugin();
pluginStatus = status.error;
if (callback) {
callback(error);
pluginStatus = status.deferred;
_completionCallback = callback;
sendPluginStateToWorker();

//Start downloading the plugin immediately if not intending to lazy-load
if (!deferred) {
downloadRTLTextPlugin();
}
};

export const downloadRTLTextPlugin = function() {
if (pluginStatus !== status.deferred || !pluginURL) {
throw new Error('rtl-text-plugin cannot be downloaded unless a pluginURL is specified');
}
pluginStatus = status.loading;
sendPluginStateToWorker();
if (pluginURL) {
getArrayBuffer({url: pluginURL}, (error, data) => {
if (error) {
triggerPluginCompletionEvent(error);
} else {
const rtlBlob = new window.Blob([data], {type: 'application/javascript'});
pluginBlobURL = window.URL.createObjectURL(rtlBlob);
pluginStatus = status.loaded;
sendPluginStateToWorker();
}
} else {
// Called once for each worker
pluginStatus = status.loaded;
}
};
evented.fire(new Event('pluginAvailable', {pluginURL, completionCallback: _completionCallback}));
});
}
};

export const plugin: {
applyArabicShaping: ?Function,
processBidirectionalText: ?(string, Array<number>) => Array<string>,
processStyledBidirectionalText: ?(string, Array<number>, Array<number>) => Array<[string, Array<number>]>,
isLoaded: () => boolean
isLoaded: () => boolean,
isLoading: () => boolean,
setState: (state: PluginState) => void,
isParsed: () => boolean,
getURLs: () => { blob: ?string, host: ?string }
} = {
applyArabicShaping: null,
processBidirectionalText: null,
processStyledBidirectionalText: null,
isLoaded() {
return pluginStatus === status.loaded || // Foreground: loaded if the completion callback returned successfully
plugin.applyArabicShaping != null; // Background: loaded if the plugin functions have been compiled
return pluginStatus === status.loaded || // Main Thread: loaded if the completion callback returned successfully
plugin.applyArabicShaping != null; // Web-worker: loaded if the plugin functions have been compiled
},
isLoading() { // Main Thread Only: query the loading status, this function does not return the correct value in the worker context.
return pluginStatus === status.loading;
},
setState(state: PluginState) { // Worker thread only: this tells the worker threads that the plugin is available on the Main thread
assert(isWorker(), 'Cannot set the state of the rtl-text-plugin when not in the web-worker context');

pluginStatus = state.pluginStatus;
pluginURL = state.pluginURL;
pluginBlobURL = state.pluginBlobURL;
},
isParsed(): boolean {
assert(isWorker(), 'rtl-text-plugin is only parsed on the worker-threads');

return plugin.applyArabicShaping != null &&
plugin.processBidirectionalText != null &&
plugin.processStyledBidirectionalText != null;
},
getURLs(): { blob: ?string, host: ?string } {
assert(isWorker(), 'rtl-text-plugin urls can only be queried from the worker threads');

return {
blob: pluginBlobURL,
host: pluginURL,
};
}
};
15 changes: 15 additions & 0 deletions src/source/tile.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class Tile {

symbolFadeHoldUntil: ?number;
hasSymbolBuckets: boolean;
hasRTLText: boolean;

/**
* @param {OverscaledTileID} tileID
Expand All @@ -110,6 +111,7 @@ class Tile {
this.expirationTime = null;
this.queryPadding = 0;
this.hasSymbolBuckets = false;
this.hasRTLText = false;

// Counts the number of times a response was already expired when
// received. We're using this to add a delay when making a new request
Expand Down Expand Up @@ -184,6 +186,19 @@ class Tile {
}
}

this.hasRTLText = false;
if (this.hasSymbolBuckets) {
for (const id in this.buckets) {
const bucket = this.buckets[id];
if (bucket instanceof SymbolBucket) {
if (bucket.hasRTLText) {
this.hasRTLText = true;
break;
}
}
}
}

this.queryPadding = 0;
for (const id in this.buckets) {
const bucket = this.buckets[id];
Expand Down
10 changes: 10 additions & 0 deletions src/source/vector_tile_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import TileBounds from './tile_bounds';
import {ResourceType} from '../util/ajax';
import browser from '../util/browser';
import {cacheEntryPossiblyAdded} from '../util/tile_request_cache';
import {plugin as rtlTextPlugin, getRTLTextPluginStatus, downloadRTLTextPlugin} from './rtl_text_plugin';

import type {Source} from './source';
import type {OverscaledTileID} from './tile_id';
Expand Down Expand Up @@ -153,6 +154,15 @@ class VectorTileSource extends Evented implements Source {

if (this.map._refreshExpiredTiles && data) tile.setExpiryData(data);
tile.loadVectorData(data, this.map.painter);
if (tile.hasRTLText) {
const plugin = rtlTextPlugin;
if (!plugin.isLoading() &&
!plugin.isLoaded() &&
getRTLTextPluginStatus() === 'deferred'
) {
downloadRTLTextPlugin();
}
}

cacheEntryPossiblyAdded(this.dispatcher);

Expand Down
22 changes: 15 additions & 7 deletions src/source/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
import type {WorkerGlobalScopeInterface} from '../util/web_worker';
import type {Callback} from '../types/callback';
import type {LayerSpecification} from '../style-spec/types';
import type {PluginState} from './rtl_text_plugin';

/**
* @private
Expand Down Expand Up @@ -59,8 +60,9 @@ export default class Worker {
this.workerSourceTypes[name] = WorkerSource;
};

// This is invoked by the RTL text plugin when the download via the `importScripts` call has finished, and the code has been parsed.
this.self.registerRTLTextPlugin = (rtlTextPlugin: {applyArabicShaping: Function, processBidirectionalText: Function, processStyledBidirectionalText?: Function}) => {
if (globalRTLTextPlugin.isLoaded()) {
if (globalRTLTextPlugin.isParsed()) {
throw new Error('RTL text plugin already registered.');
}
globalRTLTextPlugin['applyArabicShaping'] = rtlTextPlugin.applyArabicShaping;
Expand Down Expand Up @@ -151,13 +153,19 @@ export default class Worker {
}
}

loadRTLTextPlugin(map: string, pluginURL: string, callback: Callback<void>) {
syncRTLPluginState(map: string, state: PluginState, callback: Callback<boolean>) {
try {
if (!globalRTLTextPlugin.isLoaded()) {
this.self.importScripts(pluginURL);
callback(globalRTLTextPlugin.isLoaded() ?
null :
new Error(`RTL Text Plugin failed to import scripts from ${pluginURL}`));
globalRTLTextPlugin.setState(state);
const {blob, host} = globalRTLTextPlugin.getURLs();
if (
globalRTLTextPlugin.isLoaded() &&
!globalRTLTextPlugin.isParsed() &&
blob != null && host != null // Not possible when `isLoaded` is true, but keeps flow happy
) {
this.self.importScripts(blob);
const complete = globalRTLTextPlugin.isParsed();
const error = complete ? undefined : new Error(`RTL Text Plugin failed to import scripts from ${host}`);
callback(error, complete);
}
} catch (e) {
callback(e.toString());
Expand Down
Loading