diff --git a/packages/visualizations/package-lock.json b/packages/visualizations/package-lock.json index 2350d639..8a372c33 100644 --- a/packages/visualizations/package-lock.json +++ b/packages/visualizations/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "@opendatasoft/visualizations", - "version": "0.8.2", + "version": "0.9.0", "license": "MIT", "dependencies": { "@mapbox/geo-viewport": "^0.5.0", @@ -34,8 +34,11 @@ "@rollup/plugin-replace": "^3.0.1", "@rollup/plugin-typescript": "^6.1.0", "@tsconfig/svelte": "^1.0.10", + "@types/chroma-js": "^2.1.4", + "@types/geojson": "^7946.0.10", "@types/lodash": "^4.14.182", "@types/luxon": "^2.0.5", + "@types/mapbox__geo-viewport": "^0.4.1", "@types/markdown-it": "^12.0.1", "@types/markdown-it-link-attributes": "^3.0.1", "@types/rollup-plugin-visualizer": "^4.2.1", @@ -2045,6 +2048,12 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/chroma-js": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.1.4.tgz", + "integrity": "sha512-l9hWzP7cp7yleJUI7P2acmpllTJNYf5uU6wh50JzSIZt3fFHe+w2FM6w9oZGBTYzjjm2qHdnQvI+fF/JF/E5jQ==", + "dev": true + }, "node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -2052,9 +2061,9 @@ "dev": true }, "node_modules/@types/geojson": { - "version": "7946.0.8", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", - "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==" }, "node_modules/@types/json-schema": { "version": "7.0.9", @@ -2086,6 +2095,12 @@ "integrity": "sha512-GKrG5v16BOs9XGpouu33hOkAFaiSDi3ZaDXG9F2yAoyzHRBtksZnI60VWY5aM/yAENCccBejrxw8jDY+9OVlxw==", "dev": true }, + "node_modules/@types/mapbox__geo-viewport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox__geo-viewport/-/mapbox__geo-viewport-0.4.1.tgz", + "integrity": "sha512-aW7Orm58KsT9KmpZb1sqM2l/KufsS1IUsL1RCplVLfUIbpTk3PYkpOat3CN7jA8KcO1w1TuNFapn0g+/rASWzQ==", + "dev": true + }, "node_modules/@types/mapbox__point-geometry": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.2.tgz", @@ -9224,6 +9239,12 @@ "@babel/types": "^7.3.0" } }, + "@types/chroma-js": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.1.4.tgz", + "integrity": "sha512-l9hWzP7cp7yleJUI7P2acmpllTJNYf5uU6wh50JzSIZt3fFHe+w2FM6w9oZGBTYzjjm2qHdnQvI+fF/JF/E5jQ==", + "dev": true + }, "@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", @@ -9231,9 +9252,9 @@ "dev": true }, "@types/geojson": { - "version": "7946.0.8", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", - "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==" }, "@types/json-schema": { "version": "7.0.9", @@ -9265,6 +9286,12 @@ "integrity": "sha512-GKrG5v16BOs9XGpouu33hOkAFaiSDi3ZaDXG9F2yAoyzHRBtksZnI60VWY5aM/yAENCccBejrxw8jDY+9OVlxw==", "dev": true }, + "@types/mapbox__geo-viewport": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox__geo-viewport/-/mapbox__geo-viewport-0.4.1.tgz", + "integrity": "sha512-aW7Orm58KsT9KmpZb1sqM2l/KufsS1IUsL1RCplVLfUIbpTk3PYkpOat3CN7jA8KcO1w1TuNFapn0g+/rASWzQ==", + "dev": true + }, "@types/mapbox__point-geometry": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.2.tgz", diff --git a/packages/visualizations/package.json b/packages/visualizations/package.json index c37c4887..8cd92b53 100644 --- a/packages/visualizations/package.json +++ b/packages/visualizations/package.json @@ -45,8 +45,11 @@ "@rollup/plugin-replace": "^3.0.1", "@rollup/plugin-typescript": "^6.1.0", "@tsconfig/svelte": "^1.0.10", + "@types/chroma-js": "^2.1.4", + "@types/geojson": "^7946.0.10", "@types/lodash": "^4.14.182", "@types/luxon": "^2.0.5", + "@types/mapbox__geo-viewport": "^0.4.1", "@types/markdown-it": "^12.0.1", "@types/markdown-it-link-attributes": "^3.0.1", "@types/rollup-plugin-visualizer": "^4.2.1", diff --git a/packages/visualizations/src/components/Map/Choropleth.svelte b/packages/visualizations/src/components/Map/Choropleth.svelte index e94b720d..d5418cb2 100644 --- a/packages/visualizations/src/components/Map/Choropleth.svelte +++ b/packages/visualizations/src/components/Map/Choropleth.svelte @@ -1,18 +1,31 @@ - diff --git a/packages/visualizations/src/components/Map/index.ts b/packages/visualizations/src/components/Map/index.ts index 430d0b09..3eb64f31 100644 --- a/packages/visualizations/src/components/Map/index.ts +++ b/packages/visualizations/src/components/Map/index.ts @@ -1,4 +1,5 @@ -import type { ChoroplethOptions, DataFrame } from '../types'; +import type { DataFrame } from '../types'; +import type { ChoroplethOptions } from './types'; import ChoroplethImpl from './Choropleth.svelte'; import SvelteImpl from '../SvelteImpl'; diff --git a/packages/visualizations/src/components/Map/types.ts b/packages/visualizations/src/components/Map/types.ts new file mode 100644 index 00000000..f3034b2b --- /dev/null +++ b/packages/visualizations/src/components/Map/types.ts @@ -0,0 +1,71 @@ +import type { Feature, FeatureCollection, Position } from 'geojson'; +import type { FillLayerSpecification, Popup } from 'maplibre-gl'; +import type { Color, ColorsScale } from '../types'; + +export interface ChoroplethOptions { + shapes: ChoroplethShapeValue; + colorsScale?: ColorsScale; + legend?: MapLegend; + aspectRatio: number; + activeShapes?: string[]; + interactive?: boolean; + emptyValueColor: Color; + tooltip: { label: ChoroplethTooltipFormatter }; +} + +export interface MapLegend { + title?: string; +} + +/** Function used to render an HTML Tooltip depending on the shape the user + * interacted with. + */ +export type ChoroplethTooltipFormatter = ({ + value, + label, + key, +}: { + /** Numeric value of the shape */ + value?: number; + /** Label of the shape */ + label: string; + /** Value of the key used to match shapes and numeric data */ + key?: string; +}) => string; + +/** Structure containing the numerical data used by the Choropleth to compute + * the legend and the color of the shapes it renders. + */ +export interface ChoroplethDataValue { + x: string; + y: number; +} + +/** `ChoroplethShapeValue` implementation based on a GeoJSON FeatureCollection. */ +export interface ChoroplethShapeGeoJsonValue { + type: 'geojson'; + geoJson: FeatureCollection | null; +} + +/** `ChoroplethShapeValue` implementation based on a Vector Tiles source URL. */ +export interface ChoroplethShapeVectorTilesValue { + type: 'vtiles'; + url: string; +} + +/** Structure containing everything necessary for a Choropleth to render shapes visually. + * Supports different types of structures, such as GeoJSON features, or a Vector Tiles source. + */ +export type ChoroplethShapeValue = ChoroplethShapeGeoJsonValue | ChoroplethShapeVectorTilesValue; + +export interface ChoroplethFixedTooltipDescription { + center: Position; + description: string; + popup: Popup; +} + +export type MapRenderTooltipFunction = (f: Feature) => string; + +export type ChoroplethLayer = Omit; + +export type MapLayer = ChoroplethLayer; diff --git a/packages/visualizations/src/components/Map/utils.js b/packages/visualizations/src/components/Map/utils.ts similarity index 69% rename from packages/visualizations/src/components/Map/utils.js rename to packages/visualizations/src/components/Map/utils.ts index 7bbb8b4f..9bf7f1ae 100644 --- a/packages/visualizations/src/components/Map/utils.js +++ b/packages/visualizations/src/components/Map/utils.ts @@ -2,23 +2,42 @@ import chroma from 'chroma-js'; import turfBbox from '@turf/bbox'; import maplibregl from 'maplibre-gl'; import geoViewport from '@mapbox/geo-viewport'; - -export const LIGHT_GREY = '#CBD2DB'; -export const DARK_GREY = '#515457'; - -export const colorShapes = (geoJson, values, colorsScale, emptyValueColor) => { +import type { FeatureCollection, Feature, Position, BBox } from 'geojson'; +import type { Scale } from 'chroma-js'; +import type { Color, ColorsScale } from '../types'; +import type { + ChoroplethDataValue, + ChoroplethFixedTooltipDescription, + MapRenderTooltipFunction, +} from './types'; + +export const LIGHT_GREY: Color = '#CBD2DB'; +export const DARK_GREY: Color = '#515457'; + +export const colorShapes = ( + geoJson: FeatureCollection, + values: ChoroplethDataValue[], + colorsScale: ColorsScale, + emptyValueColor: Color +): { + geoJson: FeatureCollection; + bounds: { + min: number; + max: number; + }; +} => { // Key in the values is "x" // Key in the shapes is "key" // We add a color property in the JSON const rawValues = values.map((v) => v.y); const min = Math.min(...rawValues); const max = Math.max(...rawValues); - let colorMin; - let colorMax; - let scale; + let colorMin: Color; + let colorMax: Color; + let scale: Scale; if (colorsScale?.type === 'palette') { - const thresholdArray = []; + const thresholdArray: number[] = []; colorsScale.colors.forEach((_color, i) => { if (i === 0) { thresholdArray.push(min); @@ -36,15 +55,15 @@ export const colorShapes = (geoJson, values, colorsScale, emptyValueColor) => { scale = chroma.scale([colorMin, colorMax]).domain([min, max]); } - const dataMapping = {}; + const dataMapping: { [key: ChoroplethDataValue['x']]: ChoroplethDataValue['y'] } = {}; values.forEach((v) => { dataMapping[v.x] = v.y; }); // Iterate shapes, compute color from matching value const coloredFeatures = geoJson.features.map((feature) => { - const shapeMapping = feature.properties.key; - const value = dataMapping[shapeMapping]; // FIXME: beware of int/string differences in keys + const shapeMapping: string = feature.properties?.key; + const value: number = dataMapping[shapeMapping]; // FIXME: beware of int/string differences in keys const color = value ? scale(value).hex() : emptyValueColor; return { @@ -67,31 +86,14 @@ export const colorShapes = (geoJson, values, colorsScale, emptyValueColor) => { }; }; -export const mapKeyToColor = (values, colorScale) => { - const rawValues = values.map((v) => v.y); - const min = Math.min(...rawValues); - const max = Math.max(...rawValues); - - const colorMin = chroma(colorScale).darken(4).hex(); - const colorMax = chroma(colorScale).brighten(4).hex(); - - const scale = chroma.scale([colorMin, colorMax]).domain([min, max]); - - const mapping = {}; - - values.forEach((v) => { - mapping[v.x] = scale(v.y).hex(); - }); - - return mapping; -}; - // This is a default bound that will be extended -const VOID_BOUNDS = [180, 90, -180, -90]; +const VOID_BOUNDS: BBox = [180, 90, -180, -90]; -function computeBboxFromCoords(coordsPath, bbox) { - return coordsPath.reduce( - (current, coords) => [ +type CoordsPath = Position[]; + +function computeBboxFromCoords(coordsPath: CoordsPath, bbox: BBox): BBox { + return coordsPath.reduce( + (current: BBox, coords: Position) => [ Math.min(coords[0], current[0]), Math.min(coords[1], current[1]), Math.max(coords[0], current[2]), @@ -103,8 +105,12 @@ function computeBboxFromCoords(coordsPath, bbox) { // The features given by querySourceFeatures are cut based on a tile representation // but we need the bounding box of the features themselves, so we need to build them again -function mergeBboxFromFeaturesWithSameKey(features) { - const mergedBboxes = {}; +function mergeBboxFromFeaturesWithSameKey(features: Feature[]) { + const mergedBboxes: { + [key: string]: { + bbox: BBox; + }; + } = {}; features.forEach((feature) => { // FIXME: supports only shapes for now if (feature.geometry.type === 'Polygon') { @@ -113,14 +119,14 @@ function mergeBboxFromFeaturesWithSameKey(features) { feature.geometry.coordinates.forEach((coordsPath) => { bbox = computeBboxFromCoords(coordsPath, bbox); }); - const id = feature.properties.key; + const id: string = feature.properties?.key; if (!mergedBboxes[id]) { mergedBboxes[id] = { bbox, }; } else { const storedBbox = mergedBboxes[id].bbox; - const mergedBbox = [ + const mergedBbox: BBox = [ Math.min(bbox[0], storedBbox[0]), Math.min(bbox[1], storedBbox[1]), Math.max(bbox[2], storedBbox[2]), @@ -137,10 +143,13 @@ function mergeBboxFromFeaturesWithSameKey(features) { } // We're calculating the maximum zoom required to fit the smallest feature we're displaying, to prevent people from zooming "too far" by accident -export const computeMaxZoomFromGeoJsonFeatures = (mapContainer, features) => { +export const computeMaxZoomFromGeoJsonFeatures = ( + mapContainer: HTMLElement, + features: Feature[] +): number => { let maxZoom = 0; // maxZoom lowest value possible const filteredBboxes = mergeBboxFromFeaturesWithSameKey(features); - Object.values(filteredBboxes).forEach((value) => { + Object.values(filteredBboxes).forEach((value: any) => { // Vtiles = 512 tilesize maxZoom = Math.max( geoViewport.viewport( @@ -157,16 +166,20 @@ export const computeMaxZoomFromGeoJsonFeatures = (mapContainer, features) => { return maxZoom; }; -const getShapeCenter = (feature) => { +const getShapeCenter = (feature: Feature) => { const featureBbox = turfBbox(feature.geometry); const centerLatitude = (featureBbox[1] + featureBbox[3]) / 2; const centerLongitude = (featureBbox[0] + featureBbox[2]) / 2; return [centerLongitude, centerLatitude]; }; -export const getFixedTooltips = (shapeKeys, features, renderTooltip) => { +export const getFixedTooltips = ( + shapeKeys: string[], + features: Feature[], + renderTooltip: MapRenderTooltipFunction +): ChoroplethFixedTooltipDescription[] => { const popups = shapeKeys.map((shapeKey) => { - const matchedFeature = features.find((feature) => feature.properties.key === shapeKey); + const matchedFeature = features.find((feature) => feature.properties?.key === shapeKey); if (matchedFeature) { const center = getShapeCenter(matchedFeature); const description = renderTooltip(matchedFeature); @@ -180,5 +193,7 @@ export const getFixedTooltips = (shapeKeys, features, renderTooltip) => { return null; }); - return popups; + return popups.filter((item): item is NonNullable => + Boolean(item) + ) as ChoroplethFixedTooltipDescription[]; }; diff --git a/packages/visualizations/src/components/types.ts b/packages/visualizations/src/components/types.ts index dd8f593a..681ba925 100644 --- a/packages/visualizations/src/components/types.ts +++ b/packages/visualizations/src/components/types.ts @@ -234,8 +234,6 @@ export interface KpiCardOptions { format?: (value: number) => string; } -export interface ChoroplethOptions {} - export interface DataBounds { min: number; max: number; diff --git a/packages/visualizations/src/components/utils/ColorsLegend.svelte b/packages/visualizations/src/components/utils/ColorsLegend.svelte index 388a0652..ee727cea 100644 --- a/packages/visualizations/src/components/utils/ColorsLegend.svelte +++ b/packages/visualizations/src/components/utils/ColorsLegend.svelte @@ -10,7 +10,7 @@ export let dataBounds: DataBounds; export let colorsScale: ColorsScale; export let variant: LegendVariant; - export let title: string; + export let title: string | undefined; // the part below is related to labels rotation let legendWidth: number; diff --git a/packages/visualizations/src/index.ts b/packages/visualizations/src/index.ts index 510b3d7e..95e7d3ab 100644 --- a/packages/visualizations/src/index.ts +++ b/packages/visualizations/src/index.ts @@ -4,3 +4,4 @@ export { default as KpiCard } from './components/KpiCard'; export { default as Choropleth } from './components/Map'; export * from './types'; export * from './components/types'; +export * from './components/Map/types';