@tellegen/svelte 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +15 -0
  2. package/dist/Sparkline.svelte.d.ts +0 -1
  3. package/dist/TellegenMap.svelte +109 -47
  4. package/dist/TellegenMap.svelte.d.ts +0 -1
  5. package/dist/TellegenProvider.svelte.d.ts +0 -1
  6. package/dist/TellegenShell.svelte +28 -3
  7. package/dist/TellegenShell.svelte.d.ts +0 -1
  8. package/dist/TellegenViewer.svelte.d.ts +0 -1
  9. package/dist/api.d.ts +0 -1
  10. package/dist/colors.d.ts +0 -1
  11. package/dist/components/AppFooter.svelte +26 -1
  12. package/dist/components/AppFooter.svelte.d.ts +5 -18
  13. package/dist/components/AppHeader.svelte +63 -3
  14. package/dist/components/AppHeader.svelte.d.ts +0 -1
  15. package/dist/components/BindingLines.svelte +13 -0
  16. package/dist/components/BindingLines.svelte.d.ts +0 -1
  17. package/dist/components/BusPicker.svelte +40 -0
  18. package/dist/components/BusPicker.svelte.d.ts +4 -2
  19. package/dist/components/ControlPanel.svelte +359 -72
  20. package/dist/components/ControlPanel.svelte.d.ts +2 -18
  21. package/dist/components/DemandSlider.svelte +28 -8
  22. package/dist/components/DemandSlider.svelte.d.ts +0 -1
  23. package/dist/components/DisplayControls.svelte.d.ts +0 -1
  24. package/dist/components/DropZone.svelte.d.ts +0 -1
  25. package/dist/components/FormulationSelector.svelte +17 -0
  26. package/dist/components/FormulationSelector.svelte.d.ts +0 -1
  27. package/dist/components/LocalCaseDetails.svelte +5 -2
  28. package/dist/components/LocalCaseDetails.svelte.d.ts +0 -1
  29. package/dist/components/MulticonductorDetails.svelte +3 -3
  30. package/dist/components/MulticonductorDetails.svelte.d.ts +0 -1
  31. package/dist/components/NetworkStats.svelte.d.ts +0 -1
  32. package/dist/components/PlacementCue.svelte.d.ts +0 -1
  33. package/dist/components/RatingSlider.svelte.d.ts +0 -1
  34. package/dist/components/RestoreDefaultsButton.svelte.d.ts +0 -1
  35. package/dist/components/SensitivityReadout.svelte +10 -1
  36. package/dist/components/SensitivityReadout.svelte.d.ts +0 -1
  37. package/dist/components/SizeLegend.svelte.d.ts +0 -1
  38. package/dist/components/SolveCard.svelte +2 -1
  39. package/dist/components/SolveCard.svelte.d.ts +0 -1
  40. package/dist/components/TopMovers.svelte.d.ts +0 -1
  41. package/dist/components.d.ts +0 -1
  42. package/dist/context.svelte.d.ts +0 -1
  43. package/dist/controller.svelte.d.ts +24 -12
  44. package/dist/controller.svelte.js +218 -101
  45. package/dist/display.d.ts +0 -1
  46. package/dist/drop-classify.d.ts +5 -42
  47. package/dist/drop-classify.js +5 -78
  48. package/dist/drop-limits.d.ts +27 -0
  49. package/dist/drop-limits.js +83 -0
  50. package/dist/format.d.ts +0 -1
  51. package/dist/index.d.ts +3 -5
  52. package/dist/index.js +2 -2
  53. package/dist/map-bounds.d.ts +5 -0
  54. package/dist/map-bounds.js +41 -0
  55. package/dist/map.d.ts +0 -1
  56. package/dist/multiconductor.d.ts +0 -1
  57. package/dist/state.svelte.d.ts +13 -1
  58. package/dist/state.svelte.js +13 -0
  59. package/dist/study-package.d.ts +0 -1
  60. package/dist/styles.css +16 -2
  61. package/dist/synthetic-layout.d.ts +0 -1
  62. package/dist/synthetic-layout.js +16 -2
  63. package/dist/transformer-icon.d.ts +0 -1
  64. package/dist/types.d.ts +0 -1
  65. package/package.json +14 -11
  66. package/dist/Sparkline.svelte.d.ts.map +0 -1
  67. package/dist/TellegenMap.svelte.d.ts.map +0 -1
  68. package/dist/TellegenProvider.svelte.d.ts.map +0 -1
  69. package/dist/TellegenShell.svelte.d.ts.map +0 -1
  70. package/dist/TellegenViewer.svelte.d.ts.map +0 -1
  71. package/dist/api.d.ts.map +0 -1
  72. package/dist/colors.d.ts.map +0 -1
  73. package/dist/components/AppFooter.svelte.d.ts.map +0 -1
  74. package/dist/components/AppHeader.svelte.d.ts.map +0 -1
  75. package/dist/components/BindingLines.svelte.d.ts.map +0 -1
  76. package/dist/components/BusPicker.svelte.d.ts.map +0 -1
  77. package/dist/components/ControlPanel.svelte.d.ts.map +0 -1
  78. package/dist/components/DemandSlider.svelte.d.ts.map +0 -1
  79. package/dist/components/DisplayControls.svelte.d.ts.map +0 -1
  80. package/dist/components/DropZone.svelte.d.ts.map +0 -1
  81. package/dist/components/FormulationSelector.svelte.d.ts.map +0 -1
  82. package/dist/components/LocalCaseDetails.svelte.d.ts.map +0 -1
  83. package/dist/components/MulticonductorDetails.svelte.d.ts.map +0 -1
  84. package/dist/components/NetworkStats.svelte.d.ts.map +0 -1
  85. package/dist/components/PlacementCue.svelte.d.ts.map +0 -1
  86. package/dist/components/RatingSlider.svelte.d.ts.map +0 -1
  87. package/dist/components/RestoreDefaultsButton.svelte.d.ts.map +0 -1
  88. package/dist/components/SensitivityReadout.svelte.d.ts.map +0 -1
  89. package/dist/components/SizeLegend.svelte.d.ts.map +0 -1
  90. package/dist/components/SolveCard.svelte.d.ts.map +0 -1
  91. package/dist/components/TopMovers.svelte.d.ts.map +0 -1
  92. package/dist/components.d.ts.map +0 -1
  93. package/dist/context.svelte.d.ts.map +0 -1
  94. package/dist/controller.svelte.d.ts.map +0 -1
  95. package/dist/display.d.ts.map +0 -1
  96. package/dist/drop-classify.d.ts.map +0 -1
  97. package/dist/format.d.ts.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/map.d.ts.map +0 -1
  100. package/dist/multiconductor.d.ts.map +0 -1
  101. package/dist/state.svelte.d.ts.map +0 -1
  102. package/dist/study-package.d.ts.map +0 -1
  103. package/dist/synthetic-layout.d.ts.map +0 -1
  104. package/dist/transformer-icon.d.ts.map +0 -1
  105. package/dist/types.d.ts.map +0 -1
@@ -4,9 +4,10 @@ import { caseDeltas as sharedCaseDeltas, caseRatings as sharedCaseRatings, displ
4
4
  import { CaseState, LocalCase, MulticonductorCase } from './state.svelte.js';
5
5
  import { placeSyntheticTopology } from './synthetic-layout.js';
6
6
  import { studyCommitIndex } from './study-package.js';
7
- import { classifyJson, distExtensionFormat, isGeoFileName } from './drop-classify.js';
7
+ import { distExtensionFormat, isGeoFileName } from './drop-classify.js';
8
+ import { DropBatchGate, readDropFileBytes, validateDropBatch } from './drop-limits.js';
8
9
  import { buildGeographicView, placeMultiView } from './multiconductor.js';
9
- import { applyDisplayGeo, applyGeo, applyLayout, createStudy, exportStudy, extractGeo, FORMULATIONS, formatOf, ingestCase, ingestDistCase, isDisplayFile, isPermanentEngineFailure, loadPackage, parseDisplay, parseGeo } from '@tellegen/engine';
10
+ import { applyDisplayGeo, applyGeo, applyLayout, createStudy, exportStudy, extractGeo, FORMULATIONS, formatOf, ingestCase, ingestDistCaseBytes, ingestJsonDrop, isDisplayFile, isPermanentEngineFailure, parseDisplay, parseGeo } from '@tellegen/engine';
10
11
  import { errorText, extent, formulationLabel, rgbaCss } from './format.js';
11
12
  const HIDDEN_DEFAULT_CASES_KEY = 'tellegen.hiddenDefaultCases.v1';
12
13
  // Open on South Carolina by default: it has the most interesting price action.
@@ -21,6 +22,11 @@ const COMPUTE_OFF_NOTICE = 'solving in this browser did not complete, and server
21
22
  * starts anyway: a missing map or a cue that never appears must not stall the
22
23
  * selection. */
23
24
  const FOCUS_SETTLE_CAP_MS = 1200;
25
+ /** A lowered analysis row is view-only only when a current producer says so;
26
+ * absence keeps payloads from older compatible engine/server versions editable. */
27
+ export function isDisplayOnlyElement(element) {
28
+ return element?.editable === false;
29
+ }
24
30
  function delayUntilFocusSettles(ms, signal) {
25
31
  if (ms <= 0 || signal.aborted)
26
32
  return Promise.resolve();
@@ -83,6 +89,9 @@ export class Controller {
83
89
  // In-flight initial case-list load, so a remount during the first fetch dedupes
84
90
  // instead of firing a second concurrent load().
85
91
  loading = null;
92
+ // File reads and worker messages retain whole buffers. Admit one validated
93
+ // batch at a time so repeated drops cannot multiply the aggregate byte cap.
94
+ dropBatchGate = new DropBatchGate();
86
95
  nearbyRangeAnchor = $state(null);
87
96
  // Default cases the user has closed; drives the restore affordance. Seeded in load().
88
97
  hiddenDefaults = $state(new Set());
@@ -270,7 +279,12 @@ export class Controller {
270
279
  study.free();
271
280
  return null;
272
281
  }
273
- this.caseStudies.set(c, { study, networkJson, formulation, baseSolution });
282
+ this.caseStudies.set(c, {
283
+ study,
284
+ networkJson,
285
+ formulation,
286
+ baseSolution
287
+ });
274
288
  return study;
275
289
  }
276
290
  catch (e) {
@@ -288,7 +302,12 @@ export class Controller {
288
302
  this.studyBuilds.delete(c);
289
303
  }
290
304
  })();
291
- this.studyBuilds.set(c, { networkJson, formulation, token, promise: build });
305
+ this.studyBuilds.set(c, {
306
+ networkJson,
307
+ formulation,
308
+ token,
309
+ promise: build
310
+ });
292
311
  return build;
293
312
  }
294
313
  // The ∂LMP/∂parameter column at `target` for the case's active formulation, solved in
@@ -299,9 +318,7 @@ export class Controller {
299
318
  // error.
300
319
  async browserSensitivity(c, networkJson, target) {
301
320
  const study = await this.getStudy(c, networkJson);
302
- return study
303
- ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target)
304
- : null;
321
+ return study ? study.sensitivity(c.id, this.caseDeltas(c), this.caseRatings(c), target) : null;
305
322
  }
306
323
  // Release a case's Study (if any) when the case is removed.
307
324
  disposeStudy(c) {
@@ -376,7 +393,7 @@ export class Controller {
376
393
  return deltas;
377
394
  }
378
395
  demandBounds(mode, bus, center) {
379
- if (!bus)
396
+ if (!bus || isDisplayOnlyElement(bus))
380
397
  return { min: 0, max: 0, span: 0 };
381
398
  // Floor, not ceil: -ceil(demand) can push base + delta below zero for
382
399
  // non-integer demand, which the server rejects (400) and which is
@@ -384,7 +401,11 @@ export class Controller {
384
401
  const physicalMin = -Math.floor(bus.demand_mw);
385
402
  const physicalMax = Math.max(Math.ceil(bus.demand_mw), 50);
386
403
  if (mode === 'full')
387
- return { min: physicalMin, max: physicalMax, span: physicalMax - physicalMin };
404
+ return {
405
+ min: physicalMin,
406
+ max: physicalMax,
407
+ span: physicalMax - physicalMin
408
+ };
388
409
  const span = Math.max(5, Math.min(25, 0.1 * Math.max(bus.demand_mw, 50)));
389
410
  return {
390
411
  min: Math.max(physicalMin, center - span),
@@ -417,7 +438,10 @@ export class Controller {
417
438
  // Drive the values off the resolved mode, not app.displayMode, so the
418
439
  // displayOptions[0] fallback holds while displayMode is stale relative to the
419
440
  // formulation (e.g. leaving SOCWR before +page resets it to 'lmp').
420
- return { ...activeMeta, values: displaySeriesFor(this.activeSolvable, activeMeta.mode) };
441
+ return {
442
+ ...activeMeta,
443
+ values: displaySeriesFor(this.activeSolvable, activeMeta.mode)
444
+ };
421
445
  });
422
446
  displayStats = $derived.by(() => {
423
447
  if (!this.activeDisplay)
@@ -471,6 +495,7 @@ export class Controller {
471
495
  sliderBounds = $derived.by(() => this.demandBounds(this.app.demandRangeMode, this.selectedBusData, this.nearbyRangeCenter));
472
496
  sliderMin = $derived(this.sliderBounds.min);
473
497
  sliderMax = $derived(this.sliderBounds.max);
498
+ sliderDisabled = $derived(!this.selectedBusData || isDisplayOnlyElement(this.selectedBusData));
474
499
  committedRating = $derived.by(() => this.activeSolvable && this.app.selectedBranch !== null
475
500
  ? (this.caseRatings(this.activeSolvable)[this.app.selectedBranch] ?? 0)
476
501
  : 0);
@@ -482,10 +507,14 @@ export class Controller {
482
507
  // to perturb.
483
508
  ratingBounds = $derived.by(() => {
484
509
  const b = this.selectedBranchData;
485
- if (!b || b.rate_mw <= 0)
510
+ if (!b || isDisplayOnlyElement(b) || b.rate_mw <= 0)
486
511
  return { min: 0, max: 0, disabled: true };
487
512
  const span = Math.min(50, Math.max(5, 0.2 * b.rate_mw));
488
- return { min: Math.max(-(b.rate_mw - 1), -span), max: span, disabled: false };
513
+ return {
514
+ min: Math.max(-(b.rate_mw - 1), -span),
515
+ max: span,
516
+ disabled: false
517
+ };
489
518
  });
490
519
  selectedSensitivity = $derived.by(() => {
491
520
  const c = this.activeSolvable;
@@ -838,8 +867,9 @@ export class Controller {
838
867
  c.coordsKind = 'geofile';
839
868
  c.syntheticCenter = undefined;
840
869
  c.geoSource = sourceLabel;
870
+ const placedCanonicalBuses = payload.view?.buses.filter((bus) => bus.editable !== false).length ?? 0;
841
871
  c.geoWarnings = [
842
- `${payload.view?.buses.length ?? 0} of ${payload.n_bus} buses placed from ${placedFrom}`,
872
+ `${placedCanonicalBuses} of ${payload.n_bus} buses placed from ${placedFrom}`,
843
873
  ...extraWarnings,
844
874
  ...payload.warnings
845
875
  ];
@@ -1049,7 +1079,9 @@ export class Controller {
1049
1079
  this.app.placingLocalId = null;
1050
1080
  const { ac, sensitivitySeq } = this.beginBusSelection(c, busId);
1051
1081
  try {
1052
- const sensitivity = await this.browserSensitivity(c, c.networkJson, { bus: busId });
1082
+ const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1083
+ bus: busId
1084
+ });
1053
1085
  if (!ac.signal.aborted)
1054
1086
  this.acceptSensitivity(c, sensitivity, { bus: busId }, sensitivitySeq);
1055
1087
  if (!ac.signal.aborted && !sensitivity) {
@@ -1102,7 +1134,9 @@ export class Controller {
1102
1134
  if (ac.signal.aborted)
1103
1135
  return;
1104
1136
  }
1105
- const sensitivity = await this.browserSensitivity(c, c.networkJson, { branch: branchId });
1137
+ const sensitivity = await this.browserSensitivity(c, c.networkJson, {
1138
+ branch: branchId
1139
+ });
1106
1140
  if (ac.signal.aborted)
1107
1141
  return;
1108
1142
  if (sensitivity) {
@@ -1325,7 +1359,7 @@ export class Controller {
1325
1359
  commitDelta = (value) => {
1326
1360
  const c = this.activeSolvable;
1327
1361
  const bus = this.app.selectedBus;
1328
- if (!c || bus === null)
1362
+ if (!c || bus === null || this.sliderDisabled)
1329
1363
  return;
1330
1364
  // Refresh the engine preview at the commit value (a typed value may not have
1331
1365
  // driven a drag), then score the commit with the engine's predicted Δobjective.
@@ -1337,6 +1371,8 @@ export class Controller {
1337
1371
  this.runSolve(c, { bus });
1338
1372
  };
1339
1373
  finishDemandInput = (value) => {
1374
+ if (this.sliderDisabled)
1375
+ return;
1340
1376
  if (Math.abs(value - this.committedDelta) < 0.25) {
1341
1377
  if (!this.activeSolvable?.solving) {
1342
1378
  this.app.previewActive = false;
@@ -1395,32 +1431,61 @@ export class Controller {
1395
1431
  this.runSolve(c, this.selectionTarget);
1396
1432
  };
1397
1433
  /** Parse dropped files in the browser via the powerio wasm module. Case
1398
- * files (.m, .raw, .aux) become local networks; geographic files can
1434
+ * files (.m, .raw, .aux, .epc, .pwb) become local networks; geographic files can
1399
1435
  * place those networks; a PowerWorld .pwd becomes a substation point
1400
1436
  * preview. Files run serially; nothing uploads. */
1401
1437
  ingestFiles = async (files) => {
1402
- const list = Array.from(files);
1438
+ const release = this.dropBatchGate.enter();
1439
+ if (!release) {
1440
+ this.app.error = 'another file batch is still being parsed';
1441
+ return;
1442
+ }
1443
+ try {
1444
+ let list;
1445
+ try {
1446
+ list = validateDropBatch(files);
1447
+ }
1448
+ catch (e) {
1449
+ this.app.error = errorText(e);
1450
+ return;
1451
+ }
1452
+ await this.ingestFileBatch(list);
1453
+ }
1454
+ catch (e) {
1455
+ this.app.error = errorText(e);
1456
+ }
1457
+ finally {
1458
+ this.app.parsingFile = false;
1459
+ release();
1460
+ }
1461
+ };
1462
+ ingestFileBatch = async (list) => {
1403
1463
  let parsedCaseCount = 0;
1404
- // True once a dropped case file took the geographic sidecars; a restored
1405
- // package or an existing case only takes them when no case file did.
1464
+ // True once a dropped balanced case took the geographic sidecars. When no
1465
+ // case parses, the selected existing case may take them at the end.
1406
1466
  let geoLayersConsumed = false;
1407
- // Routable `.json` content (a saved study package, a multiconductor package,
1408
- // a BMOPF/PMD document) shares the `.json` extension with a geographic file,
1409
- // so classify and consume it up front: the geo/case split below sends every
1410
- // `.json` to the coordinate parser, which would misread these as coordinate
1411
- // data. Unroutable `.json` files fall through unchanged.
1467
+ // Routable `.json` content (saved packages, model JSON, and transmission or
1468
+ // distribution documents) shares the extension with geographic files. Route
1469
+ // it up front, but stage recognized payloads until the geographic/display
1470
+ // sidecars below have parsed. Unrouted JSON retains its first byte buffer so
1471
+ // the geographic fallback does not read a potentially large file twice.
1412
1472
  const rest = [];
1413
- let restoredAnyPackage = false;
1473
+ const routedJson = [];
1474
+ const jsonBytes = new Map();
1414
1475
  for (const file of list) {
1415
1476
  if (file.name.toLowerCase().endsWith('.json')) {
1416
- const outcome = await this.ingestJsonDrop(file);
1417
- if (outcome === 'restored' || outcome === 'viewed') {
1477
+ const route = await this.routeJsonDrop(file);
1478
+ if (route.outcome === 'balanced' ||
1479
+ route.outcome === 'restored' ||
1480
+ route.outcome === 'multiconductor') {
1418
1481
  parsedCaseCount++;
1419
- restoredAnyPackage ||= outcome === 'restored';
1482
+ routedJson.push({ file, route });
1420
1483
  continue;
1421
1484
  }
1422
- if (outcome === 'failed')
1485
+ if (route.outcome === 'failed')
1423
1486
  continue;
1487
+ if (route.bytes)
1488
+ jsonBytes.set(file, route.bytes);
1424
1489
  }
1425
1490
  rest.push(file);
1426
1491
  }
@@ -1428,8 +1493,14 @@ export class Controller {
1428
1493
  for (const file of rest.filter((f) => isGeoFileName(f.name))) {
1429
1494
  this.app.parsingFile = true;
1430
1495
  try {
1431
- const parsed = await parseGeo(new Uint8Array(await file.arrayBuffer()), file.name);
1432
- geoLayers.push({ name: file.name, layer: parsed.layer, warnings: parsed.warnings });
1496
+ const bytes = await readDropFileBytes(file, jsonBytes.get(file));
1497
+ jsonBytes.delete(file);
1498
+ const parsed = await parseGeo(bytes, file.name);
1499
+ geoLayers.push({
1500
+ name: file.name,
1501
+ layer: parsed.layer,
1502
+ warnings: parsed.warnings
1503
+ });
1433
1504
  this.app.error = null;
1434
1505
  }
1435
1506
  catch (e) {
@@ -1447,7 +1518,12 @@ export class Controller {
1447
1518
  this.app.parsingFile = true;
1448
1519
  try {
1449
1520
  const bytes = new Uint8Array(await file.arrayBuffer());
1450
- displays.push({ file, bytes, preview: await parseDisplay(bytes), consumed: false });
1521
+ displays.push({
1522
+ file,
1523
+ bytes,
1524
+ preview: await parseDisplay(bytes),
1525
+ consumed: false
1526
+ });
1451
1527
  this.app.error = null;
1452
1528
  }
1453
1529
  catch (e) {
@@ -1457,6 +1533,33 @@ export class Controller {
1457
1533
  this.app.parsingFile = false;
1458
1534
  }
1459
1535
  }
1536
+ // Materialize routed JSON cases only after sidecars are ready. This keeps
1537
+ // placement ahead of the first solve and preserves the original JSON order.
1538
+ // `prepareDroppedLocal` is a wasm call that takes seconds on a large case,
1539
+ // and the drop gate refuses a second batch throughout, so the UI has to
1540
+ // keep saying so.
1541
+ if (routedJson.length > 0)
1542
+ this.app.parsingFile = true;
1543
+ try {
1544
+ for (const { file, route } of routedJson) {
1545
+ if (route.outcome === 'multiconductor') {
1546
+ this.addMultiCase(file.name, route.payload);
1547
+ continue;
1548
+ }
1549
+ if (route.outcome !== 'balanced' && route.outcome !== 'restored')
1550
+ continue;
1551
+ const local = route.outcome === 'restored'
1552
+ ? this.localFromPackage(file.name, route.payload)
1553
+ : this.localFromBalancedPayload(file.name, route.payload);
1554
+ const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1555
+ geoLayersConsumed ||= placement.geoLayersConsumed;
1556
+ this.addAndActivateLocal(local);
1557
+ this.app.error = placement.error;
1558
+ }
1559
+ }
1560
+ finally {
1561
+ this.app.parsingFile = false;
1562
+ }
1460
1563
  for (const file of rest.filter((f) => !isGeoFileName(f.name) && !isDisplayFile(f.name))) {
1461
1564
  // OpenDSS routes by extension into the multiconductor viewing path.
1462
1565
  const distFormat = distExtensionFormat(file.name);
@@ -1467,13 +1570,13 @@ export class Controller {
1467
1570
  }
1468
1571
  const format = formatOf(file.name);
1469
1572
  if (!format) {
1470
- this.app.error = `${file.name}: not a case or geographic file (.m, .raw, .aux, .dss, .pwd, .csv, .json, .geojson)`;
1573
+ this.app.error = `${file.name}: not a case or geographic file (.m, .raw, .aux, .epc, .pwb, .dss, .pwd, .csv, .json, .geojson)`;
1471
1574
  continue;
1472
1575
  }
1473
1576
  this.app.parsingFile = true;
1474
1577
  try {
1475
- const text = await file.text();
1476
- const { network_json, topology, view, ...summary } = await ingestCase(text, format);
1578
+ const bytes = new Uint8Array(await file.arrayBuffer());
1579
+ const { network_json, topology, view, ...summary } = await ingestCase(bytes, format);
1477
1580
  if (format === 'aux' && (summary.n_branch === 0 || summary.n_gen === 0)) {
1478
1581
  this.app.error = `${file.name}: aux parsed, but no complete network; drop the matching .m or .raw case file`;
1479
1582
  continue;
@@ -1492,29 +1595,11 @@ export class Controller {
1492
1595
  coordsKind: summary.coords_kind,
1493
1596
  view
1494
1597
  });
1495
- // A co-dropped geographic file places (or repositions) the network. Apply
1496
- // it for any parsed case, not just synthetic ones, so a geo overlay
1497
- // dropped alongside a case that already has coordinates takes effect
1498
- // instead of being silently discarded. A failed apply keeps the parsed
1499
- // case (placement still works) and surfaces the reason.
1500
- let geoError = null;
1501
- if (geoLayers.length > 0 && local.networkJson) {
1502
- try {
1503
- await this.applyGeoLayers(local, geoLayers);
1504
- geoLayersConsumed = true;
1505
- }
1506
- catch (e) {
1507
- geoError = `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`;
1508
- }
1509
- }
1510
- else if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1511
- // No sidecar: a coordinate-less case may still borrow a co-dropped
1512
- // .pwd's substation points through the SubNum join.
1513
- await this.fillFromDisplaySibling(local, displays);
1514
- }
1598
+ const placement = await this.prepareDroppedLocal(local, geoLayers, displays);
1599
+ geoLayersConsumed ||= placement.geoLayersConsumed;
1515
1600
  this.addAndActivateLocal(local);
1516
1601
  parsedCaseCount++;
1517
- this.app.error = geoError; // a successful parse clears a prior file's error
1602
+ this.app.error = placement.error; // a successful parse clears a prior file's error
1518
1603
  }
1519
1604
  catch (e) {
1520
1605
  this.app.error = `${file.name}: ${e instanceof Error ? e.message : e}`;
@@ -1541,13 +1626,34 @@ export class Controller {
1541
1626
  substations: { points, approximate: true }
1542
1627
  }));
1543
1628
  }
1544
- // Unconsumed sidecars apply to the restored package (now the active local
1545
- // case) so a saved study co-dropped with its coordinates places, or to an
1546
- // existing case when nothing in the drop parsed at all.
1547
- if (geoLayers.length > 0 && !geoLayersConsumed && (restoredAnyPackage || parsedCaseCount === 0)) {
1629
+ // When the drop carried only sidecars, apply them to the selected existing
1630
+ // case. Every balanced case parsed in this batch already took them above.
1631
+ if (geoLayers.length > 0 && !geoLayersConsumed && parsedCaseCount === 0) {
1548
1632
  await this.applyGeoLayersToExisting(geoLayers);
1549
1633
  }
1550
1634
  };
1635
+ /** Apply co-dropped geographic data before a local case becomes active and
1636
+ * starts solving. Without geographic data, let a coordinate-less case borrow
1637
+ * the first matching PowerWorld display. A failed geographic join keeps the
1638
+ * parsed case and returns the user-facing placement error. */
1639
+ prepareDroppedLocal = async (local, geoLayers, displays) => {
1640
+ if (geoLayers.length > 0 && local.networkJson) {
1641
+ try {
1642
+ await this.applyGeoLayers(local, geoLayers);
1643
+ return { geoLayersConsumed: true, error: null };
1644
+ }
1645
+ catch (e) {
1646
+ return {
1647
+ geoLayersConsumed: false,
1648
+ error: `${geoLayers.map((l) => l.name).join(' + ')}: ${errorText(e)}; use place on map for manual placement`
1649
+ };
1650
+ }
1651
+ }
1652
+ if (local.coordsKind === 'synthetic_pending' && local.networkJson) {
1653
+ await this.fillFromDisplaySibling(local, displays);
1654
+ }
1655
+ return { geoLayersConsumed: false, error: null };
1656
+ };
1551
1657
  /** Fill a coordinate-less case's positions from a co-dropped PowerWorld
1552
1658
  * `.pwd`: the display's substation points project to approximate
1553
1659
  * longitude/latitude and join onto buses through the `SubNum` extras key.
@@ -1568,47 +1674,43 @@ export class Controller {
1568
1674
  }
1569
1675
  }
1570
1676
  };
1571
- /** Route a dropped `.json` by its content: a saved study package restores
1572
- * (`restored`), a multiconductor package or a BMOPF/PMD distribution
1573
- * document views (`viewed`), `failed` means the content looked routable but
1574
- * the engine rejected it (a fail-closed message is set), and `not-package`
1575
- * falls through to the caller's geo/case handling. `drop-classify` is the
1576
- * single owner of the classification. */
1577
- ingestJsonDrop = async (file) => {
1578
- let text;
1677
+ /** Route a dropped `.json` by its content. Recognized payloads return without
1678
+ * mutating case state so co-dropped sidecars can be applied first; unrouted
1679
+ * content returns the bytes already read for the geographic fallback. */
1680
+ routeJsonDrop = async (file) => {
1681
+ let bytes;
1579
1682
  try {
1580
- text = await file.text();
1683
+ bytes = await readDropFileBytes(file);
1581
1684
  }
1582
1685
  catch {
1583
- return 'not-package';
1686
+ return { outcome: 'unrouted', bytes: null };
1584
1687
  }
1585
- const kind = classifyJson(text);
1586
- if (kind === 'not-json')
1587
- return 'not-package';
1588
1688
  this.app.parsingFile = true;
1589
1689
  try {
1590
- if (kind === 'balanced-package') {
1591
- this.restoreLocalFromPackage(file.name, await loadPackage(text));
1690
+ const result = await ingestJsonDrop(bytes);
1691
+ if (result.kind === 'unknown')
1692
+ return { outcome: 'unrouted', bytes };
1693
+ if (result.kind === 'ambiguous') {
1694
+ this.app.error = `${file.name}: JSON markers name both transmission and distribution formats`;
1695
+ return { outcome: 'failed' };
1696
+ }
1697
+ if (result.kind === 'balanced-package') {
1592
1698
  this.app.error = null;
1593
- return 'restored';
1699
+ return { outcome: 'restored', payload: result.payload };
1594
1700
  }
1595
- const format = kind === 'multiconductor-package' ? 'pio' : kind;
1596
- const payload = await ingestDistCase(text, format);
1597
- // BMOPF is the classifier's catch-all and its reader is liberal: an
1598
- // arbitrary JSON object parses as an empty case (unknown fields land
1599
- // in extras). Zero buses means this was not a distribution document;
1600
- // leave the file to the geo sidecar path (which places coordinates
1601
- // or reports its own precise error) instead of adding a phantom
1602
- // empty multiconductor case.
1603
- if (kind === 'bmopf' && payload.n_bus === 0)
1604
- return 'not-package';
1605
- this.addMultiCase(file.name, payload);
1606
- this.app.error = null;
1607
- return 'viewed';
1701
+ if (result.kind === 'model-json' || result.kind === 'transmission') {
1702
+ this.app.error = null;
1703
+ return { outcome: 'balanced', payload: result.payload };
1704
+ }
1705
+ if (result.kind === 'multiconductor-package' || result.kind === 'distribution') {
1706
+ this.app.error = null;
1707
+ return { outcome: 'multiconductor', payload: result.payload };
1708
+ }
1709
+ throw new Error('engine returned an unsupported JSON drop result');
1608
1710
  }
1609
1711
  catch (e) {
1610
1712
  this.app.error = `${file.name}: ${errorText(e)}`;
1611
- return 'failed';
1713
+ return { outcome: 'failed' };
1612
1714
  }
1613
1715
  finally {
1614
1716
  this.app.parsingFile = false;
@@ -1619,7 +1721,8 @@ export class Controller {
1619
1721
  ingestDistFile = async (file, format) => {
1620
1722
  this.app.parsingFile = true;
1621
1723
  try {
1622
- this.addMultiCase(file.name, await ingestDistCase(await file.text(), format));
1724
+ const bytes = new Uint8Array(await file.arrayBuffer());
1725
+ this.addMultiCase(file.name, await ingestDistCaseBytes(bytes, format));
1623
1726
  this.app.error = null;
1624
1727
  return true;
1625
1728
  }
@@ -1725,9 +1828,25 @@ export class Controller {
1725
1828
  else
1726
1829
  this.placeLocalCase(lon, lat);
1727
1830
  };
1728
- /** Build a local case from a restored study package: the same shape a dropped case
1729
- * takes, with the saved demand/rating deltas and formulation pre-applied. */
1730
- restoreLocalFromPackage = (fileName, pkg) => {
1831
+ /** Build a local case from a bare balanced ingest payload without activating
1832
+ * it yet; co-dropped placement data is applied before the first solve. */
1833
+ localFromBalancedPayload = (fileName, payload) => {
1834
+ const { network_json, topology, view, ...summary } = payload;
1835
+ const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
1836
+ return new LocalCase({
1837
+ id: `local-${++this.localSeq}`,
1838
+ label,
1839
+ fileName,
1840
+ summary,
1841
+ networkJson: network_json,
1842
+ topology,
1843
+ coordsKind: summary.coords_kind,
1844
+ view
1845
+ });
1846
+ };
1847
+ /** Build a local case from a saved package, including its edit state and
1848
+ * formulation, without activating it until sidecars have been applied. */
1849
+ localFromPackage = (fileName, pkg) => {
1731
1850
  const { network_json, topology, view, formulation, deltas, rates, ...summary } = pkg;
1732
1851
  const id = `local-${++this.localSeq}`;
1733
1852
  const label = summary.name && summary.name !== 'case' ? summary.name : fileName.replace(/\.[^.]+$/, '');
@@ -1744,7 +1863,7 @@ export class Controller {
1744
1863
  local.deltas = deltas;
1745
1864
  local.ratings = rates;
1746
1865
  local.formulation = formulation;
1747
- this.addAndActivateLocal(local);
1866
+ return local;
1748
1867
  };
1749
1868
  /** Build (or reuse) the case's Study and commit it at the current edit state, so a
1750
1869
  * save or export captures exactly what is on screen. Null (with an error set) when no
@@ -1867,9 +1986,7 @@ export class Controller {
1867
1986
  const { lmp, objectiveDelta } = await study.preview(this.previewDeltas(c, bus, value), this.caseRatings(c));
1868
1987
  // Apply only while this point is still the live drag target: a
1869
1988
  // preview that resolves after a re-selection or a commit is stale.
1870
- if (!this.app.previewActive ||
1871
- this.activeSolvable !== c ||
1872
- this.app.selectedBus !== bus)
1989
+ if (!this.app.previewActive || this.activeSolvable !== c || this.app.selectedBus !== bus)
1873
1990
  continue;
1874
1991
  const delta = new Map();
1875
1992
  for (const e of lmp)
@@ -1891,7 +2008,7 @@ export class Controller {
1891
2008
  }
1892
2009
  }
1893
2010
  setSliderPreview = (value) => {
1894
- if (value === undefined)
2011
+ if (value === undefined || this.sliderDisabled)
1895
2012
  return;
1896
2013
  this.app.previewActive = true;
1897
2014
  this.app.previewDeltaMw = value;
@@ -1996,7 +2113,7 @@ export class Controller {
1996
2113
  commitRating = async (value) => {
1997
2114
  const c = this.activeSolvable;
1998
2115
  const branch = this.app.selectedBranch;
1999
- if (!c || branch === null)
2116
+ if (!c || branch === null || isDisplayOnlyElement(this.selectedBranchData))
2000
2117
  return;
2001
2118
  // Refresh the engine preview at the commit value (a typed value may not have
2002
2119
  // driven a drag), then score the commit with the engine's predicted Δobjective.
package/dist/display.d.ts CHANGED
@@ -28,4 +28,3 @@ export declare function caseDeltas(c: SolvableCase): DemandDeltas;
28
28
  /** The committed branch rating deltas for a case (MW from base, keyed by branch).
29
29
  * The rating counterpart of `caseDeltas`. */
30
30
  export declare function caseRatings(c: SolvableCase): BranchRatingDeltas;
31
- //# sourceMappingURL=display.d.ts.map
@@ -1,48 +1,11 @@
1
- /** The single owner of drop-file classification: given a file name or its JSON
2
- * content, decide which ingest path handles it. Extension routing (`.dss`, and
3
- * the balanced `.m`/`.raw`/`.aux` via `formatOf` in the engine) is name-based;
4
- * everything under `.json` is content-sniffed here so there is one place that
5
- * decides package-vs-case and balanced-vs-multiconductor.
6
- *
7
- * The rules mirror the readers they feed:
8
- * - a `.pio.json` package is recognized by its `schema` envelope field, then
9
- * split by the authoritative `model_kind` (balanced restores a study;
10
- * multiconductor is viewed);
11
- * - a non-package document is distribution JSON, PMD when it carries the
12
- * `data_model` marker and BMOPF otherwise (the same split
13
- * `powerio_dist` uses for `.json`), except a GeoJSON FeatureCollection,
14
- * which stays unrouted so the geo sidecar path keeps accepting it.
15
- *
16
- * Every function is total: malformed, truncated, or non-JSON input classifies
17
- * as `not-json` rather than throwing. */
18
- /** The schema URL prefix every `.pio.json` package envelope carries. */
19
- export declare const PIO_PACKAGE_SCHEMA_PREFIX = "https://powerio.dev/schema/pio-package";
20
- /** How a dropped JSON file should be ingested. */
21
- export type JsonDropKind =
22
- /** A saved balanced study package: restore the case, edits, and formulation. */
23
- 'balanced-package'
24
- /** A package carrying a multiconductor payload: view it (no solve). */
25
- | 'multiconductor-package'
26
- /** A BMOPF JSON distribution case. */
27
- | 'bmopf'
28
- /** A PowerModelsDistribution ENGINEERING JSON case. */
29
- | 'pmd'
30
- /** Not JSON, or JSON we do not route (left to the generic error path). */
31
- | 'not-json';
1
+ /** File name helpers for drop routing. JSON content classification lives in
2
+ * powerio's Rust/Wasm `classify_json_bytes` path, not in this TypeScript layer. */
32
3
  /** The distribution format for a file recognized by extension, or null. Only
33
- * `.dss` is name-routed; PMD and BMOPF share the `.json` extension and are
34
- * content-sniffed by {@link classifyJson}. */
4
+ * `.dss` is name routed; PMD and BMOPF share the `.json` extension and are
5
+ * classified by the engine. */
35
6
  export declare function distExtensionFormat(name: string): 'dss' | null;
36
7
  /** True for geographic sidecar files by extension (`.csv`, `.json`,
37
8
  * `.geojson`). Routable `.json` content (packages, distribution documents) is
38
- * consumed by {@link classifyJson} before this applies; what remains parses
9
+ * consumed by the engine classifier before this applies; what remains parses
39
10
  * through the engine's tolerant geo reader. */
40
11
  export declare function isGeoFileName(name: string): boolean;
41
- /** Whether `text` is a `.pio.json` package envelope, by its `schema` field. */
42
- export declare function isPackageEnvelope(obj: Record<string, unknown> | null): boolean;
43
- /** Classify a dropped JSON document into its ingest path. */
44
- export declare function classifyJson(text: string): JsonDropKind;
45
- /** Whether `text` is a saved study package (a balanced `.pio.json`). Kept for
46
- * the study-restore path; multiconductor packages are viewed, not restored. */
47
- export declare function isStudyPackageText(text: string): boolean;
48
- //# sourceMappingURL=drop-classify.d.ts.map