@tellegen/svelte 0.4.4 → 0.4.6
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 +25 -0
- package/dist/TellegenMap.svelte +21 -8
- package/dist/TellegenShell.svelte +2 -0
- package/dist/components/DiagnosticLogView.svelte +176 -0
- package/dist/components/DiagnosticLogView.svelte.d.ts +11 -0
- package/dist/components/LogEntryView.svelte +188 -0
- package/dist/components/LogEntryView.svelte.d.ts +11 -0
- package/dist/components/LogPanel.svelte +25 -0
- package/dist/components/LogPanel.svelte.d.ts +3 -0
- package/dist/components/NotificationCenter.svelte +37 -6
- package/dist/components/schematic/text.d.ts +1 -1
- package/dist/components.d.ts +3 -0
- package/dist/components.js +3 -0
- package/dist/controller.svelte.d.ts +7 -0
- package/dist/controller.svelte.js +57 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/log.svelte.d.ts +77 -0
- package/dist/log.svelte.js +149 -0
- package/dist/notices.svelte.d.ts +3 -0
- package/dist/state.svelte.d.ts +5 -4
- package/dist/state.svelte.js +21 -4
- package/package.json +2 -2
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
|
package/dist/TellegenMap.svelte
CHANGED
|
@@ -844,17 +844,26 @@
|
|
|
844
844
|
// maplibre's own WebGL2 context instead of a second canvas, so the page
|
|
845
845
|
// holds one GL context rather than two. Safari evicts WebGL contexts under
|
|
846
846
|
// memory and tab pressure, and one context survives that far better; the
|
|
847
|
-
// deck layers also inherit maplibre's 4096 canvas clamp. The flat
|
|
848
|
-
//
|
|
849
|
-
|
|
847
|
+
// deck layers also inherit maplibre's 4096 canvas clamp. The flat basemap
|
|
848
|
+
// writes no depth, so on the flat map the overlay always draws on top. On
|
|
849
|
+
// the globe it tests against the depth maplibre writes for the earth, so a
|
|
850
|
+
// network on the far hemisphere hides behind it instead of showing through.
|
|
851
|
+
function overlayParameters(type: MapProjection) {
|
|
852
|
+
return {
|
|
853
|
+
depthWriteEnabled: false,
|
|
854
|
+
depthCompare: type === 'globe' ? ('less-equal' as const) : ('always' as const)
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function buildOverlay(
|
|
859
|
+
MapLibreOverlay: MapModules['MapLibreOverlay'],
|
|
860
|
+
type: MapProjection
|
|
861
|
+
): MapLibreOverlay {
|
|
850
862
|
const touch = coarsePointer();
|
|
851
863
|
return new MapLibreOverlay({
|
|
852
864
|
interleaved: true,
|
|
853
865
|
layers: [],
|
|
854
|
-
parameters:
|
|
855
|
-
depthWriteEnabled: false,
|
|
856
|
-
depthCompare: 'always'
|
|
857
|
-
},
|
|
866
|
+
parameters: overlayParameters(type),
|
|
858
867
|
// a small bus draws ~9px across, well under a fingertip
|
|
859
868
|
pickingRadius: touch ? 12 : 0,
|
|
860
869
|
onClick: (info: PickingInfo) => {
|
|
@@ -915,7 +924,10 @@
|
|
|
915
924
|
onFallback: (reason) => onBasemapFallback?.(reason),
|
|
916
925
|
onFailure: (reason) => onBasemapError?.(reason)
|
|
917
926
|
});
|
|
918
|
-
const o = buildOverlay(
|
|
927
|
+
const o = buildOverlay(
|
|
928
|
+
MapLibreOverlay,
|
|
929
|
+
untrack(() => projection)
|
|
930
|
+
);
|
|
919
931
|
m.addControl(o);
|
|
920
932
|
m.on('click', (e) => {
|
|
921
933
|
if (app.placingId) onplacecase(e.lngLat.lng, e.lngLat.lat);
|
|
@@ -1057,6 +1069,7 @@
|
|
|
1057
1069
|
// MapLibre throws, and the `style.load` handler above applies it instead.
|
|
1058
1070
|
$effect(() => {
|
|
1059
1071
|
const type = projection;
|
|
1072
|
+
overlay?.setProps({ parameters: overlayParameters(type) });
|
|
1060
1073
|
const m = map;
|
|
1061
1074
|
if (!m) return;
|
|
1062
1075
|
try {
|
|
@@ -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>
|
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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: "
|
|
62
|
+
state: "warning" | "normal" | "violation";
|
|
63
63
|
}[];
|
|
64
64
|
/** Rows of a bus stack's card. */
|
|
65
65
|
stackRows(members: number[]): {
|
package/dist/components.d.ts
CHANGED
|
@@ -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';
|
package/dist/components.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/notices.svelte.d.ts
CHANGED
|
@@ -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;
|
package/dist/state.svelte.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
343
|
-
|
|
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. */
|
package/dist/state.svelte.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.4.6",
|
|
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.
|
|
70
|
+
"@tellegen/engine": "^0.4.6"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@deck.gl/core": "^9.4.0",
|