diff --git a/packages/visualizations-react/src/components/CategoryLegend.tsx b/packages/visualizations-react/src/components/CategoryLegend.tsx new file mode 100644 index 00000000..8a14b797 --- /dev/null +++ b/packages/visualizations-react/src/components/CategoryLegend.tsx @@ -0,0 +1,8 @@ +import { CategoryLegend as _CategoryLegend, CategoryLegendOptions } from '@opendatasoft/visualizations'; +import { FC } from 'react'; +import { SimpleProps } from './Props'; +import wrap from './ReactSimpleImpl'; + +// Explicit name and type are needed for storybook +const CategoryLegend: FC> = wrap(_CategoryLegend); +export default CategoryLegend; \ No newline at end of file diff --git a/packages/visualizations-react/src/components/Props.ts b/packages/visualizations-react/src/components/Props.ts index 90dcd34a..b8e0b866 100644 --- a/packages/visualizations-react/src/components/Props.ts +++ b/packages/visualizations-react/src/components/Props.ts @@ -6,3 +6,8 @@ export interface Props extends HTMLAttributes { options: Options; tag?: string; } + +export interface SimpleProps extends HTMLAttributes { + options: Options; + tag?: string; +} \ No newline at end of file diff --git a/packages/visualizations-react/src/components/ReactSimpleImpl.tsx b/packages/visualizations-react/src/components/ReactSimpleImpl.tsx new file mode 100644 index 00000000..7776d13e --- /dev/null +++ b/packages/visualizations-react/src/components/ReactSimpleImpl.tsx @@ -0,0 +1,55 @@ +import { SimpleComponent } from '@opendatasoft/visualizations'; +import React, { FC, ForwardedRef, forwardRef, useEffect, useRef } from 'react'; +import { useMergeRefs } from 'use-callback-ref'; +import { SimpleProps } from './Props'; + +// FIXME: Test the wrap method + +// Represent one of our simple component class's constructor like CategoryLegend +type ComponentConstructor> = + new (container: HTMLElement, options: Options) => ComponentClass; + +// The wrapper build a function component for the given component class +export default function wrap>( + ComponentConstructor: ComponentConstructor +): FC> { + // We use forwardRef to forward the actual ref of the container + return forwardRef((props: SimpleProps< Options>, forwardedRef: ForwardedRef) => { + const { tag, options, ...elementProps } = props; + + // This ref will hold our SDK component instance + const componentRef = useRef(null); + // This ref will hold the container element + const containerRef = useRef(null); + // By merging container ref and forwarded ref parent component could also access the container ref ! + const ref = useMergeRefs([forwardedRef, containerRef]); + + // Update options (put before creating the component to skip the initial render) + useEffect(() => { + componentRef.current?.updateOptions(options); + }, [options]); + + // Create and destroy + useEffect(() => { + const container = containerRef.current; + if (container) { + const component = new ComponentConstructor(container, options); + componentRef.current = component; + return () => { + component.destroy(); + componentRef.current = null; + }; + } + + throw new Error('Container was expected to be available in useEffect. This is a bug.'); + // We only want to create or destroy on mount, hot reload or tag change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tag]); + + // With React 17 we should be able to just use jsx runtime. + return React.createElement(tag || 'div', { + ...elementProps, // such as style... + ref, + }); + }); +} diff --git a/packages/visualizations-react/src/index.tsx b/packages/visualizations-react/src/index.tsx index 2a3bbb97..308ddc62 100644 --- a/packages/visualizations-react/src/index.tsx +++ b/packages/visualizations-react/src/index.tsx @@ -1,4 +1,4 @@ -export type { Props } from './components/Props'; +export type { Props, SimpleProps } from './components/Props'; export { default as Chart } from './components/Chart'; export { default as MarkdownText } from './components/MarkdownText'; export { default as KpiCard } from './components/KpiCard'; @@ -6,3 +6,4 @@ 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'; +export { default as CategoryLegend } from './components/CategoryLegend'; diff --git a/packages/visualizations-react/stories/Legend/CategoryLegend.stories.tsx b/packages/visualizations-react/stories/Legend/CategoryLegend.stories.tsx new file mode 100644 index 00000000..78bc98b7 --- /dev/null +++ b/packages/visualizations-react/stories/Legend/CategoryLegend.stories.tsx @@ -0,0 +1,27 @@ +import { ComponentMeta } from '@storybook/react'; +import type { CategoryLegendOptions } from '@opendatasoft/visualizations'; +import { CATEGORY_ITEM_VARIANT } from '@opendatasoft/visualizations'; +import { CategoryLegend, SimpleProps } from '../../src'; +import CategoryLegendTemplate from './CategoryLegendTemplate'; + +const meta: ComponentMeta = { + title: 'Legend', + component: CategoryLegend, +}; + +export default meta; + +export const CategoryCircleLegend = CategoryLegendTemplate.bind({}); +const CategoryCircleLegendArgs: SimpleProps = { + options: { + type: 'category' as const, + title: "I Am Legend", + items: [ + { label: 'category 1', color: '#F5C2C1', borderColor: 'red', variant: CATEGORY_ITEM_VARIANT.Circle }, + { label: 'category 2', color: '#90EE90', borderColor: 'green', variant: CATEGORY_ITEM_VARIANT.Circle }, + { label: 'category 3', color: '#ADD8E6', borderColor: 'blue', variant: CATEGORY_ITEM_VARIANT.Circle }, + ], + align: 'start' as const, + }, +}; +CategoryCircleLegend.args = CategoryCircleLegendArgs; diff --git a/packages/visualizations-react/stories/Legend/CategoryLegendTemplate.tsx b/packages/visualizations-react/stories/Legend/CategoryLegendTemplate.tsx new file mode 100644 index 00000000..35b9183d --- /dev/null +++ b/packages/visualizations-react/stories/Legend/CategoryLegendTemplate.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ComponentStory } from '@storybook/react'; +import { CategoryLegendOptions } from '@opendatasoft/visualizations'; +import { CategoryLegend, SimpleProps } from '../../src'; + +const CategoryLegendTemplate: ComponentStory = ( + args: SimpleProps +) => ( +
+ +
+); + +export default CategoryLegendTemplate; \ No newline at end of file diff --git a/packages/visualizations/src/components/Legend/CategoryLegend.svelte b/packages/visualizations/src/components/Legend/CategoryLegend.svelte index d0d931e7..d6cf5530 100644 --- a/packages/visualizations/src/components/Legend/CategoryLegend.svelte +++ b/packages/visualizations/src/components/Legend/CategoryLegend.svelte @@ -1,8 +1,8 @@
diff --git a/packages/visualizations/src/components/Legend/index.ts b/packages/visualizations/src/components/Legend/index.ts new file mode 100644 index 00000000..554e4bb4 --- /dev/null +++ b/packages/visualizations/src/components/Legend/index.ts @@ -0,0 +1,9 @@ +import CategoryLegendImpl from './CategoryLegend.svelte'; +import SvelteSimpleImpl from '../SvelteSimpleImpl'; +import type { CategoryLegendOptions } from './types'; + +export default class CategoryLegendComponent extends SvelteSimpleImpl { + protected getSvelteSimpleComponentClass(): typeof CategoryLegendImpl { + return CategoryLegendImpl; + } +} diff --git a/packages/visualizations/src/components/Legend/types.ts b/packages/visualizations/src/components/Legend/types.ts index 1b271742..16f206b1 100644 --- a/packages/visualizations/src/components/Legend/types.ts +++ b/packages/visualizations/src/components/Legend/types.ts @@ -58,7 +58,7 @@ export type LineCategoryItem = BaseCategoryItem & { export type CategoryItem = CircleCategoryItem | BoxCategoryItem | LineCategoryItem; -export type CategoryLegend = { +export type CategoryLegendOptions = { type: 'category'; items: CategoryItem[]; title?: string; diff --git a/packages/visualizations/src/components/MapPoi/MapRender.svelte b/packages/visualizations/src/components/MapPoi/MapRender.svelte index 0609223c..025d43c6 100644 --- a/packages/visualizations/src/components/MapPoi/MapRender.svelte +++ b/packages/visualizations/src/components/MapPoi/MapRender.svelte @@ -65,7 +65,7 @@

{description}

{/if} {#if legend} - + {/if} {#if sourceLink} diff --git a/packages/visualizations/src/components/MapPoi/types.ts b/packages/visualizations/src/components/MapPoi/types.ts index ba8fb798..565e6148 100644 --- a/packages/visualizations/src/components/MapPoi/types.ts +++ b/packages/visualizations/src/components/MapPoi/types.ts @@ -1,7 +1,7 @@ import type { CircleLayerSpecification, StyleSpecification } from 'maplibre-gl'; import type { BBox } from 'geojson'; import type { Color, Source } from '../types'; -import type { CategoryLegend } from '../Legend/types'; +import type { CategoryLegendOptions } from '../Legend/types'; // To render data layers on the map export type PoiMapData = Partial<{ @@ -28,7 +28,7 @@ export interface PoiMapOptions { title?: string; subtitle?: string; description?: string; - legend?: CategoryLegend; + legend?: CategoryLegendOptions; /** Link button to source */ sourceLink?: Source; } diff --git a/packages/visualizations/src/components/SvelteSimpleImpl.ts b/packages/visualizations/src/components/SvelteSimpleImpl.ts new file mode 100644 index 00000000..0ea2323d --- /dev/null +++ b/packages/visualizations/src/components/SvelteSimpleImpl.ts @@ -0,0 +1,29 @@ +import type { SvelteComponent } from 'svelte'; +import { SimpleComponent } from '../types'; + +export default abstract class SvelteImpl extends SimpleComponent { + protected abstract getSvelteSimpleComponentClass(): typeof SvelteComponent; + + private svelteComponent: SvelteComponent | undefined; + + protected onCreate() { + const ComponentClass = this.getSvelteSimpleComponentClass(); + this.svelteComponent = new ComponentClass({ + target: this.container, + props: { + options: this.options, + }, + }); + } + + protected onOptionsUpdated() { + this.svelteComponent?.$$set?.({ + options: this.options, + }); + } + + protected onDestroy() { + this.svelteComponent?.$destroy(); + this.svelteComponent = undefined; + } +} diff --git a/packages/visualizations/src/index.ts b/packages/visualizations/src/index.ts index e9b46583..b0f7a838 100644 --- a/packages/visualizations/src/index.ts +++ b/packages/visualizations/src/index.ts @@ -4,6 +4,7 @@ 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 { default as CategoryLegend } from './components/Legend'; export * from './types'; export * from './components/types'; diff --git a/packages/visualizations/src/types.ts b/packages/visualizations/src/types.ts index efd70db9..d8b643d6 100644 --- a/packages/visualizations/src/types.ts +++ b/packages/visualizations/src/types.ts @@ -44,3 +44,31 @@ export abstract class BaseComponent { protected abstract onDestroy(): void; } + +export abstract class SimpleComponent { + readonly container: HTMLElement; + + protected options: Options; + + constructor(container: HTMLElement, options: Options) { + this.container = container; + this.options = options; + this.onCreate(); + } + + public updateOptions(newOptions: Options): void { + const oldOptions = this.options; + this.options = newOptions; + this.onOptionsUpdated(oldOptions); + } + + public destroy(): void { + this.onDestroy(); + } + + protected abstract onCreate(): void; + + protected abstract onOptionsUpdated(oldOptions: Options): void; + + protected abstract onDestroy(): void; +} \ No newline at end of file