@tellegen/svelte 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -0
- package/dist/Sparkline.svelte +2 -2
- package/dist/TellegenMap.svelte +239 -66
- package/dist/TellegenMap.svelte.d.ts +45 -0
- package/dist/TellegenProvider.svelte +36 -4
- package/dist/TellegenShell.svelte +14 -42
- package/dist/TellegenViewer.svelte +12 -2
- package/dist/api.d.ts +10 -2
- package/dist/api.js +2 -0
- package/dist/basemap.d.ts +192 -0
- package/dist/basemap.js +259 -0
- package/dist/colors.d.ts +55 -1
- package/dist/colors.js +89 -0
- package/dist/components/AppFooter.svelte +3 -2
- package/dist/components/AppHeader.svelte +21 -8
- package/dist/components/BindingLines.svelte +1 -1
- package/dist/components/BusPicker.svelte +3 -3
- package/dist/components/ControlPanel.svelte +4 -2
- package/dist/components/DemandSlider.svelte +16 -7
- package/dist/components/DiagramCanvas.svelte +17 -5
- package/dist/components/DisplayControls.svelte +5 -1
- package/dist/components/DropZone.svelte +1 -1
- package/dist/components/FormulationSelector.svelte +1 -1
- package/dist/components/LocalCaseDetails.svelte +6 -3
- package/dist/components/McResults.svelte +98 -35
- package/dist/components/McResults.svelte.d.ts +8 -2
- package/dist/components/ModelDetails.svelte +5 -5
- package/dist/components/MulticonductorDetails.svelte +9 -9
- package/dist/components/NotificationCenter.svelte +4 -2
- package/dist/components/PanelCard.svelte +72 -7
- package/dist/components/PanelHost.svelte +59 -34
- package/dist/components/PanelHost.svelte.d.ts +4 -1
- package/dist/components/PlacementCue.svelte +1 -1
- package/dist/components/PowerFlowDetails.svelte +1 -1
- package/dist/components/RatingSlider.svelte +14 -5
- package/dist/components/RestoreDefaultsButton.svelte +1 -1
- package/dist/components/SavedNetworkDetails.svelte +3 -3
- package/dist/components/SensitivityReadout.svelte +2 -2
- package/dist/components/ShellEnvironment.svelte +51 -0
- package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
- package/dist/components/SizeLegend.svelte +2 -2
- package/dist/components/SolveCard.svelte +3 -3
- package/dist/components/TopMovers.svelte +1 -1
- package/dist/components.d.ts +3 -0
- package/dist/components.js +3 -0
- package/dist/context.svelte.d.ts +8 -1
- package/dist/context.svelte.js +8 -3
- package/dist/controller.svelte.d.ts +65 -5
- package/dist/controller.svelte.js +266 -78
- package/dist/display.d.ts +3 -2
- package/dist/display.js +22 -4
- package/dist/format.d.ts +9 -0
- package/dist/format.js +13 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -0
- package/dist/map.d.ts +4 -2
- package/dist/map.js +2 -1
- package/dist/maplibre-worker.d.ts +5 -0
- package/dist/maplibre-worker.dev.d.ts +11 -0
- package/dist/maplibre-worker.dev.js +15 -0
- package/dist/maplibre-worker.js +8 -0
- package/dist/notices.svelte.js +5 -1
- package/dist/panels.svelte.d.ts +6 -0
- package/dist/panels.svelte.js +31 -1
- package/dist/slider.d.ts +12 -0
- package/dist/slider.js +31 -0
- package/dist/state.svelte.d.ts +25 -3
- package/dist/state.svelte.js +13 -0
- package/dist/styles.css +59 -6
- package/dist/types.d.ts +1 -1
- package/package.json +13 -8
|
@@ -16,8 +16,17 @@
|
|
|
16
16
|
children?: Snippet;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
let {
|
|
20
|
-
|
|
19
|
+
let {
|
|
20
|
+
apiBase,
|
|
21
|
+
loadDefaultCases,
|
|
22
|
+
docsHref,
|
|
23
|
+
orgHref,
|
|
24
|
+
orgLabel,
|
|
25
|
+
showFooter,
|
|
26
|
+
theme,
|
|
27
|
+
cartoApiKey,
|
|
28
|
+
children
|
|
29
|
+
}: Props = $props();
|
|
21
30
|
|
|
22
31
|
const config = untrack(() =>
|
|
23
32
|
resolveTellegenUiConfig({
|
|
@@ -26,17 +35,40 @@
|
|
|
26
35
|
docsHref,
|
|
27
36
|
orgHref,
|
|
28
37
|
orgLabel,
|
|
29
|
-
showFooter
|
|
38
|
+
showFooter,
|
|
39
|
+
theme,
|
|
40
|
+
cartoApiKey
|
|
30
41
|
})
|
|
31
42
|
);
|
|
32
43
|
const app = createAppState();
|
|
33
|
-
const ctrl = createController(app, {
|
|
44
|
+
const ctrl = createController(app, {
|
|
45
|
+
apiBase: config.apiBase,
|
|
46
|
+
loadDefaultCases: config.loadDefaultCases
|
|
47
|
+
});
|
|
48
|
+
// A host that opens its own cases never calls load(), so the shell must not
|
|
49
|
+
// sit in its loading state waiting for a fetch that will not come.
|
|
50
|
+
if (!config.loadDefaultCases) ctrl.casesLoaded = true;
|
|
34
51
|
|
|
35
52
|
setPanelLayout(new PanelLayout());
|
|
36
53
|
setNoticeCenter(new NoticeCenter());
|
|
37
54
|
setUiConfig(config);
|
|
38
55
|
setAppState(app);
|
|
39
56
|
setController(ctrl);
|
|
57
|
+
|
|
58
|
+
// The dark tokens in styles.css hang off `:root[data-theme='dark']`. Set it
|
|
59
|
+
// only for dark, and only while mounted, so a light host is untouched. A
|
|
60
|
+
// server-rendered host that wants no light flash can also put the
|
|
61
|
+
// attribute in its app template.
|
|
62
|
+
$effect(() => {
|
|
63
|
+
if (config.theme !== 'dark') return;
|
|
64
|
+
const root = document.documentElement;
|
|
65
|
+
const previous = root.dataset.theme;
|
|
66
|
+
root.dataset.theme = 'dark';
|
|
67
|
+
return () => {
|
|
68
|
+
if (previous === undefined) delete root.dataset.theme;
|
|
69
|
+
else root.dataset.theme = previous;
|
|
70
|
+
};
|
|
71
|
+
});
|
|
40
72
|
</script>
|
|
41
73
|
|
|
42
74
|
{@render children?.()}
|
|
@@ -9,23 +9,22 @@
|
|
|
9
9
|
import RestoreDefaultsButton from './components/RestoreDefaultsButton.svelte';
|
|
10
10
|
import SolveCard from './components/SolveCard.svelte';
|
|
11
11
|
import PanelHost from './components/PanelHost.svelte';
|
|
12
|
-
import
|
|
12
|
+
import ShellEnvironment from './components/ShellEnvironment.svelte';
|
|
13
13
|
import { getAppState, getController, getUiConfig } from './context.svelte.js';
|
|
14
14
|
import TellegenMap from './TellegenMap.svelte';
|
|
15
15
|
|
|
16
|
-
// Gates the drag-and-drop affordance only. The upload button in the header
|
|
17
|
-
// is always rendered: a file picker works on every device, and the plain
|
|
18
|
-
// `hover`/`pointer` queries resolve false on a touchscreen laptop that also
|
|
19
|
-
// has a mouse, which hid the whole file flow there (#110). `any-*` match
|
|
20
|
-
// when at least one attached input can hover and point precisely.
|
|
21
|
-
const FILE_DROP_QUERY = '(any-hover: hover) and (any-pointer: fine)';
|
|
22
|
-
|
|
23
16
|
const app = getAppState();
|
|
24
17
|
const ctrl = getController();
|
|
25
18
|
const config = getUiConfig();
|
|
26
19
|
|
|
27
20
|
let dragDepth = 0;
|
|
28
21
|
|
|
22
|
+
// ShellEnvironment drops the affordance when no fine pointer is attached;
|
|
23
|
+
// forget any drag that was in flight so the next one counts from zero.
|
|
24
|
+
$effect(() => {
|
|
25
|
+
if (!ctrl.showFileDropUi) dragDepth = 0;
|
|
26
|
+
});
|
|
27
|
+
|
|
29
28
|
$effect(() => {
|
|
30
29
|
if (app.studyView) return;
|
|
31
30
|
let hasDisplayMode = false;
|
|
@@ -41,44 +40,13 @@
|
|
|
41
40
|
});
|
|
42
41
|
|
|
43
42
|
onMount(() => {
|
|
43
|
+
// The provider already marked the shell loaded when default cases are
|
|
44
|
+
// off, and load() itself is gated; this only frames whatever is there.
|
|
44
45
|
if (config.loadDefaultCases) {
|
|
45
46
|
if (!ctrl.casesLoaded) void ctrl.load();
|
|
46
47
|
} else {
|
|
47
|
-
|
|
48
|
-
app.requestFrame('all');
|
|
48
|
+
void app.requestFrame('all');
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
const query = window.matchMedia(FILE_DROP_QUERY);
|
|
52
|
-
const syncFileDropUi = () => {
|
|
53
|
-
ctrl.showFileDropUi = query.matches;
|
|
54
|
-
if (!ctrl.showFileDropUi) {
|
|
55
|
-
dragDepth = 0;
|
|
56
|
-
app.dragOver = false;
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
syncFileDropUi();
|
|
60
|
-
query.addEventListener('change', syncFileDropUi);
|
|
61
|
-
|
|
62
|
-
const compact = window.matchMedia(PANEL_COMPACT_QUERY);
|
|
63
|
-
const syncCompact = () => {
|
|
64
|
-
app.compactLayout = compact.matches;
|
|
65
|
-
if (!compact.matches) app.sheetInset = 0;
|
|
66
|
-
};
|
|
67
|
-
syncCompact();
|
|
68
|
-
compact.addEventListener('change', syncCompact);
|
|
69
|
-
|
|
70
|
-
// Shared viewport measurements keep panel and map framing dimensions aligned.
|
|
71
|
-
const syncViewport = () => (app.viewportHeight = window.innerHeight);
|
|
72
|
-
syncViewport();
|
|
73
|
-
window.addEventListener('resize', syncViewport);
|
|
74
|
-
window.addEventListener('orientationchange', syncViewport);
|
|
75
|
-
|
|
76
|
-
return () => {
|
|
77
|
-
query.removeEventListener('change', syncFileDropUi);
|
|
78
|
-
compact.removeEventListener('change', syncCompact);
|
|
79
|
-
window.removeEventListener('resize', syncViewport);
|
|
80
|
-
window.removeEventListener('orientationchange', syncViewport);
|
|
81
|
-
};
|
|
82
50
|
});
|
|
83
51
|
|
|
84
52
|
function dragHasFiles(e: DragEvent): boolean {
|
|
@@ -122,6 +90,8 @@
|
|
|
122
90
|
ondrop={onDrop}
|
|
123
91
|
/>
|
|
124
92
|
|
|
93
|
+
<ShellEnvironment />
|
|
94
|
+
|
|
125
95
|
<main>
|
|
126
96
|
<TellegenMap
|
|
127
97
|
onbusclick={ctrl.selectBus}
|
|
@@ -132,6 +102,8 @@
|
|
|
132
102
|
onmultiedgeclick={ctrl.selectMultiEdge}
|
|
133
103
|
onplacecase={ctrl.placeCase}
|
|
134
104
|
onmapclick={ctrl.clearSelection}
|
|
105
|
+
theme={config.theme}
|
|
106
|
+
cartoApiKey={config.cartoApiKey || undefined}
|
|
135
107
|
/>
|
|
136
108
|
|
|
137
109
|
<AppHeader />
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
import TellegenShell from './TellegenShell.svelte';
|
|
4
4
|
import type { TellegenUiOptions } from './context.svelte.js';
|
|
5
5
|
|
|
6
|
-
let {
|
|
7
|
-
|
|
6
|
+
let {
|
|
7
|
+
apiBase,
|
|
8
|
+
loadDefaultCases,
|
|
9
|
+
docsHref,
|
|
10
|
+
orgHref,
|
|
11
|
+
orgLabel,
|
|
12
|
+
showFooter,
|
|
13
|
+
theme,
|
|
14
|
+
cartoApiKey
|
|
15
|
+
}: TellegenUiOptions = $props();
|
|
8
16
|
</script>
|
|
9
17
|
|
|
10
18
|
<TellegenProvider
|
|
@@ -14,6 +22,8 @@
|
|
|
14
22
|
{orgHref}
|
|
15
23
|
{orgLabel}
|
|
16
24
|
{showFooter}
|
|
25
|
+
{theme}
|
|
26
|
+
{cartoApiKey}
|
|
17
27
|
>
|
|
18
28
|
<TellegenShell />
|
|
19
29
|
</TellegenProvider>
|
package/dist/api.d.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { CaseSummary, DemandDeltas, Network, SensitivityColumn, Solution, S
|
|
|
2
2
|
export type { BranchRatingDeltas, CaseSummary, DemandDeltas, Network, NetworkBranch, NetworkBus, SensitivityColumn, Solution, SolveIteration } from '@tellegen/engine';
|
|
3
3
|
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
4
4
|
export interface TellegenApiClientOptions {
|
|
5
|
-
|
|
5
|
+
/** Backend base path, default `/api`. `null` means no backend: the
|
|
6
|
+
* factory returns `null` instead of a client. */
|
|
7
|
+
apiBase?: string | null;
|
|
6
8
|
fetch?: FetchLike;
|
|
7
9
|
EventSource?: typeof EventSource;
|
|
8
10
|
}
|
|
@@ -47,4 +49,10 @@ export interface SolveStreamHandlers {
|
|
|
47
49
|
onfail?: (message: string) => void;
|
|
48
50
|
ondone?: () => void;
|
|
49
51
|
}
|
|
50
|
-
export declare function createApiClient(options
|
|
52
|
+
export declare function createApiClient(options: TellegenApiClientOptions & {
|
|
53
|
+
apiBase: null;
|
|
54
|
+
}): null;
|
|
55
|
+
export declare function createApiClient(options?: TellegenApiClientOptions & {
|
|
56
|
+
apiBase?: string;
|
|
57
|
+
}): TellegenApiClient;
|
|
58
|
+
export declare function createApiClient(options?: TellegenApiClientOptions): TellegenApiClient | null;
|
package/dist/api.js
CHANGED
|
@@ -72,6 +72,8 @@ async function getJson(fetchImpl, url, signal) {
|
|
|
72
72
|
return res.json();
|
|
73
73
|
}
|
|
74
74
|
export function createApiClient(options = {}) {
|
|
75
|
+
if (options.apiBase === null)
|
|
76
|
+
return null;
|
|
75
77
|
const apiBase = options.apiBase ?? '/api';
|
|
76
78
|
const fetchImpl = options.fetch ?? defaultFetch();
|
|
77
79
|
return {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { StyleSpecification } from 'maplibre-gl';
|
|
2
|
+
/** Basemap selection, fallback and tuning for `TellegenMap`.
|
|
3
|
+
*
|
|
4
|
+
* CARTO's vector styles sit quietly under a network overlay, and CARTO's
|
|
5
|
+
* basemap terms ask for an API key (free for non-commercial use, from
|
|
6
|
+
* carto.com/basemaps/apikey). With a key, the CARTO style leads and
|
|
7
|
+
* OpenFreeMap (no key, CORS open) is the fallback. Without one, OpenFreeMap
|
|
8
|
+
* leads and the keyless CARTO style is the fallback. Whenever an OpenFreeMap
|
|
9
|
+
* style renders, `openFreeMapEdits` tunes it towards CARTO's look. A host can
|
|
10
|
+
* replace either style with its own URL or inline `StyleSpecification`, or
|
|
11
|
+
* disable the fallback with `null`. */
|
|
12
|
+
export type BasemapTheme = 'light' | 'dark';
|
|
13
|
+
export type BasemapStyle = string | StyleSpecification;
|
|
14
|
+
/** `TellegenMap`'s `theme` prop: the basemap theme, which also picks the
|
|
15
|
+
* overlay palette. */
|
|
16
|
+
export type MapTheme = BasemapTheme;
|
|
17
|
+
/** `TellegenMap`'s `projection` prop. MapLibre's globe falls back to
|
|
18
|
+
* Mercator at high zoom on its own. */
|
|
19
|
+
export type MapProjection = 'mercator' | 'globe';
|
|
20
|
+
/** Who serves a basemap style: one of the two default providers, or
|
|
21
|
+
* `'custom'` for a style the host supplied. */
|
|
22
|
+
export type BasemapProvider = 'carto' | 'openfreemap' | 'custom';
|
|
23
|
+
export interface BasemapSpec {
|
|
24
|
+
primary: BasemapStyle;
|
|
25
|
+
fallback: BasemapStyle | null;
|
|
26
|
+
/** Hide the style's `symbol` layers after every `style.load`. */
|
|
27
|
+
hideLabels: boolean;
|
|
28
|
+
/** Who serves `primary`. Absent counts as `'custom'`. */
|
|
29
|
+
primaryProvider?: BasemapProvider;
|
|
30
|
+
/** Who serves `fallback`, `null` with no fallback. Absent counts as
|
|
31
|
+
* `'custom'`. */
|
|
32
|
+
fallbackProvider?: BasemapProvider | null;
|
|
33
|
+
}
|
|
34
|
+
/** A `BasemapSpec` that names both providers, as `defaultBasemap` and
|
|
35
|
+
* `resolveBasemap` return it. A host words its credits and fallback notice
|
|
36
|
+
* from these instead of reading style URLs. */
|
|
37
|
+
export interface ResolvedBasemap extends BasemapSpec {
|
|
38
|
+
primaryProvider: BasemapProvider;
|
|
39
|
+
fallbackProvider: BasemapProvider | null;
|
|
40
|
+
}
|
|
41
|
+
/** What `defaultBasemap` returns: one CARTO and one OpenFreeMap style URL,
|
|
42
|
+
* in the order the key decides. Never `'custom'`, and never without a
|
|
43
|
+
* fallback. */
|
|
44
|
+
export interface DefaultBasemap extends ResolvedBasemap {
|
|
45
|
+
primary: string;
|
|
46
|
+
fallback: string;
|
|
47
|
+
primaryProvider: 'carto' | 'openfreemap';
|
|
48
|
+
fallbackProvider: 'carto' | 'openfreemap';
|
|
49
|
+
}
|
|
50
|
+
export declare const OPENFREEMAP_STYLES: {
|
|
51
|
+
readonly light: "https://tiles.openfreemap.org/styles/positron";
|
|
52
|
+
readonly dark: "https://tiles.openfreemap.org/styles/dark";
|
|
53
|
+
};
|
|
54
|
+
export declare const CARTO_STYLES: {
|
|
55
|
+
readonly light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
|
|
56
|
+
readonly 'light-nolabels': "https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json";
|
|
57
|
+
readonly dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json";
|
|
58
|
+
readonly 'dark-nolabels': "https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json";
|
|
59
|
+
};
|
|
60
|
+
/** A CARTO key with surrounding whitespace removed, or `undefined` when
|
|
61
|
+
* nothing is left. A variable saved with a stray newline or space must not
|
|
62
|
+
* count as a key. */
|
|
63
|
+
export declare function normalizeCartoApiKey(apiKey: string | undefined): string | undefined;
|
|
64
|
+
/** A CARTO style URL with the optional API key appended as `key=`. A blank
|
|
65
|
+
* key appends nothing. */
|
|
66
|
+
export declare function cartoStyleUrl(style: string, apiKey?: string): string;
|
|
67
|
+
export interface DefaultBasemapOptions {
|
|
68
|
+
/** CARTO basemap key, sent as `key=`. With one, CARTO leads and
|
|
69
|
+
* OpenFreeMap is the fallback; without one, the order flips. The key is
|
|
70
|
+
* trimmed, and a blank one counts as none. Restrict the key to the hosts
|
|
71
|
+
* that serve your app. */
|
|
72
|
+
cartoApiKey?: string;
|
|
73
|
+
/** Default `true`: the CARTO style uses its `-nolabels` variant, and label
|
|
74
|
+
* layers are hidden on load whichever style renders. */
|
|
75
|
+
hideLabels?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** The default primary and fallback for a theme, ordered by whether a CARTO
|
|
78
|
+
* key is at hand. */
|
|
79
|
+
export declare function defaultBasemap(theme: BasemapTheme, options?: DefaultBasemapOptions): DefaultBasemap;
|
|
80
|
+
export interface ResolveBasemapInput {
|
|
81
|
+
/** Overrides the default primary. */
|
|
82
|
+
mapStyle?: BasemapStyle;
|
|
83
|
+
/** Overrides the default fallback; `null` disables the fallback. */
|
|
84
|
+
fallbackMapStyle?: BasemapStyle | null;
|
|
85
|
+
theme: BasemapTheme;
|
|
86
|
+
hideBasemapLabels: boolean;
|
|
87
|
+
cartoApiKey?: string;
|
|
88
|
+
}
|
|
89
|
+
/** The spec `TellegenMap` renders from its props: explicit styles win, the
|
|
90
|
+
* theme fills in whatever is left, and `null` turns the fallback off. An
|
|
91
|
+
* explicit style is `'custom'`, whichever host serves it. */
|
|
92
|
+
export declare function resolveBasemap(input: ResolveBasemapInput): ResolvedBasemap;
|
|
93
|
+
/** Whether a style draws on OpenFreeMap's tiles, whichever URL loaded it. */
|
|
94
|
+
export declare function isOpenFreeMapStyle(style: StyleSpecification): boolean;
|
|
95
|
+
/** One change to a loaded style, applied through the map's own API. */
|
|
96
|
+
export type StyleEdit = {
|
|
97
|
+
kind: 'zoom-range';
|
|
98
|
+
layer: string;
|
|
99
|
+
minzoom: number;
|
|
100
|
+
maxzoom: number;
|
|
101
|
+
} | {
|
|
102
|
+
kind: 'paint';
|
|
103
|
+
layer: string;
|
|
104
|
+
property: 'fill-color' | 'line-color';
|
|
105
|
+
value: string;
|
|
106
|
+
};
|
|
107
|
+
/** Edits that let an OpenFreeMap style sit under a network overlay the way
|
|
108
|
+
* CARTO's styles do.
|
|
109
|
+
*
|
|
110
|
+
* - Road lines (`transportation`) start at z8.
|
|
111
|
+
* - In the dark theme, water and waterways take Dark Matter's colours, and
|
|
112
|
+
* plain land fills (`landuse`, `landcover`, `park`) take the style's
|
|
113
|
+
* background colour, so the water stands out against one tone of land.
|
|
114
|
+
*
|
|
115
|
+
* Layers are matched by `source-layer`, not by id, so the edits still apply
|
|
116
|
+
* when OpenFreeMap renames its layers. */
|
|
117
|
+
export declare function openFreeMapEdits(style: StyleSpecification, theme: BasemapTheme): StyleEdit[];
|
|
118
|
+
/** The providers a fallback event concerns. */
|
|
119
|
+
export interface BasemapSwitch {
|
|
120
|
+
/** The provider whose style failed. */
|
|
121
|
+
from: BasemapProvider;
|
|
122
|
+
/** The provider switched to; `null` when no basemap is left to show. */
|
|
123
|
+
to: BasemapProvider | null;
|
|
124
|
+
}
|
|
125
|
+
type BasemapFailure = {
|
|
126
|
+
kind: 'style-load';
|
|
127
|
+
message: string;
|
|
128
|
+
status?: number;
|
|
129
|
+
} | {
|
|
130
|
+
kind: 'source-forbidden';
|
|
131
|
+
message: string;
|
|
132
|
+
status: number;
|
|
133
|
+
sourceId: string;
|
|
134
|
+
} | {
|
|
135
|
+
kind: 'tiles-unavailable';
|
|
136
|
+
message: string;
|
|
137
|
+
sourceId: string;
|
|
138
|
+
} | {
|
|
139
|
+
kind: 'webgl';
|
|
140
|
+
message: string;
|
|
141
|
+
};
|
|
142
|
+
export type BasemapFallbackReason = BasemapFailure & BasemapSwitch;
|
|
143
|
+
/** The fields of a MapLibre `error` or `sourcedata` event the fallback reads. */
|
|
144
|
+
export interface BasemapMapEvent {
|
|
145
|
+
error?: unknown;
|
|
146
|
+
sourceId?: string;
|
|
147
|
+
tile?: unknown;
|
|
148
|
+
dataType?: string;
|
|
149
|
+
isSourceLoaded?: boolean;
|
|
150
|
+
}
|
|
151
|
+
/** The slice of `maplibregl.Map` the fallback needs; a test drives a fake. */
|
|
152
|
+
export interface BasemapMapLike {
|
|
153
|
+
on(type: string, listener: (event: BasemapMapEvent) => void): unknown;
|
|
154
|
+
off(type: string, listener: (event: BasemapMapEvent) => void): unknown;
|
|
155
|
+
setStyle(style: BasemapStyle, options?: {
|
|
156
|
+
diff?: boolean;
|
|
157
|
+
}): unknown;
|
|
158
|
+
/** MapLibre types this `boolean | void`; only `true` counts as loaded. */
|
|
159
|
+
isStyleLoaded(): boolean | void;
|
|
160
|
+
}
|
|
161
|
+
export interface BasemapFallbackHandlers {
|
|
162
|
+
/** The primary failed and the fallback style was requested. `from` is the
|
|
163
|
+
* spec's primary provider and `to` its fallback provider. */
|
|
164
|
+
onFallback?(reason: BasemapFallbackReason): void;
|
|
165
|
+
/** No basemap can be shown: the fallback failed too, none was configured,
|
|
166
|
+
* or WebGL is unavailable. `from` is the provider on screen, `to` is
|
|
167
|
+
* `null`. */
|
|
168
|
+
onFailure?(reason: BasemapFallbackReason): void;
|
|
169
|
+
}
|
|
170
|
+
export interface BasemapFallbackOptions {
|
|
171
|
+
/** Clock for the tile failure window; tests inject one. */
|
|
172
|
+
now?: () => number;
|
|
173
|
+
}
|
|
174
|
+
/** Watch a map's `error` stream and switch to `spec.fallback` when the
|
|
175
|
+
* primary basemap cannot be shown. Rules, in order:
|
|
176
|
+
*
|
|
177
|
+
* 1. an error with no `sourceId` before the first `style.load` is a style
|
|
178
|
+
* fetch failure: switch to the fallback once;
|
|
179
|
+
* 2. a source or tile error with status 401, 403 or 429, or a message naming
|
|
180
|
+
* an API key, forbidden or unauthorized access: switch;
|
|
181
|
+
* 3. more than six tile fetch failures within ten seconds while no tile of
|
|
182
|
+
* any source has arrived: switch. A failure is a network error with no
|
|
183
|
+
* status, or an HTTP 5xx answer;
|
|
184
|
+
* 4. a WebGL error: report failure, no style can help;
|
|
185
|
+
* 5. everything else is logged the way MapLibre logs an unhandled error,
|
|
186
|
+
* because registering this listener silences that default.
|
|
187
|
+
*
|
|
188
|
+
* After the switch the same rules report `onFailure` instead of switching
|
|
189
|
+
* again, as they do when `spec.fallback` is `null`. Returns a detach
|
|
190
|
+
* function. */
|
|
191
|
+
export declare function attachBasemapFallback(map: BasemapMapLike, spec: BasemapSpec, handlers?: BasemapFallbackHandlers, options?: BasemapFallbackOptions): () => void;
|
|
192
|
+
export {};
|
package/dist/basemap.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
export const OPENFREEMAP_STYLES = {
|
|
2
|
+
light: 'https://tiles.openfreemap.org/styles/positron',
|
|
3
|
+
dark: 'https://tiles.openfreemap.org/styles/dark'
|
|
4
|
+
};
|
|
5
|
+
export const CARTO_STYLES = {
|
|
6
|
+
light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
|
7
|
+
'light-nolabels': 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json',
|
|
8
|
+
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
|
|
9
|
+
'dark-nolabels': 'https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json'
|
|
10
|
+
};
|
|
11
|
+
/** A CARTO key with surrounding whitespace removed, or `undefined` when
|
|
12
|
+
* nothing is left. A variable saved with a stray newline or space must not
|
|
13
|
+
* count as a key. */
|
|
14
|
+
export function normalizeCartoApiKey(apiKey) {
|
|
15
|
+
return apiKey?.trim() || undefined;
|
|
16
|
+
}
|
|
17
|
+
/** A CARTO style URL with the optional API key appended as `key=`. A blank
|
|
18
|
+
* key appends nothing. */
|
|
19
|
+
export function cartoStyleUrl(style, apiKey) {
|
|
20
|
+
const key = normalizeCartoApiKey(apiKey);
|
|
21
|
+
if (!key)
|
|
22
|
+
return style;
|
|
23
|
+
const separator = style.includes('?') ? '&' : '?';
|
|
24
|
+
return `${style}${separator}key=${encodeURIComponent(key)}`;
|
|
25
|
+
}
|
|
26
|
+
/** The default primary and fallback for a theme, ordered by whether a CARTO
|
|
27
|
+
* key is at hand. */
|
|
28
|
+
export function defaultBasemap(theme, options = {}) {
|
|
29
|
+
const hideLabels = options.hideLabels ?? true;
|
|
30
|
+
const variant = hideLabels ? `${theme}-nolabels` : theme;
|
|
31
|
+
const key = normalizeCartoApiKey(options.cartoApiKey);
|
|
32
|
+
const carto = cartoStyleUrl(CARTO_STYLES[variant], key);
|
|
33
|
+
const openFreeMap = OPENFREEMAP_STYLES[theme];
|
|
34
|
+
return key
|
|
35
|
+
? {
|
|
36
|
+
primary: carto,
|
|
37
|
+
fallback: openFreeMap,
|
|
38
|
+
hideLabels,
|
|
39
|
+
primaryProvider: 'carto',
|
|
40
|
+
fallbackProvider: 'openfreemap'
|
|
41
|
+
}
|
|
42
|
+
: {
|
|
43
|
+
primary: openFreeMap,
|
|
44
|
+
fallback: carto,
|
|
45
|
+
hideLabels,
|
|
46
|
+
primaryProvider: 'openfreemap',
|
|
47
|
+
fallbackProvider: 'carto'
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** The spec `TellegenMap` renders from its props: explicit styles win, the
|
|
51
|
+
* theme fills in whatever is left, and `null` turns the fallback off. An
|
|
52
|
+
* explicit style is `'custom'`, whichever host serves it. */
|
|
53
|
+
export function resolveBasemap(input) {
|
|
54
|
+
const defaults = defaultBasemap(input.theme, {
|
|
55
|
+
cartoApiKey: input.cartoApiKey,
|
|
56
|
+
hideLabels: input.hideBasemapLabels
|
|
57
|
+
});
|
|
58
|
+
const fallback = input.fallbackMapStyle;
|
|
59
|
+
return {
|
|
60
|
+
primary: input.mapStyle ?? defaults.primary,
|
|
61
|
+
fallback: fallback === undefined ? defaults.fallback : fallback,
|
|
62
|
+
hideLabels: input.hideBasemapLabels,
|
|
63
|
+
primaryProvider: input.mapStyle === undefined ? defaults.primaryProvider : 'custom',
|
|
64
|
+
fallbackProvider: fallback === undefined ? defaults.fallbackProvider : fallback === null ? null : 'custom'
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const OPENFREEMAP_TILES = 'https://tiles.openfreemap.org/';
|
|
68
|
+
/** Road lines start here on an OpenFreeMap style. Below it, the road web
|
|
69
|
+
* reads like grid lines under a network overlay. */
|
|
70
|
+
const ROAD_MIN_ZOOM = 8;
|
|
71
|
+
/** CARTO Dark Matter's water and waterway colours, which stand clear of
|
|
72
|
+
* near-black land. */
|
|
73
|
+
const DARK_WATER = '#2c353c';
|
|
74
|
+
const DARK_WATERWAY = '#3f5a6d';
|
|
75
|
+
const LAND_SOURCE_LAYERS = new Set(['landuse', 'landcover', 'park']);
|
|
76
|
+
/** Whether a style draws on OpenFreeMap's tiles, whichever URL loaded it. */
|
|
77
|
+
export function isOpenFreeMapStyle(style) {
|
|
78
|
+
return Object.values(style.sources ?? {}).some((source) => {
|
|
79
|
+
const urls = [
|
|
80
|
+
'url' in source ? source.url : undefined,
|
|
81
|
+
...('tiles' in source ? (source.tiles ?? []) : [])
|
|
82
|
+
];
|
|
83
|
+
return urls.some((url) => typeof url === 'string' && url.startsWith(OPENFREEMAP_TILES));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** Edits that let an OpenFreeMap style sit under a network overlay the way
|
|
87
|
+
* CARTO's styles do.
|
|
88
|
+
*
|
|
89
|
+
* - Road lines (`transportation`) start at z8.
|
|
90
|
+
* - In the dark theme, water and waterways take Dark Matter's colours, and
|
|
91
|
+
* plain land fills (`landuse`, `landcover`, `park`) take the style's
|
|
92
|
+
* background colour, so the water stands out against one tone of land.
|
|
93
|
+
*
|
|
94
|
+
* Layers are matched by `source-layer`, not by id, so the edits still apply
|
|
95
|
+
* when OpenFreeMap renames its layers. */
|
|
96
|
+
export function openFreeMapEdits(style, theme) {
|
|
97
|
+
const paintOf = (layer) => (layer.paint ?? {});
|
|
98
|
+
const background = style.layers.find((layer) => layer.type === 'background');
|
|
99
|
+
const backgroundColor = background ? paintOf(background)['background-color'] : undefined;
|
|
100
|
+
// A plain colour only; an expression is left alone rather than guessed at.
|
|
101
|
+
const land = typeof backgroundColor === 'string' ? backgroundColor : undefined;
|
|
102
|
+
const edits = [];
|
|
103
|
+
for (const layer of style.layers) {
|
|
104
|
+
const sourceLayer = 'source-layer' in layer ? layer['source-layer'] : undefined;
|
|
105
|
+
if (sourceLayer === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
if (sourceLayer === 'transportation' && layer.type === 'line') {
|
|
108
|
+
if ((layer.minzoom ?? 0) < ROAD_MIN_ZOOM) {
|
|
109
|
+
edits.push({
|
|
110
|
+
kind: 'zoom-range',
|
|
111
|
+
layer: layer.id,
|
|
112
|
+
minzoom: ROAD_MIN_ZOOM,
|
|
113
|
+
maxzoom: layer.maxzoom ?? 24
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else if (theme === 'dark' && sourceLayer === 'water' && layer.type === 'fill') {
|
|
118
|
+
edits.push({ kind: 'paint', layer: layer.id, property: 'fill-color', value: DARK_WATER });
|
|
119
|
+
}
|
|
120
|
+
else if (theme === 'dark' && sourceLayer === 'waterway' && layer.type === 'line') {
|
|
121
|
+
edits.push({ kind: 'paint', layer: layer.id, property: 'line-color', value: DARK_WATERWAY });
|
|
122
|
+
}
|
|
123
|
+
else if (theme === 'dark' &&
|
|
124
|
+
land !== undefined &&
|
|
125
|
+
layer.type === 'fill' &&
|
|
126
|
+
LAND_SOURCE_LAYERS.has(sourceLayer) &&
|
|
127
|
+
paintOf(layer)['fill-pattern'] === undefined) {
|
|
128
|
+
edits.push({ kind: 'paint', layer: layer.id, property: 'fill-color', value: land });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return edits;
|
|
132
|
+
}
|
|
133
|
+
/** Tile fetch failures from one source, with no tile of any source arriving,
|
|
134
|
+
* that trip the fallback inside the window. */
|
|
135
|
+
const TILE_FAILURE_LIMIT = 6;
|
|
136
|
+
const TILE_FAILURE_WINDOW_MS = 10_000;
|
|
137
|
+
const FORBIDDEN_STATUSES = new Set([401, 403, 429]);
|
|
138
|
+
const FORBIDDEN_MESSAGE = /api key|forbidden|unauthorized/i;
|
|
139
|
+
const NETWORK_MESSAGE = /failed to fetch|load failed|network ?error|network request failed/i;
|
|
140
|
+
const WEBGL_MESSAGE = /webgl/i;
|
|
141
|
+
function errorMessage(error) {
|
|
142
|
+
if (error instanceof Error)
|
|
143
|
+
return error.message;
|
|
144
|
+
if (typeof error === 'string')
|
|
145
|
+
return error;
|
|
146
|
+
if (error && typeof error === 'object' && 'message' in error)
|
|
147
|
+
return String(error.message);
|
|
148
|
+
return error === undefined || error === null ? '' : String(error);
|
|
149
|
+
}
|
|
150
|
+
function errorStatus(error) {
|
|
151
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
152
|
+
const status = error.status;
|
|
153
|
+
if (typeof status === 'number')
|
|
154
|
+
return status;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/** Watch a map's `error` stream and switch to `spec.fallback` when the
|
|
159
|
+
* primary basemap cannot be shown. Rules, in order:
|
|
160
|
+
*
|
|
161
|
+
* 1. an error with no `sourceId` before the first `style.load` is a style
|
|
162
|
+
* fetch failure: switch to the fallback once;
|
|
163
|
+
* 2. a source or tile error with status 401, 403 or 429, or a message naming
|
|
164
|
+
* an API key, forbidden or unauthorized access: switch;
|
|
165
|
+
* 3. more than six tile fetch failures within ten seconds while no tile of
|
|
166
|
+
* any source has arrived: switch. A failure is a network error with no
|
|
167
|
+
* status, or an HTTP 5xx answer;
|
|
168
|
+
* 4. a WebGL error: report failure, no style can help;
|
|
169
|
+
* 5. everything else is logged the way MapLibre logs an unhandled error,
|
|
170
|
+
* because registering this listener silences that default.
|
|
171
|
+
*
|
|
172
|
+
* After the switch the same rules report `onFailure` instead of switching
|
|
173
|
+
* again, as they do when `spec.fallback` is `null`. Returns a detach
|
|
174
|
+
* function. */
|
|
175
|
+
export function attachBasemapFallback(map, spec, handlers = {}, options = {}) {
|
|
176
|
+
const now = options.now ?? (() => Date.now());
|
|
177
|
+
const primaryProvider = spec.primaryProvider ?? 'custom';
|
|
178
|
+
const fallbackProvider = spec.fallback === null ? null : (spec.fallbackProvider ?? 'custom');
|
|
179
|
+
let styleLoaded = map.isStyleLoaded() === true;
|
|
180
|
+
let fellBack = false;
|
|
181
|
+
let failed = false;
|
|
182
|
+
let tileFailures = 0;
|
|
183
|
+
let tileWindowStart = 0;
|
|
184
|
+
let tileArrived = false;
|
|
185
|
+
const fail = (reason) => {
|
|
186
|
+
if (failed)
|
|
187
|
+
return;
|
|
188
|
+
failed = true;
|
|
189
|
+
const from = fellBack && fallbackProvider !== null ? fallbackProvider : primaryProvider;
|
|
190
|
+
handlers.onFailure?.({ ...reason, from, to: null });
|
|
191
|
+
};
|
|
192
|
+
const fallBack = (reason) => {
|
|
193
|
+
if (fellBack || spec.fallback === null) {
|
|
194
|
+
fail(reason);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
fellBack = true;
|
|
198
|
+
styleLoaded = false;
|
|
199
|
+
tileFailures = 0;
|
|
200
|
+
tileArrived = false;
|
|
201
|
+
handlers.onFallback?.({ ...reason, from: primaryProvider, to: fallbackProvider });
|
|
202
|
+
map.setStyle(spec.fallback, { diff: false });
|
|
203
|
+
};
|
|
204
|
+
const passThrough = (event) => {
|
|
205
|
+
console.error(event?.error ?? event ?? 'Empty error event');
|
|
206
|
+
};
|
|
207
|
+
const onStyleLoad = () => {
|
|
208
|
+
styleLoaded = true;
|
|
209
|
+
};
|
|
210
|
+
const onSourceData = (event) => {
|
|
211
|
+
if (event?.tile || event?.isSourceLoaded === true)
|
|
212
|
+
tileArrived = true;
|
|
213
|
+
};
|
|
214
|
+
const onError = (event) => {
|
|
215
|
+
const message = errorMessage(event?.error);
|
|
216
|
+
const status = errorStatus(event?.error);
|
|
217
|
+
if (WEBGL_MESSAGE.test(message)) {
|
|
218
|
+
fail({ kind: 'webgl', message });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const sourceId = typeof event?.sourceId === 'string' ? event.sourceId : undefined;
|
|
222
|
+
if (sourceId === undefined) {
|
|
223
|
+
if (!styleLoaded) {
|
|
224
|
+
fallBack({ kind: 'style-load', message, status });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
passThrough(event);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if ((status !== undefined && FORBIDDEN_STATUSES.has(status)) ||
|
|
231
|
+
FORBIDDEN_MESSAGE.test(message)) {
|
|
232
|
+
fallBack({ kind: 'source-forbidden', message, status: status ?? 0, sourceId });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const transient = status === undefined || status === 0 ? NETWORK_MESSAGE.test(message) : status >= 500;
|
|
236
|
+
if (event.tile && transient) {
|
|
237
|
+
const t = now();
|
|
238
|
+
if (tileFailures === 0 || t - tileWindowStart > TILE_FAILURE_WINDOW_MS) {
|
|
239
|
+
tileWindowStart = t;
|
|
240
|
+
tileFailures = 0;
|
|
241
|
+
tileArrived = false;
|
|
242
|
+
}
|
|
243
|
+
tileFailures++;
|
|
244
|
+
if (tileFailures > TILE_FAILURE_LIMIT && !tileArrived) {
|
|
245
|
+
fallBack({ kind: 'tiles-unavailable', message, sourceId });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
passThrough(event);
|
|
250
|
+
};
|
|
251
|
+
map.on('style.load', onStyleLoad);
|
|
252
|
+
map.on('sourcedata', onSourceData);
|
|
253
|
+
map.on('error', onError);
|
|
254
|
+
return () => {
|
|
255
|
+
map.off('style.load', onStyleLoad);
|
|
256
|
+
map.off('sourcedata', onSourceData);
|
|
257
|
+
map.off('error', onError);
|
|
258
|
+
};
|
|
259
|
+
}
|