From f7216a8854cd441dfb7fc0728df9ad5d259183fb Mon Sep 17 00:00:00 2001 From: Jochen Jacobs Date: Mon, 15 Jun 2026 14:33:39 +0200 Subject: [PATCH 1/5] Migrate dn_charting bundle to TypeScript Convert all bundle sources from JavaScript to TypeScript with strong typing. Add a local api.ts for domain types and an index.d.ts for the vendored dn_charting-c3 library, derive typed i18n Messages, and apply license headers. --- src/main/js/bundles/dn_charting-c3/index.d.ts | 154 ++++++ ...ataProvider.js => C3ChartsDataProvider.ts} | 84 ++-- ...{C3ChartsFactory.js => C3ChartsFactory.ts} | 79 +-- ...get.vue => ChartingDashboardWidget.ts.vue} | 44 +- .../ChartingDashboardWidgetFactory.js | 79 --- .../ChartingDashboardWidgetFactory.ts | 100 ++++ .../ChartingDashboardWidgetModel.js | 372 -------------- .../ChartingDashboardWidgetModel.ts | 471 ++++++++++++++++++ .../js/bundles/dn_charting/QueryController.js | 108 ---- .../js/bundles/dn_charting/QueryController.ts | 138 +++++ .../ResultCenterChartingToolHandler.js | 69 --- .../ResultCenterChartingToolHandler.ts | 83 +++ src/main/js/bundles/dn_charting/api.ts | 182 +++++++ src/main/js/bundles/dn_charting/main.js | 16 - src/main/js/bundles/dn_charting/manifest.json | 3 + src/main/js/bundles/dn_charting/module.js | 22 - src/main/js/bundles/dn_charting/module.ts | 22 + src/main/js/bundles/dn_charting/nls/bundle.js | 31 -- src/main/js/bundles/dn_charting/nls/bundle.ts | 35 ++ .../js/bundles/dn_charting/nls/de/bundle.js | 28 -- .../js/bundles/dn_charting/nls/de/bundle.ts | 33 ++ 21 files changed, 1328 insertions(+), 825 deletions(-) create mode 100644 src/main/js/bundles/dn_charting-c3/index.d.ts rename src/main/js/bundles/dn_charting/{C3ChartsDataProvider.js => C3ChartsDataProvider.ts} (51%) rename src/main/js/bundles/dn_charting/{C3ChartsFactory.js => C3ChartsFactory.ts} (53%) rename src/main/js/bundles/dn_charting/{ChartingDashboardWidget.vue => ChartingDashboardWidget.ts.vue} (77%) delete mode 100644 src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.js create mode 100644 src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts delete mode 100644 src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.js create mode 100644 src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts delete mode 100644 src/main/js/bundles/dn_charting/QueryController.js create mode 100644 src/main/js/bundles/dn_charting/QueryController.ts delete mode 100644 src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.js create mode 100644 src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts create mode 100644 src/main/js/bundles/dn_charting/api.ts delete mode 100644 src/main/js/bundles/dn_charting/main.js delete mode 100644 src/main/js/bundles/dn_charting/module.js create mode 100644 src/main/js/bundles/dn_charting/module.ts delete mode 100644 src/main/js/bundles/dn_charting/nls/bundle.js create mode 100644 src/main/js/bundles/dn_charting/nls/bundle.ts delete mode 100644 src/main/js/bundles/dn_charting/nls/de/bundle.js create mode 100644 src/main/js/bundles/dn_charting/nls/de/bundle.ts diff --git a/src/main/js/bundles/dn_charting-c3/index.d.ts b/src/main/js/bundles/dn_charting-c3/index.d.ts new file mode 100644 index 0000000..6d4d082 --- /dev/null +++ b/src/main/js/bundles/dn_charting-c3/index.d.ts @@ -0,0 +1,154 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +/** + * Type declarations for the bundled c3 chart library (https://c3js.org/). + * + * The implementation is the vendored, minified `index.js`. These declarations cover the + * subset of the c3 API that is consumed in this project plus the most common configuration + * options. Extend them as needed when additional c3 features are used. + */ + +/** A single data value understood by c3. */ +export type Primitive = string | number | boolean | Date | null; + +/** A row or column of data values (the first entry is usually the series/x label). */ +export type PrimitiveArray = Primitive[]; + +/** Built-in c3 chart types (a plain string is accepted for forward compatibility). */ +export type ChartType = + | "line" + | "spline" + | "step" + | "area" + | "area-spline" + | "area-step" + | "bar" + | "scatter" + | "pie" + | "donut" + | "gauge" + | (string & {}); + +/** Format callback for axis ticks / data labels. c3 stringifies whatever is returned. */ +export type FormatFunction = (value: any, ...args: any[]) => string | number | null; + +/** Padding around the chart drawing area. */ +export interface Padding { + top?: number; + right?: number; + bottom?: number; + left?: number; +} + +/** Data configuration. */ +export interface Data { + /** Name of the column used as the x values. */ + x?: string; + /** Parser pattern for x values provided as strings. */ + xFormat?: string; + /** Data provided as rows (each entry is one record). */ + rows?: PrimitiveArray[]; + /** Data provided as columns (each entry is one series). */ + columns?: PrimitiveArray[]; + /** Chart type applied to every series. */ + type?: ChartType; + /** Chart type per series, keyed by series id. */ + types?: { [series: string]: ChartType }; + /** Series ids that should be stacked together. */ + groups?: string[][]; + /** Explicit color per series, keyed by series id. */ + colors?: { [series: string]: string }; + /** Show data point labels (or configure their format). */ + labels?: boolean | { format?: FormatFunction | { [series: string]: FormatFunction } }; + /** Human readable names per series, keyed by series id. */ + names?: { [series: string]: string }; +} + +/** Tick configuration of an axis. */ +export interface AxisTick { + /** `d3.format` pattern or a callback used to format tick labels. */ + format?: string | FormatFunction; + /** Fit the tick count to the data instead of rounding. */ + fit?: boolean; + /** Number of ticks to show. */ + count?: number; +} + +/** Configuration of a single axis. */ +export interface Axis { + /** Axis value type. */ + type?: "timeseries" | "category" | "indexed" | (string & {}); + tick?: AxisTick; + show?: boolean; + label?: string | { text?: string; position?: string }; +} + +/** Axes configuration. */ +export interface Axes { + /** Swap the x and y axes. */ + rotated?: boolean; + x?: Axis; + y?: Axis; + y2?: Axis; +} + +/** Configuration passed to {@link C3Static.generate}. */ +export interface ChartConfiguration { + /** Element (or selector) the chart is rendered into. */ + bindto?: HTMLElement | string | null; + data: Data; + size?: { width?: number; height?: number }; + padding?: Padding; + axis?: Axes; + color?: { pattern?: string[] }; + line?: { connectNull?: boolean; step?: { type?: string } }; +} + +/** Arguments accepted by {@link ChartAPI.load}. */ +export interface LoadOptions { + rows?: PrimitiveArray[]; + columns?: PrimitiveArray[]; + /** Series ids to remove when loading. */ + unload?: boolean | string[]; + done?: () => void; +} + +/** A generated c3 chart instance. */ +export interface ChartAPI { + /** Resize the chart; without an argument the chart resizes to its container. */ + resize(size?: { width?: number; height?: number }): void; + /** Load (or replace) chart data. */ + load(args: LoadOptions): void; + /** Unload data from the chart. */ + unload(args?: { ids?: string | string[]; done?: () => void }): void; + /** Flush and redraw the chart. */ + flush(): void; + /** Destroy the chart and release its resources. */ + destroy(): void; +} + +/** The c3 module entry point. */ +export interface C3Static { + /** Generate a chart from the given configuration. */ + generate(config: ChartConfiguration): ChartAPI; + /** The c3 library version. */ + readonly version: string; +} + +declare const c3: C3Static; +export default c3; diff --git a/src/main/js/bundles/dn_charting/C3ChartsDataProvider.js b/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts similarity index 51% rename from src/main/js/bundles/dn_charting/C3ChartsDataProvider.js rename to src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts index d1973d9..a400611 100644 --- a/src/main/js/bundles/dn_charting/C3ChartsDataProvider.js +++ b/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts @@ -1,24 +1,26 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + import d_string from "dojo/string"; +import type { ChartAttributes, ChartData, ChartProperties } from "./api"; -class C3ChartsDataProvider { +export default class C3ChartsDataProvider { - getChartData(props, attributes) { - const res = [["x"]]; + getChartData(props: ChartProperties, attributes: ChartAttributes): ChartData { + const res: ChartData = [["x"]]; if (props.dataSeries) { this._getDataSeriesChartData(props, attributes, res); @@ -28,14 +30,14 @@ class C3ChartsDataProvider { return res; } - _getDefaultChartData(props, attributes, res) { + private _getDefaultChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { if (props.relatedData && props.headers && props.headers.length === 1) { - const array = [d_string.substitute(props.title, attributes) || ""]; - const relatedData = attributes.relatedData; - const relatedDataObject = relatedData.find((r) => r.time.toString() === props.headers[0]); - props.data.forEach((data) => { + const array: Array = [d_string.substitute(props.title, attributes) || ""]; + const relatedData = attributes.relatedData ?? []; + const relatedDataObject = relatedData.find((r) => r.time.toString() === props.headers![0]); + props.data?.forEach((data) => { res[0].push(d_string.substitute(data.title, attributes) || ""); - let value = relatedDataObject.attributes[data.attribute]; + let value = relatedDataObject!.attributes[data.attribute]; if (typeof value === "undefined") { value = null; } @@ -43,8 +45,8 @@ class C3ChartsDataProvider { }); res.push(array); } else { - const array = [d_string.substitute(props.title, attributes) || ""]; - props.data.forEach((data) => { + const array: Array = [d_string.substitute(props.title, attributes) || ""]; + props.data?.forEach((data) => { res[0].push(d_string.substitute(data.title, attributes) || ""); let value = attributes[data.attribute]; if (typeof value === "undefined") { @@ -56,7 +58,7 @@ class C3ChartsDataProvider { } } - _getDataSeriesChartData(props, attributes, res) { + private _getDataSeriesChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { if (props.headers) { props.headers.forEach((header) => { res[0].push(d_string.substitute(header, attributes) || ""); @@ -64,22 +66,22 @@ class C3ChartsDataProvider { } if (props.relatedData) { res[0] = ["x"]; - props.dataSeries.forEach((series, i) => { - const array = [d_string.substitute(series.title, attributes) || ""]; - let relatedData = attributes.relatedData; + props.dataSeries!.forEach((series, i) => { + const array: Array = [d_string.substitute(series.title, attributes) || ""]; + let relatedData = attributes.relatedData ?? []; if (props.headers) { // filter values - relatedData = relatedData.filter((r) => props.headers.includes(r.time.toString())); + relatedData = relatedData.filter((r) => props.headers!.includes(r.time.toString())); } - relatedData.sort((a, b) => a.time - b.time); - const attribute = series.attribute; + relatedData.sort((a, b) => Number(a.time) - Number(b.time)); + const attribute = series.attribute!; relatedData.forEach((data) => { let value = data.attributes[attribute]; if (typeof value === "undefined") { value = null; } - // eslint-disable-next-line max-len - if (props.axisIsDateObject && props.axisDatePeriodFilter && !(data.time > props.axisDatePeriodFilter.start && data.time < props.axisDatePeriodFilter.end)) { + // eslint-disable-next-line @stylistic/max-len + if (props.axisIsDateObject && props.axisDatePeriodFilter && !(Number(data.time) > Number(props.axisDatePeriodFilter.start) && Number(data.time) < Number(props.axisDatePeriodFilter.end))) { return; } array.push(value); @@ -94,9 +96,9 @@ class C3ChartsDataProvider { res.push(array); }); } else { - props.dataSeries.forEach((series) => { - const array = [d_string.substitute(series.title, attributes) || ""]; - series.attributes.forEach((attribute) => { + props.dataSeries!.forEach((series) => { + const array: Array = [d_string.substitute(series.title, attributes) || ""]; + series.attributes!.forEach((attribute) => { let value = attributes[attribute]; if (typeof value === "undefined") { value = null; @@ -108,9 +110,9 @@ class C3ChartsDataProvider { } } - getDataColors(props) { + getDataColors(props: ChartProperties): Record | null { if (props.dataSeries) { - const colors = {}; + const colors: Record = {}; props.dataSeries.forEach((series) => { if (series.color) { colors[series.title] = series.color; @@ -123,5 +125,3 @@ class C3ChartsDataProvider { } } - -module.exports = C3ChartsDataProvider; diff --git a/src/main/js/bundles/dn_charting/C3ChartsFactory.js b/src/main/js/bundles/dn_charting/C3ChartsFactory.ts similarity index 53% rename from src/main/js/bundles/dn_charting/C3ChartsFactory.js rename to src/main/js/bundles/dn_charting/C3ChartsFactory.ts index 39219ba..a97160e 100644 --- a/src/main/js/bundles/dn_charting/C3ChartsFactory.js +++ b/src/main/js/bundles/dn_charting/C3ChartsFactory.ts @@ -1,30 +1,53 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import c3 from "dn_charting-c3"; +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import c3, { type ChartAPI, type ChartConfiguration } from "dn_charting-c3"; +import type { ChartAttributes, ChartProperties } from "./api"; +import type C3ChartsDataProvider from "./C3ChartsDataProvider"; export default class C3ChartsFactory { - createChart(chartNode, chartProperties, attributes, chart) { - // eslint-disable-next-line max-len - return chart ? this._updateChart(chartProperties, attributes, chart) : this._createChart(chartProperties, attributes, chartNode); + declare private _c3ChartsDataProvider: C3ChartsDataProvider; + + createChart( + chartNode: HTMLElement, + chartProperties: ChartProperties, + attributes: ChartAttributes, + chart: null + ): ChartAPI; + createChart( + chartNode: HTMLElement, + chartProperties: ChartProperties, + attributes: ChartAttributes, + chart: ChartAPI + ): void; + createChart( + chartNode: HTMLElement, + chartProperties: ChartProperties, + attributes: ChartAttributes, + chart: ChartAPI | null + ): ChartAPI | void { + return chart + ? this._updateChart(chartProperties, attributes, chart) + : this._createChart(chartProperties, attributes, chartNode); } - _createChart(chartProperties, attributes, node) { + private _createChart(chartProperties: ChartProperties, attributes: ChartAttributes, node: HTMLElement): ChartAPI { const data = this._c3ChartsDataProvider.getChartData(chartProperties, attributes); - const props = { + const props: ChartConfiguration = { bindto: node, padding: chartProperties.padding || { right: 10 @@ -50,14 +73,14 @@ export default class C3ChartsFactory { } }; if (chartProperties.axisFormat) { - props.axis.x.tick = { + props.axis!.x!.tick = { format: chartProperties.axisFormat }; if (chartProperties.axisTickAdjusted !== undefined) { - props.axis.x.tick.fit = chartProperties.axisTickAdjusted; + props.axis!.x!.tick.fit = chartProperties.axisTickAdjusted; } if (chartProperties.axisTickCount !== undefined) { - props.axis.x.tick.count = chartProperties.axisTickCount; + props.axis!.x!.tick.count = chartProperties.axisTickCount; } } if (chartProperties.axisFormat && !chartProperties.axisIsDateObject) { @@ -67,10 +90,10 @@ export default class C3ChartsFactory { chartProperties.dataOrientation = "rows"; } if (chartProperties.hideDecimalValues) { - props.axis.y = { + props.axis!.y = { tick: { - format: function (d) { - return (parseInt(d) === d) ? d : null; + format: function (d: number): number | null { + return (parseInt(`${d}`) === d) ? d : null; } } }; @@ -90,7 +113,7 @@ export default class C3ChartsFactory { return c3.generate(props); } - _updateChart(chartProperties, attributes, chart) { + private _updateChart(chartProperties: ChartProperties, attributes: ChartAttributes, chart: ChartAPI): void { const data = this._c3ChartsDataProvider.getChartData(chartProperties, attributes); switch (chartProperties.dataOrientation) { diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidget.vue b/src/main/js/bundles/dn_charting/ChartingDashboardWidget.ts.vue similarity index 77% rename from src/main/js/bundles/dn_charting/ChartingDashboardWidget.vue rename to src/main/js/bundles/dn_charting/ChartingDashboardWidget.ts.vue index db30069..0f43cb1 100644 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidget.vue +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidget.ts.vue @@ -68,55 +68,39 @@ - diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.js b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.js deleted file mode 100644 index f9f6caf..0000000 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import ChartingDashboardWidget from "./ChartingDashboardWidget.vue"; -import Vue from "apprt-vue/Vue"; -import VueDijit from "apprt-vue/VueDijit"; -import Binding from "apprt-binding/Binding"; -import d_aspect from "dojo/aspect"; -import ct_util from "ct/ui/desktop/util"; - -export default class ChartingDashboardWidgetFactory { - - activate() { - this._initComponent(); - } - - _initComponent() { - const model = this._chartingDashboardWidgetModel; - const vm = this.vm = new Vue(ChartingDashboardWidget); - vm.i18n = this.i18n = this._i18n.get().ui; - - Binding - .create() - .bindTo(vm, model) - .syncAll("activeTab") - .syncAllToLeft("loading", "tabs", "expandedCharts") - .enable() - .syncToLeftNow(); - - vm.$once('start', () => { - const widget = this.widget; - const enclosingWidget = ct_util.findEnclosingWindow(widget); - if (enclosingWidget) { - d_aspect.before(enclosingWidget, "resize", (dims) => { - if (dims) { - model.resizeCharts(dims.w); - } - }); - } - }); - - vm.$on('activeTabChanged', (activeTab) => { - model.drawGraphicsForActiveTab(activeTab); - }); - - d_aspect.after(model, "_drawCharts", () => { - this.resizeCharts(); - }); - } - - resizeCharts() { - const model = this._chartingDashboardWidgetModel; - let width; - const rect = this.vm.$el && this.vm.$el.getBoundingClientRect(); - if (rect) { - width = rect.width; - } else { - width = 500; - } - model.resizeCharts(width); - } - - createInstance() { - this.widget = VueDijit(this.vm); - return this.widget; - } -} diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts new file mode 100644 index 0000000..12dc614 --- /dev/null +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts @@ -0,0 +1,100 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import ChartingDashboardWidget from "./ChartingDashboardWidget.ts.vue"; +import Vue from "apprt-vue/Vue"; +import VueDijit from "apprt-vue/VueDijit"; +import Binding, { type Bindable } from "apprt-binding/Binding"; +import d_aspect from "dojo/aspect"; +import ct_util from "ct/ui/desktop/util"; +import type { I18N } from "apprt/api"; +import type { ChartingDashboardWidgetModel } from "./ChartingDashboardWidgetModel"; +import type { Messages } from "./nls/bundle"; +import type { Tab } from "./api"; + +/** The Vue view model bound to the dashboard widget model. */ +type ChartingDashboardVm = Vue & { + i18n: Messages["ui"]; + loading: boolean; + tabs: Tab[]; + expandedCharts: Array; + activeTab: number; +}; + +export default class ChartingDashboardWidgetFactory { + + declare private _chartingDashboardWidgetModel: ChartingDashboardWidgetModel; + declare private _i18n: I18N; + + private vm!: ChartingDashboardVm; + private i18n!: Messages["ui"]; + private widget!: any; + + activate(): void { + this._initComponent(); + } + + private _initComponent(): void { + const model = this._chartingDashboardWidgetModel; + const vm = this.vm = new Vue(ChartingDashboardWidget as any) as ChartingDashboardVm; + vm.i18n = this.i18n = this._i18n.get().ui; + + Binding + .create() + .bindTo(vm as unknown as Bindable, model as unknown as Bindable) + .syncAll("activeTab") + .syncAllToLeft("loading", "tabs", "expandedCharts") + .enable() + .syncToLeftNow(); + + vm.$once('start', () => { + const widget = this.widget; + const enclosingWidget = ct_util.findEnclosingWindow(widget); + if (enclosingWidget) { + d_aspect.before(enclosingWidget, "resize", (dims: { w: number } | undefined) => { + if (dims) { + model.resizeCharts(dims.w); + } + }); + } + }); + + vm.$on('activeTabChanged', (activeTab: number) => { + model.drawGraphicsForActiveTab(activeTab); + }); + + d_aspect.after(model, "_drawCharts", () => { + this.resizeCharts(); + }); + } + + resizeCharts(): void { + const model = this._chartingDashboardWidgetModel; + let width: number; + const rect = this.vm.$el && this.vm.$el.getBoundingClientRect(); + if (rect) { + width = rect.width; + } else { + width = 500; + } + model.resizeCharts(width); + } + + createInstance(): any { + this.widget = VueDijit(this.vm); + return this.widget; + } +} diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.js b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.js deleted file mode 100644 index 5f09d56..0000000 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.js +++ /dev/null @@ -1,372 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import {declare} from "apprt-core/Mutable"; -import domConstruct from "dojo/dom-construct"; -import ct_lang from "ct/_lang"; -import apprt_when from "apprt-core/when"; -import * as geometryEngine from "@arcgis/core/geometry/geometryEngine"; - -const _currentHighlight = Symbol("_currentHighlight"); - -export default declare({ - - loading: false, - tabTitle: "", - activeTab: 0, - chartsTitle: "", - tabs: [], - expandedCharts: [], - _charts: [], - _geometries: [], - - activate() { - this._tool.watch("active", (name, oldValue, newValue) => { - if (!newValue) { - this._clearHighlight(); - } - }); - }, - - receiveSelections(event) { - if (this.drawChartsForSelectionResults) { - if (this.loading) { - return; - } - this.loading = true; - const queryExecutions = event.getProperty("executions"); - queryExecutions.waitForExecution().then((response) => { - const executions = response.executions; - const responses = []; - executions.forEach((response) => { - if (response.result && response.result.length) { - responses.push(response); - } - }); - this.setCharts(responses); - }); - } - }, - - setCharts(responses) { - this.getAllAttributes(responses).then((res) => { - this.handleChartResponses(res); - }); - }, - - resizeCharts(width) { - if (width >= 40) { - width -= 40; - } - this._charts.forEach((chart) => { - chart.resize({width: width}); - }); - }, - - drawGraphicsForActiveTab(activeTab) { - const tab = this.tabs[activeTab]; - const geometries = tab && tab.geometries; - if (geometries) { - this._highlightGeometries(geometries); - } - }, - - getAllAttributes(responses) { - const promises = responses.map((response) => { - let store = response.source.store; - if (store.masterStore) { - store = store.masterStore; - } - const ids = response.result.map((result) => result[store.idProperty]); - const query = {}; - query[store.idProperty] = {$in: ids}; - return store.query(query).then((results) => { - response.result = results; - return response; - }); - }); - return Promise.all(promises).then((res) => res); - }, - - handleChartResponses(responses) { - this.tabs = []; - this._charts = []; - this._geometries = []; - this._tool.set("active", true); - let newPromise; - let oldPromise; - if (this.chartsTabs) { - newPromise = this._newChartsConfiguration(responses); - } - if (this.chartsProperties) { - oldPromise = this._oldChartsConfiguration(responses); - } - Promise.all([newPromise, oldPromise]).then(() => { - this.activeTab = 0; - this.drawGraphicsForActiveTab(0); - }); - }, - - _getSumObjects(responses) { - return responses.map((response) => new Promise((resolve, reject) => { - let sumObject = null; - const results = response.result; - const storeId = response.source.id; - const relationShips = this._properties.relationships; - const relationShip = relationShips.find((relation) => relation.storeId === storeId); - this._queryController.getRelatedData(results, relationShip).then((results) => { - results.forEach((result) => { - if (!sumObject) { - sumObject = {}; - } - ct_lang.forEachProp(result, (value, name) => { - if (name === "relatedData") { - if (!sumObject.relatedData) { - sumObject.relatedData = value; - return; - } - sumObject.relatedData.forEach((data) => { - const newData = value.find((d) => d.time === data.time); - ct_lang.forEachProp(newData.attributes, (value, name) => { - if (data.attributes[name]) { - if (typeof value === "number") { - data.attributes[name] = data.attributes[name] += parseFloat(value); - } - } else { - if (typeof value === "number") { - data.attributes[name] = parseFloat(value); - } else { - data.attributes[name] = value; - } - } - }); - }); - } else { - if (sumObject[name]) { - if (typeof value === "number") { - sumObject[name] = sumObject[name] += parseFloat(value); - } - } else { - if (typeof value === "number") { - sumObject[name] = parseFloat(value); - } else { - sumObject[name] = value; - } - } - } - }); - - }); - - if (this.drawTabGeometries) { - // eslint-disable-next-line max-len - apprt_when(this._queryController.getGeometryForSumObject(results, response.source.store), (results) => { - const geometries = []; - results.forEach((result) => { - if (result.geometry) { - // eslint-disable-next-line max-len - const geometryAlreadyContained = this._isGeometryAlreadyContained(result.geometry, geometries); - !geometryAlreadyContained && geometries.push(result.geometry); - } - }); - resolve({ - object: sumObject, - count: results.length, - storeId: storeId, - geometries: geometries - }); - }); - } else { - resolve({ - object: sumObject, - count: results.length, - storeId: storeId, - geometries: [] - }); - } - }); - })); - }, - - _newChartsConfiguration(responses) { - const chartsTabs = this.chartsTabs; - const sumObjectsPromises = this._getSumObjects(responses); - - return Promise.all(sumObjectsPromises).then((sumObjects) => { - chartsTabs.forEach((chartsTab) => { - const chartNodes = []; - const tab = { - id: this.tabs.length, - tabTitle: chartsTab.title, - chartsTitle: this._getChartsTitle(chartsTab.chartsTitle, sumObjects), - chartNodes: chartNodes, - geometries: [] - }; - this._drawCharts(sumObjects, chartsTab.charts, tab); - if (chartNodes.length) { - this.tabs.push(tab); - } - }); - this.loading = false; - }, (error) => { - console.error(error); - this.loading = false; - }); - }, - - _oldChartsConfiguration(responses) { - const sumObjectsPromises = this._getSumObjects(responses); - - return Promise.all(sumObjectsPromises).then((sumObjects) => { - responses.forEach((response) => { - const tabTitle = response.source.title; - const storeId = response.source.id; - const chartsProperties = this._getChartsProperties(storeId); - if (!chartsProperties) { - return; - } - const chartNodes = []; - const tab = { - id: this.tabs.length, - tabTitle: tabTitle, - chartsTitle: this._getChartsTitle(chartsProperties.titleAttribute, response), - chartNodes: chartNodes, - geometries: [] - }; - this.tabs.push(tab); - this._drawCharts(sumObjects, chartsProperties.charts, tab, storeId); - }); - this.loading = false; - }, (error) => { - console.error(error); - this.loading = false; - }); - }, - - _getChartsTitle(properties, objects) { - if (properties && typeof properties === "object" && properties.constructor === Object) { - const sumObject = objects.find((object) => object.storeId === properties.storeId); - let title = ""; - if (!sumObject) { - return title; - } - const count = sumObject.count; - if (count === 1) { - title = sumObject.object[properties.titleAttribute]; - if (!title && sumObject.object.relatedData.length) { - title = sumObject.object.relatedData[0].attributes[properties.titleAttribute]; - } - } else { - title = this._i18n.get().ui.multipleObjects; - } - return title; - } else { - const total = objects.total; - return total === 1 ? objects.result[0][properties] : this._i18n.get().ui.multipleObjects; - } - }, - - _getChartsProperties(storeId) { - const chartsProperties = this._properties.chartsProperties; - return chartsProperties.find((properties) => properties.storeId === storeId); - }, - - _drawCharts(sumObjects, chartsProperties, tab, storeId) { - const factory = this._c3ChartsFactory; - chartsProperties.forEach((chartProperties) => { - const attributes = {}; - const sumObject = sumObjects.find((sumObject) => { - if (chartProperties.storeId) { - return sumObject.storeId === chartProperties.storeId; - } else if (storeId) { - return sumObject.storeId === storeId; - } - }); - if (!sumObject) { - return; - } - sumObject.geometries.forEach((geometry) => { - const geometryAlreadyContained = this._isGeometryAlreadyContained(geometry, tab.geometries); - !geometryAlreadyContained && tab.geometries.push(geometry); - }); - if (chartProperties.calculationType === "mean") { - ct_lang.forEachOwnProp(sumObject.object, (value, name) => { - if (typeof value === "number") { - attributes[name] = Math.round(value / sumObject.count * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - sumObject.object.relatedData && sumObject.object.relatedData.forEach((data) => { - ct_lang.forEachOwnProp(data.attributes, (value, name) => { - if (typeof value === "number") { - attributes[name] = Math.round(value / sumObject.count * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - }); - } else { - ct_lang.forEachOwnProp(sumObject.object, (value, name) => { - if (typeof value === "number") { - attributes[name] = Math.round(value * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - sumObject.object.relatedData && sumObject.object.relatedData.forEach((data) => { - ct_lang.forEachOwnProp(data.attributes, (value, name) => { - if (typeof value === "number") { - attributes[name] = Math.round(value * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - }); - } - const chartNode = domConstruct.create("div"); - const chart = factory.createChart(chartNode, chartProperties, attributes, null); - this._charts.push(chart); - chartNode.titleText = chartProperties.title; - const expanded = undefined ? true : chartProperties.expanded; - this.expandedCharts.push(expanded); - tab.chartNodes.push(chartNode); - }); - }, - - _highlightGeometries(geometries) { - this._clearHighlight(); - const highlightObjects = geometries.map((geometry) => { - return { - geometry: geometry - }; - }); - this[_currentHighlight] = this._highlighter.highlight(highlightObjects); - }, - - _clearHighlight() { - if (this[_currentHighlight]) { - this[_currentHighlight].remove(); - this[_currentHighlight] = undefined; - } - }, - - _isGeometryAlreadyContained(geometry, geometries) { - return geometries.find((g) => { - const distance = geometryEngine.distance(g.extent.center, geometry.extent.center, "meters"); - return distance === 0; - }); - } -}); diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts new file mode 100644 index 0000000..c8618bf --- /dev/null +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts @@ -0,0 +1,471 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { declare } from "apprt-core/Mutable"; +import domConstruct from "dojo/dom-construct"; +import ct_lang from "ct/_lang"; +import apprt_when from "apprt-core/when"; +import * as geometryEngine from "@arcgis/core/geometry/geometryEngine"; +import type Geometry from "@arcgis/core/geometry/Geometry"; +import type { Highlight, Highlighter } from "highlights/api"; +import type Tool from "ct/tools/Tool"; +import type { I18N } from "apprt/api"; +import type { SearchSource } from "selection-services/api"; +import type { ChartAPI } from "dn_charting-c3"; +import type { Messages } from "./nls/bundle"; +import type C3ChartsFactory from "./C3ChartsFactory"; +import type QueryController from "./QueryController"; +import type { + ChartAttributes, + ChartingComponentProperties, + ChartProperties, + ChartResultSet, + ChartsTab, + ChartsTitle, + ChartNode, + ChartStore, + RelatedDataEntry, + SelectionExecutingEvent, + StoreChartsProperties, + SumObject, + Tab +} from "./api"; + +const _currentHighlight = Symbol("_currentHighlight"); + +/** + * Public contract of the charting dashboard widget model. + * It exposes the mutable view state, the injected references and all behaviour methods. + */ +export interface ChartingDashboardWidgetModel { + // mutable view state + loading: boolean; + tabTitle: string; + activeTab: number; + chartsTitle: string; + tabs: Tab[]; + expandedCharts: Array; + _charts: ChartAPI[]; + _geometries: Geometry[]; + + // configured component properties (propertiesConstructor) + drawTabGeometries: boolean; + drawChartsForSelectionResults: boolean; + chartsTabs: ChartsTab[]; + chartsProperties: StoreChartsProperties[]; + + // injected references + _properties: ChartingComponentProperties; + _tool: Tool; + _c3ChartsFactory: C3ChartsFactory; + _queryController: QueryController; + _highlighter: Highlighter; + _i18n: I18N; + _mapWidgetModel?: unknown; + + // internal state + [_currentHighlight]?: Highlight; + + activate(): void; + receiveSelections(event: SelectionExecutingEvent): void; + setCharts(resultSets: ChartResultSet[]): void; + resizeCharts(width: number): void; + drawGraphicsForActiveTab(activeTab: number): void; + getAllAttributes(resultSets: ChartResultSet[]): Promise; + handleChartResponses(resultSets: ChartResultSet[]): void; + _getSumObjects(resultSets: ChartResultSet[]): Array>; + _newChartsConfiguration(resultSets: ChartResultSet[]): Promise; + _oldChartsConfiguration(resultSets: ChartResultSet[]): Promise; + _getChartsTitle(properties: ChartsTitle, objects: SumObject[] | ChartResultSet): string; + _getChartsProperties(storeId: string): StoreChartsProperties | undefined; + _drawCharts(sumObjects: SumObject[], chartsProperties: ChartProperties[], tab: Tab, storeId?: string): void; + _highlightGeometries(geometries: Geometry[]): void; + _clearHighlight(): void; + _isGeometryAlreadyContained(geometry: Geometry, geometries: Geometry[]): Geometry | undefined; +} + +export default declare({ + + loading: false, + tabTitle: "", + activeTab: 0, + chartsTitle: "", + tabs: [], + expandedCharts: [], + _charts: [], + _geometries: [], + + activate(this: ChartingDashboardWidgetModel): void { + this._tool.watch("active", (name, oldValue, newValue) => { + if (!newValue) { + this._clearHighlight(); + } + }); + }, + + receiveSelections(this: ChartingDashboardWidgetModel, event: SelectionExecutingEvent): void { + if (this.drawChartsForSelectionResults) { + if (this.loading) { + return; + } + this.loading = true; + const queryExecutions = event.getProperty("executions"); + queryExecutions.waitForExecution().then((executions) => { + const resultSets: ChartResultSet[] = []; + executions.executions.forEach((execution) => { + const result = execution.result; + if (!result || !result.length) { + return; + } + // A selection execution's source is a selection-services SearchSource (the public + // store-api typing only exposes the minimal DataSource view). + const source = execution.source as unknown as SearchSource; + resultSets.push({ + storeId: source.id, + title: source.title, + store: source.store as unknown as ChartStore, + result: result as unknown[], + total: execution.total ?? result.length + }); + }); + this.setCharts(resultSets); + }); + } + }, + + setCharts(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): void { + this.getAllAttributes(resultSets).then((res) => { + this.handleChartResponses(res); + }); + }, + + resizeCharts(this: ChartingDashboardWidgetModel, width: number): void { + if (width >= 40) { + width -= 40; + } + this._charts.forEach((chart) => { + chart.resize({ width: width }); + }); + }, + + drawGraphicsForActiveTab(this: ChartingDashboardWidgetModel, activeTab: number): void { + const tab = this.tabs[activeTab]; + const geometries = tab && tab.geometries; + if (geometries) { + this._highlightGeometries(geometries); + } + }, + + getAllAttributes(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { + const promises = resultSets.map((resultSet) => { + let store = resultSet.store; + if (store.masterStore) { + store = store.masterStore; + } + const idProperty = store.idProperty; + const ids = resultSet.result.map((result) => result[idProperty]); + const query: Record = {}; + query[idProperty] = { $in: ids }; + return store.query(query).then((results) => { + resultSet.result = results; + return resultSet; + }); + }); + return Promise.all(promises).then((res) => res); + }, + + handleChartResponses(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): void { + this.tabs = []; + this._charts = []; + this._geometries = []; + this._tool.set("active", true); + let newPromise: Promise | undefined; + let oldPromise: Promise | undefined; + if (this.chartsTabs) { + newPromise = this._newChartsConfiguration(resultSets); + } + if (this.chartsProperties) { + oldPromise = this._oldChartsConfiguration(resultSets); + } + Promise.all([newPromise, oldPromise]).then(() => { + this.activeTab = 0; + this.drawGraphicsForActiveTab(0); + }); + }, + + _getSumObjects(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Array> { + return resultSets.map((resultSet) => new Promise((resolve) => { + let sumObject: (Record & { relatedData?: RelatedDataEntry[] }) | null = null; + const results = resultSet.result; + const storeId = resultSet.storeId; + const relationShips = this._properties.relationships; + const relationShip = relationShips.find((relation) => relation.storeId === storeId); + this._queryController.getRelatedData(results, relationShip).then((relatedResults) => { + relatedResults.forEach((result) => { + if (!sumObject) { + sumObject = {}; + } + const current = sumObject; + ct_lang.forEachProp(result, (value: any, name: string) => { + if (name === "relatedData") { + if (!current.relatedData) { + current.relatedData = value; + return; + } + current.relatedData.forEach((data) => { + const newData = value.find((d: RelatedDataEntry) => d.time === data.time); + ct_lang.forEachProp(newData.attributes, (value2: any, name2: string) => { + if (data.attributes[name2]) { + if (typeof value2 === "number") { + data.attributes[name2] += value2; + } + } else { + data.attributes[name2] = value2; + } + }); + }); + } else { + if (current[name]) { + if (typeof value === "number") { + current[name] += value; + } + } else { + current[name] = value; + } + } + }); + + }); + + if (this.drawTabGeometries) { + // eslint-disable-next-line @stylistic/max-len + apprt_when(this._queryController.getGeometryForSumObject(relatedResults, resultSet.store), (geometryResults: any[]) => { + const geometries: Geometry[] = []; + geometryResults.forEach((result) => { + if (result.geometry) { + // eslint-disable-next-line @stylistic/max-len + const geometryAlreadyContained = this._isGeometryAlreadyContained(result.geometry, geometries); + !geometryAlreadyContained && geometries.push(result.geometry); + } + }); + resolve({ + object: sumObject, + count: geometryResults.length, + storeId: storeId, + geometries: geometries + }); + }); + } else { + resolve({ + object: sumObject, + count: relatedResults.length, + storeId: storeId, + geometries: [] + }); + } + }); + })); + }, + + _newChartsConfiguration(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { + const chartsTabs = this.chartsTabs; + const sumObjectsPromises = this._getSumObjects(resultSets); + + return Promise.all(sumObjectsPromises).then((sumObjects) => { + chartsTabs.forEach((chartsTab) => { + const chartNodes: ChartNode[] = []; + const tab: Tab = { + id: this.tabs.length, + tabTitle: chartsTab.title, + chartsTitle: this._getChartsTitle(chartsTab.chartsTitle, sumObjects), + chartNodes: chartNodes, + geometries: [] + }; + this._drawCharts(sumObjects, chartsTab.charts, tab); + if (chartNodes.length) { + this.tabs.push(tab); + } + }); + this.loading = false; + }, (error) => { + console.error(error); + this.loading = false; + }); + }, + + _oldChartsConfiguration(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { + const sumObjectsPromises = this._getSumObjects(resultSets); + + return Promise.all(sumObjectsPromises).then((sumObjects) => { + resultSets.forEach((resultSet) => { + const tabTitle = resultSet.title; + const storeId = resultSet.storeId; + const chartsProperties = this._getChartsProperties(storeId); + if (!chartsProperties) { + return; + } + const chartNodes: ChartNode[] = []; + const tab: Tab = { + id: this.tabs.length, + tabTitle: tabTitle, + chartsTitle: this._getChartsTitle(chartsProperties.titleAttribute, resultSet), + chartNodes: chartNodes, + geometries: [] + }; + this.tabs.push(tab); + this._drawCharts(sumObjects, chartsProperties.charts, tab, storeId); + }); + this.loading = false; + }, (error) => { + console.error(error); + this.loading = false; + }); + }, + + _getChartsTitle( + this: ChartingDashboardWidgetModel, + properties: ChartsTitle, + objects: SumObject[] | ChartResultSet + ): string { + if (properties && typeof properties === "object" && properties.constructor === Object) { + const sumObjects = objects as SumObject[]; + const sumObject = sumObjects.find((object) => object.storeId === properties.storeId); + let title = ""; + if (!sumObject) { + return title; + } + const count = sumObject.count; + if (count === 1) { + title = sumObject.object?.[properties.titleAttribute] ?? ""; + if (!title && sumObject.object?.relatedData?.length) { + title = sumObject.object.relatedData[0].attributes[properties.titleAttribute]; + } + } else { + title = this._i18n.get().ui.multipleObjects; + } + return title; + } else { + const resultSet = objects as ChartResultSet; + const total = resultSet.total; + return total === 1 ? resultSet.result[0][properties as string] : this._i18n.get().ui.multipleObjects; + } + }, + + _getChartsProperties(this: ChartingDashboardWidgetModel, storeId: string): StoreChartsProperties | undefined { + const chartsProperties = this._properties.chartsProperties; + return chartsProperties.find((properties) => properties.storeId === storeId); + }, + + _drawCharts( + this: ChartingDashboardWidgetModel, + sumObjects: SumObject[], + chartsProperties: ChartProperties[], + tab: Tab, + storeId?: string + ): void { + const factory = this._c3ChartsFactory; + chartsProperties.forEach((chartProperties) => { + const attributes: ChartAttributes = {}; + const sumObject = sumObjects.find((sumObject) => { + if (chartProperties.storeId) { + return sumObject.storeId === chartProperties.storeId; + } else if (storeId) { + return sumObject.storeId === storeId; + } + return false; + }); + if (!sumObject) { + return; + } + sumObject.geometries.forEach((geometry) => { + const geometryAlreadyContained = this._isGeometryAlreadyContained(geometry, tab.geometries); + !geometryAlreadyContained && tab.geometries.push(geometry); + }); + if (chartProperties.calculationType === "mean") { + ct_lang.forEachOwnProp(sumObject.object, (value: any, name: string) => { + if (typeof value === "number") { + attributes[name] = Math.round(value / sumObject.count * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + }); + sumObject.object?.relatedData?.forEach((data) => { + ct_lang.forEachOwnProp(data.attributes, (value: any, name: string) => { + if (typeof value === "number") { + attributes[name] = Math.round(value / sumObject.count * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + }); + }); + } else { + ct_lang.forEachOwnProp(sumObject.object, (value: any, name: string) => { + if (typeof value === "number") { + attributes[name] = Math.round(value * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + }); + sumObject.object?.relatedData?.forEach((data) => { + ct_lang.forEachOwnProp(data.attributes, (value: any, name: string) => { + if (typeof value === "number") { + attributes[name] = Math.round(value * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + }); + }); + } + const chartNode = domConstruct.create("div") as ChartNode; + const chart = factory.createChart(chartNode, chartProperties, attributes, null); + this._charts.push(chart); + chartNode.titleText = chartProperties.title; + const expanded = chartProperties.expanded; + this.expandedCharts.push(expanded); + tab.chartNodes.push(chartNode); + }); + }, + + _highlightGeometries(this: ChartingDashboardWidgetModel, geometries: Geometry[]): void { + this._clearHighlight(); + const highlightObjects = geometries.map((geometry) => { + return { + geometry: geometry + }; + }); + // Geometry is the abstract base type; the highlighter expects a concrete esri geometry union. + this[_currentHighlight] = this._highlighter.highlight(highlightObjects as any); + }, + + _clearHighlight(this: ChartingDashboardWidgetModel): void { + const highlight = this[_currentHighlight]; + if (highlight) { + highlight.remove(); + this[_currentHighlight] = undefined; + } + }, + + _isGeometryAlreadyContained( + this: ChartingDashboardWidgetModel, + geometry: Geometry, + geometries: Geometry[] + ): Geometry | undefined { + return geometries.find((g) => { + const distance = geometryEngine.distance(g.extent!.center, geometry.extent!.center, "meters"); + return distance === 0; + }); + } +}) as unknown as { new (...args: any[]): ChartingDashboardWidgetModel; (...args: any[]): ChartingDashboardWidgetModel }; diff --git a/src/main/js/bundles/dn_charting/QueryController.js b/src/main/js/bundles/dn_charting/QueryController.js deleted file mode 100644 index feb5a51..0000000 --- a/src/main/js/bundles/dn_charting/QueryController.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { apprtFetch } from "apprt-fetch"; - -export default class QueryController { - - findRelatedRecords(objectId, url, metadata) { - const relationships = this.relationships = metadata.relationships; - const requests = relationships.map((relationship) => { - const relationshipId = relationship && relationship.id; - return apprtFetch(url + "/queryRelatedRecords", { - query: { - objectIds: [objectId], - relationshipId: relationshipId, - outFields: "*", - returnGeometry: true, - returnCountOnly: false, - f: 'json' - }, - handleAs: 'json' - }); - }); - if (requests.length > 0) { - return Promise.all(requests); - } else { - return null; - } - } - - getRelatedData(results, relationship) { - return new Promise((resolve, reject) => { - if (!relationship || !results.length) { - resolve(results); - } else { - const requests = results.map((result) => apprtFetch(relationship.tableUrl + "/query", { - query: { - where: relationship.foreignKey + " LIKE " + result[relationship.primaryKey], - outFields: "*", - returnGeometry: false, - returnCountOnly: false, - f: 'json' - }, - handleAs: 'json' - }).then((relatedData) => { - const features = []; - relatedData.features.forEach((feature) => { - const attributes = feature.attributes; - const time = attributes[relationship.timeAttribute]; - features.push({time: time, attributes: attributes}); - }); - result.relatedData = features; - return result; - })); - Promise.all(requests).then((results) => { - resolve(results); - }); - } - }); - } - - getRelatedMetadata(url, metadata) { - url = url.substr(0, url.lastIndexOf("/")); - const relationships = this.relationships = metadata.relationships; - const requests = relationships.map((relationship) => { - const relatedTableId = relationship && relationship.relatedTableId; - return apprtFetch(url + "/" + relatedTableId, { - query: { - f: 'json' - }, - handleAs: 'json' - }); - }); - return Promise.all(requests); - } - - getMetadata(url) { - return apprtFetch(url, { - query: { - f: 'json' - }, - handleAs: 'json' - }); - } - - getGeometryForSumObject(results, store) { - const query = {}; - const ids = results.map((result) => result[store.idProperty]); - query[store.idProperty] = {$in: ids}; - return store.query(query, { - fields: { - geometry: 1 - } - }); - } -} diff --git a/src/main/js/bundles/dn_charting/QueryController.ts b/src/main/js/bundles/dn_charting/QueryController.ts new file mode 100644 index 0000000..acf07ab --- /dev/null +++ b/src/main/js/bundles/dn_charting/QueryController.ts @@ -0,0 +1,138 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { apprtFetch } from "apprt-fetch"; +import type { ChartStore, FeatureAttributes, RelatedDataEntry, Relationship } from "./api"; + +/** Metadata of a feature service layer, holding its relationships. */ +interface ServiceMetadata { + relationships: Relationship[]; +} + +/** A feature returned by an esri feature service query. */ +interface EsriFeature { + attributes: FeatureAttributes; +} + +/** Response of an esri feature service query. */ +interface EsriFeatureSet { + features: EsriFeature[]; +} + +/** + * apprt-fetch supports the legacy `handleAs`/`query` options which resolve directly to + * the parsed response body. The current typings only describe the standard fetch API, + * so a typed wrapper is used here. + */ +interface LegacyFetchInit { + query?: Record; + handleAs?: "json"; +} +const legacyFetch = apprtFetch as unknown as (url: string, init: LegacyFetchInit) => Promise; + +export default class QueryController { + + relationships?: Relationship[]; + + findRelatedRecords(objectId: number | string, url: string, metadata: ServiceMetadata): Promise | null { + const relationships = this.relationships = metadata.relationships; + const requests = relationships.map((relationship) => { + const relationshipId = relationship && relationship.id; + return legacyFetch(url + "/queryRelatedRecords", { + query: { + objectIds: [objectId], + relationshipId: relationshipId, + outFields: "*", + returnGeometry: true, + returnCountOnly: false, + f: 'json' + }, + handleAs: 'json' + }); + }); + if (requests.length > 0) { + return Promise.all(requests); + } else { + return null; + } + } + + getRelatedData(results: FeatureAttributes[], relationship: Relationship | undefined): Promise { + return new Promise((resolve) => { + if (!relationship || !results.length) { + resolve(results); + } else { + const requests = results.map((result) => legacyFetch(relationship.tableUrl + "/query", { + query: { + where: relationship.foreignKey + " LIKE " + result[relationship.primaryKey], + outFields: "*", + returnGeometry: false, + returnCountOnly: false, + f: 'json' + }, + handleAs: 'json' + }).then((relatedData) => { + const features: RelatedDataEntry[] = []; + relatedData.features.forEach((feature) => { + const attributes = feature.attributes; + const time = attributes[relationship.timeAttribute]; + features.push({ time: time, attributes: attributes }); + }); + result.relatedData = features; + return result; + })); + Promise.all(requests).then((res) => { + resolve(res); + }); + } + }); + } + + getRelatedMetadata(url: string, metadata: ServiceMetadata): Promise { + url = url.substr(0, url.lastIndexOf("/")); + const relationships = this.relationships = metadata.relationships; + const requests = relationships.map((relationship) => { + const relatedTableId = relationship && relationship.relatedTableId; + return legacyFetch(url + "/" + relatedTableId, { + query: { + f: 'json' + }, + handleAs: 'json' + }); + }); + return Promise.all(requests); + } + + getMetadata(url: string): Promise { + return legacyFetch(url, { + query: { + f: 'json' + }, + handleAs: 'json' + }); + } + + getGeometryForSumObject(results: FeatureAttributes[], store: ChartStore): Promise { + const query: Record = {}; + const ids = results.map((result) => result[store.idProperty]); + query[store.idProperty] = { $in: ids }; + return store.query(query, { + fields: { + geometry: 1 + } + }); + } +} diff --git a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.js b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.js deleted file mode 100644 index c74f063..0000000 --- a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.js +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import ct_when from "apprt-core/when"; -import ServiceResolver from "apprt/ServiceResolver"; - -export default class ResultCenterChartingToolHandler { - - activate(componentContext) { - const serviceResolver = this.serviceResolver = new ServiceResolver(); - const bundleCtx = componentContext.getBundleContext(); - serviceResolver.setBundleCtx(bundleCtx); - } - - drawResultCenterCharts() { - ct_when(this._queryData(), (result) => { - const datasource = this._dataModel.datasource; - const storeProperties = this._getStoreProperties(datasource.id); - const response = { - result: result, - total: result.length, - source: { - id: datasource.id, - title: storeProperties.title, - store: datasource - } - }; - const responses = [response]; - this._chartingDashboardWidgetModel.setCharts(responses); - }); - } - - _queryData() { - const model = this._chartingDashboardWidgetModel; - model.loading = true; - const dataModel = this._dataModel; - const selectedIds = dataModel.getSelected(); - if (selectedIds && selectedIds.length) { - return dataModel.queryById(selectedIds); - } else { - return dataModel.query({}); - } - } - - _getStore(id) { - return this.serviceResolver.getService("ct.api.Store", "(id=" + id + ")"); - } - - _getStoreProperties(idOrStore) { - var resolver = this.serviceResolver; - if (typeof (idOrStore) === "string") { - return resolver.getServiceProperties("ct.api.Store", "(id=" + idOrStore + ")"); - } - return resolver.getServiceProperties(idOrStore); - } - -} diff --git a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts new file mode 100644 index 0000000..63d742b --- /dev/null +++ b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts @@ -0,0 +1,83 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import ct_when from "apprt-core/when"; +import ServiceResolver from "apprt/ServiceResolver"; +import type { ComponentContext, ServiceInstance, ServiceProperties } from "apprt/api"; +import type { ChartingDashboardWidgetModel } from "./ChartingDashboardWidgetModel"; +import type { ChartResultSet, ChartStore } from "./api"; + +interface ResultCenterDataModel { + datasource: ChartStore & { id: string; title?: string }; + getSelected(): any[] | undefined; + queryById(ids: any[]): Promise; + query(query: any): Promise; +} + +export default class ResultCenterChartingToolHandler { + + declare private _dataModel: ResultCenterDataModel; + declare private _chartingDashboardWidgetModel: ChartingDashboardWidgetModel | undefined; + + private serviceResolver!: ServiceResolver; + + activate(componentContext: ComponentContext): void { + const bundleCtx = componentContext.getBundleContext(); + this.serviceResolver = new ServiceResolver({ bundleCtx }); + } + + drawResultCenterCharts(): void { + ct_when(this._queryData(), (result: any[]) => { + const datasource = this._dataModel.datasource; + const storeProperties = this._getStoreProperties(datasource.id); + const resultSet: ChartResultSet = { + storeId: datasource.id, + title: storeProperties!.title as string, + store: datasource, + result: result, + total: result.length + }; + this._chartingDashboardWidgetModel?.setCharts([resultSet]); + }); + } + + private _queryData(): Promise { + const model = this._chartingDashboardWidgetModel; + if (model) { + model.loading = true; + } + const dataModel = this._dataModel; + const selectedIds = dataModel.getSelected(); + if (selectedIds && selectedIds.length) { + return dataModel.queryById(selectedIds); + } else { + return dataModel.query({}); + } + } + + private _getStore(id: string): ServiceInstance | undefined { + return this.serviceResolver.getService("ct.api.Store", "(id=" + id + ")"); + } + + private _getStoreProperties(idOrStore: string | ServiceInstance): ServiceProperties | undefined { + const resolver = this.serviceResolver; + if (typeof (idOrStore) === "string") { + return resolver.getServiceProperties("ct.api.Store", "(id=" + idOrStore + ")"); + } + return resolver.getServiceProperties(idOrStore); + } + +} diff --git a/src/main/js/bundles/dn_charting/api.ts b/src/main/js/bundles/dn_charting/api.ts new file mode 100644 index 0000000..f0f2cbb --- /dev/null +++ b/src/main/js/bundles/dn_charting/api.ts @@ -0,0 +1,182 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import type Geometry from "@arcgis/core/geometry/Geometry"; +import type { SelectionResult } from "selection-services/api"; + +/** Arbitrary feature attributes keyed by field name. */ +export type FeatureAttributes = Record; + +/** A single related-data record (e.g. a time series entry). */ +export interface RelatedDataEntry { + time: number | Date; + attributes: FeatureAttributes; +} + +/** Feature attributes that may carry related (time series) data. */ +export interface ChartAttributes extends FeatureAttributes { + relatedData?: RelatedDataEntry[]; +} + +/** A single data point definition in the legacy chart configuration. */ +export interface ChartDataDefinition { + title: string; + attribute: string; +} + +/** A data series definition for multi-series charts. */ +export interface ChartDataSeries { + title: string; + /** Single attribute, used for related-data driven series. */ + attribute?: string; + /** Multiple attributes, used for non related-data series. */ + attributes?: string[]; + color?: string; +} + +/** Date period filter applied to a date based x-axis. */ +export interface AxisDatePeriodFilter { + start: number | Date; + end: number | Date; +} + +/** Configuration of a single chart. */ +export interface ChartProperties { + type?: string; + groups?: string[][]; + showDataLabels?: boolean; + width?: number; + height?: number; + padding?: Record; + rotatedAxis?: boolean; + axisType?: string; + axisFormat?: string; + axisParserFormat?: string; + axisTickAdjusted?: boolean; + axisTickCount?: number; + axisIsDateObject?: boolean; + axisDatePeriodFilter?: AxisDatePeriodFilter; + dataOrientation?: "rows" | "columns"; + hideDecimalValues?: boolean; + colorPattern?: string[]; + title?: string; + /** Legacy single-object data definition. */ + data?: ChartDataDefinition[]; + /** Multi-series data definition. */ + dataSeries?: ChartDataSeries[]; + headers?: string[]; + relatedData?: boolean; + calculationType?: "mean" | "sum" | string; + expanded?: boolean; + storeId?: string; +} + +/** Chart data in c3 form: an array of rows or columns. */ +export type ChartData = Array>; + +/** Reference to an attribute used as charts title for a specific store. */ +export interface ChartsTitleReference { + storeId: string; + titleAttribute: string; +} + +/** A charts title is either a fixed attribute name or a store reference. */ +export type ChartsTitle = string | ChartsTitleReference; + +/** A configured charts tab (new configuration style). */ +export interface ChartsTab { + title: string; + chartsTitle: ChartsTitle; + charts: ChartProperties[]; +} + +/** Per-store charts configuration (legacy configuration style). */ +export interface StoreChartsProperties { + storeId: string; + titleAttribute: string; + charts: ChartProperties[]; +} + +/** Relationship metadata used to query related data for a store. */ +export interface Relationship { + id?: number | string; + relatedTableId?: number | string; + storeId: string; + tableUrl: string; + foreignKey: string; + primaryKey: string; + timeAttribute: string; +} + +/** Minimal store contract used by this bundle. */ +export interface ChartStore { + idProperty: string; + masterStore?: ChartStore; + query(query?: any, options?: any): Promise; +} + +export interface ChartResultSet { + /** Id of the source store; matches relationships and per-store chart configuration. */ + storeId: string; + /** Human readable source title (used as the tab title in the legacy configuration). */ + title: string; + /** The queryable store the results originate from. */ + store: ChartStore; + /** The result records. Replaced with full attribute records by `getAllAttributes`. */ + result: any[]; + /** Total number of available results. */ + total: number; +} + +/** Aggregated object computed for a store from its query results. */ +export interface SumObject { + object: (FeatureAttributes & { relatedData?: RelatedDataEntry[] }) | null; + count: number; + storeId: string; + geometries: Geometry[]; +} + +/** DOM node hosting a single chart, augmented with its title text. */ +export interface ChartNode extends HTMLElement { + titleText?: string; +} + +/** A single tab in the dashboard widget. */ +export interface Tab { + id: number; + tabTitle: string; + chartsTitle: string; + chartNodes: ChartNode[]; + geometries: Geometry[]; +} + +/** Configured component properties of the dashboard widget model. */ +export interface ChartingComponentProperties { + drawTabGeometries: boolean; + drawChartsForSelectionResults: boolean; + relationships: Relationship[]; + chartsProperties: StoreChartsProperties[]; + chartsTabs: ChartsTab[]; +} + +/** + * apprt event envelope delivered on the `selection/EXECUTING` topic. + * It wraps a {@link SelectionResult} (from `selection-services`); e.g. + * `getProperty("executions")` yields the store-api `QueryExecutions` of the running selection. + */ +export interface SelectionExecutingEvent { + getProperty(name: K): SelectionResult[K]; +} diff --git a/src/main/js/bundles/dn_charting/main.js b/src/main/js/bundles/dn_charting/main.js deleted file mode 100644 index 35544d1..0000000 --- a/src/main/js/bundles/dn_charting/main.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import "dojo/i18n!./nls/bundle"; diff --git a/src/main/js/bundles/dn_charting/manifest.json b/src/main/js/bundles/dn_charting/manifest.json index d86e4e5..d31449e 100644 --- a/src/main/js/bundles/dn_charting/manifest.json +++ b/src/main/js/bundles/dn_charting/manifest.json @@ -5,6 +5,9 @@ "description": "${bundleDescription}", "vendor": "con terra GmbH", "productName": "devnet-mapapps-charting", + "i18n": [ + "bundle" + ], "dependencies": { "dn_charting-c3": "^0.7.8", "@arcgis/core": "^4.33.0", diff --git a/src/main/js/bundles/dn_charting/module.js b/src/main/js/bundles/dn_charting/module.js deleted file mode 100644 index a7e84ae..0000000 --- a/src/main/js/bundles/dn_charting/module.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import "."; -import "./C3ChartsFactory"; -import "./C3ChartsDataProvider"; -import "./ChartingDashboardWidgetFactory"; -import "./ChartingDashboardWidgetModel"; -import "./ResultCenterChartingToolHandler"; -import "./QueryController"; diff --git a/src/main/js/bundles/dn_charting/module.ts b/src/main/js/bundles/dn_charting/module.ts new file mode 100644 index 0000000..63b12bf --- /dev/null +++ b/src/main/js/bundles/dn_charting/module.ts @@ -0,0 +1,22 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import "./C3ChartsFactory"; +import "./C3ChartsDataProvider"; +import "./ChartingDashboardWidgetFactory"; +import "./ChartingDashboardWidgetModel"; +import "./ResultCenterChartingToolHandler"; +import "./QueryController"; diff --git a/src/main/js/bundles/dn_charting/nls/bundle.js b/src/main/js/bundles/dn_charting/nls/bundle.js deleted file mode 100644 index 05026e7..0000000 --- a/src/main/js/bundles/dn_charting/nls/bundle.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -module.exports = { - root: ({ - bundleName: "Charts", - bundleDescription: "Charts", - ui: { - statistics: "Statistics for ", - warning: "No feature found", - multipleObjects: "multiple objects" - }, - tool: { - title: "Statistics", - tooltip: "Statistics" - } - }), - "de": true -}; diff --git a/src/main/js/bundles/dn_charting/nls/bundle.ts b/src/main/js/bundles/dn_charting/nls/bundle.ts new file mode 100644 index 0000000..ec44e4f --- /dev/null +++ b/src/main/js/bundles/dn_charting/nls/bundle.ts @@ -0,0 +1,35 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +const i18n = { + root: ({ + bundleName: "Charts", + bundleDescription: "Charts", + ui: { + statistics: "Statistics for ", + warning: "No feature found", + multipleObjects: "multiple objects" + }, + tool: { + title: "Statistics", + tooltip: "Statistics" + } + }), + "de": true +}; + +export type Messages = (typeof i18n)["root"]; +export default i18n; diff --git a/src/main/js/bundles/dn_charting/nls/de/bundle.js b/src/main/js/bundles/dn_charting/nls/de/bundle.js deleted file mode 100644 index 2608597..0000000 --- a/src/main/js/bundles/dn_charting/nls/de/bundle.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2025 con terra GmbH (info@conterra.de) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -module.exports = { - bundleName: "Charts", - bundleDescription: "Charts", - ui: { - statistics: "Statistiken für ", - warning: "Es konnte kein Feature gefunden werden", - multipleObjects: "mehrere Objekte" - }, - tool: { - title: "Statistiken", - tooltip: "Statistiken" - } -}; diff --git a/src/main/js/bundles/dn_charting/nls/de/bundle.ts b/src/main/js/bundles/dn_charting/nls/de/bundle.ts new file mode 100644 index 0000000..791fcc7 --- /dev/null +++ b/src/main/js/bundles/dn_charting/nls/de/bundle.ts @@ -0,0 +1,33 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { type Messages } from "../bundle"; + +const i18n = { + bundleName: "Charts", + bundleDescription: "Charts", + ui: { + statistics: "Statistiken für ", + warning: "Es konnte kein Feature gefunden werden", + multipleObjects: "mehrere Objekte" + }, + tool: { + title: "Statistiken", + tooltip: "Statistiken" + } +} satisfies Messages; + +export default i18n; From f19035af1ecb53c28c497f73c19237c7c3af5285 Mon Sep 17 00:00:00 2001 From: Jochen Jacobs Date: Mon, 15 Jun 2026 14:33:39 +0200 Subject: [PATCH 2/5] Split state model from controller Separate the reactive Mutable state model from a behavior controller, mark private fields and methods, type selection events, convert to async/await, and replace ct_lang helpers with plain JS. --- src/main/js/apps/sample/app.json | 3 +- .../dn_charting/C3ChartsDataProvider.ts | 8 +- .../js/bundles/dn_charting/C3ChartsFactory.ts | 16 +- .../ChartingDashboardController.ts | 408 ++++++++++++++++ .../ChartingDashboardWidgetFactory.ts | 24 +- .../ChartingDashboardWidgetModel.ts | 461 +----------------- .../ResultCenterChartingToolHandler.ts | 28 +- src/main/js/bundles/dn_charting/api.ts | 43 +- src/main/js/bundles/dn_charting/manifest.json | 40 +- src/main/js/bundles/dn_charting/module.ts | 1 + 10 files changed, 511 insertions(+), 521 deletions(-) create mode 100644 src/main/js/bundles/dn_charting/ChartingDashboardController.ts diff --git a/src/main/js/apps/sample/app.json b/src/main/js/apps/sample/app.json index b376c4a..f9138bf 100644 --- a/src/main/js/apps/sample/app.json +++ b/src/main/js/apps/sample/app.json @@ -36,7 +36,8 @@ "toolset", "dn_charting", "dn_imprintprivacy", - "dn_querybuilder" + "dn_querybuilder", + "console" ], "styles": [ "${app}:app.css" diff --git a/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts b/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts index a400611..4aebc95 100644 --- a/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts +++ b/src/main/js/bundles/dn_charting/C3ChartsDataProvider.ts @@ -23,14 +23,14 @@ export default class C3ChartsDataProvider { const res: ChartData = [["x"]]; if (props.dataSeries) { - this._getDataSeriesChartData(props, attributes, res); + this.getDataSeriesChartData(props, attributes, res); } else { - this._getDefaultChartData(props, attributes, res); + this.getDefaultChartData(props, attributes, res); } return res; } - private _getDefaultChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { + private getDefaultChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { if (props.relatedData && props.headers && props.headers.length === 1) { const array: Array = [d_string.substitute(props.title, attributes) || ""]; const relatedData = attributes.relatedData ?? []; @@ -58,7 +58,7 @@ export default class C3ChartsDataProvider { } } - private _getDataSeriesChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { + private getDataSeriesChartData(props: ChartProperties, attributes: ChartAttributes, res: ChartData): void { if (props.headers) { props.headers.forEach((header) => { res[0].push(d_string.substitute(header, attributes) || ""); diff --git a/src/main/js/bundles/dn_charting/C3ChartsFactory.ts b/src/main/js/bundles/dn_charting/C3ChartsFactory.ts index a97160e..bb9310b 100644 --- a/src/main/js/bundles/dn_charting/C3ChartsFactory.ts +++ b/src/main/js/bundles/dn_charting/C3ChartsFactory.ts @@ -20,7 +20,7 @@ import type C3ChartsDataProvider from "./C3ChartsDataProvider"; export default class C3ChartsFactory { - declare private _c3ChartsDataProvider: C3ChartsDataProvider; + declare private c3ChartsDataProvider: C3ChartsDataProvider; createChart( chartNode: HTMLElement, @@ -41,12 +41,12 @@ export default class C3ChartsFactory { chart: ChartAPI | null ): ChartAPI | void { return chart - ? this._updateChart(chartProperties, attributes, chart) - : this._createChart(chartProperties, attributes, chartNode); + ? this.updateChart(chartProperties, attributes, chart) + : this.generateChart(chartProperties, attributes, chartNode); } - private _createChart(chartProperties: ChartProperties, attributes: ChartAttributes, node: HTMLElement): ChartAPI { - const data = this._c3ChartsDataProvider.getChartData(chartProperties, attributes); + private generateChart(chartProperties: ChartProperties, attributes: ChartAttributes, node: HTMLElement): ChartAPI { + const data = this.c3ChartsDataProvider.getChartData(chartProperties, attributes); const props: ChartConfiguration = { bindto: node, padding: chartProperties.padding || { @@ -103,7 +103,7 @@ export default class C3ChartsFactory { pattern: chartProperties.colorPattern }; } - const colors = this._c3ChartsDataProvider.getDataColors(chartProperties); + const colors = this.c3ChartsDataProvider.getDataColors(chartProperties); if (colors) { props.data.colors = colors; } @@ -113,8 +113,8 @@ export default class C3ChartsFactory { return c3.generate(props); } - private _updateChart(chartProperties: ChartProperties, attributes: ChartAttributes, chart: ChartAPI): void { - const data = this._c3ChartsDataProvider.getChartData(chartProperties, attributes); + private updateChart(chartProperties: ChartProperties, attributes: ChartAttributes, chart: ChartAPI): void { + const data = this.c3ChartsDataProvider.getChartData(chartProperties, attributes); switch (chartProperties.dataOrientation) { case "columns": diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardController.ts b/src/main/js/bundles/dn_charting/ChartingDashboardController.ts new file mode 100644 index 0000000..30197a0 --- /dev/null +++ b/src/main/js/bundles/dn_charting/ChartingDashboardController.ts @@ -0,0 +1,408 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import domConstruct from "dojo/dom-construct"; +import * as geometryEngine from "@arcgis/core/geometry/geometryEngine"; +import type { InjectedReference } from "apprt-core/InjectedReference"; +import type Geometry from "@arcgis/core/geometry/Geometry"; +import type { Highlight, Highlighter } from "highlights/api"; +import type Tool from "ct/tools/Tool"; +import type { I18N, TopicEvent } from "apprt/api"; +import type { SearchSource, SelectionResult } from "selection-services/api"; +import type { ChartAPI } from "dn_charting-c3"; +import type { Messages } from "./nls/bundle"; +import type C3ChartsFactory from "./C3ChartsFactory"; +import type QueryController from "./QueryController"; +import type { + ChartAttributes, + ChartingDashboardWidgetModel, + ChartProperties, + ChartResultSet, + ChartsTitle, + ChartNode, + ChartStore, + RelatedDataEntry, + StoreChartsProperties, + SumObject, + Tab +} from "./api"; + +const currentHighlight = Symbol("currentHighlight"); + +/** + * Orchestrates the charting dashboard: handles selection events, queries attributes and related + * data, aggregates results, draws c3 charts into the tabs of the {@link ChartingDashboardWidgetModel}, + * and highlights the corresponding geometries on the map. + * + * The configured properties (`relationships`, `chartsProperties`, `chartsTabs`, …) live on the + * injected model — the model is the bundle's public configuration contract (component key + * `ChartingDashboardWidgetModel`) — so this controller reads them via `this.model`. + */ +export default class ChartingDashboardController { + + private model: InjectedReference; + private tool: InjectedReference; + private c3ChartsFactory: InjectedReference; + private queryController: InjectedReference; + private highlighter: InjectedReference; + private _i18n: InjectedReference>; + + /* The c3 chart instances currently displayed */ + private charts: ChartAPI[] = []; + private [currentHighlight]?: Highlight; + + activate(): void { + this.tool!.watch("active", (name, oldValue, newValue) => { + if (!newValue) { + this.clearHighlight(); + } + }); + } + + async receiveSelections(event: TopicEvent): Promise { + if (this.model!.drawChartsForSelectionResults) { + const model = this.model!; + if (model.loading) { + return; + } + model.loading = true; + // The `selection/EXECUTING` topic carries a selection-services SelectionResult. + const queryExecutions = event.getProperty("executions")!; + const executions = await queryExecutions.waitForExecution(); + const resultSets: ChartResultSet[] = []; + executions.executions.forEach((execution) => { + const result = execution.result; + if (!result || !result.length) { + return; + } + // A selection execution's source is a selection-services SearchSource (the public + // store-api typing only exposes the minimal DataSource view). + const source = execution.source as unknown as SearchSource; + resultSets.push({ + storeId: source.id, + title: source.title, + store: source.store as unknown as ChartStore, + result: result as unknown[], + total: execution.total ?? result.length + }); + }); + this.setCharts(resultSets); + } + } + + async setCharts(resultSets: ChartResultSet[]): Promise { + this.model!.loading = true; + const attributes = await this.getAllAttributes(resultSets); + await this.handleChartResponses(attributes); + } + + resizeCharts(width: number): void { + if (width >= 40) { + width -= 40; + } + this.charts.forEach((chart) => { + chart.resize({ width: width }); + }); + } + + drawGraphicsForActiveTab(activeTab: number): void { + const tab = this.model!.tabs[activeTab]; + const geometries = tab && tab.geometries; + if (geometries) { + this.highlightGeometries(geometries); + } + } + + private async getAllAttributes(resultSets: ChartResultSet[]): Promise { + const promises = resultSets.map(async (resultSet) => { + let store = resultSet.store; + if (store.masterStore) { + store = store.masterStore; + } + const idProperty = store.idProperty; + const ids = resultSet.result.map((result) => result[idProperty]); + const query: Record = {}; + query[idProperty] = { $in: ids }; + const results = await store.query(query); + resultSet.result = results; + return resultSet; + }); + return Promise.all(promises); + } + + private async handleChartResponses(resultSets: ChartResultSet[]): Promise { + const model = this.model!; + model.tabs = []; + this.charts = []; + this.tool!.set("active", true); + + if (model.chartsTabs) { + await this.newChartsConfiguration(resultSets); + } + if (model.chartsProperties) { + await this.oldChartsConfiguration(resultSets); + } + model.activeTab = 0; + this.drawGraphicsForActiveTab(0); + } + + private getSumObjects(resultSets: ChartResultSet[]): Promise { + return Promise.all(resultSets.map((resultSet) => this.getSumObject(resultSet))); + } + + private async getSumObject(resultSet: ChartResultSet): Promise { + const storeId = resultSet.storeId; + const relationShip = this.model!.relationships.find((relation) => relation.storeId === storeId); + const relatedResults = await this.queryController!.getRelatedData(resultSet.result, relationShip); + + let sumObject: (Record & { relatedData?: RelatedDataEntry[] }) | null = null; + relatedResults.forEach((result) => { + if (!sumObject) { + sumObject = {}; + } + const current = sumObject; + for (const [name, value] of Object.entries(result)) { + if (name === "relatedData") { + if (!current.relatedData) { + current.relatedData = value; + continue; + } + current.relatedData.forEach((data) => { + const newData = value.find((d: RelatedDataEntry) => d.time === data.time); + if (!newData) { + return; + } + for (const [name2, value2] of Object.entries(newData.attributes)) { + if (data.attributes[name2]) { + if (typeof value2 === "number") { + data.attributes[name2] += value2; + } + } else { + data.attributes[name2] = value2; + } + } + }); + } else { + if (current[name]) { + if (typeof value === "number") { + current[name] += value; + } + } else { + current[name] = value; + } + } + } + }); + + if (!this.model!.drawTabGeometries) { + return { object: sumObject, count: relatedResults.length, storeId: storeId, geometries: [] }; + } + + const geometryResults: any[] = + await this.queryController!.getGeometryForSumObject(relatedResults, resultSet.store); + const geometries: Geometry[] = []; + geometryResults.forEach((result) => { + if (result.geometry) { + const geometryAlreadyContained = this.isGeometryAlreadyContained(result.geometry, geometries); + !geometryAlreadyContained && geometries.push(result.geometry); + } + }); + return { object: sumObject, count: geometryResults.length, storeId: storeId, geometries: geometries }; + } + + private async newChartsConfiguration(resultSets: ChartResultSet[]): Promise { + const model = this.model!; + const chartsTabs = model.chartsTabs; + + try { + const sumObjects = await this.getSumObjects(resultSets); + chartsTabs.forEach((chartsTab) => { + const chartNodes: ChartNode[] = []; + const tab: Tab = { + id: model.tabs.length, + tabTitle: chartsTab.title, + chartsTitle: this.getChartsTitle(chartsTab.chartsTitle, sumObjects), + chartNodes: chartNodes, + geometries: [] + }; + this.drawCharts(sumObjects, chartsTab.charts, tab); + if (chartNodes.length) { + model.tabs.push(tab); + } + }); + model.loading = false; + } catch (error) { + console.error(error); + model.loading = false; + } + } + + private async oldChartsConfiguration(resultSets: ChartResultSet[]): Promise { + const model = this.model!; + + try { + const sumObjects = await this.getSumObjects(resultSets); + resultSets.forEach((resultSet) => { + const tabTitle = resultSet.title; + const storeId = resultSet.storeId; + const chartsProperties = this.getChartsProperties(storeId); + if (!chartsProperties) { + return; + } + const chartNodes: ChartNode[] = []; + const tab: Tab = { + id: model.tabs.length, + tabTitle: tabTitle, + chartsTitle: this.getChartsTitle(chartsProperties.titleAttribute, resultSet), + chartNodes: chartNodes, + geometries: [] + }; + model.tabs.push(tab); + this.drawCharts(sumObjects, chartsProperties.charts, tab, storeId); + }); + model.loading = false; + } catch (error) { + console.error(error); + model.loading = false; + } + } + + private getChartsTitle(properties: ChartsTitle, objects: SumObject[] | ChartResultSet): string { + if (properties && typeof properties === "object" && properties.constructor === Object) { + const sumObjects = objects as SumObject[]; + const sumObject = sumObjects.find((object) => object.storeId === properties.storeId); + let title = ""; + if (!sumObject) { + return title; + } + const count = sumObject.count; + if (count === 1) { + title = sumObject.object?.[properties.titleAttribute] ?? ""; + if (!title && sumObject.object?.relatedData?.length) { + title = sumObject.object.relatedData[0].attributes[properties.titleAttribute]; + } + } else { + title = this._i18n!.get().ui.multipleObjects; + } + return title; + } else { + const resultSet = objects as ChartResultSet; + const total = resultSet.total; + return total === 1 ? resultSet.result[0][properties as string] : this._i18n!.get().ui.multipleObjects; + } + } + + private getChartsProperties(storeId: string): StoreChartsProperties | undefined { + const chartsProperties = this.model!.chartsProperties; + return chartsProperties.find((properties) => properties.storeId === storeId); + } + + drawCharts(sumObjects: SumObject[], chartsProperties: ChartProperties[], tab: Tab, storeId?: string): void { + const model = this.model!; + const factory = this.c3ChartsFactory!; + chartsProperties.forEach((chartProperties) => { + const attributes: ChartAttributes = {}; + const sumObject = sumObjects.find((sumObject) => { + if (chartProperties.storeId) { + return sumObject.storeId === chartProperties.storeId; + } else if (storeId) { + return sumObject.storeId === storeId; + } + return false; + }); + if (!sumObject) { + return; + } + sumObject.geometries.forEach((geometry) => { + const geometryAlreadyContained = this.isGeometryAlreadyContained(geometry, tab.geometries); + !geometryAlreadyContained && tab.geometries.push(geometry); + }); + const object = sumObject.object; + if (chartProperties.calculationType === "mean") { + if (object) { + for (const [name, value] of Object.entries(object)) { + if (typeof value === "number") { + attributes[name] = Math.round(value / sumObject.count * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + } + } + object?.relatedData?.forEach((data) => { + for (const [name, value] of Object.entries(data.attributes)) { + if (typeof value === "number") { + attributes[name] = Math.round(value / sumObject.count * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + } + }); + } else { + if (object) { + for (const [name, value] of Object.entries(object)) { + if (typeof value === "number") { + attributes[name] = Math.round(value * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + } + } + object?.relatedData?.forEach((data) => { + for (const [name, value] of Object.entries(data.attributes)) { + if (typeof value === "number") { + attributes[name] = Math.round(value * 100) / 100; + } else if (!attributes[name]) { + attributes[name] = value; + } + } + }); + } + const chartNode = domConstruct.create("div") as ChartNode; + const chart = factory.createChart(chartNode, chartProperties, attributes, null); + this.charts.push(chart); + chartNode.titleText = chartProperties.title; + const expanded = chartProperties.expanded; + model.expandedCharts.push(expanded); + tab.chartNodes.push(chartNode); + }); + } + + private highlightGeometries(geometries: Geometry[]): void { + this.clearHighlight(); + const highlightObjects = geometries.map((geometry) => { + return { + geometry: geometry + }; + }); + // Geometry is the abstract base type; the highlighter expects a concrete esri geometry union. + this[currentHighlight] = this.highlighter!.highlight(highlightObjects as any); + } + + private clearHighlight(): void { + const highlight = this[currentHighlight]; + if (highlight) { + highlight.remove(); + this[currentHighlight] = undefined; + } + } + + private isGeometryAlreadyContained(geometry: Geometry, geometries: Geometry[]): Geometry | undefined { + return geometries.find((g) => { + const distance = geometryEngine.distance(g.extent!.center, geometry.extent!.center, "meters"); + return distance === 0; + }); + } +} diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts index 12dc614..f8c0bc1 100644 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts @@ -21,9 +21,9 @@ import Binding, { type Bindable } from "apprt-binding/Binding"; import d_aspect from "dojo/aspect"; import ct_util from "ct/ui/desktop/util"; import type { I18N } from "apprt/api"; -import type { ChartingDashboardWidgetModel } from "./ChartingDashboardWidgetModel"; +import type ChartingDashboardController from "./ChartingDashboardController"; import type { Messages } from "./nls/bundle"; -import type { Tab } from "./api"; +import type { ChartingDashboardWidgetModel, Tab } from "./api"; /** The Vue view model bound to the dashboard widget model. */ type ChartingDashboardVm = Vue & { @@ -36,7 +36,8 @@ type ChartingDashboardVm = Vue & { export default class ChartingDashboardWidgetFactory { - declare private _chartingDashboardWidgetModel: ChartingDashboardWidgetModel; + declare private chartingDashboardWidgetModel: ChartingDashboardWidgetModel; + declare private controller: ChartingDashboardController; declare private _i18n: I18N; private vm!: ChartingDashboardVm; @@ -44,11 +45,12 @@ export default class ChartingDashboardWidgetFactory { private widget!: any; activate(): void { - this._initComponent(); + this.initComponent(); } - private _initComponent(): void { - const model = this._chartingDashboardWidgetModel; + private initComponent(): void { + const model = this.chartingDashboardWidgetModel; + const controller = this.controller; const vm = this.vm = new Vue(ChartingDashboardWidget as any) as ChartingDashboardVm; vm.i18n = this.i18n = this._i18n.get().ui; @@ -66,23 +68,23 @@ export default class ChartingDashboardWidgetFactory { if (enclosingWidget) { d_aspect.before(enclosingWidget, "resize", (dims: { w: number } | undefined) => { if (dims) { - model.resizeCharts(dims.w); + controller.resizeCharts(dims.w); } }); } }); vm.$on('activeTabChanged', (activeTab: number) => { - model.drawGraphicsForActiveTab(activeTab); + controller.drawGraphicsForActiveTab(activeTab); }); - d_aspect.after(model, "_drawCharts", () => { + d_aspect.after(controller, "drawCharts", () => { this.resizeCharts(); }); } resizeCharts(): void { - const model = this._chartingDashboardWidgetModel; + const controller = this.controller; let width: number; const rect = this.vm.$el && this.vm.$el.getBoundingClientRect(); if (rect) { @@ -90,7 +92,7 @@ export default class ChartingDashboardWidgetFactory { } else { width = 500; } - model.resizeCharts(width); + controller.resizeCharts(width); } createInstance(): any { diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts index c8618bf..0696f15 100644 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetModel.ts @@ -15,457 +15,18 @@ /// import { declare } from "apprt-core/Mutable"; -import domConstruct from "dojo/dom-construct"; -import ct_lang from "ct/_lang"; -import apprt_when from "apprt-core/when"; -import * as geometryEngine from "@arcgis/core/geometry/geometryEngine"; -import type Geometry from "@arcgis/core/geometry/Geometry"; -import type { Highlight, Highlighter } from "highlights/api"; -import type Tool from "ct/tools/Tool"; -import type { I18N } from "apprt/api"; -import type { SearchSource } from "selection-services/api"; -import type { ChartAPI } from "dn_charting-c3"; -import type { Messages } from "./nls/bundle"; -import type C3ChartsFactory from "./C3ChartsFactory"; -import type QueryController from "./QueryController"; -import type { - ChartAttributes, - ChartingComponentProperties, - ChartProperties, - ChartResultSet, - ChartsTab, - ChartsTitle, - ChartNode, - ChartStore, - RelatedDataEntry, - SelectionExecutingEvent, - StoreChartsProperties, - SumObject, - Tab -} from "./api"; - -const _currentHighlight = Symbol("_currentHighlight"); - -/** - * Public contract of the charting dashboard widget model. - * It exposes the mutable view state, the injected references and all behaviour methods. - */ -export interface ChartingDashboardWidgetModel { - // mutable view state - loading: boolean; - tabTitle: string; - activeTab: number; - chartsTitle: string; - tabs: Tab[]; - expandedCharts: Array; - _charts: ChartAPI[]; - _geometries: Geometry[]; - - // configured component properties (propertiesConstructor) - drawTabGeometries: boolean; - drawChartsForSelectionResults: boolean; - chartsTabs: ChartsTab[]; - chartsProperties: StoreChartsProperties[]; - - // injected references - _properties: ChartingComponentProperties; - _tool: Tool; - _c3ChartsFactory: C3ChartsFactory; - _queryController: QueryController; - _highlighter: Highlighter; - _i18n: I18N; - _mapWidgetModel?: unknown; - - // internal state - [_currentHighlight]?: Highlight; - - activate(): void; - receiveSelections(event: SelectionExecutingEvent): void; - setCharts(resultSets: ChartResultSet[]): void; - resizeCharts(width: number): void; - drawGraphicsForActiveTab(activeTab: number): void; - getAllAttributes(resultSets: ChartResultSet[]): Promise; - handleChartResponses(resultSets: ChartResultSet[]): void; - _getSumObjects(resultSets: ChartResultSet[]): Array>; - _newChartsConfiguration(resultSets: ChartResultSet[]): Promise; - _oldChartsConfiguration(resultSets: ChartResultSet[]): Promise; - _getChartsTitle(properties: ChartsTitle, objects: SumObject[] | ChartResultSet): string; - _getChartsProperties(storeId: string): StoreChartsProperties | undefined; - _drawCharts(sumObjects: SumObject[], chartsProperties: ChartProperties[], tab: Tab, storeId?: string): void; - _highlightGeometries(geometries: Geometry[]): void; - _clearHighlight(): void; - _isGeometryAlreadyContained(geometry: Geometry, geometries: Geometry[]): Geometry | undefined; -} +import type { ChartsTab, Relationship, StoreChartsProperties, Tab } from "./api"; export default declare({ - + // reactive view state loading: false, - tabTitle: "", activeTab: 0, - chartsTitle: "", - tabs: [], - expandedCharts: [], - _charts: [], - _geometries: [], - - activate(this: ChartingDashboardWidgetModel): void { - this._tool.watch("active", (name, oldValue, newValue) => { - if (!newValue) { - this._clearHighlight(); - } - }); - }, - - receiveSelections(this: ChartingDashboardWidgetModel, event: SelectionExecutingEvent): void { - if (this.drawChartsForSelectionResults) { - if (this.loading) { - return; - } - this.loading = true; - const queryExecutions = event.getProperty("executions"); - queryExecutions.waitForExecution().then((executions) => { - const resultSets: ChartResultSet[] = []; - executions.executions.forEach((execution) => { - const result = execution.result; - if (!result || !result.length) { - return; - } - // A selection execution's source is a selection-services SearchSource (the public - // store-api typing only exposes the minimal DataSource view). - const source = execution.source as unknown as SearchSource; - resultSets.push({ - storeId: source.id, - title: source.title, - store: source.store as unknown as ChartStore, - result: result as unknown[], - total: execution.total ?? result.length - }); - }); - this.setCharts(resultSets); - }); - } - }, - - setCharts(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): void { - this.getAllAttributes(resultSets).then((res) => { - this.handleChartResponses(res); - }); - }, - - resizeCharts(this: ChartingDashboardWidgetModel, width: number): void { - if (width >= 40) { - width -= 40; - } - this._charts.forEach((chart) => { - chart.resize({ width: width }); - }); - }, - - drawGraphicsForActiveTab(this: ChartingDashboardWidgetModel, activeTab: number): void { - const tab = this.tabs[activeTab]; - const geometries = tab && tab.geometries; - if (geometries) { - this._highlightGeometries(geometries); - } - }, - - getAllAttributes(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { - const promises = resultSets.map((resultSet) => { - let store = resultSet.store; - if (store.masterStore) { - store = store.masterStore; - } - const idProperty = store.idProperty; - const ids = resultSet.result.map((result) => result[idProperty]); - const query: Record = {}; - query[idProperty] = { $in: ids }; - return store.query(query).then((results) => { - resultSet.result = results; - return resultSet; - }); - }); - return Promise.all(promises).then((res) => res); - }, - - handleChartResponses(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): void { - this.tabs = []; - this._charts = []; - this._geometries = []; - this._tool.set("active", true); - let newPromise: Promise | undefined; - let oldPromise: Promise | undefined; - if (this.chartsTabs) { - newPromise = this._newChartsConfiguration(resultSets); - } - if (this.chartsProperties) { - oldPromise = this._oldChartsConfiguration(resultSets); - } - Promise.all([newPromise, oldPromise]).then(() => { - this.activeTab = 0; - this.drawGraphicsForActiveTab(0); - }); - }, - - _getSumObjects(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Array> { - return resultSets.map((resultSet) => new Promise((resolve) => { - let sumObject: (Record & { relatedData?: RelatedDataEntry[] }) | null = null; - const results = resultSet.result; - const storeId = resultSet.storeId; - const relationShips = this._properties.relationships; - const relationShip = relationShips.find((relation) => relation.storeId === storeId); - this._queryController.getRelatedData(results, relationShip).then((relatedResults) => { - relatedResults.forEach((result) => { - if (!sumObject) { - sumObject = {}; - } - const current = sumObject; - ct_lang.forEachProp(result, (value: any, name: string) => { - if (name === "relatedData") { - if (!current.relatedData) { - current.relatedData = value; - return; - } - current.relatedData.forEach((data) => { - const newData = value.find((d: RelatedDataEntry) => d.time === data.time); - ct_lang.forEachProp(newData.attributes, (value2: any, name2: string) => { - if (data.attributes[name2]) { - if (typeof value2 === "number") { - data.attributes[name2] += value2; - } - } else { - data.attributes[name2] = value2; - } - }); - }); - } else { - if (current[name]) { - if (typeof value === "number") { - current[name] += value; - } - } else { - current[name] = value; - } - } - }); - - }); - - if (this.drawTabGeometries) { - // eslint-disable-next-line @stylistic/max-len - apprt_when(this._queryController.getGeometryForSumObject(relatedResults, resultSet.store), (geometryResults: any[]) => { - const geometries: Geometry[] = []; - geometryResults.forEach((result) => { - if (result.geometry) { - // eslint-disable-next-line @stylistic/max-len - const geometryAlreadyContained = this._isGeometryAlreadyContained(result.geometry, geometries); - !geometryAlreadyContained && geometries.push(result.geometry); - } - }); - resolve({ - object: sumObject, - count: geometryResults.length, - storeId: storeId, - geometries: geometries - }); - }); - } else { - resolve({ - object: sumObject, - count: relatedResults.length, - storeId: storeId, - geometries: [] - }); - } - }); - })); - }, - - _newChartsConfiguration(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { - const chartsTabs = this.chartsTabs; - const sumObjectsPromises = this._getSumObjects(resultSets); - - return Promise.all(sumObjectsPromises).then((sumObjects) => { - chartsTabs.forEach((chartsTab) => { - const chartNodes: ChartNode[] = []; - const tab: Tab = { - id: this.tabs.length, - tabTitle: chartsTab.title, - chartsTitle: this._getChartsTitle(chartsTab.chartsTitle, sumObjects), - chartNodes: chartNodes, - geometries: [] - }; - this._drawCharts(sumObjects, chartsTab.charts, tab); - if (chartNodes.length) { - this.tabs.push(tab); - } - }); - this.loading = false; - }, (error) => { - console.error(error); - this.loading = false; - }); - }, - - _oldChartsConfiguration(this: ChartingDashboardWidgetModel, resultSets: ChartResultSet[]): Promise { - const sumObjectsPromises = this._getSumObjects(resultSets); - - return Promise.all(sumObjectsPromises).then((sumObjects) => { - resultSets.forEach((resultSet) => { - const tabTitle = resultSet.title; - const storeId = resultSet.storeId; - const chartsProperties = this._getChartsProperties(storeId); - if (!chartsProperties) { - return; - } - const chartNodes: ChartNode[] = []; - const tab: Tab = { - id: this.tabs.length, - tabTitle: tabTitle, - chartsTitle: this._getChartsTitle(chartsProperties.titleAttribute, resultSet), - chartNodes: chartNodes, - geometries: [] - }; - this.tabs.push(tab); - this._drawCharts(sumObjects, chartsProperties.charts, tab, storeId); - }); - this.loading = false; - }, (error) => { - console.error(error); - this.loading = false; - }); - }, - - _getChartsTitle( - this: ChartingDashboardWidgetModel, - properties: ChartsTitle, - objects: SumObject[] | ChartResultSet - ): string { - if (properties && typeof properties === "object" && properties.constructor === Object) { - const sumObjects = objects as SumObject[]; - const sumObject = sumObjects.find((object) => object.storeId === properties.storeId); - let title = ""; - if (!sumObject) { - return title; - } - const count = sumObject.count; - if (count === 1) { - title = sumObject.object?.[properties.titleAttribute] ?? ""; - if (!title && sumObject.object?.relatedData?.length) { - title = sumObject.object.relatedData[0].attributes[properties.titleAttribute]; - } - } else { - title = this._i18n.get().ui.multipleObjects; - } - return title; - } else { - const resultSet = objects as ChartResultSet; - const total = resultSet.total; - return total === 1 ? resultSet.result[0][properties as string] : this._i18n.get().ui.multipleObjects; - } - }, - - _getChartsProperties(this: ChartingDashboardWidgetModel, storeId: string): StoreChartsProperties | undefined { - const chartsProperties = this._properties.chartsProperties; - return chartsProperties.find((properties) => properties.storeId === storeId); - }, - - _drawCharts( - this: ChartingDashboardWidgetModel, - sumObjects: SumObject[], - chartsProperties: ChartProperties[], - tab: Tab, - storeId?: string - ): void { - const factory = this._c3ChartsFactory; - chartsProperties.forEach((chartProperties) => { - const attributes: ChartAttributes = {}; - const sumObject = sumObjects.find((sumObject) => { - if (chartProperties.storeId) { - return sumObject.storeId === chartProperties.storeId; - } else if (storeId) { - return sumObject.storeId === storeId; - } - return false; - }); - if (!sumObject) { - return; - } - sumObject.geometries.forEach((geometry) => { - const geometryAlreadyContained = this._isGeometryAlreadyContained(geometry, tab.geometries); - !geometryAlreadyContained && tab.geometries.push(geometry); - }); - if (chartProperties.calculationType === "mean") { - ct_lang.forEachOwnProp(sumObject.object, (value: any, name: string) => { - if (typeof value === "number") { - attributes[name] = Math.round(value / sumObject.count * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - sumObject.object?.relatedData?.forEach((data) => { - ct_lang.forEachOwnProp(data.attributes, (value: any, name: string) => { - if (typeof value === "number") { - attributes[name] = Math.round(value / sumObject.count * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - }); - } else { - ct_lang.forEachOwnProp(sumObject.object, (value: any, name: string) => { - if (typeof value === "number") { - attributes[name] = Math.round(value * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - sumObject.object?.relatedData?.forEach((data) => { - ct_lang.forEachOwnProp(data.attributes, (value: any, name: string) => { - if (typeof value === "number") { - attributes[name] = Math.round(value * 100) / 100; - } else if (!attributes[name]) { - attributes[name] = value; - } - }); - }); - } - const chartNode = domConstruct.create("div") as ChartNode; - const chart = factory.createChart(chartNode, chartProperties, attributes, null); - this._charts.push(chart); - chartNode.titleText = chartProperties.title; - const expanded = chartProperties.expanded; - this.expandedCharts.push(expanded); - tab.chartNodes.push(chartNode); - }); - }, - - _highlightGeometries(this: ChartingDashboardWidgetModel, geometries: Geometry[]): void { - this._clearHighlight(); - const highlightObjects = geometries.map((geometry) => { - return { - geometry: geometry - }; - }); - // Geometry is the abstract base type; the highlighter expects a concrete esri geometry union. - this[_currentHighlight] = this._highlighter.highlight(highlightObjects as any); - }, - - _clearHighlight(this: ChartingDashboardWidgetModel): void { - const highlight = this[_currentHighlight]; - if (highlight) { - highlight.remove(); - this[_currentHighlight] = undefined; - } - }, - - _isGeometryAlreadyContained( - this: ChartingDashboardWidgetModel, - geometry: Geometry, - geometries: Geometry[] - ): Geometry | undefined { - return geometries.find((g) => { - const distance = geometryEngine.distance(g.extent!.center, geometry.extent!.center, "meters"); - return distance === 0; - }); - } -}) as unknown as { new (...args: any[]): ChartingDashboardWidgetModel; (...args: any[]): ChartingDashboardWidgetModel }; + tabs: [] as Tab[], + expandedCharts: [] as Array, + // configured properties (set via propertiesConstructor; component key "ChartingDashboardWidgetModel") + drawTabGeometries: true, + drawChartsForSelectionResults: true, + relationships: [] as Relationship[], + chartsProperties: [] as StoreChartsProperties[], + chartsTabs: [] as ChartsTab[] +}); diff --git a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts index 63d742b..7753756 100644 --- a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts +++ b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts @@ -17,7 +17,7 @@ import ct_when from "apprt-core/when"; import ServiceResolver from "apprt/ServiceResolver"; import type { ComponentContext, ServiceInstance, ServiceProperties } from "apprt/api"; -import type { ChartingDashboardWidgetModel } from "./ChartingDashboardWidgetModel"; +import type ChartingDashboardController from "./ChartingDashboardController"; import type { ChartResultSet, ChartStore } from "./api"; interface ResultCenterDataModel { @@ -29,8 +29,8 @@ interface ResultCenterDataModel { export default class ResultCenterChartingToolHandler { - declare private _dataModel: ResultCenterDataModel; - declare private _chartingDashboardWidgetModel: ChartingDashboardWidgetModel | undefined; + declare private dataModel: ResultCenterDataModel; + declare private controller: ChartingDashboardController | undefined; private serviceResolver!: ServiceResolver; @@ -40,9 +40,9 @@ export default class ResultCenterChartingToolHandler { } drawResultCenterCharts(): void { - ct_when(this._queryData(), (result: any[]) => { - const datasource = this._dataModel.datasource; - const storeProperties = this._getStoreProperties(datasource.id); + ct_when(this.queryData(), (result: any[]) => { + const datasource = this.dataModel.datasource; + const storeProperties = this.getStoreProperties(datasource.id); const resultSet: ChartResultSet = { storeId: datasource.id, title: storeProperties!.title as string, @@ -50,16 +50,12 @@ export default class ResultCenterChartingToolHandler { result: result, total: result.length }; - this._chartingDashboardWidgetModel?.setCharts([resultSet]); + this.controller?.setCharts([resultSet]); }); } - private _queryData(): Promise { - const model = this._chartingDashboardWidgetModel; - if (model) { - model.loading = true; - } - const dataModel = this._dataModel; + private queryData(): Promise { + const dataModel = this.dataModel; const selectedIds = dataModel.getSelected(); if (selectedIds && selectedIds.length) { return dataModel.queryById(selectedIds); @@ -68,11 +64,7 @@ export default class ResultCenterChartingToolHandler { } } - private _getStore(id: string): ServiceInstance | undefined { - return this.serviceResolver.getService("ct.api.Store", "(id=" + id + ")"); - } - - private _getStoreProperties(idOrStore: string | ServiceInstance): ServiceProperties | undefined { + private getStoreProperties(idOrStore: string | ServiceInstance): ServiceProperties | undefined { const resolver = this.serviceResolver; if (typeof (idOrStore) === "string") { return resolver.getServiceProperties("ct.api.Store", "(id=" + idOrStore + ")"); diff --git a/src/main/js/bundles/dn_charting/api.ts b/src/main/js/bundles/dn_charting/api.ts index f0f2cbb..7e96f5f 100644 --- a/src/main/js/bundles/dn_charting/api.ts +++ b/src/main/js/bundles/dn_charting/api.ts @@ -15,7 +15,6 @@ /// import type Geometry from "@arcgis/core/geometry/Geometry"; -import type { SelectionResult } from "selection-services/api"; /** Arbitrary feature attributes keyed by field name. */ export type FeatureAttributes = Record; @@ -163,20 +162,34 @@ export interface Tab { geometries: Geometry[]; } -/** Configured component properties of the dashboard widget model. */ -export interface ChartingComponentProperties { - drawTabGeometries: boolean; - drawChartsForSelectionResults: boolean; - relationships: Relationship[]; - chartsProperties: StoreChartsProperties[]; - chartsTabs: ChartsTab[]; -} - /** - * apprt event envelope delivered on the `selection/EXECUTING` topic. - * It wraps a {@link SelectionResult} (from `selection-services`); e.g. - * `getProperty("executions")` yields the store-api `QueryExecutions` of the running selection. + * The charting dashboard model: reactive view state plus the bundle's configured properties. + * + * The widget binds the view state (via apprt-binding) and the `ChartingDashboardController` reads + * the configuration and mutates the view state. The configured properties are delivered through + * `propertiesConstructor` from the app config (keyed by the `ChartingDashboardWidgetModel` component, + * the bundle's public configuration contract). Implemented as a Mutable in `ChartingDashboardWidgetModel.ts`. */ -export interface SelectionExecutingEvent { - getProperty(name: K): SelectionResult[K]; +export interface ChartingDashboardWidgetModel { + // --- reactive view state --- + /** Whether a chart computation is currently running. */ + loading: boolean; + /** Index of the active tab (two-way bound to the view). */ + activeTab: number; + /** The tabs to display. */ + tabs: Tab[]; + /** Expansion state per chart panel. */ + expandedCharts: Array; + + // --- configured properties (propertiesConstructor) --- + /** Whether the geometries of a tab's results are highlighted on the map. */ + readonly drawTabGeometries: boolean; + /** Whether charts are drawn automatically from selection results. */ + readonly drawChartsForSelectionResults: boolean; + /** Relationship metadata used to query related (time series) data. */ + readonly relationships: Relationship[]; + /** Per-store chart configuration (legacy configuration style). */ + readonly chartsProperties: StoreChartsProperties[]; + /** Tabbed chart configuration (new configuration style). */ + readonly chartsTabs: ChartsTab[]; } diff --git a/src/main/js/bundles/dn_charting/manifest.json b/src/main/js/bundles/dn_charting/manifest.json index d31449e..077b9e7 100644 --- a/src/main/js/bundles/dn_charting/manifest.json +++ b/src/main/js/bundles/dn_charting/manifest.json @@ -85,7 +85,7 @@ ], "references": [ { - "name": "_c3ChartsDataProvider", + "name": "c3ChartsDataProvider", "providing": "dn_charting.C3ChartsDataProvider" } ] @@ -99,8 +99,7 @@ { "name": "ChartingDashboardWidgetModel", "provides": [ - "dn_charting.ChartingDashboardWidgetModel", - "ct.framework.api.EventHandler" + "dn_charting.ChartingDashboardWidgetModel" ], "propertiesConstructor": true, "properties": { @@ -108,7 +107,16 @@ "drawChartsForSelectionResults": true, "relationships": [], "chartsProperties": [], - "chartsTabs": [], + "chartsTabs": [] + } + }, + { + "name": "ChartingDashboardController", + "provides": [ + "dn_charting.ChartingDashboardController", + "ct.framework.api.EventHandler" + ], + "properties": { "Event-Topics": [ { "topic": "selection/EXECUTING", @@ -118,23 +126,23 @@ }, "references": [ { - "name": "_mapWidgetModel", - "providing": "map-widget.MapWidgetModel" + "name": "model", + "providing": "dn_charting.ChartingDashboardWidgetModel" }, { - "name": "_c3ChartsFactory", + "name": "c3ChartsFactory", "providing": "dn_charting.C3ChartsFactory" }, { - "name": "_queryController", + "name": "queryController", "providing": "dn_charting.QueryController" }, { - "name": "_highlighter", + "name": "highlighter", "providing": "highlights.HighlightService" }, { - "name": "_tool", + "name": "tool", "providing": "ct.tools.Tool", "filter": "(id=chartingDashboardToggleTool)" } @@ -152,8 +160,12 @@ }, "references": [ { - "name": "_chartingDashboardWidgetModel", + "name": "chartingDashboardWidgetModel", "providing": "dn_charting.ChartingDashboardWidgetModel" + }, + { + "name": "controller", + "providing": "dn_charting.ChartingDashboardController" } ] }, @@ -164,12 +176,12 @@ ], "references": [ { - "name": "_dataModel", + "name": "dataModel", "providing": "resultcenter.DataModel" }, { - "name": "_chartingDashboardWidgetModel", - "providing": "dn_charting.ChartingDashboardWidgetModel", + "name": "controller", + "providing": "dn_charting.ChartingDashboardController", "cardinality": "0..1" } ] diff --git a/src/main/js/bundles/dn_charting/module.ts b/src/main/js/bundles/dn_charting/module.ts index 63b12bf..29d3b36 100644 --- a/src/main/js/bundles/dn_charting/module.ts +++ b/src/main/js/bundles/dn_charting/module.ts @@ -18,5 +18,6 @@ import "./C3ChartsFactory"; import "./C3ChartsDataProvider"; import "./ChartingDashboardWidgetFactory"; import "./ChartingDashboardWidgetModel"; +import "./ChartingDashboardController"; import "./ResultCenterChartingToolHandler"; import "./QueryController"; From e11ff502dcd2ffd0e4b403618161188999e7acd6 Mon Sep 17 00:00:00 2001 From: Jochen Jacobs Date: Mon, 15 Jun 2026 14:33:40 +0200 Subject: [PATCH 3/5] Replace ResultCenter tool with result-ui bulk action --- .../bundles/dn_charting/ChartingBulkAction.ts | 60 +++++++++++++++ .../ChartingDashboardWidgetFactory.ts | 3 +- src/main/js/bundles/dn_charting/README.md | 29 +++---- .../ResultCenterChartingToolHandler.ts | 75 ------------------- src/main/js/bundles/dn_charting/manifest.json | 42 +++-------- src/main/js/bundles/dn_charting/module.ts | 2 +- 6 files changed, 88 insertions(+), 123 deletions(-) create mode 100644 src/main/js/bundles/dn_charting/ChartingBulkAction.ts delete mode 100644 src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts diff --git a/src/main/js/bundles/dn_charting/ChartingBulkAction.ts b/src/main/js/bundles/dn_charting/ChartingBulkAction.ts new file mode 100644 index 0000000..095ac80 --- /dev/null +++ b/src/main/js/bundles/dn_charting/ChartingBulkAction.ts @@ -0,0 +1,60 @@ +/// +/// Copyright (C) 2025 con terra GmbH (info@conterra.de) +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import type { InjectedReference } from "apprt-core/InjectedReference"; +import type { BulkActionContext, BulkButtonTableAction } from "result-api/api"; +import type ChartingDashboardController from "./ChartingDashboardController"; +import type { ChartResultSet, ChartStore } from "./api"; + +const actionId = "charting-bulk-action"; + +export default class ChartingBulkAction implements BulkButtonTableAction { + + readonly uiType = "button" as const; + readonly id = actionId; + readonly icon: string; + readonly label: string; + readonly tooltip: string; + readonly priority?: number; + + declare private controller: InjectedReference; + + constructor(properties: Record) { + this.icon = properties.icon; + this.label = properties.label; + this.tooltip = properties.tooltip; + this.priority = properties.priority; + } + + async trigger(actionContext: BulkActionContext): Promise { + const dataTable = actionContext.dataTable; + const dataset = dataTable.dataset; + + // Chart the selected rows, or the whole table if nothing is selected. + const selectedIds = dataTable.tableModel.getSelectedIds(); + const query = selectedIds.length ? { ids: selectedIds } : {}; + const items = await dataset.queryItems(query).toArray(); + + const resultSet: ChartResultSet = { + storeId: dataset.id, + title: dataset.title, + store: dataset.dataSource as ChartStore, + result: items.map((item) => item.attributes), + total: items.length + }; + this.controller?.setCharts([resultSet]); + } +} diff --git a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts index f8c0bc1..6c3d8ca 100644 --- a/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts +++ b/src/main/js/bundles/dn_charting/ChartingDashboardWidgetFactory.ts @@ -41,7 +41,6 @@ export default class ChartingDashboardWidgetFactory { declare private _i18n: I18N; private vm!: ChartingDashboardVm; - private i18n!: Messages["ui"]; private widget!: any; activate(): void { @@ -52,7 +51,7 @@ export default class ChartingDashboardWidgetFactory { const model = this.chartingDashboardWidgetModel; const controller = this.controller; const vm = this.vm = new Vue(ChartingDashboardWidget as any) as ChartingDashboardVm; - vm.i18n = this.i18n = this._i18n.get().ui; + vm.i18n = this._i18n.get().ui; Binding .create() diff --git a/src/main/js/bundles/dn_charting/README.md b/src/main/js/bundles/dn_charting/README.md index a20342d..934d59c 100644 --- a/src/main/js/bundles/dn_charting/README.md +++ b/src/main/js/bundles/dn_charting/README.md @@ -31,16 +31,7 @@ https://demos.conterra.de/mapapps/resources/jsregistry/root/agssearch/latest/REA There are two ways to draw charts using the Charting Bundle: 1. Select features via the selection-ui bundle when the property _drawChartsForSelectionResults_ is enabled. -2. If there are results in the ResultCenter, click the _ResultCenterChartingTool_ to draw charts of currently selected features. - -If you are using the Query Builder Bundle to get results in the ResultCenter disable _useMemorySelectionStore_ property: -``` -"dn_querybuilder": { - "QueryBuilderProperties": { - "useMemorySelectionStore": false - } -} -``` +2. If there are results in the result-ui, select one or more rows and trigger the _Statistics_ bulk action (provided by the _ChartingBulkAction_ component) to draw charts of the selected features. If no rows are selected, all results of the table are used. ## Configuration Reference @@ -359,13 +350,23 @@ There are two ways to define charts tabs for the charting widget. It is possible More information about how to place the charting widget: https://developernetwork.conterra.de/en/documentation/mapapps/39/developers-documentation/templates -### ResultCenterChartingTool -To hide the ResultCenterChartingTool in the ResultCenter use this configuration in your bundle configuration. +### ChartingBulkAction +The _Statistics_ action in the result-ui table is provided by the _ChartingBulkAction_ component. You can customize its appearance and ordering: ``` -"ResultCenterChartingTool": { - "visibility": false +"ChartingBulkAction": { + "icon": "icon-chart-pie", + "label": "Statistics", + "tooltip": "Statistics", + "priority": 1 } ``` +| Property | Type | Default | Description | +|----------|---------|--------------------|-------------------------------------------------------------------| +| icon | String | ```icon-chart-pie``` | Icon class of the bulk action button. | +| label | String | ```${tool.title}``` | Label of the bulk action button. | +| tooltip | String | ```${tool.tooltip}```| Tooltip of the bulk action button. | +| priority | Number | ```1``` | Ordering of the action among other result-ui bulk actions. | + ### Chart configuration samples diff --git a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts b/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts deleted file mode 100644 index 7753756..0000000 --- a/src/main/js/bundles/dn_charting/ResultCenterChartingToolHandler.ts +++ /dev/null @@ -1,75 +0,0 @@ -/// -/// Copyright (C) 2025 con terra GmbH (info@conterra.de) -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import ct_when from "apprt-core/when"; -import ServiceResolver from "apprt/ServiceResolver"; -import type { ComponentContext, ServiceInstance, ServiceProperties } from "apprt/api"; -import type ChartingDashboardController from "./ChartingDashboardController"; -import type { ChartResultSet, ChartStore } from "./api"; - -interface ResultCenterDataModel { - datasource: ChartStore & { id: string; title?: string }; - getSelected(): any[] | undefined; - queryById(ids: any[]): Promise; - query(query: any): Promise; -} - -export default class ResultCenterChartingToolHandler { - - declare private dataModel: ResultCenterDataModel; - declare private controller: ChartingDashboardController | undefined; - - private serviceResolver!: ServiceResolver; - - activate(componentContext: ComponentContext): void { - const bundleCtx = componentContext.getBundleContext(); - this.serviceResolver = new ServiceResolver({ bundleCtx }); - } - - drawResultCenterCharts(): void { - ct_when(this.queryData(), (result: any[]) => { - const datasource = this.dataModel.datasource; - const storeProperties = this.getStoreProperties(datasource.id); - const resultSet: ChartResultSet = { - storeId: datasource.id, - title: storeProperties!.title as string, - store: datasource, - result: result, - total: result.length - }; - this.controller?.setCharts([resultSet]); - }); - } - - private queryData(): Promise { - const dataModel = this.dataModel; - const selectedIds = dataModel.getSelected(); - if (selectedIds && selectedIds.length) { - return dataModel.queryById(selectedIds); - } else { - return dataModel.query({}); - } - } - - private getStoreProperties(idOrStore: string | ServiceInstance): ServiceProperties | undefined { - const resolver = this.serviceResolver; - if (typeof (idOrStore) === "string") { - return resolver.getServiceProperties("ct.api.Store", "(id=" + idOrStore + ")"); - } - return resolver.getServiceProperties(idOrStore); - } - -} diff --git a/src/main/js/bundles/dn_charting/manifest.json b/src/main/js/bundles/dn_charting/manifest.json index 077b9e7..7e227ac 100644 --- a/src/main/js/bundles/dn_charting/manifest.json +++ b/src/main/js/bundles/dn_charting/manifest.json @@ -16,7 +16,8 @@ "apprt-binding": "^4.20.0", "apprt-core": "^4.20.0", "highlights": "^4.20.0", - "selection-services": "^4.20.0" + "selection-services": "^4.20.0", + "result-api": "^4.20.0" }, "cssThemesExtension": [ { @@ -170,15 +171,18 @@ ] }, { - "name": "ResultCenterChartingToolHandler", + "name": "ChartingBulkAction", "provides": [ - "dn_charting.ResultCenterChartingToolHandler" + "result-api.BulkTableAction" ], + "propertiesConstructor": true, + "properties": { + "icon": "icon-chart-pie", + "label": "${tool.title}", + "tooltip": "${tool.tooltip}", + "priority": 1 + }, "references": [ - { - "name": "dataModel", - "providing": "resultcenter.DataModel" - }, { "name": "controller", "providing": "dn_charting.ChartingDashboardController", @@ -202,30 +206,6 @@ "noGroup": true } }, - { - "name": "ResultCenterChartingTool", - "impl": "ct/tools/Tool", - "provides": [ - "ct.tools.Tool" - ], - "propertiesConstructor": true, - "properties": { - "id": "resultCenterChartingTool", - "title": "${tool.title}", - "tooltip": "${tool.tooltip}", - "toolRole": "resultcenter", - "priority": -3, - "iconClass": "icon-chart-pie", - "clickHandler": "drawResultCenterCharts", - "togglable": false - }, - "references": [ - { - "name": "handlerScope", - "providing": "dn_charting.ResultCenterChartingToolHandler" - } - ] - }, { "name": "QueryController", "provides": "dn_charting.QueryController" diff --git a/src/main/js/bundles/dn_charting/module.ts b/src/main/js/bundles/dn_charting/module.ts index 29d3b36..140b56b 100644 --- a/src/main/js/bundles/dn_charting/module.ts +++ b/src/main/js/bundles/dn_charting/module.ts @@ -19,5 +19,5 @@ import "./C3ChartsDataProvider"; import "./ChartingDashboardWidgetFactory"; import "./ChartingDashboardWidgetModel"; import "./ChartingDashboardController"; -import "./ResultCenterChartingToolHandler"; +import "./ChartingBulkAction"; import "./QueryController"; From f8d2244576cb8154aa9855f8ec94e17a98ba7482 Mon Sep 17 00:00:00 2001 From: Jochen Jacobs Date: Mon, 15 Jun 2026 14:33:40 +0200 Subject: [PATCH 4/5] Add per-year charting via related tables Fix the related-data fetch (use apprtFetchJson and quote string join keys) and demonstrate it in the sample app with a Kreisgrenzen relationship. --- src/main/js/apps/sample/app.json | 70 +++++++---- .../js/bundles/dn_charting/QueryController.ts | 113 ++++-------------- src/main/js/bundles/dn_charting/README.md | 50 ++++++++ 3 files changed, 119 insertions(+), 114 deletions(-) diff --git a/src/main/js/apps/sample/app.json b/src/main/js/apps/sample/app.json index f9138bf..16558b3 100644 --- a/src/main/js/apps/sample/app.json +++ b/src/main/js/apps/sample/app.json @@ -48,42 +48,62 @@ }, "bundles": { "dn_charting": { - "ResultCenterChartingTool": { - "visibility": true - }, "ChartingDashboardWidgetModel": { "drawTabGeometries": true, "drawChartsForSelectionResults": true, - "relationships": [], - "chartsProperties": [ + "relationships": [ + { + "storeId": "Kreisgrenzen_2022", + "tableUrl": "https://services2.arcgis.com/jUpNdisbWqRpMo35/ArcGIS/rest/services/a7103e/FeatureServer/0", + "primaryKey": "AGS", + "foreignKey": "AGS", + "timeAttribute": "YEAR" + } + ], + "chartsTabs": [ { - "storeId": "Einkommen_Haushalte_Kreise", - "titleAttribute": "NAME", + "title": "Einkommensentwicklung", + "chartsTitle": { + "storeId": "Kreisgrenzen_2022", + "titleAttribute": "GEN" + }, "charts": [ { - "title": "Einkommensverteilung", - "type": "bar", - "height": 600, - "data": [ + "title": "Verfügbares Einkommen", + "storeId": "Kreisgrenzen_2022", + "type": "line", + "height": 400, + "relatedData": true, + "dataOrientation": "columns", + "dataSeries": [ { - "attribute": "a0", - "title": "verfüg. Einkommen der priv. Haushalte (Tsd. EUR)" - }, + "title": "EUR", + "attribute": "a0" + } + ], + "showDataLabels": true, + "expanded": true + }, + { + "title": "Verfügbares Einkommen je Einwohner", + "storeId": "Kreisgrenzen_2022", + "type": "line", + "height": 400, + "relatedData": true, + "dataOrientation": "columns", + "dataSeries": [ { - "attribute": "a1", - "title": "verfüg. Einkommen der priv. Haushalte je Einwohner (EUR)" + "title": "EUR je Einwohner", + "attribute": "a1" } ], - "calculationType": "mean", - "dataOrientation": "rows", "showDataLabels": true, - "rotatedAxis": false, "expanded": true } + ] } - ], - "chartsTabs": [] + ] } }, "dn_querybuilder": { @@ -148,10 +168,10 @@ "map": { "layers": [ { - "id": "Einkommen_Haushalte_Kreise", - "title": "Einkommen Haushalte Kreise", - "type":"AGS_FEATURE", - "url": "https://services2.arcgis.com/jUpNdisbWqRpMo35/ArcGIS/rest/services/a7103e/FeatureServer/0", + "id": "Kreisgrenzen_2022", + "title": "Kreise", + "type": "AGS_FEATURE", + "url": "https://services2.arcgis.com/jUpNdisbWqRpMo35/ArcGIS/rest/services/Kreisgrenzen_2022/FeatureServer/0", "visible": true } ] diff --git a/src/main/js/bundles/dn_charting/QueryController.ts b/src/main/js/bundles/dn_charting/QueryController.ts index acf07ab..a3d0e59 100644 --- a/src/main/js/bundles/dn_charting/QueryController.ts +++ b/src/main/js/bundles/dn_charting/QueryController.ts @@ -14,14 +14,9 @@ /// limitations under the License. /// -import { apprtFetch } from "apprt-fetch"; +import { apprtFetchJson } from "apprt-fetch"; import type { ChartStore, FeatureAttributes, RelatedDataEntry, Relationship } from "./api"; -/** Metadata of a feature service layer, holding its relationships. */ -interface ServiceMetadata { - relationships: Relationship[]; -} - /** A feature returned by an esri feature service query. */ interface EsriFeature { attributes: FeatureAttributes; @@ -29,102 +24,42 @@ interface EsriFeature { /** Response of an esri feature service query. */ interface EsriFeatureSet { - features: EsriFeature[]; + features?: EsriFeature[]; } -/** - * apprt-fetch supports the legacy `handleAs`/`query` options which resolve directly to - * the parsed response body. The current typings only describe the standard fetch API, - * so a typed wrapper is used here. - */ -interface LegacyFetchInit { - query?: Record; - handleAs?: "json"; -} -const legacyFetch = apprtFetch as unknown as (url: string, init: LegacyFetchInit) => Promise; - export default class QueryController { - relationships?: Relationship[]; - - findRelatedRecords(objectId: number | string, url: string, metadata: ServiceMetadata): Promise | null { - const relationships = this.relationships = metadata.relationships; - const requests = relationships.map((relationship) => { - const relationshipId = relationship && relationship.id; - return legacyFetch(url + "/queryRelatedRecords", { + async getRelatedData( + results: FeatureAttributes[], + relationship: Relationship | undefined + ): Promise { + if (!relationship || !results.length) { + return results; + } + const requests = results.map(async (result) => { + const keyValue = result[relationship.primaryKey]; + const whereValue = typeof keyValue === "string" ? `'${keyValue}'` : keyValue; + const relatedData = await apprtFetchJson(relationship.tableUrl + "/query", { query: { - objectIds: [objectId], - relationshipId: relationshipId, + where: relationship.foreignKey + " LIKE " + whereValue, outFields: "*", - returnGeometry: true, + returnGeometry: false, returnCountOnly: false, - f: 'json' - }, - handleAs: 'json' + f: "json" + } }); - }); - if (requests.length > 0) { - return Promise.all(requests); - } else { - return null; - } - } - - getRelatedData(results: FeatureAttributes[], relationship: Relationship | undefined): Promise { - return new Promise((resolve) => { - if (!relationship || !results.length) { - resolve(results); - } else { - const requests = results.map((result) => legacyFetch(relationship.tableUrl + "/query", { - query: { - where: relationship.foreignKey + " LIKE " + result[relationship.primaryKey], - outFields: "*", - returnGeometry: false, - returnCountOnly: false, - f: 'json' - }, - handleAs: 'json' - }).then((relatedData) => { - const features: RelatedDataEntry[] = []; - relatedData.features.forEach((feature) => { - const attributes = feature.attributes; - const time = attributes[relationship.timeAttribute]; - features.push({ time: time, attributes: attributes }); - }); - result.relatedData = features; - return result; - })); - Promise.all(requests).then((res) => { - resolve(res); - }); - } - }); - } - - getRelatedMetadata(url: string, metadata: ServiceMetadata): Promise { - url = url.substr(0, url.lastIndexOf("/")); - const relationships = this.relationships = metadata.relationships; - const requests = relationships.map((relationship) => { - const relatedTableId = relationship && relationship.relatedTableId; - return legacyFetch(url + "/" + relatedTableId, { - query: { - f: 'json' - }, - handleAs: 'json' + const features: RelatedDataEntry[] = (relatedData.features ?? []).map((feature) => { + return { + time: feature.attributes[relationship.timeAttribute], + attributes: feature.attributes + }; }); + result.relatedData = features; + return result; }); return Promise.all(requests); } - getMetadata(url: string): Promise { - return legacyFetch(url, { - query: { - f: 'json' - }, - handleAs: 'json' - }); - } - getGeometryForSumObject(results: FeatureAttributes[], store: ChartStore): Promise { const query: Record = {}; const ids = results.map((result) => result[store.idProperty]); diff --git a/src/main/js/bundles/dn_charting/README.md b/src/main/js/bundles/dn_charting/README.md index 934d59c..106e0c4 100644 --- a/src/main/js/bundles/dn_charting/README.md +++ b/src/main/js/bundles/dn_charting/README.md @@ -347,6 +347,56 @@ There are two ways to define charts tabs for the charting widget. It is possible | chart.dataSeries.groups | Array | | ```[]``` | Optional property that allows to use stacked charts. Array of grouped attributes. (e.g. [["2016", "2017"]]) | | chart.dataSeries.color | Array | | ```[]``` | Optional property that change the color of the attribute. (e.g. "#FF0000") | +### Related data (time series from a related table) +If the values to plot live in a **separate table** with one row per time period (e.g. one row per year), +you can configure a relationship. When a feature of the configured store is selected, the bundle queries +the related table for the matching rows and draws them as a time series — one data point per period. + +Add a `relationships` entry to the model configuration: +``` +"ChartingDashboardWidgetModel": { + "relationships": [ + { + "storeId": "Kreisgrenzen_2022", + "tableUrl": "https://.../FeatureServer/0", + "primaryKey": "AGS", + "foreignKey": "AGS", + "timeAttribute": "YEAR" + } + ], + "chartsTabs": [ + { + "title": "Einkommensentwicklung", + "chartsTitle": { "storeId": "Kreisgrenzen_2022", "titleAttribute": "GEN" }, + "charts": [ + { + "title": "Verfügbares Einkommen je Einwohner", + "storeId": "Kreisgrenzen_2022", + "type": "line", + "relatedData": true, + "dataOrientation": "columns", + "dataSeries": [ + { "title": "EUR je Einwohner", "attribute": "a1" } + ] + } + ] + } + ] +} +``` +A related-data chart uses `"relatedData": true` and a `dataSeries` where each series names a single +`attribute` (the value plotted across all periods). The x-axis is built from the `timeAttribute` values, +sorted ascending. Use the optional `chart.headers` to restrict or order the periods shown. + +##### relationships properties +| Property | Type | Possible Values | Default | Description | +|---------------|--------|-----------------|---------|------------------------------------------------------------------------------------------------------| +| storeId | String | | | Id of the selectable store. The relationship applies when a feature of this store is selected. | +| tableUrl | String | | | Query endpoint of the related feature service layer/table holding the per-period rows. | +| primaryKey | String | | | Attribute on the selected feature used as the join value. | +| foreignKey | String | | | Attribute on the related table matched against the join value. May be a string or numeric field. | +| timeAttribute | String | | | Attribute on the related rows used as the time/period axis value. | + More information about how to place the charting widget: https://developernetwork.conterra.de/en/documentation/mapapps/39/developers-documentation/templates From 5029043b6eaf22d12183acecd831397fb9d0739d Mon Sep 17 00:00:00 2001 From: Jochen Jacobs Date: Thu, 18 Jun 2026 09:22:07 +0200 Subject: [PATCH 5/5] fix x-axis label color --- src/main/js/bundles/dn_charting/css/styles.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/js/bundles/dn_charting/css/styles.css b/src/main/js/bundles/dn_charting/css/styles.css index e030155..e52271b 100644 --- a/src/main/js/bundles/dn_charting/css/styles.css +++ b/src/main/js/bundles/dn_charting/css/styles.css @@ -74,15 +74,15 @@ color: #000000; } -.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-x path, .chartingDashboardWidget .c3 .c3-axis-x line { +.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-x path, .ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-x line { stroke: #ffffff; } -.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-y path, .chartingDashboardWidget .c3 .c3-axis-y line { +.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-y path, .ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-y line { stroke: #ffffff; } -.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-y text, .chartingDashboardWidget .c3 .c3-axis-x text { +.ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-y text, .ctAppRoot.everlasting .chartingDashboardWidget .c3 .c3-axis-x text { fill: #ffffff; }