@geo2france/api-dashboard 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD CHANGED
@@ -2,33 +2,35 @@
2
2
 
3
3
  Collection de composants React pour faciliter la création de **tableaux de bords territoriaux**.
4
4
 
5
- Le projet permet la mise en place d'un tableau de bord facile à déployer sur une **infrastucture légère**.
6
- Le tableau de bord, une fois compilé, peut-être mis à disposition des utilisateurs via un **simple server web** (HTTP/HTTPS) sans configuration particulière.
7
- Il s'agit d'une application React (Javascript) s'executant dans le navigateur des utilisateurs. Il n'y a pas **backend à installer**,
8
- l'application récupère les données via API **auprès d'un partenaire** (plateforme régionale, portail open-data, etc.) ou sur **votre serveur de données**.
9
- Les données sont ensuites traitées par le client et présentées à l'utilisateur via des graphiques ou cartes.
5
+ Demo :
6
+ - [Odema](https://www.geo2france.fr/public/odema/dashboard/) ([code source](https://github.com/geo2france/odema-dashboard))
7
+ - [Clicnat](https://dashboard.clicnat.fr/)
10
8
 
11
- Le tableau de bord est construit de manière déclarative **JSX** (accronyme de JavaScript XML).
12
- Comme son nom l'indique, il permet de combiner la clarté et l'efficacité du XML, avec la souplesse et la puissance du JavaScript.
9
+ ![capture d'écran](screenshot.png)
13
10
 
14
- Les API suivantes sont actuellement supportées (interrogation, filtre, pagination, etc. ) :
15
- - [WFS](src/data_providers/wfs/) : API proposée par la plupart des serveurs geographiques (QGIS Server, GeoServer, ArcGIS server, etc.)
16
- - [Data Fair](src/data_providers/datafair/) : API de la solution open source Data Fair.
17
- - En développement : OGC API Features, TJS
11
+ ## Points forts
18
12
 
19
- ![diag](architecture_1.png)
13
+ - ✅ Compatible avec de **nombreux fournisseurs de données** (WFS, Datafair, fichiers statiques csv ou json, etc.),
14
+ - ✅ **Traitements** de données possibles (filtres, agréations, etc. via SQL ou JavaScript),
15
+ - ✅ Créez vos dataviz en utilisants [Apache ECharts](https://echarts.apache.org/) ou n'importe quelle bibliothèque web,
16
+ - ✅ Application cliente : peut-être mise à disposition des utilisateurs via un **simple server web** (HTTP/HTTPS) sans configuration particulière,
17
+ - ✅ Ecriture des tableaux de bord en **JSX/TSX** : combiner la clarté du XML avec la souplesse et puissance du JavaScript/TypeScript.
20
18
 
21
- En bref :
22
19
 
23
- - ✅ Déploiement facile et rapide (_client side_)
24
- - ✅ Présentez au même endroit des données tierces, vos données et celles de vos partenaires
25
- - ✅ Possibilité de visualiser des données sensibles
26
- - ✅ Flexibilité
20
+ ## Installation
27
21
 
28
- Les composants sont actuellement utilisés pour le [tableau de bord de l'Odema](https://github.com/geo2france/odema-dashboard).
22
+ ## Installer nvm et Node.js
29
23
 
30
- ## Installation
24
+ - Sur Windows : https://github.com/coreybutler/nvm-windows?tab=readme-ov-file#install-nvm-windows
25
+ - Sur Linux : `apt install nvm`
31
26
 
27
+ Ensuite, depuis un terminal ([Powershell](https://learn.microsoft.com/fr-fr/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5) ou autre) :
28
+ ```bash
29
+ # Installer la dernière vers LTS de Node.js
30
+ nvm install --lts
31
+ ```
32
+
33
+ ## Créer un tableau de bord
32
34
  Pour créer un nouveau tableau de bord :
33
35
 
34
36
  ```sh
@@ -41,3 +43,4 @@ npm run dev
41
43
 
42
44
  Consulter la [documentation](https://geo2france.github.io/api-dashboard/) du projet.
43
45
 
46
+
@@ -1,7 +1,11 @@
1
1
  import { EChartsOption } from "echarts";
2
2
  import EChartsReact, { EChartsReactProps } from "echarts-for-react";
3
- interface ChartEchartsProps extends EChartsReactProps {
3
+ export interface ChartEchartsProps extends EChartsReactProps {
4
4
  option: EChartsOption;
5
5
  }
6
+ /**
7
+ * Ce composant peut servir de base aux développements d'autres composants ou être utilisé directement dans une page (non conseillé).
8
+ * - Applique la palette utilisateur
9
+ * - Utilise le style de texte de l'application
10
+ */
6
11
  export declare const ChartEcharts: import("react").ForwardRefExoticComponent<ChartEchartsProps & import("react").RefAttributes<EChartsReact>>;
7
- export {};
@@ -5,11 +5,10 @@ import EChartsReact from "echarts-for-react";
5
5
  import { usePalette } from "../Palette/Palette";
6
6
  import deepMerge from "../../utils/deepmerge";
7
7
  const { useToken } = theme;
8
- /*
8
+ /**
9
9
  * Ce composant peut servir de base aux développements d'autres composants ou être utilisé directement dans une page (non conseillé).
10
10
  * - Applique la palette utilisateur
11
11
  * - Utilise le style de texte de l'application
12
- * devnote : A partir de React 19, ne plus utiliser forwardRef https://react.dev/reference/react/forwardRef
13
12
  */
14
13
  export const ChartEcharts = forwardRef(({ option = {}, ...restProps }, ref) => {
15
14
  const innerRef = useRef(null);
@@ -1,11 +1,29 @@
1
- interface IChartPieProps {
1
+ import { EChartsOption, LabelFormatterCallback } from "echarts";
2
+ import type { CallbackDataParams } from 'echarts/types/dist/shared';
3
+ export interface IChartPieProps {
4
+ /** Identifiant du dataset */
2
5
  dataset?: string;
6
+ /** Nom de la colonne qui contient les valeurs numériques */
3
7
  dataKey: string;
8
+ /** Nom de la colonne qui contient les catégories */
4
9
  nameKey: string;
10
+ /** Unité à afficher */
5
11
  unit?: string;
12
+ /** Titre du graphique */
6
13
  title?: string;
14
+ /** Donut ou camembert ? */
7
15
  donut?: boolean;
16
+ /** Merge categorie with less than `other` percent */
8
17
  other?: number | null;
18
+ /** Nombre de décimales après la virgule à afficher (1 par défaut)
19
+ */
20
+ precision?: number;
21
+ /** Personnaliser le formatter des labels de la série
22
+ * cf. https://echarts.apache.org/en/option.html#series-pie.label.formatter */
23
+ labelFormatter?: string | LabelFormatterCallback<CallbackDataParams>;
24
+ /** Options suplémentaires passées à Echarts
25
+ * @see https://echarts.apache.org/en/option.html
26
+ */
27
+ option?: EChartsOption;
9
28
  }
10
- export declare const ChartPie: React.FC<IChartPieProps>;
11
- export {};
29
+ export declare const ChartPie: ({ dataset: dataset_id, nameKey, dataKey, unit, title, donut, other, labelFormatter, precision, option: customOption }: IChartPieProps) => import("react/jsx-runtime").JSX.Element;
@@ -5,7 +5,8 @@ import { from, op } from "arquero";
5
5
  import { ChartEcharts } from "./ChartEcharts";
6
6
  import { merge_others } from "../..";
7
7
  import { useBlockConfig } from "../DashboardPage/Block";
8
- export const ChartPie = ({ dataset: dataset_id, nameKey, dataKey, unit, title, donut = false, other = 5 }) => {
8
+ import deepMerge from "../../utils/deepmerge";
9
+ export const ChartPie = ({ dataset: dataset_id, nameKey, dataKey, unit, title, donut = false, other = 5, labelFormatter, precision = 1, option: customOption = {} }) => {
9
10
  const dataset = useDataset(dataset_id);
10
11
  const data = dataset?.data;
11
12
  useBlockConfig({
@@ -22,6 +23,9 @@ export const ChartPie = ({ dataset: dataset_id, nameKey, dataKey, unit, title, d
22
23
  value: d.value,
23
24
  }))
24
25
  : [];
26
+ //@ts-ignore
27
+ const total = chart_data1.length > 0 && from(chart_data1).rollup({ value: op.sum('value') }).object()?.value;
28
+ console.log('total', total);
25
29
  const chart_data = merge_others({ dataset: chart_data1 || [], min: other || -1 });
26
30
  const colors = usePalette({ nColors: chart_data?.length });
27
31
  const colors_labels = usePaletteLabels();
@@ -36,11 +40,26 @@ export const ChartPie = ({ dataset: dataset_id, nameKey, dataKey, unit, title, d
36
40
  },
37
41
  data: chart_data,
38
42
  radius: donut ? ['40%', '75%'] : [0, '75%'],
43
+ label: {
44
+ formatter: labelFormatter,
45
+ overflow: "break"
46
+ }
39
47
  }],
40
48
  tooltip: {
41
49
  show: true,
42
- valueFormatter: v => `${v?.toLocaleString()} ${unit || ''} `
50
+ valueFormatter: v => `${v?.toLocaleString(undefined, { maximumFractionDigits: precision })} ${unit || ''} `
51
+ },
52
+ graphic: {
53
+ type: 'text',
54
+ left: 'center',
55
+ top: 'center',
56
+ style: {
57
+ text: `${total.toLocaleString(undefined, { maximumFractionDigits: precision })} ${unit}`,
58
+ fontSize: 24,
59
+ fontWeight: 'bold',
60
+ fill: '#333',
61
+ }
43
62
  }
44
63
  };
45
- return _jsx(ChartEcharts, { option: option });
64
+ return _jsx(ChartEcharts, { option: deepMerge({}, option, customOption) });
46
65
  };
@@ -1,6 +1,7 @@
1
1
  import { ReactElement } from "react";
2
2
  import { SimpleRecord } from "../../types";
3
3
  type comparwithType = "first" | "previous";
4
+ type aggregateType = "last" | "first" | "sum" | "lastNotNull" | "min" | "max" | "count" | "mean" | "countDistinct" | "countMissing";
4
5
  interface ICallbackParams {
5
6
  /** Valeur principale */
6
7
  value: number;
@@ -11,7 +12,7 @@ interface ICallbackParams {
11
12
  /** Valeur de comparaison */
12
13
  compareValue: number;
13
14
  }
14
- interface StatisticsProps {
15
+ export interface StatisticsProps {
15
16
  /** Identifiant du jeu de données */
16
17
  dataset: string;
17
18
  /** Nom de la colonne qui contient les valeurs */
@@ -37,7 +38,11 @@ interface StatisticsProps {
37
38
  /** Texte d'annotation (remplace evolution si définie) */
38
39
  annotation?: React.ReactNode | ((param: ICallbackParams) => React.ReactNode);
39
40
  /** Fonction a appliquer avant rendu */
40
- valueFormatter?: ((param: ICallbackParams) => React.ReactNode);
41
+ valueFormatter?: ((param: ICallbackParams) => string);
42
+ /** Méthode d'aggrégation */
43
+ aggregate?: aggregateType;
44
+ /** Afficher une animation (Count-up) */
45
+ animation?: boolean;
41
46
  }
42
47
  /**
43
48
  * Composant `Statistics` affichant une valeur d'un dataset avec son évolution.
@@ -53,7 +58,7 @@ interface StatisticsProps {
53
58
  * @returns {ReactElement} Carte statistique
54
59
  */
55
60
  export declare const Statistics: React.FC<StatisticsProps>;
56
- type StatisticsCollectionProps = {
61
+ export interface StatisticsCollectionProps {
57
62
  /**
58
63
  * Un ou plusieurs composants `<Statistics>`.
59
64
  */
@@ -66,13 +71,10 @@ type StatisticsCollectionProps = {
66
71
  * Titre du bloc.
67
72
  */
68
73
  title?: string;
69
- };
74
+ }
70
75
  /**
71
76
  * `StatisticsCollection` permet de regrouper plusieurs cartes statistiques
72
77
  * dans un bloc
73
- *
74
- * @param {StatisticsProps} props - Propriétés du composant
75
- * @returns {ReactElement} Collection de cartes statistiques
76
78
  * ```
77
79
  */
78
80
  export declare const StatisticsCollection: React.FC<StatisticsCollectionProps>;
@@ -5,6 +5,8 @@ import { Children } from "react";
5
5
  import { useDataset } from "../Dataset/hooks";
6
6
  import { Icon } from "@iconify/react";
7
7
  import { useBlockConfig } from "../DashboardPage/Block";
8
+ import { aggregator } from "../../utils/aggregator";
9
+ import CountUp from "react-countup";
8
10
  const { Text, Paragraph } = Typography;
9
11
  // DEV : modele cf https://bootstrapbrain.com/component/bootstrap-statistics-card-example/
10
12
  /**
@@ -20,13 +22,12 @@ const { Text, Paragraph } = Typography;
20
22
  * @param {StatisticsProps} props - Propriétés du composant
21
23
  * @returns {ReactElement} Carte statistique
22
24
  */
23
- export const Statistics = ({ dataset: dataset_id, dataKey, unit, evolutionSuffix, title, icon: icon_input, color, invertColor = false, help, compareWith, relativeEvolution = false, valueFormatter = (param) => (param.value.toLocaleString()), annotation }) => {
25
+ export const Statistics = ({ dataset: dataset_id, dataKey, unit, evolutionSuffix, title, icon: icon_input, color, invertColor = false, help, compareWith, relativeEvolution = false, valueFormatter = (param) => (param.value.toLocaleString()), annotation, aggregate = "last", animation = false }) => {
24
26
  const icon = typeof icon_input === "string" ? _jsx(Icon, { icon: icon_input }) : icon_input;
25
27
  const dataset = useDataset(dataset_id);
26
- const row = dataset?.data?.slice(-1)[0];
27
- const value = row?.[dataKey]; // Dernière valeur du dataset. Caster en Number ?
28
+ const { row, value } = aggregator({ data: dataset?.data, dataKey, aggregate });
28
29
  const compare_value = compareWith === 'previous' ? dataset?.data?.slice(-2)?.[0]?.[dataKey] : dataset?.data?.slice(0, 1)?.[0]?.[dataKey]; //Première ou avant dernière
29
- const evolution = relativeEvolution ? 100 * ((value - compare_value) / compare_value) : value - compare_value;
30
+ const evolution = relativeEvolution ? 100 * ((Number(value) - compare_value) / compare_value) : Number(value) - compare_value;
30
31
  const evolution_unit = relativeEvolution ? '%' : unit;
31
32
  const evolution_is_good = invertColor ? evolution < 0 : evolution > 0;
32
33
  const tooltip = help && _jsx(Tooltip, { title: help, children: _jsx(QuestionCircleOutlined, {}) });
@@ -53,17 +54,18 @@ export const Statistics = ({ dataset: dataset_id, dataKey, unit, evolutionSuffix
53
54
  fontSize: 14,
54
55
  minHeight: 35,
55
56
  },
56
- }, extra: tooltip, children: _jsxs(Flex, { vertical: true, children: [_jsxs(Flex, { justify: "space-between", align: "center", children: [_jsxs(Text, { style: { fontSize: "150%", paddingTop: 8, paddingBottom: 8, paddingLeft: 0 }, children: [valueFormatter(CallbackParams), " ", unit] }), icon && _jsx(Avatar, { size: 32 + 8, icon: icon, style: { backgroundColor: color } })] }), typeof subtitle == 'string' ?
57
+ }, extra: tooltip, children: _jsxs(Flex, { vertical: true, children: [_jsxs(Flex, { justify: "space-between", align: "center", children: [_jsx(Text, { style: { fontSize: "150%", paddingTop: 8, paddingBottom: 8, paddingLeft: 0 }, children: animation ?
58
+ _jsx(CountUp, { formattingFn: (v) => `${valueFormatter({ ...CallbackParams, value: v })} ${unit}`, duration: 1.5, end: value || NaN })
59
+ :
60
+ _jsxs("span", { children: [valueFormatter(CallbackParams), " ", unit] }) }), icon && _jsx(Avatar, { size: 32 + 8, icon: icon, style: { backgroundColor: color } })] }), typeof subtitle == 'string' ?
57
61
  _jsx(Text, { italic: true, type: "secondary", children: subtitle })
58
62
  :
59
63
  _jsx("div", { children: subtitle })] }) }));
60
64
  };
65
+ ;
61
66
  /**
62
67
  * `StatisticsCollection` permet de regrouper plusieurs cartes statistiques
63
68
  * dans un bloc
64
- *
65
- * @param {StatisticsProps} props - Propriétés du composant
66
- * @returns {ReactElement} Collection de cartes statistiques
67
69
  * ```
68
70
  */
69
71
  export const StatisticsCollection = ({ children, columns = 3, title }) => {
@@ -2,7 +2,7 @@
2
2
  * Graphique standard pour afficher des données annuelles
3
3
  */
4
4
  import { EChartsOption, SeriesOption } from "echarts";
5
- interface IYearSerieProps {
5
+ export interface IYearSerieProps {
6
6
  dataset: string;
7
7
  title?: string;
8
8
  yearKey: string;
@@ -25,4 +25,3 @@ interface IYearSerieProps {
25
25
  options?: Partial<EChartsOption>;
26
26
  }
27
27
  export declare const ChartYearSerie: React.FC<IYearSerieProps>;
28
- export {};
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Card, Dropdown, theme } from "antd";
2
+ import { Card, Dropdown, Spin, theme } from "antd";
3
3
  import { createContext, useContext, useEffect, useId, useState } from "react";
4
4
  import { Icon } from "@iconify/react";
5
5
  import { ProducersFooter } from "../Dataset/Producer";
6
6
  import { MoreOutlined } from '@ant-design/icons';
7
7
  import { ErrorBoundary } from "../Layout/Error";
8
8
  import { cardStyles } from "../../utils/cardStyles";
9
+ import { useDataset } from "../../dsl";
9
10
  const { useToken } = theme;
10
11
  export const ChartBlockContext = createContext(undefined);
11
12
  export const DSL_ChartBlock = ({ children }) => {
12
13
  const id = useId();
13
14
  const [config, setConfig] = useState({});
14
15
  const { token } = useToken();
16
+ const dataset = useDataset(children.props.dataset);
15
17
  const menu_items = [
16
18
  {
17
19
  key: "export_data_csv",
@@ -38,7 +40,7 @@ export const DSL_ChartBlock = ({ children }) => {
38
40
  };
39
41
  DL();
40
42
  };
41
- return (_jsx(ChartBlockContext.Provider, { value: { config: config, setConfig: (e) => setConfig(e) }, children: _jsx(Card, { className: "dashboard-element", style: { height: '100%' }, styles: cardStyles, extra: has_action && dropdown_toolbox, title: config.title, children: _jsxs(ErrorBoundary, { children: [children, _jsx(ProducersFooter, { component: children })] }) }) }));
43
+ return (_jsx(ChartBlockContext.Provider, { value: { config: config, setConfig: (e) => setConfig(e) }, children: _jsx(Card, { className: "dashboard-element", style: { height: '100%' }, styles: cardStyles, extra: has_action && dropdown_toolbox, title: config.title, children: _jsx(ErrorBoundary, { children: _jsxs(Spin, { spinning: dataset?.isFetching || false, size: "large", delay: 250, children: [children, _jsx(ProducersFooter, { component: children })] }) }) }) }));
42
44
  };
43
45
  export const useBlockConfig = ({ title, dataExport }) => {
44
46
  const blockContext = useContext(ChartBlockContext);
@@ -0,0 +1,12 @@
1
+ import { ReactElement } from "react";
2
+ interface IntroProps {
3
+ children: ReactElement;
4
+ /** Titre de la modal */
5
+ title?: string;
6
+ }
7
+ /**
8
+ * Texte introductif optionnel
9
+ * Modal accessible via un bouton flottant dans le coin supérieur droit
10
+ */
11
+ export declare const Intro: React.FC<IntroProps>;
12
+ export {};
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Icon } from "@iconify/react";
3
+ import { FloatButton, Modal } from "antd";
4
+ import { useState } from "react";
5
+ /**
6
+ * Texte introductif optionnel
7
+ * Modal accessible via un bouton flottant dans le coin supérieur droit
8
+ */
9
+ export const Intro = ({ children, title }) => {
10
+ const [isModalOpen, setIsModalOpen] = useState(false);
11
+ const showModal = () => {
12
+ setIsModalOpen(true);
13
+ };
14
+ const handleOk = () => {
15
+ setIsModalOpen(false);
16
+ };
17
+ const handleCancel = () => {
18
+ setIsModalOpen(false);
19
+ };
20
+ return (_jsxs(_Fragment, { children: [_jsx(FloatButton, { type: "primary", onClick: showModal, className: "IntroButton", style: { top: 5, height: 25 }, icon: _jsx(Icon, { icon: "fontisto:info" }), shape: "square" }), _jsx(Modal, { title: title, open: isModalOpen, onOk: handleOk, onCancel: handleCancel, footer: null, width: { xs: '100%', xl: '80%', xxl: '80%' }, children: children })] }));
21
+ };
@@ -9,6 +9,7 @@ import { DEFAULT_PALETTE, Palette, PaletteContext } from "../Palette/Palette";
9
9
  import { Section } from "./Section";
10
10
  import { Icon } from "@iconify/react";
11
11
  import { DatasetRegistryContext } from "../Dataset/context";
12
+ import { Intro } from "./Intro";
12
13
  const { Header } = Layout;
13
14
  const { useToken } = theme;
14
15
  const getSection = (child) => React.isValidElement(child) ? child.props.section : undefined;
@@ -59,6 +60,9 @@ export const DSL_DashboardPage = ({ name = 'Tableau de bord', columns = 2, child
59
60
  if (typeof (c.type) != 'string' && logicalComponents.includes(c.type.name)) {
60
61
  return "logical";
61
62
  }
63
+ else if (typeof (c.type) != 'string' && c.type.name == Intro.name) {
64
+ return "intro";
65
+ }
62
66
  else if (typeof (c.type) != 'string' && c.type.name == DSL_Control.name) {
63
67
  return "control";
64
68
  }
@@ -73,6 +77,7 @@ export const DSL_DashboardPage = ({ name = 'Tableau de bord', columns = 2, child
73
77
  const logic_components = childrenArray.filter((c) => getComponentKind(c) == 'logical');
74
78
  const control_components = childrenArray.filter((c) => getComponentKind(c) == 'control');
75
79
  const section_components = childrenArray.filter((c) => getComponentKind(c) == 'section');
80
+ const intro_component = childrenArray.find((c) => getComponentKind(c) == 'intro');
76
81
  if (debug && !logic_components.some((c) => typeof c.type !== "string" && c.type.name === Debug.name)) {
77
82
  logic_components.push(_jsx(Debug, {}, "debug_property"));
78
83
  }
@@ -104,5 +109,5 @@ export const DSL_DashboardPage = ({ name = 'Tableau de bord', columns = 2, child
104
109
  borderRadius: token.borderRadiusLG }, style: { margin: 4 } })
105
110
  :
106
111
  _jsxs("div", { style: { margin: 4 }, children: [" ", items?.[0].children, " "] }) //Show content without tabs if only one
107
- , logic_components] })] }));
112
+ , logic_components, intro_component] })] }));
108
113
  };
@@ -1,7 +1,28 @@
1
1
  import { useCallback, useContext, useState } from "react";
2
2
  import { DatasetRegistryContext } from "./context";
3
+ const demo_dataset = {
4
+ id: 'demo_dataset',
5
+ isError: false,
6
+ isFetching: false,
7
+ resource: 'demo_dataset',
8
+ data: [
9
+ { name: 'Mercure', type: 'Planète', satellites: 0, diameter_km: 4879 },
10
+ { name: 'Vénus', type: 'Planète', satellites: 0, diameter_km: 12104 },
11
+ { name: 'Terre', type: 'Planète', satellites: 1, diameter_km: 12742 },
12
+ { name: 'Mars', type: 'Planète', satellites: 2, diameter_km: 6779 },
13
+ { name: 'Jupiter', type: 'Planète', satellites: 79, diameter_km: 139820 },
14
+ { name: 'Saturne', type: 'Planète', satellites: 83, diameter_km: 116460 },
15
+ { name: 'Uranus', type: 'Planète', satellites: 27, diameter_km: 50724 },
16
+ { name: 'Neptune', type: 'Planète', satellites: 14, diameter_km: 49244 },
17
+ { name: 'Pluton', type: 'Planète naine', satellites: 5, diameter_km: 2370 },
18
+ { name: 'Cérès', type: 'Astéroïde', satellites: 0, diameter_km: 946 },
19
+ ]
20
+ };
3
21
  // 🔹 Hook pour récupérer un dataset unique
4
22
  export const useDataset = (dataset_id) => {
23
+ if (dataset_id == 'demo_dataset') {
24
+ return demo_dataset;
25
+ }
5
26
  const datasetRegistry = useContext(DatasetRegistryContext);
6
27
  if (dataset_id) {
7
28
  return datasetRegistry.get(dataset_id);
@@ -20,5 +20,5 @@ export const Debug = () => {
20
20
  label: _jsxs("span", { children: [_jsx(DatasetBadgeStatus, { isError: dataset?.isError, isFetching: dataset?.isFetching }), " ", dataset.id, " ", " ", _jsxs(Text, { type: "secondary", children: [" ", dataset?.resource, " "] }), " ", _jsx(Badge, { color: token.colorInfo, overflowCount: 9999, count: dataset?.data?.length })] }),
21
21
  children: _jsx(DataPreview, { dataset: dataset.id, pageSize: 3 })
22
22
  }));
23
- return (_jsxs(_Fragment, { children: [_jsx(FloatButton, { icon: _jsx(BugOutlined, {}), type: "primary", onClick: () => setIsModalOpen(true), style: { top: 5 }, className: "debugFloatButton" }), _jsxs(Modal, { title: "Information concepteur", width: "90%", centered: true, styles: { content: { 'width': "100%", padding: 36 } }, closable: { 'aria-label': 'Custom Close Button' }, open: isModalOpen, onCancel: () => setIsModalOpen(false), footer: null, children: [_jsx(Title, { level: 5, children: "Jeux de donn\u00E9es " }), _jsx(Collapse, { accordion: true, items: items }), _jsx(Divider, {}), _jsx(Title, { level: 5, children: "Contr\u00F4les utilisateur " }), _jsx(ControlPreview, {}), _jsx(Divider, {}), _jsx(Title, { level: 5, children: "Palette " }), _jsx(PalettePreview, {})] })] }));
23
+ return (_jsxs(_Fragment, { children: [_jsx(FloatButton, { icon: _jsx(BugOutlined, {}), type: "primary", onClick: () => setIsModalOpen(true), style: { bottom: 8, left: 8 }, className: "debugFloatButton" }), _jsxs(Modal, { title: "Information concepteur", width: "90%", centered: true, styles: { body: { 'width': "100%", padding: 36 } }, closable: { 'aria-label': 'Custom Close Button' }, open: isModalOpen, onCancel: () => setIsModalOpen(false), footer: null, children: [_jsx(Title, { level: 5, children: "Jeux de donn\u00E9es " }), _jsx(Collapse, { accordion: true, items: items }), _jsx(Divider, {}), _jsx(Title, { level: 5, children: "Contr\u00F4les utilisateur " }), _jsx(ControlPreview, {}), _jsx(Divider, {}), _jsx(Title, { level: 5, children: "Palette " }), _jsx(PalettePreview, {})] })] }));
24
24
  };
@@ -35,6 +35,10 @@ export interface DashboardConfig {
35
35
  * Active ou désactive le mode “slider” dans le pied de page (faire défiler les logos de partenaires).
36
36
  */
37
37
  footerSlider?: boolean;
38
+ /**
39
+ * Désactiver la mention à Géo2France
40
+ */
41
+ disablePoweredBy?: boolean;
38
42
  }
39
43
  declare const DashboardApp: React.FC<DashboardConfig>;
40
44
  export default DashboardApp;
@@ -33,8 +33,8 @@ const default_theme = {
33
33
  }
34
34
  };
35
35
  export const AppContext = createContext({});
36
- const DashboardApp = ({ routes, theme, logo, brands, footerSlider, title, subtitle }) => {
36
+ const DashboardApp = ({ routes, theme, logo, brands, footerSlider, title, subtitle, disablePoweredBy = false }) => {
37
37
  const context_values = { title, subtitle, logo };
38
- return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(ConfigProvider, { theme: theme || default_theme /* Merger plutôt ?*/, children: _jsx(HelmetProvider, { children: _jsx(AppContext.Provider, { value: context_values, children: _jsx(DatasetRegistryContext.Provider, { value: createDatasetRegistry(), children: _jsx(ControlContext.Provider, { value: CreateControlesRegistry(), children: _jsx(HashRouter, { children: _jsx(Routes, { children: _jsxs(Route, { element: _jsxs(Layout, { hasSider: true, style: { minHeight: '100vh' }, children: [_jsx(DashboardSider, { route_config: routes }), _jsxs(Layout, { children: [_jsx(Content, { style: { width: "100%" }, children: _jsx(Outlet, {}) }), _jsx(DasbhoardFooter, { brands: brands, slider: footerSlider })] })] }), children: [generateRoutes(routes), _jsx(Route, { path: "*", element: _jsx(ErrorComponent, {}) })] }) }) }) }) }) }) }) }) }));
38
+ return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(ConfigProvider, { theme: theme || default_theme /* Merger plutôt ?*/, children: _jsx(HelmetProvider, { children: _jsx(AppContext.Provider, { value: context_values, children: _jsx(DatasetRegistryContext.Provider, { value: createDatasetRegistry(), children: _jsx(ControlContext.Provider, { value: CreateControlesRegistry(), children: _jsx(HashRouter, { children: _jsx(Routes, { children: _jsxs(Route, { element: _jsxs(Layout, { hasSider: true, style: { minHeight: '100vh' }, children: [_jsx(DashboardSider, { route_config: routes, poweredBy: !disablePoweredBy }), _jsxs(Layout, { children: [_jsx(Content, { style: { width: "100%" }, children: _jsx(Outlet, {}) }), _jsx(DasbhoardFooter, { brands: brands, slider: footerSlider })] })] }), children: [generateRoutes(routes), _jsx(Route, { path: "*", element: _jsx(ErrorComponent, {}) })] }) }) }) }) }) }) }) }) }));
39
39
  };
40
40
  export default DashboardApp;
@@ -4,6 +4,7 @@ interface DbSiderProps {
4
4
  logo?: string;
5
5
  route_config?: RouteConfig[];
6
6
  style?: CSSProperties;
7
+ poweredBy?: boolean;
7
8
  }
8
9
  declare const DashboardSider: React.FC<DbSiderProps>;
9
10
  export default DashboardSider;
@@ -1,16 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useContext, useState } from "react";
3
- import { Layout, Menu, theme, Row, Col, Button, Divider } from "antd";
4
- import { NavLink, useLocation } from "react-router-dom";
3
+ import { Layout, Menu, theme, Row, Col, Button, Divider, Typography } from "antd";
4
+ import { Link, NavLink, useLocation } from "react-router-dom";
5
5
  import { generateMenuItems } from "../../utils/route_utils";
6
6
  import { AppContext } from "./DashboardApp";
7
7
  import { Icon } from "@iconify/react";
8
+ const { Text } = Typography;
8
9
  const style_img = {
9
10
  height: 52,
10
11
  maxWidth: "100%",
11
12
  objectFit: "contain"
12
13
  };
13
- const DashboardSider = ({ style, logo, route_config }) => {
14
+ const DashboardSider = ({ style, logo, route_config, poweredBy = true }) => {
14
15
  const { logo: appLogo, title } = useContext(AppContext);
15
16
  const { token } = theme.useToken();
16
17
  const { pathname: selectedKey } = useLocation();
@@ -32,21 +33,21 @@ const DashboardSider = ({ style, logo, route_config }) => {
32
33
  borderRight: "1px solid #ccc",
33
34
  ...style
34
35
  };
35
- return (_jsx(Layout.Sider, { theme: "light", collapsible: true, collapsedWidth: isMobile ? 40 : 80, collapsed: collapsed, onCollapse: toggleCollapsed, style: siderStyle, width: isMobile ? '80%' : 220, trigger: null, children: _jsxs(Row, { justify: "center", children: [_jsx(Col, { span: 24, children: _jsxs("div", { style: {
36
- margin: 4,
37
- display: "flex",
38
- justifyContent: "center",
39
- alignItems: "center",
40
- backgroundColor: token.colorBgElevated,
41
- }, children: [_jsx(NavLink, { to: "", style: {
42
- display: collapsed ? 'none' : undefined,
43
- marginTop: 8, marginLeft: 8
44
- }, children: _jsx("img", { style: style_img, src: appLogo, alt: title }) }), _jsx(Divider, { style: { display: collapsed ? 'none' : undefined }, type: "vertical" }), _jsx(Button, { type: "text", onClick: () => setCollapsed(!collapsed), icon: collapsed ? _jsx(Icon, { icon: "material-symbols:keyboard-double-arrow-right-rounded" }) : _jsx(Icon, { icon: "material-symbols:keyboard-double-arrow-left-rounded" }), style: {
45
- fontSize: '28px',
46
- width: 32,
47
- height: 32,
48
- //backgroundColor: token.colorFillSecondary,
49
- marginTop: 8
50
- } })] }) }), _jsx(Col, { span: 24, children: _jsx(Menu, { items: route_config && generateMenuItems(route_config), selectedKeys: [selectedKey], mode: "inline", style: { marginTop: "20px", width: "100%" } }) })] }) }));
36
+ return (_jsxs(Layout.Sider, { theme: "light", collapsible: true, collapsedWidth: isMobile ? 40 : 80, collapsed: collapsed, onCollapse: toggleCollapsed, style: siderStyle, width: isMobile ? '80%' : 220, trigger: null, children: [_jsxs(Row, { justify: "center", children: [_jsx(Col, { span: 24, children: _jsxs("div", { style: {
37
+ margin: 4,
38
+ display: "flex",
39
+ justifyContent: "center",
40
+ alignItems: "center",
41
+ backgroundColor: token.colorBgElevated,
42
+ }, children: [_jsx(NavLink, { to: "", style: {
43
+ display: collapsed ? 'none' : undefined,
44
+ marginTop: 8, marginLeft: 8
45
+ }, children: _jsx("img", { style: style_img, src: appLogo, alt: title }) }), _jsx(Divider, { style: { display: collapsed ? 'none' : undefined }, type: "vertical" }), _jsx(Button, { type: "text", onClick: () => setCollapsed(!collapsed), icon: collapsed ? _jsx(Icon, { icon: "material-symbols:keyboard-double-arrow-right-rounded" }) : _jsx(Icon, { icon: "material-symbols:keyboard-double-arrow-left-rounded" }), style: {
46
+ fontSize: '28px',
47
+ width: 32,
48
+ height: 32,
49
+ //backgroundColor: token.colorFillSecondary,
50
+ marginTop: 8
51
+ } })] }) }), _jsx(Col, { span: 24, children: _jsx(Menu, { items: route_config && generateMenuItems(route_config), selectedKeys: [selectedKey], mode: "inline", style: { marginTop: "20px", width: "100%" } }) })] }), (poweredBy && !collapsed) && _jsxs(Text, { type: "secondary", style: { position: "absolute", bottom: 0, left: 0, margin: 4 }, children: ["Propuls\u00E9 et fait avec \u2764\uFE0F par", " ", _jsx(Link, { to: "https://github.com/geo2france/api-dashboard", children: "G\u00E9o2France" })] })] }));
51
52
  };
52
53
  export default DashboardSider;
@@ -1,20 +1,7 @@
1
1
  import axios from "axios";
2
2
  const axiosInstance = axios.create();
3
- axiosInstance.interceptors.response.use(
4
- // API WFS retourne toujours un code http 200, y compris en cas d'erreur.
5
- // Une réponse est présumée valide si elle renvoie un objet json valide. (Sinon c'est un xml)
6
- // TODO parser le XML retourné en cas d'erreur
7
- (response) => {
8
- if (typeof response.data === "object") {
9
- return response;
10
- }
11
- else {
12
- const customError = {
13
- message: response.data,
14
- statusCode: response.status,
15
- };
16
- return Promise.reject(customError);
17
- }
3
+ axiosInstance.interceptors.response.use((response) => {
4
+ return response;
18
5
  }, (error) => {
19
6
  const customError = {
20
7
  ...error,
@@ -21,4 +21,5 @@ import { Statistics, StatisticsCollection } from "../components/Charts/Statistic
21
21
  import { MapLayer, Map } from "../components/Map/Map";
22
22
  import { Section } from "../components/DashboardPage/Section";
23
23
  import { LegendControl } from "../components/MapLegend/MapLegend";
24
- export { Dashboard, Dataset, Provider, Transform, Join, Filter, Section, DataPreview, ChartEcharts, ChartPie, ChartYearSerie, Statistics, StatisticsCollection, useDataset, useDatasets, useAllDatasets, useBlockConfig, Producer, Control, useControl, useAllControls, Radio, Select, Input, Palette, usePalette, usePaletteLabels, PalettePreview, Debug, Map, MapLayer, LegendControl };
24
+ import { Intro } from "../components/DashboardPage/Intro";
25
+ export { Dashboard, Dataset, Provider, Transform, Join, Filter, Section, Intro, DataPreview, ChartEcharts, ChartPie, ChartYearSerie, Statistics, StatisticsCollection, useDataset, useDatasets, useAllDatasets, useBlockConfig, Producer, Control, useControl, useAllControls, Radio, Select, Input, Palette, usePalette, usePaletteLabels, PalettePreview, Debug, Map, MapLayer, LegendControl };
package/dist/dsl/index.js CHANGED
@@ -21,4 +21,5 @@ import { Statistics, StatisticsCollection } from "../components/Charts/Statistic
21
21
  import { MapLayer, Map } from "../components/Map/Map";
22
22
  import { Section } from "../components/DashboardPage/Section";
23
23
  import { LegendControl } from "../components/MapLegend/MapLegend";
24
- export { Dashboard, Dataset, Provider, Transform, Join, Filter, Section, DataPreview, ChartEcharts, ChartPie, ChartYearSerie, Statistics, StatisticsCollection, useDataset, useDatasets, useAllDatasets, useBlockConfig, Producer, Control, useControl, useAllControls, Radio, Select, Input, Palette, usePalette, usePaletteLabels, PalettePreview, Debug, Map, MapLayer, LegendControl };
24
+ import { Intro } from "../components/DashboardPage/Intro";
25
+ export { Dashboard, Dataset, Provider, Transform, Join, Filter, Section, Intro, DataPreview, ChartEcharts, ChartPie, ChartYearSerie, Statistics, StatisticsCollection, useDataset, useDatasets, useAllDatasets, useBlockConfig, Producer, Control, useControl, useAllControls, Radio, Select, Input, Palette, usePalette, usePaletteLabels, PalettePreview, Debug, Map, MapLayer, LegendControl };
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { useMapControl } from "./utils/useMapControl";
7
7
  export { BaseRecordToGeojsonPoint } from "./utils/baserecordtogeojsonpoint";
8
8
  export { cardStyles } from "./utils/cardStyles";
9
9
  export { merge_others } from "./utils/merge_others";
10
+ export { aggregator } from "./utils/aggregator";
10
11
  import KeyFigure from "./components/KeyFigure/KeyFigure";
11
12
  import LoadingContainer from "./components/LoadingContainer/LoadingContainer";
12
13
  import FlipCard from "./components/FlipCard/FlipCard";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { useMapControl } from "./utils/useMapControl";
9
9
  export { BaseRecordToGeojsonPoint } from "./utils/baserecordtogeojsonpoint";
10
10
  export { cardStyles } from "./utils/cardStyles";
11
11
  export { merge_others } from "./utils/merge_others";
12
+ export { aggregator } from "./utils/aggregator";
12
13
  // Components
13
14
  import KeyFigure from "./components/KeyFigure/KeyFigure";
14
15
  import LoadingContainer from "./components/LoadingContainer/LoadingContainer";
@@ -0,0 +1,18 @@
1
+ import { SimpleRecord } from "../types";
2
+ interface AggregatorParams {
3
+ /** Tableau de données */
4
+ data?: SimpleRecord[];
5
+ /** Colonne à aggréger */
6
+ dataKey?: string;
7
+ /** Agregat */
8
+ aggregate: "last" | "first" | "sum" | "lastNotNull" | "min" | "max" | "count" | "mean" | "countDistinct" | "countMissing";
9
+ }
10
+ interface AggregatorResult {
11
+ /** Ligne retenue (pour "last", "first", "lastNotNull") */
12
+ row?: SimpleRecord;
13
+ /** Valeur agrégée */
14
+ value?: number;
15
+ }
16
+ /** Fonction permettant d'agréger une colonne d'un dataset */
17
+ export declare const aggregator: ({ data, dataKey, aggregate }: AggregatorParams) => AggregatorResult;
18
+ export {};
@@ -0,0 +1,49 @@
1
+ import { from, op } from "arquero";
2
+ /** Fonction permettant d'agréger une colonne d'un dataset */
3
+ export const aggregator = ({ data, dataKey, aggregate }) => {
4
+ if (data == undefined || dataKey == undefined || data.length < 1) {
5
+ return { row: undefined, value: undefined };
6
+ }
7
+ switch (aggregate) {
8
+ case "last": {
9
+ const row = data.slice(-1)[0];
10
+ return { row, value: Number(row[dataKey]) };
11
+ }
12
+ case "first": {
13
+ const row = data[0];
14
+ return { row, value: Number(row[dataKey]) };
15
+ }
16
+ case "lastNotNull": {
17
+ const row = data.filter(r => r[dataKey] != null).slice(-1)?.[0];
18
+ return { row, value: Number(row?.[dataKey]) };
19
+ }
20
+ case "sum": {
21
+ const value = from(data).rollup({ value: op.sum(dataKey) }).object().value;
22
+ return { row: undefined, value };
23
+ }
24
+ case "min": {
25
+ const value = from(data).rollup({ value: op.min(dataKey) }).object().value;
26
+ return { row: undefined, value };
27
+ }
28
+ case "max": {
29
+ const value = from(data).rollup({ value: op.max(dataKey) }).object().value;
30
+ return { row: undefined, value };
31
+ }
32
+ case "count": {
33
+ const value = from(data).rollup({ value: op.valid(dataKey) }).object().value;
34
+ return { row: undefined, value };
35
+ }
36
+ case "mean": {
37
+ const value = from(data).rollup({ value: op.average(dataKey) }).object().value;
38
+ return { row: undefined, value };
39
+ }
40
+ case "countDistinct": {
41
+ const value = from(data).rollup({ value: op.distinct(dataKey) }).object().value;
42
+ return { row: undefined, value };
43
+ }
44
+ case "countMissing": {
45
+ const value = from(data).rollup({ value: op.invalid(dataKey) }).object().value;
46
+ return { row: undefined, value };
47
+ }
48
+ }
49
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geo2france/api-dashboard",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "private": false,
5
5
  "description": "Build dashboards with JSX/TSX",
6
6
  "main": "dist/index.js",
@@ -42,7 +42,9 @@
42
42
  },
43
43
  "scripts": {
44
44
  "tsc": "tsc",
45
- "test": "jest --watchAll"
45
+ "test": "jest --watchAll",
46
+ "storybook": "storybook dev -p 6006",
47
+ "build-storybook": "storybook build"
46
48
  },
47
49
  "dependencies": {
48
50
  "@ant-design/react-slick": "^1.1.2",
@@ -57,6 +59,7 @@
57
59
  "echarts": "^6.0.0",
58
60
  "echarts-for-react": "^3.0.4",
59
61
  "query-string": "~7.1.3",
62
+ "react-countup": "^6.5.3",
60
63
  "react-error-boundary": "^6.0.0",
61
64
  "react-helmet": "^6.1.0",
62
65
  "react-helmet-async": "^2.0.5",
@@ -65,7 +68,13 @@
65
68
  },
66
69
  "devDependencies": {
67
70
  "@ant-design/icons": "^6.0.2",
71
+ "@chromatic-com/storybook": "^5.0.0",
68
72
  "@iconify/json": "^2.2.382",
73
+ "@storybook/addon-a11y": "^10.2.1",
74
+ "@storybook/addon-docs": "^10.2.1",
75
+ "@storybook/addon-onboarding": "^10.2.1",
76
+ "@storybook/addon-vitest": "^10.2.1",
77
+ "@storybook/react-vite": "^10.2.1",
69
78
  "@tanstack/react-query": "^5.51.11",
70
79
  "@testing-library/dom": "^10.4.0",
71
80
  "@testing-library/jest-dom": "^6.5.0",
@@ -74,36 +83,41 @@
74
83
  "@types/chroma-js": "^3.1.1",
75
84
  "@types/geojson": "^7946.0.14",
76
85
  "@types/jest": "^29.5.13",
77
- "@types/node": "^22.7.4",
78
- "@types/react": "^18.3.10",
79
- "@types/react-dom": "^18.3.0",
86
+ "@types/node": "^25.1.0",
87
+ "@types/react": "^18.3.1",
88
+ "@types/react-dom": "^18.3.1",
80
89
  "@types/react-helmet-async": "^1.0.1",
81
90
  "@types/react-icons": "^2.2.7",
82
91
  "@typescript-eslint/eslint-plugin": "^7.16.1",
83
92
  "@typescript-eslint/parser": "^7.16.1",
84
93
  "@vitejs/plugin-react": "^4.3.1",
85
- "antd": "^5.24.3",
94
+ "@vitest/browser-playwright": "^4.0.18",
95
+ "@vitest/coverage-v8": "^4.0.18",
96
+ "antd": "^6.2.2",
86
97
  "jest": "^29.7.0",
87
98
  "jest-environment-jsdom": "^29.7.0",
88
99
  "maplibre-gl": "^4.7.1",
100
+ "playwright": "^1.58.0",
89
101
  "react": "^18.3.1",
90
102
  "react-map-gl": "^7.1.9",
91
- "react-router-dom": "^6.25.1",
103
+ "react-router-dom": "^7.13.0",
104
+ "storybook": "^10.2.1",
92
105
  "ts-jest": "^29.2.5",
93
106
  "ts-node": "^10.9.2",
94
107
  "tsup": "^8.5.0",
95
108
  "typescript": "^5.5.2",
96
- "vite": "^5.3.4",
97
- "vite-plugin-svgr": "^4.2.0"
109
+ "vite": "^7.3.1",
110
+ "vite-plugin-svgr": "^4.2.0",
111
+ "vitest": "^4.0.18"
98
112
  },
99
113
  "peerDependencies": {
100
114
  "@ant-design/icons": "^6.0.2",
101
115
  "@tanstack/react-query": "^5.51.11",
102
- "antd": "^5.18.3",
116
+ "antd": "^6.2.2",
103
117
  "maplibre-gl": "^4.7.1",
104
118
  "react": "^18.3.1",
105
119
  "react-dom": "^18.3.1",
106
120
  "react-map-gl": "^7.1.9",
107
- "react-router-dom": "^6.25.1"
121
+ "react-router-dom": "^7.13.0"
108
122
  }
109
123
  }