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/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-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..6226f44 100644 --- a/schema-inputs.json +++ b/schema-inputs.json @@ -1,17 +1,312 @@ { "$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], + "default": 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", + "default": "linear" + }, + "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..8bc7156 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,9 +25,9 @@ export function App(props: TypeAppProps): JSX.Element { const { schemaValidator } = props; // Translation - const { t, i18n } = useTranslation(); + 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,49 +90,45 @@ export function App(props: TypeAppProps): JSX.Element { if (ev.detail.state === 2) setIsLoadingDatasource(true); }; - /** **************************************** EVENT HANDLERS SECTION END *********************************************** */ - /** ******************************************* HOOKS SECTION START *************************************************** */ + // #endregion - /** - * Handles when the Chart language is changed. - */ - const handleChartLanguage = useCallback( - (e: Event): void => { - const ev = e as CustomEvent; - i18n.changeLanguage(ev.detail.language); - }, - [i18n] - ); + // #region HOOKS SECTION ******************************************************************************************** /** * 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. + * @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 } })); - }, []) 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) + * 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( - (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')}`); + 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); + }, []); + + /** + * Handles when the Chart language is changed. + */ + const handleChartLanguage = useCallback( + (e: Event): void => { + const ev = e as CustomEvent; + i18n.changeLanguage(ev.detail.language); }, - [t] - ) as (validators: (ValidatorResult | undefined)[]) => void; // Crazy typing, because can't use the generic version of 'useCallback' + [i18n] + ); // 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. @@ -149,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 ( @@ -167,6 +165,8 @@ export function App(props: TypeAppProps): JSX.Element { onError={handleError} /> ); + + // #endregion } export default App; 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..299223c 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 { logHigh, 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); - } - }); + logHigh('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,215 @@ 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 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 + 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 +1349,7 @@ export function GeoChart< * @returns The Chart JSX.Element itself using Line as default */ const renderChart = (): JSX.Element => { - return ; + return ; }; /** @@ -1113,7 +1461,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 +1486,7 @@ export function GeoChart< * @returns The Ttile Element */ const renderTitle = (): JSX.Element => { - if (inputs) { + if (inputs && inputs.title) { return {inputs.title}; } @@ -1171,7 +1519,7 @@ export function GeoChart< if (inputs?.ui?.resetStates) { return ( ); } @@ -1196,32 +1544,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 +1586,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 +1627,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 +1691,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 +1718,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); +};