@meshmakers/octo-meshboard 3.4.810 → 3.4.880

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.
@@ -3195,9 +3195,8 @@ class QueryExecutorService {
3195
3195
  /**
3196
3196
  * Best-effort per-series labels for resolution-aware fan-out (AB#4290): reads each source
3197
3197
  * entity's attribute matching `labelField` (canonical, case/underscore-insensitive so an archive
3198
- * column like `obis_code` maps to the CK attribute `obisCode`). Entities that fail to load or
3199
- * lack the attribute are simply omitted; the caller falls back to the rtId. Returns a
3200
- * `rtId → label` map.
3198
+ * column name maps to its CK attribute). Entities that fail to load or lack the attribute are
3199
+ * simply omitted; the caller falls back to the rtId. Returns a `rtId → label` map.
3201
3200
  */
3202
3201
  async fetchSeriesLabels(ckTypeId, rtIds, labelField) {
3203
3202
  const map = new Map();
@@ -3218,6 +3217,49 @@ class QueryExecutorService {
3218
3217
  }));
3219
3218
  return map;
3220
3219
  }
3220
+ /** Cap on the source-entity population read for group-aggregation (AB#4714). A truncated read is
3221
+ * logged, never silent. */
3222
+ static ENTITY_GROUP_FETCH_CAP = 5000;
3223
+ /**
3224
+ * Group-aggregation source resolution (AB#4714): fetches the source entities of `ckTypeId` in a
3225
+ * single bulk read and buckets their rtIds by the value of `groupField` (canonical match — case
3226
+ * and non-alphanumerics are ignored, so an archive column name maps to its CK attribute). Returns
3227
+ * `groupValue → rtIds`. Optionally restricted to `restrictRtIds` (an entity-selector scope) —
3228
+ * entities outside that set are dropped. Entities whose group attribute is missing/null are
3229
+ * omitted (they cannot be attributed to a line). The population is bounded to
3230
+ * {@link ENTITY_GROUP_FETCH_CAP}; a truncated read is logged (never silently capped).
3231
+ */
3232
+ async fetchEntityGroups(ckTypeId, groupField, restrictRtIds) {
3233
+ const groups = new Map();
3234
+ const canon = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
3235
+ const wanted = canon(groupField);
3236
+ const restrict = restrictRtIds && restrictRtIds.length > 0 ? new Set(restrictRtIds) : null;
3237
+ const res = await firstValueFrom(this.entitiesGql.fetch({ variables: { ckTypeId, first: QueryExecutorService.ENTITY_GROUP_FETCH_CAP } }));
3238
+ const connection = res.data?.runtime?.runtimeEntities;
3239
+ const items = connection?.items ?? [];
3240
+ const total = connection?.totalCount ?? items.length;
3241
+ if (total > items.length) {
3242
+ console.warn(`Group-aggregation: ${ckTypeId} has ${total} entities but only ${items.length} were read `
3243
+ + `(cap ${QueryExecutorService.ENTITY_GROUP_FETCH_CAP}); some series may be incomplete.`);
3244
+ }
3245
+ for (const item of items) {
3246
+ const rtId = item?.rtId != null ? String(item.rtId) : null;
3247
+ if (!rtId || (restrict && !restrict.has(rtId)))
3248
+ continue;
3249
+ const attr = item?.attributes?.items?.find(a => !!a?.attributeName && canon(a.attributeName) === wanted);
3250
+ if (attr?.value == null)
3251
+ continue;
3252
+ const groupValue = String(attr.value);
3253
+ const bucket = groups.get(groupValue);
3254
+ if (bucket) {
3255
+ bucket.push(rtId);
3256
+ }
3257
+ else {
3258
+ groups.set(groupValue, [rtId]);
3259
+ }
3260
+ }
3261
+ return groups;
3262
+ }
3221
3263
  buildStreamDataArg(args) {
3222
3264
  // Skip the entire `arg` field when the caller has nothing to override —
3223
3265
  // the persisted query then runs with its intrinsic from/to/limit and the
@@ -14811,10 +14853,12 @@ class LineChartWidgetComponent {
14811
14853
  }
14812
14854
  /**
14813
14855
  * Resolution-aware routing (AB#4290): resolve the best archive/rollup for the window + target
14814
- * point count, then downsample each source series against it. The transient downsampling groups
14815
- * by bin (merging source rtIds), so this fans out one call per source rtId to keep each register
14816
- * a separate line; each series' bins are reshaped onto the widget's configured value/series
14817
- * fields so the shared {@link processData} pipeline renders them unchanged.
14856
+ * point count, then downsample the source series against it. Two fan-out shapes, both reshaped
14857
+ * onto the widget's value/series fields so the shared {@link processData} pipeline renders them
14858
+ * unchanged:
14859
+ * - default: one call per source rtId → one line per register ({@link loadPerRtIdRows}).
14860
+ * - `aggregateSeriesByGroup` (AB#4714): one call per `seriesGroupField` value with the whole
14861
+ * group's rtIds → one server-side-summed line per group ({@link loadGroupAggregatedRows}).
14818
14862
  */
14819
14863
  async loadResolutionAware(ds, from, to, sourceRtIds, fieldFilter) {
14820
14864
  const targetPoints = this.computeDownsampleLimit();
@@ -14834,7 +14878,6 @@ class LineChartWidgetComponent {
14834
14878
  requiredAggregation: aggregation,
14835
14879
  sourcePath,
14836
14880
  rtIds: sourceRtIds && sourceRtIds.length > 0 ? sourceRtIds : undefined,
14837
- obisFilter: ds.obisFilter || undefined,
14838
14881
  // Resolve calendar rollups in the board's zone so civil-day buckets match the
14839
14882
  // window (computed on the same zone by resolveCurrentTimeRange) — AB#4190.
14840
14883
  timeZone: this.stateService.resolveStreamDataTimeZone()
@@ -14856,31 +14899,52 @@ class LineChartWidgetComponent {
14856
14899
  qFrom = new Date(alignedFrom);
14857
14900
  qTo = new Date(alignedFrom + Math.max(1, resolution.points) * bucket);
14858
14901
  }
14859
- // One downsampling call per source rtId → each register stays its own series (the transient
14860
- // downsampling groups by bin, merging rtIds otherwise). With no source scope, a single merged
14861
- // series is produced (labeled by the series-group field name).
14902
+ // Group-aggregation mode (AB#4714): one downsampling call PER GROUP, each with the whole
14903
+ // group's rtIds, so the transient downsampling reduces over every member (per
14904
+ // requiredAggregation) into one aggregate line. Default mode: one call per source rtId, keeping
14905
+ // each source its own series. See loadGroupAggregatedRows / loadPerRtIdRows.
14906
+ const downsample = (rtIds, label) => this.queryExecutor.downsampleByArchive({
14907
+ archiveRtId: resolution.archiveRtId,
14908
+ from: qFrom,
14909
+ to: qTo,
14910
+ limit: Math.max(1, resolution.points),
14911
+ sourcePath,
14912
+ aggregation: resolution.reducingFunction,
14913
+ rtIds: rtIds && rtIds.length > 0 ? rtIds : undefined,
14914
+ fieldFilter
14915
+ }).then(seriesRows => seriesRows.map(r => this.reshapeResolutionRow(r, label)));
14916
+ const rows = ds.aggregateSeriesByGroup && info.ckTypeId
14917
+ ? await this.loadGroupAggregatedRows(info.ckTypeId, sourceRtIds, downsample)
14918
+ : await this.loadPerRtIdRows(info.ckTypeId, sourceRtIds, downsample);
14919
+ return { rows, signal: resolution, requestedPoints: targetPoints };
14920
+ }
14921
+ /**
14922
+ * Group-aggregation fan-out (AB#4714): buckets the source entities by `seriesGroupField` and runs
14923
+ * one downsampling call per group with the group's rtIds. The transient downsampling reduces over
14924
+ * the group's members server-side, yielding one aggregate line per distinct group value. Falls
14925
+ * back to a single merged series when no group can be resolved.
14926
+ */
14927
+ async loadGroupAggregatedRows(ckTypeId, sourceRtIds, downsample) {
14928
+ const groups = await this.queryExecutor.fetchEntityGroups(ckTypeId, this.config.seriesGroupField, sourceRtIds);
14929
+ if (groups.size === 0) {
14930
+ // No group attribute resolved → a single merged line over the whole (optionally scoped) set.
14931
+ return downsample(sourceRtIds, this.config.seriesGroupField);
14932
+ }
14933
+ const perGroup = await Promise.all(Array.from(groups.entries()).map(([groupValue, rtIds]) => downsample(rtIds, groupValue)));
14934
+ return perGroup.flat();
14935
+ }
14936
+ /**
14937
+ * Default resolution-aware fan-out (AB#4290): one downsampling call per source rtId so each
14938
+ * register stays its own line, labeled from its `seriesGroupField` attribute. With no source
14939
+ * scope a single merged series is produced (labeled by the series-group field name).
14940
+ */
14941
+ async loadPerRtIdRows(ckTypeId, sourceRtIds, downsample) {
14862
14942
  const seriesRtIds = sourceRtIds && sourceRtIds.length > 0 ? sourceRtIds : [undefined];
14863
- const labels = info.ckTypeId
14864
- ? await this.queryExecutor.fetchSeriesLabels(info.ckTypeId, sourceRtIds ?? [], this.config.seriesGroupField)
14943
+ const labels = ckTypeId
14944
+ ? await this.queryExecutor.fetchSeriesLabels(ckTypeId, sourceRtIds ?? [], this.config.seriesGroupField)
14865
14945
  : new Map();
14866
- const rows = [];
14867
- for (const rtId of seriesRtIds) {
14868
- const seriesRows = await this.queryExecutor.downsampleByArchive({
14869
- archiveRtId: resolution.archiveRtId,
14870
- from: qFrom,
14871
- to: qTo,
14872
- limit: Math.max(1, resolution.points),
14873
- sourcePath,
14874
- aggregation: resolution.reducingFunction,
14875
- rtIds: rtId ? [rtId] : undefined,
14876
- fieldFilter
14877
- });
14878
- const label = rtId ? (labels.get(rtId) ?? rtId) : this.config.seriesGroupField;
14879
- for (const r of seriesRows) {
14880
- rows.push(this.reshapeResolutionRow(r, label));
14881
- }
14882
- }
14883
- return { rows, signal: resolution, requestedPoints: targetPoints };
14946
+ const perSeries = await Promise.all(seriesRtIds.map(rtId => downsample(rtId ? [rtId] : undefined, rtId ? (labels.get(rtId) ?? rtId) : this.config.seriesGroupField)));
14947
+ return perSeries.flat();
14884
14948
  }
14885
14949
  /**
14886
14950
  * Reshapes one downsampling bin onto the widget's configured value + series-group fields so the
@@ -28154,6 +28218,10 @@ function buildDataSourceFromPersisted(data, config) {
28154
28218
  if (config['resolutionAware'] === true) {
28155
28219
  persisted.resolutionAware = true;
28156
28220
  }
28221
+ // Group-aggregation (AB#4714) — only meaningful with resolutionAware.
28222
+ if (config['aggregateSeriesByGroup'] === true) {
28223
+ persisted.aggregateSeriesByGroup = true;
28224
+ }
28157
28225
  const persistedAggregation = config['requiredAggregation'];
28158
28226
  if (persistedAggregation === 'AVG' || persistedAggregation === 'MIN' || persistedAggregation === 'MAX'
28159
28227
  || persistedAggregation === 'SUM' || persistedAggregation === 'COUNT') {
@@ -29238,6 +29306,9 @@ function registerDefaultWidgets(registry) {
29238
29306
  };
29239
29307
  },
29240
29308
  applyConfigResult: (widget, result) => {
29309
+ // The config dialog does not surface aggregateSeriesByGroup; carry it over from the existing
29310
+ // data source so a dialog edit never silently drops the seed-level flag.
29311
+ const existing = widget.dataSource;
29241
29312
  const dataSource = {
29242
29313
  type: 'persistentQuery',
29243
29314
  queryRtId: result.queryRtId,
@@ -29246,7 +29317,8 @@ function registerDefaultWidgets(registry) {
29246
29317
  ignoreTimeFilter: result.ignoreTimeFilter,
29247
29318
  entitySelectorId: result.entitySelectorId,
29248
29319
  resolutionAware: result.resolutionAware,
29249
- requiredAggregation: result.requiredAggregation
29320
+ requiredAggregation: result.requiredAggregation,
29321
+ aggregateSeriesByGroup: existing.aggregateSeriesByGroup
29250
29322
  };
29251
29323
  const filters = result.filters?.map(f => ({
29252
29324
  attributePath: f.attributePath,
@@ -29314,6 +29386,9 @@ function registerDefaultWidgets(registry) {
29314
29386
  ...(widget.dataSource.requiredAggregation && {
29315
29387
  requiredAggregation: widget.dataSource.requiredAggregation
29316
29388
  }),
29389
+ ...(widget.dataSource.aggregateSeriesByGroup && {
29390
+ aggregateSeriesByGroup: true
29391
+ }),
29317
29392
  filters: widget.filters
29318
29393
  }
29319
29394
  }),