From 049672eb8a721070f957fca22866cbb6b74a7920 Mon Sep 17 00:00:00 2001 From: Thomas Minier Date: Mon, 18 Sep 2023 10:39:51 +0200 Subject: [PATCH 01/12] fix(Chart): expose `stack` property To make bar charts with groups --- .../AxisAssemblages.stories.tsx | 194 ++++++++++++++++++ .../src/components/Chart/datasets.ts | 1 + .../src/components/Chart/types.ts | 1 + 3 files changed, 196 insertions(+) diff --git a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx index 1b9c1335..b8261dc9 100644 --- a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx +++ b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx @@ -375,6 +375,104 @@ const BarChartPercentageArgs: Props = { }; BarChartPercentage.args = BarChartPercentageArgs; +export const BarChartStackedGroups = ChartTemplate.bind({}); +const BarChartStackedGroupsArgs: Props = { + data: { + loading: false, + value: [ + { series_001: null, series_002: 20, series_003: 50, series_004: 11, x: 'Acer' }, + { series_001: 22, series_002: 28, series_003: 27, series_004: 67, x: 'Aesculus' }, + { series_001: 18, series_002: 18, series_003: 11, series_004: 10, x: 'Alnus' }, + { series_001: 11, series_002: 11, series_003: 33, series_004: 21, x: 'Araucaria' }, + { series_001: 20, series_002: 20, series_003: 9, series_004: 22, x: 'Betula' }, + { series_001: 21, series_002: 21, series_003: 21, series_004: 18.14, x: 'Calocedrus' }, + { series_001: 10, series_002: 9, series_003: 22, series_004: 10, x: 'Catalpa' }, + { series_001: 21, series_002: 19.375, series_003: 15, series_004: 12, x: 'Cedrus' }, + { series_001: 18, series_002: 15, series_003: 20, series_004: 27, x: 'Celtis' }, + ], + }, + options: { + labelColumn: 'x', + title: { + text: 'Bar Chart - Stacked Groups', + }, + series: [ + { + type: ChartSeriesType.Bar, + valueColumn: 'series_001', + indexAxis: 'y', + stack: 'stack_0', + borderColor: 'rgba(255, 0, 0, 1)', + backgroundColor: 'rgba(255, 0, 0, 0.5)', + dataLabels: { + display: false, + color: 'rgba(255, 0, 0, 1)', + }, + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_002', + indexAxis: 'y', + stack: 'stack_0', + borderColor: 'rgba(38, 56, 145, 1)', + backgroundColor: 'rgba(38, 56, 145, 0.5)', + dataLabels: { + display: false, + color: 'rgba(38, 56, 145, 1)', + }, + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_003', + indexAxis: 'y', + stack: 'stack_1', + borderColor: 'rgba(218, 30, 127, 0.8)', + backgroundColor: 'rgba(218, 30, 127, 0.8)', + dataLabels: { + display: false, + color: 'rgba(218, 30, 127, 0.8)', + }, + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_004', + indexAxis: 'y', + stack: 'stack_1', + borderColor: 'rgba(30, 218, 147, 0.8)', + backgroundColor: 'rgba(30, 218, 147, 0.8)', + dataLabels: { + display: false, + color: 'rgba(30, 218, 147, 0.8)', + }, + }, + ], + axis: { + x: { + display: true, + type: 'linear', + gridLines: { + display: true, + }, + ticks: { + display: true, + }, + }, + y: { + display: true, + gridLines: { + display: false, + }, + type: 'category', + }, + assemblage: { + stacked: true, + percentaged: false, + }, + }, + }, +}; +BarChartStackedGroups.args = BarChartStackedGroupsArgs; + export const ColumnChartStacked = ChartTemplate.bind({}); const ColumnChartStackedArgs: Props = { data: { @@ -516,3 +614,99 @@ const ColumnChartPercentageArgs: Props = { }, }; ColumnChartPercentage.args = ColumnChartPercentageArgs; + + +export const ColumnChartStackedGroups = ChartTemplate.bind({}); +const ColumnChartStackedGroupsArgs: Props = { + data: { + loading: false, + value: [ + { series_001: null, series_002: 20, series_003: 50, series_004: 11, x: 'Acer' }, + { series_001: 22, series_002: 28, series_003: 27, series_004: 67, x: 'Aesculus' }, + { series_001: 18, series_002: 18, series_003: 11, series_004: 10, x: 'Alnus' }, + { series_001: 11, series_002: 11, series_003: 33, series_004: 21, x: 'Araucaria' }, + { series_001: 20, series_002: 20, series_003: 9, series_004: 22, x: 'Betula' }, + { series_001: 21, series_002: 21, series_003: 21, series_004: 18.14, x: 'Calocedrus' }, + { series_001: 10, series_002: 9, series_003: 22, series_004: 10, x: 'Catalpa' }, + { series_001: 21, series_002: 19.375, series_003: 15, series_004: 12, x: 'Cedrus' }, + { series_001: 18, series_002: 15, series_003: 20, series_004: 27, x: 'Celtis' }, + ], + }, + options: { + labelColumn: 'x', + title: { + text: 'Column Chart - Stacked Groups', + }, + series: [ + { + type: ChartSeriesType.Bar, + valueColumn: 'series_001', + borderColor: 'rgba(255, 0, 0, 1)', + backgroundColor: 'rgba(255, 0, 0, 0.5)', + dataLabels: { + display: false, + color: 'rgba(255, 0, 0, 1)', + }, + stack: 'stack_0', + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_002', + borderColor: 'rgba(38, 56, 145, 1)', + backgroundColor: 'rgba(38, 56, 145, 0.5)', + dataLabels: { + display: false, + color: 'rgba(38, 56, 145, 1)', + }, + stack: 'stack_0', + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_003', + stack: 'stack_1', + borderColor: 'rgba(218, 30, 127, 0.8)', + backgroundColor: 'rgba(218, 30, 127, 0.8)', + dataLabels: { + display: false, + color: 'rgba(218, 30, 127, 0.8)', + }, + }, + { + type: ChartSeriesType.Bar, + valueColumn: 'series_004', + stack: 'stack_1', + borderColor: 'rgba(30, 218, 147, 0.8)', + backgroundColor: 'rgba(30, 218, 147, 0.8)', + dataLabels: { + display: false, + color: 'rgba(30, 218, 147, 0.8)', + }, + }, + ], + axis: { + x: { + display: true, + offset: true, + gridLines: { + display: false, + }, + type: 'category', + }, + y: { + display: true, + type: 'linear', + gridLines: { + display: true, + }, + ticks: { + display: true, + }, + }, + assemblage: { + stacked: true, + percentaged: false, + }, + }, + }, +}; +ColumnChartStackedGroups.args = ColumnChartStackedGroupsArgs; \ No newline at end of file diff --git a/packages/visualizations/src/components/Chart/datasets.ts b/packages/visualizations/src/components/Chart/datasets.ts index 7fb1c48a..81a1162c 100644 --- a/packages/visualizations/src/components/Chart/datasets.ts +++ b/packages/visualizations/src/components/Chart/datasets.ts @@ -46,6 +46,7 @@ export default function toDataset(df: DataFrame, s: ChartSeries): ChartDataset { barPercentage: defaultValue(s.barPercentage, 0.9), categoryPercentage: defaultValue(s.categoryPercentage, 0.8), datalabels: chartJsDataLabels(s.dataLabels), + stack: s.stack, }; } diff --git a/packages/visualizations/src/components/Chart/types.ts b/packages/visualizations/src/components/Chart/types.ts index 3f24a95c..3e6b7757 100644 --- a/packages/visualizations/src/components/Chart/types.ts +++ b/packages/visualizations/src/components/Chart/types.ts @@ -176,6 +176,7 @@ export interface Bar { categoryPercentage?: number; barPercentage?: number; dataLabels?: DataLabelsConfiguration; + stack?: string; } export interface Pie { From f046cdd6894caf72222465a963b9a36c1d8d5317 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Mon, 18 Sep 2023 10:44:24 +0200 Subject: [PATCH 02/12] chore(release): publish new versions - @opendatasoft/visualizations-react@0.15.3 - @opendatasoft/visualizations@0.15.3 --- packages/visualizations-react/CHANGELOG.md | 11 +++++++++++ packages/visualizations-react/package-lock.json | 4 ++-- packages/visualizations-react/package.json | 4 ++-- packages/visualizations/CHANGELOG.md | 11 +++++++++++ packages/visualizations/package-lock.json | 4 ++-- packages/visualizations/package.json | 2 +- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/visualizations-react/CHANGELOG.md b/packages/visualizations-react/CHANGELOG.md index 5c96762b..a634f14a 100644 --- a/packages/visualizations-react/CHANGELOG.md +++ b/packages/visualizations-react/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.15.3](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.15.2...@opendatasoft/visualizations-react@0.15.3) (2023-09-18) + + +### Bug Fixes + +* **Chart:** expose `stack` property ([049672e](https://github.com/opendatasoft/ods-dataviz-sdk/commit/049672eb8a721070f957fca22866cbb6b74a7920)) + + + + + ## [0.15.2](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.15.1...@opendatasoft/visualizations-react@0.15.2) (2023-09-07) diff --git a/packages/visualizations-react/package-lock.json b/packages/visualizations-react/package-lock.json index c01befad..ffd1d4d5 100644 --- a/packages/visualizations-react/package-lock.json +++ b/packages/visualizations-react/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.15.2", + "version": "0.15.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations-react", - "version": "0.15.2", + "version": "0.15.3", "license": "MIT", "dependencies": { "use-callback-ref": "^1.2.4" diff --git a/packages/visualizations-react/package.json b/packages/visualizations-react/package.json index 11ec3285..08ea2e0f 100644 --- a/packages/visualizations-react/package.json +++ b/packages/visualizations-react/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.15.2", + "version": "0.15.3", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", @@ -53,7 +53,7 @@ "trailingComma": "es5" }, "dependencies": { - "@opendatasoft/visualizations": "0.15.2", + "@opendatasoft/visualizations": "0.15.3", "use-callback-ref": "^1.2.4" }, "devDependencies": { diff --git a/packages/visualizations/CHANGELOG.md b/packages/visualizations/CHANGELOG.md index 345ec380..0f8c4442 100644 --- a/packages/visualizations/CHANGELOG.md +++ b/packages/visualizations/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.15.3](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.15.2...@opendatasoft/visualizations@0.15.3) (2023-09-18) + + +### Bug Fixes + +* **Chart:** expose `stack` property ([049672e](https://github.com/opendatasoft/ods-dataviz-sdk/commit/049672eb8a721070f957fca22866cbb6b74a7920)) + + + + + ## [0.15.2](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.15.1...@opendatasoft/visualizations@0.15.2) (2023-09-07) diff --git a/packages/visualizations/package-lock.json b/packages/visualizations/package-lock.json index 9baff2a1..de6624c3 100644 --- a/packages/visualizations/package-lock.json +++ b/packages/visualizations/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations", - "version": "0.15.2", + "version": "0.15.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations", - "version": "0.15.2", + "version": "0.15.3", "license": "MIT", "dependencies": { "@mapbox/geo-viewport": "^0.5.0", diff --git a/packages/visualizations/package.json b/packages/visualizations/package.json index e4a3dc8f..74125c50 100644 --- a/packages/visualizations/package.json +++ b/packages/visualizations/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations", - "version": "0.15.2", + "version": "0.15.3", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", From ec7f61620fc66c47521cf2955379c9f452b6f5b1 Mon Sep 17 00:00:00 2001 From: AnthoPepin <115981142+AnthoPepin@users.noreply.github.com> Date: Tue, 19 Sep 2023 09:32:55 +0200 Subject: [PATCH 03/12] [FEATURE][SC-42061] Add scatter chart (#182) * feat(Chart): Add scatter type --------- Co-authored-by: Kevin Fabre --- .../AxisAssemblages.stories.tsx | 143 +++++++++++++++--- .../src/components/Chart/Chart.svelte | 7 + .../src/components/Chart/datasets.ts | 13 ++ .../src/components/Chart/scales.ts | 6 + .../src/components/Chart/types.ts | 30 +++- 5 files changed, 175 insertions(+), 24 deletions(-) diff --git a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx index b8261dc9..015f6625 100644 --- a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx +++ b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx @@ -6,12 +6,12 @@ import { COLORS } from '../../utils'; import ChartTemplate from '../ChartTemplate'; const meta: Meta = { + component: ChartTemplate, title: 'Chart/AxisAssemblages', }; export default meta; -export const AreaChartStacked = ChartTemplate.bind({}); const AreaChartStackedArgs: Props = { data: { loading: false, @@ -63,9 +63,8 @@ const AreaChartStackedArgs: Props = { }, }, }; -AreaChartStacked.args = AreaChartStackedArgs; +export const AreaChartStacked = {args: AreaChartStackedArgs}; -export const AreaChartPercentage = ChartTemplate.bind({}); const AreaChartPercentageArgs: Props = { data: { loading: false, @@ -117,9 +116,8 @@ const AreaChartPercentageArgs: Props = { }, }, }; -AreaChartPercentage.args = AreaChartPercentageArgs; +export const AreaChartPercentage = {args: AreaChartPercentageArgs}; -export const LineChartStacked = ChartTemplate.bind({}); const LineChartStackedArgs: Props = { data: { loading: false, @@ -173,9 +171,8 @@ const LineChartStackedArgs: Props = { }, }, }; -LineChartStacked.args = LineChartStackedArgs; +export const LineChartStacked = {args: LineChartStackedArgs}; -export const LineChartPercentage = ChartTemplate.bind({}); const LineChartPercentageArgs: Props = { data: { loading: false, @@ -229,9 +226,8 @@ const LineChartPercentageArgs: Props = { }, }, }; -LineChartPercentage.args = LineChartPercentageArgs; +export const LineChartPercentage = {args: LineChartPercentageArgs}; -export const BarChartStacked = ChartTemplate.bind({}); const BarChartStackedArgs: Props = { data: { loading: false, @@ -301,9 +297,8 @@ const BarChartStackedArgs: Props = { }, }, }; -BarChartStacked.args = BarChartStackedArgs; +export const BarChartStacked = {args: BarChartStackedArgs}; -export const BarChartPercentage = ChartTemplate.bind({}); const BarChartPercentageArgs: Props = { data: { loading: false, @@ -373,9 +368,8 @@ const BarChartPercentageArgs: Props = { }, }, }; -BarChartPercentage.args = BarChartPercentageArgs; +export const BarChartPercentage = {args: BarChartPercentageArgs}; -export const BarChartStackedGroups = ChartTemplate.bind({}); const BarChartStackedGroupsArgs: Props = { data: { loading: false, @@ -471,9 +465,8 @@ const BarChartStackedGroupsArgs: Props = { }, }, }; -BarChartStackedGroups.args = BarChartStackedGroupsArgs; +export const BarChartStackedGroups = {args: BarChartStackedGroupsArgs}; -export const ColumnChartStacked = ChartTemplate.bind({}); const ColumnChartStackedArgs: Props = { data: { loading: false, @@ -542,9 +535,8 @@ const ColumnChartStackedArgs: Props = { }, }, }; -ColumnChartStacked.args = ColumnChartStackedArgs; +export const ColumnChartStacked = {args: ColumnChartStackedArgs}; -export const ColumnChartPercentage = ChartTemplate.bind({}); const ColumnChartPercentageArgs: Props = { data: { loading: false, @@ -613,10 +605,8 @@ const ColumnChartPercentageArgs: Props = { }, }, }; -ColumnChartPercentage.args = ColumnChartPercentageArgs; +export const ColumnChartPercentage = {args: ColumnChartPercentageArgs}; - -export const ColumnChartStackedGroups = ChartTemplate.bind({}); const ColumnChartStackedGroupsArgs: Props = { data: { loading: false, @@ -709,4 +699,115 @@ const ColumnChartStackedGroupsArgs: Props = { }, }, }; -ColumnChartStackedGroups.args = ColumnChartStackedGroupsArgs; \ No newline at end of file +export const ColumnChartStackedGroups = {args: ColumnChartStackedGroupsArgs}; + +const ScatterPlotChartArgs: Props = { + data: { + loading: false, + value: [ + {label: 'id-0', x: -10, y: 20}, + {label: 'id-1', x: 20, y: -10}, + {label: 'id-2', x: 5, y: 2}, + {label: 'id-3', x: 7, y: 3} + ], + }, + options: { + labelColumn: 'label', + series: [ + { + type: ChartSeriesType.Scatter, + label: "Serie 1", + valueColumn:"x", + indexAxis:"y", + backgroundColor: COLORS.blue, + }, + ], + axis: { + x: { + display: true, + type: 'linear', + title: { + display: true, + text: "Horizontal axis" + }, + }, + y: { + display: true, + title: { + display: true, + text: "Vertical axis" + }, + type: 'linear', + }, + }, + title: { + text: 'Scatterplot Chart', + }, + }, +}; +export const ScatterplotChart = {args: ScatterPlotChartArgs}; + +function randomNormal(mean = 0, stdDev = 1) { + let u1 = 0; + let u2 = 0; + while (u1 === 0) u1 = Math.random(); + while (u2 === 0) u2 = Math.random(); + const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2); + return z0 * stdDev + mean; + } + +function generateNormalDistribution(n: number, xMean = 0, xStdDev = 1, yMean = 0, yStdDev = 1) { + const points = []; + for (let i = 0; i < n; i++) { + points.push({ + label: `id-${i}`, + x: randomNormal(xMean, xStdDev), + y: randomNormal(yMean, yStdDev), + }); + } + return points; + } + +const ScatterplotNormalDistribChartArgs: Props = { + data: { + loading: false, + value: generateNormalDistribution(1000, 5, 2, 5, 2), + }, + options: { + labelColumn: 'label', + series: [ + { + label: "Serie 1", + type: ChartSeriesType.Scatter, + valueColumn:"x", + indexAxis:"y", + backgroundColor: COLORS.blue, + }, + ], + axis: { + x: { + display: true, + title: { + display: true, + text: "Horizontal axis" + }, + beginAtZero: true + }, + y: { + display: true, + title: { + display: true, + text: "Vertical axis" + }, + beginAtZero: true + }, + }, + title: { + text: 'Scatterplot Chart - Normal distribution', + }, + }, +}; +export const ScatterplotNormalDistribChart = { + args: ScatterplotNormalDistribChartArgs, + parameters: {chromatic: { disableSnapshot: true }} +}; diff --git a/packages/visualizations/src/components/Chart/Chart.svelte b/packages/visualizations/src/components/Chart/Chart.svelte index 87bb3c3a..c5c38567 100644 --- a/packages/visualizations/src/components/Chart/Chart.svelte +++ b/packages/visualizations/src/components/Chart/Chart.svelte @@ -139,6 +139,13 @@ // charts, the label is not the series legend, it's the category. return `${dataFrame[dataIndex].x}: ${format(parsed)}`; } + if (seriesType === ChartSeriesType.Scatter) { + const formattedValues = `${format(parsed.x)}, ${format(parsed.y)}`; + // e.g. dataset 1: (4.5, 54) + if (prefix) return `${prefix}(${formattedValues})`; + // 4.5, 54 + return formattedValues; + } } return prefix + formattedValue + suffix; diff --git a/packages/visualizations/src/components/Chart/datasets.ts b/packages/visualizations/src/components/Chart/datasets.ts index 81a1162c..b611e1de 100644 --- a/packages/visualizations/src/components/Chart/datasets.ts +++ b/packages/visualizations/src/components/Chart/datasets.ts @@ -101,5 +101,18 @@ export default function toDataset(df: DataFrame, s: ChartSeries): ChartDataset { }; } + if (s.type === 'scatter') { + return { + type: 'scatter', + label: defaultValue(s.label, ''), + data: df.map((entry) => ({ x: entry[s.indexAxis], y: entry[s.valueColumn] })), + datalabels: chartJsDataLabels(s.dataLabels), + backgroundColor: singleChartJsColor(s.backgroundColor), + pointRadius: defaultValue(s.pointRadius, 5), + pointHitRadius: defaultValue(s.pointHitRadius, 5), + pointHoverRadius: defaultValue(s.pointHoverRadius, 5), + pointBorderColor: defaultValue(s.pointBorderColor, 'rgba(255,255,255, 0)'), + }; + } throw new Error('Unknown chart type'); } diff --git a/packages/visualizations/src/components/Chart/scales.ts b/packages/visualizations/src/components/Chart/scales.ts index c256d144..6e682b96 100644 --- a/packages/visualizations/src/components/Chart/scales.ts +++ b/packages/visualizations/src/components/Chart/scales.ts @@ -81,6 +81,9 @@ export default function buildScales(options: ChartOptions): ChartJsChartOptions[ // X Axis if (options.axis?.x) { scales.x = { + ...(options.axis.x.type === 'linear' && { + beginAtZero: defaultValue(options?.axis?.x?.beginAtZero, true), + }), stacked: options.axis?.assemblage?.stacked, max: options?.axis?.x?.type === 'linear' && options.axis?.assemblage?.percentaged @@ -130,6 +133,9 @@ export default function buildScales(options: ChartOptions): ChartJsChartOptions[ // Y Axis if (options.axis?.y) { scales.y = { + ...(options.axis.y.type === 'linear' && { + beginAtZero: defaultValue(options?.axis?.y?.beginAtZero, true), + }), stacked: options.axis?.assemblage?.stacked, max: options?.axis?.y?.type === 'linear' && options.axis?.assemblage?.percentaged diff --git a/packages/visualizations/src/components/Chart/types.ts b/packages/visualizations/src/components/Chart/types.ts index 3e6b7757..349a87c9 100644 --- a/packages/visualizations/src/components/Chart/types.ts +++ b/packages/visualizations/src/components/Chart/types.ts @@ -76,10 +76,19 @@ export interface CategoryCartesianAxisConfiguration extends BaseCartesianAxisCon type?: 'category'; } -export interface NumericCartesianAxisConfiguration extends BaseCartesianAxisConfiguration { - type: 'linear' | 'logarithmic'; +export interface LinearCartesianAxisConfiguration extends BaseCartesianAxisConfiguration { + type: 'linear'; + beginAtZero?: boolean; +} + +export interface LogarithmicCartesianAxisConfiguration extends BaseCartesianAxisConfiguration { + type: 'logarithmic'; } +export type NumericCartesianAxisConfiguration = + | LinearCartesianAxisConfiguration + | LogarithmicCartesianAxisConfiguration; + export interface AxisTitleConfiguration { display?: boolean; align?: 'start' | 'center' | 'end'; @@ -138,7 +147,7 @@ export interface DataLabelsConfiguration { padding?: number; } -export type ChartSeries = Line | Bar | Pie | Radar | Doughnut; +export type ChartSeries = Line | Bar | Pie | Radar | Doughnut | Scatter; export enum ChartSeriesType { Line = 'line', @@ -146,6 +155,7 @@ export enum ChartSeriesType { Pie = 'pie', Radar = 'radar', Doughnut = 'doughnut', + Scatter = 'scatter', } export interface Line { @@ -209,6 +219,20 @@ export interface Doughnut { indexAxis?: 'x' | 'y'; } +export interface Scatter { + type: ChartSeriesType.Scatter; + valueColumn: string; + label?: string; + indexAxis: string; + /** Point color */ + backgroundColor?: Color | Color[]; + pointRadius?: number; + pointHitRadius?: number; + pointHoverRadius?: number; + pointBorderColor?: string; + dataLabels?: DataLabelsConfiguration; +} + export type FillMode = false | number | string | { value: number }; export interface FillConfiguration { From 511bb8ba4f7d305c0d1f6979e897b268b5f94d1f Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Fri, 1 Sep 2023 09:36:49 +0200 Subject: [PATCH 04/12] fix: add typing for API export --- packages/api-client/src/client/types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/api-client/src/client/types.ts b/packages/api-client/src/client/types.ts index 90404390..719d0254 100644 --- a/packages/api-client/src/client/types.ts +++ b/packages/api-client/src/client/types.ts @@ -59,6 +59,9 @@ export interface ApiQuery { results: T[]; } +export interface ApiExport { + [key: string]: T; +} export const EnumExportCatalogFormat = { CSV: 'csv', JSON: 'json', From 0b0a746121195eb0f5b9837b8459a431f9ef5fa0 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Mon, 31 Jul 2023 16:50:54 +0200 Subject: [PATCH 05/12] fix: add MVT dataset export format --- packages/api-client/src/client/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api-client/src/client/types.ts b/packages/api-client/src/client/types.ts index 719d0254..07963bd9 100644 --- a/packages/api-client/src/client/types.ts +++ b/packages/api-client/src/client/types.ts @@ -95,6 +95,7 @@ export const EnumExportDatasetFormat = { RDFXML: 'rdfxml', TURTLE: 'turtle', N3: 'n3', + MVT: 'mvt', } as const; export type ExportDatasetFormat = From 023eb288c27570addd2efb3bfc82dbabf25fb169 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Fri, 1 Sep 2023 09:38:05 +0200 Subject: [PATCH 06/12] feat: add PoiMap component --- .../src/components/PoiMap.tsx | 7 + packages/visualizations-react/src/index.tsx | 1 + .../stories/Poi/PoiMap.stories.tsx | 112 +++++++ .../stories/Poi/sources.ts | 97 +++++++ .../visualizations-react/stories/utils.ts | 4 + .../src/components/MapPoi/Map.svelte | 33 +++ .../src/components/MapPoi/Map.ts | 274 ++++++++++++++++++ .../src/components/MapPoi/MapRender.svelte | 81 ++++++ .../src/components/MapPoi/constants.ts | 21 ++ .../src/components/MapPoi/index.ts | 11 + .../src/components/MapPoi/mapStyles.ts | 9 + .../src/components/MapPoi/types.ts | 76 +++++ .../src/components/MapPoi/utils.ts | 76 +++++ packages/visualizations/src/index.ts | 2 + 14 files changed, 804 insertions(+) create mode 100644 packages/visualizations-react/src/components/PoiMap.tsx create mode 100644 packages/visualizations-react/stories/Poi/PoiMap.stories.tsx create mode 100644 packages/visualizations-react/stories/Poi/sources.ts create mode 100644 packages/visualizations/src/components/MapPoi/Map.svelte create mode 100644 packages/visualizations/src/components/MapPoi/Map.ts create mode 100644 packages/visualizations/src/components/MapPoi/MapRender.svelte create mode 100644 packages/visualizations/src/components/MapPoi/constants.ts create mode 100644 packages/visualizations/src/components/MapPoi/index.ts create mode 100644 packages/visualizations/src/components/MapPoi/mapStyles.ts create mode 100644 packages/visualizations/src/components/MapPoi/types.ts create mode 100644 packages/visualizations/src/components/MapPoi/utils.ts diff --git a/packages/visualizations-react/src/components/PoiMap.tsx b/packages/visualizations-react/src/components/PoiMap.tsx new file mode 100644 index 00000000..2508a110 --- /dev/null +++ b/packages/visualizations-react/src/components/PoiMap.tsx @@ -0,0 +1,7 @@ +import { PoiMap as _PoiMap, PoiMapOptions, PoiMapData, Async } from '@opendatasoft/visualizations'; +import { FC } from 'react'; +import wrap from './ReactImpl'; + +// Explicit name and type are needed for Storybook +const PoiMap: FC<{ data: Async; options: PoiMapOptions }> = wrap(_PoiMap); +export default PoiMap; diff --git a/packages/visualizations-react/src/index.tsx b/packages/visualizations-react/src/index.tsx index fff7ccf5..2a3bbb97 100644 --- a/packages/visualizations-react/src/index.tsx +++ b/packages/visualizations-react/src/index.tsx @@ -5,3 +5,4 @@ export { default as KpiCard } from './components/KpiCard'; export { default as ChoroplethGeoJson } from './components/ChoroplethGeoJson'; export { default as ChoroplethVectorTiles } from './components/ChoroplethVectorTiles'; export { default as ChoroplethSvg } from './components/ChoroplethSvg'; +export { default as PoiMap } from './components/PoiMap'; diff --git a/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx new file mode 100644 index 00000000..2eedb4e5 --- /dev/null +++ b/packages/visualizations-react/stories/Poi/PoiMap.stories.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { BBox } from 'geojson'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; + +import type { Layer } from '@opendatasoft/visualizations'; +import { PopupDisplayTypes } from '@opendatasoft/visualizations'; + +import sources from './sources'; +import { PoiMap } from '../../src'; +import { timeout } from '../utils'; + +const BASE_STYLE = 'https://demotiles.maplibre.org/style.json'; + +const layer1: Layer = { + id: 'layer-001', + source: 'cities', + type: 'circle', + color: 'black', + popup: { + display: PopupDisplayTypes.Tooltip, + getContent: async (_, properties) => { + await timeout(500); + const { key } = properties as { key: string }; + return Promise.resolve(`

${key}

`); + }, + getLoadingContent: () => 'Loading...', + }, +}; + +const layer2: Layer = { + id: 'layer-002', + source: 'battles', + type: 'circle', + color: 'red', + popup: { + display: PopupDisplayTypes.Sidebar, + getContent: async (_, properties) => { + await timeout(500); + const { name, date, description } = properties as { + name: string; + date: string; + description: string; + }; + return Promise.resolve(`

${name}

${description}

${date}`); + }, + getLoadingContent: () => 'Loading...', + }, +}; + +const layers = [layer1, layer2]; + +const citiesColorMatch = { + key: 'key', + colors: { Paris: 'blue', Nantes: 'yellow', Bordeaux: 'purple', Marseille: 'lightblue' }, +}; + +const bbox: BBox = [-6.855469, 41.343825, 11.645508, 51.37178]; + +const meta: ComponentMeta = { + title: 'Poi/PoiMap', + component: PoiMap, +}; + +export default meta; + +const Template: ComponentStory = (args) => ( +

+ +
+); + +/** + * STORY: No layer params + */ +export const PoiMapNoLayersParams: ComponentStory = Template.bind({}); +const PoiMapNoLayersParamsArgs = { + data: {}, + options: { style: BASE_STYLE, bbox }, +}; +PoiMapNoLayersParams.args = PoiMapNoLayersParamsArgs; + +/** + * STORY: No interactive + */ +export const PoiMapNonInteractive: ComponentStory = Template.bind({}); +const PoiMapNonInteractiveArgs = { + data: { value: { layers, sources } }, + options: { + style: BASE_STYLE, + bbox, + interactive: false, + }, +}; +PoiMapNonInteractive.args = PoiMapNonInteractiveArgs; + +/** + * STORY: With match expression + */ +export const PoiMapMatchExpression: ComponentStory = Template.bind({}); +const PoiMapMatchExpressionArgs = { + data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + options: { style: BASE_STYLE, bbox }, +}; +PoiMapMatchExpression.args = PoiMapMatchExpressionArgs; diff --git a/packages/visualizations-react/stories/Poi/sources.ts b/packages/visualizations-react/stories/Poi/sources.ts new file mode 100644 index 00000000..5b2b723b --- /dev/null +++ b/packages/visualizations-react/stories/Poi/sources.ts @@ -0,0 +1,97 @@ + +import { PoiMapData } from '@opendatasoft/visualizations'; + +const sources : PoiMapData["sources"] = { + cities : { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [ + { + id: 1, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [2.357573,48.837904], + }, + properties: { + key: 'Paris' + }, + }, + { + id: 2, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-0.563328,44.838245], + }, + properties: { + key: 'Bordeaux' + }, + }, + { + id: 3, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-1.552924,47.214847], + }, + properties: { + key: 'Nantes' + }, + }, + { + id: 4, + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [5.360529,43.303114], + }, + properties: { + key: 'Marseille' + }, + }, + ], + } + }, + battles : { + type: 'geojson', + data: { + type: "FeatureCollection", + features: [ + { + id: 5, + type: "Feature", + properties: { + name: "Battle of Verdun", + date: "1916", + description: "The Battle of Verdun was fought from 21 February to 18 December 1916 on the Western Front in France. The battle was the longest of the First World War and took place on the hills north of Verdun-sur-Meuse." + }, + geometry: { + type: "Point", + coordinates: [5.422, 49.208] + } + }, + { + id: 6, + type: "Feature", + properties: { + name: "Battle of the Somme", + date: "1916", + description: "The Battle of the Somme, also known as the Somme offensive, was a battle of the First World War fought by the armies of the British Empire and the French Third Republic against the German Empire." + }, + geometry: { + type: "Point", + coordinates: [2.712, 49.993 ] + } + }, + ] + } + + } +}; + +export default sources; + + + diff --git a/packages/visualizations-react/stories/utils.ts b/packages/visualizations-react/stories/utils.ts index 7f83ab60..2a6b3240 100644 --- a/packages/visualizations-react/stories/utils.ts +++ b/packages/visualizations-react/stories/utils.ts @@ -136,3 +136,7 @@ export const comparisonFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1, signDisplay: 'exceptZero', }); + +export async function timeout(ms : number) { + await new Promise((resolve) => {setTimeout(resolve, ms); }); +} diff --git a/packages/visualizations/src/components/MapPoi/Map.svelte b/packages/visualizations/src/components/MapPoi/Map.svelte new file mode 100644 index 00000000..27acab88 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/Map.svelte @@ -0,0 +1,33 @@ + + +
+ {#key style} + + {/key} +
+ + diff --git a/packages/visualizations/src/components/MapPoi/Map.ts b/packages/visualizations/src/components/MapPoi/Map.ts new file mode 100644 index 00000000..4aa70793 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/Map.ts @@ -0,0 +1,274 @@ +import type { BBox } from 'geojson'; +import { debounce } from 'lodash'; +import maplibregl, { + LngLatBoundsLike, + LngLatLike, + MapGeoJSONFeature, + MapLayerMouseEvent, + MapOptions, + StyleSpecification, +} from 'maplibre-gl'; + +import { DEFAULT_MAP_CENTER, POPUP_OPTIONS } from './constants'; +import type { PopupsConfiguration } from './types'; + +type MapFunction = (map: maplibregl.Map) => unknown; + +export default class MapPOI { + /** The Map object representing the maplibregl.Map instance. */ + private map: maplibregl.Map | null = null; + + /** Map resize observer */ + private mapResizeObserver: ResizeObserver | null = null; + + /** Flag indicating whether the map is ready. */ + private isReady = false; + + /** The base style of the map */ + private baseStyle: StyleSpecification | null = null; + + /** Array of layer IDs that are not from the base style of the map */ + private layerIds: string[] = []; + + /** Array of source IDs used in the map */ + private sourceIds: string[] = []; + + /** A navigation control for the map. */ + private navigationControl = new maplibregl.NavigationControl({ showCompass: false }); + + /** A popup for displaying information on the map. */ + private popup = new maplibregl.Popup(POPUP_OPTIONS); + + /** Popups configurations. One configuration by layer */ + private popupsConfiguration: PopupsConfiguration = {}; + + /** An array of GeoJSONFeatures associated with the popup. */ + private popupFeatures: MapGeoJSONFeature[] = []; + + /** An array of functions to be executed when the map is ready. */ + private queuedFunctions: Array = []; + + /** To queue functions that depend on map readiness. Will be executed when the card is ready. */ + private queue(fn: MapFunction) { + if (this.isReady && this.map) fn(this.map); + else this.queuedFunctions.push(fn); + } + + /** Execute queued functions */ + private enqueue(map: maplibregl.Map) { + this.queuedFunctions.forEach((fn) => fn(map)); + this.queuedFunctions = []; + } + + /** Initialize a resize observer to always fit the map to its container */ + private initializeMapResizer(map: maplibregl.Map, container: HTMLElement) { + // Set a resizeObserver to resize map on container size changes + this.mapResizeObserver = new ResizeObserver( + debounce(() => { + map.resize(); + }, 100) + ); + this.mapResizeObserver.observe(container); + } + + /** + * Event handler for click events on the map. + * Currently, is only used to handle popup display. + * @param {MapLayerMouseEvent} event + */ + private onClick({ point }: MapLayerMouseEvent) { + this.queue((map) => { + /** + * Get features closed to the click. + * We ask for features that are not in base style layers + */ + const features = map.queryRenderedFeatures(point, { layers: this.layerIds }); + + // Will close the popup + if (this.popupFeatures.length) return; + + // If features from selected layers, update the popup + if (features.length) { + this.popupFeatures = features; + this.setPopup(map); + } + }); + } + + private bindedOnClick = this.onClick.bind(this); + + /** Event handler for popup close event. */ + private onPopupClose() { + this.popupFeatures.forEach(({ source, sourceLayer, id }) => { + if (this.sourceIds.includes(source)) { + this.queue((map) => { + map.setFeatureState({ source, sourceLayer, id }, { 'popup-feature': false }); + }); + } + }); + this.popupFeatures = []; + } + + private bindedOnPopupClose = this.onPopupClose.bind(this); + + /** Set the popup content and positioning */ + private setPopup(map: maplibregl.Map) { + if (!this.popupFeatures.length) return; + + // Current rule: use the first feature to build the popup. + // TO DO: Create a menu to display a list of feature to choose from. + const { + id: featureId, + layer: { id: layerId }, + geometry, + properties, + source, + sourceLayer, + } = this.popupFeatures[0]; + + if (geometry.type !== 'Point') return; + + // Get the popup configuration for a layer + const popupConfiguration = this.popupsConfiguration[layerId]; + + /* + * We remove the popup if: + * - no popup configuration for a layer + * - popup's source is no longer used in the map + */ + if (!popupConfiguration || !this.sourceIds.includes(source)) { + this.popup.remove(); + this.popupFeatures = []; + return; + } + + const { display, getContent, getLoadingContent } = popupConfiguration; + + if (this.popup.isOpen() === false) { + this.popup.setLngLat(geometry.coordinates.slice() as LngLatLike).addTo(map); + } + + this.popup.setHTML(getLoadingContent()); + getContent(featureId, properties).then((content) => { + this.popup.setHTML(content); + }); + + const classnameModifier = display === 'sidebar' ? 'addClassName' : 'removeClassName'; + this.popup[classnameModifier](`${POPUP_OPTIONS.className}--as-sidebar`); + + if (featureId) { + map.setFeatureState({ source, sourceLayer, id: featureId }, { 'popup-feature': true }); + } + } + + initialize( + style: MapOptions['style'], + container: HTMLElement, + options: Omit + ) { + this.map = new maplibregl.Map({ style, container, center: DEFAULT_MAP_CENTER, ...options }); + + this.queue((map) => this.initializeMapResizer(map, container)); + + this.map.on('load', () => { + this.isReady = true; + if (this.map) { + // Store base style after the first load + this.baseStyle = this.map?.getStyle(); + this.map.on('click', this.bindedOnClick); + this.popup.on('close', this.bindedOnPopupClose); + this.enqueue(this.map); + } + }); + } + + destroy() { + this.popupFeatures = []; + this.popup.remove(); + this.queue((map) => map.remove()); + this.mapResizeObserver?.disconnect(); + } + + // TODO: add tests to check that layers are at the end of the array + /* + * TODO: When updating Maplibre to a 3.2.2 version or up + * - Update this code to use the option transformStyle. + * https://maplibre.org/maplibre-gl-js/docs/API/types/maplibregl.TransformStyleFunction/ + * - `baseStyle` could be removed + * - The key block could also be removed in MapRender.svelte + */ + /** + * Update the sources and layers of the map. + * Layers of the map base style are untouched. + */ + setSourcesAndLayers( + sources: StyleSpecification['sources'], + layers: StyleSpecification['layers'] + ) { + this.queue((map) => { + if (this.baseStyle) { + map.setStyle({ + ...this.baseStyle, + sources: { + ...sources, + ...this.baseStyle.sources, + }, + layers: [...this.baseStyle.layers, ...layers], + }); + } + this.layerIds = layers.map(({ id }) => id); + this.sourceIds = Object.keys(sources); + }); + } + + setBbox(bbox?: BBox) { + this.queue((map) => { + if (!bbox) { + // zoom-out to bounds defined in the initialization + map.setZoom(map.getMinZoom()); + return; + } + + // Cancel any saved max bounds to properly fitBounds + map.setMaxBounds(null); + // Using padding, keep enough room for controls (zoom) to make sure they don't hide anything + map.fitBounds(bbox as LngLatBoundsLike, { + animate: false, + padding: 40, + }); + }); + } + + setPopupsConfiguration(config: PopupsConfiguration) { + this.popupsConfiguration = config; + this.queue((map) => this.setPopup(map)); + } + + toggleInteractivity(interaction: 'enable' | 'disable') { + this.queue((map) => { + map.boxZoom[interaction](); + map.doubleClickZoom[interaction](); + map.dragPan[interaction](); + map.dragRotate[interaction](); + map.keyboard[interaction](); + map.scrollZoom[interaction](); + map.touchZoomRotate[interaction](); + + const hasControl = map.hasControl(this.navigationControl); + + if (interaction === 'disable') { + this.popup.remove(); + map.off('click', this.bindedOnClick); + if (hasControl) { + map.removeControl(this.navigationControl); + } + } + if (interaction === 'enable') { + if (!hasControl) { + map.addControl(this.navigationControl, 'top-right'); + } + map.on('click', this.bindedOnClick); + } + }); + } +} diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte new file mode 100644 index 00000000..a079d0f0 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -0,0 +1,81 @@ + + + + +
+
+
+
+
+ + diff --git a/packages/visualizations/src/components/MapPoi/constants.ts b/packages/visualizations/src/components/MapPoi/constants.ts new file mode 100644 index 00000000..512cbec2 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/constants.ts @@ -0,0 +1,21 @@ +import type { BBox } from 'geojson'; +import type { LngLatLike, PopupOptions, StyleSpecification } from 'maplibre-gl'; + +export const DEFAULT_BASEMAP_STYLE: StyleSpecification = { + version: 8, + name: 'Opendatasoft default basemap style', + sources: {}, + layers: [], +}; + +export const DEFAULT_BBOX: BBox = [180, 90, -180, -90]; + +export const DEFAULT_ASPECT_RATIO = 1; + +export const DEFAULT_MAP_CENTER: LngLatLike = [0, 0]; + +export const POPUP_OPTIONS: PopupOptions = { + className: 'poi-map__popup', + closeButton: false, + maxWidth: '300px', +}; diff --git a/packages/visualizations/src/components/MapPoi/index.ts b/packages/visualizations/src/components/MapPoi/index.ts new file mode 100644 index 00000000..f04105ef --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/index.ts @@ -0,0 +1,11 @@ +import type { PoiMapData, PoiMapOptions } from './types'; +import MapImpl from './Map.svelte'; +import SvelteImpl from '../SvelteImpl'; + +import 'maplibre-gl/dist/maplibre-gl.css'; + +export default class Map extends SvelteImpl { + protected getSvelteComponentClass(): typeof MapImpl { + return MapImpl; + } +} diff --git a/packages/visualizations/src/components/MapPoi/mapStyles.ts b/packages/visualizations/src/components/MapPoi/mapStyles.ts new file mode 100644 index 00000000..d16fd523 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/mapStyles.ts @@ -0,0 +1,9 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +// eslint-disable-next-line import/prefer-default-export +export const BLANK: StyleSpecification = { + version: 8, + sources: {}, + metadata: {}, + layers: [], +}; diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts new file mode 100644 index 00000000..671992d8 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -0,0 +1,76 @@ +import type { CircleLayerSpecification, GeoJSONFeature, StyleSpecification } from 'maplibre-gl'; +import type { BBox, GeoJsonProperties } from 'geojson'; +import type { Color } from '../types'; + +// To render data layers on the map +export type PoiMapData = Partial<{ + sources: StyleSpecification['sources']; + layers: Layer[]; +}>; + +export interface PoiMapOptions { + /* + * To render a basemap. Could be: + * - A MapLibre style URL; See https://maplibre.org/maplibre-gl-js/docs/API/types/maplibregl.MapOptions. + * - Or an object with a 'sources' and a 'layers' keys. Useful when using a Raster or WMS basemap. + */ + style?: string | PoiMapStyleOption; + /** + * Maximum boundaries of the map, outside of which the user cannot zoom/move + * Also set the position of the map when rendering. + */ + bbox?: BBox | undefined; + // Aspect ratio used to draw the map. The map will take he width available to it, and decide its height based on that ratio. + aspectRatio?: number; + // Is the map interactive for the user (zoom, move, tooltips...) + interactive?: boolean; +} + +export type PoiMapStyleOption = Partial>; + +// Supported layers +export type LayerSpecification = CircleLayerSpecification; + +/** + * Base on Maplibre layers https://maplibre.org/maplibre-style-spec/layers/ + * Use only part of the configuration to build layers with consistent styles. + */ +export type Layer = { + id: string; + source: string; + sourceLayer?: string; + type: LayerSpecification['type']; + color: Color; + popup?: PopupLayer; + /** + * Set a marker color based on a value. + * If no match, default color comes from `color` + */ + colorMatch?: { + key: string; + colors: { [key: string]: Color }; + }; +}; + +export enum PopupDisplayTypes { + Tooltip = 'tooltip', + Sidebar = 'sidebar', +} + +export type PopupLayer = { + /** + * Control where to display the popup + * - `sidebar`: As a side element (on the left) + * - `tooltip`: Above the feature that has been clicked + */ + display: PopupDisplayTypes; + getContent: (id?: GeoJSONFeature['id'], properties?: GeoJsonProperties) => Promise; + getLoadingContent: () => string; +}; + +export type GeoPoint = { + lat: number; + lon: number; +}; + +export type PopupsConfiguration = { [key: string]: PopupLayer }; diff --git a/packages/visualizations/src/components/MapPoi/utils.ts b/packages/visualizations/src/components/MapPoi/utils.ts new file mode 100644 index 00000000..e1127a68 --- /dev/null +++ b/packages/visualizations/src/components/MapPoi/utils.ts @@ -0,0 +1,76 @@ +import type { + CircleLayerSpecification, + DataDrivenPropertyValueSpecification, + MapOptions, + StyleSpecification, +} from 'maplibre-gl'; + +import type { Color } from '../types'; + +import type { Layer, PoiMapData, PoiMapOptions, PopupsConfiguration } from './types'; +import { DEFAULT_BASEMAP_STYLE, DEFAULT_ASPECT_RATIO, DEFAULT_BBOX } from './constants'; + +export const getMapStyle = (style: PoiMapOptions['style']): MapOptions['style'] => { + if (!style) return DEFAULT_BASEMAP_STYLE; + if (typeof style === 'string') return style; + return { ...DEFAULT_BASEMAP_STYLE, ...style }; +}; +export const getMapSources = (sources: PoiMapData['sources']): StyleSpecification['sources'] => { + if (!sources) return DEFAULT_BASEMAP_STYLE.sources; + return sources; +}; + +// Only circle layers are supported +export const getMapLayers = (layers?: Layer[]): CircleLayerSpecification[] => { + if (!layers) return []; + + return layers.map((layer) => { + let circleColor: DataDrivenPropertyValueSpecification | Color = layer.color; + + if (layer.colorMatch) { + const { key, colors } = layer.colorMatch; + const groupByColors = ['match', ['get', key]]; + Object.keys(colors).forEach((color) => { + groupByColors.push(color, colors[color]); + }); + groupByColors.push(layer.color); + circleColor = groupByColors; + } + const { id, type, source, sourceLayer } = layer; + return { + id, + type, + source, + ...(sourceLayer ? { 'source-layer': sourceLayer } : undefined), + paint: { + 'circle-radius': [ + 'case', + ['boolean', ['feature-state', 'popup-feature'], false], + 8, + 5, + ], + 'circle-color': circleColor, + }, + filter: ['==', ['geometry-type'], 'Point'], + }; + }); +}; + +export const getPopupsConfiguration = (layers?: Layer[]): PopupsConfiguration => { + const configuration: PopupsConfiguration = {}; + layers?.forEach(({ id, popup }) => { + if (popup) { + configuration[id] = popup; + } + }); + return configuration; +}; + +export const getMapOptions = (options: PoiMapOptions) => { + const { aspectRatio = DEFAULT_ASPECT_RATIO, bbox = DEFAULT_BBOX, interactive = true } = options; + return { + aspectRatio, + bbox, + interactive, + }; +}; diff --git a/packages/visualizations/src/index.ts b/packages/visualizations/src/index.ts index d10012de..e9b46583 100644 --- a/packages/visualizations/src/index.ts +++ b/packages/visualizations/src/index.ts @@ -3,6 +3,7 @@ export { default as MarkdownText } from './components/MarkdownText'; export { default as KpiCard } from './components/KpiCard'; export { ChoroplethGeoJson, ChoroplethVectorTiles } from './components/Map/WebGl'; export { default as ChoroplethSvg } from './components/Map/Svg'; +export { default as PoiMap } from './components/MapPoi'; export * from './types'; export * from './components/types'; @@ -10,4 +11,5 @@ export * from './components/Chart/types'; export * from './components/KpiCard/types'; export * from './components/Map/types'; export * from './components/MarkdownText/types'; +export * from './components/MapPoi/types'; export * from './components/Legend/types'; From f7b6dc9d7ef0ea0d4bf28affddb4bcca37ad5c0c Mon Sep 17 00:00:00 2001 From: RafaelSzmarowski <84501475+RafaelSzmarowski@users.noreply.github.com> Date: Fri, 22 Sep 2023 12:25:51 +0200 Subject: [PATCH 07/12] feat: new variant ('circle') for category legend And side changes: - Add legend component to POI maps - Add source link, title, subtitle and description for poi maps - Add source link for choropleth maps - Update bottom padding for title and subtitle for charts and maps --------- Co-authored-by: Kevin Fabre --- .../stories/Map/StudioChoropleth.stories.tsx | 3 +- .../Map/StudioChoroplethVector.stories.tsx | 2 + .../stories/Poi/PoiMap.stories.tsx | 80 ++++++++++-- .../src/components/Chart/Chart.svelte | 21 ++-- .../src/components/Chart/datasets.ts | 15 ++- .../src/components/Chart/legend.ts | 14 ++- .../src/components/Chart/utils.ts | 2 + .../components/Legend/CategoryLegend.svelte | 119 ++++++------------ .../Item/CategoryLegendItem.svelte | 54 ++++++++ .../Item/CategoryLegendItemLabel.svelte | 14 +++ .../Item/CategoryLegendItemSymbol.svelte | 17 +++ .../CategoryLegend/Symbols/BoxSymbol.svelte | 18 +++ .../Symbols/CircleSymbol.svelte | 18 +++ .../CategoryLegend/Symbols/LineSymbol.svelte | 27 ++++ .../src/components/Legend/ColorsLegend.svelte | 4 +- .../src/components/Legend/types.ts | 35 +++++- .../Map/WebGl/ChoroplethGeoJson.svelte | 6 +- .../Map/WebGl/ChoroplethVectorTiles.svelte | 6 +- .../src/components/Map/WebGl/MapRender.svelte | 33 +++-- .../src/components/Map/types.ts | 4 +- .../src/components/MapPoi/MapRender.svelte | 50 +++++++- .../src/components/MapPoi/constants.ts | 3 + .../src/components/MapPoi/types.ts | 19 ++- .../src/components/MapPoi/utils.ts | 61 +++++++-- 24 files changed, 484 insertions(+), 141 deletions(-) create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItem.svelte create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemLabel.svelte create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemSymbol.svelte create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Symbols/BoxSymbol.svelte create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Symbols/CircleSymbol.svelte create mode 100644 packages/visualizations/src/components/Legend/CategoryLegend/Symbols/LineSymbol.svelte diff --git a/packages/visualizations-react/stories/Map/StudioChoropleth.stories.tsx b/packages/visualizations-react/stories/Map/StudioChoropleth.stories.tsx index ec0405a9..e80c526c 100644 --- a/packages/visualizations-react/stories/Map/StudioChoropleth.stories.tsx +++ b/packages/visualizations-react/stories/Map/StudioChoropleth.stories.tsx @@ -10,7 +10,7 @@ import { } from '@opendatasoft/visualizations'; import { ChoroplethGeoJson, Props } from '../../src'; import { shapes, multiPolygonShapes, worldCopies } from './data'; -import { IMAGES } from '../utils'; +import { IMAGES, defaultSource } from '../utils'; const meta: ComponentMeta = { title: 'Map/Choropleth', @@ -378,6 +378,7 @@ const StudioChoroplethNavigationMapButtonsArgs: Props = { title: 'Map/ChoroplethVector', @@ -305,6 +306,7 @@ const StudioChoroplethNavigationMapButtonsArgs: Props { @@ -32,6 +33,7 @@ const layer2: Layer = { source: 'battles', type: 'circle', color: 'red', + borderColor: 'white', popup: { display: PopupDisplayTypes.Sidebar, getContent: async (_, properties) => { @@ -52,10 +54,52 @@ const layers = [layer1, layer2]; const citiesColorMatch = { key: 'key', colors: { Paris: 'blue', Nantes: 'yellow', Bordeaux: 'purple', Marseille: 'lightblue' }, + borderColors: { Paris: 'white', Nantes: 'black', Bordeaux: 'white', Marseille: 'black' }, }; const bbox: BBox = [-6.855469, 41.343825, 11.645508, 51.37178]; +const legend = { + type: 'category' as const, + title: 'French cities', + items: [ + { + label: 'Paris', + color: citiesColorMatch.colors.Paris, + borderColor: citiesColorMatch.borderColors.Paris, + variant: CATEGORY_ITEM_VARIANT.Circle, + }, + { + label: 'Nantes', + color: citiesColorMatch.colors.Nantes, + borderColor: citiesColorMatch.borderColors.Nantes, + variant: CATEGORY_ITEM_VARIANT.Circle, + }, + { + label: 'Bordeaux', + color: citiesColorMatch.colors.Bordeaux, + borderColor: citiesColorMatch.borderColors.Bordeaux, + variant: CATEGORY_ITEM_VARIANT.Circle, + }, + { + label: 'Marseille', + color: citiesColorMatch.colors.Marseille, + borderColor: citiesColorMatch.borderColors.Marseille, + variant: CATEGORY_ITEM_VARIANT.Circle, + }, + ], + align: 'start' as const, +}; + +const options = { + style: BASE_STYLE, + bbox, + title: 'Lorem Ipsum', + subtitle: 'Dolor Sit Amet', + desciption: 'More aria description', + sourceLink: defaultSource, +}; + const meta: ComponentMeta = { title: 'Poi/PoiMap', component: PoiMap, @@ -83,7 +127,7 @@ const Template: ComponentStory = (args) => ( export const PoiMapNoLayersParams: ComponentStory = Template.bind({}); const PoiMapNoLayersParamsArgs = { data: {}, - options: { style: BASE_STYLE, bbox }, + options, }; PoiMapNoLayersParams.args = PoiMapNoLayersParamsArgs; @@ -93,11 +137,7 @@ PoiMapNoLayersParams.args = PoiMapNoLayersParamsArgs; export const PoiMapNonInteractive: ComponentStory = Template.bind({}); const PoiMapNonInteractiveArgs = { data: { value: { layers, sources } }, - options: { - style: BASE_STYLE, - bbox, - interactive: false, - }, + options: { ...options, interactive: false }, }; PoiMapNonInteractive.args = PoiMapNonInteractiveArgs; @@ -107,6 +147,26 @@ PoiMapNonInteractive.args = PoiMapNonInteractiveArgs; export const PoiMapMatchExpression: ComponentStory = Template.bind({}); const PoiMapMatchExpressionArgs = { data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, - options: { style: BASE_STYLE, bbox }, + options, }; PoiMapMatchExpression.args = PoiMapMatchExpressionArgs; + +/** + * STORY: With legend on start align + */ +export const PoiMapLegendStart: ComponentStory = Template.bind({}); +const PoiMapLegendStartArgs = { + data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + options: { ...options, legend }, +}; +PoiMapLegendStart.args = PoiMapLegendStartArgs; + +/** + * STORY: With legend on center align + */ +export const PoiMapLegendCenter: ComponentStory = Template.bind({}); +const PoiMapLegendCenterArgs = { + data: { value: { layers: [{ ...layer1, colorMatch: citiesColorMatch }, layer2], sources } }, + options: { ...options, legend: { ...legend, align: 'center' as const } }, +}; +PoiMapLegendCenter.args = PoiMapLegendCenterArgs; diff --git a/packages/visualizations/src/components/Chart/Chart.svelte b/packages/visualizations/src/components/Chart/Chart.svelte index c5c38567..dbf04367 100644 --- a/packages/visualizations/src/components/Chart/Chart.svelte +++ b/packages/visualizations/src/components/Chart/Chart.svelte @@ -15,7 +15,7 @@ import { defaultValue } from './utils'; import toDataset from './datasets'; import buildScales from './scales'; - import { buildLegend, buildCustomLegend } from './legend'; + import { buildLegend, buildPieAndDoughnutCustomLegend } from './legend'; export let data: Async; export let options: ChartOptions; @@ -193,8 +193,11 @@ $: legendPosition = clientWidth <= 375 ? 'bottom' : defaultValue(options?.legend?.position, 'bottom'); let legendOptions: CategoryLegendType; - $: if (options?.legend?.custom) { - legendOptions = buildCustomLegend({ chart, options, chartConfig }); + $: if ( + [ChartSeriesType.Pie, ChartSeriesType.Doughnut].includes(options.series[0].type) && + options?.legend?.custom + ) { + legendOptions = buildPieAndDoughnutCustomLegend({ chart, options, chartConfig }); } @@ -245,6 +248,10 @@ } .header { width: 100%; + margin: 0 0 1em 0; + } + .header h3, + .header p { margin: 0; } @@ -262,14 +269,6 @@ flex-direction: row; } - figcaption { - display: grid; - justify-content: center; - grid-gap: 3px 13px; - grid-template-columns: repeat(auto-fit, minmax(120px, max-content)); - padding: 13px 0; - } - /* Suitable for elements that are used via aria-describedby or aria-labelledby */ .a11y-invisible-description { display: none; diff --git a/packages/visualizations/src/components/Chart/datasets.ts b/packages/visualizations/src/components/Chart/datasets.ts index b611e1de..507e8822 100644 --- a/packages/visualizations/src/components/Chart/datasets.ts +++ b/packages/visualizations/src/components/Chart/datasets.ts @@ -3,7 +3,12 @@ import type { Options as DataLabelsOptions } from 'chartjs-plugin-datalabels/typ import type { ChartSeries, DataLabelsConfiguration, FillConfiguration } from './types'; import type { DataFrame } from '../types'; import { defaultCompactNumberFormat } from '../utils/formatter'; -import { defaultValue, singleChartJsColor, multipleChartJsColors } from './utils'; +import { + defaultValue, + singleChartJsColor, + multipleChartJsColors, + DEFAULT_GREY_COLOR, +} from './utils'; function chartJsFill(fill: FillConfiguration | undefined) { if (fill === undefined) return false; @@ -73,7 +78,9 @@ export default function toDataset(df: DataFrame, s: ChartSeries): ChartDataset { type: 'pie', label: defaultValue(s.label, ''), data: df.map((entry) => entry[s.valueColumn]), - backgroundColor: multipleChartJsColors(s.backgroundColor), + backgroundColor: multipleChartJsColors( + s.backgroundColor?.length ? s.backgroundColor : [DEFAULT_GREY_COLOR] + ), datalabels: chartJsDataLabels(s.dataLabels), }; } @@ -96,7 +103,9 @@ export default function toDataset(df: DataFrame, s: ChartSeries): ChartDataset { type: 'doughnut', label: defaultValue(s.label, ''), data: df.map((entry) => entry[s.valueColumn]), - backgroundColor: multipleChartJsColors(s.backgroundColor), + backgroundColor: multipleChartJsColors( + s.backgroundColor?.length ? s.backgroundColor : DEFAULT_GREY_COLOR + ), datalabels: chartJsDataLabels(s.dataLabels), }; } diff --git a/packages/visualizations/src/components/Chart/legend.ts b/packages/visualizations/src/components/Chart/legend.ts index 4bc5eac6..e63cbc9a 100644 --- a/packages/visualizations/src/components/Chart/legend.ts +++ b/packages/visualizations/src/components/Chart/legend.ts @@ -2,7 +2,8 @@ import type { LegendOptions, ChartTypeRegistry, ChartConfiguration, Chart } from import type { _DeepPartialObject } from 'chart.js/types/utils'; import type { ChartOptions } from './types'; import { assureMaxLength } from '../utils/formatter'; -import { defaultValue } from './utils'; +import { defaultValue, DEFAULT_GREY_COLOR } from './utils'; +import { CATEGORY_ITEM_VARIANT } from '../Legend/types'; const LEGEND_MAX_LENGTH = 50; @@ -80,7 +81,7 @@ function buildLegendLabels( return `${chartConfig.data.labels?.[index]}`; } -export function buildCustomLegend({ +export function buildPieAndDoughnutCustomLegend({ chart, options, chartConfig, @@ -90,12 +91,17 @@ export function buildCustomLegend({ chartConfig: ChartConfiguration; }) { const { series } = options; + const backgroundColors = series[0].backgroundColor?.length + ? series[0].backgroundColor + : [DEFAULT_GREY_COLOR]; return { type: 'category' as const, position: defaultValue(options?.legend?.position, 'bottom'), + align: defaultValue(options?.legend?.align, 'center'), items: chartConfig.data.datasets[0].data.map((_data, i) => ({ - color: series[0].backgroundColor?.[i], - borderDashed: false, + color: backgroundColors[i % backgroundColors.length], + variant: CATEGORY_ITEM_VARIANT.Box, + dashed: false, label: buildLegendLabels(i, options, chartConfig), onClick: (index: number) => { if (chart) { diff --git a/packages/visualizations/src/components/Chart/utils.ts b/packages/visualizations/src/components/Chart/utils.ts index bd633f74..cb93ea0e 100644 --- a/packages/visualizations/src/components/Chart/utils.ts +++ b/packages/visualizations/src/components/Chart/utils.ts @@ -1,5 +1,7 @@ import type { Color } from '../types'; +export const DEFAULT_GREY_COLOR = '#F0F0F0'; + export function defaultValue(value: T | undefined, fallback: T): T { if (value === undefined) return fallback; return value; diff --git a/packages/visualizations/src/components/Legend/CategoryLegend.svelte b/packages/visualizations/src/components/Legend/CategoryLegend.svelte index 121b3d9f..d0d931e7 100644 --- a/packages/visualizations/src/components/Legend/CategoryLegend.svelte +++ b/packages/visualizations/src/components/Legend/CategoryLegend.svelte @@ -1,94 +1,57 @@ -{#each categoryItems as item (item.id)} -
{ - refinedSeries = isRefined(item, refinedSeries) - ? refinedSeries.filter((id) => id !== item.id) - : [...refinedSeries, item.id]; - item.onClick(item.id); - }} - on:mouseenter={() => { - if (item.onHover) { - item.onHover(item.id, !isRefined(item, refinedSeries)); - } - }} - on:mouseleave={() => { - if (item.onLeave) { - item.onLeave(); - } - }} - > - {#if item.color} -
- {:else} -
+ {#if title} +
{title}
+ {/if} +
+ {#each items as item, i} + - {/if} -
- {item.label} -
+ {/each}
-{/each} +
diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItem.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItem.svelte new file mode 100644 index 00000000..cad39bfc --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItem.svelte @@ -0,0 +1,54 @@ + + +
{ + if (item.onClick) { + toggleSerie(itemIndex); + item.onClick?.(itemIndex); + } + }} + on:mouseenter={() => { + if (item.onHover) { + item.onHover(itemIndex, !refined); + } + }} + on:mouseleave={() => { + if (item.onLeave) { + item.onLeave(); + } + }} +> + + {#if stringLabel} + + {/if} +
+ + diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemLabel.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemLabel.svelte new file mode 100644 index 00000000..f0a1f1fd --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemLabel.svelte @@ -0,0 +1,14 @@ + + +
+ {label} +
+ + diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemSymbol.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemSymbol.svelte new file mode 100644 index 00000000..c1122d49 --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Item/CategoryLegendItemSymbol.svelte @@ -0,0 +1,17 @@ + + +{#if item.variant === CATEGORY_ITEM_VARIANT.Circle} + +{:else if item.variant === CATEGORY_ITEM_VARIANT.Box} + +{:else} + +{/if} diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/BoxSymbol.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/BoxSymbol.svelte new file mode 100644 index 00000000..7b3defbf --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/BoxSymbol.svelte @@ -0,0 +1,18 @@ + + +
+ + diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/CircleSymbol.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/CircleSymbol.svelte new file mode 100644 index 00000000..31be9dac --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/CircleSymbol.svelte @@ -0,0 +1,18 @@ + + +
+ + diff --git a/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/LineSymbol.svelte b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/LineSymbol.svelte new file mode 100644 index 00000000..3604fd3c --- /dev/null +++ b/packages/visualizations/src/components/Legend/CategoryLegend/Symbols/LineSymbol.svelte @@ -0,0 +1,27 @@ + + +
+ + diff --git a/packages/visualizations/src/components/Legend/ColorsLegend.svelte b/packages/visualizations/src/components/Legend/ColorsLegend.svelte index 5a93690f..8321f1ac 100644 --- a/packages/visualizations/src/components/Legend/ColorsLegend.svelte +++ b/packages/visualizations/src/components/Legend/ColorsLegend.svelte @@ -147,8 +147,8 @@ width: 276px; } .legend-colors--fluid { - width: 90%; - padding: 6px; + width: 100%; + padding: 13px 0; margin: auto; } .legend-colors-title { diff --git a/packages/visualizations/src/components/Legend/types.ts b/packages/visualizations/src/components/Legend/types.ts index 3b927d53..1b271742 100644 --- a/packages/visualizations/src/components/Legend/types.ts +++ b/packages/visualizations/src/components/Legend/types.ts @@ -25,17 +25,42 @@ export interface LegendConfiguration { export type LegendVariant = 'fluid' | 'fixed'; -export type CategoryItem = { - color?: Color; - borderColor?: Color; - borderDashed?: boolean; +export const CATEGORY_ITEM_VARIANT = { + Circle: 'circle', + Line: 'line', + Box: 'box', +} as const; + +type BaseCategoryItem = { label: LegendLabelsConfiguration | string | undefined; - onClick: (index: number) => void; + onClick?: (index: number) => void; onHover?(index: number, isVisible: boolean): void; onLeave?(): void; }; +export type CircleCategoryItem = BaseCategoryItem & { + variant: typeof CATEGORY_ITEM_VARIANT.Circle; + color: Color; + borderColor?: Color; +}; + +export type BoxCategoryItem = BaseCategoryItem & { + variant: typeof CATEGORY_ITEM_VARIANT.Box; + color: Color; + borderColor?: Color; +}; + +export type LineCategoryItem = BaseCategoryItem & { + variant: typeof CATEGORY_ITEM_VARIANT.Line; + borderColor: Color; + dashed?: boolean; +}; + +export type CategoryItem = CircleCategoryItem | BoxCategoryItem | LineCategoryItem; + export type CategoryLegend = { type: 'category'; items: CategoryItem[]; + title?: string; + align?: 'start' | 'center' | 'end'; }; diff --git a/packages/visualizations/src/components/Map/WebGl/ChoroplethGeoJson.svelte b/packages/visualizations/src/components/Map/WebGl/ChoroplethGeoJson.svelte index 459a625e..65284d7b 100644 --- a/packages/visualizations/src/components/Map/WebGl/ChoroplethGeoJson.svelte +++ b/packages/visualizations/src/components/Map/WebGl/ChoroplethGeoJson.svelte @@ -3,7 +3,7 @@ import type { FilterSpecification, SourceSpecification } from 'maplibre-gl'; import type { BBox, FeatureCollection } from 'geojson'; import { debounce } from 'lodash'; - import type { ColorScale, DataBounds, Color } from '../../types'; + import type { ColorScale, DataBounds, Color, Source } from '../../types'; import MapRender from './MapRender.svelte'; import { BLANK } from '../mapStyles'; import { @@ -41,6 +41,8 @@ let subtitle: string | undefined; let description: string | undefined; let navigationMaps: NavigationMap[] | undefined; + // Data source link + let sourceLink: Source | undefined; // Used to apply a chosen color for shapes without values (default: #cccccc) let emptyValueColor: Color; @@ -63,6 +65,7 @@ subtitle, description, navigationMaps, + sourceLink, } = options); // Choropleth is always display over a blank map, for readability purposes @@ -127,6 +130,7 @@ {description} {navigationMaps} {data} + {sourceLink} />
diff --git a/packages/visualizations/src/components/Map/WebGl/ChoroplethVectorTiles.svelte b/packages/visualizations/src/components/Map/WebGl/ChoroplethVectorTiles.svelte index ebb30df1..a541133b 100644 --- a/packages/visualizations/src/components/Map/WebGl/ChoroplethVectorTiles.svelte +++ b/packages/visualizations/src/components/Map/WebGl/ChoroplethVectorTiles.svelte @@ -2,7 +2,7 @@ import type { FilterSpecification, SourceSpecification } from 'maplibre-gl'; import type { BBox } from 'geojson'; import { debounce } from 'lodash'; - import type { ColorScale, DataBounds, Color } from '../../types'; + import type { ColorScale, DataBounds, Color, Source } from '../../types'; import MapRender from './MapRender.svelte'; import { BLANK } from '../mapStyles'; import { @@ -45,6 +45,8 @@ let subtitle: string | undefined; let description: string | undefined; let navigationMaps: NavigationMap[] | undefined; + // Data source link + let sourceLink: Source | undefined; // Used to apply a chosen color for shapes without values (default: #cccccc) let emptyValueColor: Color; @@ -70,6 +72,7 @@ subtitle, description, navigationMaps, + sourceLink, } = options); // Choropleth is always display over a blank map, for readability purposes @@ -146,6 +149,7 @@ {description} {navigationMaps} {data} + {sourceLink} /> diff --git a/packages/visualizations/src/components/MapPoi/constants.ts b/packages/visualizations/src/components/MapPoi/constants.ts index 512cbec2..c5840251 100644 --- a/packages/visualizations/src/components/MapPoi/constants.ts +++ b/packages/visualizations/src/components/MapPoi/constants.ts @@ -1,5 +1,6 @@ import type { BBox } from 'geojson'; import type { LngLatLike, PopupOptions, StyleSpecification } from 'maplibre-gl'; +import type { Color } from '../types'; export const DEFAULT_BASEMAP_STYLE: StyleSpecification = { version: 8, @@ -12,6 +13,8 @@ export const DEFAULT_BBOX: BBox = [180, 90, -180, -90]; export const DEFAULT_ASPECT_RATIO = 1; +export const DEFAULT_DARK_GREY: Color = '#515457'; + export const DEFAULT_MAP_CENTER: LngLatLike = [0, 0]; export const POPUP_OPTIONS: PopupOptions = { diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index 671992d8..5ff63120 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -1,6 +1,7 @@ -import type { CircleLayerSpecification, GeoJSONFeature, StyleSpecification } from 'maplibre-gl'; +import type { CircleLayerSpecification, StyleSpecification, GeoJSONFeature } from 'maplibre-gl'; import type { BBox, GeoJsonProperties } from 'geojson'; -import type { Color } from '../types'; +import type { Color, Source } from '../types'; +import type { CategoryLegend } from '../Legend/types'; // To render data layers on the map export type PoiMapData = Partial<{ @@ -19,11 +20,17 @@ export interface PoiMapOptions { * Maximum boundaries of the map, outside of which the user cannot zoom/move * Also set the position of the map when rendering. */ - bbox?: BBox | undefined; + bbox?: BBox; // Aspect ratio used to draw the map. The map will take he width available to it, and decide its height based on that ratio. aspectRatio?: number; // Is the map interactive for the user (zoom, move, tooltips...) interactive?: boolean; + title?: string; + subtitle?: string; + description?: string; + legend?: CategoryLegend; + /** Link button to source */ + sourceLink?: Source; } export type PoiMapStyleOption = Partial>; @@ -41,7 +48,9 @@ export type Layer = { sourceLayer?: string; type: LayerSpecification['type']; color: Color; - popup?: PopupLayer; + borderColor?: Color; + circleRadius?: number; + circleStrokeWidth?: number; /** * Set a marker color based on a value. * If no match, default color comes from `color` @@ -49,7 +58,9 @@ export type Layer = { colorMatch?: { key: string; colors: { [key: string]: Color }; + borderColors?: { [key: string]: Color }; }; + popup?: PopupLayer; }; export enum PopupDisplayTypes { diff --git a/packages/visualizations/src/components/MapPoi/utils.ts b/packages/visualizations/src/components/MapPoi/utils.ts index e1127a68..df4e1646 100644 --- a/packages/visualizations/src/components/MapPoi/utils.ts +++ b/packages/visualizations/src/components/MapPoi/utils.ts @@ -8,7 +8,12 @@ import type { import type { Color } from '../types'; import type { Layer, PoiMapData, PoiMapOptions, PopupsConfiguration } from './types'; -import { DEFAULT_BASEMAP_STYLE, DEFAULT_ASPECT_RATIO, DEFAULT_BBOX } from './constants'; +import { + DEFAULT_DARK_GREY, + DEFAULT_BASEMAP_STYLE, + DEFAULT_ASPECT_RATIO, + DEFAULT_BBOX, +} from './constants'; export const getMapStyle = (style: PoiMapOptions['style']): MapOptions['style'] => { if (!style) return DEFAULT_BASEMAP_STYLE; @@ -25,18 +30,40 @@ export const getMapLayers = (layers?: Layer[]): CircleLayerSpecification[] => { if (!layers) return []; return layers.map((layer) => { - let circleColor: DataDrivenPropertyValueSpecification | Color = layer.color; + const { + id, + type, + source, + sourceLayer, + circleRadius = 7, + circleStrokeWidth = 1.5, + colorMatch, + color: layerColor, + borderColor: layerBorderColor, + } = layer; - if (layer.colorMatch) { - const { key, colors } = layer.colorMatch; + let circleColor: DataDrivenPropertyValueSpecification | Color = layerColor; + let circleBorderColor: DataDrivenPropertyValueSpecification | Color | undefined = + layerBorderColor; + + if (colorMatch) { + const { key, colors, borderColors } = colorMatch; const groupByColors = ['match', ['get', key]]; Object.keys(colors).forEach((color) => { groupByColors.push(color, colors[color]); }); - groupByColors.push(layer.color); + groupByColors.push(layerColor); circleColor = groupByColors; + + if (borderColors) { + const groupBordersByColors = ['match', ['get', key]]; + Object.keys(borderColors).forEach((borderColor) => { + groupBordersByColors.push(borderColor, borderColors[borderColor]); + }); + groupBordersByColors.push(circleBorderColor || DEFAULT_DARK_GREY); + circleBorderColor = groupBordersByColors; + } } - const { id, type, source, sourceLayer } = layer; return { id, type, @@ -46,10 +73,12 @@ export const getMapLayers = (layers?: Layer[]): CircleLayerSpecification[] => { 'circle-radius': [ 'case', ['boolean', ['feature-state', 'popup-feature'], false], - 8, - 5, + circleRadius * 1.3, + circleRadius, ], + ...(circleBorderColor && { 'circle-stroke-width': circleStrokeWidth }), 'circle-color': circleColor, + ...(circleBorderColor && { 'circle-stroke-color': circleBorderColor }), }, filter: ['==', ['geometry-type'], 'Point'], }; @@ -67,10 +96,24 @@ export const getPopupsConfiguration = (layers?: Layer[]): PopupsConfiguration => }; export const getMapOptions = (options: PoiMapOptions) => { - const { aspectRatio = DEFAULT_ASPECT_RATIO, bbox = DEFAULT_BBOX, interactive = true } = options; + const { + aspectRatio = DEFAULT_ASPECT_RATIO, + bbox = DEFAULT_BBOX, + interactive = true, + title, + subtitle, + description, + legend, + sourceLink, + } = options; return { aspectRatio, bbox, interactive, + title, + subtitle, + description, + legend, + sourceLink, }; }; From 3e832f7737255303a7b01338a0ac6cf15df4018b Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Fri, 22 Sep 2023 14:11:00 +0200 Subject: [PATCH 08/12] chore(release): publish new versions - @opendatasoft/api-client@21.1.1 - @opendatasoft/visualizations-react@0.16.0 - @opendatasoft/visualizations@0.16.0 --- packages/api-client/CHANGELOG.md | 12 ++++++++++++ packages/api-client/package-lock.json | 4 ++-- packages/api-client/package.json | 2 +- packages/visualizations-react/CHANGELOG.md | 12 ++++++++++++ packages/visualizations-react/package-lock.json | 4 ++-- packages/visualizations-react/package.json | 4 ++-- packages/visualizations/CHANGELOG.md | 12 ++++++++++++ packages/visualizations/package-lock.json | 4 ++-- packages/visualizations/package.json | 2 +- 9 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/api-client/CHANGELOG.md b/packages/api-client/CHANGELOG.md index 841c1ee3..6c6a4fdc 100644 --- a/packages/api-client/CHANGELOG.md +++ b/packages/api-client/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [21.1.1](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/api-client@21.1.0...@opendatasoft/api-client@21.1.1) (2023-09-22) + + +### Bug Fixes + +* add MVT dataset export format ([0b0a746](https://github.com/opendatasoft/ods-dataviz-sdk/commit/0b0a746121195eb0f5b9837b8459a431f9ef5fa0)) +* add typing for API export ([511bb8b](https://github.com/opendatasoft/ods-dataviz-sdk/commit/511bb8ba4f7d305c0d1f6979e897b268b5f94d1f)) + + + + + # [21.1.0](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/api-client@21.0.0...@opendatasoft/api-client@21.1.0) (2023-08-31) diff --git a/packages/api-client/package-lock.json b/packages/api-client/package-lock.json index 7c6e22fb..7ca2010b 100644 --- a/packages/api-client/package-lock.json +++ b/packages/api-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/api-client", - "version": "21.1.0", + "version": "21.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/api-client", - "version": "21.1.0", + "version": "21.1.1", "license": "MIT", "dependencies": { "immutability-helper": "^3.1.1" diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 6f156b9d..cd2ed70a 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/api-client", - "version": "21.1.0", + "version": "21.1.1", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", diff --git a/packages/visualizations-react/CHANGELOG.md b/packages/visualizations-react/CHANGELOG.md index a634f14a..149a7a40 100644 --- a/packages/visualizations-react/CHANGELOG.md +++ b/packages/visualizations-react/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.16.0](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.15.3...@opendatasoft/visualizations-react@0.16.0) (2023-09-22) + + +### Features + +* add PoiMap component ([023eb28](https://github.com/opendatasoft/ods-dataviz-sdk/commit/023eb288c27570addd2efb3bfc82dbabf25fb169)) +* new variant ('circle') for category legend ([f7b6dc9](https://github.com/opendatasoft/ods-dataviz-sdk/commit/f7b6dc9d7ef0ea0d4bf28affddb4bcca37ad5c0c)) + + + + + ## [0.15.3](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.15.2...@opendatasoft/visualizations-react@0.15.3) (2023-09-18) diff --git a/packages/visualizations-react/package-lock.json b/packages/visualizations-react/package-lock.json index ffd1d4d5..4b02d9e2 100644 --- a/packages/visualizations-react/package-lock.json +++ b/packages/visualizations-react/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.15.3", + "version": "0.16.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations-react", - "version": "0.15.3", + "version": "0.16.0", "license": "MIT", "dependencies": { "use-callback-ref": "^1.2.4" diff --git a/packages/visualizations-react/package.json b/packages/visualizations-react/package.json index 08ea2e0f..9816b741 100644 --- a/packages/visualizations-react/package.json +++ b/packages/visualizations-react/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.15.3", + "version": "0.16.0", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", @@ -53,7 +53,7 @@ "trailingComma": "es5" }, "dependencies": { - "@opendatasoft/visualizations": "0.15.3", + "@opendatasoft/visualizations": "0.16.0", "use-callback-ref": "^1.2.4" }, "devDependencies": { diff --git a/packages/visualizations/CHANGELOG.md b/packages/visualizations/CHANGELOG.md index 0f8c4442..9baf0654 100644 --- a/packages/visualizations/CHANGELOG.md +++ b/packages/visualizations/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.16.0](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.15.3...@opendatasoft/visualizations@0.16.0) (2023-09-22) + + +### Features + +* add PoiMap component ([023eb28](https://github.com/opendatasoft/ods-dataviz-sdk/commit/023eb288c27570addd2efb3bfc82dbabf25fb169)) +* new variant ('circle') for category legend ([f7b6dc9](https://github.com/opendatasoft/ods-dataviz-sdk/commit/f7b6dc9d7ef0ea0d4bf28affddb4bcca37ad5c0c)) + + + + + ## [0.15.3](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.15.2...@opendatasoft/visualizations@0.15.3) (2023-09-18) diff --git a/packages/visualizations/package-lock.json b/packages/visualizations/package-lock.json index de6624c3..4284db17 100644 --- a/packages/visualizations/package-lock.json +++ b/packages/visualizations/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations", - "version": "0.15.3", + "version": "0.16.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations", - "version": "0.15.3", + "version": "0.16.0", "license": "MIT", "dependencies": { "@mapbox/geo-viewport": "^0.5.0", diff --git a/packages/visualizations/package.json b/packages/visualizations/package.json index 74125c50..f671dbfd 100644 --- a/packages/visualizations/package.json +++ b/packages/visualizations/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations", - "version": "0.15.3", + "version": "0.16.0", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", From 78a9cd45fa20c53ae01de9fa8198b980059095ba Mon Sep 17 00:00:00 2001 From: William Mai Date: Mon, 25 Sep 2023 09:33:27 +0200 Subject: [PATCH 09/12] fix: Use same typing for `indexAxis` as other charts series (#193) --- .../AxisAssemblages/AxisAssemblages.stories.tsx | 14 +++++++------- .../src/components/Chart/datasets.ts | 5 ++++- .../visualizations/src/components/Chart/types.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx index 015f6625..387717dd 100644 --- a/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx +++ b/packages/visualizations-react/stories/Chart/AxisAssemblages/AxisAssemblages.stories.tsx @@ -705,8 +705,8 @@ const ScatterPlotChartArgs: Props = { data: { loading: false, value: [ - {label: 'id-0', x: -10, y: 20}, - {label: 'id-1', x: 20, y: -10}, + {label: 'id-0', x: -10, y: 20}, + {label: 'id-1', x: 20, y: -10}, {label: 'id-2', x: 5, y: 2}, {label: 'id-3', x: 7, y: 3} ], @@ -760,7 +760,7 @@ function generateNormalDistribution(n: number, xMean = 0, xStdDev = 1, yMean = 0 const points = []; for (let i = 0; i < n; i++) { points.push({ - label: `id-${i}`, + label: `id-${i}`, x: randomNormal(xMean, xStdDev), y: randomNormal(yMean, yStdDev), }); @@ -779,8 +779,8 @@ const ScatterplotNormalDistribChartArgs: Props = { { label: "Serie 1", type: ChartSeriesType.Scatter, - valueColumn:"x", - indexAxis:"y", + valueColumn:"y", + indexAxis:"x", backgroundColor: COLORS.blue, }, ], @@ -790,7 +790,7 @@ const ScatterplotNormalDistribChartArgs: Props = { title: { display: true, text: "Horizontal axis" - }, + }, beginAtZero: true }, y: { @@ -808,6 +808,6 @@ const ScatterplotNormalDistribChartArgs: Props = { }, }; export const ScatterplotNormalDistribChart = { - args: ScatterplotNormalDistribChartArgs, + args: ScatterplotNormalDistribChartArgs, parameters: {chromatic: { disableSnapshot: true }} }; diff --git a/packages/visualizations/src/components/Chart/datasets.ts b/packages/visualizations/src/components/Chart/datasets.ts index 507e8822..1530ba99 100644 --- a/packages/visualizations/src/components/Chart/datasets.ts +++ b/packages/visualizations/src/components/Chart/datasets.ts @@ -114,7 +114,10 @@ export default function toDataset(df: DataFrame, s: ChartSeries): ChartDataset { return { type: 'scatter', label: defaultValue(s.label, ''), - data: df.map((entry) => ({ x: entry[s.indexAxis], y: entry[s.valueColumn] })), + data: df.map((entry) => ({ + x: entry[defaultValue(s.indexAxis, 'x')], + y: entry[defaultValue(s.valueColumn, 'y')], + })), datalabels: chartJsDataLabels(s.dataLabels), backgroundColor: singleChartJsColor(s.backgroundColor), pointRadius: defaultValue(s.pointRadius, 5), diff --git a/packages/visualizations/src/components/Chart/types.ts b/packages/visualizations/src/components/Chart/types.ts index 349a87c9..ebc21f10 100644 --- a/packages/visualizations/src/components/Chart/types.ts +++ b/packages/visualizations/src/components/Chart/types.ts @@ -223,7 +223,7 @@ export interface Scatter { type: ChartSeriesType.Scatter; valueColumn: string; label?: string; - indexAxis: string; + indexAxis?: 'x' | 'y'; /** Point color */ backgroundColor?: Color | Color[]; pointRadius?: number; From 64ecd7fe4a69e2b8b82ba31611c4d3f63ce7a474 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Mon, 25 Sep 2023 09:34:14 +0200 Subject: [PATCH 10/12] chore(release): publish new versions - @opendatasoft/visualizations-react@0.16.1 - @opendatasoft/visualizations@0.16.1 --- packages/visualizations-react/CHANGELOG.md | 11 +++++++++++ packages/visualizations-react/package-lock.json | 4 ++-- packages/visualizations-react/package.json | 4 ++-- packages/visualizations/CHANGELOG.md | 11 +++++++++++ packages/visualizations/package-lock.json | 4 ++-- packages/visualizations/package.json | 2 +- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/visualizations-react/CHANGELOG.md b/packages/visualizations-react/CHANGELOG.md index 149a7a40..175000e2 100644 --- a/packages/visualizations-react/CHANGELOG.md +++ b/packages/visualizations-react/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.16.1](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.16.0...@opendatasoft/visualizations-react@0.16.1) (2023-09-25) + + +### Bug Fixes + +* Use same typing for `indexAxis` as other charts series ([#193](https://github.com/opendatasoft/ods-dataviz-sdk/issues/193)) ([78a9cd4](https://github.com/opendatasoft/ods-dataviz-sdk/commit/78a9cd45fa20c53ae01de9fa8198b980059095ba)) + + + + + # [0.16.0](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations-react@0.15.3...@opendatasoft/visualizations-react@0.16.0) (2023-09-22) diff --git a/packages/visualizations-react/package-lock.json b/packages/visualizations-react/package-lock.json index 4b02d9e2..ba00e7a2 100644 --- a/packages/visualizations-react/package-lock.json +++ b/packages/visualizations-react/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations-react", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "use-callback-ref": "^1.2.4" diff --git a/packages/visualizations-react/package.json b/packages/visualizations-react/package.json index 9816b741..9bca084a 100644 --- a/packages/visualizations-react/package.json +++ b/packages/visualizations-react/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations-react", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", @@ -53,7 +53,7 @@ "trailingComma": "es5" }, "dependencies": { - "@opendatasoft/visualizations": "0.16.0", + "@opendatasoft/visualizations": "0.16.1", "use-callback-ref": "^1.2.4" }, "devDependencies": { diff --git a/packages/visualizations/CHANGELOG.md b/packages/visualizations/CHANGELOG.md index 9baf0654..1be16c30 100644 --- a/packages/visualizations/CHANGELOG.md +++ b/packages/visualizations/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.16.1](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.16.0...@opendatasoft/visualizations@0.16.1) (2023-09-25) + + +### Bug Fixes + +* Use same typing for `indexAxis` as other charts series ([#193](https://github.com/opendatasoft/ods-dataviz-sdk/issues/193)) ([78a9cd4](https://github.com/opendatasoft/ods-dataviz-sdk/commit/78a9cd45fa20c53ae01de9fa8198b980059095ba)) + + + + + # [0.16.0](https://github.com/opendatasoft/ods-dataviz-sdk/compare/@opendatasoft/visualizations@0.15.3...@opendatasoft/visualizations@0.16.0) (2023-09-22) diff --git a/packages/visualizations/package-lock.json b/packages/visualizations/package-lock.json index 4284db17..98e7634c 100644 --- a/packages/visualizations/package-lock.json +++ b/packages/visualizations/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opendatasoft/visualizations", - "version": "0.16.0", + "version": "0.16.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opendatasoft/visualizations", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "dependencies": { "@mapbox/geo-viewport": "^0.5.0", diff --git a/packages/visualizations/package.json b/packages/visualizations/package.json index f671dbfd..cec8d354 100644 --- a/packages/visualizations/package.json +++ b/packages/visualizations/package.json @@ -1,6 +1,6 @@ { "name": "@opendatasoft/visualizations", - "version": "0.16.0", + "version": "0.16.1", "license": "MIT", "author": "opendatasoft", "homepage": "https://github.com/opendatasoft/ods-dataviz-sdk", From 7386dd7302b8d514c3540c7882607b87eeb8a3af Mon Sep 17 00:00:00 2001 From: Kevin Fabre <117300300+KevinFabre-ods@users.noreply.github.com> Date: Wed, 27 Sep 2023 12:08:50 +0200 Subject: [PATCH 11/12] fix(POI Map): improve cursor no interaction: default drag: move hover a feature with a popup: pointer --- .../src/components/MapPoi/Map.ts | 47 +++++++++++++++++-- .../src/components/MapPoi/MapRender.svelte | 3 ++ .../src/components/MapPoi/types.ts | 3 ++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/visualizations/src/components/MapPoi/Map.ts b/packages/visualizations/src/components/MapPoi/Map.ts index 4aa70793..534c04c3 100644 --- a/packages/visualizations/src/components/MapPoi/Map.ts +++ b/packages/visualizations/src/components/MapPoi/Map.ts @@ -5,6 +5,7 @@ import maplibregl, { LngLatLike, MapGeoJSONFeature, MapLayerMouseEvent, + MapMouseEvent, MapOptions, StyleSpecification, } from 'maplibre-gl'; @@ -12,6 +13,12 @@ import maplibregl, { import { DEFAULT_MAP_CENTER, POPUP_OPTIONS } from './constants'; import type { PopupsConfiguration } from './types'; +const CURSOR = { + DEFAULT: 'default', + HOVER: 'pointer', + DRAG: 'move', +}; + type MapFunction = (map: maplibregl.Map) => unknown; export default class MapPOI { @@ -71,6 +78,38 @@ export default class MapPOI { this.mapResizeObserver.observe(container); } + /** + * Event handler for mousemove event. + * Show a pointer cursor if hovering a feature with a popup configuration + */ + private onMouseMove({ point }: MapMouseEvent) { + this.queue((map) => { + const canvas = map.getCanvas(); + const features = map.queryRenderedFeatures(point, { layers: this.layerIds }); + const isMovingOverFeatureWithPopup = + features.length && + features.some((feature) => + Object.keys(this.popupsConfiguration).includes(feature.layer.id) + ); + canvas.style.cursor = isMovingOverFeatureWithPopup ? CURSOR.HOVER : CURSOR.DEFAULT; + }); + } + + private bindedOnMouseMove = this.onMouseMove.bind(this); + + /** + * How cursor should react on drag and when mouse move over the map + */ + private initializeCursorBehavior(map: maplibregl.Map) { + const canvas = map.getCanvas(); + map.on('dragstart', () => { + canvas.style.cursor = CURSOR.DRAG; + }); + map.on('dragend', () => { + canvas.style.cursor = CURSOR.DEFAULT; + }); + } + /** * Event handler for click events on the map. * Currently, is only used to handle popup display. @@ -169,13 +208,13 @@ export default class MapPOI { this.map = new maplibregl.Map({ style, container, center: DEFAULT_MAP_CENTER, ...options }); this.queue((map) => this.initializeMapResizer(map, container)); + this.queue((map) => this.initializeCursorBehavior(map)); this.map.on('load', () => { this.isReady = true; if (this.map) { // Store base style after the first load this.baseStyle = this.map?.getStyle(); - this.map.on('click', this.bindedOnClick); this.popup.on('close', this.bindedOnPopupClose); this.enqueue(this.map); } @@ -254,11 +293,14 @@ export default class MapPOI { map.scrollZoom[interaction](); map.touchZoomRotate[interaction](); + const eventFunction = interaction === 'enable' ? 'on' : 'off'; + map[eventFunction]('click', this.bindedOnClick); + map[eventFunction]('mousemove', this.bindedOnMouseMove); + const hasControl = map.hasControl(this.navigationControl); if (interaction === 'disable') { this.popup.remove(); - map.off('click', this.bindedOnClick); if (hasControl) { map.removeControl(this.navigationControl); } @@ -267,7 +309,6 @@ export default class MapPOI { if (!hasControl) { map.addControl(this.navigationControl, 'top-right'); } - map.on('click', this.bindedOnClick); } }); } diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index b2891fee..3d668f5d 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -84,6 +84,9 @@ #map { height: 400px; } + #map :global(canvas) { + cursor: default; + } @supports (aspect-ratio: auto) { #map { height: auto; diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index 5ff63120..a5909627 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -60,6 +60,9 @@ export type Layer = { colors: { [key: string]: Color }; borderColors?: { [key: string]: Color }; }; + /** + * A feature for which a popup is defined will update the cursor style in pointer mode + */ popup?: PopupLayer; }; From a123107008e3fd32e3568f2fbc608caff9169394 Mon Sep 17 00:00:00 2001 From: Kevin Fabre Date: Fri, 22 Sep 2023 17:27:05 +0200 Subject: [PATCH 12/12] fix(POI Map): improve large content in popup --- .../visualizations/src/components/MapPoi/MapRender.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index 3d668f5d..933a8e21 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -107,6 +107,14 @@ margin: 0; } /* To add classes programmatically in svelte we will use a global selector. We place it inside a local selector to obtain some encapsulation and avoid side effects */ + .map-card :global(.poi-map__popup) { + /* To be above map controls (z-index: 2)*/ + z-index: 3; + /* 26px is for its padding */ + max-height: calc(100% - 26px); + height: auto; + overflow-y: auto; + } .map-card :global(.poi-map__popup.poi-map__popup--as-sidebar) { /* TO DO: add common stylesheet */ transform: translate(13px, 13px) !important;