@meri-imperiumi/signalk-passage-briefing 0.13.0 → 0.13.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.13.2] - 2026-10-19
6
+
7
+ ### Fixed
8
+ - Briefing refresh crashed with 502 `Cannot read properties of undefined (reading '1')` when the course provider reported the vessel navigating the briefed route while still heading to its first waypoint (`navigation.course.activeRoute.pointIndex` 0, forward — e.g. just after engaging a route): the progress trim read `raw[pointIndex - 1]` for a last passed waypoint that does not exist yet. Forward pointIndex 0 now trims to the vessel position with nothing trimmed off (`trimmedFromNm` 0), and without a known vessel position the full untrimmed plan serves.
9
+
10
+ ## [0.13.1] - 2026-10-19
11
+
12
+ ### Changed
13
+ - The warnings the resources carry are now one canonical set, published against the union of the vessel's position and the active route (work doc #48): every publish pass — fetch windows and page loads alike — previously filtered AND pruned against its own trigger's track, so a here-mode page load (position only) wiped the route's warnings and a route compile wiped the position's: the boat saw one bounding-box region while the cyclone, its 300-nautical-mile wind circle and the troughs rode the console unseen, then nothing at all. The publication track is now the union of both — and a page load that serves a cached briefing (no compile ran in the process) publishes from that briefing's own waypoints, so a restart plus a page load is enough to fill the plotter. Content-addressed ids make the re-publish idempotent, and unchanged warning sets write nothing to disk. The wider-area severe warnings — the off-track context the console never shows — publish too, as `coverage: "wider"` regions: plotters show the gale 300 nautical miles off, and the briefing map draws them from the resources muted, exactly as it drew them from the payload before. The map's payload-drawn warning layers are gone: only the route ribbon, the timeline event pins and the cyclone graphic (whose per-ring thresholds and forecast track the regions schema cannot carry) remain payload-driven, and the cyclone's own region rings yield to that graphic.
14
+ - The published regions now split at the antimeridian (work doc #48): the parsed warning chains carry raw coordinates (166E → 180 → 170W on this week's convergence zone), and plotters that do not unwrap — Freeboard draws the raw ring — would render a seam-crossing warning across the wrong hemisphere. Every published ring — parsed areas, corridor bands, cyclone quadrant rings, CAP polygons — now unfolds into a continuous frame and clips against the seam, publishing a MultiPolygon of spec-clean parts (the convergence zone became 166..180E plus 180..152W), so both the briefing map and any other plotter draw the same honest shape. Bounds-boxes split the same way and did already.
15
+
16
+ ### Added
17
+ - The NFFN storm warnings' quadrant wind fields now parse (work doc #48 session): RSMC Nadi spells the wind areas out in prose — "EXPECT WINDS OVER 47 KNOTS WITHIN 50 NAUTICAL MILES IN NE QUADRANT AND WITHIN 60 NAUTICAL MILES IN SE QUADRANT …" — a format the NHC advisory parser never accepted, so a tropical cyclone announced this way was a lone point everywhere: no wind rings on the map's cyclone graphic and no region on the chart plotter. Each threshold's four quadrant radii now build the same great-circle rings the advisory fields carry, on the same `storm.fields` structure — the map graphic and the published regions read both formats alike, and the 33-knot gale ring and the 47-knot storm ring publish as a MultiPolygon region beside the declaration's point note.
18
+ - Warning shapes on the chart plotter (work doc #48): the METAREA and CAP warnings already publish as georeferenced notes, but the notes schema carries a single point — the gale area, the corridor band and the CAP polygon collapsed to their nearest-vertex marker. The plugin now registers a `regions` resource provider alongside the server's built-in one (the resources API merges providers, so user-created regions are untouched) and publishes a region beside each shaped warning's note: bounding boxes as ring polygons (antimeridian-crossing boxes split into two spec-clean rectangles), parsed polygons as-is, trough axis bands expanded into the same corridor rings the briefing map draws, and tropical-cyclone advisories as the MultiPolygon of their present wind/seas quadrant rings. Each paired note links to its region via `href` — the exact link Freeboard's region panel lists related notes by — and provenance (issuer, zone, subject, derivation) rides the feature properties. GDACS hazards stay note-only (points); axis lines without a stated band and text-only storm declarations have no area on the map either.
19
+ - The briefing's warnings map draws the published resources (work doc #47 session): the hand-drawn warning-shapes layer — bulletin block shapes, CAP polygons, GDACS pins — is replaced by the same `/resources/notes` and `/resources/regions` the plotter sees, so the map and Freeboard render one set of warnings and any third-party resource provider's notes and regions appear for free. Severity coloring re-derives from the warning text with the console's own highlighter (ours and third-party alike); the dashed-bbox honesty cue and the console click-through stay ours-only, matched by the block text the resources carry verbatim; a note whose region draws stays hidden so a shaped warning renders once. What stays payload-driven on purpose: the route ribbon, the timeline event pins, the cyclone graphic (per-ring thresholds and the forecast track are not representable in the regions schema) and the muted wider-area set, which keeps its muting and stays off other plotters. Third-party resources never drive the fit — the view stays anchored on the vessel's waters.
20
+
21
+ ### Fixed
22
+ - The METAREA notes pass pruned the whole shared notes store, erasing GDACS hazard notes, CAP notes and any client-written notes on every bulletin publish. All three feeds now prune scoped to their own id prefixes (`metarea-`, `hazard-`, `cap-`), so notes from the other feeds and notes written by other clients survive every pass.
23
+
5
24
  ## [0.13.0] - 2026-10-18
6
25
 
7
26
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -1358,6 +1358,66 @@ function advisorySeverityLabel(maxWindKt) {
1358
1358
  * @returns {object|null} Structured storm, null when the text is not
1359
1359
  * the advisory family
1360
1360
  */
1361
+ /**
1362
+ * A tropical-cyclone declaration's quadrant wind radii (work doc #48
1363
+ * session): the RSMC NFFN storm warnings spell the wind fields out in
1364
+ * prose — "EXPECT WINDS OVER 47 KNOTS WITHIN 50 NAUTICAL MILES IN NE
1365
+ * QUADRANT AND WITHIN 60 NAUTICAL MILES IN SE QUADRANT …" — a format
1366
+ * the NHC advisory parser never sees. Each threshold's quadrant
1367
+ * radii build the same great-circle rings the advisory fields carry,
1368
+ * so the map's cyclone graphic and the published regions read the
1369
+ * same parsed structure for both formats. Null when the text names no
1370
+ * radii set.
1371
+ *
1372
+ * @param {string} text - Block text
1373
+ * @param {number[][]|null} coordinates - The declaration point
1374
+ * ([lon, lat]) the radii centre on
1375
+ * @returns {object|null} A partial advisory shape ({center, fields,
1376
+ * forecastPoints}) — the fields the map graphic and the region
1377
+ * publisher already consume
1378
+ */
1379
+ function declarationStorm(text, coordinates) {
1380
+ if (
1381
+ !Array.isArray(coordinates) ||
1382
+ coordinates.length !== 2 ||
1383
+ !coordinates.every(Number.isFinite)
1384
+ ) {
1385
+ return null;
1386
+ }
1387
+ const [lon, lat] = coordinates;
1388
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
1389
+ return null;
1390
+ }
1391
+ const fields = [];
1392
+ // One set per threshold: "OVER 47 KNOTS" followed by its quadrant
1393
+ // radii chain — the chain breaks at the next "EXPECT ... KNOTS"
1394
+ const setRe =
1395
+ /OVER\s+(\d{1,3})\s*KNOTS((?:\s+AND)?\s+WITHIN\s+\d+\s*NAUTICAL\s*MILES\s+IN\s+(?:NE|SE|SW|NW)\s+QUADRANT)+/gi;
1396
+ const quadRe =
1397
+ /WITHIN\s+(\d+)\s*NAUTICAL\s*MILES\s+IN\s+(NE|SE|SW|NW)\s+QUADRANT/gi;
1398
+ for (const setMatch of text.matchAll(setRe)) {
1399
+ const threshold = Number.parseInt(setMatch[1], 10);
1400
+ const radiiNm = { ne: 0, se: 0, sw: 0, nw: 0 };
1401
+ for (const quadMatch of setMatch[0].matchAll(quadRe)) {
1402
+ radiiNm[quadMatch[2].toLowerCase()] = Number.parseInt(quadMatch[1], 10);
1403
+ }
1404
+ const geometry = quadrantRing({ lat, lon, radiiNm });
1405
+ if (geometry) {
1406
+ fields.push({
1407
+ label: `${threshold} KT`,
1408
+ threshold,
1409
+ kind: "wind",
1410
+ radiiNm,
1411
+ geometry,
1412
+ });
1413
+ }
1414
+ }
1415
+ if (fields.length === 0) {
1416
+ return null;
1417
+ }
1418
+ return { center: { lat, lon }, fields, forecastPoints: [] };
1419
+ }
1420
+
1361
1421
  function parseAdvisory(text) {
1362
1422
  if (
1363
1423
  typeof text !== "string" ||
@@ -1578,11 +1638,20 @@ function filterBulletin({
1578
1638
  if (!geometry && !hasSevereKeyword(blockText)) {
1579
1639
  return null;
1580
1640
  }
1641
+ // A storm declaration whose text spells out quadrant wind
1642
+ // radii carries the same parsed fields the advisory family
1643
+ // does — the cyclone's wind rings become map graphic and
1644
+ // published region, not just a point (work doc #48 session)
1645
+ const storm =
1646
+ geometry?.type === "point"
1647
+ ? declarationStorm(blockText, geometry.coordinates)
1648
+ : null;
1581
1649
  return {
1582
1650
  text: blockText,
1583
1651
  subject: subject ?? null,
1584
1652
  geometryType: geometry ? geometry.type : null,
1585
1653
  geometry: geometry ?? null,
1654
+ ...(storm ? { storm } : {}),
1586
1655
  source,
1587
1656
  };
1588
1657
  })
package/plugin/index.js CHANGED
@@ -59,8 +59,13 @@ const ephemeris = require("./celestial-ephemeris.js");
59
59
  const { registerPlotterExtension } = require("./brief-ext.js");
60
60
  const { registerStatusTileExamples } = require("./statustilesexamples.js");
61
61
  const { createNotesStore, registerNotesProvider } = require("./notes-store.js");
62
+ const {
63
+ createRegionsStore,
64
+ registerRegionsProvider,
65
+ } = require("./regions-store.js");
62
66
  const {
63
67
  clearNotes,
68
+ clearRegions,
64
69
  publishNotes,
65
70
  publishHazardNotes,
66
71
  publishCapAlertNotes,
@@ -316,6 +321,8 @@ module.exports = (app) => {
316
321
  let teardownPlotterExt = null;
317
322
  let teardownStatusTiles = null;
318
323
  let teardownNotesProvider = null;
324
+ let regionsStore = null;
325
+ let teardownRegionsProvider = null;
319
326
  let notesStore = null;
320
327
  /** Route-list cache (bergie session perf pass): the resources
321
328
  * listing is seconds-slow per call and the list changes rarely —
@@ -557,12 +564,25 @@ module.exports = (app) => {
557
564
  ) {
558
565
  return null; // Not past the first point, or an index we cannot use
559
566
  }
567
+ const target = raw[pointIndex];
568
+ const vessel = vesselPosition();
569
+ // Heading to the first waypoint (forward pointIndex 0): no leg of
570
+ // the plan is behind the boat, so there is no last passed waypoint
571
+ // to anchor the trim distance on. The plan starts at the vessel
572
+ // when known (nothing trimmed off, 0 nm in), else serves in full.
573
+ if (!reverse && pointIndex === 0) {
574
+ if (!vessel) {
575
+ return null;
576
+ }
577
+ return {
578
+ coordinates: [[vessel.lon, vessel.lat], ...raw],
579
+ trimmedFromNm: 0,
580
+ };
581
+ }
560
582
  // Travel order decides what remains: forward navigates toward
561
583
  // higher indices, reverse toward lower ones (the course provider's
562
584
  // `reverse` flag). The start point is the vessel's actual position
563
585
  // when available, else the last passed waypoint.
564
- const target = raw[pointIndex];
565
- const vessel = vesselPosition();
566
586
  const start = vessel
567
587
  ? [vessel.lon, vessel.lat]
568
588
  : reverse
@@ -572,7 +592,8 @@ module.exports = (app) => {
572
592
  ? raw.slice(0, pointIndex + 1).reverse()
573
593
  : raw.slice(pointIndex);
574
594
  // Distance along the stored plan from its start to the trim point:
575
- // the boat sits between the target and the last passed point
595
+ // the boat sits between the target and the last passed point (the
596
+ // forward pointIndex 0 case returned above, so this is defined)
576
597
  const reference = reverse ? target : raw[pointIndex - 1];
577
598
  const anchorSlice = reverse
578
599
  ? raw.slice(0, pointIndex + 1)
@@ -933,12 +954,80 @@ module.exports = (app) => {
933
954
  /**
934
955
  * Publishes the filtered bulletin blocks as georeferenced server
935
956
  * notes (work doc #12). Local-only: not gated on the internet
936
- * state, so spool-sourced warnings publish the same way.
957
+ * state, so spool-sourced warnings publish the same way. Also runs
958
+ * at serve time (trigger "serve", work doc #48): the refiltered
959
+ * bulletin the console renders is what the resources must carry.
937
960
  *
938
- * @param {"here"|"route"} trigger
961
+ * @param {"here"|"route"|"serve"} trigger
939
962
  * @param {object|null} bulletin - metareaBulletin with blocks
940
963
  * @param {Array<{lat: number, lon: number}>} waypoints
941
964
  */
965
+ /**
966
+ * The publication track (work doc #48): the vessel's position first
967
+ * (it anchors the nearest-point note placement), then the route
968
+ * waters — the caller's track hint (the served briefing's own
969
+ * waypoints) when one exists, else the most recent route compile's.
970
+ * The union of the two is the set of waters the passage touches —
971
+ * the notes and regions the plotter sees must not depend on which
972
+ * view triggered the last publish, nor on whether a compile ran in
973
+ * this process at all (a cached-briefing page load never compiles).
974
+ *
975
+ * @param {Array<{lat: number, lon: number}>|null} [hint] - The
976
+ * served briefing's waypoints when available
977
+ * @returns {Array<{lat: number, lon: number}>}
978
+ */
979
+ function publicationTrack(hint = null) {
980
+ const track = [];
981
+ const seen = new Set();
982
+ const push = (lat, lon) => {
983
+ const key = `${lat},${lon}`;
984
+ if (Number.isFinite(lat) && Number.isFinite(lon) && !seen.has(key)) {
985
+ seen.add(key);
986
+ track.push({ lat, lon });
987
+ }
988
+ };
989
+ const position = vesselPosition();
990
+ if (position) {
991
+ push(position.lat, position.lon);
992
+ }
993
+ for (const waypoint of hint ?? lastRouteWaypoints ?? []) {
994
+ push(waypoint.lat, waypoint.lon);
995
+ }
996
+ return track;
997
+ }
998
+
999
+ /**
1000
+ * The canonical publication pass (work doc #48): one set of notes
1001
+ * and regions for the plotter, filtered against the publication
1002
+ * track — the vessel's position plus the active route — regardless
1003
+ * of which view or fetch window triggered it. A per-trigger filter
1004
+ * (here: position-only, route: route-only) would have each pass
1005
+ * prune the other's warnings; the union track ends the fight. An
1006
+ * empty result clears this feed's resources: expiry is part of the
1007
+ * feature.
1008
+ *
1009
+ * @param {string} trigger - for the debug line
1010
+ * @param {Array<{lat: number, lon: number}>|null} [hint] - The
1011
+ * served briefing's waypoints when available
1012
+ */
1013
+ async function publishWarningsFor(trigger, hint = null) {
1014
+ if (!publishNotesEnabled) {
1015
+ return;
1016
+ }
1017
+ const track = publicationTrack(hint);
1018
+ if (track.length === 0) {
1019
+ return;
1020
+ }
1021
+ const bulletin = await bulletinForTrack(track);
1022
+ await publishNotesFor(
1023
+ trigger,
1024
+ // Null bulletin (nothing on our waters) publishes an empty set:
1025
+ // the scoped prune drops this feed's stale warnings
1026
+ bulletin ?? { blocks: [], wider: [] },
1027
+ track,
1028
+ );
1029
+ }
1030
+
942
1031
  async function publishNotesFor(trigger, bulletin, waypoints) {
943
1032
  if (!publishNotesEnabled || !bulletin) {
944
1033
  return;
@@ -956,15 +1045,24 @@ module.exports = (app) => {
956
1045
  }
957
1046
  const result = await publishNotes({
958
1047
  store: notesStore,
1048
+ regionsStore,
959
1049
  bulletin,
960
1050
  zone,
961
1051
  synopticChartFor: () => chart,
962
1052
  ref: waypoints.length > 0 ? [waypoints[0].lon, waypoints[0].lat] : null,
963
1053
  });
964
- if (result.published.length > 0 || result.pruned.length > 0) {
1054
+ if (
1055
+ result.published.length > 0 ||
1056
+ result.pruned.length > 0 ||
1057
+ result.regionsPublished.length > 0 ||
1058
+ result.regionsPruned.length > 0
1059
+ ) {
965
1060
  app.debug?.(
966
1061
  `Notes ${trigger}: ${result.published.length} published, ` +
967
- `${result.pruned.length} expired`,
1062
+ `${result.pruned.length} expired, ` +
1063
+ `${result.regionsPublished.length} regions published, ` +
1064
+ `${result.regionsPruned.length} regions expired, ` +
1065
+ `${result.widerPublished.length} wider regions`,
968
1066
  );
969
1067
  }
970
1068
  }
@@ -1098,6 +1196,16 @@ module.exports = (app) => {
1098
1196
  * @param {object} payload - Briefing payload, mutated in place
1099
1197
  * @returns {Promise<void>}
1100
1198
  */
1199
+ /**
1200
+ * Re-publishes the metarea warnings from the canonical publication
1201
+ * track (work doc #48): the fetch-time publish ran against the
1202
+ * track as it was when the bulletin arrived — the crew has moved
1203
+ * since, and a page load must not wait for the next fetch window.
1204
+ * Content-addressed ids make the re-publish idempotent, and
1205
+ * unchanged sets write nothing.
1206
+ *
1207
+ * @param {{waypoints?: Array<{lat: number, lon: number}>}} payload
1208
+ */
1101
1209
  async function refilterPayloadBulletin(payload) {
1102
1210
  if (!payload?.waypoints?.length) {
1103
1211
  return;
@@ -1106,6 +1214,12 @@ module.exports = (app) => {
1106
1214
  if (fresh) {
1107
1215
  payload.metareaBulletin = fresh;
1108
1216
  }
1217
+ // The publication track is view-independent — the payload's own
1218
+ // refiltered bulletin stays per-view for the console. The served
1219
+ // briefing's waypoints ARE the route waters: a cached-payload page
1220
+ // load publishes from them even when no compile ran in this
1221
+ // process
1222
+ await publishWarningsFor("serve", payload.waypoints);
1109
1223
  }
1110
1224
 
1111
1225
  /**
@@ -1187,12 +1301,20 @@ module.exports = (app) => {
1187
1301
  if (notesStore) {
1188
1302
  const result = await publishCapAlertNotes({
1189
1303
  store: notesStore,
1304
+ regionsStore,
1190
1305
  alerts: payload.capAlerts,
1191
1306
  });
1192
- if (result.published.length > 0 || result.pruned.length > 0) {
1307
+ if (
1308
+ result.published.length > 0 ||
1309
+ result.pruned.length > 0 ||
1310
+ result.regionsPublished.length > 0 ||
1311
+ result.regionsPruned.length > 0
1312
+ ) {
1193
1313
  app.debug?.(
1194
1314
  `CAP notes: ${result.published.length} published, ` +
1195
- `${result.pruned.length} expired`,
1315
+ `${result.pruned.length} expired, ` +
1316
+ `${result.regionsPublished.length} regions published, ` +
1317
+ `${result.regionsPruned.length} regions expired`,
1196
1318
  );
1197
1319
  }
1198
1320
  }
@@ -1274,7 +1396,7 @@ module.exports = (app) => {
1274
1396
  if (bulletin) {
1275
1397
  payload.metareaBulletin = bulletin;
1276
1398
  }
1277
- await publishNotesFor("here", bulletin, waypoints);
1399
+ await publishWarningsFor("here");
1278
1400
  // Space weather rides the same online window (work doc #3);
1279
1401
  // degrades to an absent field when the endpoints fail. Phase-2
1280
1402
  // ephemeris events are local computation; the visibility gate
@@ -1506,6 +1628,7 @@ module.exports = (app) => {
1506
1628
  }
1507
1629
  const waypoints = sampleRoutePoints(coordinates);
1508
1630
  lastCompiledWaypoints = waypoints;
1631
+ lastRouteWaypoints = waypoints;
1509
1632
  // Bulletins ride the same online window as the weather (SPEC §3.1
1510
1633
  // metareaBulletin): pulled first so this briefing carries them
1511
1634
  await refreshBulletinsOnline("briefing", waypoints);
@@ -1518,7 +1641,7 @@ module.exports = (app) => {
1518
1641
  if (bulletin) {
1519
1642
  payload.metareaBulletin = bulletin;
1520
1643
  }
1521
- await publishNotesFor("route", bulletin, waypoints);
1644
+ await publishWarningsFor("route");
1522
1645
  // Space weather for the departure position (work doc #3):
1523
1646
  // fetched Phase-1 sources plus local Phase-2 ephemeris events,
1524
1647
  // all gated on the payload's own cloud cover; nightly moon and
@@ -1703,6 +1826,11 @@ module.exports = (app) => {
1703
1826
  /** Waypoints of the most recent compile (route or here): the
1704
1827
  * escalation's track reference between refreshes. */
1705
1828
  let lastCompiledWaypoints = null;
1829
+ /** Waypoints of the most recent ROUTE compile: the publication
1830
+ * track's route half (work doc #48) — warnings publish for the
1831
+ * waters the plan crosses plus the vessel's position, whichever
1832
+ * view triggered the pass. */
1833
+ let lastRouteWaypoints = null;
1706
1834
 
1707
1835
  async function runFetch(trigger) {
1708
1836
  if (!isOnline()) {
@@ -2796,9 +2924,10 @@ module.exports = (app) => {
2796
2924
  modelsPromise.catch(() => {});
2797
2925
 
2798
2926
  // Notes lifecycle (work doc #12): when publication is disabled
2799
- // the store clears, so chart plotters stop seeing our warnings
2927
+ // the stores clear, so chart plotters stop seeing our warnings
2800
2928
  if (!publishNotesEnabled) {
2801
2929
  clearNotes(notesStore).catch(() => {});
2930
+ clearRegions(regionsStore).catch(() => {});
2802
2931
  }
2803
2932
 
2804
2933
  app.subscriptionmanager.subscribe(
@@ -2859,12 +2988,20 @@ module.exports = (app) => {
2859
2988
 
2860
2989
  // The server has no built-in notes provider: the plugin
2861
2990
  // registers as the notes resource provider (work doc #12) and
2862
- // serves the bulletin warnings plus any client-written notes
2991
+ // serves the bulletin warnings plus any client-written notes.
2992
+ // Regions (work doc #48) ride beside the built-in provider: the
2993
+ // resources API merges providers per type, so user-created
2994
+ // regions survive untouched
2863
2995
  notesStore = createNotesStore(app.getDataDirPath());
2864
2996
  teardownNotesProvider = registerNotesProvider(app, {
2865
2997
  id: PLUGIN_ID,
2866
2998
  store: notesStore,
2867
2999
  });
3000
+ regionsStore = createRegionsStore(app.getDataDirPath());
3001
+ teardownRegionsProvider = registerRegionsProvider(app, {
3002
+ id: PLUGIN_ID,
3003
+ store: regionsStore,
3004
+ });
2868
3005
  // Acknowledge put clears the tile's NEW badge
2869
3006
  if (typeof app.registerPutHandler === "function") {
2870
3007
  app.registerPutHandler(
@@ -2913,6 +3050,10 @@ module.exports = (app) => {
2913
3050
  teardownNotesProvider();
2914
3051
  teardownNotesProvider = null;
2915
3052
  }
3053
+ if (teardownRegionsProvider) {
3054
+ teardownRegionsProvider();
3055
+ teardownRegionsProvider = null;
3056
+ }
2916
3057
  if (teardownStatusTiles) {
2917
3058
  teardownStatusTiles();
2918
3059
  teardownStatusTiles = null;