From 0c74b4dbe258f7384c844b68c121ce631af226dc Mon Sep 17 00:00:00 2001 From: cirun Date: Thu, 25 Jul 2024 19:02:26 +0200 Subject: [PATCH] LLCAXCHZF-56/feat: Add more_info button, include moment.js adapter, and configure date axis for scatter and bubble charts --- ckanext/charts/assets/css/charts.css | 2 +- .../charts/assets/js/charts-render-chartjs.js | 74 ++++++++++++++++++ ckanext/charts/chart_builders/base.py | 8 ++ ckanext/charts/chart_builders/chartjs.py | 34 +++++++- ckanext/charts/chart_builders/observable.py | 5 ++ ckanext/charts/chart_builders/plotly.py | 4 + ckanext/charts/fetchers.py | 4 + .../form_snippets/chart_more_info_button.html | 77 +++++++++++++++++++ ckanext/charts/theme/mixins.scss | 32 ++++++++ 9 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 ckanext/charts/templates/scheming/form_snippets/chart_more_info_button.html diff --git a/ckanext/charts/assets/css/charts.css b/ckanext/charts/assets/css/charts.css index ee0a3a0..9d43bed 100644 --- a/ckanext/charts/assets/css/charts.css +++ b/ckanext/charts/assets/css/charts.css @@ -1 +1 @@ -.row.wrapper.charts-view .dataset-resource-form{position:relative;padding:0}.resource-view-charts_builder_view label.no-dots:after,.resource-view-charts_view label.no-dots:after,#content div.charts-view label.no-dots:after{display:none}.resource-view-charts_builder_view #chart-container,.resource-view-charts_view #chart-container,#content div.charts-view #chart-container{max-height:600px}.resource-view-charts_builder_view select,.resource-view-charts_view select,#content div.charts-view select{border:1px solid #d0d0d0;padding:8px;border-radius:3px;width:100%}.resource-view-charts_builder_view #chart-clear,.resource-view-charts_view #chart-clear,#content div.charts-view #chart-clear{margin-left:auto}.resource-view-charts_builder_view .tab-content,.resource-view-charts_view .tab-content,#content div.charts-view .tab-content{display:block !important}.resource-view-charts_builder_view #chart-indicator,.resource-view-charts_view #chart-indicator,#content div.charts-view #chart-indicator{display:none}.resource-view-charts_builder_view .htmx-request#chart-indicator,.resource-view-charts_view .htmx-request#chart-indicator,#content div.charts-view .htmx-request#chart-indicator{display:inline}.charts-filters .filter-container{margin-bottom:1rem}.charts-filters .filter-container .filter-pair{display:flex;gap:.5rem;margin-bottom:1rem;padding-bottom:1rem;border-bottom:1px solid beige}.charts-filters .filter-container .filter-pair:last-of-type{margin-bottom:0;padding-bottom:0;border-bottom:unset}.charts-filters .filter-container .filter-pair .remove-pair{top:0;height:fit-content}.charts-filters .filter-container .filter-pair .ts-wrapper{flex-basis:45%}.charts-view--form .ts-wrapper .ts-control{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em}.charts-view--form .ts-wrapper.plugin-clear_button .ts-control{background:#fff}.charts-view--form .ts-wrapper.plugin-clear_button .ts-control .clear-button{right:.55rem;font-size:1.25rem}.charts-view--form .ts-wrapper .ts-dropdown{top:unset;margin-top:1px} \ No newline at end of file +.date-formats-container__row{display:flex;justify-content:space-between}.date-formats-container__column{flex:1;margin-right:20px}.date-formats-container__column:last-child{margin-right:0}.date-formats-text{font-size:1.2em;color:#333}.date-formats-list{list-style-type:none;padding-left:0}.date-formats-item{margin:5px 0;font-size:1em;color:#555}.row.wrapper.charts-view .dataset-resource-form{position:relative;padding:0}.resource-view-charts_builder_view label.no-dots:after,.resource-view-charts_view label.no-dots:after,#content div.charts-view label.no-dots:after{display:none}.resource-view-charts_builder_view #chart-container,.resource-view-charts_view #chart-container,#content div.charts-view #chart-container{max-height:600px}.resource-view-charts_builder_view select,.resource-view-charts_view select,#content div.charts-view select{border:1px solid #d0d0d0;padding:8px;border-radius:3px;width:100%}.resource-view-charts_builder_view #chart-clear,.resource-view-charts_view #chart-clear,#content div.charts-view #chart-clear{margin-left:auto}.resource-view-charts_builder_view .tab-content,.resource-view-charts_view .tab-content,#content div.charts-view .tab-content{display:block !important}.resource-view-charts_builder_view #chart-indicator,.resource-view-charts_view #chart-indicator,#content div.charts-view #chart-indicator{display:none}.resource-view-charts_builder_view .htmx-request#chart-indicator,.resource-view-charts_view .htmx-request#chart-indicator,#content div.charts-view .htmx-request#chart-indicator{display:inline}.charts-filters .filter-container{margin-bottom:1rem}.charts-filters .filter-container .filter-pair{display:flex;gap:.5rem;margin-bottom:1rem;padding-bottom:1rem;border-bottom:1px solid beige}.charts-filters .filter-container .filter-pair:last-of-type{margin-bottom:0;padding-bottom:0;border-bottom:unset}.charts-filters .filter-container .filter-pair .remove-pair{top:0;height:fit-content}.charts-filters .filter-container .filter-pair .ts-wrapper{flex-basis:45%}.charts-view--form .ts-wrapper .ts-control{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em}.charts-view--form .ts-wrapper.plugin-clear_button .ts-control{background:#fff}.charts-view--form .ts-wrapper.plugin-clear_button .ts-control .clear-button{right:.55rem;font-size:1.25rem}.charts-view--form .ts-wrapper .ts-dropdown{top:unset;margin-top:1px} \ No newline at end of file diff --git a/ckanext/charts/assets/js/charts-render-chartjs.js b/ckanext/charts/assets/js/charts-render-chartjs.js index 7f3d075..77ac6b7 100644 --- a/ckanext/charts/assets/js/charts-render-chartjs.js +++ b/ckanext/charts/assets/js/charts-render-chartjs.js @@ -23,3 +23,77 @@ ckan.module("charts-render-chartjs", function ($, _) { } }; }); + + +/*! + * chartjs-adapter-moment v1.0.0 + * https://www.chartjs.org + * (c) 2021 chartjs-adapter-moment Contributors + * Released under the MIT license + */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('moment'), require('chart.js')) : + typeof define === 'function' && define.amd ? define(['moment', 'chart.js'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.moment, global.Chart)); + }(this, (function (moment, chart_js) { 'use strict'; + + function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + + var moment__default = /*#__PURE__*/_interopDefaultLegacy(moment); + + const FORMATS = { + datetime: 'MMM D, YYYY, h:mm:ss a', + millisecond: 'h:mm:ss.SSS a', + second: 'h:mm:ss a', + minute: 'h:mm a', + hour: 'hA', + day: 'MMM D', + week: 'll', + month: 'MMM YYYY', + quarter: '[Q]Q - YYYY', + year: 'YYYY' + }; + + chart_js._adapters._date.override(typeof moment__default['default'] === 'function' ? { + _id: 'moment', // DEBUG ONLY + + formats: function() { + return FORMATS; + }, + + parse: function(value, format) { + if (typeof value === 'string' && typeof format === 'string') { + value = moment__default['default'](value, format); + } else if (!(value instanceof moment__default['default'])) { + value = moment__default['default'](value); + } + return value.isValid() ? value.valueOf() : null; + }, + + format: function(time, format) { + return moment__default['default'](time).format(format); + }, + + add: function(time, amount, unit) { + return moment__default['default'](time).add(amount, unit).valueOf(); + }, + + diff: function(max, min, unit) { + return moment__default['default'](max).diff(moment__default['default'](min), unit); + }, + + startOf: function(time, unit, weekday) { + time = moment__default['default'](time); + if (unit === 'isoWeek') { + weekday = Math.trunc(Math.min(Math.max(0, weekday), 6)); + return time.isoWeekday(weekday).startOf('day').valueOf(); + } + return time.startOf(unit).valueOf(); + }, + + endOf: function(time, unit) { + return moment__default['default'](time).endOf(unit).valueOf(); + } + } : {}); + +}))); diff --git a/ckanext/charts/chart_builders/base.py b/ckanext/charts/chart_builders/base.py index 7677a63..533fe2c 100644 --- a/ckanext/charts/chart_builders/base.py +++ b/ckanext/charts/chart_builders/base.py @@ -607,6 +607,14 @@ def height_field(self) -> dict[str, Any]: "group": "Data", } + def more_info_button(self) -> dict[str, Any]: + return { + "field_name": "more_info", + "label": "More info", + "form_snippet": "chart_more_info_button.html", + "group": "Data", + } + def size_field(self, choices: list[dict[str, str]]) -> dict[str, Any]: field = self.column_field(choices) field.update({"field_name": "size", "label": "Size", "group": "Structure"}) diff --git a/ckanext/charts/chart_builders/chartjs.py b/ckanext/charts/chart_builders/chartjs.py index 4a94dee..e788cdd 100644 --- a/ckanext/charts/chart_builders/chartjs.py +++ b/ckanext/charts/chart_builders/chartjs.py @@ -90,6 +90,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_multi_axis_field(columns), + self.more_info_button(), self.limit_field(), self.filter_field(columns), ] @@ -148,6 +149,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_multi_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.limit_field(), @@ -204,6 +206,7 @@ def get_form_fields(self): self.type_field(chart_types), self.values_field(columns), self.names_field(columns), + self.more_info_button(), self.limit_field(), self.filter_field(columns), ] @@ -245,8 +248,32 @@ def to_json(self) -> str: "data": dataset_data, } ] + return json.dumps(self._configure_date_axis(data)) + + def _configure_date_axis(self, data: dict[str, Any]) -> dict[str, Any]: + """ + Configure date settings for the x-axis if it uses 'date_time'. + """ + x_axis = data["options"]["x"] + scales = data["options"].get("scales", {}) + + if x_axis == "date_time": + x_scale = scales.get("x", {}) + x_scale.update( + { + "type": "time", + "time": { + "unit": "day", + "displayFormats": {"day": "YYYY-MM-DD"}, + }, + } + ) + scales["x"] = x_scale - return json.dumps(data) + if scales: + data["options"]["scales"] = scales + + return data class ChartJSScatterForm(BaseChartForm): @@ -268,6 +295,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.limit_field(), @@ -303,8 +331,7 @@ def to_json(self) -> str: data["data"]["datasets"] = [ {"label": self.settings["y"], "data": dataset_data}, ] - - return json.dumps(data) + return json.dumps(self._configure_date_axis(data)) def _calculate_bubble_radius(self, data_series: pd.Series, max_size: int) -> int: """Calculate bubble radius based on the size column""" @@ -394,6 +421,7 @@ def get_form_fields(self): columns, help_text="Select 3 or more different categorical variables (dimensions)", ), + self.more_info_button(), self.limit_field(), self.filter_field(columns), ] diff --git a/ckanext/charts/chart_builders/observable.py b/ckanext/charts/chart_builders/observable.py index fe30dbf..7edb29c 100644 --- a/ckanext/charts/chart_builders/observable.py +++ b/ckanext/charts/chart_builders/observable.py @@ -54,6 +54,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.fill_field(columns), @@ -108,6 +109,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.limit_field(), @@ -181,6 +183,7 @@ def get_form_fields(self): self.type_field(chart_types), self.values_field(columns), self.names_field(columns), + self.more_info_button(), self.opacity_field(), self.inner_radius_field(), self.stroke_width_field(), @@ -221,6 +224,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.color_field(columns), @@ -259,6 +263,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.color_field(columns), diff --git a/ckanext/charts/chart_builders/plotly.py b/ckanext/charts/chart_builders/plotly.py index a52f4ca..0bda1ee 100644 --- a/ckanext/charts/chart_builders/plotly.py +++ b/ckanext/charts/chart_builders/plotly.py @@ -106,6 +106,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.log_x_field(), self.log_y_field(), self.sort_x_field(), @@ -137,6 +138,7 @@ def get_form_fields(self): self.type_field(chart_types), self.values_field(columns), self.names_field(columns), + self.more_info_button(), self.opacity_field(), self.limit_field(), self.filter_field(columns), @@ -179,6 +181,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.plotly_y_multi_axis_field(columns, 2), + self.more_info_button(), self.sort_x_field(), self.sort_y_field(), self.limit_field(), @@ -221,6 +224,7 @@ def get_form_fields(self): self.type_field(chart_types), self.x_axis_field(columns), self.y_axis_field(columns), + self.more_info_button(), self.log_x_field(), self.log_y_field(), self.sort_x_field(), diff --git a/ckanext/charts/fetchers.py b/ckanext/charts/fetchers.py index c152e75..d701b1f 100644 --- a/ckanext/charts/fetchers.py +++ b/ckanext/charts/fetchers.py @@ -78,6 +78,10 @@ def fetch_data(self) -> pd.DataFrame: # Apply numeric conversion only to non-datetime columns df[non_datetime_cols] = df[non_datetime_cols].apply(pd.to_numeric, errors='ignore').fillna(0) + if "date_time" in df.columns: + # Convert the 'date_time' column to string format in ISO 8601 + df['date_time'] = df['date_time'].dt.strftime("%Y-%m-%dT%H:%M:%S") + except (ProgrammingError, UndefinedTable) as e: raise exception.DataFetchError( f"An error occurred during fetching data from DataStore: {e}", diff --git a/ckanext/charts/templates/scheming/form_snippets/chart_more_info_button.html b/ckanext/charts/templates/scheming/form_snippets/chart_more_info_button.html new file mode 100644 index 0000000..102d8e3 --- /dev/null +++ b/ckanext/charts/templates/scheming/form_snippets/chart_more_info_button.html @@ -0,0 +1,77 @@ +
+
+ +
+
+ + + diff --git a/ckanext/charts/theme/mixins.scss b/ckanext/charts/theme/mixins.scss index e69de29..6cf6af9 100644 --- a/ckanext/charts/theme/mixins.scss +++ b/ckanext/charts/theme/mixins.scss @@ -0,0 +1,32 @@ +.date-formats-container { + + &__row { + display: flex; + justify-content: space-between; + } + + &__column { + flex: 1; + margin-right: 20px; + + &:last-child { + margin-right: 0; + } + } +} + +.date-formats-text { + font-size: 1.2em; + color: #333; +} + +.date-formats-list { + list-style-type: none; + padding-left: 0; +} + +.date-formats-item { + margin: 5px 0; + font-size: 1em; + color: #555; +}