Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,40 @@ public sealed class BitCesiumMapProvider : BitMapProviderBase
/// <inheritdoc />
public override string JsObjectName => "BitMapCesium";

/// <summary>The CesiumJS release this provider is written against.</summary>
public const string CesiumVersion = "1.124";

/// <summary>Default <see cref="BaseUrl"/>: the official CesiumJS CDN build.</summary>
public const string DefaultBaseUrl = $"https://cesium.com/downloads/cesiumjs/releases/{CesiumVersion}/Build/Cesium";

/// <summary>
/// Base URL of the CesiumJS build to load (the <c>Build/Cesium</c> folder), without a trailing
/// slash. Defaults to <see cref="DefaultBaseUrl"/>.
/// <para>
/// Point this at a copy on your own origin when the page is served cross-origin isolated with
/// <c>Cross-Origin-Embedder-Policy: require-corp</c>: cesium.com sends neither a
/// <c>Cross-Origin-Resource-Policy</c> nor an <c>Access-Control-Allow-Origin</c> header, so the
/// browser blocks the script in both no-cors and CORS mode. Note that CesiumJS resolves its own
/// workers, <c>.wasm</c> decoders and assets relative to the URL it was loaded from, so the whole
/// <c>Build/Cesium</c> folder has to be reachable under this base - pointing it at a lone copy of
/// <c>Cesium.js</c> is not enough.
/// </para>
/// </summary>
public string BaseUrl { get; set; } = DefaultBaseUrl;

/// <inheritdoc />
public override IReadOnlyList<string> Scripts => ["https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"];
public override IReadOnlyList<string> Scripts => [$"{NormalizedBaseUrl}/Cesium.js"];

/// <inheritdoc />
public override IReadOnlyList<string> Stylesheets => ["https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Widgets/widgets.css"];
public override IReadOnlyList<string> Stylesheets => [$"{NormalizedBaseUrl}/Widgets/widgets.css"];

/// <summary>
/// <see cref="BaseUrl"/> without a trailing slash, falling back to <see cref="DefaultBaseUrl"/>
/// when it is blank, so the composed URLs never end up with an empty or doubled separator.
/// </summary>
private string NormalizedBaseUrl => string.IsNullOrWhiteSpace(BaseUrl)
? DefaultBaseUrl
: BaseUrl.Trim().TrimEnd('/');

/// <inheritdoc />
public override object BuildOptionsPayload()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,40 @@ namespace BitBlazorUI {
export class BitMapLeaflet {
private static _maps: { [id: string]: LeafletState } = {};

/**
* Wires the no-cors then CORS retry of BitMapHelpers onto a tile layer: when every tile
* of a cross-origin layer fails on a cross-origin isolated page (COEP: require-corp
* blocks no-cors subresources that carry no CORP header), the layer is redrawn in CORS
* mode. Leaflet reads options.crossOrigin per tile, so redraw() is enough.
*/
private static _wireTileCorsFallback(layer: any, urlTemplate: string, isVerification: boolean = false) {
const fallback = BitMapHelpers.createTileCorsFallback(urlTemplate, () => {
layer.options.crossOrigin = 'anonymous';
// Re-wire so the CORS-mode attempt is watched too: if its whole batch fails as
// well, the helper retracts the origin-wide mark instead of leaving the host stuck
// in CORS mode for every later layer. The listeners above stay attached but are
// inert - a fallback acts at most once - and at most one extra pair is ever added.
// Wired before the redraw, which re-requests the tiles synchronously and would
// otherwise fire their load-start events past a listener that is not there yet.
BitMapLeaflet._wireTileCorsFallback(layer, urlTemplate, true);
layer.redraw();
return true;
}, isVerification, () => {
// The verification failed as fully as the first attempt, so CORS mode was not the
// answer for this host and the mark has just been retracted. This layer is still
// the one that was switched, though, and would go on asking for tiles in a mode
// this host refuses - i.e. render nothing - so put it back the way it was created.
// No new fallback is wired to it: it is back in the state whose failure started
// this, and re-arming it as a marker would loop.
layer.options.crossOrigin = undefined;
// The map may have been disposed while the decision was deferred.
try { layer.redraw(); } catch { /* ignore */ }
});
layer.on('tileloadstart', fallback.onTileStart);
layer.on('tileload', fallback.onTileLoad);
layer.on('tileerror', fallback.onTileError);
}

public static async init(id: string, canvasId: string, element: HTMLElement, dotnetObj: DotNetObject | null | undefined, options: any) {
element = await BitMapHelpers.resolveMapCanvas(canvasId, element);

Expand Down Expand Up @@ -84,10 +118,15 @@ namespace BitBlazorUI {
tileOpacity: o.tileOpacity ?? 1,
};
const baseTileLayer = L.tileLayer(tileOptions.tileUrl, {
crossOrigin: BitMapHelpers.tileCrossOrigin(tileOptions.tileUrl),
maxZoom: tileOptions.tileMaxZoom,
attribution: tileOptions.tileAttribution,
opacity: tileOptions.tileOpacity,
}).addTo(map);
});
// Wired before the layer is added to the map: adding it requests the first batch of
// tiles synchronously, and it is exactly that batch the fallback has to count.
BitMapLeaflet._wireTileCorsFallback(baseTileLayer, tileOptions.tileUrl);
baseTileLayer.addTo(map);

const state: LeafletState = {
L, map, dotnetObj,
Expand Down Expand Up @@ -163,10 +202,13 @@ namespace BitBlazorUI {
if (tileChanged) {
if (s.baseTileLayer) s.map.removeLayer(s.baseTileLayer);
s.baseTileLayer = L.tileLayer(next.tileUrl, {
crossOrigin: BitMapHelpers.tileCrossOrigin(next.tileUrl),
maxZoom: next.tileMaxZoom,
attribution: next.tileAttribution,
opacity: next.tileOpacity,
}).addTo(s.map);
});
BitMapLeaflet._wireTileCorsFallback(s.baseTileLayer, next.tileUrl);
s.baseTileLayer.addTo(s.map);
s._tileOptions = next;
}
if (s.baseTileLayer) {
Expand Down Expand Up @@ -427,11 +469,13 @@ namespace BitBlazorUI {
delete s.tileOverlays[opts.id];
}
const tl = L.tileLayer(opts.urlTemplate, {
crossOrigin: BitMapHelpers.tileCrossOrigin(opts.urlTemplate),
opacity: opts.opacity ?? 1,
zIndex: opts.zIndex ?? 100,
maxZoom: opts.maxZoom ?? 19,
attribution: opts.attribution || "",
});
BitMapLeaflet._wireTileCorsFallback(tl, opts.urlTemplate);
tl.addTo(s.map);
s.tileOverlays[opts.id] = tl;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,66 @@ namespace BitBlazorUI {
private static readonly _defaultTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
private static readonly _osmAttribution = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';

/**
* Builds the XYZ source of a tile layer and assigns it, wiring the no-cors then CORS
* retry of BitMapHelpers: when every tile of a cross-origin layer fails on a cross-origin
* isolated page (COEP: require-corp blocks no-cors subresources that carry no CORP
* header), the source is rebuilt in CORS mode. OpenLayers reads crossOrigin when the
* source is constructed, so the retry has to create a new one - and because the rebuild
* goes through here again with isVerification set, the CORS-mode source gets a fallback
* that no longer retries but verifies: if that attempt fails as fully, it retracts the
* origin-wide mark and rebuilds the layer's source in plain no-cors mode, since the source
* that produced the evidence would otherwise keep asking for tiles in a mode this host
* refuses and render nothing for the rest of the session.
*/
private static _setTileSource(ol: any, layer: any, params: { url: string, maxZoom: number, attributions: string }, isVerification: boolean = false) {
const source = new ol.XYZ({
crossOrigin: BitMapHelpers.tileCrossOrigin(params.url),
url: params.url,
maxZoom: params.maxZoom,
attributions: params.attributions,
});
const fallback = BitMapHelpers.createTileCorsFallback(params.url, () => {
// The retry is deferred, so a sync() may have swapped a different source onto this
// layer meanwhile. Rebuilding from the captured params would resurrect the tile url
// that sync() just replaced - and because the state already records the new url, no
// later sync() would consider it changed and put it back. Only retry while the
// source this fallback was wired to is still the one the layer is showing;
// declining here also rolls the origin-wide mark back, since the CORS-mode layer
// that would have put it to the test is never created.
if (layer.getSource() !== source) return false;
BitMapOpenLayers._setTileSource(ol, layer, params, true);
return true;
}, isVerification, () => {
// Same guard as the retry: a sync() may have swapped a different source onto this
// layer while the decision was deferred, and reverting would resurrect the tile
// url that sync() replaced.
if (layer.getSource() !== source) return;
BitMapOpenLayers._setPlainTileSource(ol, layer, params);
});
source.on('tileloadstart', fallback.onTileStart);
source.on('tileloadend', fallback.onTileLoad);
source.on('tileloaderror', fallback.onTileError);
Comment thread
msynk marked this conversation as resolved.
layer.setSource(source);
}

/**
* Rebuilds a tile source in plain no-cors mode, wiring no fallback to it: this is what a
* failed verification reverts to, and the state it reverts to is the very one whose failure
* started the retry - watching it again would mark the origin, redraw in CORS mode, fail
* the verification and revert once more, round and round. crossOrigin is spelled out rather
* than read from tileCrossOrigin() so a concurrent layer re-marking the origin in between
* cannot turn the revert back into the mode being reverted from.
*/
private static _setPlainTileSource(ol: any, layer: any, params: { url: string, maxZoom: number, attributions: string }) {
layer.setSource(new ol.XYZ({
crossOrigin: undefined,
url: params.url,
maxZoom: params.maxZoom,
attributions: params.attributions,
}));
}

private static _resolveTileUrl(o: any): string {
return (o.tileUrl || BitMapOpenLayers._defaultTileUrl).replace('{s}', 'a');
}
Expand Down Expand Up @@ -51,13 +111,11 @@ namespace BitBlazorUI {
const tileAttribution = BitMapOpenLayers._resolveTileAttribution(tileUrl, o.tileAttribution);
const tileOpacity = o.tileOpacity ?? 1;

const baseTile = new ol.TileLayer({
source: new ol.XYZ({
url: tileUrl,
maxZoom: tileMaxZoom,
attributions: tileAttribution,
}),
opacity: tileOpacity,
const baseTile = new ol.TileLayer({ opacity: tileOpacity });
BitMapOpenLayers._setTileSource(ol, baseTile, {
url: tileUrl,
maxZoom: tileMaxZoom,
attributions: tileAttribution,
});

const map = new ol.Map({
Expand Down Expand Up @@ -209,11 +267,11 @@ namespace BitBlazorUI {
if (nextTileUrl !== s.tileUrl ||
nextTileMaxZoom !== s.tileMaxZoom ||
nextTileAttribution !== s.tileAttribution) {
s.baseTileLayer.setSource(new ol.XYZ({
BitMapOpenLayers._setTileSource(ol, s.baseTileLayer, {
url: nextTileUrl,
maxZoom: nextTileMaxZoom,
attributions: nextTileAttribution,
}));
});
s.tileUrl = nextTileUrl;
s.tileMaxZoom = nextTileMaxZoom;
s.tileAttribution = nextTileAttribution;
Expand Down Expand Up @@ -460,14 +518,14 @@ namespace BitBlazorUI {
delete s.tileOverlays[opts.id];
}
const tl = new ol.TileLayer({
source: new ol.XYZ({
url: (opts.urlTemplate || '').replace('{s}', 'a'),
maxZoom: opts.maxZoom ?? 19,
attributions: opts.attribution || '',
}),
opacity: opts.opacity ?? 1,
zIndex: opts.zIndex ?? 100,
});
BitMapOpenLayers._setTileSource(ol, tl, {
url: (opts.urlTemplate || '').replace('{s}', 'a'),
maxZoom: opts.maxZoom ?? 19,
attributions: opts.attribution || '',
});
s.tileOverlays[opts.id] = tl;
s.map.addLayer(tl);
}
Expand Down
Loading
Loading