@tellegen/svelte 0.3.0 → 0.4.0

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.
Files changed (67) hide show
  1. package/README.md +233 -0
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +201 -47
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +36 -4
  6. package/dist/TellegenShell.svelte +14 -42
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +10 -2
  9. package/dist/api.js +2 -0
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +18 -0
  13. package/dist/colors.js +26 -0
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +21 -8
  16. package/dist/components/BindingLines.svelte +1 -1
  17. package/dist/components/BusPicker.svelte +3 -3
  18. package/dist/components/DemandSlider.svelte +16 -7
  19. package/dist/components/DiagramCanvas.svelte +1 -1
  20. package/dist/components/DisplayControls.svelte +5 -1
  21. package/dist/components/DropZone.svelte +1 -1
  22. package/dist/components/FormulationSelector.svelte +1 -1
  23. package/dist/components/LocalCaseDetails.svelte +6 -3
  24. package/dist/components/McResults.svelte +98 -35
  25. package/dist/components/McResults.svelte.d.ts +8 -2
  26. package/dist/components/ModelDetails.svelte +5 -5
  27. package/dist/components/MulticonductorDetails.svelte +9 -9
  28. package/dist/components/NotificationCenter.svelte +4 -2
  29. package/dist/components/PanelCard.svelte +72 -7
  30. package/dist/components/PanelHost.svelte +59 -34
  31. package/dist/components/PanelHost.svelte.d.ts +4 -1
  32. package/dist/components/PlacementCue.svelte +1 -1
  33. package/dist/components/PowerFlowDetails.svelte +1 -1
  34. package/dist/components/RatingSlider.svelte +14 -5
  35. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  36. package/dist/components/SavedNetworkDetails.svelte +3 -3
  37. package/dist/components/SensitivityReadout.svelte +2 -2
  38. package/dist/components/ShellEnvironment.svelte +51 -0
  39. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  40. package/dist/components/SizeLegend.svelte +2 -2
  41. package/dist/components/SolveCard.svelte +3 -3
  42. package/dist/components/TopMovers.svelte +1 -1
  43. package/dist/components.d.ts +3 -0
  44. package/dist/components.js +3 -0
  45. package/dist/context.svelte.d.ts +8 -1
  46. package/dist/context.svelte.js +8 -3
  47. package/dist/controller.svelte.d.ts +65 -5
  48. package/dist/controller.svelte.js +262 -76
  49. package/dist/format.d.ts +9 -0
  50. package/dist/format.js +12 -0
  51. package/dist/index.d.ts +5 -1
  52. package/dist/index.js +2 -0
  53. package/dist/map.d.ts +4 -2
  54. package/dist/map.js +2 -1
  55. package/dist/maplibre-worker.d.ts +5 -0
  56. package/dist/maplibre-worker.dev.d.ts +11 -0
  57. package/dist/maplibre-worker.dev.js +15 -0
  58. package/dist/maplibre-worker.js +8 -0
  59. package/dist/notices.svelte.js +5 -1
  60. package/dist/panels.svelte.d.ts +6 -0
  61. package/dist/panels.svelte.js +31 -1
  62. package/dist/slider.d.ts +12 -0
  63. package/dist/slider.js +31 -0
  64. package/dist/state.svelte.d.ts +19 -2
  65. package/dist/state.svelte.js +12 -0
  66. package/dist/styles.css +59 -6
  67. package/package.json +13 -8
@@ -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 { PANEL_COMPACT_QUERY } from './panels.svelte.js';
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
- ctrl.casesLoaded = true;
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 { apiBase, loadDefaultCases, docsHref, orgHref, orgLabel, showFooter }: TellegenUiOptions =
7
- $props();
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
- apiBase?: string;
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?: TellegenApiClientOptions): TellegenApiClient;
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 {};
@@ -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
+ }
package/dist/colors.d.ts CHANGED
@@ -32,6 +32,24 @@ export declare const sensColor: (t: number) => RGBA;
32
32
  export declare const sensGradient: string;
33
33
  export declare const sensNeutral: RGBA;
34
34
  export declare const busNeutral: RGBA;
35
+ /** Overlay colours that depend on the basemap's lightness: bus outlines,
36
+ * hover highlights, the selected outline, the "not yet solved" grey, and
37
+ * the `.pwd` substation marker. The selection blue is the same in both. */
38
+ export interface MapPalette {
39
+ outline: RGBA;
40
+ /** The multiconductor view's bus outline, a shade stronger. */
41
+ outlineStrong: RGBA;
42
+ highlight: RGBA;
43
+ highlightSoft: RGBA;
44
+ selected: RGBA;
45
+ grey: RGBA;
46
+ greyLine: RGBA;
47
+ substation: RGBA;
48
+ substationLine: RGBA;
49
+ /** Fill of a neutral (non-phase) terminal in the multiconductor detail. */
50
+ neutral: RGBA;
51
+ }
52
+ export declare const MAP_PALETTE: Record<'light' | 'dark', MapPalette>;
35
53
  export declare function sensFlatColor(domain: Pick<SensitivityDomain, 'mean'>): RGBA;
36
54
  /** The fixed normalization for a slider drag: predicted nodal value change divides by
37
55
  * scale × maxAbsStep, so color intensity is linear in the step and saturates at
package/dist/colors.js CHANGED
@@ -78,6 +78,32 @@ export const sensColor = (t) => ramp(SENS_STOPS, (t + 1) / 2);
78
78
  export const sensGradient = cssGradient(SENS_STOPS);
79
79
  export const sensNeutral = SENS_STOPS[2];
80
80
  export const busNeutral = [180, 175, 165, 200];
81
+ export const MAP_PALETTE = {
82
+ light: {
83
+ outline: [46, 42, 34, 110],
84
+ outlineStrong: [46, 42, 34, 120],
85
+ highlight: [32, 36, 43, 90],
86
+ highlightSoft: [32, 36, 43, 70],
87
+ selected: [32, 36, 43, 255],
88
+ grey: [110, 115, 120, 200],
89
+ greyLine: [138, 131, 117, 150],
90
+ substation: [70, 92, 124, 190],
91
+ substationLine: [38, 52, 78, 220],
92
+ neutral: [120, 114, 102, 255]
93
+ },
94
+ dark: {
95
+ outline: [232, 238, 244, 120],
96
+ outlineStrong: [232, 238, 244, 130],
97
+ highlight: [232, 238, 244, 90],
98
+ highlightSoft: [232, 238, 244, 70],
99
+ selected: [255, 255, 255, 255],
100
+ grey: [150, 160, 170, 200],
101
+ greyLine: [150, 160, 170, 150],
102
+ substation: [120, 150, 200, 190],
103
+ substationLine: [180, 205, 235, 220],
104
+ neutral: [150, 160, 170, 255]
105
+ }
106
+ };
81
107
  const FLAT_SENSITIVITY_TINT = 0.55;
82
108
  export function sensFlatColor(domain) {
83
109
  if (domain.mean === 0)