From 0a3565eea4ac1e1c53d045474daf5fa140556395 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Dec 2023 17:11:10 -0500 Subject: [PATCH 1/3] Added regions and cleaned up some code to align with the new documentation Improved schema validators Added OGC feature sample Better support of Dates written as ISO standard Error callback is now generic Dataset now visible even when Chart is filtered. States hold more information like: visibility, checked, color information Fixing of the download data for bar, pie and doughnut charts Added download callback Now filtering further the chartData based on legend Explicit handling of afterInit event Relaxed the schema validation, due to useEffect sync problems. useEffect parent section Color palettes and typing orders Schema inputs documentation Added missing description --- index.html | 41 +- schema-chartjs-options.json | 5 +- schema-inputs.json | 299 ++++++- src/app.tsx | 59 +- src/chart-parsing.ts | 249 +++--- src/chart-types.ts | 63 +- src/chart-util.ts | 18 +- src/chart.tsx | 1492 +++++++++++++++++++++-------------- src/logger.ts | 53 +- 9 files changed, 1484 insertions(+), 795 deletions(-) diff --git a/index.html b/index.html index 2b95c58..c6bbe20 100644 --- a/index.html +++ b/index.html @@ -216,7 +216,7 @@ }] }; - const DATA_INPUT_LINE_3 = { + const DATA_INPUT_BAR_3 = { chart: 'bar', title: 'Bar Chart with OGC Features', query: { @@ -251,6 +251,40 @@ }] }; + const DATA_INPUT_LINE_3 = { + chart: 'line', + title: 'Line Chart with OGC Features', + query: { + type: "ogcAPIFeatures", + url: "https://api.czs-dev.services.geo.ca/collections/sante_canada_tab", + queryOptions: { + whereClauses: [ + { + field: "location_name", + prefix: "'", + valueIs: "8000_8_45.1134_-66.8242", + suffix: "'" + }] + } + }, + geochart: { + xAxis: { + type: 'time', + property: 'start_time', + }, + yAxis: { + property: 'value_nsvhr' + } + }, + ui: { + resetStates: true, + download: true + }, + datasources: [{ + display: "Data", + }] + }; + const DATA_INPUT_2 = { chart: 'bar', title: 'Bar Chart', @@ -712,7 +746,8 @@ - + +
@@ -857,7 +892,7 @@ } // Load data 1 - importDataInputs(DATA_INPUT_LINE_1); + importDataInputs(DATA_INPUT_LINE_1); // DATA_INPUT_LINE_3 importDataParsed(DATA_NATIVE_1); importOptionsParsed(OPTIONS_NATIVE_1); diff --git a/schema-chartjs-options.json b/schema-chartjs-options.json index e7295f0..5239243 100644 --- a/schema-chartjs-options.json +++ b/schema-chartjs-options.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "GeoChart Options Schema", - "description": "This Schema validator validates the ChartJS options. ABSOLUTELY UNFINISHED.", + "description": "This Schema validator validates the ChartJS options.", "type": "object", "properties": { "responsive": { "type": "boolean" }, @@ -15,6 +15,9 @@ } } } + }, + "scales": { + "type": "object" } }, "required": ["responsive", "plugins"] diff --git a/schema-inputs.json b/schema-inputs.json index 0ecfedf..00237c2 100644 --- a/schema-inputs.json +++ b/schema-inputs.json @@ -1,17 +1,310 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "GeoChart Inputs Schema", - "description": "This Schema validator validates the GeoChart Inputs. ABSOLUTELY UNFINISHED.", + "description": "This Schema validator validates the GeoChart Inputs.", "type": "object", "properties": { "chart": { "description": "Supported types of chart.", "enum": ["line", "bar", "pie", "doughnut"], - "default": "line" + "default": "line" + }, + "title": { + "description": "Optionally provide the title of the chart", + "type": "string" + }, + "query": { + "description": "Groups information on how the data should be queried in the table source", + "type": "object", + "properties": { + "type": { + "description": "Indicates the kind of query to perform - supported values are: 'esriRegular', 'ogcAPIFeatures' and 'json'", + "type": "string" + }, + "url": { + "description": "Indicates the url where to fetch the data to build the chart with - supported urls are Esri services, OGC API Features services or urls pointing to a .json file built on the GeoJson format", + "type": "string" + }, + "queryOptions": { + "description": "", + "type": "object", + "properties": { + "whereClauses": { + "description": "Indicates how to generate the where clause to fetch the correct data in the table source. This is an array to support filtering on more than 1 field. The and logic operator is implicit", + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "description": "Indicates the field name, in the table source, on which to filter", + "type": "string" + }, + "prefix": { + "description": "Indicates the prefix/suffix to use to build the query (useful to support single-quotes when the attribute to query is a string)", + "type": "string" + }, + "valueIs": { + "description": "Indicates the value as a literal information (not read from a property name from the datasource)", + "type": "string" + }, + "valueFrom": { + "description": "Indicates the property name, in the datasource.sourceItem(!), to use to query the table source (the url)", + "type": "string" + }, + "suffix": { + "description": "Indicates the prefix/suffix to use to build the query (useful to support single-quotes when the attribute to query is a string)", + "type": "string" + } + }, + "required": ["field"] + } + }, + "orderByField": { + "description": "Indicates the property on which to order the results of the data coming from the table source", + "type": "string" + } + } + } + }, + "required": ["type", "url"] }, "geochart": { + "description": "Groups information on how to build the chart", + "type": "object", + "properties": { + "borderWidth": { + "description": "Indicates the thickness of the borders (or lines in the line chart)", + "type": "number" + }, + "useSteps": { + "description": "Indicates if the line chart should use steps - supported values are: 'before', 'middle', 'after', false", + "enum": ["before", "after", "middle", false] + }, + "tension": { + "description": "Indicates if the line chart should use tension when drawing the line between the values", + "type": "number" + }, + "xAxis": { + "description": "Groups information on the x axis", + "type": "object", + "properties": { + "property": { + "description": "Indicates the property name on which to read the information from the table source", + "type": "string" + }, + "type": { + "description": "Indicates the type of axis - supported values are: 'linear', 'time', 'timeseries', 'logarithmic', 'category'", + "type": "string" + }, + "label": { + "description": "Indicates the text in the user interface that should be shown for the axis", + "type": "string" + }, + "usePalette": { + "description": "Indicates if a pre-determined (GeoChart specific) color palette should be used", + "type": "boolean" + }, + "paletteBackgrounds": { + "description": "Indicates the array of rgba color values to use as the palette for background coloring", + "type": "array", + "items": { + "description": "A rgba() color", + "type": "string" + } + }, + "paletteBorders": { + "description": "Indicates the array of rgb color values to use as the palette for border coloring", + "type": "array", + "items": { + "description": "A rgb() color", + "type": "string" + } + }, + "tooltipSuffix": { + "description": "Indicates the suffix to use on for the values when displayed in the tooltip", + "type": "string" + } + }, + "required": ["property"] + }, + "yAxis": { + "description": "Groups information on the y axis", + "type": "object", + "properties": { + "property": { + "description": "Indicates the property name on which to read the information from the table source", + "type": "string" + }, + "type": { + "description": "Indicates the type of axis - supported values are: 'linear', 'time', 'timeseries', 'logarithmic', 'category'", + "type": "string" + }, + "label": { + "description": "Indicates the text in the user interface that should be shown for the axis", + "type": "string" + }, + "usePalette": { + "description": "Indicates if a pre-determined (GeoChart specific) color palette should be used", + "type": "boolean" + }, + "paletteBackgrounds": { + "description": "Indicates the array of rgba color values to use as the palette for background coloring", + "type": "array", + "items": { + "description": "A rgba() color", + "type": "string" + } + }, + "paletteBorders": { + "description": "Indicates the array of rgb color values to use as the palette for border coloring", + "type": "array", + "items": { + "description": "A rgb() color", + "type": "string" + } + }, + "tooltipSuffix": { + "description": "Indicates the suffix to use on for the values when displayed in the tooltip", + "type": "string" + } + }, + "required": ["property"] + } + }, + "required": ["xAxis", "yAxis"] + }, + "category": { + "description": "Indicates how the data from the table source should be categorized (this creates the datasets aka the legend)", + "type": "object", + "properties": { + "property": { + "description": "Indicates the property name to use to categorize records", + "type": "string" + }, + "usePalette": { + "description": "Indicates if a pre-determined (GeoChart specific) color palette should be used", + "type": "boolean" + }, + "paletteBackgrounds": { + "description": "Indicates the array of rgba color values to use as the palette for background coloring", + "type": "array", + "items": { + "description": "A rgba() color", + "type": "string" + } + }, + "paletteBorders": { + "description": "Indicates the array of rgb color values to use as the palette for border coloring", + "type": "array", + "items": { + "description": "A rgb() color", + "type": "string" + } + } + }, + "required": ["property"] + }, + "datasources": { + "description": "Groups information on the datasources to build the datasource drop down and the chart", + "type": "array", + "items": { + "type": "object", + "properties": { + "display": { + "description": "Indicates the string to be displayed in the drop down", + "type": "string" + }, + "sourceItem": { + "description": "Indicates the source item(!) used as reference to query the data from. This property has an object with a property that should equal the property in query.queryOptions.whereClauses.valueFrom", + "type": "object" + }, + "value": { + "description": "Indicates the inner value used for the 'sourceItem'", + "type": "string" + }, + "items": { + "description": "Indicates the actual items (coming from the table source), associated with the datasources.sourceItem (coming from the origin source), used to build the chart with. When items is already specified/populated, the data isn't fetched via the query.url", + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": ["display"] + } + }, + "ui": { + "description": "Indicates what ui elements to show with the chart", + "type": "object", + "properties": { + "xSlider": { + "description": "Groups information on the x slider", + "type": "object", + "properties": { + "display": { + "description": "Indicates if the slider should be displayed", + "type": "boolean" + }, + "step": { + "description": "Indicates the steps the slider should jump when sliding", + "type": "number" + }, + "min": { + "description": "Indicates the minimum value for the slider", + "type": "number" + }, + "max": { + "description": "Indicates the maximum value for the slider", + "type": "number" + } + }, + "required": ["display"] + }, + "ySlider": { + "description": "Groups information on the y slider", + "type": "object", + "properties": { + "display": { + "description": "Indicates if the slider should be displayed", + "type": "boolean" + }, + "step": { + "description": "Indicates the steps the slider should jump when sliding", + "type": "number" + }, + "min": { + "description": "Indicates the minimum value for the slider", + "type": "number" + }, + "max": { + "description": "Indicates the maximum value for the slider", + "type": "number" + } + }, + "required": ["display"] + }, + "stepsSwitcher": { + "description": "Indicates if the select drop down to switch the steps on-the-fly is displayed", + "type": "boolean" + }, + "resetStates": { + "description": "Indicates if the button to reset the states is displayed", + "type": "boolean" + }, + "description": { + "description": "Indicates the description text to show at the bottom of the chart", + "type": "string" + }, + "download": { + "description": "Indicates if a download button should be displayed", + "type": "boolean" + } + } + }, + "chartjsOptions": { + "description": "Iindicates further ChartJS specific options to open the door to further customization when natively supported by ChartJS: https://www.chartjs.org/docs/latest/general/options.html", "type": "object" } }, - "required": ["chart", "geochart"] + "required": ["chart", "geochart", "datasources"] } \ No newline at end of file diff --git a/src/app.tsx b/src/app.tsx index 1f1d6fa..d9babcd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,6 +1,6 @@ import { GeoChart } from './chart'; import { GeoChartConfig, ChartType, ChartOptions, ChartData, GeoChartAction, DefaultDataPoint } from './chart-types'; -import { SchemaValidator, ValidatorResult } from './chart-schema-validator'; +import { SchemaValidator } from './chart-schema-validator'; /** * Main props for the Application @@ -25,7 +25,7 @@ export function App(props: TypeAppProps): JSX.Element { const { schemaValidator } = props; // Translation - const { t, i18n } = useTranslation(); + const { i18n } = useTranslation(); /** ****************************************** USE STATE SECTION START ************************************************ */ @@ -89,6 +89,31 @@ export function App(props: TypeAppProps): JSX.Element { if (ev.detail.state === 2) setIsLoadingDatasource(true); }; + /** + * Handles when the Chart has parsed inputs. + * @param theChart ChartType The chart type + * @param theOptions ChartOptions The chart options + * @param theData ChartData The chart data + */ + const handleParsed = useCallback((theChart: ChartType, theOptions: ChartOptions, theData: ChartData): void => { + // Raise event higher + window.dispatchEvent(new CustomEvent('chart/parsed', { detail: { chart: theChart, options: theOptions, data: theData } })); + }, []); + + /** + * Handles a generic error that happened in the Chart component. + * @param error The error message + * @param exception The exception that happened (if any) + */ + const handleError = useCallback((error: string, exception: unknown): void => { + // Show the error using an alert. We can't use the cgpv SnackBar as that component is attached to + // a map and we're not even running a cgpv.init() at all here. + // eslint-disable-next-line no-console + console.error(error, exception); + // eslint-disable-next-line no-alert + alert(error); + }, []); + /** **************************************** EVENT HANDLERS SECTION END *********************************************** */ /** ******************************************* HOOKS SECTION START *************************************************** */ @@ -103,36 +128,6 @@ export function App(props: TypeAppProps): JSX.Element { [i18n] ); - /** - * Handles when the Chart has parsed inputs. - * We use a 'useCallback' so that any child component with a useEffect dependency on the callback - * doesn't get triggered everytime this parent component re-renders and re-generates its stub. - */ - const handleParsed = useCallback((theChart: ChartType, theOptions: ChartOptions, theData: ChartData): void => { - // Raise event higher - window.dispatchEvent(new CustomEvent('chart/parsed', { detail: { chart: theChart, options: theOptions, data: theData } })); - }, []) as (theChart: ChartType, theOptions: ChartOptions, theData: ChartData) => void; // Crazy typing, because can't use the generic version of 'useCallback' - - /** - * Handles an error that happened in the Chart component. - * We use a 'useCallback' so that any child component with a useEffect dependency on the callback - * doesn't get triggered everytime this parent component re-renders and re-generates its stub. - * @param dataErrors The data errors that happened (if any) - * @param optionsErrors The options errors that happened (if any) - */ - const handleError = useCallback( - (validators: (ValidatorResult | undefined)[]): void => { - // Gather all error messages - const msgAll = SchemaValidator.parseValidatorResultsMessages(validators); - - // Show the error using an alert. We can't use the cgpv SnackBar as that component is attached to - // a map and we're not even running a cgpv.init() at all here. - // eslint-disable-next-line no-alert - alert(`${t('geochart.parsingError')}\n\n${msgAll}\n\n${t('geochart.viewConsoleDetails')}`); - }, - [t] - ) as (validators: (ValidatorResult | undefined)[]) => void; // Crazy typing, because can't use the generic version of 'useCallback' - // Effect hook to add and remove event listeners. // Using window.addEventListener is unconventional here, but this is strictly for the 'app' logic with the index.html. // It's not something to be used by the developers when using the Chart component in their projects. diff --git a/src/chart-parsing.ts b/src/chart-parsing.ts index c5e3ede..2563440 100644 --- a/src/chart-parsing.ts +++ b/src/chart-parsing.ts @@ -1,4 +1,4 @@ -import { ChartType, ChartData, ChartDataset, ChartOptions, Tick } from 'chart.js'; +import { ChartType, ChartData, ChartDataset, ChartOptions, Tick, PluginChartOptions } from 'chart.js'; import { GeoChartConfig, GeoChartXYData, @@ -7,6 +7,7 @@ import { GeoChartCategoriesGroup, GeoChartQuery, GeoChartQueryOptionClause, + GeoChartSelectedDataset, StepsPossibilities, DEFAULT_COLOR_PALETTE_CUSTOM_TRANSPARENT, DEFAULT_COLOR_PALETTE_CUSTOM_OPAQUE, @@ -16,7 +17,7 @@ import { DEFAULT_COLOR_PALETTE_CHARTJS_OPAQUE, DATE_OPTIONS_AXIS, } from './chart-types'; -import { isNumber, getColorFromPalette } from './chart-util'; +import { isNumber } from './chart-util'; /** * Sorts all ChartDatasets based on the X values of their data. @@ -63,66 +64,13 @@ function sortOnDatasetLabels The GeoChart configuration - */ -function setColorsUsingPalette, TLabel = string>( - chartConfig: GeoChartConfig, - data: ChartData -): void { - // If pie or doughnut - let colorPaletteForAll: string[]; - if (chartConfig.chart === 'pie' || chartConfig.chart === 'doughnut') { - // Create a new color array of expected length - colorPaletteForAll = Array.from({ length: data.labels!.length }, (_, paletteIndex: number) => { - return getColorFromPalette(chartConfig.geochart.xAxis!.paletteBackgrounds, paletteIndex)!; - }); - } - - // For each dataset - data.datasets.forEach((ds: ChartDataset, idx: number) => { - // If we categorize - let backgroundColor: string | string[] | undefined; - let borderColor: string | string[] | undefined; - if (chartConfig.category?.property) { - // If pie or doughnut - if (chartConfig.chart === 'pie' || chartConfig.chart === 'doughnut') { - backgroundColor = colorPaletteForAll; - } else { - // The colors to use - backgroundColor = getColorFromPalette(chartConfig.category!.paletteBackgrounds, idx); - } - borderColor = getColorFromPalette(chartConfig.category!.paletteBorders, idx); - } else { - // Not categorizing - // If pie or doughnut - // eslint-disable-next-line no-lonely-if - if (chartConfig.chart === 'pie' || chartConfig.chart === 'doughnut') { - backgroundColor = colorPaletteForAll; - borderColor = undefined; - } else { - // The colors to use - backgroundColor = getColorFromPalette(chartConfig.geochart.xAxis!.paletteBackgrounds, idx); - borderColor = getColorFromPalette(chartConfig.geochart.xAxis!.paletteBorders, idx); - } - } - - // Set the colors - // eslint-disable-next-line no-param-reassign - if (backgroundColor) ds.backgroundColor = backgroundColor; - // eslint-disable-next-line no-param-reassign - if (borderColor) ds.borderColor = borderColor; - }); -} - /** * Builds a where clause string, to be used in an url, given the array of GeoChartQueryOptionClause. * @param whereClauses GeoChartQueryOptionClause[] The array of where clauses objects. - * @param source TypeJsonObject The source to read the information from when building the clause. + * @param sourceItem TypeJsonObject The source to read the information from when building the clause in case 'valueFrom' is needed. * @returns string Returns the where clause string */ -const buildQueryWhereClause = (whereClauses: GeoChartQueryOptionClause[], source: TypeJsonObject): string => { +const buildQueryWhereClause = (whereClauses: GeoChartQueryOptionClause[], sourceItem: TypeJsonObject | undefined): string => { // Loop on each url options let theWhereClause = ''; if (whereClauses) { @@ -132,9 +80,9 @@ const buildQueryWhereClause = (whereClauses: GeoChartQueryOptionClause[], source if (urlOpt.valueIs) { // As-is replace val = urlOpt.valueIs; - } else if (urlOpt.valueFrom) { + } else if (urlOpt.valueFrom && sourceItem) { // Value comes from the record object - val = source[urlOpt.valueFrom] as string; + val = sourceItem[urlOpt.valueFrom] as string; } // If value was read, concatenate to the where clause if (val) { @@ -232,8 +180,8 @@ export async function queryOGCFeaturesByUrl(url: string): Promise => { // Depending on the type of query let entries: TypeJsonObject[]; @@ -242,7 +190,7 @@ export const fetchItemsViaQueryForDatasource = async ( let { url } = queryConfig; // Append the mandatory params - url += `/items?f=json&lang=${language}&skipGeometry=true&offset=0`; + url += `/items?f=json&lang=${language}&skipGeometry=true&offset=0&filter-lang=cql-text`; // If any query options if (queryConfig.queryOptions) { @@ -310,7 +258,8 @@ function createDataXYFormat(chartConfig: GeoChartConfig if (chartConfig.geochart.xAxis?.type === 'time' || chartConfig.geochart.xAxis?.type === 'timeseries') { // Make sure it's a date object if (valRawX instanceof Date) xVal = valRawX as Date; - if (isNumber(valRawX)) xVal = new Date(valRawX as number); + // Do our best to convert to date + xVal = new Date(valRawX as string); } // Read the value in y, hopefully it's a number, that's what GeoChartXYPair supports for now (there's a TODO there) @@ -370,41 +319,38 @@ function createDataCompressedForPieDoughnut< */ function createDataset = GeoDefaultDataPoint>( chartConfig: GeoChartConfig, + backgroundColor: string | string[] | undefined, + borderColor: string | string[] | undefined, steps: StepsPossibilities | undefined, label?: string ): ChartDataset { // Transform the TypeFeatureJson data to ChartDataset - let theDatasetGeneric: ChartDataset; + const theDataset: ChartDataset = { + label, + data: [], + } as unknown as ChartDataset; // If building a line chart if (chartConfig.chart === 'line') { // Transform the TypeFeatureJson data to ChartDataset - const theDatasetLine: ChartDataset<'line', GeoDefaultDataPoint<'line'>> = { - label, - data: [], - }; + const theDatasetLine = theDataset as ChartDataset<'line'>; // If useSteps is defined, set it for each dataset if (steps !== undefined) theDatasetLine.stepped = steps; // If tension is defined, set it for each dataset if (chartConfig.geochart.tension) theDatasetLine.tension = chartConfig.geochart.tension; - - // Switch to generic type - theDatasetGeneric = theDatasetLine as ChartDataset; - } else { - // Switch to generic type, for all unspecific types, so typed to unknown first - theDatasetGeneric = { - label, - data: [], - } as unknown as ChartDataset; } + // Set the colors + if (backgroundColor) theDataset.backgroundColor = backgroundColor; + if (borderColor) theDataset.borderColor = borderColor; + // If the border width is set (applies to all datasets the same) if (chartConfig.geochart.borderWidth) { - theDatasetGeneric.borderWidth = chartConfig.geochart.borderWidth; + theDataset.borderWidth = chartConfig.geochart.borderWidth; } - return theDatasetGeneric!; + return theDataset!; } /** @@ -419,7 +365,12 @@ function createDatasetsLineBar< TType extends ChartType = 'line' | 'bar', TData extends GeoDefaultDataPoint = GeoDefaultDataPoint, TLabel extends string = string ->(chartConfig: GeoChartConfig, steps: StepsPossibilities, records: TypeJsonObject[]): ChartData { +>( + chartConfig: GeoChartConfig, + datasetsRegistry: GeoChartSelectedDataset, + steps: StepsPossibilities, + records: TypeJsonObject[] +): ChartData { // Transform the TypeFeatureJson data to ChartData const returnedChartData: ChartData = { labels: [], @@ -435,24 +386,34 @@ function createDatasetsLineBar< // Read the category as a string const catName = rec[chartConfig.category!.property] as string; - // If new category - if (!Object.keys(categoriesRead).includes(catName)) { - // Create dataset - const newDataset = createDataset(chartConfig, steps, catName); - categoriesRead[catName] = { index: idx++, data: newDataset.data }; - returnedChartData.datasets.push(newDataset); + // If it's a category we actually want + if (datasetsRegistry[catName].checked) { + // If new category + if (!Object.keys(categoriesRead).includes(catName)) { + // Get the color using the registry + // Create dataset + const newDataset = createDataset( + chartConfig, + datasetsRegistry[catName].backgroundColor, + datasetsRegistry[catName].borderColor, + steps, + catName + ); + categoriesRead[catName] = { index: idx++, data: newDataset.data }; + returnedChartData.datasets.push(newDataset); + } + + // Parse data + const dataParsed = createDataXYFormat(chartConfig, rec); + + // Find the data array and push in it. + categoriesRead[catName].data.push(dataParsed); } - - // Parse data - const dataParsed = createDataXYFormat(chartConfig, rec); - - // Find the data array and push in it. - categoriesRead[catName].data.push(dataParsed); }); } else { // 1 feature = 1 dataset // Create dataset - const newDataset = createDataset(chartConfig, steps, undefined); + const newDataset = createDataset(chartConfig, undefined, undefined, steps, undefined); returnedChartData.datasets.push(newDataset); // For each record @@ -479,7 +440,12 @@ function createDatasetsPieDoughnut< TType extends ChartType = 'pie' | 'doughnut', TData extends GeoDefaultDataPoint = GeoDefaultDataPoint, TLabel extends string = string ->(chartConfig: GeoChartConfig, records: TypeJsonObject[]): ChartData { +>( + chartConfig: GeoChartConfig, + datasetsRegistry: GeoChartSelectedDataset, + datasRegistry: GeoChartSelectedDataset, + records: TypeJsonObject[] +): ChartData { // Transform the TypeFeatureJson data to ChartData const returnedChartData: ChartData = { labels: [], @@ -493,6 +459,14 @@ function createDatasetsPieDoughnut< if (!returnedChartData.labels!.includes(valX)) returnedChartData.labels!.push(valX); }); + // Build the color palette using registry and label (the same palette is used for all datasets) + let paletteBackgroundAll: string[]; + if (returnedChartData.labels) { + paletteBackgroundAll = returnedChartData.labels?.map((label: TLabel) => { + return datasRegistry[label].backgroundColor; + }); + } + // If we categorize if (chartConfig.category?.property) { // 1 category = 1 dataset @@ -502,12 +476,15 @@ function createDatasetsPieDoughnut< // Read the category as a string const catName = rec[chartConfig.category!.property] as string; - // If new category - if (!Object.keys(categoriesRead).includes(catName)) { - // Create dataset - const newDataset = createDataset(chartConfig, undefined, catName); - categoriesRead[catName] = { index: idx++, data: newDataset.data }; - returnedChartData.datasets.push(newDataset); + // If it's a category we actually want + if (datasetsRegistry[catName].checked) { + // If new category + if (!Object.keys(categoriesRead).includes(catName)) { + // Create dataset + const newDataset = createDataset(chartConfig, paletteBackgroundAll, undefined, undefined, catName); + categoriesRead[catName] = { index: idx++, data: newDataset.data }; + returnedChartData.datasets.push(newDataset); + } } }); @@ -524,7 +501,7 @@ function createDatasetsPieDoughnut< } else { // 1 feature = 1 dataset // Create dataset - const newDataset = createDataset(chartConfig, undefined, undefined); + const newDataset = createDataset(chartConfig, undefined, undefined, undefined, undefined); returnedChartData.datasets.push(newDataset); // Compress the data for the ChartDataset @@ -550,13 +527,19 @@ function createDatasets< TType extends ChartType = ChartType, TData extends GeoDefaultDataPoint = GeoDefaultDataPoint, TLabel extends string = string ->(chartConfig: GeoChartConfig, steps: StepsPossibilities, records: TypeJsonObject[]): ChartData { +>( + chartConfig: GeoChartConfig, + datasetsRegistry: GeoChartSelectedDataset, + datasRegistry: GeoChartSelectedDataset, + steps: StepsPossibilities, + records: TypeJsonObject[] +): ChartData { // Depending on the ChartType if (chartConfig.chart === 'line' || chartConfig.chart === 'bar') { - return createDatasetsLineBar(chartConfig, steps, records); + return createDatasetsLineBar(chartConfig, datasetsRegistry, steps, records); } if (chartConfig.chart === 'pie' || chartConfig.chart === 'doughnut') { - return createDatasetsPieDoughnut(chartConfig, records); + return createDatasetsPieDoughnut(chartConfig, datasetsRegistry, datasRegistry, records); } throw Error('Unsupported chart type'); } @@ -573,9 +556,9 @@ function createDatasets< * is explicitely used. * @param chartConfig The GeoChart Inputs to use to build the ChartJS ingestable information. */ -function createChartJSOptionsColorPalette(chartConfig: GeoChartConfig): void { +export function setColorPalettes(chartConfig: GeoChartConfig | undefined): void { // If there's a category - if (chartConfig.category) { + if (chartConfig?.category) { // If there's no background palettes if (!chartConfig.category.paletteBackgrounds) { // For line or bar charts, set the ChartJS default color palette @@ -599,7 +582,7 @@ function createChartJSOptionsColorPalette(chartConfig: } // If there's a X-Axis - if (chartConfig.geochart.xAxis) { + if (chartConfig?.geochart.xAxis) { // If there's no background palettes if (!chartConfig.geochart.xAxis.paletteBackgrounds) { // eslint-disable-next-line no-param-reassign @@ -631,10 +614,11 @@ export function createChartJSOptions( language: string ): ChartOptions { // The Chart JS Options as entered or the default options - const options = (chartConfig.chartjsOptions || { ...defaultOptions }) as ChartOptions; - - // Verify the color palette is alright - createChartJSOptionsColorPalette(chartConfig); + const options = { + ...defaultOptions, + ...chartConfig.chartjsOptions, + plugins: { ...(defaultOptions as PluginChartOptions).plugins }, + } as ChartOptions; // If line and using a time series if (chartConfig.chart === 'line' && (chartConfig.geochart.xAxis?.type === 'time' || chartConfig.geochart.xAxis?.type === 'timeseries')) { @@ -672,27 +656,30 @@ export function createChartJSOptions( }; } - // If line and using a time series - if ((chartConfig.chart === 'line' || chartConfig.chart === 'bar') && chartConfig.geochart.yAxis?.type) { + // If line or bar + if (chartConfig.chart === 'line' || chartConfig.chart === 'bar') { const optionsLine = options as ChartOptions<'line' | 'bar'>; - optionsLine.scales = { - ...optionsLine.scales, - y: { - type: chartConfig.geochart.yAxis?.type, - }, - }; - } + // If type is set + if (chartConfig.geochart.yAxis?.type) { + optionsLine.scales = { + ...optionsLine.scales, + y: { + type: chartConfig.geochart.yAxis?.type, + }, + }; + } - // If there's a custom suffix for the tooltip for the values - if (chartConfig.geochart.yAxis.tooltipSuffix) { - const optionsLine = options as ChartOptions<'line' | 'bar'>; // Drill optionsLine.plugins = optionsLine.plugins || {}; optionsLine.plugins.tooltip = optionsLine.plugins.tooltip || {}; optionsLine.plugins.tooltip.callbacks = optionsLine.plugins.tooltip.callbacks || {}; - optionsLine.plugins.tooltip.callbacks.label = (context): string => { - return `${context.formattedValue} ${chartConfig.geochart.yAxis.tooltipSuffix}`; - }; + + // If tooltip + if (chartConfig.geochart.yAxis.tooltipSuffix) { + optionsLine.plugins.tooltip.callbacks.label = (context): string => { + return `${context.formattedValue} ${chartConfig.geochart.yAxis.tooltipSuffix}`; + }; + } } // Return the ChartJS Options @@ -701,7 +688,8 @@ export function createChartJSOptions( /** * Creates the ChartJS Data object necessary for ChartJS process. - * When the xAxis reprensents time, the datasets are sorted by date. + * The datasets are being sorted by labels. + * When the xAxis reprensents time, the datasets are internally sorted by date. * @param chartConfig GeoChartConfigThe GeoChart Inputs to use to build the ChartJS ingestable information. * @param records TypeJsonObject[] | undefined The Records to build the data from. * @param defaultData ChartDataThe default, basic, necessary Data for ChartJS. @@ -713,6 +701,8 @@ export function createChartJSData< TLabel extends string = string >( chartConfig: GeoChartConfig, + datasetsRegistry: GeoChartSelectedDataset, + datasRegistry: GeoChartSelectedDataset, steps: StepsPossibilities, records: TypeJsonObject[] | undefined, defaultData: ChartData @@ -720,15 +710,12 @@ export function createChartJSData< // If there's a data source, parse it to a GeoChart data let data: ChartData = { ...defaultData }; if (records && records.length > 0) { - data = createDatasets(chartConfig, steps, records); + data = createDatasets(chartConfig, datasetsRegistry, datasRegistry, steps, records); } // Sort the dataset labels sortOnDatasetLabels(data); - // Now that the datasets are ordered, set the color palette for them - setColorsUsingPalette(chartConfig, data); - // If the x axis type is time if (chartConfig.geochart.xAxis?.type === 'time' || chartConfig.geochart.xAxis?.type === 'timeseries') { // Make sure the datasets data are sorted on X diff --git a/src/chart-types.ts b/src/chart-types.ts index 242d233..67d435f 100644 --- a/src/chart-types.ts +++ b/src/chart-types.ts @@ -13,21 +13,15 @@ export type TypeJsonObject = TypeJsonValue & { [key: string]: TypeJsonObject }; /** * The Main GeoChart Configuration used by the GeoChart Component */ -export type GeoChartConfig = GeoChartOptions & { - chartjsOptions: ChartOptions; -}; - -/** - * The Main GeoChart Options Configuration used by the GeoChart Component - */ -export type GeoChartOptions = { +export type GeoChartConfig = { chart: TType; - title: string; - query?: GeoChartQuery; geochart: GeoChartOptionsGeochart; + datasources: GeoChartDatasource[]; + title?: string; + query?: GeoChartQuery; category?: GeoChartCategory; ui?: GeoChartOptionsUI; - datasources: GeoChartDatasource[]; + chartjsOptions?: ChartOptions; }; /** @@ -105,9 +99,9 @@ export type GeoChartOptionsUI = { * The Datasource object to hold the data, as supported by GeoChart. */ export type GeoChartDatasource = { - value?: string; display: string; - sourceItem: TypeJsonObject; // Associated source item linking back to the source of the data + sourceItem?: TypeJsonObject; // Associated source item linking back to the source of the data + value?: string; items?: TypeJsonObject[]; }; @@ -183,23 +177,46 @@ export type GeoChartAction = { }; /** - * Helper type to work with the Selected Datasets state. + * Helper type to work with the Datasets and their states. */ -export type GeoChartSelectedDatasets = { - [label: string]: boolean; +export type GeoChartDatasetOption = { + visible: boolean; + checked: boolean; + borderColor: string; + backgroundColor: string; +}; + +/** + * Helper type to work with the Datasets. + */ +export type GeoChartSelectedDataset = { + [label: string]: GeoChartDatasetOption; }; /** * The default color palette that ChartJS uses (I couldn't easily find out where that const is stored within ChartJS) */ export const DEFAULT_COLOR_PALETTE_CHARTJS_TRANSPARENT: string[] = [ - 'rgba(54, 162, 235, 0.5)', - 'rgba(255, 99, 132, 0.5)', - 'rgba(75, 192, 192, 0.5)', - 'rgba(255, 159, 64, 0.5)', - 'rgba(153, 102, 255, 0.5)', - 'rgba(255, 205, 86, 0.5)', - 'rgba(201, 203, 207, 0.5)', + 'rgba(54, 162, 235, 0.5)', // light blue + 'rgba(255, 99, 132, 0.5)', // light red + 'rgba(75, 192, 192, 0.5)', // light green + 'rgba(255, 159, 64, 0.5)', // light orange + 'rgba(153, 102, 255, 0.5)', // light purple + 'rgba(255, 205, 86, 0.5)', // light yellow + 'rgba(201, 203, 207, 0.5)', // light gray + 'rgba(0, 0, 255, 0.5)', // blue + 'rgba(0, 255, 0, 0.5)', // green + 'rgba(255, 0, 0, 0.5)', // red + 'rgba(255, 150, 0, 0.5)', // orange + 'rgba(255, 0, 255, 0.5)', // pink + 'rgba(30, 219, 34, 0.5)', // lime green + 'rgba(190, 0, 190, 0.5)', // purple + 'rgba(132, 255, 255, 0.5)', // cyan + 'rgba(255, 250, 0, 0.5)', // yellow + 'rgba(128, 0, 128, 0.5)', // maroon + 'rgba(0, 128, 128, 0.5)', // teal + 'rgba(128, 128, 0, 0.5)', // olive + 'rgba(128, 128, 128, 0.5)', // gray ]; /** diff --git a/src/chart-util.ts b/src/chart-util.ts index 30b8e67..534c49c 100644 --- a/src/chart-util.ts +++ b/src/chart-util.ts @@ -14,9 +14,9 @@ export const isNumber = (val: unknown): boolean => { * @param index number The index we should find a color for * @returns string The color at the specified index location in the palette */ -export const getColorFromPalette = (colorPalette: string[] | undefined, index: number): string | undefined => { +export const getColorFromPalette = (colorPalette: string[] | undefined, index: number, defaultColor: string): string => { if (colorPalette) return colorPalette[index % colorPalette.length]; - return undefined; + return defaultColor; }; /** @@ -58,20 +58,6 @@ export const extractColor = (color: string): string => { return `rgb(${r}, ${g}, ${b})`; } - // TODO: Get rid of this code when determined that we really won't support color names written like 'red', 'green', etc. - // TO.DO.CONT: If we do want to support such naming convention, this code works. - // // Handle named colors (like "red") - // const opt = new Option(); - // opt.style.color = color; - // const namedColor = opt.style.color; - // if (namedColor) { - // document.body.appendChild(opt); - // debugger; - // const computedColor = getComputedStyle(opt).color; - // document.body.removeChild(opt); - // return extractColor(computedColor); - // } - // As-is return color; }; diff --git a/src/chart.tsx b/src/chart.tsx index 8b01ff0..c717cb8 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -1,6 +1,9 @@ -import { Chart as ChartJS, ChartType, ChartOptions, ChartData, ChartDataset, registerables } from 'chart.js'; +import { Chart as ChartJS, ChartType, ChartOptions, ChartData, ChartDataset, registerables, ChartConfiguration, Plugin } from 'chart.js'; import { Chart as ChartReact } from 'react-chartjs-2'; import 'chartjs-adapter-moment'; +// Keeping the import line commented, until we decide to use it or code an equivalent. +// So that a dev can locally install it to work. This package is pretty useful to track this GeoChart Component. +// import { useWhatChanged, setUseWhatChange } from '@simbathesailor/use-what-changed'; import { GeoChartConfig, GeoChartAction, @@ -10,47 +13,101 @@ import { GeoChartOptionsUI, GeoChartQuery, GeoChartDatasource, - GeoChartSelectedDatasets, + GeoChartSelectedDataset, TypeJsonObject, StepsPossibilitiesConst, StepsPossibilities, DATE_OPTIONS_LONG, + GeoChartDatasetOption, } from './chart-types'; import { SchemaValidator, ValidatorResult } from './chart-schema-validator'; -import { createChartJSOptions, createChartJSData, fetchItemsViaQueryForDatasource } from './chart-parsing'; -import { isNumber, extractColor, downloadJson } from './chart-util'; +import { createChartJSOptions, createChartJSData, fetchItemsViaQueryForDatasource, setColorPalettes } from './chart-parsing'; +import { isNumber, downloadJson, getColorFromPalette } from './chart-util'; import { sxClasses } from './chart-style'; -import { log, LOG_LEVEL_MAXIMUM, LOG_LEVEL_HIGH, LOG_LEVEL_MEDIUM, LOG_LEVEL_LOW } from './logger'; +import { logLow, logUseEffectMount, logUseEffectUnmount } from './logger'; import T_EN from '../locales/en/translation.json'; import T_FR from '../locales/fr/translation.json'; +// Activate useWhatChanged in development (leaving the code commented, see header of file for reason) +// setUseWhatChange(process.env.NODE_ENV === 'development'); + /** - * Main props for the Chart + * Main props for the Chart. + * There are 2 main ways to create a chart: + * (1) Using the 'inputs' parameter which configures an elaborated GeoChart or; + * (2) Using the 'chart'+'options'+'data' parameters which creates a basic GeoChart with essential ChartJS parameters. */ export interface TypeChartChartProps< TType extends ChartType = ChartType, TData extends GeoDefaultDataPoint = GeoDefaultDataPoint, TLabel = string > { - sx?: unknown; // Will be casted as CSSProperties later via the imported cgpv react - inputs?: GeoChartConfig; // The official way to work with all GeoChart features - datasource?: GeoChartDatasource; // The selected datasource - schemaValidator: SchemaValidator; // The schemas validator object - chart?: TType; // When no inputs is specified, the GeoChart will use this chart props to work directly with ChartJS - options?: ChartOptions; // When no inputs is specified, the GeoChart will use these options props to work directly with ChartJS - data?: ChartData; // When no inputs is specified, the GeoChart will use this data props to work directly with ChartJS - action?: GeoChartAction; // Indicate an action, user interface related, to be performed by the component + // The schemas validator object + schemaValidator: SchemaValidator; + + // Will be casted as CSSProperties later via the imported cgpv react + sx?: unknown; + + // The official way to work with all GeoChart features + inputs?: GeoChartConfig; + + // The selected datasource (the selected value in the dropdown on top left corner of the ui) + datasource?: GeoChartDatasource; + + // When no inputs is specified, the GeoChart will use this chart props to work directly with ChartJS + chart?: TType; + // When no inputs is specified, the GeoChart will use this options props to work directly with ChartJS + options?: ChartOptions; + // When no inputs is specified, the GeoChart will use this data props to work directly with ChartJS + data?: ChartData; + + // Indicate an action, user interface related, to be performed by the component + action?: GeoChartAction; + + // The default colors to apply to the Chart look (essentially redirected to ChartJS) defaultColors?: GeoChartDefaultColors; + + // State indicating that the GeoChart is in 'loading' state isLoadingChart?: boolean; + + // State indicating that the GeoChart is in 'loading datasource' state isLoadingDatasource?: boolean; - onParsed?: (chart: TType, options: ChartOptions, data: ChartData) => void; + + // Callback executed when the data source changes (the selected value in the dropdown on top left corner of the ui) onDatasourceChanged?: (value: GeoChartDatasource | undefined, language: string) => void; - onDataChanged?: (dataIndex: number, dataLabel: string, checked: boolean) => void; + + // Callback executed when the checked dataset (legend) changes onDatasetChanged?: (datasetIndex: number, datasetLabel: string | undefined, checked: boolean) => void; + + // Callback executed, for the pie/doughnut chart only, when the checked data changes + onDataChanged?: (dataIndex: number, dataLabel: string, checked: boolean) => void; + + // Callback executed when user has changed the value on the slider on X axis onSliderXChanged?: (value: number | number[]) => void; + + // Callback executed when the value display for the X axis wants to show up + onSliderXValueDisplaying?: (value: number) => string; + + // Callback executed when user has changed the value on the slider on Y axis onSliderYChanged?: (value: number | number[]) => void; + + // Callback executed when the value display for the Y axis wants to show up + onSliderYValueDisplaying?: (value: number) => string; + + // Callback executed when the use has clicked the download button + onDownloadClicked?: (value: GeoChartDatasource, index: number) => string; + + // Callback executed when user has selected another steps value from the ui (top right corner in the ui at the time of writing) onStepSwitcherChanged?: (value: string) => void; - onError?: (validators: (ValidatorResult | undefined)[]) => void; + + // Callback executed when user has clicked the reset states button (top right corner in the ui at the time of writing) + onResetStates?: () => void; + + // Callback executed when the data coming from the inputs parameters have been parsed and is ready to redirect to ChartJS for rendering + onParsed?: (chart: TType, options: ChartOptions, data: ChartData) => void; + + // Callback executed when an error has happened + onError?: (error: string, exception: unknown | undefined) => void; } /** @@ -99,13 +156,17 @@ export function GeoChart< defaultColors, isLoadingChart, isLoadingDatasource: parentLoadingDatasource, - onParsed, onDatasourceChanged, onDataChanged, onDatasetChanged, onSliderXChanged, + onSliderXValueDisplaying, onSliderYChanged, + onSliderYValueDisplaying, + onDownloadClicked, onStepSwitcherChanged, + onResetStates, + onParsed, onError, } = props; @@ -115,35 +176,37 @@ export function GeoChart< // Cast the style const sx = elStyle as typeof CSSProperties; - // Attribute the ChartJS default colors - if (defaultColors?.backgroundColor) ChartJS.defaults.backgroundColor = defaultColors?.backgroundColor; - if (defaultColors?.borderColor) ChartJS.defaults.borderColor = defaultColors?.borderColor; - if (defaultColors?.color) ChartJS.defaults.color = defaultColors?.color; - // #region USE STATE SECTION **************************************************************************************** // TODO: Refactor - Check why the useState and useCallback coming from cgpv lose their generic capabilities. // TO.DO.CONT: This is rather problematic. It forces the devs to explicitely use some "not so pretty" type assertions // so that things remain typed instead of becoming 'any' when using functions such as 'useState', 'useCallback', 'useRef', etc. + // Inner component states attached to the parent component const [inputs, setInputs] = useState(parentInputs) as [ GeoChartConfig | undefined, React.Dispatch | undefined> ]; const [chartType, setChartType] = useState(parentChart!) as [TType, React.Dispatch]; - const [selectedDatasource, setSelectedDatasource] = useState(parentDatasource) as [ - GeoChartDatasource | undefined, - React.Dispatch - ]; - const [chartOptions, setChartOptions] = useState(parentOptions!) as [ChartOptions, React.Dispatch>]; const [chartData, setChartData] = useState(parentData!) as [ ChartData, React.Dispatch> ]; - const [action, setAction] = useState() as [GeoChartAction, React.Dispatch]; - const [selectedDatasets, setSelectedDatasets] = useState({}) as [GeoChartSelectedDatasets, React.Dispatch]; - const [selectedDatas, setSelectedDatas] = useState({}) as [GeoChartSelectedDatasets, React.Dispatch]; + const [chartOptions, setChartOptions] = useState(parentOptions!) as [ + ChartOptions | undefined, + React.Dispatch | undefined> + ]; + const [selectedDatasource, setSelectedDatasource] = useState(parentDatasource) as [ + GeoChartDatasource | undefined, + React.Dispatch + ]; const [redraw, setRedraw] = useState(parentAction?.shouldRedraw) as [boolean | undefined, React.Dispatch]; + const [isLoadingDatasource, setIsLoadingDatasource] = useState(parentLoadingDatasource) as [boolean, React.Dispatch]; + + // Inner component states unrelated to the parent component + const [action, setAction] = useState() as [GeoChartAction, React.Dispatch]; + const [datasetRegistry, setDatasetRegistry] = useState({}) as [GeoChartSelectedDataset, React.Dispatch]; + const [datasRegistry, setDatasRegistry] = useState({}) as [GeoChartSelectedDataset, React.Dispatch]; const [filteredRecords, setFilteredRecords] = useState() as [TypeJsonObject[] | undefined, React.Dispatch]; const [xSliderMin, setXSliderMin] = useState(0) as [number, React.Dispatch]; const [xSliderMax, setXSliderMax] = useState(0) as [number, React.Dispatch]; @@ -156,17 +219,29 @@ export function GeoChart< const [validatorInputs, setValidatorInputs] = useState() as [ValidatorResult | undefined, React.Dispatch]; const [validatorOptions, setValidatorOptions] = useState() as [ValidatorResult | undefined, React.Dispatch]; const [validatorData, setValidatorData] = useState() as [ValidatorResult | undefined, React.Dispatch]; - - let stepsSwitcher: StepsPossibilities = false; // False by default - if (inputs && inputs!.geochart.useSteps) stepsSwitcher = inputs!.geochart.useSteps; - const [selectedSteps, setSelectedSteps] = useState(stepsSwitcher) as [ - StepsPossibilities | undefined, - React.Dispatch + const [selectedSteps, setSelectedSteps] = useState(inputs?.geochart.useSteps || false) as [ + StepsPossibilities, + React.Dispatch ]; + const [plugins, setPlugins] = useState() as [Plugin[] | undefined, React.Dispatch[] | undefined>]; + const [colorPaletteCategoryBackgroundIndex, setColorPaletteCategoryBackgroundIndex] = useState(0) as [number, React.Dispatch]; + const [colorPaletteCategoryBorderIndex, setColorPaletteCategoryBorderIndex] = useState(0) as [number, React.Dispatch]; + const [colorPaletteAxisBackgroundIndex, setColorPaletteAxisBackgroundIndex] = useState(0) as [number, React.Dispatch]; + const [colorPaletteAxisBorderIndex, setColorPaletteAxisBorderIndex] = useState(0) as [number, React.Dispatch]; - const [isLoadingDatasource, setIsLoadingDatasource] = useState(parentLoadingDatasource) as [boolean, React.Dispatch]; + const chartRef = useRef() as React.MutableRefObject>; + + // #endregion - const chartRef = useRef() as React.MutableRefObject>; + // #region DEFAULTS SECTION ***************************************************************************************** + + // Attribute the ChartJS default colors + if (defaultColors?.backgroundColor) ChartJS.defaults.backgroundColor = defaultColors?.backgroundColor; + if (defaultColors?.borderColor) ChartJS.defaults.borderColor = defaultColors?.borderColor; + if (defaultColors?.color) ChartJS.defaults.color = defaultColors?.color; + + // Attribute the color palettes + setColorPalettes(inputs); // #endregion @@ -180,18 +255,22 @@ export function GeoChart< const processAxes = ( geochart: GeoChartOptionsGeochart, uiOptions: GeoChartOptionsUI | undefined, - datasourceItems: TypeJsonObject[] + datasourceItems: TypeJsonObject[] | undefined ): (number | undefined)[] => { // If has a xSlider and property and numbers as property let xMinVal = uiOptions?.xSlider?.min; let xMaxVal = uiOptions?.xSlider?.max; if (uiOptions?.xSlider?.display) { // If using numbers as data value - if (datasourceItems && datasourceItems.length > 0 && isNumber(datasourceItems![0][geochart.xAxis.property])) { + if (datasourceItems && datasourceItems.length > 0) { // If either min or max isn't preset if (xMinVal === undefined || xMaxVal === undefined) { // Dynamically calculate them const values = datasourceItems!.map((x: TypeJsonObject) => { + // If date + if (geochart.xAxis.type === 'time' || geochart.xAxis.type === 'timeseries') { + return new Date(x[geochart.xAxis.property] as string).getTime(); + } return x[geochart.xAxis.property] as number; }); xMinVal = xMinVal !== undefined ? xMinVal : Math.floor(Math.min(...values)); @@ -241,35 +320,40 @@ export function GeoChart< * @param theYSliderValues number[] | undefined */ const processAxesValues = ( + uiOptions: GeoChartOptionsUI | undefined, xMinVal: number | undefined, xMaxVal: number | undefined, yMinVal: number | undefined, yMaxVal: number | undefined, theXSliderValues: number[] | undefined, theYSliderValues: number[] | undefined - ): [boolean, number[]] => { + ): [boolean, (number | undefined)[]] => { // If still not set let valuesComeFromState: boolean = false; - if (xMaxVal && !theXSliderValues) { - // Set the values for x axis to min/max - setXSliderValues([xMinVal!, xMaxVal!]); - } else if (theXSliderValues) { - // eslint-disable-next-line no-param-reassign - [xMinVal, xMaxVal] = theXSliderValues; - valuesComeFromState = true; + if (uiOptions?.xSlider?.display) { + if (xMaxVal && !theXSliderValues) { + // Set the values for x axis to min/max + setXSliderValues([xMinVal!, xMaxVal!]); + } else if (theXSliderValues) { + // eslint-disable-next-line no-param-reassign + [xMinVal, xMaxVal] = theXSliderValues; + valuesComeFromState = true; + } } // If still not set - if (yMaxVal && !theYSliderValues) { - // Set the state - setYSliderValues([yMinVal!, yMaxVal!]); - } else if (theYSliderValues) { - // eslint-disable-next-line no-param-reassign - [yMinVal, yMaxVal] = theYSliderValues; - valuesComeFromState = true; + if (uiOptions?.ySlider?.display) { + if (yMaxVal && !theYSliderValues) { + // Set the state + setYSliderValues([yMinVal!, yMaxVal!]); + } else if (theYSliderValues) { + // eslint-disable-next-line no-param-reassign + [yMinVal, yMaxVal] = theYSliderValues; + valuesComeFromState = true; + } } // Return if the values were set - return [valuesComeFromState, [xMinVal!, xMaxVal!, yMinVal!, yMaxVal!]]; + return [valuesComeFromState, [xMinVal, xMaxVal, yMinVal, yMaxVal]]; }; /** @@ -279,15 +363,20 @@ export function GeoChart< */ const fetchDatasourceItems = async ( chartQuery: GeoChartQuery, - sourceItem: TypeJsonObject, - language: string + language: string, + sourceItem: TypeJsonObject | undefined, + errorCallback: ((error: string, exception: unknown | undefined) => void) | undefined ): Promise => { try { // Loading setIsLoadingDatasource(true); // Fetch the items for the data source in question - return await fetchItemsViaQueryForDatasource(chartQuery, sourceItem, language); + return await fetchItemsViaQueryForDatasource(chartQuery, language, sourceItem); + } catch (ex) { + // Error + errorCallback?.('Failed to fetch the data', ex); + return Promise.resolve([]); } finally { // Done setIsLoadingDatasource(false); @@ -321,193 +410,262 @@ export function GeoChart< // #endregion - // #region EVENT HANDLERS SECTION *********************************************************************************** + // #region HOOKS USE CALLBACK GEOCHART SECTION ********************************************************************** /** - * Handles when the Datasource changes - * @param e Event The Select change event - * @param item MenuItem The selected MenuItem - */ - const handleDatasourceChanged = async (e: Event, item: typeof MenuItem): Promise => { - // Find the selected datasource reference based on the MenuItem - const ds: GeoChartDatasource | undefined = inputs!.datasources.find((x) => { - return (x.value || x.display) === item.props.value; - }); - if (!ds) return; - - // If the data source has no items - if (!ds.items) { - ds.items = await fetchDatasourceItems(inputs!.query!, ds.sourceItem, i18n.language); - } - - // Set the selected datasource - setSelectedDatasource(ds); - - // Callback - onDatasourceChanged?.(ds, i18n.language); - }; - - /** - * Handles when a dataset was checked/unchecked (via the legend) - * @param datasetIndex number Indicates the dataset index that was checked/unchecked - * @param datasetLabel string | undefined Indicates the dataset label that was checked/unchecked - * @param checked boolean Indicates the checked state + * Updates the selected datasets object in synch with the actual datasets read from the data. + * @param theChartData ChartData */ - const handleDatasetChecked = (datasetIndex: number, datasetLabel: string | undefined, checked: boolean): void => { - // Keep track - selectedDatasets[datasetLabel!] = checked; + const processDatasets = useCallback( + ( + items: TypeJsonObject[] | undefined, + catPropertyName: string | undefined, + paletteBackgrounds: string[] | undefined, + paletteBorders: string[] | undefined + ): void => { + // Check + if (!items || !catPropertyName) return; + + // Loop on the items + let oneSelectedDatasetUpdated = false; + const catNames: string[] = []; + let backgroundIndex = colorPaletteCategoryBackgroundIndex; + let borderIndex = colorPaletteCategoryBorderIndex; + items?.forEach((item: TypeJsonObject) => { + // Read the category as a string + const catName = item[catPropertyName] as string; + + // Build list + if (!catNames.includes(catName)) catNames.push(catName); - // Update - setSelectedDatasets({ ...selectedDatasets }); - - // Callback - onDatasetChanged?.(datasetIndex, datasetLabel, checked); - }; - - /** - * Handles when a data was checked/unchecked (via the legend). This is only used by Pie and Doughnut Charts. - * @param dataIndex number Indicates the data index that was checked/unchecked - * @param dataLabel string Indicates the data label that was checked/unchecked - * @param checked boolean Indicates the checked state - */ - const handleDataChecked = (dataIndex: number, dataLabel: string, checked: boolean): void => { - // Keep track - selectedDatas[dataLabel] = checked; + // If not set + if (datasetRegistry[catName] === undefined) { + // eslint-disable-next-line no-param-reassign + datasetRegistry[catName] = { + visible: true, + checked: true, + backgroundColor: getColorFromPalette(paletteBackgrounds, backgroundIndex, ChartJS.defaults.color as string), + borderColor: getColorFromPalette(paletteBorders, borderIndex, ChartJS.defaults.color as string), + }; + backgroundIndex++; + borderIndex++; + oneSelectedDatasetUpdated = true; + } - // Update - setSelectedDatas({ ...selectedDatas }); + // If not visible, make sure it's visible + if (!datasetRegistry[catName].visible) { + // eslint-disable-next-line no-param-reassign + datasetRegistry[catName].visible = true; + oneSelectedDatasetUpdated = true; + } + }); + setColorPaletteCategoryBackgroundIndex(backgroundIndex); + setColorPaletteCategoryBorderIndex(borderIndex); + + // For any categories that weren't found, make sure they're invisible + Object.keys(datasetRegistry).forEach((catName: string) => { + if (!catNames.includes(catName) && datasetRegistry[catName].visible) { + // eslint-disable-next-line no-param-reassign + datasetRegistry[catName].visible = false; + oneSelectedDatasetUpdated = true; + } + }); - // Callback - onDataChanged?.(dataIndex, dataLabel, checked); - }; + // If at least one dataset was updated (prevents loopback) + if (oneSelectedDatasetUpdated) { + setDatasetRegistry({ ...datasetRegistry }); + } + }, + [datasetRegistry, colorPaletteCategoryBackgroundIndex, colorPaletteCategoryBorderIndex] + ) as ( + items: TypeJsonObject[] | undefined, + catPropertyName: string | undefined, + paletteBackgrounds: string[] | undefined, + paletteBorders: string[] | undefined + ) => void; /** - * Handles when the X Slider changes - * @param value number | number[] Indicates the slider value + * Updates the selected data object in synch with the actual labels read from the data. + * @param theChartData ChartData */ - const handleSliderXChange = (newValue: number | number[]): void => { - // Set the X State - setXSliderValues(newValue); + const processLabels = useCallback( + ( + theChartType: string, + items: TypeJsonObject[] | undefined, + labelPropertyName: string | undefined, + paletteBackgrounds: string[] | undefined, + paletteBorders: string[] | undefined + ): void => { + // Check + if (!items || !labelPropertyName) return; + + // Only working on pie or doughnut + if (theChartType === 'pie' || theChartType === 'doughnut') { + // Loop on the items + let oneSelectedDataUpdated = false; + const labelNames: string[] = []; + let backgroundIndex = colorPaletteAxisBackgroundIndex; + let borderIndex = colorPaletteAxisBorderIndex; + items?.forEach((item: TypeJsonObject) => { + // Read the label as a string + const labelName = item[labelPropertyName] as string; + + // Build list + if (!labelNames.includes(labelName)) labelNames.push(labelName); + + // If not set + if (datasRegistry[labelName] === undefined) { + // eslint-disable-next-line no-param-reassign + datasRegistry[labelName] = { + visible: true, + checked: true, + backgroundColor: getColorFromPalette(paletteBackgrounds, backgroundIndex, ChartJS.defaults.color as string), + borderColor: getColorFromPalette(paletteBorders, borderIndex, ChartJS.defaults.color as string), + }; + backgroundIndex++; + borderIndex++; + oneSelectedDataUpdated = true; + } - // Callback - onSliderXChanged?.(newValue); - }; + // If not visible, make sure it's visible + if (!datasRegistry[labelName].visible) { + // eslint-disable-next-line no-param-reassign + datasRegistry[labelName].visible = true; + oneSelectedDataUpdated = true; + } + }); - /** - * Handles when the Y Slider changes - * @param value number | number[] Indicates the slider value - */ - const handleSliderYChange = (newValue: number | number[]): void => { - // Set the Y State - setYSliderValues(newValue); + // For any categories that weren't found, make sure they're invisible + Object.keys(datasRegistry).forEach((labelName: string) => { + if (!labelNames.includes(labelName) && datasRegistry[labelName].visible) { + // eslint-disable-next-line no-param-reassign + datasRegistry[labelName].visible = false; + oneSelectedDataUpdated = true; + } + }); - // Callback - onSliderYChanged?.(newValue); - }; + // If at least one dataset was updated (prevents loopback) + if (oneSelectedDataUpdated) { + setDatasRegistry({ ...datasRegistry }); + } + setColorPaletteAxisBackgroundIndex(backgroundIndex); + setColorPaletteAxisBorderIndex(borderIndex); + } + }, + [datasRegistry, colorPaletteAxisBackgroundIndex, colorPaletteAxisBorderIndex] + ) as ( + theChartType: string, + items: TypeJsonObject[] | undefined, + labelPropertyName: string | undefined, + paletteBackgrounds: string[] | undefined, + paletteBorders: string[] | undefined + ) => void; /** - * Handles when the Steps Switcher changes - * @param value string Indicates the steps value + * Updates the chart dataset visibility based on the currently selected datasets. + * @param theChartData ChartData + * @param theSelectedDatasets GeoChartSelectedDatasets */ - const handleStepsSwitcherChanged = (e: unknown, item: typeof MenuItem): void => { - // Set the step switcher - setSelectedSteps(item.props.value); - - // Callback - onStepSwitcherChanged?.(item.props.value); - }; + const updateDatasetVisibilityUsingState = useCallback( + (theChartRef: ChartJS, theDatasetRegistry: GeoChartSelectedDataset): void => { + if (!theChartRef) return; - /** - * Handles when the States must be cleared - */ - const handleResetStates = (): void => { - // Reset all selected datasets to true - Object.keys(selectedDatasets).forEach((dataset: string) => { - selectedDatasets[dataset] = true; - }); + // Get the current dataset labels + const dsLabels = theChartRef.data.datasets.map((x: ChartDataset) => { + return x.label!; + }); - // Reset all selected datasets to true - Object.keys(selectedDatas).forEach((data: string) => { - selectedDatas[data] = true; - }); + // Make sure the datasets visibility follow the state + Object.keys(theDatasetRegistry).forEach((value: string) => { + const idx = dsLabels.indexOf(value); + if (idx >= 0) theChartRef.setDatasetVisibility(idx, theDatasetRegistry[value]?.checked); + }); - // Clear all states - setSelectedDatasets({ ...selectedDatasets }); - setSelectedDatas({ ...selectedDatas }); - setSelectedSteps(false); - setXSliderValues([xSliderMin, xSliderMax]); - setYSliderValues([ySliderMin, ySliderMax]); - }; + // Update visibility + theChartRef.update(); + }, + [] + ) as (theChartRef: ChartJS, theDatasetRegistry: GeoChartSelectedDataset) => void; /** - * Handles the display of the label on the X Slider - * @param value number | number[] Indicates the slider value + * Updates the chart data visibility based on the currently selected data. + * @param theChartData ChartData + * @param theSelectedData GeoChartSelectedDatasets */ - const handleSliderXValueDisplay = (value: number): string => { - // If current chart has time as xAxis - if (inputs?.geochart?.xAxis.type === 'time' || inputs?.geochart?.xAxis.type === 'timeseries') { - return new Date(value).toLocaleDateString(i18n.language, DATE_OPTIONS_LONG); - } - - // Default - return value.toString(); - }; + const updateDataVisibilityUsingState = useCallback( + (theChartRef: ChartJS, theDatasRegistry: GeoChartSelectedDataset): void => { + // Check + if (!theChartRef) return; + + // The Config + const chartConf = theChartRef.config as ChartConfiguration; + + // Only working on pie or doughnut + if (chartConf.type === 'pie' || chartConf.type === 'doughnut') { + // Make sure the datas visibility follow the state + theChartRef.data.labels?.forEach((value: TLabel) => { + const idx = theChartRef.data.labels!.indexOf(value); + const currVis = theChartRef.getDataVisibility(idx); + if (theDatasRegistry[value]?.checked !== currVis) { + theChartRef.toggleDataVisibility(idx); + } + }); - /** - * Handles the display of the label on the Y Slider - * @param value number | number[] Indicates the slider value - */ - const handleSliderYValueDisplay = (value: number): string => { - // Default - return value.toString(); - }; + // Update visibility + theChartRef.update(); + } + }, + [] + ) as (theChartRef: ChartJS, theDatasRegistry: GeoChartSelectedDataset) => void; /** - * Handles when the download button is clicked - * @param value number Indicates the button drop down selection when it was clicked + * Essential function to load the records in the Chart. + * @param datasource GeoChartDatasource The Datasource on which the records were grabbed + * @param records TypeJsonObject[] The actual records to load in the Chart. */ - const handleDownloadClick = (index: number): void => { - // Get the data - const data = { ...selectedDatasource! }; - - // If only the filtered information - if (index === 0) { - data.items = filteredRecords; - - // If using categories - if (inputs?.category) { - // The selected datasets strings - const selDatasetsStrings = Object.keys(selectedDatasets).filter((ds) => { - return selectedDatasets[ds]; - }); - - // Also filter on the selected datasets - data.items = data.items?.filter((value: TypeJsonObject) => { - return selDatasetsStrings.includes(value[inputs!.category!.property] as string); - }); - - // In case of pie/doughnut - if (chartType === 'pie' || chartType === 'doughnut') { - // The selected datas strings - const selDatasStrings = Object.keys(selectedDatas).filter((ds) => { - return selectedDatas[ds]; - }); - - // Also filter on selected datas - data.items = data.items?.filter((value: TypeJsonObject) => { - return selDatasStrings.includes(value[inputs!.geochart.xAxis.property] as string); - }); - } - } - } + const processLoadingRecords = useCallback( + ( + theInputs: GeoChartConfig, + theDatasetRegistry: GeoChartSelectedDataset, + theDatasRegistry: GeoChartSelectedDataset, + theLanguage: string, + theSteps: StepsPossibilities, + records: TypeJsonObject[] | undefined + ): void => { + // Parse the data + const parsedOptions = createChartJSOptions(theInputs, parentOptions!, theLanguage); + const parsedData = createChartJSData( + theInputs, + theDatasetRegistry, + theDatasRegistry, + theSteps, + records, + parentData! + ); - // Download the data as json - downloadJson(data, 'chart-data.json'); - }; + // Callback + onParsed?.(theInputs!.chart, parsedOptions, parsedData); - // #endregion + // Override + setChartType(theInputs!.chart); + setChartOptions(parsedOptions); + setChartData(parsedData); - // #region HOOKS SECTION ******************************************************************************************** + // If the resulting datasets array is empty, force a redraw action, otherwise ChartJS hangs on the last shown graphic + if (parsedData.datasets?.length === 0) setAction({ shouldRedraw: true }); + }, + // NO REACT for 'onParsed' (explicitely excluding it here instead of relying on + // the parent component to have used useCallback as they should have) + // eslint-disable-next-line react-hooks/exhaustive-deps + [parentOptions, parentData] + ) as ( + theInputs: GeoChartConfig, + theDatasetRegistry: GeoChartSelectedDataset, + theDatasRegistry: GeoChartSelectedDataset, + theLanguage: string, + theSteps: StepsPossibilities, + records: TypeJsonObject[] | undefined + ) => void; /** * Helper function to filter datasource items based on 2 possible and independent axis. @@ -516,10 +674,19 @@ export function GeoChart< * @param xValues number | number[] The values in X to filter on * @param yValues number | number[] The values in Y to filter on */ - const processFiltering = useCallback( - (theInputs: GeoChartConfig, datasourceItems: TypeJsonObject[], xValues: number | number[], yValues: number | number[]): void => { + const processLoadingRecordsFilteringFirst = useCallback( + ( + theInputs: GeoChartConfig, + theDatasetRegistry: GeoChartSelectedDataset, + theDatasRegistry: GeoChartSelectedDataset, + theLanguage: string, + theSteps: StepsPossibilities, + records: TypeJsonObject[] | undefined, + xValues: number | number[], + yValues: number | number[] + ): void => { // If chart type is line - let resItemsFinal: TypeJsonObject[] = [...datasourceItems!]; + let resItemsFinal: TypeJsonObject[] = records ? [...records] : []; if (theInputs?.chart === 'line') { // If filterings on x supported if (Array.isArray(xValues) && xValues.length === 2) { @@ -530,7 +697,7 @@ export function GeoChart< const theDateTo = new Date(xValues[1]); // Filter the datasourceItems - resItemsFinal = datasourceItems!.filter((item: TypeJsonObject) => { + resItemsFinal = records!.filter((item: TypeJsonObject) => { const d = new Date(item[theInputs.geochart.xAxis.property] as string); return theDateFrom <= d && d <= theDateTo; }); @@ -540,7 +707,7 @@ export function GeoChart< const to = xValues[1]; // Filter the datasourceItems - resItemsFinal = datasourceItems!.filter((item: TypeJsonObject) => { + resItemsFinal = records!.filter((item: TypeJsonObject) => { return ( from <= (item[theInputs.geochart.xAxis.property] as number) && (item[theInputs.geochart.xAxis.property] as number) <= to ); @@ -560,172 +727,62 @@ export function GeoChart< } } + // Filter + processLoadingRecords(theInputs, theDatasetRegistry, theDatasRegistry, theLanguage, theSteps, resItemsFinal); + // Set new filtered inputs setFilteredRecords(resItemsFinal); }, - [] + [processLoadingRecords] ) as ( theInputs: GeoChartConfig, - datasourceItems: TypeJsonObject[], + theDatasetRegistry: GeoChartSelectedDataset, + theDatasRegistry: GeoChartSelectedDataset, + theLanguage: string, + theSteps: StepsPossibilities, + records: TypeJsonObject[] | undefined, xValues: number | number[], yValues: number | number[] ) => void; - /** - * Updates the selected datasets object in synch with the actual datasets read from the data. - * @param theChartData ChartData - */ - const processLoadingRecordsUpdateDatasets = useCallback((theChartData: ChartData): void => { - // Get the dataset labels for the new data - let oneSelectedDatasetNew = false; - theChartData.datasets - .map((x: ChartDataset) => { - return x.label!; - }) - .forEach((label: string) => { - // If not set - if (selectedDatasets[label] === undefined) { - selectedDatasets[label] = true; - oneSelectedDatasetNew = true; - } - }); - - // If at least one dataset was new - if (oneSelectedDatasetNew) setSelectedDatasets({ ...selectedDatasets }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) as (theChartData: ChartData) => void; // No need for the selectedDatasets dependency (for performance) - - /** - * Updates the selected data object in synch with the actual labels read from the data. - * @param theChartData ChartData - */ - const processLoadingRecordsUpdateLabels = useCallback((theChartData: ChartData): void => { - // Get the labels for the new data - let oneSelectedDataNew = false; - theChartData.labels?.forEach((label: string) => { - // If not set - if (selectedDatas[label] === undefined) { - selectedDatas[label] = true; - oneSelectedDataNew = true; - } - }); - - // If at least one dataset was new - if (oneSelectedDataNew) setSelectedDatas({ ...selectedDatas }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) as (theChartData: ChartData) => void; // No need for the selectedDatas dependency (for performance) - - /** - * Updates the chart dataset visibility based on the currently selected datasets. - * @param theChartData ChartData - * @param theSelectedDatasets GeoChartSelectedDatasets - */ - const updateDatasetVisibilityUsingState = useCallback( - (theChartData: ChartData, theSelectedDatasets: GeoChartSelectedDatasets): void => { - // Log - if (!chartRef.current) return; - - // Get the current dataset labels - const dsLabels = theChartData.datasets.map((x: ChartDataset) => { - return x.label!; - }); - - // Make sure the datasets visibility follow the state - Object.keys(theSelectedDatasets).forEach((value: string) => { - chartRef.current.setDatasetVisibility(dsLabels.indexOf(value), theSelectedDatasets[value]); - }); + // #endregion - // Update visibility - chartRef.current.update(); - }, - [] - ) as (theChartData: ChartData, theSelectedDatasets: GeoChartSelectedDatasets) => void; + // #region HOOKS USE CALLBACK CHARTJS SECTION *********************************************************************** /** - * Updates the chart data visibility based on the currently selected data. - * @param theChartData ChartData - * @param theSelectedData GeoChartSelectedDatasets + * Handles when the ChartJS has finished initializing and before it started drawing data. + * @param chart ChartJS The ChartJS reference. */ - const updateDataVisibilityUsingState = useCallback( - (theChartType: TType, theChartData: ChartData, theSelectedDatas: GeoChartSelectedDatasets): void => { + const handleChartJSAfterInit = useCallback( + (chart: ChartJS): void => { // Log - if (!chartRef.current) return; - if (theChartType !== 'pie' && theChartType !== 'doughnut') return; - - // Make sure the datas visibility follow the state - theChartData.labels?.forEach((value: TLabel) => { - const idx = theChartData.labels!.indexOf(value); - const currVis = chartRef.current.getDataVisibility(idx); - if (theSelectedDatas[value] !== currVis) { - chartRef.current.toggleDataVisibility(idx); - } - }); + logLow('CHARTJS AFTER INIT', chart, datasetRegistry); - // Update visibility - chartRef.current.update(); + // Make sure the UI fits with the registry state before the first render is made. Mostly useful for pie/doughnut charts. + updateDatasetVisibilityUsingState(chart, datasetRegistry); + updateDataVisibilityUsingState(chart, datasRegistry); }, - [] - ) as (theChartType: TType, theChartData: ChartData, theSelectedDatas: GeoChartSelectedDatasets) => void; + [datasRegistry, datasetRegistry, updateDataVisibilityUsingState, updateDatasetVisibilityUsingState] + ); - /** - * Essential function to load the records in the Chart. - * @param datasource GeoChartDatasource The Datasource on which the records were grabbed - * @param records TypeJsonObject[] The actual records to load in the Chart. - */ - const processLoadingRecords = useCallback( - (theInputs: GeoChartConfig, theLanguage: string, theSteps: StepsPossibilities, records: TypeJsonObject[] | undefined): void => { - // Parse the data - const parsedOptions = createChartJSOptions(theInputs, parentOptions!, theLanguage); - const parsedData = createChartJSData(theInputs, theSteps, records, parentData!); - - // Callback - onParsed?.(theInputs!.chart, parsedOptions, parsedData); - - // Override - setChartType(theInputs!.chart); - setChartOptions(parsedOptions); - setChartData(parsedData); - - // Update the selected datasets - processLoadingRecordsUpdateDatasets(parsedData); - processLoadingRecordsUpdateLabels(parsedData); - - // If the resulting datasets array is empty, force a redraw action, otherwise ChartJS hands on the last shown graphic - if (parsedData.datasets?.length === 0) setAction({ shouldRedraw: true }); - }, - [parentData, parentOptions, onParsed, processLoadingRecordsUpdateDatasets, processLoadingRecordsUpdateLabels] - ) as (theInputs: GeoChartConfig, theLanguage: string, theSteps: StepsPossibilities, records: TypeJsonObject[] | undefined) => void; - - /** - * Makes sure the datasource items are initialized correctly for the first load of the Chart. - * @param datasource GeoChartDatasource The Datasource on which the records were grabbed - * @param records TypeJsonObject[] The actual records to load in the Chart. - */ - const initDatasourceItems = useCallback( - async (chartQuery: GeoChartQuery | undefined, ds: GeoChartDatasource, language: string): Promise => { - // If no items - const datasourceToLoad = ds; - if (!ds.items) { - // Must fetch straight away - datasourceToLoad.items = await fetchDatasourceItems(chartQuery!, ds.sourceItem, language); - } + // #endregion - // Set the datasource - setSelectedDatasource(datasourceToLoad); - }, - [] - ) as (chartQuery: GeoChartQuery | undefined, ds: GeoChartDatasource, language: string) => Promise; + // #region HOOKS USE EFFECT PARENT COMP SECTION ********************************************************************************* // Effect hook when the inputs change - coming from parent component. useEffect(() => { // Log - log(LOG_LEVEL_LOW, 'USE EFFECT PARENT INPUTS', parentInputs); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - PARENT - INPUTS'; + logUseEffectMount(USE_EFFECT_FUNC, parentInputs); // Refresh the inputs in this component setInputs(parentInputs); - // Clear the selected datasource, because we're cleaning house + // Clear dependency states because we're cleaning house and until the selected datasource is + // property reset, inputs might be unrelated to the selected datasource in the other useEffects. setSelectedDatasource(undefined); + setChartData(GeoChart.defaultProps.data as ChartData); + setChartOptions(GeoChart.defaultProps.options as ChartOptions); // If parentInputs is specified if (parentInputs) { @@ -735,60 +792,174 @@ export function GeoChart< return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PARENT INPUTS - UNMOUNT', parentInputs); + logUseEffectUnmount(USE_EFFECT_FUNC, parentInputs); }; }, [parentInputs, schemaValidator]); + // Effect hook when the main props about charttype, options and data change - coming from parent component. + useEffect(() => { + // Log + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - PARENT - CHARTJS INPUTS'; + logUseEffectMount(USE_EFFECT_FUNC); + + // Override + setChartType(parentChart!); + setChartOptions(parentOptions!); + setChartData(parentData!); + + return () => { + // Log + logUseEffectUnmount(USE_EFFECT_FUNC); + }; + }, [parentChart, parentOptions, parentData]); + + // Effect hook when the selected datasource changes - coming from parent component. + useEffect(() => { + // Log + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - PARENT - DATASOURCE'; + logUseEffectMount(USE_EFFECT_FUNC, parentDatasource); + + // Set the datasource as provided + setSelectedDatasource(parentDatasource); + + return () => { + // Log + logUseEffectUnmount(USE_EFFECT_FUNC, parentDatasource); + }; + }, [parentDatasource]); + + // Effect hook to be executed with loading datasource - coming from parent component. + useEffect(() => { + // Log + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - PARENT - LOADING DATASOURCE'; + logUseEffectMount(USE_EFFECT_FUNC, parentLoadingDatasource); + + // If defined, update the state + if (parentLoadingDatasource !== undefined) setIsLoadingDatasource(parentLoadingDatasource); + + return () => { + // Log + logUseEffectUnmount(USE_EFFECT_FUNC); + }; + }, [parentLoadingDatasource]); + + // Effect hook when an action needs to happen - coming from parent component. + useEffect(() => { + // Log + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - PARENT - ACTION'; + logUseEffectMount(USE_EFFECT_FUNC, parentAction); + + // Set action for the component + if (parentAction) setAction(parentAction); + + return () => { + // Log + logUseEffectUnmount(USE_EFFECT_FUNC); + }; + }, [parentAction]); + + // #endregion + + // #region HOOKS USE EFFECT CURRENT COMP SECTION ********************************************************************************* + + // Effect hook ran once when initializing + useEffect(() => { + const plugin = { + id: 'geochart-chartjs-plugin', + afterInit: (chartEvent: unknown): void => handleChartJSAfterInit(chartEvent as ChartJS), + }; + + // Register + setPlugins([plugin]); + }, [handleChartJSAfterInit]); + // Effect hook when the inputs change - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT INPUTS', inputs); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - INPUTS'; + logUseEffectMount(USE_EFFECT_FUNC, inputs); + + // Async function to fetch data from within a sync useEffect :| + const fetchAndSetSelectedDatasource = async (query: GeoChartQuery, language: string, datasource: GeoChartDatasource): Promise => { + // Perform the fetch + // eslint-disable-next-line no-param-reassign + datasource.items = await fetchDatasourceItems(query, language, datasource.sourceItem, onError); + + // Set the datasource + setSelectedDatasource(datasource); + }; - // Reset selected datasets (leave the code there, tentatively for now) - // setSelectedDatasets({}); + // If no steps switcher, reset the state of the useSteps to the config, we don't want to be stuck on a setting set by a ui which may not exist anymore + if (!inputs?.ui?.stepsSwitcher) setSelectedSteps(inputs?.geochart.useSteps || false); // If no datasources on the inputs, create a default one - if (inputs) { + if (inputs && inputs.datasources && inputs.datasources.length > 0) { + // The datasource to load on start + const ds = inputs.datasources[0]; + // Init the datasource items for the first record and sets it - initDatasourceItems(inputs!.query, inputs!.datasources[0], i18n.language); + if (!ds.items && inputs.query) { + // Must fetch straight away + fetchAndSetSelectedDatasource(inputs.query, i18n.language, ds); + } else setSelectedDatasource(ds); } else setSelectedDatasource(undefined); return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT INPUTS - UNMOUNT', inputs); + logUseEffectUnmount(USE_EFFECT_FUNC, inputs); }; - }, [inputs, i18n.language, initDatasourceItems]); + // NO REACT for 'onError' (explicitely excluding it here instead of relying on + // the parent component to have used useCallback as they should have) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputs, i18n.language]); - // Effect hook when the selected datasource changes - coming from parent component. + // Effect hook when the selected datasource changes - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_LOW, 'USE EFFECT PARENT DATASOURCE', parentDatasource); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - PROCESS DATA - REGISTRY'; + logUseEffectMount(USE_EFFECT_FUNC, inputs, selectedDatasource); - // Set the datasource as provided - setSelectedDatasource(parentDatasource); + // If selectedDatasource is specified + if (inputs && selectedDatasource) { + // Update the Datasets Registry based on the chart information + processDatasets( + selectedDatasource.items, + inputs.category?.property, + inputs.category?.paletteBackgrounds, + inputs.category?.paletteBorders + ); + + // Update the Datas/Labels Registry based on the chart information + processLabels( + inputs.chart, + selectedDatasource.items, + inputs.geochart.xAxis.property, + inputs.geochart.xAxis.paletteBackgrounds, + inputs.geochart.xAxis.paletteBorders + ); + } return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PARENT DATASOURCE - UNMOUNT', parentDatasource); + logUseEffectUnmount(USE_EFFECT_FUNC, selectedDatasource); }; - }, [parentDatasource]); + }, [inputs, selectedDatasource, processDatasets, processLabels]); // Effect hook when the selected datasource changes - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT PROCESS DATA', inputs, selectedDatasource); - - // Clear any record filters - setFilteredRecords(undefined); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - PROCESS DATA - DATA'; + logUseEffectMount(USE_EFFECT_FUNC, inputs, selectedDatasource); // If selectedDatasource is specified - if (selectedDatasource) { + if (inputs && selectedDatasource) { // Process the axes - let [xMinVal, xMaxVal, yMinVal, yMaxVal] = processAxes(inputs!.geochart, inputs!.ui, selectedDatasource!.items!); + let [xMinVal, xMaxVal, yMinVal, yMaxVal] = processAxes(inputs.geochart, inputs.ui, selectedDatasource.items); - // If any sliders + // Process the axes values let valuesComeFromState = false; [valuesComeFromState, [xMinVal, xMaxVal, yMinVal, yMaxVal]] = processAxesValues( + inputs!.ui, xMinVal, xMaxVal, yMinVal, @@ -799,54 +970,45 @@ export function GeoChart< // If using the state, filter right away if (valuesComeFromState) { - // Load data with filters - processFiltering(inputs!, selectedDatasource!.items!, [xMinVal!, xMaxVal!], [yMinVal!, yMaxVal!]); + // Load records with filtering + processLoadingRecordsFilteringFirst( + inputs, + datasetRegistry, + datasRegistry, + i18n.language, + selectedSteps, + selectedDatasource.items, + [xMinVal!, xMaxVal!], + [yMinVal!, yMaxVal!] + ); } else { // Load records without filtering for nothing - processLoadingRecords(inputs!, i18n.language, selectedSteps!, selectedDatasource!.items); + processLoadingRecords(inputs, datasetRegistry, datasRegistry, i18n.language, selectedSteps, selectedDatasource.items); } } return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PROCESS DATA - UNMOUNT', selectedDatasource); - }; - }, [inputs, i18n.language, selectedDatasource, selectedSteps, xSliderValues, ySliderValues, processLoadingRecords, processFiltering]); - - // Effect hook when the filtered records change - coming from this component. - useEffect(() => { - // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT PROCESS DATA FILTERED RECORDS', selectedDatasource, filteredRecords, selectedSteps); - - // Process loading records - if (selectedDatasource) processLoadingRecords(inputs!, i18n.language, selectedSteps!, filteredRecords || selectedDatasource!.items); - - return () => { - // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PROCESS DATA FILTERED RECORDS - UNMOUNT', selectedDatasource); + logUseEffectUnmount(USE_EFFECT_FUNC, selectedDatasource); }; - }, [inputs, i18n.language, selectedDatasource, filteredRecords, selectedSteps, processLoadingRecords]); - - // Effect hook when the main props about charttype, options and data change - coming from parent component. - useEffect(() => { - // Log - log(LOG_LEVEL_LOW, 'USE EFFECT PARENT CHARTJS INPUTS'); - - // Override - setChartType(parentChart!); - setChartOptions(parentOptions!); - setChartData(parentData!); - - return () => { - // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PARENT CHARTJS INPUTS - UNMOUNT'); - }; - }, [parentChart, parentOptions, parentData]); + }, [ + inputs, + selectedDatasource, + datasRegistry, + datasetRegistry, + i18n.language, + selectedSteps, + xSliderValues, + ySliderValues, + processLoadingRecordsFilteringFirst, + processLoadingRecords, + ]); // Effect hook when the chartOptions, chartData change - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT CHARTJS OPTIONS+DATA', chartOptions, chartData); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - CHARTJS OPTIONS+DATA'; + logUseEffectMount(USE_EFFECT_FUNC, chartOptions, chartData); // If chart options. Validate the parsing we did do follow ChartJS options schema validating if (chartOptions) setValidatorOptions(schemaValidator.validateOptions(chartOptions)); @@ -856,117 +1018,111 @@ export function GeoChart< return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT CHARTJS OPTIONS+DATA - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; }, [chartOptions, chartData, schemaValidator]); - // Effect hook when the selectedDatasets change - coming from this component. + // Effect hook when the datasetRegistry change - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT SELECTED DATASETS', selectedDatasets); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - SELECTED DATASETS'; + logUseEffectMount(USE_EFFECT_FUNC); // Make sure the visibility of the chart aligns with the selected datasets - updateDatasetVisibilityUsingState(chartData, selectedDatasets); + updateDatasetVisibilityUsingState(chartRef.current, datasetRegistry); return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT SELECTED DATASETS - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; - }, [chartData, selectedDatasets, updateDatasetVisibilityUsingState]); + }, [datasetRegistry, updateDatasetVisibilityUsingState]); - // Effect hook when the selectedDatas change - coming from this component. + // Effect hook when the datasRegistry change - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT SELECTED DATAS', selectedDatas); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - SELECTED DATAS'; + logUseEffectMount(USE_EFFECT_FUNC); // Make sure the visibility of the chart aligns with the selected datas - updateDataVisibilityUsingState(chartType, chartData, selectedDatas); + updateDataVisibilityUsingState(chartRef.current, datasRegistry); return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT SELECTED DATAS - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; - }, [chartType, chartData, selectedDatas, updateDataVisibilityUsingState]); + }, [datasRegistry, updateDataVisibilityUsingState]); // Effect hook to validate the schemas of inputs - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_HIGH, 'USE EFFECT VALIDATORS INPUTS', hasValidSchemas([validatorInputs])); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - VALIDATORS - INPUTS'; + logUseEffectMount(USE_EFFECT_FUNC, hasValidSchemas([validatorInputs])); // If any error if (!hasValidSchemas([validatorInputs])) { + // Gather error messages + const error = SchemaValidator.parseValidatorResultsMessages([validatorInputs]); // If a callback is defined - onError?.([validatorInputs]); - // eslint-disable-next-line no-console - console.error([validatorInputs]); + onError?.(`${t('geochart.parsingError')}\n\n${error}`, undefined); } return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT VALIDATORS INPUTS - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; - }, [validatorInputs, onError]); + // NO REACT for 'onError' (explicitely excluding it here instead of relying on + // the parent component to have used useCallback as they should have) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [validatorInputs, t]); // Effect hook to validate the schemas of inputs - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_HIGH, 'USE EFFECT VALIDATORS OPTIONS+DATA', hasValidSchemas([validatorOptions, validatorData])); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - VALIDATORS - OPTIONS+DATA'; + logUseEffectMount(USE_EFFECT_FUNC, hasValidSchemas([validatorOptions, validatorData])); // If any error if (!hasValidSchemas([validatorOptions, validatorData])) { + // Gather error messages + const error = SchemaValidator.parseValidatorResultsMessages([validatorOptions, validatorData]); // If a callback is defined - onError?.([validatorOptions, validatorData]); - // eslint-disable-next-line no-console - console.error([validatorOptions, validatorData]); + onError?.(`${t('geochart.parsingError')}\n\n${error}`, undefined); } return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT VALIDATORS OPTIONS+DATA - UNMOUNT'); - }; - }, [validatorOptions, validatorData, onError]); - - // Effect hook when an action needs to happen - coming from parent component. - useEffect(() => { - // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT PARENT ACTION', parentAction); - - // Set action for the component - if (parentAction) setAction(parentAction); - - return () => { - // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PARENT ACTION - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; - }, [parentAction]); + // NO REACT for 'onError' (explicitely excluding it here instead of relying on + // the parent component to have used useCallback as they should have) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [validatorOptions, validatorData, t]); // Effect hook when an action needs to happen - coming from this component. useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT ACTION', action); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - ACTION'; + logUseEffectMount(USE_EFFECT_FUNC, action); // If redraw is true, reset the property in the action, set the redraw property to true for the chart, then prep a timer to reset it to false after the redraw has happened. - // A bit funky, but only way I could find without having code in the Parent Component. + // A bit funky, but only way I could find without having code the logic within the Parent Component. if (action?.shouldRedraw) { action!.shouldRedraw = false; // Redraw - performRedraw().then(() => { - // Readjust visibility, because redraw resets all datasets visibility to true - updateDatasetVisibilityUsingState(chartData, selectedDatasets); - updateDataVisibilityUsingState(chartType, chartData, selectedDatas); - }); + performRedraw(); } return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT ACTION - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; - }, [chartType, chartData, action, selectedDatasets, selectedDatas, updateDatasetVisibilityUsingState, updateDataVisibilityUsingState]); + }, [action]); // Effect hook to be executed with i18n useEffect(() => { // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT ADD_RESOURCE_BUNDLE'); + const USE_EFFECT_FUNC = 'GEOCHART - USE EFFECT - CURRENT - i18n'; + logUseEffectMount(USE_EFFECT_FUNC); // Add GeoChart translations file i18n.addResourceBundle('en', 'translation', T_EN); @@ -974,23 +1130,212 @@ export function GeoChart< return () => { // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT ADD_RESOURCE_BUNDLE - UNMOUNT'); + logUseEffectUnmount(USE_EFFECT_FUNC); }; }, [i18n]); - // Effect hook to be executed with loading datasource - coming from parent component. - useEffect(() => { - // Log - log(LOG_LEVEL_MEDIUM, 'USE EFFECT PARENT LOADING DATASOURCE', parentLoadingDatasource); + // #endregion - // If defined, update the state - if (parentLoadingDatasource !== undefined) setIsLoadingDatasource(parentLoadingDatasource); + // #region EVENT HANDLERS SECTION *********************************************************************************** - return () => { - // Log - log(LOG_LEVEL_MAXIMUM, 'USE EFFECT PARENT LOADING DATASOURCE - UNMOUNT'); - }; - }, [parentLoadingDatasource]); + /** + * Handles when the Datasource changes + * @param e Event The Select change event + * @param item MenuItem The selected MenuItem + */ + const handleDatasourceChanged = async (e: Event, item: typeof MenuItem): Promise => { + // Find the selected datasource reference based on the MenuItem + const ds: GeoChartDatasource | undefined = inputs!.datasources.find((datasource: GeoChartDatasource) => { + return (datasource.value || datasource.display) === item.props.value; + }); + if (!ds) return; + + // If the data source has no items + if (!ds.items) { + ds.items = await fetchDatasourceItems(inputs!.query!, i18n.language, ds.sourceItem, onError); + } + + // Set the selected datasource + setSelectedDatasource(ds); + + // Callback + onDatasourceChanged?.(ds, i18n.language); + }; + + /** + * Handles when a dataset was checked/unchecked (via the legend) + * @param datasetIndex number Indicates the dataset index that was checked/unchecked + * @param datasetLabel string | undefined Indicates the dataset label that was checked/unchecked + * @param checked boolean Indicates the checked state + */ + const handleDatasetChecked = (datasetIndex: number, datasetLabel: string | undefined, checked: boolean): void => { + // Keep track + datasetRegistry[datasetLabel!].checked = checked; + + // Update the registry + setDatasetRegistry({ ...datasetRegistry }); + + // Callback + onDatasetChanged?.(datasetIndex, datasetLabel, checked); + }; + + /** + * Handles when a data was checked/unchecked (via the legend). This is only used by Pie and Doughnut Charts. + * @param dataIndex number Indicates the data index that was checked/unchecked + * @param dataLabel string Indicates the data label that was checked/unchecked + * @param checked boolean Indicates the checked state + */ + const handleDataChecked = (dataIndex: number, dataLabel: string, checked: boolean): void => { + // Keep track + datasRegistry[dataLabel].checked = checked; + + // Update the registry + setDatasRegistry({ ...datasRegistry }); + + // Callback + onDataChanged?.(dataIndex, dataLabel, checked); + }; + + /** + * Handles when the X Slider changes + * @param value number | number[] Indicates the slider value + */ + const handleSliderXChange = (newValue: number | number[]): void => { + // Set the X State + setXSliderValues(newValue); + + // Callback + onSliderXChanged?.(newValue); + }; + + /** + * Handles when the Y Slider changes + * @param value number | number[] Indicates the slider value + */ + const handleSliderYChange = (newValue: number | number[]): void => { + // Set the Y State + setYSliderValues(newValue); + + // Callback + onSliderYChanged?.(newValue); + }; + + /** + * Handles when the Steps Switcher changes + * @param value string Indicates the steps value + */ + const handleStepsSwitcherChanged = (e: unknown, item: typeof MenuItem): void => { + // Set the step switcher + setSelectedSteps(item.props.value); + + // Callback + onStepSwitcherChanged?.(item.props.value); + }; + + /** + * Handles when the States must be cleared + */ + const handleResetStates = (): void => { + // Reset all selected datasets to true + Object.keys(datasetRegistry).forEach((dataset: string) => { + datasetRegistry[dataset].checked = true; + }); + + // Reset all selected datasets to true + Object.keys(datasRegistry).forEach((data: string) => { + datasRegistry[data].checked = true; + }); + + // Clear all states + setDatasetRegistry({ ...datasetRegistry }); + setDatasRegistry({ ...datasRegistry }); + setSelectedSteps(inputs?.geochart.useSteps || false); + setXSliderValues([xSliderMin, xSliderMax]); + setYSliderValues([ySliderMin, ySliderMax]); + + // Callback + onResetStates?.(); + }; + + /** + * Handles the display of the label on the X Slider + * @param value number | number[] Indicates the slider value + */ + const handleSliderXValueDisplay = (value: number): string => { + // Callback in case we're overriding this behavior + const val = onSliderXValueDisplaying?.(value); + if (val) return val; + + // Default behavior + // If current chart has time as xAxis + if (inputs?.geochart?.xAxis.type === 'time' || inputs?.geochart?.xAxis.type === 'timeseries') { + return new Date(value).toLocaleDateString(i18n.language, DATE_OPTIONS_LONG); + } + + // Default value as is + return value.toString(); + }; + + /** + * Handles the display of the label on the Y Slider + * @param value number | number[] Indicates the slider value + */ + const handleSliderYValueDisplay = (value: number): string => { + // Callback in case we're overriding this behavior + const val = onSliderYValueDisplaying?.(value); + if (val) return val; + + // Default value as is + return value.toString(); + }; + + /** + * Handles when the download button is clicked + * @param value number Indicates the button drop down selection index when it was clicked + */ + const handleDownloadClick = (index: number): void => { + // Get the data + const data = { ...selectedDatasource! } as GeoChartDatasource; + + // If only the filtered information + if (index === 0) { + // Get either the actually filtered records (via the sliders) or the data.items + data.items = filteredRecords || data.items; + + // If using categories + if (inputs?.category) { + // The checked datasets strings + const checkedDatasetsStrings = Object.keys(datasetRegistry).filter((ds) => { + return datasetRegistry[ds].checked; + }); + + // Also filter on the selected datasets + data.items = data.items?.filter((value: TypeJsonObject) => { + return checkedDatasetsStrings.includes(value[inputs.category!.property] as string); + }); + + // In case of pie/doughnut + if (chartType === 'pie' || chartType === 'doughnut') { + // The checked datas strings + const checkedDatasStrings = Object.keys(datasRegistry).filter((ds) => { + return datasRegistry[ds].checked; + }); + + // Also filter on selected datas + data.items = data.items?.filter((value: TypeJsonObject) => { + return checkedDatasStrings.includes(value[inputs.geochart.xAxis.property] as string); + }); + } + } + } + + // Callback + let fileName = onDownloadClicked?.(data, index); + if (!fileName) fileName = 'chart-data.json'; + + // Download the data as json + downloadJson(data, fileName); + }; // #endregion @@ -1001,7 +1346,7 @@ export function GeoChart< * @returns The Chart JSX.Element itself using Line as default */ const renderChart = (): JSX.Element => { - return ; + return ; }; /** @@ -1113,7 +1458,7 @@ export function GeoChart< if (inputs) { // Create the menu items const menuItems: (typeof TypeMenuItemProps)[] = []; - inputs!.datasources.forEach((s: GeoChartDatasource) => { + inputs.datasources.forEach((s: GeoChartDatasource) => { menuItems.push({ key: s.value || s.display, item: { value: s.value || s.display, children: s.display || s.value } }); }); return ( @@ -1138,7 +1483,7 @@ export function GeoChart< * @returns The Ttile Element */ const renderTitle = (): JSX.Element => { - if (inputs) { + if (inputs && inputs.title) { return {inputs.title}; } @@ -1196,32 +1541,34 @@ export function GeoChart< * @returns The Dataset selector Element */ const renderDatasetSelector = (): JSX.Element => { - if (chartData) { - const { datasets } = chartData; - if (datasets.length > 1) { + if (inputs && chartData && inputs.category) { + if (Object.keys(datasetRegistry).length > 1) { const label = chartType === 'pie' || chartType === 'doughnut' ? `${t('geochart.category')}:` : ''; return ( {label} - {datasets.map((ds: ChartDataset, idx: number) => { - let { color } = ChartJS.defaults; - if (ds.borderColor) color = ds.borderColor! as string; - else if ((chartType === 'line' || chartType === 'bar') && ds.backgroundColor) color = ds.backgroundColor! as string; - - return ( - - ): void => { - handleDatasetChecked(idx, ds.label, e.target?.checked); - }} - checked={selectedDatasets[ds.label!] !== undefined ? selectedDatasets[ds.label!] : true} - /> - - {ds.label} - - - ); - })} + {Object.entries(datasetRegistry) + .filter(([, dsOption]: [string, GeoChartDatasetOption]) => { + return dsOption.visible; + }) + .map(([dsLabel, dsOption]: [string, GeoChartDatasetOption], idx: number) => { + let color; + if (chartType === 'line' || chartType === 'bar') color = dsOption.borderColor as string; + + return ( + + ): void => { + handleDatasetChecked(idx, dsLabel, e.target?.checked); + }} + checked={datasetRegistry[dsLabel].checked !== undefined ? datasetRegistry[dsLabel].checked : true} + /> + + {dsLabel} + + + ); + })} ); } @@ -1236,31 +1583,35 @@ export function GeoChart< * @returns The Data selector Element */ const renderDataSelector = (): JSX.Element => { - if (chartData) { - const { labels, datasets } = chartData; - if ((chartType === 'pie' || chartType === 'doughnut') && labels && datasets && labels.length > 1 && datasets.length >= 1) { - return ( - - {labels.map((lbl: TLabel, idx: number) => { - let { color } = ChartJS.defaults; - if (Array.isArray(datasets[0].backgroundColor)) color = extractColor(datasets[0].backgroundColor[idx])!; - - return ( - - ): void => { - handleDataChecked(idx, lbl, e.target?.checked); - }} - checked={selectedDatas[lbl] !== undefined ? selectedDatas[lbl] : true} - /> - - {lbl} - - - ); - })} - - ); + if (inputs && chartData) { + if (chartType === 'pie' || chartType === 'doughnut') { + if (Object.keys(datasRegistry).length > 1) { + return ( + + {Object.entries(datasRegistry) + .filter(([, dsOption]: [string, GeoChartDatasetOption]) => { + return dsOption.visible; + }) + .map(([dsLabel, dsOption]: [string, GeoChartDatasetOption], idx: number) => { + const color = dsOption.borderColor; + + return ( + + ): void => { + handleDataChecked(idx, dsLabel, e.target?.checked); + }} + checked={datasRegistry[dsLabel].checked !== undefined ? datasRegistry[dsLabel].checked : true} + /> + + {dsLabel} + + + ); + })} + + ); + } } } @@ -1273,47 +1624,41 @@ export function GeoChart< * @returns The Chart container JSX.Element or an empty box */ const renderChartContainer = (): JSX.Element => { - // If not loading - if (chartOptions) { - // The xs: 1, 11 and 12 used here are as documented online - return ( - - - - - {renderDatasourceSelector()} - {renderTitle()} - {renderUIOptions()} - - {renderDataSelector()} - {renderDatasetSelector()} - - - {renderYAxisLabel()} - - - {isLoadingDatasource && } - {renderChart()} - - - {renderYSlider()} - - - - {renderXAxisLabel()} - {renderXSlider()} - - - {renderDescription()} - {renderDownload()} - + // The xs: 1, 11 and 12 used here are as documented online + return ( + + + + + {renderDatasourceSelector()} + {renderTitle()} + {renderUIOptions()} + + {renderDataSelector()} + {renderDatasetSelector()} - - ); - } - - // Empty - return ; + + {renderYAxisLabel()} + + + {isLoadingDatasource && } + {renderChart()} + + + {renderYSlider()} + + + + {renderXAxisLabel()} + {renderXSlider()} + + + {renderDescription()} + {renderDownload()} + + + + ); }; /** @@ -1343,6 +1688,7 @@ export function GeoChart< // #endregion + // TODO: Add a check if there's a 'current error', not just a 'valid schemas' error // If no errors if (hasValidSchemas([validatorInputs, validatorOptions, validatorData])) { // Render the chart @@ -1369,12 +1715,4 @@ GeoChart.defaultProps = { }, } as ChartOptions, data: { datasets: [], labels: [] }, - defaultColors: null, - action: null, - onParsed: null, - onDatasourceChanged: null, - onDatasetChanged: null, - onSliderXChanged: null, - onSliderYChanged: null, - onError: null, }; diff --git a/src/logger.ts b/src/logger.ts index dfc3a9a..62b1dd9 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,11 +1,11 @@ /* eslint-disable no-console */ // Some arbitrary log levels. -export const LOG_LEVEL_MAXIMUM = 10; -export const LOG_LEVEL_HIGH = 8; -export const LOG_LEVEL_MEDIUM = 5; -export const LOG_LEVEL_LOW = 3; -export const LOG_LEVEL_MINIMAL = 1; +export const LOG_LEVEL_MAXIMUM = 10; // Max detail steps (like useEffects unmounting) +export const LOG_LEVEL_HIGH = 8; // Fine detail steps (like useEffects) +export const LOG_LEVEL_MEDIUM = 5; // Medium level steps +export const LOG_LEVEL_LOW = 3; // Essential steps +export const LOG_LEVEL_MINIMAL = 1; // Core steps // Indicates logging level. The higher the number, the more detailed the log. const LOGGING_LEVEL: number = LOG_LEVEL_LOW; @@ -14,8 +14,8 @@ const LOGGING_LEVEL: number = LOG_LEVEL_LOW; * Checks if the web application is running localhost * @returns boolean true if running localhost */ -const runningLocalhost = (): boolean => { - return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +const runningDev = (): boolean => { + return process.env.NODE_ENV === 'development'; }; /** @@ -24,8 +24,43 @@ const runningLocalhost = (): boolean => { * @param level number the level associated with the message to be logged. * @param message string[] the messages to log */ -export const log = (level: number, ...message: unknown[]): void => { +const log = (level: number, ...message: unknown[]): void => { // If not running localhost, skip - if (!runningLocalhost) return; + if (!runningDev()) return; if (level <= LOGGING_LEVEL) console.log(`${'-'.repeat(level)}>`, ...message); }; + +export const logMaximum = (...message: unknown[]): void => { + // Redirect + log(LOG_LEVEL_MAXIMUM, ...message); +}; + +export const logHigh = (...message: unknown[]): void => { + // Redirect + log(LOG_LEVEL_HIGH, ...message); +}; + +export const logMedium = (...message: unknown[]): void => { + // Redirect + log(LOG_LEVEL_MEDIUM, ...message); +}; + +export const logLow = (...message: unknown[]): void => { + // Redirect + log(LOG_LEVEL_LOW, ...message); +}; + +export const logMinimal = (...message: unknown[]): void => { + // Redirect + log(LOG_LEVEL_MINIMAL, ...message); +}; + +export const logUseEffectMount = (useEffectFunction: string, ...message: unknown[]): void => { + // Redirect + logHigh(`${useEffectFunction} - MOUNT`, ...message); +}; + +export const logUseEffectUnmount = (useEffectFunction: string, ...message: unknown[]): void => { + // Redirect + logMaximum(`${useEffectFunction} - UNMOUNT`, ...message); +}; From b99126e1dadf21cd1bcbd65faf5ff7cb4fa52bea Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 7 Dec 2023 17:20:58 -0500 Subject: [PATCH 2/3] Now using #region in app.tsx --- src/app.tsx | 21 +++++++++++++-------- src/chart.tsx | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index d9babcd..8bc7156 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -27,7 +27,7 @@ export function App(props: TypeAppProps): JSX.Element { // Translation const { i18n } = useTranslation(); - /** ****************************************** USE STATE SECTION START ************************************************ */ + // #region USE STATE SECTION **************************************************************************************** const [inputs, setInputs] = useState() as [ GeoChartConfig | undefined, @@ -43,8 +43,9 @@ export function App(props: TypeAppProps): JSX.Element { const [isLoadingChart, setIsLoadingChart] = useState() as [boolean, React.Dispatch>]; const [isLoadingDatasource, setIsLoadingDatasource] = useState() as [boolean, React.Dispatch>]; - /** ****************************************** USE STATE SECTION END ************************************************** */ - /** *************************************** EVENT HANDLERS SECTION START ********************************************** */ + // #endregion + + // #region EVENT HANDLERS SECTION *********************************************************************************** /** * Handles when the Chart has to be loaded with data or options. @@ -89,6 +90,10 @@ export function App(props: TypeAppProps): JSX.Element { if (ev.detail.state === 2) setIsLoadingDatasource(true); }; + // #endregion + + // #region HOOKS SECTION ******************************************************************************************** + /** * Handles when the Chart has parsed inputs. * @param theChart ChartType The chart type @@ -114,9 +119,6 @@ export function App(props: TypeAppProps): JSX.Element { alert(error); }, []); - /** **************************************** EVENT HANDLERS SECTION END *********************************************** */ - /** ******************************************* HOOKS SECTION START *************************************************** */ - /** * Handles when the Chart language is changed. */ @@ -144,8 +146,9 @@ export function App(props: TypeAppProps): JSX.Element { }; }, [handleChartLanguage]); - /** ********************************************* HOOKS SECTION END *************************************************** */ - /** ******************************************** RENDER SECTION START ************************************************* */ + // #endregion + + // #region RENDER SECTION START ************************************************************************************* // Render the Chart return ( @@ -162,6 +165,8 @@ export function App(props: TypeAppProps): JSX.Element { onError={handleError} /> ); + + // #endregion } export default App; diff --git a/src/chart.tsx b/src/chart.tsx index c717cb8..268245e 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -24,7 +24,7 @@ import { SchemaValidator, ValidatorResult } from './chart-schema-validator'; import { createChartJSOptions, createChartJSData, fetchItemsViaQueryForDatasource, setColorPalettes } from './chart-parsing'; import { isNumber, downloadJson, getColorFromPalette } from './chart-util'; import { sxClasses } from './chart-style'; -import { logLow, logUseEffectMount, logUseEffectUnmount } from './logger'; +import { logHigh, logUseEffectMount, logUseEffectUnmount } from './logger'; import T_EN from '../locales/en/translation.json'; import T_FR from '../locales/fr/translation.json'; @@ -756,7 +756,7 @@ export function GeoChart< const handleChartJSAfterInit = useCallback( (chart: ChartJS): void => { // Log - logLow('CHARTJS AFTER INIT', chart, datasetRegistry); + logHigh('CHARTJS AFTER INIT', chart, datasetRegistry); // Make sure the UI fits with the registry state before the first render is made. Mostly useful for pie/doughnut charts. updateDatasetVisibilityUsingState(chart, datasetRegistry); From 6b808820907a0691bea7baa4672e6e8216183821 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 Dec 2023 09:19:06 -0500 Subject: [PATCH 3/3] Added translation for reset states Added some default values to schema validator Fixed a JSDOC comment --- locales/en/translation.json | 3 ++- locales/fr/translation.json | 3 ++- schema-inputs.json | 6 ++++-- src/chart.tsx | 7 +++++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/locales/en/translation.json b/locales/en/translation.json index 8c6f03f..85437b6 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -6,6 +6,7 @@ "parsingError": "There was an error parsing the Chart inputs.", "viewConsoleDetails": "View console for details.", "downloadFiltered": "Download visible", - "downloadAll": "Download all" + "downloadAll": "Download all", + "resetStates": "Reset states" } } \ No newline at end of file diff --git a/locales/fr/translation.json b/locales/fr/translation.json index b19a7e0..cf98948 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -6,6 +6,7 @@ "parsingError": "Une erreur est survenue lors de la lecture des paramètres.", "viewConsoleDetails": "Voir détails dans la console.", "downloadFiltered": "Télécharger visuel", - "downloadAll": "Télécharger tout" + "downloadAll": "Télécharger tout", + "resetStates": "Réinitialiser états" } } \ No newline at end of file diff --git a/schema-inputs.json b/schema-inputs.json index 00237c2..6226f44 100644 --- a/schema-inputs.json +++ b/schema-inputs.json @@ -78,7 +78,8 @@ }, "useSteps": { "description": "Indicates if the line chart should use steps - supported values are: 'before', 'middle', 'after', false", - "enum": ["before", "after", "middle", false] + "enum": ["before", "after", "middle", false], + "default": false }, "tension": { "description": "Indicates if the line chart should use tension when drawing the line between the values", @@ -94,7 +95,8 @@ }, "type": { "description": "Indicates the type of axis - supported values are: 'linear', 'time', 'timeseries', 'logarithmic', 'category'", - "type": "string" + "type": "string", + "default": "linear" }, "label": { "description": "Indicates the text in the user interface that should be shown for the axis", diff --git a/src/chart.tsx b/src/chart.tsx index 268245e..299223c 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -1291,7 +1291,10 @@ export function GeoChart< /** * Handles when the download button is clicked - * @param value number Indicates the button drop down selection index when it was clicked + * @param index number Indicates the button drop down selection index when it was clicked. + * For our button usage: + * - 0: Means 'download view' was selected when button was clicked + * - 1: Means 'download all' was selected when button was clicked */ const handleDownloadClick = (index: number): void => { // Get the data @@ -1516,7 +1519,7 @@ export function GeoChart< if (inputs?.ui?.resetStates) { return ( ); }