@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
@@ -1,7 +1,10 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { tick, untrack } from 'svelte';
3
3
  import { getAppState, getController, getUiConfig } from '../context.svelte.js';
4
- import { splitName } from '../format.js';
4
+ import PanelFrame from './PanelFrame.svelte';
5
+ import SavedNetworkDetails from './SavedNetworkDetails.svelte';
6
+ import ModelDetails from './ModelDetails.svelte';
7
+ import { getPanelLayout } from '../panels.svelte.js';
5
8
  import AppFooter from './AppFooter.svelte';
6
9
  import BindingLines from './BindingLines.svelte';
7
10
  import BusPicker from './BusPicker.svelte';
@@ -11,6 +14,7 @@
11
14
  import LocalCaseDetails from './LocalCaseDetails.svelte';
12
15
  import MulticonductorDetails from './MulticonductorDetails.svelte';
13
16
  import NetworkStats from './NetworkStats.svelte';
17
+ import PowerFlowDetails from './PowerFlowDetails.svelte';
14
18
  import RatingSlider from './RatingSlider.svelte';
15
19
  import SensitivityReadout from './SensitivityReadout.svelte';
16
20
  import SizeLegend from './SizeLegend.svelte';
@@ -20,56 +24,9 @@
20
24
  const ctrl = getController();
21
25
  const config = getUiConfig();
22
26
 
23
- // Bottom sheet heights, compact layout only. peek is measured from the grab
24
- // bar; half and full are fractions of the viewport.
25
- type Snap = 'peek' | 'half' | 'full';
26
- const SNAPS: Snap[] = ['peek', 'half', 'full'];
27
- const SNAP_FRACTION: Record<Snap, number> = { peek: 0, half: 0.46, full: 0.92 };
28
- /** Below this the viewport is a landscape phone and half has to give up room. */
29
- const SHORT_VIEWPORT_PX = 560;
30
- /** px/ms above which a release snaps in the drag direction instead of to the nearest. */
31
- const FLICK_SPEED = 0.35;
32
- /** Map left visible above a full sheet, on top of the header it also clears. */
33
- const FULL_SNAP_MAP_BAND = 14;
34
-
35
- let snap = $state<Snap>('half');
36
- let headEl = $state.raw<HTMLElement | undefined>(undefined);
37
- let bodyEl = $state.raw<HTMLElement | undefined>(undefined);
38
- let headHeight = $state(52);
39
- /** Height under an active drag; null when resting on a snap. */
40
- let dragHeight = $state<number | null>(null);
41
-
42
- const fractionFor = (s: Snap) =>
43
- s === 'half' && app.viewportHeight < SHORT_VIEWPORT_PX ? 0.38 : SNAP_FRACTION[s];
44
-
45
- const snapHeight = (s: Snap) =>
46
- s === 'peek'
47
- ? headHeight
48
- : Math.min(
49
- Math.round(app.viewportHeight * fractionFor(s)),
50
- app.viewportHeight - app.headerInset - FULL_SNAP_MAP_BAND
51
- );
52
- const sheetHeight = $derived(dragHeight ?? snapHeight(snap));
53
-
54
- const caseLabel = $derived(
55
- app.active
56
- ? splitName(app.active.name)[0]
57
- : (app.activeLocal?.label ?? app.activeMulti?.label ?? '')
58
- );
59
-
60
- /** Single line shown on the grab bar while collapsed. */
61
- const summary = $derived.by(() => {
62
- if (app.error) return 'error — open for details';
63
- if (app.selectedBus !== null) return `bus ${app.selectedBus} · ∂LMP/∂d`;
64
- if (app.selectedBranch !== null) return `line ${app.selectedBranch} · ∂LMP/∂rating`;
65
- const stats = ctrl.networkStats;
66
- if (!stats) return app.parsingFile ? 'parsing…' : 'loading…';
67
- return `${caseLabel} · ${stats.buses} buses · ${stats.branches} lines`;
68
- });
69
-
70
- const expandLabel = $derived(
71
- snap === 'full' ? 'collapse the control panel' : 'expand the control panel'
72
- );
27
+ const layout = getPanelLayout();
28
+ let open = $state(true);
29
+ let bodyEl = $state.raw<HTMLElement>();
73
30
 
74
31
  /** A selection whose readout has resolved (or is resolving). */
75
32
  const busReadout = $derived(
@@ -81,141 +38,34 @@
81
38
  /** Compact only: a resolved selection outranks the case stats it was read
82
39
  * against. Gated on networkStats because that is what renders the readout. */
83
40
  const selectionLeads = $derived(
84
- app.compactLayout && !!ctrl.networkStats && (busReadout || branchReadout)
41
+ app.compactLayout &&
42
+ !!ctrl.networkStats &&
43
+ (busReadout ||
44
+ branchReadout ||
45
+ (ctrl.activeFormulation === 'acpf' &&
46
+ (app.selectedBus !== null || app.selectedBranch !== null)))
85
47
  );
86
48
 
87
49
  $effect(() => {
88
- app.sheetInset = app.compactLayout ? sheetHeight : 0;
89
- });
90
-
91
- // peek height tracks the grab bar, which resizes when the summary line changes.
92
- $effect(() => {
93
- const el = headEl;
94
- if (!el) return;
95
- const measure = () => (headHeight = Math.round(el.getBoundingClientRect().height));
96
- measure();
97
- const observer = new ResizeObserver(measure);
98
- observer.observe(el);
99
- return () => observer.disconnect();
100
- });
101
-
102
- // A map selection renders the sensitivity readout and the demand control, both
103
- // of which are below the fold at peek. The body also has to go back to the top:
104
- // reaching the bus lookup scrolls it down, and the readout that replaces it as
105
- // the first block would otherwise open out of view.
106
- $effect(() => {
107
- const selected = app.selectedBus ?? app.selectedBranch;
108
- if (selected === null) return;
109
- untrack(() => {
110
- if (!app.compactLayout) return;
111
- if (snap === 'peek') snap = 'half';
112
- if (bodyEl) bodyEl.scrollTop = 0;
113
- });
50
+ const selection = app.selectedBus ?? app.selectedBranch;
51
+ if (selection !== null)
52
+ untrack(() => {
53
+ open = true;
54
+ if (layout.compact) {
55
+ layout.drawer = 'network';
56
+ void tick().then(() => bodyEl?.closest('.panel-content')?.scrollTo({ top: 0 }));
57
+ }
58
+ });
114
59
  });
115
-
116
- function cycle() {
117
- snap = snap === 'peek' ? 'half' : snap === 'half' ? 'full' : 'peek';
118
- }
119
-
120
- function nearestSnap(height: number): Snap {
121
- return SNAPS.reduce((best, s) =>
122
- Math.abs(snapHeight(s) - height) < Math.abs(snapHeight(best) - height) ? s : best
123
- );
124
- }
125
-
126
- let dragStartY = 0;
127
- let dragStartHeight = 0;
128
- let dragMoved = false;
129
- let lastY = 0;
130
- let lastT = 0;
131
- let speed = 0;
132
-
133
- function onPointerDown(e: PointerEvent) {
134
- if (!app.compactLayout || !e.isPrimary) return;
135
- if (e.currentTarget instanceof HTMLElement) e.currentTarget.setPointerCapture(e.pointerId);
136
- dragStartY = e.clientY;
137
- dragStartHeight = sheetHeight;
138
- dragHeight = dragStartHeight;
139
- dragMoved = false;
140
- speed = 0;
141
- lastY = e.clientY;
142
- lastT = e.timeStamp;
143
- }
144
-
145
- function onPointerMove(e: PointerEvent) {
146
- if (dragHeight === null) return;
147
- const dy = dragStartY - e.clientY;
148
- if (Math.abs(dy) > 4) dragMoved = true;
149
- const dt = e.timeStamp - lastT;
150
- if (dt > 0) speed = (lastY - e.clientY) / dt;
151
- lastY = e.clientY;
152
- lastT = e.timeStamp;
153
- dragHeight = Math.max(
154
- snapHeight('peek'),
155
- Math.min(snapHeight('full'), dragStartHeight + dy)
156
- );
157
- }
158
-
159
- function onPointerUp() {
160
- if (dragHeight === null) return;
161
- const height = dragHeight;
162
- dragHeight = null;
163
- if (!dragMoved) return; // a tap; the click handler cycles
164
- const landed = nearestSnap(height);
165
- if (Math.abs(speed) > FLICK_SPEED) {
166
- const next = SNAPS.indexOf(landed) + (speed > 0 ? 1 : -1);
167
- snap = SNAPS[Math.max(0, Math.min(SNAPS.length - 1, next))];
168
- } else {
169
- snap = landed;
170
- }
171
- }
172
-
173
- function onHeadKeydown(e: KeyboardEvent) {
174
- const step = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0;
175
- if (step === 0) return;
176
- e.preventDefault();
177
- const next = SNAPS.indexOf(snap) + step;
178
- snap = SNAPS[Math.max(0, Math.min(SNAPS.length - 1, next))];
179
- }
180
60
  </script>
181
61
 
182
- <aside
183
- class="panel"
184
- class:sheet={app.compactLayout}
185
- class:dragging={dragHeight !== null}
186
- style:height={app.compactLayout ? `${sheetHeight}px` : null}
187
- >
188
- {#if app.compactLayout}
189
- <button
190
- type="button"
191
- class="sheet-head"
192
- bind:this={headEl}
193
- aria-expanded={snap !== 'peek'}
194
- aria-controls="control-panel-body"
195
- aria-label={expandLabel}
196
- onpointerdown={onPointerDown}
197
- onpointermove={onPointerMove}
198
- onpointerup={onPointerUp}
199
- onpointercancel={onPointerUp}
200
- onkeydown={onHeadKeydown}
201
- onclick={() => {
202
- // click fires after pointerup; a keyboard activation leaves dragMoved
203
- // unset, so clear it here rather than in the pointer handlers
204
- const moved = dragMoved;
205
- dragMoved = false;
206
- if (!moved) cycle();
207
- }}
208
- >
209
- <span class="grabber" aria-hidden="true"></span>
210
- {#if snap === 'peek'}
211
- <span class="sheet-summary mono">{summary}</span>
212
- {/if}
213
- </button>
214
- {/if}
215
-
62
+ <PanelFrame id="network" title="Network" side="left" order={10} width={320} bind:open>
216
63
  <div class="panel-body" id="control-panel-body" bind:this={bodyEl}>
217
64
  {#snippet selectionBlock()}
218
- {#if busReadout}
65
+ {#if ctrl.activeFormulation === 'acpf'}
66
+ <DisplayControls />
67
+ <PowerFlowDetails />
68
+ {:else if busReadout}
219
69
  <SensitivityReadout />
220
70
 
221
71
  <DemandSlider />
@@ -236,108 +86,90 @@
236
86
  {/if}
237
87
  {/snippet}
238
88
 
239
- {#if app.error}
240
- <p class="error mono">{app.error}</p>
241
- <div class="error-actions">
242
- <button class="reset mono" onclick={ctrl.retryError}>retry</button>
243
- <button class="reset mono" onclick={() => (app.error = null)}>dismiss</button>
244
- </div>
245
- {/if}
246
- {#if app.parsingFile}
247
- <p class="dim mono blink">parsing&hellip;</p>
248
- {/if}
89
+ {#if app.studyView}
90
+ <SavedNetworkDetails />
91
+ {:else}
92
+ {#if app.parsingFile}
93
+ <p class="dim mono blink">parsing&hellip;</p>
94
+ {/if}
249
95
 
250
- <!-- The sheet shows a few hundred px at most, so what the last tap produced
251
- leads; the case provenance and stats it was read against follow. -->
252
- {#if selectionLeads}
253
- {@render selectionBlock()}
96
+ <!-- Selected equipment leads on narrow screens. -->
97
+ {#if selectionLeads}
98
+ {@render selectionBlock()}
254
99
 
255
- <hr />
256
- {/if}
100
+ <hr />
101
+ {/if}
257
102
 
258
- {#if app.activeLocal}
259
- <LocalCaseDetails />
260
- {/if}
261
- {#if app.activeMulti}
262
- <MulticonductorDetails />
263
- {/if}
264
- {#if !ctrl.networkStats}
265
- {#if !app.error && !app.activeLocal && !app.activeMulti}
266
- {#if ctrl.casesLoaded && app.cases.length === 0}
267
- <p class="dim mono">
268
- {config.loadDefaultCases ? 'no default cases loaded' : 'drop a case file to begin'}
269
- </p>
270
- {#if config.loadDefaultCases}
271
- <button class="reset mono" onclick={ctrl.restoreDefaultCases}
272
- >restore default cases</button
103
+ {#if app.activeLocal}
104
+ {#if app.activeLocal.diagram && app.activeLocal.view?.buses.length}
105
+ <div class="view-mode" aria-label="Coordinate display">
106
+ <button
107
+ aria-pressed={app.activeLocal.displayMode === 'geographic'}
108
+ onclick={() => ctrl.setCaseDisplayMode(app.activeLocal!, 'geographic')}>Map</button
273
109
  >
274
- {/if}
275
- {:else if ctrl.loadingBackendCase}
276
- <p class="dim mono blink">loading selected case&hellip;</p>
277
- {:else}
278
- <p class="dim mono blink">loading cases&hellip;</p>
110
+ <button
111
+ aria-pressed={app.activeLocal.displayMode === 'diagram'}
112
+ onclick={() => ctrl.setCaseDisplayMode(app.activeLocal!, 'diagram')}>Diagram</button
113
+ >
114
+ </div>
279
115
  {/if}
116
+ <LocalCaseDetails />
280
117
  {/if}
281
- {:else}
282
- {#if !app.activeLocal}
283
- <NetworkStats />
118
+ {#if app.activeMulti}
119
+ <MulticonductorDetails />
284
120
  {/if}
121
+ {#if !ctrl.networkStats}
122
+ {#if !app.activeLocal && !app.activeMulti}
123
+ {#if ctrl.casesLoaded && app.cases.length === 0}
124
+ <p class="dim mono">
125
+ {config.loadDefaultCases ? 'no default cases loaded' : 'drop a case file to begin'}
126
+ </p>
127
+ {#if config.loadDefaultCases}
128
+ <button class="reset mono" onclick={ctrl.restoreDefaultCases}
129
+ >restore default cases</button
130
+ >
131
+ {/if}
132
+ {:else if ctrl.loadingBackendCase}
133
+ <p class="dim mono blink">loading selected case&hellip;</p>
134
+ {:else}
135
+ <p class="dim mono blink">loading cases&hellip;</p>
136
+ {/if}
137
+ {/if}
138
+ {:else}
139
+ {#if !app.activeLocal}
140
+ <NetworkStats />
141
+ {/if}
285
142
 
286
- {#if ctrl.activeSolvable}
287
- <FormulationSelector />
288
- {/if}
143
+ {#if ctrl.activeSolvable}
144
+ <FormulationSelector />
145
+ {/if}
289
146
 
290
- {#if app.compactLayout}
147
+ <ModelDetails details={ctrl.activeSolvable?.network?.model_details} />
291
148
  <BusPicker inline />
292
- {/if}
293
149
 
294
- {#if !app.placingLocalId}
295
- <BindingLines />
296
- {/if}
150
+ {#if !app.placingLocalId && ctrl.activeFormulation !== 'acpf'}
151
+ <BindingLines />
152
+ {/if}
297
153
 
298
- {#if !selectionLeads}
299
- <hr />
154
+ {#if !selectionLeads}
155
+ <hr />
300
156
 
301
- {@render selectionBlock()}
302
- {/if}
157
+ {@render selectionBlock()}
158
+ {/if}
303
159
 
304
- <hr />
160
+ <hr />
305
161
 
306
- <SizeLegend />
162
+ <SizeLegend />
163
+ {/if}
307
164
  {/if}
308
-
309
165
  {#if app.compactLayout && config.showFooter}
310
166
  <AppFooter inline />
311
167
  {/if}
312
168
  </div>
313
- </aside>
169
+ </PanelFrame>
314
170
 
315
171
  <style>
316
- .panel {
317
- position: absolute;
318
- top: 76px;
319
- left: 20px;
320
- z-index: 10;
321
- display: flex;
322
- flex-direction: column;
323
- width: 312px;
324
- max-height: calc(100% - 122px);
325
- overflow: hidden;
326
- background: var(--panel);
327
- border: 1px solid var(--line);
328
- border-radius: 3px;
329
- backdrop-filter: blur(6px);
330
- box-shadow: 0 4px 24px rgba(32, 36, 43, 0.08);
331
- animation: rise 0.5s 0.12s ease-out both;
332
- }
333
-
334
172
  .panel-body {
335
- flex: 1 1 auto;
336
- min-height: 0;
337
- overflow-y: auto;
338
- /* A flick that runs past the end of the panel must not scroll the page or
339
- pan the map underneath. */
340
- overscroll-behavior: contain;
341
173
  padding: 16px 18px;
342
174
  }
343
175
 
@@ -347,95 +179,26 @@
347
179
  margin: 12px 0;
348
180
  }
349
181
 
350
- .error {
351
- color: var(--red);
352
- font-size: 12px;
353
- }
354
-
355
- .error-actions {
356
- display: flex;
357
- gap: 6px;
358
- }
359
-
360
- /* The grab bar: a 44px strip that drags the sheet and cycles it on tap. */
361
- .sheet-head {
362
- flex: 0 0 auto;
182
+ .view-mode {
363
183
  display: flex;
364
- flex-direction: column;
365
- align-items: center;
366
- justify-content: center;
367
- gap: 7px;
368
- width: 100%;
369
- min-height: 44px;
370
- padding: 9px 16px 11px;
371
- background: none;
372
- border: 0;
373
- border-bottom: 1px solid transparent;
374
- color: inherit;
375
- font: inherit;
376
- cursor: grab;
377
- /* the browser must not claim the vertical gesture */
378
- touch-action: none;
379
- }
380
-
381
- .sheet-head:active {
382
- cursor: grabbing;
383
- }
384
-
385
- .grabber {
386
- width: 40px;
387
- height: 4px;
388
- border-radius: 2px;
389
- background: var(--line);
390
- }
391
-
392
- .sheet-head:hover .grabber,
393
- .sheet-head:focus-visible .grabber {
394
- background: var(--accent);
184
+ gap: 4px;
185
+ margin-bottom: 12px;
395
186
  }
396
-
397
- .sheet-summary {
398
- max-width: 100%;
399
- overflow: hidden;
400
- font-size: 11.5px;
401
- color: var(--text-secondary);
402
- text-overflow: ellipsis;
403
- white-space: nowrap;
404
- }
405
-
406
- .sheet {
407
- top: auto;
408
- right: 0;
409
- bottom: 0;
410
- left: 0;
411
- width: auto;
412
- max-height: none;
187
+ .view-mode button {
188
+ border: 1px solid var(--line);
189
+ border-radius: 4px;
190
+ padding: 5px 10px;
191
+ font: 12px/1.4 var(--font-display);
192
+ color: var(--ink);
413
193
  background: var(--paper);
414
- border-width: 1px 0 0;
415
- border-radius: 12px 12px 0 0;
416
- box-shadow: 0 -6px 28px rgba(32, 36, 43, 0.14);
417
- transition: height var(--dur-med) var(--ease-out);
418
- }
419
-
420
- .sheet .panel-body {
421
- padding: 0 16px 14px;
422
- /* clear the home indicator on a phone with no bezel */
423
- padding-bottom: max(14px, env(safe-area-inset-bottom));
424
- -webkit-overflow-scrolling: touch;
194
+ cursor: pointer;
425
195
  }
426
-
427
- .sheet .sheet-head {
428
- border-bottom-color: var(--line);
196
+ .view-mode button[aria-pressed='true'] {
197
+ border-color: var(--accent);
198
+ background: var(--accent-soft);
429
199
  }
430
-
431
- /* A dragged sheet tracks the finger; the snap animation is for releases. */
432
- .sheet.dragging {
433
- transition: none;
434
- }
435
-
436
- @media (prefers-reduced-motion: reduce) {
437
- .panel {
438
- animation: none;
439
- }
200
+ .view-mode button:focus-visible {
201
+ outline: 2px solid var(--focus-ring);
202
+ outline-offset: 2px;
440
203
  }
441
204
  </style>
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { getAppState, getController } from '../context.svelte.js';
3
3
  import { fmt, signed } from '../format.js';
4
+ import { sliderKeyValue } from '../slider.js';
4
5
 
5
6
  const app = getAppState();
6
7
  const ctrl = getController();
@@ -26,7 +27,7 @@
26
27
  'The black tick marks the last committed demand. Drag the knob to preview a new demand; release to solve and move the tick to that point.';
27
28
  const disabledTip = 'this analysis bus is display-only; demand is not adjustable';
28
29
  const scoreTip =
29
- 'Gradient is the estimate of total cost change versus base before the solve finishes. It uses the selected bus LMP times the demand step, plus local curvature when the preview engine is unavailable. Exact is the resolved OPF objective change.';
30
+ 'Gradient estimates the declared objective change before the solve finishes. It uses the selected bus LMP times the demand step, plus local curvature when the preview engine is unavailable. Exact is the resolved OPF objective change.';
30
31
  </script>
31
32
 
32
33
  {#if ctrl.activeSolvable}
@@ -37,49 +38,57 @@
37
38
  <span class="val">{signed(ctrl.sliderValue)} MW</span>
38
39
  </div>
39
40
  <div class="range-mode">
40
- <div class="segment mono" aria-label="demand range">
41
+ <div class="segment mono" role="group" aria-label="demand range">
41
42
  <button
42
43
  type="button"
43
44
  class:active={app.demandRangeMode === 'local'}
44
- aria-pressed={app.demandRangeMode === 'local'}
45
- aria-label="nearby demand range"
46
- disabled={disabled}
45
+ aria-pressed={app.demandRangeMode === 'local'}
46
+ aria-label="nearby demand range"
47
+ {disabled}
47
48
  title="range near the selected demand setting"
48
49
  onclick={() => ctrl.setDemandRangeMode('local')}>nearby</button
49
50
  >
50
51
  <button
51
52
  type="button"
52
53
  class:active={app.demandRangeMode === 'full'}
53
- aria-pressed={app.demandRangeMode === 'full'}
54
- aria-label="full demand range"
55
- disabled={disabled}
54
+ aria-pressed={app.demandRangeMode === 'full'}
55
+ aria-label="full range of demand"
56
+ {disabled}
56
57
  title="range from zero load to the local physical limit"
57
58
  onclick={() => ctrl.setDemandRangeMode('full')}>full range</button
58
59
  >
59
60
  </div>
60
- <span class="mono dim">
61
- {#if disabled}
62
- not adjustable
63
- {:else}
64
- {fmt.format(ctrl.sliderMin)} to {fmt.format(ctrl.sliderMax)} MW
65
- {/if}
66
- </span>
61
+ <span class="mono dim">
62
+ {#if disabled}
63
+ not adjustable
64
+ {:else}
65
+ {fmt.format(ctrl.sliderMin)} to {fmt.format(ctrl.sliderMax)} MW
66
+ {/if}
67
+ </span>
67
68
  </div>
68
69
  <div
69
70
  class="slider-track"
70
71
  style="--fill-lo:{fillLo}; --fill-hi:{fillHi}; --neutral-pos:{neutralPos}"
71
- title={disabled ? disabledTip : sliderTip}
72
+ title={disabled ? disabledTip : sliderTip}
72
73
  >
73
74
  <input
74
75
  type="range"
75
76
  min={ctrl.sliderMin}
76
- max={ctrl.sliderMax}
77
- step="any"
78
- disabled={disabled}
77
+ max={ctrl.sliderMax}
78
+ step="any"
79
+ {disabled}
79
80
  bind:value={ctrl.sliderCurrent, ctrl.setSliderPreview}
80
81
  aria-label="demand delta at selected bus"
81
82
  onpointerdown={() => ctrl.setSliderPreview(ctrl.sliderValue)}
82
- onkeydown={() => ctrl.setSliderPreview(ctrl.sliderValue)}
83
+ onkeydown={(e) => {
84
+ const next = sliderKeyValue(e.key, ctrl.sliderValue, ctrl.sliderMin, ctrl.sliderMax);
85
+ if (next === null) {
86
+ ctrl.setSliderPreview(ctrl.sliderValue);
87
+ return;
88
+ }
89
+ e.preventDefault();
90
+ ctrl.setSliderPreview(next);
91
+ }}
83
92
  onpointerup={(e) => ctrl.finishDemandInput(Number(e.currentTarget.value))}
84
93
  onkeyup={(e) => ctrl.finishDemandInput(Number(e.currentTarget.value))}
85
94
  onchange={(e) => ctrl.finishDemandInput(Number(e.currentTarget.value))}
@@ -88,7 +97,7 @@
88
97
  <div class="demand-feedback" class:idle={!ctrl.previewing && !ctrl.isPerturbed(c)}>
89
98
  <p class="pred mono dim" aria-hidden={!(ctrl.predictedDeltaObj !== null && ctrl.previewing)}>
90
99
  {#if ctrl.predictedDeltaObj !== null && ctrl.previewing}
91
- predicted &Delta;cost {signed(ctrl.predictedDeltaObj)} $/h
100
+ predicted &Delta;objective {signed(ctrl.predictedDeltaObj)}
92
101
  {:else}
93
102
  &nbsp;
94
103
  {/if}
@@ -96,10 +105,8 @@
96
105
  <p class="score mono" aria-hidden={!(ctrl.gradientScore && ctrl.isPerturbed(c))}>
97
106
  {#if ctrl.gradientScore && ctrl.isPerturbed(c)}
98
107
  <span title={scoreTip}>
99
- gradient {signed(ctrl.gradientScore.pred)} &middot; exact {signed(
100
- ctrl.gradientScore.exact
101
- )}
102
- $/h
108
+ gradient {signed(ctrl.gradientScore.pred)}, exact {signed(ctrl.gradientScore.exact)} objective
109
+ units
103
110
  </span>
104
111
  {:else}
105
112
  &nbsp;
@@ -122,7 +129,7 @@
122
129
  .slider-head {
123
130
  display: flex;
124
131
  justify-content: space-between;
125
- font-size: 11.5px;
132
+ font-size: 13.5px;
126
133
  color: var(--text-secondary);
127
134
  margin-bottom: 4px;
128
135
  }
@@ -137,7 +144,7 @@
137
144
  justify-content: space-between;
138
145
  gap: 8px;
139
146
  margin: 6px 0 7px;
140
- font-size: 10.5px;
147
+ font-size: 12.5px;
141
148
  }
142
149
 
143
150
  .slider-track {
@@ -258,13 +265,13 @@
258
265
 
259
266
  .pred {
260
267
  margin: 2px 0 0;
261
- font-size: 11px;
268
+ font-size: 13px;
262
269
  min-height: 16px;
263
270
  }
264
271
 
265
272
  .score {
266
273
  margin: 8px 0 0;
267
- font-size: 11px;
274
+ font-size: 13px;
268
275
  color: var(--ink);
269
276
  min-height: 16px;
270
277
  }