@meshmakers/octo-meshboard 3.4.910 → 3.4.930

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,10 +72,10 @@ To set a Well-Known Name for a MeshBoard:
72
72
 
73
73
  The Well-Known Name should be lowercase with hyphens, similar to URL slugs.
74
74
 
75
- ### URL Sync on Board Switch (`meshBoardSyncUrl`)
75
+ ### URL Sync (`meshBoardSyncUrl`)
76
76
 
77
- After a post-init board switch (e.g. via the manager dialog), the view syncs the
78
- loaded board's rtId into the URL:
77
+ After the initial load and after every post-init board switch (e.g. via the
78
+ manager dialog), the view syncs the loaded board's rtId into the URL:
79
79
 
80
80
  - If the active route has an `:rtId` param, the last URL segment is replaced —
81
81
  always enabled, no configuration needed.
@@ -468,6 +468,8 @@ const GetEntitiesByCkTypeDocumentDto = gql `
468
468
  rtId
469
469
  ckTypeId
470
470
  rtWellKnownName
471
+ rtDisplayName
472
+ rtDisplayDescription
471
473
  rtCreationDateTime
472
474
  rtChangedDateTime
473
475
  attributes(resolveEnumValuesToNames: true) {
@@ -2910,6 +2912,14 @@ const GetStreamDataQueryArchiveDocumentDto = gql `
2910
2912
  }
2911
2913
  }
2912
2914
  }
2915
+ runtime {
2916
+ systemSimpleSdQuery(rtId: $rtId) {
2917
+ items {
2918
+ rtId
2919
+ rtIds
2920
+ }
2921
+ }
2922
+ }
2913
2923
  }
2914
2924
  `;
2915
2925
  class GetStreamDataQueryArchiveDtoGQL extends i1.Query {
@@ -3158,7 +3168,11 @@ class QueryExecutorService {
3158
3168
  * Resolution-aware routing (AB#4290): reads a persisted stream-data query's base archive rtId
3159
3169
  * and target CK type WITHOUT executing the query (the `rows` sub-connection is not selected), so
3160
3170
  * a widget can feed the archive to {@link resolveSeriesQuery} and use the CK type to resolve
3161
- * per-series labels. Returns null when the query is missing or has no archive.
3171
+ * per-series labels. Also reads the query's persisted RtIds pin
3172
+ * (`System/StreamDataQuery.RtIds`) from the runtime entity — the transient downsampling path
3173
+ * bypasses the persisted query execution, so widgets must re-apply the pin themselves
3174
+ * (AB#4818). `rtIds` is null when the query has no pin (or is not a SimpleSdQuery).
3175
+ * Returns null when the query is missing or has no archive.
3162
3176
  */
3163
3177
  async fetchQueryArchive(queryRtId) {
3164
3178
  const result = await firstValueFrom(this.queryArchiveGql.fetch({ variables: { rtId: queryRtId }, fetchPolicy: 'cache-first' }));
@@ -3166,7 +3180,12 @@ class QueryExecutorService {
3166
3180
  if (item?.archiveRtId == null) {
3167
3181
  return null;
3168
3182
  }
3169
- return { archiveRtId: String(item.archiveRtId), ckTypeId: item.associatedCkTypeId != null ? String(item.associatedCkTypeId) : null };
3183
+ const pinned = result.data?.runtime?.systemSimpleSdQuery?.items?.[0]?.rtIds;
3184
+ return {
3185
+ archiveRtId: String(item.archiveRtId),
3186
+ ckTypeId: item.associatedCkTypeId != null ? String(item.associatedCkTypeId) : null,
3187
+ rtIds: pinned && pinned.length > 0 ? pinned.map(String) : null
3188
+ };
3170
3189
  }
3171
3190
  /**
3172
3191
  * Resolution-aware routing (AB#4290): runs the AB#4233 downsampling query against an explicit
@@ -4652,7 +4671,9 @@ class EntityCardConfigDialogComponent {
4652
4671
  rtId: entity.rtId,
4653
4672
  ckTypeId: entity.ckTypeId,
4654
4673
  rtWellKnownName: entity.rtWellKnownName ?? undefined,
4655
- displayName: entity.rtWellKnownName || entity.rtId
4674
+ rtDisplayName: entity.rtDisplayName,
4675
+ rtDisplayDescription: entity.rtDisplayDescription ?? undefined,
4676
+ displayName: entity.rtDisplayName
4656
4677
  };
4657
4678
  }
4658
4679
  }
@@ -6181,7 +6202,9 @@ class KpiConfigDialogComponent {
6181
6202
  rtId: entity.rtId,
6182
6203
  ckTypeId: entity.ckTypeId,
6183
6204
  rtWellKnownName: entity.rtWellKnownName ?? undefined,
6184
- displayName: entity.rtWellKnownName || entity.rtId
6205
+ rtDisplayName: entity.rtDisplayName,
6206
+ rtDisplayDescription: entity.rtDisplayDescription ?? undefined,
6207
+ displayName: entity.rtDisplayName
6185
6208
  };
6186
6209
  }
6187
6210
  }
@@ -7545,13 +7568,26 @@ const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]
7545
7568
  function isIsoDateTime(value) {
7546
7569
  return typeof value === 'string' && ISO_DATE_TIME_RE.test(value);
7547
7570
  }
7548
- /** Parses a value into a valid `Date`, or returns `null` when it is not a usable instant. */
7571
+ /**
7572
+ * ISO-8601 date-time WITHOUT a zone designator (no `Z`, no offset). The wire
7573
+ * serializes UTC instants in this naive form (e.g. `2026-08-16T10:50:00`), but
7574
+ * `new Date(...)` parses such strings as browser-LOCAL time — shifting every
7575
+ * timestamp by the UTC offset before the board's timezone mode is even applied
7576
+ * (AB#4818). {@link toInstant} pins these to UTC instead.
7577
+ */
7578
+ const ISO_NAIVE_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
7579
+ /**
7580
+ * Parses a value into a valid `Date`, or returns `null` when it is not a usable
7581
+ * instant. A naive ISO date-time string (no `Z`/offset) is treated as UTC — the
7582
+ * wire's serialization of an instant — never as browser-local time (AB#4818).
7583
+ */
7549
7584
  function toInstant(value) {
7550
7585
  if (value instanceof Date) {
7551
7586
  return isNaN(value.getTime()) ? null : value;
7552
7587
  }
7553
7588
  if (typeof value === 'string' || typeof value === 'number') {
7554
- const date = new Date(value);
7589
+ const normalized = typeof value === 'string' && ISO_NAIVE_DATE_TIME_RE.test(value) ? `${value}Z` : value;
7590
+ const date = new Date(normalized);
7555
7591
  return isNaN(date.getTime()) ? null : date;
7556
7592
  }
7557
7593
  return null;
@@ -10959,7 +10995,9 @@ class GaugeConfigDialogComponent {
10959
10995
  rtId: entity.rtId,
10960
10996
  ckTypeId: entity.ckTypeId,
10961
10997
  rtWellKnownName: entity.rtWellKnownName ?? undefined,
10962
- displayName: entity.rtWellKnownName || entity.rtId
10998
+ rtDisplayName: entity.rtDisplayName,
10999
+ rtDisplayDescription: entity.rtDisplayDescription ?? undefined,
11000
+ displayName: entity.rtDisplayName
10963
11001
  };
10964
11002
  }
10965
11003
  }
@@ -14866,6 +14904,11 @@ class LineChartWidgetComponent {
14866
14904
  if (!info) {
14867
14905
  throw new Error('Resolution-aware line chart: base archive not found for the selected query.');
14868
14906
  }
14907
+ // Source scope (AB#4818): an active entity-selector binding overrides; otherwise the persisted
14908
+ // query's RtIds pin applies — the transient downsampling bypasses the persisted query execution,
14909
+ // so without re-applying the pin a pinned widget would silently aggregate every entity captured
14910
+ // by the archive.
14911
+ const scopeRtIds = sourceRtIds && sourceRtIds.length > 0 ? sourceRtIds : (info.rtIds ?? undefined);
14869
14912
  // The Y-axis value field is the column being reduced, so it doubles as the resolver's
14870
14913
  // source path (matched case-insensitively against the rollup's aggregation spec).
14871
14914
  const sourcePath = this.config.valueField;
@@ -14877,7 +14920,7 @@ class LineChartWidgetComponent {
14877
14920
  targetPoints,
14878
14921
  requiredAggregation: aggregation,
14879
14922
  sourcePath,
14880
- rtIds: sourceRtIds && sourceRtIds.length > 0 ? sourceRtIds : undefined,
14923
+ rtIds: scopeRtIds,
14881
14924
  // Resolve calendar rollups in the board's zone so civil-day buckets match the
14882
14925
  // window (computed on the same zone by resolveCurrentTimeRange) — AB#4190.
14883
14926
  timeZone: this.stateService.resolveStreamDataTimeZone()
@@ -14914,8 +14957,8 @@ class LineChartWidgetComponent {
14914
14957
  fieldFilter
14915
14958
  }).then(seriesRows => seriesRows.map(r => this.reshapeResolutionRow(r, label)));
14916
14959
  const rows = ds.aggregateSeriesByGroup && info.ckTypeId
14917
- ? await this.loadGroupAggregatedRows(info.ckTypeId, sourceRtIds, downsample)
14918
- : await this.loadPerRtIdRows(info.ckTypeId, sourceRtIds, downsample);
14960
+ ? await this.loadGroupAggregatedRows(info.ckTypeId, scopeRtIds, downsample)
14961
+ : await this.loadPerRtIdRows(info.ckTypeId, scopeRtIds, downsample);
14919
14962
  return { rows, signal: resolution, requestedPoints: targetPoints };
14920
14963
  }
14921
14964
  /**
@@ -15044,9 +15087,11 @@ class LineChartWidgetComponent {
15044
15087
  }
15045
15088
  }
15046
15089
  if (categoryValue && seriesGroupValue) {
15047
- // Parse date for sorting
15090
+ // Parse date for sorting. toInstant treats a naive wire timestamp (no zone designator)
15091
+ // as UTC — a bare `new Date(...)` would reinterpret it as browser-local time and shift
15092
+ // the axis by the UTC offset (AB#4818).
15048
15093
  if (!allCategories.has(categoryValue)) {
15049
- allCategories.set(categoryValue, new Date(categoryValue));
15094
+ allCategories.set(categoryValue, toInstant(categoryValue) ?? new Date(NaN));
15050
15095
  }
15051
15096
  allSeriesGroups.add(seriesGroupValue);
15052
15097
  if (!dataMap.has(categoryValue)) {
@@ -16649,13 +16694,11 @@ class HeatmapWidgetComponent {
16649
16694
  return values.length;
16650
16695
  }
16651
16696
  }
16697
+ /** Delegates to {@link toInstant} so naive wire timestamps are pinned to UTC (AB#4818). */
16652
16698
  parseDate(value) {
16653
16699
  if (!value)
16654
16700
  return null;
16655
- if (value instanceof Date)
16656
- return value;
16657
- const date = new Date(String(value));
16658
- return isNaN(date.getTime()) ? null : date;
16701
+ return toInstant(value);
16659
16702
  }
16660
16703
  convertFiltersToDto(filters) {
16661
16704
  const variables = this.stateService.getVariables();
@@ -30865,6 +30908,120 @@ function buildUrlWithRtId(input) {
30865
30908
  }
30866
30909
  return null;
30867
30910
  }
30911
+ /**
30912
+ * Returns the URL to navigate to after the *initial* board load, or `null`
30913
+ * when the URL must not be rewritten.
30914
+ *
30915
+ * The constructor effect skips URL sync during the initial load (it would race
30916
+ * with the transient board `loadInitialMeshBoard` puts up first), so the view
30917
+ * syncs once after loading settles. Nothing to do when no board loaded or the
30918
+ * URL already carried the rtId; otherwise the `buildUrlWithRtId` rules apply.
30919
+ */
30920
+ function buildInitialUrlWithRtId(input) {
30921
+ const { loadedRtId, rtIdFromRoute, ...rest } = input;
30922
+ if (!loadedRtId || rtIdFromRoute) {
30923
+ return null;
30924
+ }
30925
+ return buildUrlWithRtId({ ...rest, rtId: loadedRtId });
30926
+ }
30927
+
30928
+ /**
30929
+ * Pure helpers for representing the active time-filter selection in the URL.
30930
+ *
30931
+ * The URL is written from two sides — the board-switch rtId sync and the
30932
+ * time-range picker — and two concurrent Router navigations supersede each
30933
+ * other (the later one wins, computed from the pre-navigation route, silently
30934
+ * dropping the rtId path change). Both writers therefore serialize the tf_*
30935
+ * params through these helpers onto one target URL string and navigate once.
30936
+ */
30937
+ /** All tf_* query params; params not used by the selection type are null (= removed). */
30938
+ function timeFilterQueryParams(selection) {
30939
+ const params = {
30940
+ tf_type: selection.type,
30941
+ tf_year: null,
30942
+ tf_quarter: null,
30943
+ tf_month: null,
30944
+ tf_day: null,
30945
+ tf_hf: null,
30946
+ tf_ht: null,
30947
+ tf_rv: null,
30948
+ tf_ru: null,
30949
+ tf_from: null,
30950
+ tf_to: null
30951
+ };
30952
+ switch (selection.type) {
30953
+ case 'year':
30954
+ if (selection.year != null)
30955
+ params['tf_year'] = selection.year.toString();
30956
+ break;
30957
+ case 'quarter':
30958
+ if (selection.year != null)
30959
+ params['tf_year'] = selection.year.toString();
30960
+ if (selection.quarter != null)
30961
+ params['tf_quarter'] = selection.quarter.toString();
30962
+ break;
30963
+ case 'month':
30964
+ if (selection.year != null)
30965
+ params['tf_year'] = selection.year.toString();
30966
+ if (selection.month != null)
30967
+ params['tf_month'] = selection.month.toString();
30968
+ break;
30969
+ case 'day':
30970
+ if (selection.year != null)
30971
+ params['tf_year'] = selection.year.toString();
30972
+ if (selection.month != null)
30973
+ params['tf_month'] = selection.month.toString();
30974
+ if (selection.day != null)
30975
+ params['tf_day'] = selection.day.toString();
30976
+ if (selection.hourFrom != null)
30977
+ params['tf_hf'] = selection.hourFrom.toString();
30978
+ if (selection.hourTo != null)
30979
+ params['tf_ht'] = selection.hourTo.toString();
30980
+ break;
30981
+ case 'relative':
30982
+ if (selection.relativeValue != null)
30983
+ params['tf_rv'] = selection.relativeValue.toString();
30984
+ if (selection.relativeUnit)
30985
+ params['tf_ru'] = selection.relativeUnit;
30986
+ break;
30987
+ case 'custom':
30988
+ if (selection.customFrom)
30989
+ params['tf_from'] = selection.customFrom;
30990
+ if (selection.customTo)
30991
+ params['tf_to'] = selection.customTo;
30992
+ break;
30993
+ }
30994
+ return params;
30995
+ }
30996
+ /**
30997
+ * Returns `url` with the given query params merged in: string values are set,
30998
+ * null values are removed, params not mentioned are preserved.
30999
+ */
31000
+ function applyQueryParams(url, params) {
31001
+ const [pathPart, queryPart] = url.split('?');
31002
+ const search = new URLSearchParams(queryPart ?? '');
31003
+ for (const [key, value] of Object.entries(params)) {
31004
+ if (value === null) {
31005
+ search.delete(key);
31006
+ }
31007
+ else {
31008
+ search.set(key, value);
31009
+ }
31010
+ }
31011
+ const query = search.toString();
31012
+ return query ? `${pathPart}?${query}` : pathPart;
31013
+ }
31014
+ /**
31015
+ * Returns `url` with its tf_* query params replaced by the given selection's.
31016
+ * Non-tf params (es_*, …) are preserved. `selection` null/undefined returns
31017
+ * the url unchanged.
31018
+ */
31019
+ function applyTimeFilterParams(url, selection) {
31020
+ if (!selection) {
31021
+ return url;
31022
+ }
31023
+ return applyQueryParams(url, timeFilterQueryParams(selection));
31024
+ }
30868
31025
 
30869
31026
  /**
30870
31027
  * Component for editing MeshBoard variables.
@@ -32211,9 +32368,14 @@ class MeshBoardViewComponent {
32211
32368
  if (currentRtId && currentRtId !== this.lastNavigatedRtId) {
32212
32369
  this.lastNavigatedRtId = currentRtId;
32213
32370
  if (this.initialLoadComplete) {
32214
- // Only update URL and time filter for post-init board switches (e.g. manager dialog)
32215
- this.updateUrlWithRtId(currentRtId);
32371
+ // Only update URL and time filter for post-init board switches (e.g.
32372
+ // manager dialog). Time filter first: updateUrlWithRtId serializes
32373
+ // the then-active selection into the SAME navigation as the rtId
32374
+ // path change — a second concurrent navigation (e.g. the picker's
32375
+ // writeTimeFilterToUrl) would supersede the path change and drop the
32376
+ // rtId from the URL.
32216
32377
  this.initializeTimeFilterVariables();
32378
+ this.updateUrlWithRtId(currentRtId);
32217
32379
  }
32218
32380
  }
32219
32381
  });
@@ -32265,9 +32427,16 @@ class MeshBoardViewComponent {
32265
32427
  syncUrlOptIn: this.route.snapshot.data['meshBoardSyncUrl'] === true
32266
32428
  });
32267
32429
  if (targetUrl !== null) {
32268
- this.router.navigateByUrl(targetUrl, { replaceUrl: true });
32430
+ // One navigation for path + tf_* params: a separate time-filter
32431
+ // navigation would supersede this one and drop the rtId path change.
32432
+ this.router.navigateByUrl(applyTimeFilterParams(targetUrl, this.activeTimeFilterSelection()), { replaceUrl: true });
32269
32433
  }
32270
32434
  }
32435
+ /** The active time-filter selection to mirror into the URL, or null when the filter is off. */
32436
+ activeTimeFilterSelection() {
32437
+ const config = this.stateService.getTimeFilterConfig();
32438
+ return config?.enabled ? config.selection ?? null : null;
32439
+ }
32271
32440
  async ngOnInit() {
32272
32441
  // Track the component's own width for the compact layout tiers. The callback
32273
32442
  // fires outside the Angular zone; re-enter it so the signal update schedules
@@ -32323,6 +32492,21 @@ class MeshBoardViewComponent {
32323
32492
  await this.initializeEntitySelectors();
32324
32493
  // Mark initial load as complete so the effect can handle subsequent board switches
32325
32494
  this.initialLoadComplete = true;
32495
+ // The constructor effect skipped the URL sync during the initial load
32496
+ // (and already consumed the rtId via lastNavigatedRtId), so sync once
32497
+ // here, after loading settled — the transient board from
32498
+ // loadInitialMeshBoard never reaches the URL. Stays a no-op without
32499
+ // meshBoardSyncUrl/:rtId (AB#4457) and preserves the query params.
32500
+ const initialUrl = buildInitialUrlWithRtId({
32501
+ currentUrl: this.router.url,
32502
+ loadedRtId: this.stateService.persistedMeshBoardId(),
32503
+ rtIdFromRoute,
32504
+ hasRtIdParam: this.route.snapshot.paramMap.has('rtId'),
32505
+ syncUrlOptIn: this.route.snapshot.data['meshBoardSyncUrl'] === true
32506
+ });
32507
+ if (initialUrl !== null) {
32508
+ this.router.navigateByUrl(applyTimeFilterParams(initialUrl, this.activeTimeFilterSelection()), { replaceUrl: true });
32509
+ }
32326
32510
  // Update breadcrumb with MeshBoard name
32327
32511
  this.updateBreadcrumb();
32328
32512
  // Wire visibility-aware auto-refresh. The config-watching effect already
@@ -32760,69 +32944,17 @@ class MeshBoardViewComponent {
32760
32944
  * Only writes params relevant to the current type, clears all others.
32761
32945
  */
32762
32946
  writeTimeFilterToUrl(selection) {
32763
- // Start with all params cleared
32764
- const cleanParams = {
32765
- tf_type: selection.type,
32766
- tf_year: null,
32767
- tf_quarter: null,
32768
- tf_month: null,
32769
- tf_day: null,
32770
- tf_hf: null,
32771
- tf_ht: null,
32772
- tf_rv: null,
32773
- tf_ru: null,
32774
- tf_from: null,
32775
- tf_to: null
32776
- };
32777
- // Set only the params relevant to the current type
32778
- switch (selection.type) {
32779
- case 'year':
32780
- if (selection.year != null)
32781
- cleanParams['tf_year'] = selection.year.toString();
32782
- break;
32783
- case 'quarter':
32784
- if (selection.year != null)
32785
- cleanParams['tf_year'] = selection.year.toString();
32786
- if (selection.quarter != null)
32787
- cleanParams['tf_quarter'] = selection.quarter.toString();
32788
- break;
32789
- case 'month':
32790
- if (selection.year != null)
32791
- cleanParams['tf_year'] = selection.year.toString();
32792
- if (selection.month != null)
32793
- cleanParams['tf_month'] = selection.month.toString();
32794
- break;
32795
- case 'day':
32796
- if (selection.year != null)
32797
- cleanParams['tf_year'] = selection.year.toString();
32798
- if (selection.month != null)
32799
- cleanParams['tf_month'] = selection.month.toString();
32800
- if (selection.day != null)
32801
- cleanParams['tf_day'] = selection.day.toString();
32802
- if (selection.hourFrom != null)
32803
- cleanParams['tf_hf'] = selection.hourFrom.toString();
32804
- if (selection.hourTo != null)
32805
- cleanParams['tf_ht'] = selection.hourTo.toString();
32806
- break;
32807
- case 'relative':
32808
- if (selection.relativeValue != null)
32809
- cleanParams['tf_rv'] = selection.relativeValue.toString();
32810
- if (selection.relativeUnit)
32811
- cleanParams['tf_ru'] = selection.relativeUnit;
32812
- break;
32813
- case 'custom':
32814
- if (selection.customFrom)
32815
- cleanParams['tf_from'] = selection.customFrom;
32816
- if (selection.customTo)
32817
- cleanParams['tf_to'] = selection.customTo;
32818
- break;
32947
+ // Serialize onto the current URL string instead of router.navigate([],
32948
+ // {relativeTo}): the relative form is computed from the pre-navigation
32949
+ // route and, when it supersedes an in-flight rtId navigation, restores the
32950
+ // stale path (old/no board rtId) into the URL.
32951
+ const targetUrl = applyTimeFilterParams(this.router.url, selection);
32952
+ if (targetUrl === this.router.url) {
32953
+ // No-op writes must not navigate: the router would still cancel an
32954
+ // in-flight rtId navigation before skipping the identical URL.
32955
+ return;
32819
32956
  }
32820
- this.router.navigate([], {
32821
- relativeTo: this.route,
32822
- queryParams: cleanParams,
32823
- queryParamsHandling: 'merge',
32824
- replaceUrl: true
32825
- });
32957
+ this.router.navigateByUrl(targetUrl, { replaceUrl: true });
32826
32958
  }
32827
32959
  /**
32828
32960
  * Reads the time filter selection from URL query parameters.
@@ -32851,17 +32983,33 @@ class MeshBoardViewComponent {
32851
32983
  * Compares two TimeRangeSelection objects for equality.
32852
32984
  */
32853
32985
  isSelectionEqual(a, b) {
32854
- return (a.type === b.type &&
32855
- a.year === b.year &&
32856
- a.quarter === b.quarter &&
32857
- a.month === b.month &&
32858
- a.day === b.day &&
32859
- a.hourFrom === b.hourFrom &&
32860
- a.hourTo === b.hourTo &&
32861
- a.relativeValue === b.relativeValue &&
32862
- a.relativeUnit === b.relativeUnit &&
32863
- a.customFrom === b.customFrom &&
32864
- a.customTo === b.customTo);
32986
+ // Compare only the fields the selection type actually uses: the picker
32987
+ // pre-fills the unused fields with current-date defaults (e.g. year/month
32988
+ // on a relative selection) while stored selections omit them. A
32989
+ // field-by-field comparison then reports equal selections as changed and
32990
+ // triggers a spurious URL write that supersedes (and thereby drops) the
32991
+ // board-switch rtId navigation.
32992
+ if (a.type !== b.type) {
32993
+ return false;
32994
+ }
32995
+ const eq = (x, y) => (x ?? null) === (y ?? null);
32996
+ switch (a.type) {
32997
+ case 'year':
32998
+ return eq(a.year, b.year);
32999
+ case 'quarter':
33000
+ return eq(a.year, b.year) && eq(a.quarter, b.quarter);
33001
+ case 'month':
33002
+ return eq(a.year, b.year) && eq(a.month, b.month);
33003
+ case 'day':
33004
+ return eq(a.year, b.year) && eq(a.month, b.month) && eq(a.day, b.day) &&
33005
+ eq(a.hourFrom, b.hourFrom) && eq(a.hourTo, b.hourTo);
33006
+ case 'relative':
33007
+ return eq(a.relativeValue, b.relativeValue) && eq(a.relativeUnit, b.relativeUnit);
33008
+ case 'custom':
33009
+ return eq(a.customFrom, b.customFrom) && eq(a.customTo, b.customTo);
33010
+ default:
33011
+ return false;
33012
+ }
32865
33013
  }
32866
33014
  /**
32867
33015
  * Duplicates a widget with all its settings.
@@ -33298,12 +33446,13 @@ class MeshBoardViewComponent {
33298
33446
  for (const selector of selectors) {
33299
33447
  params[`es_${selector.id}`] = selector.selectedRtId ?? null;
33300
33448
  }
33301
- this.router.navigate([], {
33302
- relativeTo: this.route,
33303
- queryParams: params,
33304
- queryParamsHandling: 'merge',
33305
- replaceUrl: true
33306
- });
33449
+ // Same single-URL serialization as writeTimeFilterToUrl: no relative
33450
+ // navigation off a possibly stale route, and no no-op navigation that
33451
+ // would cancel an in-flight rtId navigation.
33452
+ const targetUrl = applyQueryParams(this.router.url, params);
33453
+ if (targetUrl !== this.router.url) {
33454
+ this.router.navigateByUrl(targetUrl, { replaceUrl: true });
33455
+ }
33307
33456
  }
33308
33457
  /**
33309
33458
  * Reads entity selector selections from URL query parameters.