@tellegen/svelte 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,6 +180,31 @@ it is set, `app.error` is a message written for people, and the detail holds
180
180
  the technical text behind it. `NotificationCenter` shows that text in a
181
181
  collapsed disclosure.
182
182
 
183
+ ### Log
184
+
185
+ `app.log` keeps every error and engine finding of the session as structured
186
+ entries: severity (error, warning or note), the step (`Open`, `Coordinates`,
187
+ `Solve`, `Export`), who reported it (`PowerIO` or `tellegen`), the code, the
188
+ message, the file or case it belongs to, the element and buses it names, and
189
+ PowerIO's suggested action and details. The controller records what PowerIO
190
+ reported while reading a case or a geographic layer, what each solve reported
191
+ (islands, HVDC transfers), what each export's writer reported, and every
192
+ `app.error`. A finding reported again counts up in place.
193
+
194
+ `DiagnosticLogView` (from `@tellegen/svelte/components`) shows the log grouped
195
+ by step and file, with severity filters, Copy, Download (`tellegen.log` JSON)
196
+ and Clear; pass `onbus` to make the named buses selectable. The default shell
197
+ shows it in a "Log" panel (`LogPanel`). A host can add its own findings:
198
+
199
+ ```ts
200
+ getAppState().log.record({
201
+ severity: 'warning',
202
+ stage: 'Fetch',
203
+ source: 'Overpass',
204
+ message: 'overpass-api.de answered 504; trying the next mirror'
205
+ });
206
+ ```
207
+
183
208
  ## Basemap
184
209
 
185
210
  CARTO's vector styles suit a network overlay best: quiet roads, flat land and
@@ -9,6 +9,7 @@
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 LogPanel from './components/LogPanel.svelte';
12
13
  import ShellEnvironment from './components/ShellEnvironment.svelte';
13
14
  import { getAppState, getController, getUiConfig } from './context.svelte.js';
14
15
  import TellegenMap from './TellegenMap.svelte';
@@ -120,6 +121,7 @@
120
121
  <NotificationCenter />
121
122
  <ControlPanel />
122
123
  <SolveCard />
124
+ <LogPanel />
123
125
  {#if !app.studyView}
124
126
  <DropZone />
125
127
  <PlacementCue />
@@ -0,0 +1,176 @@
1
+ <script lang="ts">
2
+ import { getAppState } from '../context.svelte.js';
3
+ import { downloadText } from '../controller.svelte.js';
4
+ import {
5
+ LOG_SEVERITIES,
6
+ type DiagnosticLog,
7
+ type LogEntry,
8
+ type LogEntryInput,
9
+ type LogSeverity
10
+ } from '../log.svelte.js';
11
+ import LogEntryView from './LogEntryView.svelte';
12
+ let {
13
+ log = getAppState().log,
14
+ onbus,
15
+ filename = 'tellegen-log.json',
16
+ empty = 'Nothing to report. Findings from opening, solving and exporting a case appear here.'
17
+ }: {
18
+ log?: DiagnosticLog;
19
+ /** Makes each named bus a button, for a host that can select it. */
20
+ onbus?: (bus: number, entry: LogEntryInput) => void;
21
+ filename?: string;
22
+ empty?: string;
23
+ } = $props();
24
+ const PLURAL = { error: 'Errors', warning: 'Warnings', note: 'Notes' } as const;
25
+ let filter = $state<LogSeverity | 'all'>('all');
26
+ let copied = $state(false);
27
+ const counts = $derived(log.counts);
28
+ const shown = $derived(
29
+ filter === 'all' ? log.entries : log.entries.filter((e) => e.severity === filter)
30
+ );
31
+ // One group per step and subject, in the order each first appeared.
32
+ const groups = $derived.by(() => {
33
+ const out = new Map<string, { stage: string; subject?: string; entries: LogEntry[] }>();
34
+ for (const entry of shown) {
35
+ const key = `${entry.stage}\u0000${entry.subject ?? ''}`;
36
+ let group = out.get(key);
37
+ if (!group)
38
+ out.set(key, (group = { stage: entry.stage, subject: entry.subject, entries: [] }));
39
+ group.entries.push(entry);
40
+ }
41
+ return [...out.values()];
42
+ });
43
+ $effect(() => {
44
+ if (filter !== 'all' && counts[filter] === 0) filter = 'all';
45
+ });
46
+ const text = () => JSON.stringify(log.toJSON(), null, 2);
47
+ async function copy() {
48
+ try {
49
+ await navigator.clipboard.writeText(text());
50
+ copied = true;
51
+ setTimeout(() => (copied = false), 1600);
52
+ } catch {
53
+ copied = false;
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <section class="log" aria-label="Log" data-testid="diagnostic-log">
59
+ {#if log.entries.length}
60
+ <div class="toolbar">
61
+ <div class="filters" role="group" aria-label="Show">
62
+ <button type="button" aria-pressed={filter === 'all'} onclick={() => (filter = 'all')}
63
+ >All <span class="n">{log.entries.length}</span></button
64
+ >
65
+ {#each LOG_SEVERITIES as severity (severity)}
66
+ {#if counts[severity]}
67
+ <button
68
+ type="button"
69
+ data-severity={severity}
70
+ aria-pressed={filter === severity}
71
+ onclick={() => (filter = severity)}
72
+ >{PLURAL[severity]} <span class="n">{counts[severity]}</span></button
73
+ >
74
+ {/if}
75
+ {/each}
76
+ </div>
77
+ <div class="actions">
78
+ <button type="button" onclick={copy}>{copied ? 'Copied' : 'Copy'}</button>
79
+ <button type="button" onclick={() => downloadText(text(), filename, 'application/json')}
80
+ >Download</button
81
+ >
82
+ <button type="button" onclick={() => log.clear()}>Clear</button>
83
+ </div>
84
+ </div>
85
+ {#each groups as group (`${group.stage}\u0000${group.subject ?? ''}`)}
86
+ <section class="group" data-stage={group.stage}>
87
+ <h4>
88
+ <span class="stage">{group.stage}</span>{#if group.subject}<span class="subject"
89
+ >{group.subject}</span
90
+ >{/if}
91
+ </h4>
92
+ <ol>
93
+ {#each group.entries as entry (entry.id)}
94
+ <li><LogEntryView {entry} showSubject={false} {onbus} /></li>
95
+ {/each}
96
+ </ol>
97
+ </section>
98
+ {/each}
99
+ {:else}
100
+ <p class="empty">{empty}</p>
101
+ {/if}
102
+ </section>
103
+
104
+ <style>
105
+ .log {
106
+ font: var(--fs-meta, 12px) / 1.45 var(--font-display);
107
+ color: var(--text-primary, var(--ink));
108
+ }
109
+ .toolbar {
110
+ display: flex;
111
+ flex-wrap: wrap;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ gap: 6px;
115
+ margin-bottom: 8px;
116
+ }
117
+ .filters,
118
+ .actions {
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ gap: 4px;
122
+ }
123
+ button {
124
+ font: inherit;
125
+ color: var(--ink);
126
+ border: 1px solid var(--line);
127
+ background: transparent;
128
+ padding: 3px 8px;
129
+ border-radius: 3px;
130
+ cursor: pointer;
131
+ }
132
+ button:hover {
133
+ background: var(--accent-soft);
134
+ }
135
+ button[aria-pressed='true'] {
136
+ background: var(--accent-soft);
137
+ border-color: var(--border-strong, var(--line));
138
+ }
139
+ button:focus-visible {
140
+ outline: 2px solid var(--focus-ring);
141
+ outline-offset: 2px;
142
+ }
143
+ .n {
144
+ font: 10px var(--font-mono);
145
+ color: var(--text-secondary);
146
+ }
147
+ .group + .group {
148
+ margin-top: 10px;
149
+ }
150
+ h4 {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ gap: 6px;
154
+ align-items: baseline;
155
+ margin: 0 0 4px;
156
+ font: 600 var(--fs-meta, 12px) / 1.4 var(--font-display);
157
+ }
158
+ .subject {
159
+ font-weight: 400;
160
+ color: var(--text-secondary);
161
+ overflow-wrap: anywhere;
162
+ }
163
+ ol {
164
+ list-style: none;
165
+ margin: 0;
166
+ padding: 0;
167
+ }
168
+ li {
169
+ padding: 6px 0;
170
+ border-top: 1px solid var(--line);
171
+ }
172
+ .empty {
173
+ margin: 0;
174
+ color: var(--text-secondary);
175
+ }
176
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type DiagnosticLog, type LogEntryInput } from '../log.svelte.js';
2
+ type $$ComponentProps = {
3
+ log?: DiagnosticLog;
4
+ /** Makes each named bus a button, for a host that can select it. */
5
+ onbus?: (bus: number, entry: LogEntryInput) => void;
6
+ filename?: string;
7
+ empty?: string;
8
+ };
9
+ declare const DiagnosticLogView: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type DiagnosticLogView = ReturnType<typeof DiagnosticLogView>;
11
+ export default DiagnosticLogView;
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import type { LogEntry, LogEntryInput } from '../log.svelte.js';
3
+ let {
4
+ entry,
5
+ showSubject = true,
6
+ onbus
7
+ }: {
8
+ entry: LogEntryInput | LogEntry;
9
+ /** Off inside a group that already names the subject. */
10
+ showSubject?: boolean;
11
+ /** Makes each named bus a button, for a host that can select it. */
12
+ onbus?: (bus: number, entry: LogEntryInput) => void;
13
+ } = $props();
14
+ const LABEL = { error: 'Error', warning: 'Warning', note: 'Note' } as const;
15
+ const MAX_BUSES = 12;
16
+ const count = $derived('count' in entry ? entry.count : 1);
17
+ const buses = $derived(entry.buses ?? []);
18
+ const details = $derived(
19
+ Object.entries(entry.details ?? {}).map(
20
+ ([key, value]) => [key, typeof value === 'string' ? value : JSON.stringify(value)] as const
21
+ )
22
+ );
23
+ </script>
24
+
25
+ <div class="entry" data-severity={entry.severity} data-testid="log-entry">
26
+ <span class="severity">{LABEL[entry.severity]}</span>
27
+ <div class="body">
28
+ <p class="message">
29
+ {entry.message}{#if count > 1}<span class="count" aria-label={`reported ${count} times`}
30
+ >{count}×</span
31
+ >{/if}
32
+ </p>
33
+ <dl class="meta">
34
+ <div>
35
+ <dt>From</dt>
36
+ <dd>{entry.source}</dd>
37
+ </div>
38
+ {#if entry.code}<div>
39
+ <dt>Code</dt>
40
+ <dd class="mono">{entry.code}</dd>
41
+ </div>{/if}
42
+ {#if showSubject && entry.subject}<div>
43
+ <dt>In</dt>
44
+ <dd>{entry.subject}</dd>
45
+ </div>{/if}
46
+ {#if entry.target}<div>
47
+ <dt>Element</dt>
48
+ <dd class="mono">{entry.target}</dd>
49
+ </div>{/if}
50
+ </dl>
51
+ {#if buses.length}
52
+ <p class="buses">
53
+ <span class="label">{buses.length === 1 ? 'Bus' : 'Buses'}</span>
54
+ {#each buses.slice(0, MAX_BUSES) as bus (bus)}
55
+ {#if onbus}<button type="button" onclick={() => onbus(bus, entry)}>{bus}</button
56
+ >{:else}<span class="bus">{bus}</span>{/if}
57
+ {/each}
58
+ {#if buses.length > MAX_BUSES}<span class="more">and {buses.length - MAX_BUSES} more</span
59
+ >{/if}
60
+ </p>
61
+ {/if}
62
+ {#if entry.hint}<p class="hint">{entry.hint}</p>{/if}
63
+ {#if details.length}
64
+ <details>
65
+ <summary>Details</summary>
66
+ <dl class="details">
67
+ {#each details as [key, value] (key)}
68
+ <div>
69
+ <dt class="mono">{key}</dt>
70
+ <dd class="mono">{value}</dd>
71
+ </div>
72
+ {/each}
73
+ </dl>
74
+ </details>
75
+ {/if}
76
+ </div>
77
+ </div>
78
+
79
+ <style>
80
+ .entry {
81
+ --log-warning: #8a5300;
82
+ display: grid;
83
+ grid-template-columns: 62px minmax(0, 1fr);
84
+ gap: 8px;
85
+ font: var(--fs-meta, 12px) / 1.45 var(--font-display);
86
+ color: var(--text-primary, var(--ink));
87
+ }
88
+ :global(:root[data-theme='dark']) .entry {
89
+ --log-warning: #f0c36d;
90
+ }
91
+ .severity {
92
+ font: 600 10px/1.9 var(--font-mono);
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.04em;
95
+ color: var(--text-secondary);
96
+ }
97
+ [data-severity='error'] .severity {
98
+ color: var(--text-danger, var(--red));
99
+ }
100
+ [data-severity='warning'] .severity {
101
+ color: var(--log-warning);
102
+ }
103
+ .body {
104
+ min-width: 0;
105
+ }
106
+ p {
107
+ margin: 0;
108
+ }
109
+ .message {
110
+ overflow-wrap: anywhere;
111
+ }
112
+ .count {
113
+ margin-left: 6px;
114
+ font: 10px var(--font-mono);
115
+ color: var(--text-secondary);
116
+ }
117
+ .meta {
118
+ display: flex;
119
+ flex-wrap: wrap;
120
+ gap: 2px 12px;
121
+ margin: 3px 0 0;
122
+ color: var(--text-secondary);
123
+ }
124
+ .meta div,
125
+ .details div {
126
+ display: flex;
127
+ gap: 4px;
128
+ min-width: 0;
129
+ }
130
+ dt {
131
+ color: var(--text-tertiary, var(--text-secondary));
132
+ }
133
+ dd {
134
+ margin: 0;
135
+ overflow-wrap: anywhere;
136
+ }
137
+ .mono {
138
+ font-family: var(--font-mono);
139
+ font-size: 11px;
140
+ }
141
+ .buses {
142
+ display: flex;
143
+ flex-wrap: wrap;
144
+ align-items: center;
145
+ gap: 4px;
146
+ margin-top: 4px;
147
+ }
148
+ .label,
149
+ .more {
150
+ color: var(--text-secondary);
151
+ }
152
+ .bus,
153
+ .buses button {
154
+ font: 11px var(--font-mono);
155
+ padding: 1px 6px;
156
+ border-radius: 3px;
157
+ background: var(--surface-chip, var(--accent-soft));
158
+ color: inherit;
159
+ border: 0;
160
+ }
161
+ .buses button {
162
+ cursor: pointer;
163
+ }
164
+ .buses button:hover {
165
+ background: var(--accent-soft);
166
+ }
167
+ .buses button:focus-visible,
168
+ summary:focus-visible {
169
+ outline: 2px solid var(--focus-ring);
170
+ outline-offset: 2px;
171
+ }
172
+ .hint {
173
+ margin-top: 4px;
174
+ color: var(--text-secondary);
175
+ }
176
+ details {
177
+ margin-top: 4px;
178
+ }
179
+ summary {
180
+ cursor: pointer;
181
+ color: var(--text-secondary);
182
+ }
183
+ .details {
184
+ display: grid;
185
+ gap: 2px;
186
+ margin: 4px 0 0;
187
+ }
188
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { LogEntry, LogEntryInput } from '../log.svelte.js';
2
+ type $$ComponentProps = {
3
+ entry: LogEntryInput | LogEntry;
4
+ /** Off inside a group that already names the subject. */
5
+ showSubject?: boolean;
6
+ /** Makes each named bus a button, for a host that can select it. */
7
+ onbus?: (bus: number, entry: LogEntryInput) => void;
8
+ };
9
+ declare const LogEntryView: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type LogEntryView = ReturnType<typeof LogEntryView>;
11
+ export default LogEntryView;
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import { getAppState, getController } from '../context.svelte.js';
3
+ import DiagnosticLogView from './DiagnosticLogView.svelte';
4
+ import PanelFrame from './PanelFrame.svelte';
5
+ const app = getAppState();
6
+ const ctrl = getController();
7
+ let open = $state(false);
8
+ // A bus named in a finding selects that bus on the case on screen.
9
+ function selectBus(bus: number) {
10
+ if (app.activeLocal) void ctrl.selectLocalBus(app.activeLocal.id, bus);
11
+ else if (app.activeCaseId) ctrl.selectBus(app.activeCaseId, bus);
12
+ }
13
+ </script>
14
+
15
+ <PanelFrame id="log" title="Log" side="right" order={40} width={360} bind:open>
16
+ <div class="log-body">
17
+ <DiagnosticLogView onbus={selectBus} />
18
+ </div>
19
+ </PanelFrame>
20
+
21
+ <style>
22
+ .log-body {
23
+ padding: 12px 14px;
24
+ }
25
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const LogPanel: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type LogPanel = ReturnType<typeof LogPanel>;
3
+ export default LogPanel;
@@ -2,7 +2,9 @@
2
2
  import { onDestroy, untrack } from 'svelte';
3
3
  import { formatPowerIoDiagnostic } from '@tellegen/engine';
4
4
  import { getAppState, getController } from '../context.svelte.js';
5
+ import { powerIoEntry, type LogEntryInput } from '../log.svelte.js';
5
6
  import { getNoticeCenter, errorNoticeTitle } from '../notices.svelte.js';
7
+ import LogEntryView from './LogEntryView.svelte';
6
8
  const app = getAppState();
7
9
  const ctrl = getController();
8
10
  const notices = getNoticeCenter();
@@ -33,7 +35,20 @@
33
35
  const multi = app.activeMulti;
34
36
  const summary = local?.summary ?? multi?.summary;
35
37
  const geo = local?.geoWarnings ?? multi?.geoWarnings;
36
- for (const [identity, title, details] of [
38
+ const subject = local?.fileName ?? multi?.fileName;
39
+ const entries: LogEntryInput[] = summary
40
+ ? [
41
+ ...summary.diagnostics.map((d) => powerIoEntry(d, { stage: 'Open', subject })),
42
+ ...('warnings' in summary ? summary.warnings : []).map((message) => ({
43
+ severity: 'note' as const,
44
+ stage: 'Open',
45
+ source: 'tellegen',
46
+ subject,
47
+ message
48
+ }))
49
+ ]
50
+ : [];
51
+ for (const [identity, title, details, items] of [
37
52
  [
38
53
  summary,
39
54
  'Case import notes',
@@ -42,13 +57,15 @@
42
57
  ...summary.diagnostics.map(formatPowerIoDiagnostic),
43
58
  ...('warnings' in summary ? summary.warnings : [])
44
59
  ].join('\n\n')
45
- : ''
60
+ : '',
61
+ entries
46
62
  ],
47
- [geo, 'Coordinate import notes', geo?.join('\n\n') ?? '']
48
- ] as Array<[object | undefined, string, string]>) {
63
+ [geo, 'Coordinate import notes', geo?.join('\n\n') ?? '', undefined]
64
+ ] as Array<[object | undefined, string, string, LogEntryInput[] | undefined]>) {
49
65
  if (identity && !observed.has(identity)) {
50
66
  observed.add(identity);
51
- if (details) untrack(() => notices.push({ kind: 'warning', title, details }));
67
+ if (details)
68
+ untrack(() => notices.push({ kind: 'warning', title, details, entries: items }));
52
69
  }
53
70
  }
54
71
  });
@@ -92,7 +109,15 @@
92
109
  >{entry.title}{#if entry.count > 1}<span class="count">{entry.count}</span
93
110
  >{/if}</summary
94
111
  >
95
- <pre data-testid="notification-details">{entry.details}</pre>
112
+ {#if entry.entries?.length}
113
+ <ol class="entries" data-testid="notification-details">
114
+ {#each entry.entries as item, i (i)}
115
+ <li><LogEntryView entry={item} showSubject={false} /></li>
116
+ {/each}
117
+ </ol>
118
+ {:else}
119
+ <pre data-testid="notification-details">{entry.details}</pre>
120
+ {/if}
96
121
  </details>
97
122
  {#if entry.retry && entry.canRetry?.()}<button onclick={entry.retry}>Retry</button
98
123
  >{/if}
@@ -227,6 +252,12 @@
227
252
  summary {
228
253
  cursor: pointer;
229
254
  }
255
+ .entries {
256
+ margin-top: 8px;
257
+ }
258
+ .entries li {
259
+ padding: 6px 0;
260
+ }
230
261
  pre {
231
262
  font: 11px/1.5 var(--font-mono);
232
263
  white-space: pre-wrap;
@@ -59,7 +59,7 @@ export declare class SchematicText {
59
59
  level: string;
60
60
  wires: string;
61
61
  loading: string | null;
62
- state: "normal" | "warning" | "violation";
62
+ state: "warning" | "normal" | "violation";
63
63
  }[];
64
64
  /** Rows of a bus stack's card. */
65
65
  stackRows(members: number[]): {
@@ -1,6 +1,9 @@
1
1
  export { default as ModelDetails } from './components/ModelDetails.svelte';
2
2
  export { default as AppFooter } from './components/AppFooter.svelte';
3
3
  export { default as NotificationCenter } from './components/NotificationCenter.svelte';
4
+ export { default as DiagnosticLogView } from './components/DiagnosticLogView.svelte';
5
+ export { default as LogEntryView } from './components/LogEntryView.svelte';
6
+ export { default as LogPanel } from './components/LogPanel.svelte';
4
7
  export { default as PanelHost } from './components/PanelHost.svelte';
5
8
  export { default as ShellEnvironment } from './components/ShellEnvironment.svelte';
6
9
  export { default as AppHeader } from './components/AppHeader.svelte';
@@ -1,6 +1,9 @@
1
1
  export { default as ModelDetails } from './components/ModelDetails.svelte';
2
2
  export { default as AppFooter } from './components/AppFooter.svelte';
3
3
  export { default as NotificationCenter } from './components/NotificationCenter.svelte';
4
+ export { default as DiagnosticLogView } from './components/DiagnosticLogView.svelte';
5
+ export { default as LogEntryView } from './components/LogEntryView.svelte';
6
+ export { default as LogPanel } from './components/LogPanel.svelte';
4
7
  export { default as PanelHost } from './components/PanelHost.svelte';
5
8
  export { default as ShellEnvironment } from './components/ShellEnvironment.svelte';
6
9
  export { default as AppHeader } from './components/AppHeader.svelte';
@@ -20,6 +20,9 @@ type SavedCaseCapture = {
20
20
  export declare function isDisplayOnlyElement(element: {
21
21
  editable?: boolean;
22
22
  } | null | undefined): boolean;
23
+ /** Trigger a browser download of in-memory text. Case files never leave the machine:
24
+ * a save is a local Blob download. */
25
+ export declare function downloadText(text: string, filename: string, mime: string): void;
23
26
  export type { SolvableCase };
24
27
  type DemandRangeAnchor = {
25
28
  caseId: string;
@@ -416,6 +419,10 @@ export declare class Controller {
416
419
  caseGeographyLayers(c: LocalCase): Promise<string[]>;
417
420
  /** Capture a matching cached result without constructing or solving a model. */
418
421
  captureSavedCase(c: SolvableCase): Promise<SavedCaseCapture>;
422
+ /** Record what PowerIO and the engine said while reading a case file. */
423
+ private logImport;
424
+ /** Record a geographic layer's reader findings and placement notes. */
425
+ private logCoordinates;
419
426
  /** Build a local case from a balanced PowerIO ingest payload without activating
420
427
  * it yet; co-dropped placement data is applied before the first solve. */
421
428
  private localFromBalancedPayload;
@@ -3,6 +3,7 @@ import { previewScaleFor, scalarDomain, sensFlatColor, sensitivityDomain } from
3
3
  import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displayMetaFor, displaySeriesFor } from './display.js';
4
4
  import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
5
5
  import { placeSyntheticTopology } from './synthetic-layout.js';
6
+ import { powerIoEntry, solveEntry } from './log.svelte.js';
6
7
  import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
7
8
  import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
8
9
  import { buildGeographicView, buildDiagramView, placeMultiView } from './multiconductor.js';
@@ -52,7 +53,7 @@ function delayUntilFocusSettles(ms, signal) {
52
53
  }
53
54
  /** Trigger a browser download of in-memory text. Case files never leave the machine:
54
55
  * a save is a local Blob download. */
55
- function downloadText(text, filename, mime) {
56
+ export function downloadText(text, filename, mime) {
56
57
  const url = URL.createObjectURL(new Blob([text], { type: mime }));
57
58
  const anchor = document.createElement('a');
58
59
  anchor.href = url;
@@ -443,6 +444,10 @@ export class Controller {
443
444
  if (seq !== (c.solveSeq ?? 0))
444
445
  return;
445
446
  c.solving = false;
447
+ // Every solve of a case restates the same findings; they count once.
448
+ const findings = c.solution?.diagnostics ?? [];
449
+ if (findings.length)
450
+ this.app.log.record(findings.map((d) => solveEntry(d, { subject: this.caseName(c) })), { repeat: false });
446
451
  const selectionLive = target !== null &&
447
452
  this.isActiveSolveCase(c) &&
448
453
  ('bus' in target
@@ -1019,6 +1024,7 @@ export class Controller {
1019
1024
  routes += payload.report.matched_branches;
1020
1025
  unmatched += payload.report.unmatched_features;
1021
1026
  warnings.push(...l.diagnostics.map((d) => `${l.name}: ${formatPowerIoDiagnostic(d)}`), ...payload.report.notes.map((n) => `${l.name}: ${n}`));
1027
+ this.logCoordinates(l.name, l.diagnostics, payload.report.notes);
1022
1028
  }
1023
1029
  if (!payload)
1024
1030
  return;
@@ -2043,6 +2049,7 @@ export class Controller {
2043
2049
  const label = summary.name && summary.name !== 'case'
2044
2050
  ? summary.name
2045
2051
  : file.name.replace(/\.[^.]+$/, '');
2052
+ this.logImport(file.name, summary);
2046
2053
  const local = new LocalCase({
2047
2054
  id,
2048
2055
  label,
@@ -2242,6 +2249,7 @@ export class Controller {
2242
2249
  const view = coordsKind === 'geographic'
2243
2250
  ? buildGeographicView(graph, payload.geo_layer)
2244
2251
  : buildDiagramView(graph, payload.geo_layer);
2252
+ this.logImport(fileName, summary);
2245
2253
  const c = new MulticonductorCase({
2246
2254
  id: `dist-${++this.localSeq}`,
2247
2255
  label,
@@ -2517,6 +2525,7 @@ export class Controller {
2517
2525
  next = payload.module_json;
2518
2526
  matched += payload.report.matched_buses + payload.report.matched_branches;
2519
2527
  notes.push(`${payload.report.matched_buses} buses, ${payload.report.matched_branches} routes matched; ${payload.report.unmatched_features} unmatched objects`, ...payload.report.notes, ...layer.diagnostics.map(formatPowerIoDiagnostic));
2528
+ this.logCoordinates(layer.name, layer.diagnostics, payload.report.notes);
2520
2529
  }
2521
2530
  if (!payload || !matched)
2522
2531
  throw new Error('No coordinates matched the selected case');
@@ -2743,7 +2752,13 @@ export class Controller {
2743
2752
  ...placeSyntheticTopology(c.topology, { lon: 0, lat: 0 }),
2744
2753
  coordinate_space: 'diagram'
2745
2754
  };
2746
- return { id: 'study', name: c.name, base_mva: c.base_mva, synthetic_coords: !c.view, ...view };
2755
+ return {
2756
+ id: 'study',
2757
+ name: c.name,
2758
+ base_mva: c.base_mva,
2759
+ synthetic_coords: !c.view,
2760
+ ...view
2761
+ };
2747
2762
  }
2748
2763
  async caseGeographyLayers(c) {
2749
2764
  if (!c.studyInputJson || !c.view)
@@ -2781,15 +2796,46 @@ export class Controller {
2781
2796
  if (snapshot.base_input !== base ||
2782
2797
  JSON.stringify(snapshot.display_solution) !== JSON.stringify(c.solution))
2783
2798
  throw new Error('The server case changed. Reload it before saving.');
2784
- return { input: snapshot.input, solution: snapshot.solution, view: snapshot.view };
2799
+ return {
2800
+ input: snapshot.input,
2801
+ solution: snapshot.solution,
2802
+ view: snapshot.view
2803
+ };
2785
2804
  }
2786
2805
  return { input: base };
2787
2806
  }
2807
+ /** Record what PowerIO and the engine said while reading a case file. */
2808
+ logImport(fileName, summary) {
2809
+ this.app.log.record([
2810
+ ...(summary.diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Open', subject: fileName })),
2811
+ ...(summary.warnings ?? []).map((message) => ({
2812
+ severity: 'note',
2813
+ stage: 'Open',
2814
+ source: 'tellegen',
2815
+ subject: fileName,
2816
+ message
2817
+ }))
2818
+ ]);
2819
+ }
2820
+ /** Record a geographic layer's reader findings and placement notes. */
2821
+ logCoordinates(layer, diagnostics, notes) {
2822
+ this.app.log.record([
2823
+ ...(diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Coordinates', subject: layer })),
2824
+ ...(notes ?? []).map((message) => ({
2825
+ severity: 'note',
2826
+ stage: 'Coordinates',
2827
+ source: 'tellegen',
2828
+ subject: layer,
2829
+ message
2830
+ }))
2831
+ ]);
2832
+ }
2788
2833
  /** Build a local case from a balanced PowerIO ingest payload without activating
2789
2834
  * it yet; co-dropped placement data is applied before the first solve. */
2790
2835
  localFromBalancedPayload = (fileName, payload) => {
2791
2836
  const { module_json, topology, view, formulation, ...summary } = payload;
2792
2837
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
2838
+ this.logImport(fileName, summary);
2793
2839
  return new LocalCase({
2794
2840
  id: `local-${++this.localSeq}`,
2795
2841
  label,
@@ -2832,7 +2878,11 @@ export class Controller {
2832
2878
  local.formulation = input.formulation;
2833
2879
  if (input.geoLayer) {
2834
2880
  await this.applyGeoLayers(local, [
2835
- { name: `${input.name}.geo.json`, layer: input.geoLayer, diagnostics: [] }
2881
+ {
2882
+ name: `${input.name}.geo.json`,
2883
+ layer: input.geoLayer,
2884
+ diagnostics: []
2885
+ }
2836
2886
  ]);
2837
2887
  }
2838
2888
  this.placeTopologyAsDiagram(local);
@@ -2940,8 +2990,10 @@ export class Controller {
2940
2990
  return [];
2941
2991
  try {
2942
2992
  const exported = await study.export(format);
2943
- downloadText(exported.text, `${this.caseFileStem(c)}.${exported.extension}`, 'text/plain');
2993
+ const fileName = `${this.caseFileStem(c)}.${exported.extension}`;
2994
+ downloadText(exported.text, fileName, 'text/plain');
2944
2995
  this.app.error = null;
2996
+ this.app.log.record((exported.diagnostics ?? []).map((d) => powerIoEntry(d, { stage: 'Export', subject: fileName })), { repeat: false });
2945
2997
  return exported.diagnostics;
2946
2998
  }
2947
2999
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { getNoticeCenter, errorNoticeTitle } from './notices.svelte.js';
2
+ export { DiagnosticLog, LOG_SEVERITIES, errorEntry, errorStage, logSeverity, powerIoEntry, solveEntry } from './log.svelte.js';
3
+ export type { LogDocument, LogEntry, LogEntryInput, LogSeverity } from './log.svelte.js';
2
4
  export { default as ModelDetails } from './components/ModelDetails.svelte';
3
5
  export { default as PanelFrame } from './components/PanelFrame.svelte';
4
6
  export { getPanelLayout } from './panels.svelte.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { getNoticeCenter, errorNoticeTitle } from './notices.svelte.js';
2
+ export { DiagnosticLog, LOG_SEVERITIES, errorEntry, errorStage, logSeverity, powerIoEntry, solveEntry } from './log.svelte.js';
2
3
  export { default as ModelDetails } from './components/ModelDetails.svelte';
3
4
  export { default as PanelFrame } from './components/PanelFrame.svelte';
4
5
  export { getPanelLayout } from './panels.svelte.js';
@@ -0,0 +1,77 @@
1
+ import type { PowerIoDiagnostic, SolveDiagnostic } from '@tellegen/engine';
2
+ /** How serious a finding is. PowerIO's `remark` and `note` both read as notes. */
3
+ export type LogSeverity = 'error' | 'warning' | 'note';
4
+ export declare const LOG_SEVERITIES: readonly LogSeverity[];
5
+ /** One finding to record. `stage` names what was happening (`Open`, `Solve`,
6
+ * `Export`, or a host's own step such as `Generate`); `source` names who
7
+ * reported it (`PowerIO`, `tellegen`, or a host's data source). */
8
+ export interface LogEntryInput {
9
+ severity: LogSeverity;
10
+ stage: string;
11
+ source: string;
12
+ message: string;
13
+ /** Stable machine code, such as `EMIT.MATPOWER.FIELD_DROPPED`. */
14
+ code?: string;
15
+ /** The case, file or download the finding belongs to. */
16
+ subject?: string;
17
+ /** The element the finding names, in PowerIO's spelling. */
18
+ target?: string;
19
+ buses?: number[];
20
+ /** What to do about it, when the reporter says. */
21
+ hint?: string;
22
+ details?: Record<string, unknown>;
23
+ }
24
+ export interface LogEntry extends LogEntryInput {
25
+ id: number;
26
+ /** How many times the same finding was reported. */
27
+ count: number;
28
+ /** Epoch milliseconds of the first and the latest report. */
29
+ first: number;
30
+ last: number;
31
+ }
32
+ /** The log as a file: what `DiagnosticLog.toJSON` returns and the log view downloads. */
33
+ export interface LogDocument {
34
+ format: 'tellegen.log';
35
+ version: 1;
36
+ written_at: string;
37
+ counts: Record<LogSeverity, number>;
38
+ entries: (Omit<LogEntry, 'first' | 'last'> & {
39
+ first: string;
40
+ last: string;
41
+ })[];
42
+ }
43
+ /** Every finding the app reported, in the order it first appeared. A finding
44
+ * reported again counts up in place instead of adding a row, so a re-solve or a
45
+ * second export of the same case does not flood the log. */
46
+ export declare class DiagnosticLog {
47
+ #private;
48
+ entries: LogEntry[];
49
+ /** Bumped on every write, including a repeat that only moves `last`. */
50
+ revision: number;
51
+ constructor(now?: () => number);
52
+ /** Record findings. With `repeat: false`, a finding already in the log keeps
53
+ * its count and takes the new details: use it for state that every solve
54
+ * restates, or to add the detail behind an error just recorded. */
55
+ record(input: LogEntryInput | readonly LogEntryInput[], options?: {
56
+ repeat?: boolean;
57
+ }): void;
58
+ clear(): void;
59
+ get counts(): Record<LogSeverity, number>;
60
+ toJSON(): LogDocument;
61
+ }
62
+ export declare function logSeverity(severity: PowerIoDiagnostic['severity']): LogSeverity;
63
+ interface EntryContext {
64
+ stage: string;
65
+ subject?: string;
66
+ }
67
+ /** A PowerIO reader or writer finding as a log entry. */
68
+ export declare function powerIoEntry(d: PowerIoDiagnostic, context: EntryContext): LogEntryInput;
69
+ /** A solver finding (islands, HVDC transfers) as a log entry. */
70
+ export declare function solveEntry(d: SolveDiagnostic, context: {
71
+ subject?: string;
72
+ }): LogEntryInput;
73
+ /** Which step an app error message comes from, by its wording. */
74
+ export declare function errorStage(message: string): string;
75
+ /** An app error as a log entry. */
76
+ export declare function errorEntry(message: string, detail?: string | null): LogEntryInput;
77
+ export {};
@@ -0,0 +1,149 @@
1
+ export const LOG_SEVERITIES = ['error', 'warning', 'note'];
2
+ const MAX_ENTRIES = 500;
3
+ const keyOf = (e) => JSON.stringify([e.severity, e.stage, e.source, e.code, e.subject, e.target, e.message]);
4
+ /** Every finding the app reported, in the order it first appeared. A finding
5
+ * reported again counts up in place instead of adding a row, so a re-solve or a
6
+ * second export of the same case does not flood the log. */
7
+ export class DiagnosticLog {
8
+ entries = $state.raw([]);
9
+ /** Bumped on every write, including a repeat that only moves `last`. */
10
+ revision = $state(0);
11
+ #nextId = 0;
12
+ #now;
13
+ constructor(now = Date.now) {
14
+ this.#now = now;
15
+ }
16
+ /** Record findings. With `repeat: false`, a finding already in the log keeps
17
+ * its count and takes the new details: use it for state that every solve
18
+ * restates, or to add the detail behind an error just recorded. */
19
+ record(input, options = {}) {
20
+ const inputs = Array.isArray(input) ? input : [input];
21
+ if (!inputs.length)
22
+ return;
23
+ const repeat = options.repeat ?? true;
24
+ const now = this.#now();
25
+ const next = [...this.entries];
26
+ const index = new Map(next.map((entry, i) => [keyOf(entry), i]));
27
+ for (const item of inputs) {
28
+ if (!item.message)
29
+ continue;
30
+ const key = keyOf(item);
31
+ const at = index.get(key);
32
+ if (at !== undefined) {
33
+ const previous = next[at];
34
+ next[at] = repeat
35
+ ? { ...previous, count: previous.count + 1, last: now }
36
+ : { ...previous, ...clean(item), last: now };
37
+ continue;
38
+ }
39
+ index.set(key, next.length);
40
+ next.push({
41
+ ...clean(item),
42
+ id: ++this.#nextId,
43
+ count: 1,
44
+ first: now,
45
+ last: now
46
+ });
47
+ }
48
+ this.entries = next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next;
49
+ this.revision++;
50
+ }
51
+ clear() {
52
+ this.entries = [];
53
+ this.revision++;
54
+ }
55
+ get counts() {
56
+ const counts = { error: 0, warning: 0, note: 0 };
57
+ for (const entry of this.entries)
58
+ counts[entry.severity]++;
59
+ return counts;
60
+ }
61
+ toJSON() {
62
+ return {
63
+ format: 'tellegen.log',
64
+ version: 1,
65
+ written_at: new Date(this.#now()).toISOString(),
66
+ counts: this.counts,
67
+ entries: this.entries.map((entry) => ({
68
+ ...entry,
69
+ first: new Date(entry.first).toISOString(),
70
+ last: new Date(entry.last).toISOString()
71
+ }))
72
+ };
73
+ }
74
+ }
75
+ function clean(input) {
76
+ const out = {
77
+ severity: input.severity,
78
+ stage: input.stage,
79
+ source: input.source,
80
+ message: input.message
81
+ };
82
+ if (input.code)
83
+ out.code = input.code;
84
+ if (input.subject)
85
+ out.subject = input.subject;
86
+ if (input.target)
87
+ out.target = input.target;
88
+ if (input.buses?.length)
89
+ out.buses = [...input.buses];
90
+ if (input.hint)
91
+ out.hint = input.hint;
92
+ if (input.details && Object.keys(input.details).length)
93
+ out.details = input.details;
94
+ return out;
95
+ }
96
+ export function logSeverity(severity) {
97
+ return severity === 'error' ? 'error' : severity === 'warning' ? 'warning' : 'note';
98
+ }
99
+ /** A PowerIO reader or writer finding as a log entry. */
100
+ export function powerIoEntry(d, context) {
101
+ return {
102
+ severity: logSeverity(d.severity),
103
+ stage: context.stage,
104
+ source: 'PowerIO',
105
+ code: d.code,
106
+ message: d.message,
107
+ subject: context.subject,
108
+ target: d.target,
109
+ hint: d.suggested_action,
110
+ details: d.details
111
+ };
112
+ }
113
+ // Solve findings that change what the numbers mean; the rest say how the
114
+ // solver read the case.
115
+ const SOLVE_WARNINGS = new Set(['island_deenergized', 'BUILD.HVDC.TERMINAL_INACTIVE']);
116
+ /** A solver finding (islands, HVDC transfers) as a log entry. */
117
+ export function solveEntry(d, context) {
118
+ return {
119
+ severity: SOLVE_WARNINGS.has(d.code) ? 'warning' : 'note',
120
+ stage: 'Solve',
121
+ source: 'tellegen',
122
+ code: d.code,
123
+ message: d.message,
124
+ subject: context.subject,
125
+ buses: d.buses
126
+ };
127
+ }
128
+ /** Which step an app error message comes from, by its wording. */
129
+ export function errorStage(message) {
130
+ if (/export|could not save|download/i.test(message))
131
+ return 'Export';
132
+ if (/coordinat|geographic|layout|placement|placed/i.test(message))
133
+ return 'Coordinates';
134
+ if (/infeasib|converg|solv|OPF|power flow|calculation|sensitivity|webassembly|study/i.test(message))
135
+ return 'Solve';
136
+ if (/pars|read|file|format|module/i.test(message))
137
+ return 'Open';
138
+ return 'App';
139
+ }
140
+ /** An app error as a log entry. */
141
+ export function errorEntry(message, detail) {
142
+ return {
143
+ severity: 'error',
144
+ stage: errorStage(message),
145
+ source: 'tellegen',
146
+ message,
147
+ details: detail ? { detail } : undefined
148
+ };
149
+ }
@@ -1,6 +1,9 @@
1
+ import type { LogEntryInput } from './log.svelte.js';
1
2
  export interface NoticeInput {
2
3
  title: string;
3
4
  details: string;
5
+ /** Structured findings behind `details`; the notice lists them when present. */
6
+ entries?: LogEntryInput[];
4
7
  kind?: 'error' | 'warning';
5
8
  retry?: () => void;
6
9
  canRetry?: () => boolean;
@@ -1,6 +1,7 @@
1
1
  import type { CaseSummary, DemandDeltas, Network, NetworkBranch, NetworkBus, SensitivityColumn, Solution, SolveIteration } from './api.js';
2
2
  import { type StudyView, type BranchRatingDeltas, type BrowserMcPfSession, type CaseFileSummary, type DistGraph, type Formulation, type IngestedDistCase, type McPfCallTiming, type McPfResult, type McPfSummary, type McLoadBranchState, type McLoadPowerEdit, type McStudySnapshot, type SensTarget, type Topology } from '@tellegen/engine';
3
3
  import type { MultiView } from './multiconductor.js';
4
+ import { DiagnosticLog } from './log.svelte.js';
4
5
  import { type GraphInputs } from './diagram/graph-inputs.js';
5
6
  import { type SearchIndex } from './diagram/search.js';
6
7
  import { type ResultSnapshot } from './diagram/snapshot.js';
@@ -339,12 +340,12 @@ export declare class AppState {
339
340
  /** Re-runs the operation behind the current `error`, when one applies. Every
340
341
  * write to `error` clears it, so a retry op can never outlive its message. */
341
342
  errorRetry: (() => void) | null;
342
- /** Technical detail behind the current `error`, when the message itself is
343
- * written for people; notices keep it in their disclosure. Every write to
344
- * `error` clears it, as it clears `errorRetry`. */
345
- errorDetail: string | null;
343
+ /** Every error and engine finding of this session, structured; see `DiagnosticLog`. */
344
+ readonly log: DiagnosticLog;
346
345
  get error(): string | null;
347
346
  set error(message: string | null);
347
+ get errorDetail(): string | null;
348
+ set errorDetail(detail: string | null);
348
349
  /** Case files parsed in the browser via the powerio wasm module. */
349
350
  localCases: LocalCase[];
350
351
  /** Local case the panel shows; clicking a bundled case or a bus clears it. */
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_FORMULATION } from '@tellegen/engine';
2
+ import { DiagnosticLog, errorEntry } from './log.svelte.js';
2
3
  import { graphInputs } from './diagram/graph-inputs.js';
3
4
  import { buildSearchIndex } from './diagram/search.js';
4
5
  import { snapshotFromMcPf } from './diagram/snapshot.js';
@@ -248,7 +249,11 @@ export class AppState {
248
249
  throw new Error('Invalid saved drawing view');
249
250
  }
250
251
  this.settleFrame();
251
- this.diagramCameraRequest = { caseId, center: [...snapshot.center], scale: snapshot.scale };
252
+ this.diagramCameraRequest = {
253
+ caseId,
254
+ center: [...snapshot.center],
255
+ scale: snapshot.scale
256
+ };
252
257
  this.diagramCameraSeq++;
253
258
  }
254
259
  requestCamera(snapshot) {
@@ -297,16 +302,28 @@ export class AppState {
297
302
  /** Technical detail behind the current `error`, when the message itself is
298
303
  * written for people; notices keep it in their disclosure. Every write to
299
304
  * `error` clears it, as it clears `errorRetry`. */
300
- errorDetail = $state(null);
305
+ #errorDetail = $state(null);
306
+ /** Every error and engine finding of this session, structured; see `DiagnosticLog`. */
307
+ log = new DiagnosticLog();
301
308
  get error() {
302
309
  return this.#error;
303
310
  }
304
311
  set error(message) {
305
312
  this.#error = message;
306
- if (message)
313
+ if (message) {
307
314
  this.errorRevision++;
315
+ this.log.record(errorEntry(message));
316
+ }
308
317
  this.errorRetry = null;
309
- this.errorDetail = null;
318
+ this.#errorDetail = null;
319
+ }
320
+ get errorDetail() {
321
+ return this.#errorDetail;
322
+ }
323
+ set errorDetail(detail) {
324
+ this.#errorDetail = detail;
325
+ if (detail && this.#error)
326
+ this.log.record(errorEntry(this.#error, detail), { repeat: false });
310
327
  }
311
328
  /** Case files parsed in the browser via the powerio wasm module. */
312
329
  localCases = $state.raw([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tellegen/svelte",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Svelte components for tellegen power system maps, local case files, and browser solves.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -67,7 +67,7 @@
67
67
  "pack:dry-run": "npm pack --dry-run --cache ../../node_modules/.cache/npm-pack"
68
68
  },
69
69
  "dependencies": {
70
- "@tellegen/engine": "^0.4.3"
70
+ "@tellegen/engine": "^0.4.6"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@deck.gl/core": "^9.4.0",