@tellegen/svelte 0.2.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 (88) hide show
  1. package/README.md +269 -5
  2. package/dist/Sparkline.svelte +2 -2
  3. package/dist/TellegenMap.svelte +518 -202
  4. package/dist/TellegenMap.svelte.d.ts +45 -0
  5. package/dist/TellegenProvider.svelte +40 -4
  6. package/dist/TellegenShell.svelte +24 -46
  7. package/dist/TellegenViewer.svelte +12 -2
  8. package/dist/api.d.ts +18 -3
  9. package/dist/api.js +8 -2
  10. package/dist/basemap.d.ts +192 -0
  11. package/dist/basemap.js +259 -0
  12. package/dist/colors.d.ts +25 -7
  13. package/dist/colors.js +39 -13
  14. package/dist/components/AppFooter.svelte +3 -2
  15. package/dist/components/AppHeader.svelte +44 -19
  16. package/dist/components/BindingLines.svelte +4 -7
  17. package/dist/components/BusPicker.svelte +11 -5
  18. package/dist/components/ControlPanel.svelte +106 -343
  19. package/dist/components/DemandSlider.svelte +36 -29
  20. package/dist/components/DiagramCanvas.svelte +486 -0
  21. package/dist/components/DiagramCanvas.svelte.d.ts +13 -0
  22. package/dist/components/DisplayControls.svelte +8 -2
  23. package/dist/components/DropZone.svelte +1 -1
  24. package/dist/components/FormulationSelector.svelte +9 -4
  25. package/dist/components/LocalCaseDetails.svelte +61 -48
  26. package/dist/components/McResults.svelte +428 -0
  27. package/dist/components/McResults.svelte.d.ts +17 -0
  28. package/dist/components/ModelDetails.svelte +230 -0
  29. package/dist/components/ModelDetails.svelte.d.ts +7 -0
  30. package/dist/components/MulticonductorDetails.svelte +232 -83
  31. package/dist/components/NetworkStats.svelte +20 -18
  32. package/dist/components/NotificationCenter.svelte +245 -0
  33. package/dist/components/NotificationCenter.svelte.d.ts +3 -0
  34. package/dist/components/PanelCard.svelte +384 -0
  35. package/dist/components/PanelCard.svelte.d.ts +9 -0
  36. package/dist/components/PanelFrame.svelte +46 -0
  37. package/dist/components/PanelFrame.svelte.d.ts +16 -0
  38. package/dist/components/PanelHost.svelte +237 -0
  39. package/dist/components/PanelHost.svelte.d.ts +6 -0
  40. package/dist/components/PlacementCue.svelte +1 -1
  41. package/dist/components/PowerFlowDetails.svelte +99 -0
  42. package/dist/components/PowerFlowDetails.svelte.d.ts +3 -0
  43. package/dist/components/RatingSlider.svelte +19 -14
  44. package/dist/components/RestoreDefaultsButton.svelte +1 -1
  45. package/dist/components/SavedNetworkDetails.svelte +214 -0
  46. package/dist/components/SavedNetworkDetails.svelte.d.ts +3 -0
  47. package/dist/components/SensitivityReadout.svelte +26 -18
  48. package/dist/components/ShellEnvironment.svelte +51 -0
  49. package/dist/components/ShellEnvironment.svelte.d.ts +18 -0
  50. package/dist/components/SizeLegend.svelte +2 -2
  51. package/dist/components/SolveCard.svelte +48 -51
  52. package/dist/components/TopMovers.svelte +9 -4
  53. package/dist/components/TopMovers.svelte.d.ts +2 -17
  54. package/dist/components.d.ts +5 -0
  55. package/dist/components.js +5 -0
  56. package/dist/context.svelte.d.ts +8 -1
  57. package/dist/context.svelte.js +8 -3
  58. package/dist/controller.svelte.d.ts +117 -36
  59. package/dist/controller.svelte.js +1133 -253
  60. package/dist/diagram-labels.d.ts +9 -0
  61. package/dist/diagram-labels.js +40 -0
  62. package/dist/display.d.ts +3 -7
  63. package/dist/display.js +31 -27
  64. package/dist/format.d.ts +9 -0
  65. package/dist/format.js +17 -4
  66. package/dist/index.d.ts +15 -5
  67. package/dist/index.js +9 -2
  68. package/dist/map.d.ts +4 -2
  69. package/dist/map.js +2 -1
  70. package/dist/maplibre-worker.d.ts +5 -0
  71. package/dist/maplibre-worker.dev.d.ts +11 -0
  72. package/dist/maplibre-worker.dev.js +15 -0
  73. package/dist/maplibre-worker.js +8 -0
  74. package/dist/multiconductor.d.ts +11 -2
  75. package/dist/multiconductor.js +107 -14
  76. package/dist/notices.svelte.d.ts +29 -0
  77. package/dist/notices.svelte.js +86 -0
  78. package/dist/panels.svelte.d.ts +71 -0
  79. package/dist/panels.svelte.js +245 -0
  80. package/dist/slider.d.ts +12 -0
  81. package/dist/slider.js +31 -0
  82. package/dist/state.svelte.d.ts +107 -23
  83. package/dist/state.svelte.js +106 -20
  84. package/dist/styles.css +59 -6
  85. package/dist/synthetic-layout.js +1 -0
  86. package/package.json +29 -11
  87. package/dist/study-package.d.ts +0 -8
  88. package/dist/study-package.js +0 -17
@@ -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
@@ -8,20 +8,20 @@ export interface SensitivityDomain {
8
8
  scale: number;
9
9
  flat: boolean;
10
10
  }
11
- export declare const lmpColor: (t: number) => RGBA;
12
- export declare const lmpGradient: string;
13
- /** Trimmed LMP color domain: 5th to 95th percentile, with a $1/MWh
14
- * minimum span. LMP distributions are heavy tailed (one binding line pins a
11
+ export declare const priceColor: (t: number) => RGBA;
12
+ export declare const priceGradient: string;
13
+ /** Trimmed nodal value color domain: 5th to 95th percentile, with a one-unit
14
+ * minimum span. Nodal values are heavy tailed when one binding line pins a
15
15
  * handful of buses far from the pack) so min-max scaling compresses the pack
16
16
  * into a single hue, and a congestion-free case is one price plus solver
17
17
  * noise that an exact scale would stretch into fake structure. Values beyond
18
18
  * the domain clamp to the ramp ends. */
19
- export declare function lmpDomain(values: number[]): {
19
+ export declare function priceDomain(values: number[]): {
20
20
  lo: number;
21
21
  hi: number;
22
22
  };
23
23
  /** Center a scalar display variable (bus voltage angle, |V|) on a symmetric domain
24
- * with a per-mode minimum span; LMP keeps its own robust quantile domain. Shared by
24
+ * with a per-mode minimum span; nodal values keep their own robust quantile domain. Shared by
25
25
  * the control panel readout and the map color scale. */
26
26
  export declare function scalarDomain(mode: DisplayMode, values: number[]): {
27
27
  lo: number;
@@ -32,8 +32,26 @@ 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
- /** The fixed normalization for a slider drag: predicted ΔLMP divides by
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
38
56
  * full deflection for the column's robust-max buses. A per-frame domain over
39
57
  * column × step would cancel the step exactly (sensitivityDomain is degree-1
package/dist/colors.js CHANGED
@@ -19,25 +19,25 @@ function cssGradient(stops) {
19
19
  const parts = stops.map((s) => `rgb(${s[0]} ${s[1]} ${s[2]})`);
20
20
  return `linear-gradient(90deg, ${parts.join(', ')})`;
21
21
  }
22
- /** Sequential LMP ramp, cheap to expensive: pale straw darkening through
22
+ /** Sequential nodal value ramp: pale straw darkening through
23
23
  * amber and rust to near-black maroon. Lightness falls monotonically, so the
24
24
  * ordering survives every kind of color vision deficiency. */
25
- const LMP_STOPS = [
25
+ const PRICE_STOPS = [
26
26
  [246, 227, 180, 235],
27
27
  [236, 178, 66, 235],
28
28
  [212, 116, 34, 240],
29
29
  [160, 56, 21, 245],
30
30
  [84, 18, 7, 250]
31
31
  ];
32
- export const lmpColor = (t) => ramp(LMP_STOPS, t);
33
- export const lmpGradient = cssGradient(LMP_STOPS);
34
- /** Trimmed LMP color domain: 5th to 95th percentile, with a $1/MWh
35
- * minimum span. LMP distributions are heavy tailed (one binding line pins a
32
+ export const priceColor = (t) => ramp(PRICE_STOPS, t);
33
+ export const priceGradient = cssGradient(PRICE_STOPS);
34
+ /** Trimmed nodal value color domain: 5th to 95th percentile, with a one-unit
35
+ * minimum span. Nodal values are heavy tailed when one binding line pins a
36
36
  * handful of buses far from the pack) so min-max scaling compresses the pack
37
37
  * into a single hue, and a congestion-free case is one price plus solver
38
38
  * noise that an exact scale would stretch into fake structure. Values beyond
39
39
  * the domain clamp to the ramp ends. */
40
- export function lmpDomain(values) {
40
+ export function priceDomain(values) {
41
41
  if (values.length === 0)
42
42
  return { lo: 0, hi: 1 };
43
43
  const sorted = [...values].sort((a, b) => a - b);
@@ -49,11 +49,11 @@ export function lmpDomain(values) {
49
49
  return { lo: mid - span / 2, hi: mid + span / 2 };
50
50
  }
51
51
  /** Center a scalar display variable (bus voltage angle, |V|) on a symmetric domain
52
- * with a per-mode minimum span; LMP keeps its own robust quantile domain. Shared by
52
+ * with a per-mode minimum span; nodal values keep their own robust quantile domain. Shared by
53
53
  * the control panel readout and the map color scale. */
54
54
  export function scalarDomain(mode, values) {
55
- if (mode === 'lmp')
56
- return lmpDomain(values);
55
+ if (mode === 'price')
56
+ return priceDomain(values);
57
57
  if (values.length === 0)
58
58
  return { lo: 0, hi: 1 };
59
59
  const { min: rawLo, max: rawHi } = extent(values);
@@ -62,9 +62,9 @@ export function scalarDomain(mode, values) {
62
62
  const mid = (rawLo + rawHi) / 2;
63
63
  return { lo: mid - span / 2, hi: mid + span / 2 };
64
64
  }
65
- /** Diverging sensitivity ramp: green (price falls per MW of demand) through
65
+ /** Diverging sensitivity ramp: green (nodal value falls per MW of demand) through
66
66
  * a paper-toned neutral to purple (price rises). The green/purple pair stays
67
- * legible under CVD and shares no hue with the warm LMP ramp, so the two
67
+ * legible under CVD and shares no hue with the warm nodal value ramp, so the two
68
68
  * modes cannot be confused. */
69
69
  const SENS_STOPS = [
70
70
  [27, 120, 55, 240],
@@ -78,13 +78,39 @@ 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)
84
110
  return sensNeutral;
85
111
  return sensColor(domain.mean > 0 ? FLAT_SENSITIVITY_TINT : -FLAT_SENSITIVITY_TINT);
86
112
  }
87
- /** The fixed normalization for a slider drag: predicted ΔLMP divides by
113
+ /** The fixed normalization for a slider drag: predicted nodal value change divides by
88
114
  * scale × maxAbsStep, so color intensity is linear in the step and saturates at
89
115
  * full deflection for the column's robust-max buses. A per-frame domain over
90
116
  * column × step would cancel the step exactly (sensitivityDomain is degree-1
@@ -31,7 +31,7 @@
31
31
  padding: 8px 20px;
32
32
  font-size: 10.5px;
33
33
  color: var(--text-secondary);
34
- background: linear-gradient(rgba(236, 233, 226, 0), rgba(236, 233, 226, 0.9));
34
+ background: linear-gradient(rgb(var(--bg-rgb) / 0), rgb(var(--bg-rgb) / 0.9));
35
35
  animation: rise 0.5s 0.24s ease-out both;
36
36
  pointer-events: none;
37
37
  }
@@ -53,10 +53,12 @@
53
53
  color: var(--text-accent);
54
54
  }
55
55
 
56
+ /* Inside the Network panel on narrow screens, panel text size applies. */
56
57
  footer.inline {
57
58
  position: static;
58
59
  margin-top: 14px;
59
60
  padding: 0;
61
+ font-size: 12px;
60
62
  background: none;
61
63
  animation: none;
62
64
  pointer-events: auto;
@@ -71,7 +73,6 @@
71
73
 
72
74
  footer.inline {
73
75
  margin-top: 4px;
74
- font-size: 11px;
75
76
  }
76
77
  }
77
78
 
@@ -26,10 +26,10 @@
26
26
  <header bind:this={headerEl}>
27
27
  <div class="brand">
28
28
  <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
29
- <path d="M4 18 L12 6 L20 18" stroke="#b25e00" stroke-width="1.6" fill="none" />
30
- <circle cx="4" cy="18" r="2.4" fill="#b25e00" />
31
- <circle cx="12" cy="6" r="2.4" fill="#20242b" />
32
- <circle cx="20" cy="18" r="2.4" fill="#b25e00" />
29
+ <path class="brand-accent-stroke" d="M4 18 L12 6 L20 18" stroke-width="1.6" fill="none" />
30
+ <circle class="brand-accent" cx="4" cy="18" r="2.4" />
31
+ <circle class="brand-ink" cx="12" cy="6" r="2.4" />
32
+ <circle class="brand-accent" cx="20" cy="18" r="2.4" />
33
33
  </svg>
34
34
  <h1>tellegen</h1>
35
35
  </div>
@@ -37,11 +37,16 @@
37
37
  {#each app.cases as c (c.id)}
38
38
  {@const [cname, cregion] = splitName(c.name)}
39
39
  <div class="case-chip" class:active={app.activeCaseId === c.id}>
40
- <button class="case-activate" onclick={() => ctrl.activateCase(c.id)}>
40
+ <button
41
+ class="case-activate"
42
+ disabled={!!c.unavailableReason}
43
+ title={c.unavailableReason ?? undefined}
44
+ onclick={() => ctrl.activateCase(c.id)}
45
+ >
41
46
  <span class="cname"
42
47
  >{cname}{#if c.perturbed}<i class="mark" title="demand perturbed"></i>{/if}</span
43
48
  >
44
- <span class="cregion mono">{cregion}</span>
49
+ <span class="cregion mono">{c.unavailableReason ? 'Unavailable' : cregion}</span>
45
50
  </button>
46
51
  <button
47
52
  class="case-remove mono"
@@ -79,16 +84,18 @@
79
84
  >
80
85
  </div>
81
86
  {/each}
82
- {#if ctrl.showFileDropUi}
83
- <button
84
- class="ghost filedrop-ui"
85
- title="parsed in your browser; the file never uploads"
86
- onclick={() => fileInput?.click()}
87
+ <button
88
+ class="ghost filedrop-ui"
89
+ title="parsed in your browser; the file never uploads"
90
+ onclick={() => fileInput?.click()}
91
+ >
92
+ <span class="cname"
93
+ ><span class="arrow">&#8675;</span>{ctrl.showFileDropUi
94
+ ? 'drop a case file'
95
+ : 'open a case file'}</span
87
96
  >
88
- <span class="cname"><span class="arrow">&#8675;</span>drop a case file</span>
89
- <span class="cregion mono">case + geo files</span>
90
- </button>
91
- {/if}
97
+ <span class="cregion mono">case + geo files</span>
98
+ </button>
92
99
  </nav>
93
100
  <span class="kicker mono">
94
101
  <a href={config.orgHref} target="_blank" rel="noreferrer">{config.orgLabel}</a>
@@ -123,7 +130,7 @@
123
130
  justify-content: space-between;
124
131
  gap: 16px;
125
132
  padding: 10px 20px;
126
- background: linear-gradient(rgba(236, 233, 226, 0.95), rgba(236, 233, 226, 0));
133
+ background: linear-gradient(rgb(var(--bg-rgb) / 0.95), rgb(var(--bg-rgb) / 0));
127
134
  animation: drop 0.5s ease-out both;
128
135
  }
129
136
 
@@ -134,6 +141,19 @@
134
141
  gap: 10px;
135
142
  }
136
143
 
144
+ /* The brand mark follows the theme: accent legs, ink apex. */
145
+ .brand-accent {
146
+ fill: var(--accent);
147
+ }
148
+
149
+ .brand-accent-stroke {
150
+ stroke: var(--accent);
151
+ }
152
+
153
+ .brand-ink {
154
+ fill: var(--ink);
155
+ }
156
+
137
157
  h1 {
138
158
  margin: 0;
139
159
  font-size: 22px;
@@ -203,6 +223,11 @@
203
223
  border: 0;
204
224
  }
205
225
 
226
+ .case-activate:disabled {
227
+ color: var(--text-secondary);
228
+ cursor: not-allowed;
229
+ }
230
+
206
231
  .case-remove {
207
232
  position: absolute;
208
233
  top: 1px;
@@ -293,8 +318,8 @@
293
318
 
294
319
  /* Ghost chip: standing invitation to drop or pick a case file. */
295
320
  .cases > button.ghost {
296
- background: rgba(252, 251, 247, 0.36);
297
- border: 1px dashed rgba(178, 94, 0, 0.55);
321
+ background: rgb(var(--paper-rgb) / 0.36);
322
+ border: 1px dashed rgb(var(--accent-rgb) / 0.55);
298
323
  color: var(--text-secondary);
299
324
  box-shadow: inset 0 0 0 1px rgba(212, 116, 34, 0.08);
300
325
  }
@@ -339,7 +364,7 @@
339
364
  flex-wrap: wrap;
340
365
  gap: 8px;
341
366
  padding: 8px 10px 12px;
342
- background: linear-gradient(rgba(236, 233, 226, 0.97), rgba(236, 233, 226, 0.72));
367
+ background: linear-gradient(rgb(var(--bg-rgb) / 0.97), rgb(var(--bg-rgb) / 0.72));
343
368
  }
344
369
 
345
370
  .brand {
@@ -32,7 +32,7 @@
32
32
  <div class="binding-lines">
33
33
  <p
34
34
  class="mono dim head"
35
- title="lines loaded to their thermal rating; select one to see &part;LMP/&part;rating"
35
+ title="lines loaded to their thermal rating; select one to see LMP sensitivity to the rating"
36
36
  >
37
37
  binding lines
38
38
  </p>
@@ -45,9 +45,7 @@
45
45
  aria-pressed={app.selectedBranch === branch.id}
46
46
  onclick={() => select(branch.id)}
47
47
  >
48
- line {branch.from}&#8201;&ndash;&#8201;{branch.to} &middot; {Math.round(
49
- loading * 100
50
- )}%
48
+ line {branch.from}&#8201;&ndash;&#8201;{branch.to}, {Math.round(loading * 100)}%
51
49
  </button>
52
50
  </li>
53
51
  {/each}
@@ -61,7 +59,7 @@
61
59
  }
62
60
 
63
61
  .head {
64
- font-size: 10.5px;
62
+ font-size: 12.5px;
65
63
  margin: 0 0 4px;
66
64
  }
67
65
 
@@ -93,8 +91,7 @@
93
91
  li button.selected {
94
92
  color: var(--text-accent);
95
93
  background:
96
- linear-gradient(90deg, rgba(177, 104, 27, 0.16), rgba(177, 104, 27, 0.05)),
97
- var(--accent-soft);
94
+ linear-gradient(90deg, rgba(177, 104, 27, 0.16), rgba(177, 104, 27, 0.05)), var(--accent-soft);
98
95
  box-shadow: inset 3px 0 0 var(--accent);
99
96
  font-weight: 600;
100
97
  }
@@ -8,7 +8,9 @@
8
8
  /** Render in the parent's flow instead of as a card floating over the map. */
9
9
  let { inline = false }: { inline?: boolean } = $props();
10
10
 
11
- const buses = $derived(app.active?.network?.buses ?? app.activeLocal?.view?.buses ?? []);
11
+ const buses = $derived(
12
+ app.studyView?.network.buses ?? app.active?.network?.buses ?? app.activeLocal?.view?.buses ?? []
13
+ );
12
14
 
13
15
  let query = $state('');
14
16
  let open = $state(false);
@@ -25,7 +27,11 @@
25
27
  function commit(id: number) {
26
28
  const caseId = app.activeCaseId;
27
29
  const localId = app.activeLocalId;
28
- if (caseId && app.active) ctrl.selectBus(caseId, id);
30
+ if (app.studyView) {
31
+ app.selectedBranch = null;
32
+ app.selectedBus = id;
33
+ void app.requestFrame({ caseId: app.studyView.caseId, busId: id });
34
+ } else if (caseId && app.active) ctrl.selectBus(caseId, id);
29
35
  else if (localId && app.activeLocal) ctrl.selectLocalBus(localId, id);
30
36
  query = '';
31
37
  open = false;
@@ -149,7 +155,7 @@
149
155
 
150
156
  label {
151
157
  display: block;
152
- font-size: 10px;
158
+ font-size: 12px;
153
159
  text-transform: uppercase;
154
160
  margin-bottom: 3px;
155
161
  }
@@ -214,7 +220,7 @@
214
220
  background: var(--panel);
215
221
  border: 1px solid var(--line);
216
222
  border-radius: var(--radius-xs, 2px);
217
- box-shadow: 0 4px 18px rgba(32, 36, 43, 0.14);
223
+ box-shadow: var(--elev-menu);
218
224
  }
219
225
 
220
226
  li {
@@ -238,7 +244,7 @@
238
244
  border: 1px solid var(--line);
239
245
  border-radius: var(--radius-xs, 2px);
240
246
  box-shadow: var(--elev-1);
241
- font-size: 10.5px;
247
+ font-size: 12.5px;
242
248
  margin: 4px 0 0;
243
249
  }
244
250