@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
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
2
  import 'maplibre-gl/dist/maplibre-gl.css';
3
- import { tick } from 'svelte';
3
+ import { tick, untrack } from 'svelte';
4
4
  import type { Layer, PickingInfo } from '@deck.gl/core';
5
5
  import type { IconLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
6
6
  import type { MapLibreOverlay } from '@deck.gl/maplibre';
7
7
  import type { LngLatBoundsLike, Map as MapLibreMap } from 'maplibre-gl';
8
8
  import type { NetworkBranch, NetworkBus } from './api.js';
9
9
  import {
10
+ MAP_PALETTE,
10
11
  branchColor,
11
12
  branchWidth,
12
13
  busNeutral,
@@ -18,8 +19,23 @@
18
19
  sensitivityDomain,
19
20
  type SensitivityDomain
20
21
  } from './colors.js';
22
+ import {
23
+ attachBasemapFallback,
24
+ isOpenFreeMapStyle,
25
+ openFreeMapEdits,
26
+ resolveBasemap,
27
+ type BasemapFallbackReason,
28
+ type BasemapStyle,
29
+ type MapProjection,
30
+ type MapTheme
31
+ } from './basemap.js';
21
32
  import { caseDeltas, caseRatings, displayMetaFor, displaySeriesFor } from './display.js';
22
- import { CaseState, type DisplayMode, type LocalCase } from './state.svelte.js';
33
+ import {
34
+ CaseState,
35
+ type CameraSnapshot,
36
+ type DisplayMode,
37
+ type LocalCase
38
+ } from './state.svelte.js';
23
39
  import {
24
40
  attachmentColor,
25
41
  edgeColor,
@@ -40,6 +56,8 @@
40
56
  import type { RGBA } from './colors.js';
41
57
  import DiagramCanvas from './components/DiagramCanvas.svelte';
42
58
 
59
+ const DEFAULT_CAMERA: CameraSnapshot = { center: [-85, 36], zoom: 4.5, bearing: 0, pitch: 0 };
60
+
43
61
  let {
44
62
  onbusclick,
45
63
  onlocalbusclick,
@@ -48,7 +66,19 @@
48
66
  onmultibusclick,
49
67
  onmultiedgeclick,
50
68
  onplacecase,
51
- onmapclick
69
+ onmapclick,
70
+ mapStyle,
71
+ fallbackMapStyle,
72
+ theme = 'light',
73
+ projection = 'mercator',
74
+ initialCamera = DEFAULT_CAMERA,
75
+ hideBasemapLabels = true,
76
+ cartoApiKey,
77
+ extraLayers = [],
78
+ attribution = 'compact',
79
+ onMapReady,
80
+ onBasemapFallback,
81
+ onBasemapError
52
82
  }: {
53
83
  onbusclick: (caseId: string, busId: number) => void;
54
84
  onlocalbusclick: (caseId: string, busId: number) => void;
@@ -58,6 +88,46 @@
58
88
  onmultiedgeclick?: (caseId: string, edgeId: string) => void;
59
89
  onplacecase: (lon: number, lat: number) => void;
60
90
  onmapclick: () => void;
91
+ /** Primary basemap: a style URL or inline style. Default: the CARTO style
92
+ * for `theme` when `cartoApiKey` is set, else the OpenFreeMap style.
93
+ * Changing it (or `fallbackMapStyle`, `theme`, `hideBasemapLabels`,
94
+ * `cartoApiKey`, `attribution`) rebuilds the map in place and keeps the
95
+ * camera. */
96
+ mapStyle?: BasemapStyle;
97
+ /** Basemap used when the primary cannot be shown; `null` disables the
98
+ * switch. Default: whichever of the CARTO and OpenFreeMap styles is not
99
+ * the default primary. */
100
+ fallbackMapStyle?: BasemapStyle | null;
101
+ /** Picks the default basemaps and the overlay palette (bus outlines,
102
+ * hover highlight, unsolved grey). */
103
+ theme?: MapTheme;
104
+ /** Re-applied after every `style.load`, so a basemap switch keeps it. */
105
+ projection?: MapProjection;
106
+ /** Camera for the first paint; read once per mount. */
107
+ initialCamera?: CameraSnapshot;
108
+ /** Hide the basemap's `symbol` layers after every `style.load`. */
109
+ hideBasemapLabels?: boolean;
110
+ /** CARTO basemap key (free for non-commercial use from
111
+ * carto.com/basemaps/apikey; restrict it to your hosts). With one, the
112
+ * CARTO style leads and OpenFreeMap is the fallback; without one, the
113
+ * order flips. Trimmed; a blank key counts as none. */
114
+ cartoApiKey?: string;
115
+ /** deck.gl layers drawn after tellegen's own, in the same overlay. */
116
+ extraLayers?: Layer[];
117
+ /** Attribution control mode. There is no off switch: tile licences
118
+ * require the credit on screen. */
119
+ attribution?: 'compact' | 'full';
120
+ /** Called once the map and overlay exist, with an optional cleanup. It
121
+ * fires again after a context-loss or basemap remount, after the previous
122
+ * cleanup ran. */
123
+ onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
124
+ /** The primary basemap failed and the fallback was requested. The
125
+ * reason's `from` and `to` name the providers (`'carto'`,
126
+ * `'openfreemap'` or `'custom'`). */
127
+ onBasemapFallback?: (reason: BasemapFallbackReason) => void;
128
+ /** No basemap can be shown; the network layers still draw. The reason's
129
+ * `from` names the provider that failed last, and `to` is `null`. */
130
+ onBasemapError?: (reason: BasemapFallbackReason) => void;
61
131
  } = $props();
62
132
 
63
133
  const app = getAppState();
@@ -104,7 +174,20 @@
104
174
  else onbranchclick?.(diagramCaseId, branchId);
105
175
  }
106
176
 
107
- const STYLE = 'https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json';
177
+ const basemap = $derived(
178
+ resolveBasemap({ mapStyle, fallbackMapStyle, theme, hideBasemapLabels, cartoApiKey })
179
+ );
180
+ // Read as separate values, not one object, so a theme change that resolves
181
+ // to the same styles does not rebuild the map.
182
+ const basemapPrimary = $derived(basemap.primary);
183
+ const basemapFallback = $derived(basemap.fallback);
184
+ const basemapHideLabels = $derived(basemap.hideLabels);
185
+ const basemapPrimaryProvider = $derived(basemap.primaryProvider);
186
+ const basemapFallbackProvider = $derived(basemap.fallbackProvider);
187
+ const palette = $derived(MAP_PALETTE[theme]);
188
+ // Set once a map has existed in this component instance: a rebuild (context
189
+ // loss or basemap change) restores the camera instead of reframing.
190
+ let hadMap = false;
108
191
  const CUE_MARGIN_PX = 64;
109
192
  const BRANCH_FOCUS_MS = 850;
110
193
  const BRANCH_FOCUS_MAX_ZOOM = 9.5;
@@ -508,9 +591,9 @@
508
591
  terminals.forEach((t, k) => {
509
592
  const cy = top + k * STACK_DY;
510
593
  const grounded = bus.grounded.includes(t);
511
- const fill = isPhaseTerminal(t) ? col(phaseColor(t)) : col([120, 114, 102, 255]);
594
+ const fill = isPhaseTerminal(t) ? col(phaseColor(t)) : col(palette.neutral);
512
595
  if (grounded) stack += groundGlyph(sx - 14, cy);
513
- stack += `<circle cx="${sx.toFixed(1)}" cy="${cy.toFixed(1)}" r="6" fill="${fill}" stroke="#20242b" stroke-width="1.2"/>`;
596
+ stack += `<circle class="mc-terminal" cx="${sx.toFixed(1)}" cy="${cy.toFixed(1)}" r="6" fill="${fill}"/>`;
514
597
  stack += `<text class="mc-term" x="${(sx + 11).toFixed(1)}" y="${(cy + 3.5).toFixed(1)}">${esc(t)}</text>`;
515
598
  // Attachment squares for elements landing on this terminal.
516
599
  const kinds = [...new Set((bus.terminalAttachments[t] ?? []).map((a) => a.kind))];
@@ -712,14 +795,17 @@
712
795
  }
713
796
 
714
797
  async function loadMapModules() {
715
- const [maplibre, deckMap, layers, worker] = await Promise.all([
798
+ const [maplibre, deckMap, layers, workerUrl] = await Promise.all([
716
799
  import('maplibre-gl'),
717
800
  import('@deck.gl/maplibre'),
718
801
  import('@deck.gl/layers'),
719
- import('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url')
802
+ // A package export, not a relative import: its `development`
803
+ // condition keeps the worker's `?worker&url` import out of the dev
804
+ // server's pre-bundle (see maplibre-worker.dev.ts).
805
+ import('@tellegen/svelte/maplibre-worker').then((m) => m.maplibreWorkerUrl())
720
806
  ]);
721
807
  // Bundle the worker with its shared imports and configure it before creating a map.
722
- maplibre.setWorkerUrl(worker.default);
808
+ maplibre.setWorkerUrl(workerUrl);
723
809
  return {
724
810
  maplibregl: maplibre,
725
811
  MapLibreOverlay: deckMap.MapLibreOverlay,
@@ -759,6 +845,20 @@
759
845
  }
760
846
 
761
847
  function initMap(container: HTMLDivElement) {
848
+ // Attachments re-run when state they read changes, so reading the
849
+ // resolved basemap and attribution mode here is what rebuilds the map
850
+ // when those props change. The camera is read once: it is initial.
851
+ const spec = {
852
+ primary: basemapPrimary,
853
+ fallback: basemapFallback,
854
+ hideLabels: basemapHideLabels,
855
+ primaryProvider: basemapPrimaryProvider,
856
+ fallbackProvider: basemapFallbackProvider
857
+ };
858
+ const compactAttribution = attribution === 'compact';
859
+ const camera = untrack(() => initialCamera);
860
+ const restoreCamera = hadMap;
861
+ hadMap = true;
762
862
  let cleanup = () => {};
763
863
  let cancelled = false;
764
864
  void loadMapModules()
@@ -767,11 +867,40 @@
767
867
  layerCtors = { PathLayer, ScatterplotLayer, IconLayer };
768
868
  const m = new maplibregl.Map({
769
869
  container,
770
- style: STYLE,
771
- center: [-85, 36],
772
- zoom: 4.5,
870
+ style: spec.primary,
871
+ center: camera.center,
872
+ zoom: camera.zoom,
873
+ bearing: camera.bearing,
874
+ pitch: camera.pitch,
773
875
  canvasContextAttributes: { antialias: true },
774
- attributionControl: { compact: true }
876
+ attributionControl: { compact: compactAttribution }
877
+ });
878
+ // `setStyle` (the fallback path included) drops the projection and
879
+ // shows every layer again, so both are re-applied on each load.
880
+ const applyStyleExtras = () => {
881
+ m.setProjection({ type: untrack(() => projection) });
882
+ const style = m.getStyle();
883
+ if (spec.hideLabels) {
884
+ for (const layer of style.layers) {
885
+ if (layer.type === 'symbol') m.setLayoutProperty(layer.id, 'visibility', 'none');
886
+ }
887
+ }
888
+ // OpenFreeMap renders as the primary or as the fallback; either
889
+ // way it is tuned towards CARTO's quieter look.
890
+ if (!isOpenFreeMapStyle(style)) return;
891
+ const edits = openFreeMapEdits(
892
+ style,
893
+ untrack(() => theme)
894
+ );
895
+ for (const edit of edits) {
896
+ if (edit.kind === 'paint') m.setPaintProperty(edit.layer, edit.property, edit.value);
897
+ else m.setLayerZoomRange(edit.layer, edit.minzoom, edit.maxzoom);
898
+ }
899
+ };
900
+ m.on('style.load', applyStyleExtras);
901
+ const detachFallback = attachBasemapFallback(m, spec, {
902
+ onFallback: (reason) => onBasemapFallback?.(reason),
903
+ onFailure: (reason) => onBasemapError?.(reason)
775
904
  });
776
905
  const o = buildOverlay(MapLibreOverlay);
777
906
  m.addControl(o);
@@ -858,11 +987,13 @@
858
987
 
859
988
  map = m;
860
989
  overlay = o;
990
+ const detachReady = onMapReady?.(m, o);
861
991
  // Frame the active grid once the map is ready. The initial frame request
862
992
  // (from the case load) can fire before the async map import finishes and
863
993
  // be lost; re-issuing it here ensures the first view is the active case,
864
- // not the default world view. A remount opens at the default view too.
865
- if (mapGen > 0 && app.camera) m.jumpTo(app.camera);
994
+ // not the default world view. A rebuilt map opens at the default view
995
+ // too, so it restores the camera the last map saved.
996
+ if (restoreCamera && app.camera) m.jumpTo(app.camera);
866
997
  else if (app.cameraSeq === handledCameraSeq)
867
998
  app.requestFrame(app.activeCaseId ?? app.activeLocalId ?? 'all');
868
999
  if (mapGen > 0) {
@@ -884,6 +1015,9 @@
884
1015
  m.off('load', syncCues);
885
1016
  m.off('move', syncCues);
886
1017
  m.off('resize', syncCues);
1018
+ m.off('style.load', applyStyleExtras);
1019
+ detachFallback();
1020
+ detachReady?.();
887
1021
  app.settleFrame();
888
1022
  m.remove();
889
1023
  map = null;
@@ -899,8 +1033,23 @@
899
1033
  };
900
1034
  }
901
1035
 
1036
+ // A projection change applies at once when the style is ready. While a
1037
+ // style is still loading (the first paint, or just after a basemap switch)
1038
+ // MapLibre throws, and the `style.load` handler above applies it instead.
1039
+ $effect(() => {
1040
+ const type = projection;
1041
+ const m = map;
1042
+ if (!m) return;
1043
+ try {
1044
+ m.setProjection({ type });
1045
+ } catch {
1046
+ // style not loaded yet; applyStyleExtras runs on style.load
1047
+ }
1048
+ });
1049
+
902
1050
  // Sync deck.gl layers with app state. New layer instances diff cheaply;
903
- // updateTriggers tell deck.gl when accessor outputs changed.
1051
+ // updateTriggers tell deck.gl when accessor outputs changed. Host layers
1052
+ // (`extraLayers`) draw after tellegen's own at both exits.
904
1053
  $effect(() => {
905
1054
  if (!overlay || !layerCtors) return;
906
1055
  const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
@@ -953,12 +1102,12 @@
953
1102
  getFillColor: (b) => {
954
1103
  const value = values.get(b.id);
955
1104
  return value === undefined
956
- ? [110, 115, 120, 200]
1105
+ ? palette.grey
957
1106
  : priceColor((value - domain.lo) / Math.max(domain.hi - domain.lo, 1e-12));
958
1107
  },
959
1108
  pickable: true,
960
1109
  stroked: true,
961
- getLineColor: [46, 42, 34, 110],
1110
+ getLineColor: palette.outline,
962
1111
  lineWidthUnits: 'pixels',
963
1112
  onClick: (info: PickingInfo) => {
964
1113
  const bus = info.object as NetworkBus | undefined;
@@ -967,10 +1116,10 @@
967
1116
  app.selectedBus = bus.id;
968
1117
  return true;
969
1118
  },
970
- updateTriggers: { getFillColor: [study, mode] }
1119
+ updateTriggers: { getFillColor: [study, mode, theme] }
971
1120
  })
972
1121
  );
973
- overlay.setProps({ layers });
1122
+ overlay.setProps({ layers: [...layers, ...extraLayers] });
974
1123
  return;
975
1124
  }
976
1125
  for (const c of app.cases) {
@@ -998,7 +1147,7 @@
998
1147
  miterLimit: 2,
999
1148
  pickable: true,
1000
1149
  autoHighlight: true,
1001
- highlightColor: [32, 36, 43, 90],
1150
+ highlightColor: palette.highlight,
1002
1151
  onClick: (info: PickingInfo) => {
1003
1152
  const branch = info.object as NetworkBranch | undefined;
1004
1153
  if (!branch || !onbranchclick) return false;
@@ -1021,13 +1170,13 @@
1021
1170
  billboard: true,
1022
1171
  getLineColor: (b) =>
1023
1172
  c.id === app.activeCaseId && b.id === app.selectedBus
1024
- ? [32, 36, 43, 255]
1025
- : [46, 42, 34, 110],
1173
+ ? palette.selected
1174
+ : palette.outline,
1026
1175
  getLineWidth: (b) => (c.id === app.activeCaseId && b.id === app.selectedBus ? 2.5 : 1),
1027
1176
  lineWidthUnits: 'pixels',
1028
1177
  pickable: true,
1029
1178
  autoHighlight: true,
1030
- highlightColor: [32, 36, 43, 70],
1179
+ highlightColor: palette.highlightSoft,
1031
1180
  onClick: (info: PickingInfo) => {
1032
1181
  const bus = info.object as NetworkBus | undefined;
1033
1182
  if (!bus) return false;
@@ -1036,7 +1185,7 @@
1036
1185
  },
1037
1186
  updateTriggers: {
1038
1187
  getFillColor: [display],
1039
- getLineColor: [app.selectedBus, app.activeCaseId],
1188
+ getLineColor: [app.selectedBus, app.activeCaseId, theme],
1040
1189
  getLineWidth: [app.selectedBus, app.activeCaseId]
1041
1190
  }
1042
1191
  })
@@ -1058,7 +1207,7 @@
1058
1207
  ? [47, 111, 187, 255]
1059
1208
  : d
1060
1209
  ? branchColor(d.loading.get(b.id) ?? 0, b.status === 1)
1061
- : [138, 131, 117, 150],
1210
+ : palette.greyLine,
1062
1211
  getWidth: (b) =>
1063
1212
  c.id === app.activeLocalId && b.id === app.selectedBranch
1064
1213
  ? Math.max((d ? branchWidth(d.loading.get(b.id) ?? 0) : 1.5) + 2, 4.5)
@@ -1072,7 +1221,7 @@
1072
1221
  miterLimit: 2,
1073
1222
  pickable: true,
1074
1223
  autoHighlight: true,
1075
- highlightColor: [32, 36, 43, 90],
1224
+ highlightColor: palette.highlight,
1076
1225
  onClick: (info: PickingInfo) => {
1077
1226
  const branch = info.object as NetworkBranch | undefined;
1078
1227
  if (!branch || !onlocalbranchclick) return false;
@@ -1080,7 +1229,7 @@
1080
1229
  return true;
1081
1230
  },
1082
1231
  updateTriggers: {
1083
- getColor: [display, app.selectedBranch, app.activeLocalId],
1232
+ getColor: [display, app.selectedBranch, app.activeLocalId, theme],
1084
1233
  getWidth: [display, app.selectedBranch, app.activeLocalId]
1085
1234
  }
1086
1235
  }),
@@ -1090,18 +1239,18 @@
1090
1239
  getPosition: (b) => [b.lon, b.lat],
1091
1240
  getRadius: (b) => busRadius(Math.max(b.demand_mw, b.gen_mw)),
1092
1241
  radiusUnits: 'pixels',
1093
- getFillColor: d ? busFill(c.id) : [110, 115, 120, 200],
1242
+ getFillColor: d ? busFill(c.id) : palette.grey,
1094
1243
  stroked: true,
1095
1244
  billboard: true,
1096
1245
  getLineColor: (b) =>
1097
1246
  c.id === app.activeLocalId && b.id === app.selectedBus
1098
- ? [32, 36, 43, 255]
1099
- : [46, 42, 34, 110],
1247
+ ? palette.selected
1248
+ : palette.outline,
1100
1249
  getLineWidth: (b) => (c.id === app.activeLocalId && b.id === app.selectedBus ? 2.5 : 1),
1101
1250
  lineWidthUnits: 'pixels',
1102
1251
  pickable: true,
1103
1252
  autoHighlight: true,
1104
- highlightColor: [32, 36, 43, 70],
1253
+ highlightColor: palette.highlightSoft,
1105
1254
  onClick: (info: PickingInfo) => {
1106
1255
  const bus = info.object as NetworkBus | undefined;
1107
1256
  if (!bus) return false;
@@ -1110,7 +1259,7 @@
1110
1259
  },
1111
1260
  updateTriggers: {
1112
1261
  getFillColor: [display],
1113
- getLineColor: [app.selectedBus, app.activeLocalId],
1262
+ getLineColor: [app.selectedBus, app.activeLocalId, theme],
1114
1263
  getLineWidth: [app.selectedBus, app.activeLocalId]
1115
1264
  }
1116
1265
  })
@@ -1130,15 +1279,15 @@
1130
1279
  getRadius: 5,
1131
1280
  radiusUnits: 'pixels',
1132
1281
  radiusMinPixels: 3,
1133
- getFillColor: [70, 92, 124, 190],
1282
+ getFillColor: palette.substation,
1134
1283
  stroked: true,
1135
1284
  billboard: true,
1136
- getLineColor: [38, 52, 78, 220],
1285
+ getLineColor: palette.substationLine,
1137
1286
  getLineWidth: 1,
1138
1287
  lineWidthUnits: 'pixels',
1139
1288
  pickable: true,
1140
1289
  autoHighlight: true,
1141
- highlightColor: [32, 36, 43, 70]
1290
+ highlightColor: palette.highlightSoft
1142
1291
  })
1143
1292
  );
1144
1293
  }
@@ -1167,7 +1316,7 @@
1167
1316
  miterLimit: 2,
1168
1317
  pickable: true,
1169
1318
  autoHighlight: true,
1170
- highlightColor: [32, 36, 43, 90],
1319
+ highlightColor: palette.highlight,
1171
1320
  onClick: (info: PickingInfo) => {
1172
1321
  const e = info.object as PlacedMultiEdge | undefined;
1173
1322
  if (!e || !onmultiedgeclick) return false;
@@ -1210,15 +1359,15 @@
1210
1359
  billboard: true,
1211
1360
  getLineColor: (b) =>
1212
1361
  selectedId === b.id
1213
- ? [32, 36, 43, 255]
1362
+ ? palette.selected
1214
1363
  : b.has_source
1215
1364
  ? [63, 111, 187, 220]
1216
- : [46, 42, 34, 120],
1365
+ : palette.outlineStrong,
1217
1366
  getLineWidth: (b) => (selectedId === b.id ? 2.5 : b.has_source ? 2 : 1),
1218
1367
  lineWidthUnits: 'pixels',
1219
1368
  pickable: true,
1220
1369
  autoHighlight: true,
1221
- highlightColor: [32, 36, 43, 70],
1370
+ highlightColor: palette.highlightSoft,
1222
1371
  onClick: (info: PickingInfo) => {
1223
1372
  const bus = info.object as PlacedMultiBus | undefined;
1224
1373
  if (!bus) return false;
@@ -1226,13 +1375,13 @@
1226
1375
  return true;
1227
1376
  },
1228
1377
  updateTriggers: {
1229
- getLineColor: [selectedId],
1378
+ getLineColor: [selectedId, theme],
1230
1379
  getLineWidth: [selectedId]
1231
1380
  }
1232
1381
  })
1233
1382
  );
1234
1383
  }
1235
- overlay.setProps({ layers });
1384
+ overlay.setProps({ layers: [...layers, ...extraLayers] });
1236
1385
  });
1237
1386
 
1238
1387
  $effect(() => {
@@ -1438,16 +1587,21 @@
1438
1587
  }
1439
1588
 
1440
1589
  .multi-detail :global(.mc-leader) {
1441
- stroke: rgba(32, 36, 43, 0.35);
1590
+ stroke: rgb(var(--ink-rgb) / 0.35);
1442
1591
  stroke-width: 1;
1443
1592
  stroke-dasharray: 2 2;
1444
1593
  }
1445
1594
 
1446
1595
  .multi-detail :global(.mc-ground line) {
1447
- stroke: #3a3a3a;
1596
+ stroke: var(--mc-ground-stroke);
1448
1597
  stroke-width: 1.3;
1449
1598
  }
1450
1599
 
1600
+ .multi-detail :global(.mc-terminal) {
1601
+ stroke: var(--ink);
1602
+ stroke-width: 1.2;
1603
+ }
1604
+
1451
1605
  .multi-detail :global(.mc-term) {
1452
1606
  fill: var(--ink, #20242b);
1453
1607
  font-family: var(--font-mono);
@@ -1568,7 +1722,7 @@
1568
1722
  color: var(--text-accent);
1569
1723
  font-size: 11px;
1570
1724
  cursor: pointer;
1571
- box-shadow: 0 4px 18px rgba(32, 36, 43, 0.1);
1725
+ box-shadow: var(--elev-cue);
1572
1726
  }
1573
1727
 
1574
1728
  @media (max-width: 420px) {
@@ -1607,7 +1761,7 @@
1607
1761
  min-width: 0;
1608
1762
  max-width: calc(100vw - 96px);
1609
1763
  box-sizing: border-box;
1610
- background: rgba(252, 251, 247, 0.75);
1764
+ background: rgb(var(--paper-rgb) / 0.75);
1611
1765
  font-family: var(--font-mono);
1612
1766
  font-size: 10px;
1613
1767
  white-space: nowrap;
@@ -1648,7 +1802,7 @@
1648
1802
  line-height: 1.5;
1649
1803
  padding: 8px 10px !important;
1650
1804
  border-radius: 2px;
1651
- box-shadow: 0 2px 10px rgba(32, 36, 43, 0.12);
1805
+ box-shadow: var(--elev-tooltip);
1652
1806
  }
1653
1807
 
1654
1808
  @media (max-width: 760px) {
@@ -1,4 +1,9 @@
1
1
  import 'maplibre-gl/dist/maplibre-gl.css';
2
+ import type { Layer } from '@deck.gl/core';
3
+ import type { MapLibreOverlay } from '@deck.gl/maplibre';
4
+ import type { Map as MapLibreMap } from 'maplibre-gl';
5
+ import { type BasemapFallbackReason, type BasemapStyle, type MapProjection, type MapTheme } from './basemap.js';
6
+ import { type CameraSnapshot } from './state.svelte.js';
2
7
  type $$ComponentProps = {
3
8
  onbusclick: (caseId: string, busId: number) => void;
4
9
  onlocalbusclick: (caseId: string, busId: number) => void;
@@ -8,6 +13,46 @@ type $$ComponentProps = {
8
13
  onmultiedgeclick?: (caseId: string, edgeId: string) => void;
9
14
  onplacecase: (lon: number, lat: number) => void;
10
15
  onmapclick: () => void;
16
+ /** Primary basemap: a style URL or inline style. Default: the CARTO style
17
+ * for `theme` when `cartoApiKey` is set, else the OpenFreeMap style.
18
+ * Changing it (or `fallbackMapStyle`, `theme`, `hideBasemapLabels`,
19
+ * `cartoApiKey`, `attribution`) rebuilds the map in place and keeps the
20
+ * camera. */
21
+ mapStyle?: BasemapStyle;
22
+ /** Basemap used when the primary cannot be shown; `null` disables the
23
+ * switch. Default: whichever of the CARTO and OpenFreeMap styles is not
24
+ * the default primary. */
25
+ fallbackMapStyle?: BasemapStyle | null;
26
+ /** Picks the default basemaps and the overlay palette (bus outlines,
27
+ * hover highlight, unsolved grey). */
28
+ theme?: MapTheme;
29
+ /** Re-applied after every `style.load`, so a basemap switch keeps it. */
30
+ projection?: MapProjection;
31
+ /** Camera for the first paint; read once per mount. */
32
+ initialCamera?: CameraSnapshot;
33
+ /** Hide the basemap's `symbol` layers after every `style.load`. */
34
+ hideBasemapLabels?: boolean;
35
+ /** CARTO basemap key (free for non-commercial use from
36
+ * carto.com/basemaps/apikey; restrict it to your hosts). With one, the
37
+ * CARTO style leads and OpenFreeMap is the fallback; without one, the
38
+ * order flips. Trimmed; a blank key counts as none. */
39
+ cartoApiKey?: string;
40
+ /** deck.gl layers drawn after tellegen's own, in the same overlay. */
41
+ extraLayers?: Layer[];
42
+ /** Attribution control mode. There is no off switch: tile licences
43
+ * require the credit on screen. */
44
+ attribution?: 'compact' | 'full';
45
+ /** Called once the map and overlay exist, with an optional cleanup. It
46
+ * fires again after a context-loss or basemap remount, after the previous
47
+ * cleanup ran. */
48
+ onMapReady?: (map: MapLibreMap, overlay: MapLibreOverlay) => void | (() => void);
49
+ /** The primary basemap failed and the fallback was requested. The
50
+ * reason's `from` and `to` name the providers (`'carto'`,
51
+ * `'openfreemap'` or `'custom'`). */
52
+ onBasemapFallback?: (reason: BasemapFallbackReason) => void;
53
+ /** No basemap can be shown; the network layers still draw. The reason's
54
+ * `from` names the provider that failed last, and `to` is `null`. */
55
+ onBasemapError?: (reason: BasemapFallbackReason) => void;
11
56
  };
12
57
  declare const TellegenMap: import("svelte").Component<$$ComponentProps, {}, "">;
13
58
  type TellegenMap = ReturnType<typeof TellegenMap>;
@@ -16,8 +16,17 @@
16
16
  children?: Snippet;
17
17
  }
18
18
 
19
- let { apiBase, loadDefaultCases, docsHref, orgHref, orgLabel, showFooter, children }: Props =
20
- $props();
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, { apiBase: config.apiBase });
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?.()}