Skip to content

Commit

Permalink
Rd-324-2 (#124)
Browse files Browse the repository at this point in the history
* wip

* fallback for missing distant style file

* Add logic for tracking failed distant style loads

* detecting faulty style URL

* Adding logic to detect CORS errors
  • Loading branch information
jonathanlurie authored Oct 14, 2024
1 parent 8c8943f commit b2d083c
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 21 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- The `Map` class instances now have a `.setTerrainAnimationDuration(d: number)` method
- The `Map` class instances now have events related to terrain animation `"terrainAnimationStart"` and `"terrainAnimationStop"`
- expose the function `getWebGLSupportError()` to detect WebGL compatibility
- Adding detection of invalid style objects of URLs and falls back to a default style if necessary.
- Updating to Maplibre v4.7.1

## 2.3.0
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"vitest": "^0.34.2"
},
"dependencies": {
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
"@maptiler/client": "^2.0.0",
"events": "^3.3.0",
"js-base64": "^3.7.4",
Expand Down
117 changes: 110 additions & 7 deletions src/Map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import maplibregl from "maplibre-gl";
import maplibregl, { AJAXError } from "maplibre-gl";
import { Base64 } from "js-base64";
import type {
StyleSpecification,
Expand Down Expand Up @@ -29,7 +29,7 @@ import { getBrowserLanguage, Language, type LanguageInfo } from "./language";
import { styleToStyle } from "./mapstyle";
import { MaptilerTerrainControl } from "./MaptilerTerrainControl";
import { MaptilerNavigationControl } from "./MaptilerNavigationControl";
import { geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client";
import { MapStyle, geolocation, getLanguageInfoFromFlag, toLanguageInfo } from "@maptiler/client";
import { MaptilerGeolocateControl } from "./MaptilerGeolocateControl";
import { ScaleControl } from "./MLAdapters/ScaleControl";
import { FullscreenControl } from "./MLAdapters/FullscreenControl";
Expand Down Expand Up @@ -187,6 +187,8 @@ export class Map extends maplibregl.Map {
private languageAlwaysBeenStyle: boolean;
private isReady = false;
private terrainAnimationDuration = 1000;
private monitoredStyleUrls!: Set<string>;
private styleInProcess = false;

constructor(options: MapOptions) {
displayNoWebGlWarning(options.container);
Expand All @@ -195,13 +197,19 @@ export class Map extends maplibregl.Map {
config.apiKey = options.apiKey;
}

const style = styleToStyle(options.style);
const hashPreConstructor = location.hash;
const { style, requiresUrlMonitoring, isFallback } = styleToStyle(options.style);
if (isFallback) {
console.warn(
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Fallback to default MapTiler style.",
);
}

if (!config.apiKey) {
console.warn("MapTiler Cloud API key is not set. Visit https://maptiler.com and try Cloud for free!");
}

const hashPreConstructor = location.hash;

// default attribution control options
let attributionControlOptions = {
compact: false,
Expand All @@ -215,13 +223,71 @@ export class Map extends maplibregl.Map {
};
}

// calling the map constructor with full length style
super({
const superOptions = {
...options,
style,
maplibreLogo: false,
transformRequest: combineTransformRequest(options.transformRequest),
attributionControl: options.forceNoAttributionControl === true ? false : attributionControlOptions,
} as maplibregl.MapOptions;

// Removing the style option from the super constructor so that we can initialize this.styleInProcess before
// calling .setStyle(). Otherwise, if a style is provided to the super constructor, the setStyle method is called as
// a child call of super, meaning instance attributes cannot be initialized yet.
// The styleInProcess instance attribute is necessary to track if a style has not fall into a CORS error, for which
// Maplibre DOES NOT throw an AJAXError (hence does not track the URL of the failed http request)
// biome-ignore lint/performance/noDelete: <explanation>
delete superOptions.style;
super(superOptions);
this.setStyle(style);

if (requiresUrlMonitoring) {
this.monitorStyleUrl(style as string);
}

const applyFallbackStyle = () => {
let warning = "The distant style could not be loaded.";
// Loading a new style failed. If a style is not already in place,
// the default one is loaded instead + warning in console
if (!this.getStyle()) {
this.setStyle(MapStyle.STREETS);
warning += `Loading default MapTiler Cloud style "${MapStyle.STREETS.getDefaultVariant().getId()}" as a fallback.`;
} else {
warning += "Leaving the style as is.";
}
console.warn(warning);
};

this.on("style.load", () => {
this.styleInProcess = false;
});

// Safeguard for distant styles at non-http 2xx status URLs
this.on("error", (event) => {
if (event.error instanceof AJAXError) {
const err = event.error as AJAXError;
const url = err.url;
const cleanUrl = new URL(url);
cleanUrl.search = "";
const clearnUrlStr = cleanUrl.href;

// If the URL is present in the list of monitored style URL,
// that means this AJAXError was about a style, and we want to fallback to
// the default style
if (this.monitoredStyleUrls.has(clearnUrlStr)) {
this.monitoredStyleUrls.delete(clearnUrlStr);
applyFallbackStyle();
}
return;
}

// CORS error when fetching distant URL are not detected as AJAXError by Maplibre, just as generic error with no url property
// so we have to find a way to detect them when it comes to failing to load a style.
if (this.styleInProcess) {
// If this.styleInProcess is true, it very likely means the style URL has not resolved due to a CORS issue.
// In such case, we load the default style
return applyFallbackStyle();
}
});

if (config.caching && !CACHE_API_AVAILABLE) {
Expand Down Expand Up @@ -632,6 +698,20 @@ export class Map extends maplibregl.Map {
});
}

private monitorStyleUrl(url: string) {
// In case this was called before the super constructor could be called.
if (typeof this.monitoredStyleUrls === "undefined") {
this.monitoredStyleUrls = new Set<string>();
}

// Note: Because of the usage of urlToAbsoluteUrl() the URL of a style is always supposed to be absolute

// Removing all the URL params to make it easier to later identify in the set
const cleanUrl = new URL(url);
cleanUrl.search = "";
this.monitoredStyleUrls.add(cleanUrl.href);
}

/**
* Update the style of the map.
* Can be:
Expand All @@ -650,7 +730,30 @@ export class Map extends maplibregl.Map {
this.forceLanguageUpdate = false;
});

return super.setStyle(styleToStyle(style), options);
const styleInfo = styleToStyle(style);

if (styleInfo.requiresUrlMonitoring) {
this.monitorStyleUrl(styleInfo.style as string);
}

// If the style is invalid and what is returned is a fallback + the map already has a style,
// the style remains unchanged.
if (styleInfo.isFallback) {
if (this.getStyle()) {
console.warn(
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Keeping the curent style instead.",
);
return this;
}

console.warn(
"Invalid style. A style must be a valid URL to a style.json, a JSON string representing a valid StyleSpecification or a valid StyleSpecification object. Fallback to default MapTiler style.",
);
}

this.styleInProcess = true;
super.setStyle(styleInfo.style, options);
return this;
}

/**
Expand Down
124 changes: 111 additions & 13 deletions src/mapstyle.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,130 @@
import { validateStyleMin } from "@maplibre/maplibre-gl-style-spec";
import { MapStyle, ReferenceMapStyle, MapStyleVariant, mapStylePresetList, expandMapStyle } from "@maptiler/client";

export function styleToStyle(
style: string | ReferenceMapStyle | MapStyleVariant | maplibregl.StyleSpecification | null | undefined,
): string | maplibregl.StyleSpecification {
): { style: string | maplibregl.StyleSpecification; requiresUrlMonitoring: boolean; isFallback: boolean } {
if (!style) {
return MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
.getDefaultVariant()
.getExpandedStyleURL();
return {
style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
.getDefaultVariant()
.getExpandedStyleURL(),
requiresUrlMonitoring: false, // default styles don't require URL monitoring
isFallback: true,
};
}

// If the provided style is a shorthand (eg. "streets-v2") or a full style URL
if (typeof style === "string" || style instanceof String) {
if (!style.startsWith("http") && style.toLowerCase().includes(".json")) {
// If a style does not start by http but still contains the extension ".json"
// we assume it's a relative path to a style json file
return style as string;
if (typeof style === "string") {
// The string could be a JSON valid style spec
const styleValidationReport = convertToStyleSpecificationString(style);

// The string is a valid JSON style that validates against the StyleSpecification spec:
// Let's use this style
if (styleValidationReport.isValidStyle) {
return {
style: styleValidationReport.styleObject as maplibregl.StyleSpecification,
requiresUrlMonitoring: false,
isFallback: false,
};
}

// The string is a valid JSON but not of an object that validates the StyleSpecification spec:
// Fallback to the default style
if (styleValidationReport.isValidJSON) {
return {
style: MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle]
.getDefaultVariant()
.getExpandedStyleURL(),
requiresUrlMonitoring: false, // default styles don't require URL monitoring
isFallback: true,
};
}

// The style is an absolute URL
if (style.startsWith("http")) {
return { style: style, requiresUrlMonitoring: true, isFallback: false };
}

return expandMapStyle(style);
// The style is a relative URL
if (style.toLowerCase().includes(".json")) {
return { style: urlToAbsoluteUrl(style), requiresUrlMonitoring: true, isFallback: false };
}

// The style is a shorthand like "streets-v2" or a MapTiler Style ID (UUID)
return { style: expandMapStyle(style), requiresUrlMonitoring: true, isFallback: false };
}

if (style instanceof MapStyleVariant) {
return style.getExpandedStyleURL();
// Built-in style variants don't require URL monitoring
return { style: style.getExpandedStyleURL(), requiresUrlMonitoring: false, isFallback: false };
}

if (style instanceof ReferenceMapStyle) {
return (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL();
// Built-in reference map styles don't require URL monitoring
return {
style: (style.getDefaultVariant() as MapStyleVariant).getExpandedStyleURL(),
requiresUrlMonitoring: false,
isFallback: false,
};
}

// If the style validates as a StyleSpecification object, we use it
if (validateStyleMin(style).length === 0) {
return {
style: style as maplibregl.StyleSpecification,
requiresUrlMonitoring: false,
isFallback: false,
};
}

return style as maplibregl.StyleSpecification;
// If none of the previous attempts to detect a valid style failed => fallback to default style
const fallbackStyle = MapStyle[mapStylePresetList[0].referenceStyleID as keyof typeof MapStyle].getDefaultVariant();
return {
style: fallbackStyle.getExpandedStyleURL(),
requiresUrlMonitoring: false, // default styles don't require URL monitoring
isFallback: true,
};
}

/**
* makes sure a URL is absolute
*/
export function urlToAbsoluteUrl(url: string): string {
// Trying to make a URL instance only works with absolute URL or when a base is provided
try {
const u = new URL(url);
return u.href;
} catch (e) {
// nothing to raise
}

// Absolute URL did not work, we are building it using the current domain
const u = new URL(url, location.origin);
return u.href;
}

type StyleValidationReport = {
isValidJSON: boolean;
isValidStyle: boolean;
styleObject: maplibregl.StyleSpecification | null;
};

export function convertToStyleSpecificationString(str: string): StyleValidationReport {
try {
const styleObj = JSON.parse(str);
const styleErrs = validateStyleMin(styleObj);

return {
isValidJSON: true,
isValidStyle: styleErrs.length === 0,
styleObject: styleErrs.length === 0 ? (styleObj as maplibregl.StyleSpecification) : null,
};
} catch (e) {
return {
isValidJSON: false,
isValidStyle: false,
styleObject: null,
};
}
}
4 changes: 3 additions & 1 deletion src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ export function maptilerCloudTransformRequest(url: string, resourceType?: Resour
}
}

const localCacheTransformedReq = localCacheTransformRequest(reqUrl, resourceType);

return {
url: localCacheTransformRequest(reqUrl, resourceType),
url: localCacheTransformedReq,
};
}

Expand Down

0 comments on commit b2d083c

Please sign in to comment.