diff --git a/packages/react-ui/__tests__/widgets/LegendWidgetUI.test.js b/packages/react-ui/__tests__/widgets/LegendWidgetUI.test.js index ca2a0e7ec..f6059e11a 100644 --- a/packages/react-ui/__tests__/widgets/LegendWidgetUI.test.js +++ b/packages/react-ui/__tests__/widgets/LegendWidgetUI.test.js @@ -1,38 +1,25 @@ import React from 'react'; -import Typography from '../../src/components/atoms/Typography'; import LegendWidgetUI from '../../src/widgets/legend/LegendWidgetUI'; import { fireEvent, render, screen } from '../widgets/utils/testUtils'; -const CUSTOM_CHILDREN = Legend custom; - const MY_CUSTOM_LEGEND_KEY = 'my-custom-legend'; -const LAYER_OPTIONS = { - PALETTE_SELECTOR: 'PALETTE_SELECTOR' -}; - -const LAYER_OPTIONS_COMPONENTS = { - [LAYER_OPTIONS.PALETTE_SELECTOR]: PaletteSelector -}; - -function PaletteSelector() { - return

PaletteSelector

; -} - describe('LegendWidgetUI', () => { const DATA = [ { + // 0 id: 'category', title: 'Category Layer', visible: true, + helperText: 'lorem', legend: { type: 'category', - note: 'lorem', colors: ['#000', '#00F', '#0F0'], labels: ['Category 1', 'Category 2', 'Category 3'] } }, { + // 1 id: 'icon', title: 'Icon Layer', visible: true, @@ -47,6 +34,7 @@ describe('LegendWidgetUI', () => { } }, { + // 2 id: 'bins', title: 'Ramp Layer', visible: true, @@ -57,6 +45,7 @@ describe('LegendWidgetUI', () => { } }, { + // 3 id: 'continuous', title: 'Ramp Layer', visible: true, @@ -67,6 +56,7 @@ describe('LegendWidgetUI', () => { } }, { + // 4 id: 'proportion', title: 'Proportion Layer', visible: true, @@ -76,14 +66,7 @@ describe('LegendWidgetUI', () => { } }, { - id: 'custom', - title: 'Single Layer', - visible: true, - legend: { - children: CUSTOM_CHILDREN - } - }, - { + // 5 id: 'custom_key', title: 'Single Layer', visible: true, @@ -95,33 +78,10 @@ describe('LegendWidgetUI', () => { colors: ['#000', '#00F', '#0F0'], labels: ['Category 1', 'Category 2', 'Category 3'] } - }, - { - id: 'custom_children', - title: 'Single Layer', - visible: true, - showOpacityControl: true, - opacity: 0.6, - legend: { - children: CUSTOM_CHILDREN - } - }, - { - id: 'palette', - title: 'Store types', - visible: true, - options: [LAYER_OPTIONS.PALETTE_SELECTOR], - legend: { - children: CUSTOM_CHILDREN - } } ]; - const Widget = (props) => ; - test('single legend', () => { - render(); - expect(screen.queryByText('Layers')).not.toBeInTheDocument(); - }); + const Widget = (props) => ; test('multiple legends', () => { render(); @@ -131,10 +91,33 @@ describe('LegendWidgetUI', () => { test('multiple legends with collapsed as true', () => { render(); - expect(screen.queryByText('Layers')).toBeInTheDocument(); + // expanded legend toggle + expect(screen.queryByText('Layers')).not.toBeInTheDocument(); + // collapsed legend toggle + expect(screen.queryByLabelText('Layers')).toBeInTheDocument(); expect(screen.queryByTestId('categories-legend')).not.toBeInTheDocument(); }); + test('layer with no legend is not shown on widget', () => { + const layers = [ + { + id: 'test-layer-no-legend', + title: 'Test layer hidden', + visible: true + }, + { + id: 'test-layer-no-legend-2', + title: 'Test layer shown', + visible: true, + legend: {} + } + ]; + render(); + expect(screen.queryByText('Layers')).toBeInTheDocument(); + expect(screen.queryByText('Test layer hidden')).not.toBeInTheDocument(); + expect(screen.queryByText('Test layer shown')).toBeInTheDocument(); + }); + test('Category legend', () => { render(); expect(screen.getByTestId('categories-legend')).toBeInTheDocument(); @@ -160,11 +143,6 @@ describe('LegendWidgetUI', () => { expect(screen.getByTestId('proportion-legend')).toBeInTheDocument(); }); - test('Custom legend', () => { - render(); - expect(screen.getByText('Legend custom')).toBeInTheDocument(); - }); - test('Empty legend', () => { const EMPTY_LAYER = { id: 'empty', title: 'Empty Layer', legend: {} }; render(); @@ -184,42 +162,53 @@ describe('LegendWidgetUI', () => { test('with custom legend types', () => { const MyCustomLegendComponent = jest.fn(); MyCustomLegendComponent.mockReturnValue(

Test

); + render( ); + expect(MyCustomLegendComponent).toHaveBeenCalled(); expect(MyCustomLegendComponent).toHaveBeenCalledWith( - { layer: DATA[6], legend: DATA[6].legend }, + { layer: DATA[5], legend: DATA[5].legend }, {} ); expect(screen.getByText('Test')).toBeInTheDocument(); }); test('legend with opacity control', async () => { - const legendConfig = DATA[7]; + const legendConfig = { + id: 'test-opacity-control', + title: 'Test opacity control', + visible: true, + showOpacityControl: true, + opacity: 0.8, + legend: {} + }; const onChangeOpacity = jest.fn(); const container = render( ); - const layerOptionsBtn = await screen.findByLabelText('Layer options'); - expect(layerOptionsBtn).toBeInTheDocument(); - layerOptionsBtn.click(); - expect(screen.getByText('Opacity')).toBeInTheDocument(); + + const toggleButton = screen.getByRole('button', { name: 'Opacity' }); + expect(toggleButton).toBeInTheDocument(); + toggleButton.click(); const opacitySelectorInput = container.getByTestId('opacity-slider'); - expect(opacitySelectorInput.value).toBe('' + legendConfig.opacity * 100); + expect(opacitySelectorInput).toBeInTheDocument(); + + expect(opacitySelectorInput.value).toBe(String(legendConfig.opacity * 100)); - fireEvent.change(opacitySelectorInput, { target: { value: '50' } }); + fireEvent.change(opacitySelectorInput, { target: { value: 50 } }); expect(onChangeOpacity).toHaveBeenCalledTimes(1); expect(onChangeOpacity).toHaveBeenCalledWith({ id: legendConfig.id, opacity: 0.5 }); }); test('should manage legend collapsed state correctly', () => { - let legendConfig = { ...DATA[7], legend: { ...DATA[7].legend, collapsed: true } }; + let legendConfig = { ...DATA[0], collapsed: true }; const onChangeLegendRowCollapsed = jest.fn(); const { rerender } = render( @@ -229,11 +218,11 @@ describe('LegendWidgetUI', () => { > ); - expect(screen.queryByText('Legend custom')).not.toBeInTheDocument(); + expect(screen.queryByTestId('legend-layer-variable-list')).not.toBeInTheDocument(); - const layerOptionsBtn = screen.getByText('Single Layer'); - expect(layerOptionsBtn).toBeInTheDocument(); - layerOptionsBtn.click(); + const toggleButton = screen.getByRole('button', { name: 'Expand layer' }); + expect(toggleButton).toBeInTheDocument(); + toggleButton.click(); expect(onChangeLegendRowCollapsed).toHaveBeenCalledTimes(1); expect(onChangeLegendRowCollapsed).toHaveBeenCalledWith({ @@ -241,7 +230,8 @@ describe('LegendWidgetUI', () => { collapsed: false }); - legendConfig = { ...DATA[7], legend: { ...DATA[7].legend, collapsed: false } }; + legendConfig = { ...DATA[0], collapsed: false }; + rerender( { > ); - expect(screen.getByText('Legend custom')).toBeInTheDocument(); + expect(screen.getByTestId('legend-layer-variable-list')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Collapse layer' })).toBeInTheDocument(); }); - test('with custom layer options', async () => { - const layer = DATA[8]; - render( - - ); - const layerOptionsBtn = await screen.findByLabelText('Layer options'); - expect(layerOptionsBtn).toBeInTheDocument(); - layerOptionsBtn.click(); - expect(screen.getByText('PaletteSelector')).toBeInTheDocument(); - }); - - test('with custom layer options - unknown option', async () => { - const layer = { ...DATA[8], options: ['unknown'] }; - render( - - ); - const layerOptionsBtn = await screen.findByLabelText('Layer options'); - expect(layerOptionsBtn).toBeInTheDocument(); - layerOptionsBtn.click(); - expect(screen.getByText('Unknown layer option')).toBeInTheDocument(); + test('helper text', () => { + render(); + expect(screen.getByText('helperText')).toBeInTheDocument(); }); }); diff --git a/packages/react-ui/__tests__/widgets/legend/LegendCategories.test.js b/packages/react-ui/__tests__/widgets/legend/LegendCategories.test.js index 914a035b9..250570fee 100644 --- a/packages/react-ui/__tests__/widgets/legend/LegendCategories.test.js +++ b/packages/react-ui/__tests__/widgets/legend/LegendCategories.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '../utils/testUtils'; -import LegendCategories from '../../../src/widgets/legend/LegendCategories'; +import LegendCategories from '../../../src/widgets/legend/legend-types/LegendCategories'; import { getPalette } from '../../../src/utils/palette'; import { hexToRgb } from '@mui/material'; diff --git a/packages/react-ui/__tests__/widgets/legend/LegendIcon.test.js b/packages/react-ui/__tests__/widgets/legend/LegendIcon.test.js index 69605b83c..9ad8b5187 100644 --- a/packages/react-ui/__tests__/widgets/legend/LegendIcon.test.js +++ b/packages/react-ui/__tests__/widgets/legend/LegendIcon.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '../../widgets/utils/testUtils'; -import LegendIcon from '../../../src/widgets/legend/LegendIcon'; +import LegendIcon from '../../../src/widgets/legend/legend-types/LegendIcon'; const ICON = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxkZWZzPgogICAgICAgIDxmaWx0ZXIgaWQ9InFsbTY3aXI1MWEiPgogICAgICAgICAgICA8ZmVDb2xvck1hdHJpeCBpbj0iU291cmNlR3JhcGhpYyIgdmFsdWVzPSIwIDAgMCAwIDAuMTcyNTQ5IDAgMCAwIDAgMC4xODgyMzUgMCAwIDAgMCAwLjE5NjA3OCAwIDAgMCAxLjAwMDAwMCAwIi8+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnPgogICAgICAgICAgICA8ZyBmaWx0ZXI9InVybCgjcWxtNjdpcjUxYSkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yNDIgLTQ2NCkgdHJhbnNsYXRlKDIyMiA0NDgpIj4KICAgICAgICAgICAgICAgIDxnIGZpbGw9IiMyQzMwMzIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIwIDE2KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyIDBjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnMtNS4zNzMgMTItMTIgMTJTMCAxOC42MjcgMCAxMiA1LjM3MyAwIDEyIDB6IiBvcGFjaXR5PSIuMiIvPgogICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjQiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==`; const ICON_2 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gNzAK/9sAQwAKBwcIBwYKCAgICwoKCw4YEA4NDQ4dFRYRGCMfJSQiHyIhJis3LyYpNCkhIjBBMTQ5Oz4+PiUuRElDPEg3PT47/9sAQwEKCwsODQ4cEBAcOygiKDs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7/8AAEQgCAAIAAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A9gooopDCiiigCWiiikAUUUUAR0UUUigooooAfRRRQIKKKKAFooopiCiiigBaKKKBBRRRQA2iiirJCiiigCOiiimZhRRRQAtFFFcRqFFFFAC0UUV2lhRRRQA6iiikQFFFFAEdFFFBgFFFFAD6KKKyNgooooAKKKK2NAooooAWiiikIKKKKAG0UUUxhRRRQA2iiioEFFFFAEtFFFIAooooAjooopFBRRRQA+iiigQUUUUALRRRTEFFFFAC0UUUCCiiigBtFFFWSFFFFAEdFFFMzCiiigBaKKK4jUKKKKAFooortLCiiigB1FFFIgKKKKAI6KKKDAKKKKAH0UUVkbBRRRQAUUUVsaBRRRQAtFFFIQUUUUANooopjCiiigCSiiisyQooooAbRRRVkhRRRQAtFFFABRRRQBBRRRVgFFFFAE9FFFQWFFFFAFSiiisTUKKKKAEooorAsKKKKAGUUUViUFFFFAFeiiitjYKKKKAJaKKK6jkCiiigDRoooqzMKKKKAKtFFFQaBRRRQBcooopmIUUUUAV6KKKg0CiiigCeiiiqMwooooAiooorU5QooooAmooorI6gooooAbRRRVkhRRRQAtFFFABRRRQBBRRRVgFFFFAE9FFFQWFFFFAFSiiisTUKKKKAEooorAsKKKKAGUUUViUFFFFAFeiiitjYKKKKAJaKKK6jkCiiigDRoooqzMKKKKAKtFFFQaBRRRQBcooopmIUUUUAV6KKKg0CiiigCeiiiqMwooooAiooorU5QooooAdRRRSNAooooAWiiigYUUUUANooorA3CiiigBKKKK5igooooA4uiiivNPRCiiigDNooorzzqCiiigChRRRXCdAUUUUAOoooqCwooooAWiiigzCiiigBaKKKRyhRRRQBPRRRXWAUUUUAdBRRRXqkhRRRQB01FFFeweWFFFFAE1FFFdhmFFFFAEdFFFUaBRRRQBJRRRUkBRRRQAUUUUHMFFFFAC0UUUDCiiigBtFFFYG4UUUUAJRRRXMUFFFFAHF0UUV5p6IUUUUAZtFFFeedQUUUUAUKKKK4ToCiiigB1FFFQWFFFFAC0UUUGYUUUUALRRRSOUKKKKAJ6KKK6wCiiigDoKKKK9UkKKKKAOmooor2DywooooAmooorsMwooooAjoooqjQKKKKAJKKKKkgKKKKACiiigQUUUUAFFFFcwBRRRQBVooorzDoCiiigDmqKKK809AKKKKAIaKKKkoKKKKALlFFFdhAUUUUAaNFFFegYhRRRQBo0UUV6BxhRRRQBHRRRWBkFFFFAEdFFFcB1hRRRQBz1FFFcp1hRRRQBlUUUVwnQFFFFAGfRRRWYwooooA72iiivoTyQooooA2KKKK9U80KKKKAI6KKK4SwooooAkoooruNgooooAKKKK5gCiiigCrRRRXmHQFFFFAHNUUUV5p6AUUUUAQ0UUVJQUUUUAXKKKK7CAooooA0aKKK9AxCiiigDRooor0DjCiiigCOiiisDIKKKKAI6KKK4DrCiiigDnqKKK5TrCiiigDKooorhOgKKKKAM+iiisxhRRRQB3tFFFfQnkhRRRQBsUUUV6p5oUUUUAR0UUVwlhRRRQBPRRRXYWFFFFACUUUVJYUUUUAcbRRRQbhRRRQBxVFFFIoKKKKAO4ooopiCiiigDrqKKKDnCiiigBKKKKsxCiiigBlFFFMxCiiigCSiiig7AooooAbRRRQMKKKKAFooooEFFFFAGPRRRXgncFFFFAHFUUUV4p7QUUUUAZ1FFFamYUUUUAeg0UUV7Z44UUUUAdBRRRXqHEFFFFABRRRVEhRRRQAlFFFSWFFFFAHG0UUUG4UUUUAcVRRRSKCiiigDuKKKKYgooooA66iiig5wooooASiiirMQooooAZRRRTMQooooAkooooOwKKKKAG0UUUDCiiigBaKKKBBRRRQBj0UUV4J3BRRRQBxVFFFeKe0FFFFAGdRRRWpmFFFFAHoNFFFe2eOFFFFAHQUUUV6hxBRRRQA2iiitDIKKKKAPHaKKKzJCiiigDyKiiipAKKKKAIaKKKosKKKKAGUUUUxhRRRQB3tFFFUMKKKKAPoqiiimWFFFFABRRRQAUUUUAPooopGYUUUUAR0UUVJqFFFFAHEUUUV4R6YUUUUAZ9FFFeeegFFFFAEVFFFZm4UUUUAZ1FFFcpqFFFFAF6iiitTmCiiigD0yiiivuz5sKKKKAJaKKKk5gooooA8dooorMkKKKKAPIqKKKkAooooAhoooqiwooooAZRRRTGFFFFAHe0UUVQwooooA+iqKKKZYUUUUAFFFFABRRRQA+iiikZhRRRQBHRRRUmoUUUUAcRRRRXhHphRRRQBn0UUV556AUUUUARUUUVmbhRRRQBnUUUVymoUUUUAXqKKK1OYKKKKAPTKKKK+7PmwooooA46iiisTUKKKKAPnCiiiqICiiigD2CiiiqMwooooA9hoooqiAooooA5CiiimSFFFFAHzTRRRUjCiiigD1CiiigoKKKKAPeaKKKQwooooAKKKK5yAooooA5OiiivAPTCiiigDkqKKK4jsCiiigDvKKKK9484KKKKAN2iiivSPPCiiigDJooorwD0gooooA8xooorzz0wooooA9Cooor2znCiiigDLooor1DzwooooA+cKKKKogKKKKAPYKKKKozCiiigD2GiiiqICiiigDkKKKKZIUUUUAfNNFFFSMKKKKAPUKKKKCgooooA95ooopDCiiigAooornICiiigDk6KKK8A9MKKKKAOSoooriOwKKKKAO8ooor3jzgooooA3aKKK9I88KKKKAMmiiivAPSCiiigDzGiiivPPTCiiigD0KiiivbOcKKKKAPCqKKK9U8AKKKKAMeiiimUFFFFAH1lRRRQUFFFFADaKKKZYUUUUALRRRSEFFFFAHjFFFFQAUUUUAeO0UUUyAooooA+x6KKKzGFFFFACUUUUywooooA8tooor5A+nCiiigDpKKKK7DAKKKKAOwooor6k+aCiiigBlFFFMyCiiigBtFFFcB2hRRRQBxFFFFeaegFFFFACUUUV5hYUUUUAeSUUUV9ieEFFFFAGPRRRTKCiiigD6yooooKCiiigBtFFFMsKKKKAFooopCCiiigDxiiiioAKKKKAPHaKKKZAUUUUAfY9FFFZjCiiigBKKKKZYUUUUAeW0UUV8gfThRRRQB0lFFFdhgFFFFAHYUUUV9SfNBRRRQAyiiimZBRRRQA2iiiuA7QooooA4iiiivNPQCiiigBKKKK8wsKKKKAPKaKKK+yPHCiiigDAooooAKKKKAPrOiiigAooooAWiiigAooooAKKKKACiiigDwWiiigAooooA8xooooAKKKKAPsaiiigAooooASiiisygooooA8rooor4g9wKKKKAOlooorrJCiiigDsKKKK+xPCCiiigBtFFFMyCiiigCCiiiuM7gooooA4aiiivKPaCiiigCxRRRXCc4UUUUAeTUUUV9aeKFFFFAGBRRRQAUUUUAfWdFFFABRRRQAtFFFABRRRQAUUUUAFFFFAHgtFFFABRRRQB5jRRRQAUUUUAfY1FFFABRRRQAlFFFZlBRRRQB5XRRRXxB7gUUUUAdLRRRXWSFFFFAHYUUUV9ieEFFFFADaKKKZkFFFFAEFFFFcZ3BRRRQBw1FFFeUe0FFFFAFiiiiuE5wooooAo0UUV9aeKFFFFAHgNFFFABRRRQB6tRRRQAUUUUAen0UUUAFFFFAHA0UUUAFFFFAHjFFFFABRRRQB67RRRQAUUUUAe10UUUAFFFFADaKKKZAUUUUAcxRRRXinshRRRQBxFFFFeWdoUUUUAd1RRRXonEFFFFAGvRRRXpHGFFFFAGLRRRXjHqBRRRQBwlFFFchYUUUUAel0UUV7RwBRRRQBx1FFFeoeaFFFFAHgNFFFABRRRQB6tRRRQAUUUUAen0UUUAFFFFAHA0UUUAFFFFAHjFFFFABRRRQB67RRRQAUUUUAe10UUUAFFFFADaKKKZAUUUUAcxRRRXinshRRRQBxFFFFeWdoUUUUAd1RRRXonEFFFFAGvRRRXpHGFFFFAGLRRRXjHqBRRRQBwlFFFchYUUUUAel0UUV7RwBRRRQBq0UUV6h5oUUUUAfJFFFFABRRRQBnUUUUAFFFFAGhRRRQAUUUUAR0UUUAFFFFAHplFFFABRRRQB7pRRRQAUUUUANooooAKKKKAIaKKKyOUKKKKAH0UUVkdoUUUUAc1RRRXzx7QUUUUAc7RRRXEMKKKKALdFFFdBIUUUUAZVFFFcIwooooAfRRRTICiiigDraKKK+oOcKKKKAOiooor1TzQooooA+SKKKKACiiigDOooooAKKKKANCiiigAooooAjooooAKKKKAPTKKKKACiiigD3SiiigAooooAbRRRQAUUUUAQ0UUVkcoUUUUAPooorI7QooooA5qiiivnj2gooooA52iiiuIYUUUUAW6KKK6CQooooAyqKKK4RhRRRQA+iiimQFFFFAHW0UUV9Qc4UUUUAdJRRRXqnmhRRRQBztFFFABRRRQB8r0UUUAFFFFABRRRQAUUUUAfUlFFFABRRRQB1FFFFABRRRQBXooooAKKKKAJqKKKACiiigBtFFFUSFFFFABRRRQaBRRRQBFRRRVmgUUUUAZVFFFcZ0BRRRQBxlFFFcZAUUUUAaVFFFIkKKKKANuiiiuoYUUUUAblFFFdZkFFFFAE1FFFIyCiiigDnaKKKACiiigD5XooooAKKKKACiiigAooooA+pKKKKACiiigDqKKKKACiiigCvRRRQAUUUUATUUUUAFFFFADaKKKokKKKKACiiig0CiiigCKiiirNAooooAyqKKK4zoCiiigDjKKKK4yAooooA0qKKKRIUUUUAbdFFFdQwooooA3KKKK6zIKKKKAJKKKKRkFFFFAElFFFABRRRQBytFFFABRRRQB4VRRRQAUUUUAclRRRQAUUUUAdrRRRQAUUUUAd5RRRQAUUUUAdHRRRQAUUUUAV6KKKosKKKKAMKiiisjMKKKKAOJooorrAKKKKAOIooopAFFFFAGtRRRXGIKKKKAPf6KKK6TMKKKKAOzoooqDUKKKKAEooorlLCiiigBKKKK6SQooooAkooooAKKKKAOVooooAKKKKAPCqKKKACiiigDkqKKKACiiigDtaKKKACiiigDvKKKKACiiigDo6KKKACiiigCvRRRVFhRRRQBhUUUVkZhRRRQBxNFFFdYBRRRQBxFFFFIAooooA1qKKK4xBRRRQB7/RRRXSZhRRRQB2dFFFQahRRRQAlFFFcpYUUUUAPooorpJCiiigCtRRRQAUUUUAWaKKKACiiigCvRRRQAUUUUAeV0UUUAFFFFAHl9FFFABRRRQBz9FFFABRRRQAUUUUAFFFFAElFFFQUFFFFAEdFFFUIKKKKAOgooopDCiiigD0miiigsKKKKAPYaKKKszCiiigBKKKKZAUUUUAJRRRQIKKKKAG0UUVzjCiiigCaiiitigooooArUUUUAFFFFAFmiiigAooooAr0UUUAFFFFAHldFFFABRRRQB5fRRRQAUUUUAc/RRRQAUUUUAFFFFABRRRQBJRRRUFBRRRQBHRRRVCCiiigDoKKKKQwooooA9JooooLCiiigD2GiiirMwooooASiiimQFFFFACUUUUCCiiigBtFFFc4wooooAlooorYoKKKKAFooooAKKKKAI6KKKACiiigCSiiigAooooAKKKKACiiigCGiiigAooooAbRRRQAUUUUAZdFFFABRRRQA+iiishBRRRQBq0UUVuWFFFFAEVFFFYlBRRRQBNRRRWJIUUUUAFFFFdYBRRRQAlFFFMAooooAWiiiggKKKKACiiipJCiiigB1FFFBYUUUUALRRRQAUUUUAR0UUUAFFFFAElFFFABRRRQAUUUUAFFFFAENFFFABRRRQA2iiigAooooAy6KKKACiiigB9FFFZCCiiigDVooorcsKKKKAIqKKKxKCiiigCaiiisSQooooAKKKK6wCiiigBKKKKYBRRRQAtFFFBAUUUUAFFFFSSFFFFABRRRQWFFFFAD6KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBKKKKACiiigBaKKKACiiigBtFFFABRRRQAUUUUyAooooAbRRRTNAooooAfRRRUiCiiigCOiiipMgooooAfRRRTLCiiigBKKKKZoFFFFAC0UUUwCiiigBtFFFQZhRRRQAtFFFUUFFFFAD6KKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigBKKKKACiiigBaKKKACiiigBtFFFABRRRQAUUUUyAooooAbRRRTNAooooAfRRRUiCiiigCOiiipMgooooAfRRRTLCiiigBKKKKZoFFFFAC0UUUwCiiigBtFFFQZhRRRQB//Z`; diff --git a/packages/react-ui/__tests__/widgets/legend/LegendProportion.test.js b/packages/react-ui/__tests__/widgets/legend/LegendProportion.test.js index 3a2758ed9..72f81d297 100644 --- a/packages/react-ui/__tests__/widgets/legend/LegendProportion.test.js +++ b/packages/react-ui/__tests__/widgets/legend/LegendProportion.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen } from '../../widgets/utils/testUtils'; -import LegendProportion from '../../../src/widgets/legend/LegendProportion'; +import LegendProportion from '../../../src/widgets/legend/legend-types/LegendProportion'; +import { IntlProvider } from 'react-intl'; const DEFAULT_LEGEND = { labels: ['0', '200'] @@ -8,12 +9,29 @@ const DEFAULT_LEGEND = { describe('LegendProportion', () => { test('renders correctly', () => { - render(); + render( + + + + ); expect(screen.queryByText('Max: 200')).toBeInTheDocument(); expect(screen.queryByText('150')).toBeInTheDocument(); expect(screen.queryByText('50')).toBeInTheDocument(); expect(screen.queryByText('Min: 0')).toBeInTheDocument(); }); + test('renders correctly without min and max', () => { + render( + + + + ); + expect(screen.queryByText('Max')).not.toBeInTheDocument(); + expect(screen.queryByText('Min')).not.toBeInTheDocument(); + expect(screen.queryByText('200')).toBeInTheDocument(); + expect(screen.queryByText('150')).toBeInTheDocument(); + expect(screen.queryByText('50')).toBeInTheDocument(); + expect(screen.queryByText('0')).toBeInTheDocument(); + }); test('renders error if neither labels is defined', () => { render(); expect( diff --git a/packages/react-ui/__tests__/widgets/legend/LegendRamp.test.js b/packages/react-ui/__tests__/widgets/legend/LegendRamp.test.js index 8edfd8d8d..89831bb15 100644 --- a/packages/react-ui/__tests__/widgets/legend/LegendRamp.test.js +++ b/packages/react-ui/__tests__/widgets/legend/LegendRamp.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen } from '../../widgets/utils/testUtils'; -import LegendRamp from '../../../src/widgets/legend/LegendRamp'; +import LegendRamp from '../../../src/widgets/legend/legend-types/LegendRamp'; import { getPalette } from '../../../src/utils/palette'; import { hexToRgb } from '@mui/material'; @@ -41,6 +41,13 @@ describe('LegendRamp', () => { expect(backgroundColor).toBe(hexToRgb(color)); }); }); + test('renders correctly without min and max', () => { + render(); + expect(screen.queryByText('< 0')).not.toBeInTheDocument(); + expect(screen.queryByText('≥ 200')).not.toBeInTheDocument(); + expect(screen.queryByText('0')).toBeInTheDocument(); + expect(screen.queryByText('200')).toBeInTheDocument(); + }); test('renders formatted labels correctly', () => { render(); expect(screen.queryByText('< 0 km')).toBeInTheDocument(); diff --git a/packages/react-ui/src/assets/icons/OpacityIcon.js b/packages/react-ui/src/assets/icons/OpacityIcon.js new file mode 100644 index 000000000..437e7691a --- /dev/null +++ b/packages/react-ui/src/assets/icons/OpacityIcon.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function OpacityIcon(props) { + return ( + + + + ); +} diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index 2418b1b68..ed9d8cc64 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -9,11 +9,12 @@ import FormulaWidgetUI from './widgets/FormulaWidgetUI/FormulaWidgetUI'; import BarWidgetUI from './widgets/BarWidgetUI/BarWidgetUI'; import HistogramWidgetUI from './widgets/HistogramWidgetUI/HistogramWidgetUI'; import PieWidgetUI from './widgets/PieWidgetUI/PieWidgetUI'; -import LegendWidgetUI, { LEGEND_TYPES } from './widgets/legend/LegendWidgetUI'; -import LegendCategories from './widgets/legend/LegendCategories'; -import LegendIcon from './widgets/legend/LegendIcon'; -import LegendProportion from './widgets/legend/LegendProportion'; -import LegendRamp from './widgets/legend/LegendRamp'; +import LegendWidgetUI from './widgets/legend/LegendWidgetUI'; +import LEGEND_TYPES from './widgets/legend/legend-types/LegendTypes' +import LegendCategories from './widgets/legend/legend-types/LegendCategories'; +import LegendIcon from './widgets/legend/legend-types/LegendIcon'; +import LegendProportion from './widgets/legend/legend-types/LegendProportion'; +import LegendRamp from './widgets/legend/legend-types/LegendRamp'; import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI/ScatterPlotWidgetUI'; import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI'; import { @@ -136,4 +137,4 @@ export { Alert, AlertProps, CartoAlertSeverity -}; +}; \ No newline at end of file diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 9cccfa6a9..aaf65a637 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -5,11 +5,12 @@ import FormulaWidgetUI from './widgets/FormulaWidgetUI/FormulaWidgetUI'; import BarWidgetUI from './widgets/BarWidgetUI/BarWidgetUI'; import HistogramWidgetUI from './widgets/HistogramWidgetUI/HistogramWidgetUI'; import PieWidgetUI from './widgets/PieWidgetUI/PieWidgetUI'; -import LegendWidgetUI, { LEGEND_TYPES } from './widgets/legend/LegendWidgetUI'; -import LegendCategories from './widgets/legend/LegendCategories'; -import LegendIcon from './widgets/legend/LegendIcon'; -import LegendProportion from './widgets/legend/LegendProportion'; -import LegendRamp from './widgets/legend/LegendRamp'; +import LegendWidgetUI from './widgets/legend/LegendWidgetUI'; +import LEGEND_TYPES from './widgets/legend/legend-types/LegendTypes'; +import LegendCategories from './widgets/legend/legend-types/LegendCategories'; +import LegendIcon from './widgets/legend/legend-types/LegendIcon'; +import LegendProportion from './widgets/legend/legend-types/LegendProportion'; +import LegendRamp from './widgets/legend/legend-types/LegendRamp'; import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI/ScatterPlotWidgetUI'; import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI'; import { diff --git a/packages/react-ui/src/localization/en.js b/packages/react-ui/src/localization/en.js index 5d74b099f..c9082407d 100644 --- a/packages/react-ui/src/localization/en.js +++ b/packages/react-ui/src/localization/en.js @@ -32,14 +32,33 @@ const locales = { clear: 'Clear' }, legend: { - by: 'By {attr}', layerOptions: 'Layer options', hide: 'Hide', show: 'Show', layer: 'layer', opacity: 'Opacity', hideLayer: 'Hide layer', - showLayer: 'Show layer' + showLayer: 'Show layer', + open: 'Open legend', + close: 'Close', + collapse: 'Collapse layer', + expand: 'Expand layer', + zoomLevel: 'Zoom level', + zoomLevelTooltip: 'This layer is only visible at certain zoom levels', + lowerThan: 'lower than', + greaterThan: 'greater than', + and: 'and', + zoomNote: 'Note: this layer will display at zoom levels', + notSupported: 'is not a known legend type', + subtitles: { + proportion: 'Radius range by', + icon: 'Icon based on', + strokeColor: 'Stroke color based on', + color: 'Color based on' + }, + max: 'Max', + min: 'Min', + maxCategories: 'Legend limited to {n} categories' }, range: { clear: 'Clear', diff --git a/packages/react-ui/src/localization/es.js b/packages/react-ui/src/localization/es.js index 7168d0df7..01396e5a7 100644 --- a/packages/react-ui/src/localization/es.js +++ b/packages/react-ui/src/localization/es.js @@ -32,14 +32,32 @@ const locales = { clear: 'Limpiar' }, legend: { - by: 'Por {attr}', layerOptions: 'Opciones de capa', hide: 'Ocultar', show: 'Mostrar', layer: 'capa', opacity: 'Opacidad', hideLayer: 'Ocultar capa', - showLayer: 'Mostrar capa' + showLayer: 'Mostrar capa', + open: 'Abrir leyenda', + close: 'Cerrar', + collapse: 'Colapsar capa', + expand: 'Expandir capa', + zoomLevel: 'Nivel de zoom', + zoomLevelTooltip: 'Esta capa solo es visible a ciertos niveles de zoom', + lowerThan: 'menor que', + greaterThan: 'mayor que', + and: 'y', + zoomNote: 'Nota: esta capa se mostrará a un nivel de zoom', + notSupported: 'no es un tipo de leyenda conocido', + subtitles: { + proportion: 'Tamaño basado en', + icon: 'Icono basado en', + strokeColor: 'Color del borde basado en', + color: 'Color basado en' + }, + max: 'Max', + min: 'Min' }, range: { clear: 'Limpiar', diff --git a/packages/react-ui/src/localization/id.js b/packages/react-ui/src/localization/id.js index 5235aeb14..1ba1895e2 100644 --- a/packages/react-ui/src/localization/id.js +++ b/packages/react-ui/src/localization/id.js @@ -33,14 +33,33 @@ const locales = { clear: 'Bersihkan' }, legend: { - by: 'Dengan {attr}', - layerOptions: 'Opsi lapisan', + layerOptions: 'Opsi Layer', hide: 'Sembunyikan', show: 'Tampilkan', - layer: 'lapisan', + layer: 'layer', opacity: 'Opasitas', - hideLayer: 'Sembunyikan lapisan', - showLayer: 'Tampilkan lapisan' + hideLayer: 'Sembunyikan layer', + showLayer: 'Tampilkan layer', + open: 'Buka legenda', + close: 'Tutup', + collapse: 'Ciutkan layer', + expand: 'Perluas layer', + zoomLevel: 'Tingkat Zoom', + zoomLevelTooltip: 'Layer ini hanya terlihat pada tingkat zoom tertentu', + lowerThan: 'lebih rendah dari', + greaterThan: 'lebih tinggi dari', + and: 'dan', + zoomNote: 'Catatan: layer ini akan ditampilkan pada tingkat zoom', + notSupported: 'bukan jenis legenda yang dikenal', + subtitles: { + proportion: 'Rentang radius berdasarkan', + icon: 'Ikon berdasarkan', + strokeColor: 'Warna garis berdasarkan', + color: 'Warna berdasarkan' + }, + max: 'Maks', + min: 'Min', + maxCategories: 'Legenda terbatas pada {n} kategori' }, range: { clear: 'Bersihkan', diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 31c04715f..f21729de7 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -7,6 +7,21 @@ export { AccordionGroupProps } from './components/molecules/AccordionGroup'; export { UploadFieldProps } from './components/molecules/UploadField/UploadField'; export { UploadFieldBaseProps } from './components/molecules/UploadField/UploadFieldBase'; export { AppBarProps } from './components/organisms/AppBar/AppBar'; +export { + LegendBins, + LegendCategories, + LegendIcons, + LegendRamp, + LegendProportion, + LegendWidgetUIProps, + LegendSelectConfig, + CustomLegendComponent, + LegendLayerData, + LegendLayerVariableData, + LegendLayerVariableBase, + LegendColors, + LegendNumericLabels +} from './widgets/legend/LegendWidgetUI' export type WrapperWidgetUI = { title: string; @@ -204,36 +219,6 @@ export type FeatureSelectionUIToggleButton = { tooltipPlacement?: 'bottom' | 'left' | 'right' | 'top'; }; -// Legends -export type LegendCategories = { - legend: { - labels?: (string | number)[]; - colors?: string | string[] | number[][]; - isStrokeColor?: boolean; - }; -}; - -export type LegendIcon = { - legend: { - labels?: string[]; - icons?: string[]; - }; -}; - -export type LegendProportion = { - legend: { - labels?: (number | string)[]; - }; -}; - -export type LegendRamp = { - isContinuous?: boolean; - legend: { - labels?: (number | string)[]; - colors?: string | string[] | number[][]; - }; -}; - export type AnimationOptions = { duration?: number; animateOnMount?: boolean; diff --git a/packages/react-ui/src/widgets/legend/LayerOptionWrapper.js b/packages/react-ui/src/widgets/legend/LayerOptionWrapper.js deleted file mode 100644 index d1378ae6d..000000000 --- a/packages/react-ui/src/widgets/legend/LayerOptionWrapper.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Box } from '@mui/material'; -import Typography from '../../components/atoms/Typography'; - -export default function LayerOptionWrapper({ label, children }) { - return ( - - - {label} - - {children} - - ); -} diff --git a/packages/react-ui/src/widgets/legend/LegendCategories.js b/packages/react-ui/src/widgets/legend/LegendCategories.js deleted file mode 100644 index 10ef61be4..000000000 --- a/packages/react-ui/src/widgets/legend/LegendCategories.js +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Box, Grid, Tooltip, styled } from '@mui/material'; -import { getPalette } from '../../utils/palette'; -import PropTypes from 'prop-types'; -import Typography from '../../components/atoms/Typography'; - -function LegendCategories({ legend }) { - const { - labels = [], - colors = [], - isStrokeColor = false, - customMarkers, - maskedMarkers = true - } = legend; - - const palette = getPalette(colors, labels.length); - - const Rows = labels.map((label, idx) => ( - - )); - - return ( - - {Rows} - - ); -} - -LegendCategories.defaultProps = { - legend: { - labels: [], - colors: [], - isStrokeColor: false - } -}; - -const ColorType = PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.number) -]); - -LegendCategories.propTypes = { - legend: PropTypes.shape({ - labels: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), - colors: PropTypes.oneOfType([PropTypes.arrayOf(ColorType), PropTypes.string]), - customMarkers: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.string), - PropTypes.string - ]), - maskedMarkers: PropTypes.bool, - isStrokeColor: PropTypes.bool - }).isRequired -}; - -export default LegendCategories; - -const getCircleStyles = ({ isMax, color, isStrokeColor, theme }) => ({ - border: '2px solid transparent', - '&::after': { - position: 'absolute', - display: isMax ? 'block' : 'none', - content: '""', - width: theme.spacing(2), - height: theme.spacing(2), - border: `2px solid ${theme.palette.grey[900]}`, - transform: 'translate(-30%, -30%)', - borderRadius: '50%', - boxSizing: 'content-box' - }, - ...(isStrokeColor ? { borderColor: color } : { backgroundColor: color }) -}); - -const getIconStyles = ({ icon, color, maskedIcon }) => ({ - maskRepeat: 'no-repeat', - maskSize: 'cover', - backgroundRepeat: 'no-repeat', - backgroundSize: 'cover', - ...(maskedIcon - ? { - backgroundColor: color, - maskImage: `url(${icon})`, - WebkitMaskImage: `url(${icon})` - } - : { - backgroundColor: `rgba(0,0,0,0)`, - backgroundImage: `url(${icon})` - }) -}); - -const Marker = styled(Box, { - shouldForwardProp: (prop) => - !['isMax', 'icon', 'maskedIcon', 'color', 'isStrokeColor'].includes(prop) -})(({ isMax, icon, maskedIcon, color, isStrokeColor, theme }) => ({ - whiteSpace: 'nowrap', - display: 'block', - width: theme.spacing(1.5), - height: theme.spacing(1.5), - borderRadius: '50%', - position: 'relative', - border: '2px solid transparent', - ...(icon - ? getIconStyles({ icon, color, maskedIcon }) - : getCircleStyles({ isMax, color, isStrokeColor, theme })) -})); - -const LongTruncate = styled(Typography)(() => ({ - flex: 1, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis' -})); - -const TitlePhantom = styled(LongTruncate)(() => ({ - opacity: 0, - position: 'absolute', - whiteSpace: 'nowrap', - pointerEvents: 'none' -})); - -function Row({ label, isMax, isStrokeColor, color = '#000', icon, maskedIcon }) { - const [showTooltip, setShowTooltip] = useState(false); - const labelRef = useRef(null); - const labelPhantomRef = useRef(null); - - useEffect(() => { - if (!labelPhantomRef?.current || !labelRef?.current) { - return; - } - const labelSizes = labelRef?.current.getBoundingClientRect(); - const labelPhantomSizes = labelPhantomRef?.current.getBoundingClientRect(); - setShowTooltip(labelPhantomSizes.width > labelSizes.width); - }, [setShowTooltip, labelPhantomRef, labelRef]); - - return ( - - - - - - - {label} - - - {label} - - - - ); -} diff --git a/packages/react-ui/src/widgets/legend/LegendIcon.js b/packages/react-ui/src/widgets/legend/LegendIcon.js deleted file mode 100644 index 7e67aa095..000000000 --- a/packages/react-ui/src/widgets/legend/LegendIcon.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Box, Grid } from '@mui/material'; -import PropTypes from 'prop-types'; -import Typography from '../../components/atoms/Typography'; -import { ICON_SIZE_MEDIUM } from '../../theme/themeConstants'; - -function LegendIcon({ legend }) { - const { labels = [], icons = [] } = legend; - - const Icons = labels.map((label, idx) => ( - - - {label} - - {label} - - )); - - return ( - - {Icons} - - ); -} - -LegendIcon.defaultProps = { - legend: { - labels: [], - icons: [] - } -}; - -LegendIcon.propTypes = { - legend: PropTypes.shape({ - labels: PropTypes.arrayOf(PropTypes.string), - icons: PropTypes.arrayOf(PropTypes.string) - }).isRequired -}; - -export default LegendIcon; diff --git a/packages/react-ui/src/widgets/legend/LegendLayer.js b/packages/react-ui/src/widgets/legend/LegendLayer.js new file mode 100644 index 000000000..2b035644f --- /dev/null +++ b/packages/react-ui/src/widgets/legend/LegendLayer.js @@ -0,0 +1,235 @@ +import React, { useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Collapse, IconButton, Tooltip } from '@mui/material'; +import EyeIcon from '@mui/icons-material/VisibilityOutlined'; +import EyeOffIcon from '@mui/icons-material/VisibilityOffOutlined'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { + LayerVariablesList, + LegendItemHeader, + LegendLayerTitleWrapper, + LegendLayerWrapper +} from './LegendWidgetUI.styles'; +import LegendOpacityControl from './LegendOpacityControl'; +import LegendLayerTitle from './LegendLayerTitle'; +import LegendLayerVariable from './LegendLayerVariable'; +import { useIntl } from 'react-intl'; +import useImperativeIntl from '../../hooks/useImperativeIntl'; +import Typography from '../../components/atoms/Typography'; + +const EMPTY_OBJ = {}; + +/** + * @param {import('./LegendWidgetUI').LegendLayerData['legend']} legend + * @returns {boolean} + */ +function isLegendEmpty(legend) { + if (!legend) { + return true; + } + + if (Array.isArray(legend)) { + return legend.every((l) => isLegendEmpty(l)); + } + + return !legend.select && !legend.type; +} + +/** + * Receives configuration options, send change events and renders a legend item + * @param {object} props + * @param {Object.} props.customLegendTypes - Allow to customise by default legend types that can be rendered. + * @param {import('./LegendWidgetUI').LegendLayerData} props.layer - Layer object from redux store. + * @param {({ id, collapsed }: { id: string, collapsed: boolean }) => void} props.onChangeCollapsed - Callback function for layer visibility change. + * @param {({ id, opacity }: { id: string, opacity: number }) => void} props.onChangeOpacity - Callback function for layer opacity change. + * @param {({ id, visible }: { id: string, visible: boolean }) => void} props.onChangeVisibility - Callback function for layer collapsed state change. + * @param {({ id, index, selection }: { id: string, index: number, selection: unknown }) => void} props.onChangeSelection - Callback function for layer selection change. + * @param {number} props.maxZoom - Global maximum zoom level for the map. + * @param {number} props.minZoom - Global minimum zoom level for the map. + * @param {number} props.currentZoom - Current zoom level for the map. + * @returns {React.ReactNode} + */ +export default function LegendLayer({ + customLegendTypes = EMPTY_OBJ, + layer = EMPTY_OBJ, + onChangeCollapsed, + onChangeOpacity, + onChangeVisibility, + onChangeSelection, + maxZoom, + minZoom, + currentZoom +}) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + const menuAnchorRef = useRef(null); + const [opacityOpen, setOpacityOpen] = useState(false); + + // layer legend defaults as defined here: https://docs.carto.com/carto-for-developers/carto-for-react/library-reference/widgets#legendwidget + const id = layer.id; + const title = layer.title; + const visible = layer.visible ?? true; + const switchable = layer.switchable ?? true; + const collapsed = layer.collapsed ?? false; + const collapsible = (layer.collapsible ?? true) && !isLegendEmpty(layer.legend); + const opacity = layer.opacity ?? 1; + const showOpacityControl = layer.showOpacityControl ?? true; + const isExpanded = visible && !collapsed; + const collapseIcon = isExpanded ? : ; + + const layerHasZoom = layer?.minZoom !== undefined || layer?.maxZoom !== undefined; + const showZoomNote = + layerHasZoom && (layer.minZoom > minZoom || layer.maxZoom < maxZoom); + const outsideCurrentZoom = currentZoom < layer.minZoom || currentZoom > layer.maxZoom; + + const zoomHelperText = getZoomHelperText({ + intl: intlConfig, + minZoom, + maxZoom, + layerMinZoom: layer.minZoom, + layerMaxZoom: layer.maxZoom + }); + const helperText = layer.helperText ?? (showZoomNote ? zoomHelperText : ''); + + const legendLayerVariables = useMemo(() => { + if (!layer.legend) { + return []; + } + return Array.isArray(layer.legend) ? layer.legend : [layer.legend]; + }, [layer.legend]); + + if (!layer.legend) { + return null; + } + + return ( + + + {collapsible && ( + onChangeCollapsed({ id, collapsed: !collapsed })} + > + {collapseIcon} + + )} + + + {showZoomNote && ( + + + {intlConfig.formatMessage({ id: 'c4r.widgets.legend.zoomLevel' })}{' '} + {layer.minZoom} - {layer.maxZoom} + + + )} + + {showOpacityControl && visible && !collapsed && ( + onChangeOpacity({ id, opacity })} + /> + )} + {switchable && ( + + + onChangeVisibility({ + id, + collapsed: visible ? collapsed : false, + visible: !visible + }) + } + > + {visible ? : } + + + )} + + + + {legendLayerVariables.map((legend, index) => ( + + onChangeSelection({ id, index, selection }) + } + /> + ))} + + {helperText && ( + +
+
+ )} +
+
+ ); +} + +LegendLayer.propTypes = { + customLegendTypes: PropTypes.object.isRequired, + layer: PropTypes.object.isRequired, + onChangeCollapsed: PropTypes.func.isRequired, + onChangeOpacity: PropTypes.func.isRequired, + onChangeVisibility: PropTypes.func.isRequired, + onChangeSelection: PropTypes.func.isRequired, + maxZoom: PropTypes.number, + minZoom: PropTypes.number, + currentZoom: PropTypes.number +}; +LegendLayer.defaultProps = { + maxZoom: 21, + minZoom: 0, + currentZoom: 0 +}; + +/** + * @param {object} props + * @param {import('react-intl').IntlShape} props.intl - React Intl object. + * @param {number} props.minZoom - Global minimum zoom level for the map. + * @param {number} props.maxZoom - Global maximum zoom level for the map. + * @param {number} props.layerMinZoom - Layer minimum zoom level. + * @param {number} props.layerMaxZoom - Layer maximum zoom level. + * @returns {string} + */ +function getZoomHelperText({ intl, minZoom, maxZoom, layerMinZoom, layerMaxZoom }) { + const and = intl.formatMessage({ id: 'c4r.widgets.legend.and' }); + const lowerThan = intl.formatMessage({ id: 'c4r.widgets.legend.lowerThan' }); + const greaterThan = intl.formatMessage({ id: 'c4r.widgets.legend.greaterThan' }); + const note = intl.formatMessage({ id: 'c4r.widgets.legend.zoomNote' }); + + const maxZoomText = layerMaxZoom < maxZoom ? `${lowerThan} ${layerMaxZoom}` : ''; + const minZoomText = layerMinZoom > minZoom ? `${greaterThan} ${layerMinZoom}` : ''; + const texts = [maxZoomText, minZoomText].filter(Boolean).join(` ${and} `); + return texts ? `${note} ${texts}` : ''; +} diff --git a/packages/react-ui/src/widgets/legend/LegendLayerTitle.js b/packages/react-ui/src/widgets/legend/LegendLayerTitle.js new file mode 100644 index 000000000..a88b62fce --- /dev/null +++ b/packages/react-ui/src/widgets/legend/LegendLayerTitle.js @@ -0,0 +1,43 @@ +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { Tooltip } from '@mui/material'; +import Typography from '../../components/atoms/Typography'; + +/** Renders the legend layer title with an optional tooltip if the title is detected to be too long. + * @param {object} props + * @param {string} props.title + * @param {boolean} props.visible + * @param {object} props.typographyProps + * @returns {React.ReactNode} + */ +export default function LegendLayerTitle({ title, visible, typographyProps }) { + const ref = useRef(null); + const [isOverflow, setIsOverflow] = useState(false); + + useLayoutEffect(() => { + if (visible && ref.current) { + const { offsetWidth, scrollWidth } = ref.current; + setIsOverflow(offsetWidth < scrollWidth); + } + }, [title, visible]); + + const element = ( + + {title} + + ); + + if (!isOverflow) { + return element; + } + + return {element}; +} diff --git a/packages/react-ui/src/widgets/legend/LegendLayerVariable.js b/packages/react-ui/src/widgets/legend/LegendLayerVariable.js new file mode 100644 index 000000000..18f4ba1aa --- /dev/null +++ b/packages/react-ui/src/widgets/legend/LegendLayerVariable.js @@ -0,0 +1,129 @@ +import React from 'react'; +import { Box, ListItemText, MenuItem, Select } from '@mui/material'; +import LegendCategories from './legend-types/LegendCategories'; +import LegendIcon from './legend-types/LegendIcon'; +import LegendRamp from './legend-types/LegendRamp'; +import LegendProportion from './legend-types/LegendProportion'; +import LEGEND_TYPES from './legend-types/LegendTypes'; +import { useIntl } from 'react-intl'; +import useImperativeIntl from '../../hooks/useImperativeIntl'; +import Typography from '../../components/atoms/Typography'; + +const legendTypeMap = { + [LEGEND_TYPES.CATEGORY]: LegendCategories, + [LEGEND_TYPES.PROPORTION]: LegendProportion, + [LEGEND_TYPES.ICON]: LegendIcon, + [LEGEND_TYPES.BINS]: (props) => , + [LEGEND_TYPES.CONTINUOUS_RAMP]: (props) => +}; + +/** + * @param {object} props + * @param {import('./LegendWidgetUI').LegendLayerVariableBase} props.legend - legend variable data. + * @returns {React.ReactNode} + */ +function LegendUnknown({ legend }) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + if (legend.select || !legend.type) { + return null; + } + + return ( + + {legend.type} {intlConfig.formatMessage({ id: 'c4r.widgets.legend.notSupported' })}. + + ); +} + +/** + * @param {import('./LegendWidgetUI').LegendLayerVariableData} legend - legend variable data. + * @returns {string} + */ +function getLegendSubtitle(legend) { + if (legend.type === LEGEND_TYPES.PROPORTION) { + return 'c4r.widgets.legend.subtitles.proportion'; + } + if (legend.type === LEGEND_TYPES.ICON || !!legend.customMarkers) { + return 'c4r.widgets.legend.subtitles.icon'; + } + if (legend.isStrokeColor) { + return 'c4r.widgets.legend.subtitles.strokeColor'; + } + return 'c4r.widgets.legend.subtitles.color'; +} + +/** + * @param {object} props + * @param {import('./LegendWidgetUI').LegendLayerData} props.layer - Layer object from redux store. + * @param {import('./LegendWidgetUI').LegendLayerVariableData} props.legend - legend variable data. + * @param {Object.} props.customLegendTypes - Map from legend type to legend component that allows to customise additional legend types that can be rendered. + * @param {(selection: unknown) => void} props.onChangeSelection - Callback function for legend options change. + * @returns {React.ReactNode} + */ +export default function LegendLayerVariable({ + layer, + legend, + customLegendTypes, + onChangeSelection +}) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + const type = legend.type; + const TypeComponent = legendTypeMap[type] || customLegendTypes[type] || LegendUnknown; + const selectOptions = legend.select?.options || []; + + return ( + + {legend.attr ? ( + + + {intlConfig.formatMessage({ id: getLegendSubtitle(legend) })} + + + {legend.attr} + + + ) : null} + {legend.select ? ( + + + {legend.select.label} + + + + ) : null} + + + ); +} diff --git a/packages/react-ui/src/widgets/legend/LegendOpacityControl.js b/packages/react-ui/src/widgets/legend/LegendOpacityControl.js new file mode 100644 index 000000000..c2b2a3ff7 --- /dev/null +++ b/packages/react-ui/src/widgets/legend/LegendOpacityControl.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { IconButton, InputAdornment, Popover, Slider, Tooltip } from '@mui/material'; +import { OpacityTextField, StyledOpacityControl } from './LegendWidgetUI.styles'; +import { useIntl } from 'react-intl'; +import useImperativeIntl from '../../hooks/useImperativeIntl'; +import OpacityIcon from '../../assets/icons/OpacityIcon'; + +/** + * @param {object} props + * @param {React.MutableRefObject} props.menuRef - Ref object for the menu anchor + * @param {boolean} props.open - Open state of the popover + * @param {(open: boolean) => void} props.toggleOpen - Callback function for open state change + * @param {number} props.opacity - Opacity value + * @param {(opacity: number) => void} props.onChange - Callback function for opacity change + * @returns {React.ReactNode} + */ +export default function LegendOpacityControl({ + menuRef, + open, + toggleOpen, + opacity, + onChange +}) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + function handleTextFieldChange(e) { + const newOpacity = parseInt(e.target.value || '0'); + const clamped = Math.min(Math.max(newOpacity, 0), 100); + onChange(clamped / 100); + } + + return ( + <> + + toggleOpen(!open)} + > + + + + toggleOpen(false)} + anchorEl={menuRef.current} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right' + }} + slotProps={{ + root: { + sx: { transform: 'translate(-12px, 36px)' } + } + }} + > + + onChange(v / 100)} + min={0} + max={100} + step={1} + /> + + {' '} + % + + ) + }} + /> + + + + ); +} diff --git a/packages/react-ui/src/widgets/legend/LegendProportion.js b/packages/react-ui/src/widgets/legend/LegendProportion.js deleted file mode 100644 index a02bd80d6..000000000 --- a/packages/react-ui/src/widgets/legend/LegendProportion.js +++ /dev/null @@ -1,130 +0,0 @@ -import React from 'react'; -import { Box, Grid, styled } from '@mui/material'; -import PropTypes from 'prop-types'; -import Typography from '../../components/atoms/Typography'; - -const sizes = { - 0: 12, - 1: 9, - 2: 6, - 3: 3 -}; - -const Circle = styled(Box, { - shouldForwardProp: (prop) => prop !== 'index' -})(({ index = 0, theme }) => { - const width = theme.spacing(sizes[index]); - const height = theme.spacing(sizes[index]); - - return { - border: `solid 1px ${theme.palette.grey[100]}`, - backgroundColor: theme.palette.grey[50], - borderRadius: '50%', - position: 'absolute', - right: 0, - bottom: 0, - width, - height - }; -}); - -const ProportionalGrid = styled(Grid)(({ theme: { spacing } }) => ({ - justifyContent: 'flex-end', - marginBottom: spacing(0.5), - position: 'relative' -})); - -function LegendProportion({ legend }) { - const { min, max, error } = calculateRange(legend); - const [step1, step2] = !error ? calculateSteps(min, max) : [0, 0]; - - return ( - - - {[...Array(4)].map((circle, index) => ( - - ))} - - - {error ? ( - - - You need to specify valid numbers for the labels property - - - ) : ( - <> - - Max: {max} - - - {step2} - - - {step1} - - - Min: {min} - - - )} - - - ); -} - -LegendProportion.defaultProps = { - legend: { - labels: [] - } -}; - -LegendProportion.propTypes = { - legend: PropTypes.shape({ - labels: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])) - }).isRequired -}; - -export default LegendProportion; - -// Aux -export function getMinMax({ labels }) { - let max = labels?.[labels.length - 1]; - let min = labels?.[0]; - - if (!Number.isFinite(min)) { - min = parseInt(min, 10); - } - - if (!Number.isFinite(max)) { - max = parseInt(max, 10); - } - - return [min, max]; -} - -function calculateRange(legend) { - let error = false; - const [min, max] = getMinMax(legend); - - if (Number.isNaN(min) || Number.isNaN(max)) { - error = true; - } - - return { min, max, error }; -} - -function calculateSteps(min, max) { - const gap = (max + min) / 4; - const step1 = min + gap; - const step2 = max - gap; - - return [step1, step2]; -} diff --git a/packages/react-ui/src/widgets/legend/LegendWidgetUI.d.ts b/packages/react-ui/src/widgets/legend/LegendWidgetUI.d.ts index ed791401c..01aaf558f 100644 --- a/packages/react-ui/src/widgets/legend/LegendWidgetUI.d.ts +++ b/packages/react-ui/src/widgets/legend/LegendWidgetUI.d.ts @@ -1,8 +1,96 @@ +import { SxProps } from '@mui/material'; +import { Theme } from '@mui/system'; +import type React from 'react' + export enum LEGEND_TYPES { CATEGORY = 'category', ICON = 'icon', CONTINUOUS_RAMP = 'continuous_ramp', BINS = 'bins', PROPORTION = 'proportion', - CUSTOM = 'custom' } + +export type LegendWidgetUIProps = { + customLegendTypes?: Record; + layers?: LegendLayerData[]; + collapsed?: boolean; + onChangeCollapsed?: (collapsed: boolean) => void; + onChangeLegendRowCollapsed?: ({ id, collapsed }: { id: string, collapsed: boolean }) => void; + onChangeOpacity?: ({ id, opacity }: { id: string, opacity: number }) => void + onChangeVisibility?: ({ id, visible }: { id: string, visible: boolean }) => void + onChangeSelection?: ({ id, index, selection }: { id: string, index: number, selection: unknown }) => void + title?: string + position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + maxZoom?: number + minZoom?: number + currentZoom?: number + isMobile?: boolean + sx?: SxProps +} + +declare const LegendWidgetUI: (props: LegendWidgetUIProps) => React.ReactNode; +export default LegendWidgetUI; + +export type LegendLayerData = { + id: string; + title?: string; + visible?: boolean; // layer visibility state + switchable?: boolean; // layer visibility state can be toggled on/off + collapsed?: boolean; // layer collapsed state + collapsible?: boolean; // layer collapsed state can be toggled on/off + opacity?: number; // layer opacity percentage + showOpacityControl?: boolean; // layer opacity percentage can be modified + helperText?: React.ReactNode; // note to show below all legend items + minZoom?: number; // min zoom at which layer is displayed + maxZoom?: number; // max zoom at which layer is displayed + legend?: LegendLayerVariableData | LegendLayerVariableData[]; +}; + +export type LegendLayerVariableBase = { + type: LEGEND_TYPES | string; + select: LegendSelectConfig + attr?: string; // subtitle to show below the legend item toggle when expanded +} +export type LegendLayerVariableData = LegendLayerVariableBase & LegendType; + +export type LegendType = LegendBins | LegendRamp | LegendIcons | LegendCategories | LegendProportion; + +export type LegendColors = string | string[] | number[][]; +export type LegendNumericLabels = number[] | { label: string; value: number }[]; + +export type LegendBins = { + colors: LegendColors + labels: LegendNumericLabels +} +export type LegendRamp = { + colors: LegendColors + labels: LegendNumericLabels +} +export type LegendIcons = { + icons: string[] + labels: string[] +} +export type LegendCategories = { + colors: LegendColors + labels: string[] | number[] + isStrokeColor?: boolean + customMarkers?: string | string[] + maskedMarkers?: boolean +} +export type LegendProportion = { + labels: [number, number] +} + +export type LegendSelectConfig = { + label: string; + value: T; + options: { + label: string; + value: T; + }[]; +}; + +export type CustomLegendComponent = React.ComponentType<{ + layer: LegendLayerData; + legend: LegendLayerVariableData; +}>; diff --git a/packages/react-ui/src/widgets/legend/LegendWidgetUI.js b/packages/react-ui/src/widgets/legend/LegendWidgetUI.js index 42211fcbb..4e783dae0 100644 --- a/packages/react-ui/src/widgets/legend/LegendWidgetUI.js +++ b/packages/react-ui/src/widgets/legend/LegendWidgetUI.js @@ -1,250 +1,156 @@ -import React, { createRef, Fragment } from 'react'; -import { Box, Button, Collapse, Divider, Grid, styled, SvgIcon } from '@mui/material'; -import LegendWrapper from './LegendWrapper'; -import LegendCategories from './LegendCategories'; -import LegendIcon from './LegendIcon'; -import LegendRamp from './LegendRamp'; -import LegendProportion from './LegendProportion'; +import React from 'react'; import PropTypes from 'prop-types'; -import Typography from '../../components/atoms/Typography'; - -const LayersIcon = () => ( - - - - - - - - - - - - - -); - -const LegendBox = styled(Box)(({ theme }) => ({ - minWidth: theme.spacing(30), - backgroundColor: theme.palette.background.paper, - boxShadow: theme.shadows[1], - borderRadius: theme.spacing(0.5) -})); - +import { Collapse, Drawer, IconButton, Tooltip, Typography } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import LayerIcon from '@mui/icons-material/LayersOutlined'; +import { LegendContent, LegendRoot, LegendToggleHeader } from './LegendWidgetUI.styles'; +import LegendLayer from './LegendLayer'; +import { useIntl } from 'react-intl'; +import useImperativeIntl from '../../hooks/useImperativeIntl'; + +const EMPTY_OBJ = {}; +const EMPTY_FN = () => {}; +const EMPTY_ARR = []; + +/** + * @param {object} props + * @param {Object.} [props.customLegendTypes] - Allow to customise by default legend types that can be rendered. + * @param {import('./LegendWidgetUI').LegendLayerData[]} [props.layers] - Array of layer objects from redux store. + * @param {boolean} [props.collapsed] - Collapsed state for whole legend widget. + * @param {(collapsed: boolean) => void} [props.onChangeCollapsed] - Callback function for collapsed state change. + * @param {({ id, collapsed }: { id: string, collapsed: boolean }) => void} [props.onChangeLegendRowCollapsed] - Callback function for layer visibility change. + * @param {({ id, opacity }: { id: string, opacity: number }) => void} [props.onChangeOpacity] - Callback function for layer opacity change. + * @param {({ id, visible }: { id: string, visible: boolean }) => void} [props.onChangeVisibility] - Callback function for layer collapsed state change. + * @param {({ id, index, selection }: { id: string, index: number, selection: unknown }) => void} props.onChangeSelection - Callback function for layer variable selection change. + * @param {string} [props.title] - Title of the toggle button when widget is open. + * @param {number} [props.maxZoom] - Global maximum zoom level for the map. + * @param {number} [props.minZoom] - Global minimum zoom level for the map. + * @param {number} [props.currentZoom] - Current zoom level for the map. + * @param {boolean} [props.isMobile] - Whether the widget is displayed on a mobile device. + * @param {import('@mui/system').SxProps} [props.sx] - Style object for the root component. + * @returns {React.ReactNode} + */ function LegendWidgetUI({ - customLegendTypes, - customLayerOptions, - layers = [], - collapsed, - onChangeCollapsed, - onChangeVisibility, - onChangeOpacity, - onChangeLegendRowCollapsed, - title -}) { - const isSingle = layers.length === 1; + customLegendTypes = EMPTY_OBJ, + layers = EMPTY_ARR, + collapsed = false, + onChangeCollapsed = EMPTY_FN, + onChangeVisibility = EMPTY_FN, + onChangeOpacity = EMPTY_FN, + onChangeLegendRowCollapsed = EMPTY_FN, + onChangeSelection = EMPTY_FN, + title, + maxZoom = 20, + minZoom = 0, + currentZoom, + isMobile, + sx +} = {}) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + const legendToggleHeader = ( + + + {title} + + + onChangeCollapsed(true)}> + + + + + ); + const legendToggleButton = ( + + onChangeCollapsed(false)}> + + + + ); return ( - - - - - + + {isMobile ? ( + <> + {legendToggleButton} + onChangeCollapsed(true)} + > + {legendToggleHeader} + + {layers.map((l) => ( + + ))} + + + + ) : ( + <> + {collapsed ? legendToggleButton : legendToggleHeader} + + + {layers.map((l) => ( + + ))} + + + + )} + ); } LegendWidgetUI.defaultProps = { - layers: [], - customLegendTypes: {}, - customLayerOptions: {}, + layers: EMPTY_ARR, + customLegendTypes: EMPTY_OBJ, collapsed: false, - title: 'Layers' + title: 'Layers', + onChangeCollapsed: EMPTY_FN, + onChangeLegendRowCollapsed: EMPTY_FN, + onChangeVisibility: EMPTY_FN, + onChangeOpacity: EMPTY_FN, + onChangeSelection: EMPTY_FN }; LegendWidgetUI.propTypes = { customLegendTypes: PropTypes.objectOf(PropTypes.func), - customLayerOptions: PropTypes.objectOf(PropTypes.func), layers: PropTypes.array, collapsed: PropTypes.bool, onChangeCollapsed: PropTypes.func, onChangeLegendRowCollapsed: PropTypes.func, onChangeVisibility: PropTypes.func, onChangeOpacity: PropTypes.func, - title: PropTypes.string + onChangeSelection: PropTypes.func, + title: PropTypes.string, + maxZoom: PropTypes.number, + minZoom: PropTypes.number, + currentZoom: PropTypes.number, + isMobile: PropTypes.bool }; export default LegendWidgetUI; - -const Header = styled(Grid)(({ theme }) => ({ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - height: theme.spacing(4.5) -})); - -const HeaderButton = styled(Button, { - shouldForwardProp: (prop) => prop !== 'collapsed' -})(({ theme, collapsed }) => ({ - flex: '1 1 auto', - justifyContent: 'space-between', - padding: theme.spacing(0.75, 1.25, 0.75, 3), - borderTop: collapsed ? 'none' : `1px solid ${theme.palette.divider}`, - cursor: 'pointer' -})); - -const CollapseWrapper = styled(Collapse)(() => ({ - '.MuiCollapse-wrapperInner': { - maxHeight: 'max(350px, 80vh)', - overflowY: 'auto', - overflowX: 'hidden' - } -})); - -function LegendContainer({ isSingle, children, collapsed, onChangeCollapsed, title }) { - const wrapper = createRef(); - - const handleExpandClick = () => { - if (onChangeCollapsed) onChangeCollapsed(!collapsed); - }; - - return isSingle ? ( - children - ) : ( - <> - - {children} - -
- } - onClick={handleExpandClick} - > - {title} - -
- - ); -} - -export const LEGEND_TYPES = { - CATEGORY: 'category', - ICON: 'icon', - CONTINUOUS_RAMP: 'continuous_ramp', - BINS: 'bins', - PROPORTION: 'proportion', - CUSTOM: 'custom' -}; - -const LEGEND_COMPONENT_BY_TYPE = { - [LEGEND_TYPES.CATEGORY]: LegendCategories, - [LEGEND_TYPES.ICON]: LegendIcon, - [LEGEND_TYPES.CONTINUOUS_RAMP]: (args) => , - [LEGEND_TYPES.BINS]: (args) => , - [LEGEND_TYPES.PROPORTION]: LegendProportion, - [LEGEND_TYPES.CUSTOM]: ({ legend }) => legend.children -}; - -function UnknownLayerOption({ optionKey }) { - return ( -

- Unknown layer option{' '} - - {optionKey} - -

- ); -} - -function LegendRows({ - layers = [], - customLegendTypes, - customLayerOptions, - onChangeVisibility, - onChangeOpacity, - onChangeCollapsed -}) { - const isSingle = layers.length === 1; - - return ( - <> - {layers.map((layer, index) => { - const { - id, - title, - switchable, - visible, - options = [], - showOpacityControl = false, - opacity = 1, - legend = {} - } = layer; - - const { - type = LEGEND_TYPES.CUSTOM, - collapsible = true, - collapsed = false, - note = '', - attr = '', - children - } = legend; - - const isLast = layers.length - 1 === index; - const LegendComponent = - LEGEND_COMPONENT_BY_TYPE[type] || customLegendTypes[type] || UnknownLegend; - const hasChildren = type === LEGEND_TYPES.CUSTOM ? !!children : !!LegendComponent; - - const layerOptions = options.map((key) => { - const Component = customLayerOptions[key] || UnknownLayerOption; - return ; - }); - - return ( - - - - - {!isSingle && !isLast && } - - ); - })} - - ); -} - -function UnknownLegend({ legend }) { - return ( - {legend.type} is not a known legend type. - ); -} diff --git a/packages/react-ui/src/widgets/legend/LegendWidgetUI.styles.js b/packages/react-ui/src/widgets/legend/LegendWidgetUI.styles.js new file mode 100644 index 000000000..1e78933d4 --- /dev/null +++ b/packages/react-ui/src/widgets/legend/LegendWidgetUI.styles.js @@ -0,0 +1,112 @@ +import { Box, Paper, TextField, styled } from '@mui/material'; +import { ICON_SIZE_MEDIUM } from '../../theme/themeConstants'; + +export const LEGEND_WIDTH = 240; + +export const LegendRoot = styled(Paper, { + shouldForwardProp: (prop) => !['collapsed'].includes(prop) +})(({ theme, collapsed }) => ({ + width: collapsed ? 'min-content' : LEGEND_WIDTH, + background: theme.palette.background.paper, + maxHeight: 'calc(100% - 120px)', + display: 'flex', + flexDirection: 'column' +})); + +export const LegendToggleHeader = styled('header', { + shouldForwardProp: (prop) => !['collapsed'].includes(prop) +})(({ theme, collapsed }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing(1), + paddingLeft: theme.spacing(2), + borderBottom: collapsed ? undefined : `1px solid ${theme.palette.divider}` +})); + +export const LegendItemHeader = styled('header')(({ theme }) => ({ + padding: theme.spacing(1.5), + paddingRight: theme.spacing(2), + gap: theme.spacing(0.5), + display: 'flex', + justifyContent: 'space-between', + position: 'sticky', + zIndex: 2, + top: 0, + background: theme.palette.background.paper +})); + +export const StyledOpacityControl = styled('div')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), + alignItems: 'center', + padding: theme.spacing(1), + minWidth: LEGEND_WIDTH - theme.spacingValue * 4 +})); + +export const OpacityTextField = styled(TextField)(({ theme }) => ({ + display: 'flex', + width: theme.spacing(7.5), + flexShrink: 0, + 'input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button': + { + '-webkit-appearance': 'none', + margin: 0 + } +})); + +export const LayerVariablesList = styled('ul', { + shouldForwardProp: (prop) => !['opacity'].includes(prop) +})(({ theme, opacity }) => ({ + opacity, + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1) +})); + +export const LegendVariableList = styled('ul')(({ theme }) => ({ + margin: 0, + padding: 0, + paddingBottom: theme.spacing(1), + display: 'flex', + flexDirection: 'column' +})); + +export const LegendIconWrapper = styled('li')(() => ({ + display: 'flex', + alignItems: 'center' +})); + +export const LegendIconImageWrapper = styled('div')(({ theme }) => ({ + marginRight: theme.spacing(1.5), + width: ICON_SIZE_MEDIUM, + height: ICON_SIZE_MEDIUM, + '& img': { + margin: 'auto', + display: 'block' + } +})); + +export const LegendContent = styled(Box, { + shouldForwardProp: (prop) => !['width'].includes(prop) +})(({ width, theme }) => ({ + width, + overflow: 'auto', + maxHeight: `calc(100% - ${theme.spacing(1.5)})` +})); + +export const LegendLayerWrapper = styled('section')(({ theme }) => ({ + '&:not(:first-of-type)': { + borderTop: `1px solid ${theme.palette.divider}` + } +})); + +export const LegendLayerTitleWrapper = styled('div')(() => ({ + flexGrow: 1, + flexShrink: 1, + minWidth: 0 +})); + +export const styles = {}; diff --git a/packages/react-ui/src/widgets/legend/LegendWrapper.js b/packages/react-ui/src/widgets/legend/LegendWrapper.js deleted file mode 100644 index 766b28326..000000000 --- a/packages/react-ui/src/widgets/legend/LegendWrapper.js +++ /dev/null @@ -1,204 +0,0 @@ -import React, { createRef, useState } from 'react'; -import { ExpandLess, ExpandMore } from '@mui/icons-material'; -import { useIntl } from 'react-intl'; -import { - Box, - Button, - Collapse, - Grid, - Icon, - Switch, - ToggleButton, - Tooltip, - styled -} from '@mui/material'; -import LayerIcon from '../../assets/icons/LayerIcon'; -import Typography from '../../components/atoms/Typography'; -import OpacityControl from '../OpacityControl'; -import Note from './Note'; -import useImperativeIntl from '../../hooks/useImperativeIntl'; - -const Wrapper = styled(Box)(() => ({ - position: 'relative', - maxWidth: '100%', - padding: 0 -})); - -const LayerOptionsWrapper = styled(Box)(({ theme }) => ({ - backgroundColor: theme.palette.grey[50], - marginTop: theme.spacing(2) -})); - -function LegendWrapper({ - id, - title, - layerOptions, - switchable = true, - collapsible = true, - collapsed = false, - visible = true, - hasChildren = true, - note, - attr, - children, - showOpacityControl, - opacity, - onChangeOpacity, - onChangeVisibility, - onChangeCollapsed -}) { - const wrapper = createRef(); - const expanded = !collapsed; - const [isLayerOptionsExpanded, setIsLayerOptionsExpanded] = useState(false); - - const intl = useIntl(); - const intlConfig = useImperativeIntl(intl); - - const handleChangeOpacity = (newOpacity) => { - if (onChangeOpacity) onChangeOpacity({ id, opacity: newOpacity }); - }; - - const handleExpandClick = () => { - if (collapsible && onChangeCollapsed) - onChangeCollapsed({ id, collapsed: !collapsed }); - }; - - const handleChangeVisibility = () => { - if (onChangeVisibility) onChangeVisibility({ id, visible: !visible }); - }; - - const handleToggleLayerOptions = () => { - setIsLayerOptionsExpanded((oldState) => !oldState); - }; - - return ( - -
0} - onToggleLayerOptions={handleToggleLayerOptions} - isLayerOptionsExpanded={isLayerOptionsExpanded} - intl={intlConfig} - /> - {hasChildren && !!children && ( - - - - {attr && ( - - {intlConfig.formatMessage({ id: 'c4r.widgets.legend.by' }, { attr })} - - )} - {children} - - - {showOpacityControl && ( - - )} - {layerOptions} - - - {note} - - - - )} - - ); -} - -const GridHeader = styled(Grid)(({ theme }) => ({ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - height: '60px', - padding: theme.spacing(1.25, 1.25, 1.25, 2.5) -})); - -const ButtonHeader = styled(Button, { - shouldForwardProp: (prop) => prop !== 'collapsible' -})(({ theme, collapsible }) => ({ - padding: 0, - flex: '1 1 auto', - justifyContent: 'flex-start', - cursor: collapsible ? 'pointer' : 'default', - '& .MuiButton-startIcon': { - marginRight: theme.spacing(1) - }, - '&:hover': { - background: 'none' - } -})); - -const ParentIcon = ({ theme }) => ({ - display: 'block', - fill: theme.palette.text.secondary -}); - -const MoreIconHeader = styled(ExpandMore)(({ theme }) => ParentIcon({ theme })); -const LessIconHeader = styled(ExpandLess)(({ theme }) => ParentIcon({ theme })); - -function Header({ - title, - switchable, - visible, - collapsible, - expanded, - onExpandClick, - onChangeVisibility, - layerOptionsEnabled, - onToggleLayerOptions, - isLayerOptionsExpanded, - intl -}) { - const ExpandIcon = expanded ? LessIconHeader : MoreIconHeader; - - return ( - - - - - ) - } - onClick={onExpandClick} - > - {title} - - {!!layerOptionsEnabled && ( - - - - - - )} - {switchable && ( - - - - )} - - ); -} - -export default LegendWrapper; diff --git a/packages/react-ui/src/widgets/legend/Note.js b/packages/react-ui/src/widgets/legend/Note.js deleted file mode 100644 index b2e44d74e..000000000 --- a/packages/react-ui/src/widgets/legend/Note.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Box } from '@mui/material'; -import React from 'react'; -import Typography from '../../components/atoms/Typography'; - -export default function Note({ children }) { - if (!children) { - return null; - } - - return ( - - Note:{' '} - {children} - - ); -} diff --git a/packages/react-ui/src/widgets/legend/legend-types/LegendCategories.js b/packages/react-ui/src/widgets/legend/legend-types/LegendCategories.js new file mode 100644 index 000000000..760840ba6 --- /dev/null +++ b/packages/react-ui/src/widgets/legend/legend-types/LegendCategories.js @@ -0,0 +1,166 @@ +import React from 'react'; +import { Box, Typography, styled } from '@mui/material'; +import { getPalette } from '../../../utils/palette'; +import PropTypes from 'prop-types'; +import LegendLayerTitle from '../LegendLayerTitle'; +import { LegendVariableList } from '../LegendWidgetUI.styles'; +import useImperativeIntl from '../../../hooks/useImperativeIntl'; +import { useIntl } from 'react-intl'; + +const MAX_CATEGORIES = 20; + +/** + * @param {object} props + * @param {import('../LegendWidgetUI').LegendLayerVariableBase & import('../LegendWidgetUI').LegendCategories} props.legend - legend variable data. + * @returns {React.ReactNode} + */ +function LegendCategories({ legend }) { + const { + labels = [], + colors = [], + isStrokeColor = false, + customMarkers, + maskedMarkers = true + } = legend; + + const palette = getPalette(colors, labels.length); + const showHelperText = labels.length > MAX_CATEGORIES; + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + return ( + <> + + {labels.slice(0, MAX_CATEGORIES).map((label, idx) => ( + + ))} + + {showHelperText && ( + + {intlConfig.formatMessage( + { + id: 'c4r.widgets.legend.maxCategories' + }, + { + n: MAX_CATEGORIES + } + )} + + )} + + ); +} + +LegendCategories.defaultProps = { + legend: { + labels: [], + colors: [], + isStrokeColor: false + } +}; + +const ColorType = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.number) +]); + +LegendCategories.propTypes = { + legend: PropTypes.shape({ + labels: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + colors: PropTypes.oneOfType([PropTypes.arrayOf(ColorType), PropTypes.string]), + customMarkers: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), + PropTypes.string + ]), + maskedMarkers: PropTypes.bool, + isStrokeColor: PropTypes.bool + }).isRequired +}; + +export default LegendCategories; + +const getCircleStyles = ({ isMax, color, isStrokeColor, theme }) => ({ + border: '2px solid transparent', + '&::after': { + position: 'absolute', + display: isMax ? 'block' : 'none', + content: '""', + width: theme.spacing(2), + height: theme.spacing(2), + border: `2px solid ${theme.palette.grey[900]}`, + transform: 'translate(-30%, -30%)', + borderRadius: '50%', + boxSizing: 'content-box' + }, + ...(isStrokeColor ? { borderColor: color } : { backgroundColor: color }) +}); + +const getIconStyles = ({ icon, color, maskedIcon }) => ({ + maskRepeat: 'no-repeat', + maskSize: 'cover', + backgroundRepeat: 'no-repeat', + backgroundSize: 'cover', + ...(maskedIcon + ? { + backgroundColor: color, + maskImage: `url(${icon})`, + WebkitMaskImage: `url(${icon})` + } + : { + backgroundColor: `rgba(0,0,0,0)`, + backgroundImage: `url(${icon})` + }) +}); + +const Marker = styled(Box, { + shouldForwardProp: (prop) => + !['icon', 'maskedIcon', 'color', 'isStrokeColor'].includes(prop) +})(({ icon, maskedIcon, color, isStrokeColor, theme }) => ({ + whiteSpace: 'nowrap', + display: 'block', + width: theme.spacing(1.5), + height: theme.spacing(1.5), + borderRadius: '50%', + position: 'relative', + border: '2px solid transparent', + ...(icon + ? getIconStyles({ icon, color, maskedIcon }) + : getCircleStyles({ color, isStrokeColor, theme })) +})); + +function LegendCategoriesRow({ label, isStrokeColor, color = '#000', icon, maskedIcon }) { + return ( + + + + + ); +} diff --git a/packages/react-ui/src/widgets/legend/legend-types/LegendIcon.js b/packages/react-ui/src/widgets/legend/legend-types/LegendIcon.js new file mode 100644 index 000000000..3a9317bbe --- /dev/null +++ b/packages/react-ui/src/widgets/legend/legend-types/LegendIcon.js @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ICON_SIZE_MEDIUM } from '../../../theme/themeConstants'; +import { + LegendIconImageWrapper, + LegendIconWrapper, + LegendVariableList +} from '../LegendWidgetUI.styles'; +import LegendLayerTitle from '../LegendLayerTitle'; + +/** + * @param {object} props + * @param {import('../LegendWidgetUI').LegendLayerVariableBase & import('../LegendWidgetUI').LegendIcons} props.legend - legend variable data. + * @returns {React.ReactNode} + */ +function LegendIcon({ legend }) { + const { labels = [], icons = [] } = legend; + return ( + + {labels.map((label, idx) => ( + + + {label} + + + + ))} + + ); +} + +LegendIcon.defaultProps = { + legend: { + labels: [], + icons: [] + } +}; + +LegendIcon.propTypes = { + legend: PropTypes.shape({ + labels: PropTypes.arrayOf(PropTypes.string), + icons: PropTypes.arrayOf(PropTypes.string) + }).isRequired +}; + +export default LegendIcon; diff --git a/packages/react-ui/src/widgets/legend/legend-types/LegendProportion.js b/packages/react-ui/src/widgets/legend/legend-types/LegendProportion.js new file mode 100644 index 000000000..16b6fa8fd --- /dev/null +++ b/packages/react-ui/src/widgets/legend/legend-types/LegendProportion.js @@ -0,0 +1,158 @@ +import React from 'react'; +import { Box, styled } from '@mui/material'; +import PropTypes from 'prop-types'; +import Typography from '../../../components/atoms/Typography'; +import { useIntl } from 'react-intl'; +import useImperativeIntl from '../../../hooks/useImperativeIntl'; + +const sizes = { + 0: 12, + 1: 9, + 2: 6, + 3: 3 +}; + +const Circle = styled(Box, { + shouldForwardProp: (prop) => prop !== 'index' +})(({ index = 0, theme }) => { + const width = theme.spacing(sizes[index]); + const height = theme.spacing(sizes[index]); + + return { + border: `solid 1px ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, + borderRadius: '50%', + position: 'absolute', + right: 0, + bottom: 0, + width, + height + }; +}); + +const CircleGrid = styled(Box)(({ theme: { spacing } }) => ({ + display: 'flex', + justifyContent: 'flex-end', + flexShrink: 0, + position: 'relative', + width: spacing(sizes[0]), + height: spacing(sizes[0]) +})); + +const LegendProportionWrapper = styled(Box)(({ theme: { spacing } }) => ({ + display: 'flex', + gap: spacing(1), + alignItems: 'stretch', + justifyContent: 'stretch', + padding: spacing(2, 0) +})); + +const LabelList = styled(Box)(() => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-around', + gap: 1, + flexGrow: 1, + flexShrink: 1 +})); + +/** + * @param {object} props + * @param {import('../LegendWidgetUI').LegendLayerVariableBase & import('../LegendWidgetUI').LegendProportion} props.legend - legend variable data. + * @returns {React.ReactNode} + */ +function LegendProportion({ legend }) { + const intl = useIntl(); + const intlConfig = useImperativeIntl(intl); + + const showMinMax = legend.showMinMax ?? true; + const { min, max, error } = calculateRange(legend); + const [step1, step2] = !error ? calculateSteps(min, max) : [0, 0]; + + return ( + + + {[...Array(4)].map((_, index) => ( + + ))} + + + {error ? ( + + + You need to specify valid numbers for the labels property + + + ) : ( + <> + + {showMinMax + ? `${intlConfig.formatMessage({ id: 'c4r.widgets.legend.max' })}: ${max}` + : max} + + + {step2} + + + {step1} + + + {showMinMax + ? `${intlConfig.formatMessage({ id: 'c4r.widgets.legend.min' })}: ${min}` + : min} + + + )} + + + ); +} + +LegendProportion.defaultProps = { + legend: { + labels: [] + } +}; + +LegendProportion.propTypes = { + legend: PropTypes.shape({ + labels: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])) + }).isRequired +}; + +export default LegendProportion; + +// Aux +export function getMinMax({ labels }) { + let max = labels?.[labels.length - 1]; + let min = labels?.[0]; + + if (!Number.isFinite(min)) { + min = parseInt(min, 10); + } + + if (!Number.isFinite(max)) { + max = parseInt(max, 10); + } + + return [min, max]; +} + +function calculateRange(legend) { + let error = false; + const [min, max] = getMinMax(legend); + + if (Number.isNaN(min) || Number.isNaN(max)) { + error = true; + } + + return { min, max, error }; +} + +function calculateSteps(min, max) { + const gap = (max + min) / 4; + const step1 = min + gap; + const step2 = max - gap; + + return [step1, step2]; +} diff --git a/packages/react-ui/src/widgets/legend/LegendRamp.js b/packages/react-ui/src/widgets/legend/legend-types/LegendRamp.js similarity index 67% rename from packages/react-ui/src/widgets/legend/LegendRamp.js rename to packages/react-ui/src/widgets/legend/legend-types/LegendRamp.js index 11f7f5d14..8f2f5a1bf 100644 --- a/packages/react-ui/src/widgets/legend/LegendRamp.js +++ b/packages/react-ui/src/widgets/legend/legend-types/LegendRamp.js @@ -1,12 +1,18 @@ -import { Grid, Tooltip, styled } from '@mui/material'; +import { Box, Tooltip, styled } from '@mui/material'; import PropTypes from 'prop-types'; import React from 'react'; -import Typography from '../../components/atoms/Typography'; -import { getPalette } from '../../utils/palette'; +import Typography from '../../../components/atoms/Typography'; +import { getPalette } from '../../../utils/palette'; import LegendProportion, { getMinMax } from './LegendProportion'; +/** + * @param {object} props + * @param {import('../LegendWidgetUI').LegendLayerVariableBase & import('../LegendWidgetUI').LegendRamp} props.legend - legend variable data. + * @param {boolean} [props.isContinuous] - If the legend is continuous. + * @returns {React.ReactNode} + */ function LegendRamp({ isContinuous = false, legend }) { - const { labels = [], colors = [] } = legend; + const { labels = [], colors = [], showMinMax = true } = legend; const palette = getPalette( colors, @@ -26,24 +32,24 @@ function LegendRamp({ isContinuous = false, legend }) { let maxLabel = formattedLabels[formattedLabels.length - 1]; let minLabel = formattedLabels[0]; - if (!isContinuous) { + if (!isContinuous && showMinMax) { minLabel = '< ' + minLabel; maxLabel = '≥ ' + maxLabel; } return ( - + {error ? ( - + You need to specify valid numbers for the labels property - + ) : ( <> - + {isContinuous ? ( - + ) : ( )} - - - {minLabel} - {maxLabel} - + + + + {minLabel} + + + {maxLabel} + + )} - + ); } @@ -86,17 +96,21 @@ LegendRamp.propTypes = { export default LegendRamp; -const StepsContinuous = styled(Grid, { +const StepsContinuous = styled(Box, { shouldForwardProp: (prop) => prop !== 'palette' })(({ palette, theme }) => ({ + display: 'block', + flexGrow: 1, height: theme.spacing(1), borderRadius: theme.spacing(0.5), background: `linear-gradient(to right, ${palette.join()})` })); -const StepGrid = styled(Grid, { +const StepGrid = styled(Box, { shouldForwardProp: (prop) => prop !== 'color' })(({ color, theme }) => ({ + display: 'block', + flexGrow: 1, height: theme.spacing(1), backgroundColor: color, '&:first-of-type': { @@ -122,7 +136,7 @@ function StepsDiscontinuous({ labels = [], palette = [], max, min }) { return ( - + ); })} diff --git a/packages/react-ui/src/widgets/legend/legend-types/LegendTypes.js b/packages/react-ui/src/widgets/legend/legend-types/LegendTypes.js new file mode 100644 index 000000000..4ee408770 --- /dev/null +++ b/packages/react-ui/src/widgets/legend/legend-types/LegendTypes.js @@ -0,0 +1,8 @@ +const LEGEND_TYPES = { + CATEGORY: 'category', + ICON: 'icon', + CONTINUOUS_RAMP: 'continuous_ramp', + BINS: 'bins', + PROPORTION: 'proportion' +}; +export default LEGEND_TYPES; diff --git a/packages/react-ui/storybook/.storybook/preview.js b/packages/react-ui/storybook/.storybook/preview.js index 93888c561..9e26a36f7 100644 --- a/packages/react-ui/storybook/.storybook/preview.js +++ b/packages/react-ui/storybook/.storybook/preview.js @@ -1,14 +1,17 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { withDesign } from 'storybook-addon-designs'; -import { - createTheme, - responsiveFontSizes, - ThemeProvider, - StyledEngineProvider, - CssBaseline -} from '@mui/material'; -import { cartoThemeOptions, theme } from '../../src/theme/carto-theme'; +import { ThemeProvider, StyledEngineProvider, CssBaseline } from '@mui/material'; +import { theme } from '../../src/theme/carto-theme'; import { BREAKPOINTS } from '../../src/theme/themeConstants'; +import { + Title, + Subtitle, + Primary, + ArgsTable, + Stories, + PRIMARY_STORY, + DocsContext +} from '@storybook/addon-docs'; const customViewports = { xs: { @@ -89,13 +92,34 @@ export const decorators = [ ) ]; +function CustomDescription() { + const context = useContext(DocsContext); + try { + const jsdoc = context.parameters.docs.extractComponentDescription(context.component); + const excerpt = jsdoc?.split('@param')[0]; + return excerpt || null; + } catch { + return null; + } +} + export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, viewMode: 'docs', docs: { source: { type: 'code' - } + }, + page: () => ( + <> + + <Subtitle /> + <CustomDescription /> + <Primary /> + <ArgsTable story={PRIMARY_STORY} /> + <Stories /> + </> + ) }, options: { storySort: { diff --git a/packages/react-ui/storybook/stories/widgetsUI/LegendWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/LegendWidgetUI.stories.js index 51c9ae469..a9743bc00 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/LegendWidgetUI.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/LegendWidgetUI.stories.js @@ -1,21 +1,23 @@ -import React, { useState } from 'react'; +import { useReducer, useState } from 'react'; import LegendWidgetUI from '../../../src/widgets/legend/LegendWidgetUI'; import { IntlProvider } from 'react-intl'; +import { Box } from '@mui/material'; +import { fixtures } from './legendFixtures'; const options = { title: 'Widgets/LegendWidgetUI', component: LegendWidgetUI, argTypes: { - layers: { - defaultValue: [], + collapsed: { + defaultValue: true, control: { - type: 'array' + type: 'boolean' } }, - collapsed: { - defaultValue: false, + layers: { + defaultValue: fixtures, control: { - type: 'boolean' + type: 'array' } } }, @@ -29,19 +31,61 @@ const options = { }; export default options; -const Widget = (props) => ( +/** + * @param {Parameters<LegendWidgetUI>[0] & { height: number }} args + */ +const Widget = ({ height, ...props }) => ( <IntlProvider locale='en'> - <LegendWidgetUI {...props} /> + <Box sx={{ height, position: 'relative' }}> + <LegendWidgetUI {...props} /> + </Box> </IntlProvider> ); +function useLegendState(args) { + const [collapsed, setCollapsed] = useState(args.collapsed); + const [layers, dispatch] = useReducer((state, action) => { + switch (action.type) { + case 'add': + return [...state, action.layer]; + case 'remove': + return state.filter((layer) => layer.id !== action.layer.id); + case 'update': + return state.map((layer) => { + if (layer.id === action.layer.id) { + return { ...layer, ...action.layer }; + } + return layer; + }); + default: + throw new Error(`Unknown action type: ${action.type}`); + } + }, args.layers); + + return { collapsed, setCollapsed, layers, dispatch }; +} + +/** + * @param {Parameters<LegendWidgetUI>[0]} args + */ const Template = ({ ...args }) => { + const { collapsed, setCollapsed, layers, dispatch } = useLegendState(args); + return ( - <Widget {...args}> - <div>Your Content</div> - </Widget> + <Widget + {...args} + height={400} + layers={layers} + collapsed={collapsed} + onChangeCollapsed={setCollapsed} + onChangeLegendRowCollapsed={(layer) => dispatch({ type: 'update', layer })} + onChangeOpacity={(layer) => dispatch({ type: 'update', layer })} + onChangeVisibility={(layer) => dispatch({ type: 'update', layer })} + currentZoom={13} + /> ); }; +export const Playground = Template.bind({}); const LegendTemplate = () => { const layers = [ @@ -50,11 +94,13 @@ const LegendTemplate = () => { title: 'Single Layer', visible: true, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 1', 'Category 2', 'Category 3'], + colors: ['#000', '#00F', '#0F0'] } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendNotFoundTemplate = () => { @@ -62,49 +108,58 @@ const LegendNotFoundTemplate = () => { { id: 0, title: 'Single Layer', - visible: true + visible: true, + legend: { + type: 'unknown' + } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; -const LegendWithOpacityTemplate = () => { +const LegendWithoutOpacityTemplate = () => { const layers = [ { id: 0, title: 'Single Layer', visible: true, - showOpacityControl: true, + showOpacityControl: false, opacity: 0.5, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 1'], + colors: ['#faa'] } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendMultiTemplate = () => { const layers = [ { id: 0, - title: 'Multi Layer', + title: 'Layer 1', visible: true, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 1'], + colors: ['#faa'] } }, { id: 1, - title: 'Multi Layer', + title: 'Layer 2', visible: false, collapsible: false, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 2'], + colors: ['#faf'] } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendMultiTemplateCollapsed = () => { @@ -113,25 +168,29 @@ const LegendMultiTemplateCollapsed = () => { const layers = [ { id: 0, - title: 'Multi Layer', + title: 'Layer 1', visible: true, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 1'], + colors: ['#faa'] } }, { id: 1, - title: 'Multi Layer', + title: 'Layer 2', visible: false, collapsible: false, legend: { - children: <div>Your Content</div> + type: 'category', + labels: ['Category 2'], + colors: ['#faf'] } } ]; return ( - <Widget + <Template layers={layers} collapsed={collapsed} onChangeCollapsed={({ collapsed }) => setCollapsed(collapsed)} @@ -142,8 +201,8 @@ const LegendMultiTemplateCollapsed = () => { const categoryLegend = { type: 'category', note: 'lorem', - colors: ['#000', '#00F', '#0F0'], - labels: ['Category 1', 'Category 2', 'Category 3'] + colors: 'RedOr', //['#000', '#00F', '#0F0'], + labels: Array.from({ length: 30 }, (_, i) => `Category ${i + 1}`) }; const LegendCategoriesTemplate = () => { @@ -155,7 +214,7 @@ const LegendCategoriesTemplate = () => { legend: categoryLegend } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendCategoriesStrokeTemplate = () => { @@ -170,9 +229,10 @@ const LegendCategoriesStrokeTemplate = () => { } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; +const ICON = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxkZWZzPgogICAgICAgIDxmaWx0ZXIgaWQ9InFsbTY3aXI1MWEiPgogICAgICAgICAgICA8ZmVDb2xvck1hdHJpeCBpbj0iU291cmNlR3JhcGhpYyIgdmFsdWVzPSIwIDAgMCAwIDAuMTcyNTQ5IDAgMCAwIDAgMC4xODgyMzUgMCAwIDAgMCAwLjE5NjA3OCAwIDAgMCAxLjAwMDAwMCAwIi8+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnPgogICAgICAgICAgICA8ZyBmaWx0ZXI9InVybCgjcWxtNjdpcjUxYSkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yNDIgLTQ2NCkgdHJhbnNsYXRlKDIyMiA0NDgpIj4KICAgICAgICAgICAgICAgIDxnIGZpbGw9IiMyQzMwMzIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIwIDE2KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyIDBjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnMtNS4zNzMgMTItMTIgMTJTMCAxOC42MjcgMCAxMiA1LjM3MyAwIDEyIDB6IiBvcGFjaXR5PSIuMiIvPgogICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjQiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==`; const LegendIconTemplate = () => { const layers = [ { @@ -182,35 +242,29 @@ const LegendIconTemplate = () => { legend: { type: 'icon', labels: ['Icon 1', 'Icon 2', 'Icon 3'], - icons: [ - '/static/media/storybook/assets/carto-symbol.svg', - '/static/media/storybook/assets/carto-symbol.svg', - '/static/media/storybook/assets/carto-symbol.svg' - ] + icons: [ICON, ICON, ICON] } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendRampTemplate = () => { - const layersDiscontinuous = [ + const layers = [ { id: 0, - title: 'Ramp Layer', + title: 'Ramp Discontinuous', visible: true, legend: { type: 'bins', colors: ['#000', '#00F', '#0F0', '#F00'], - labels: [100, 200, 300] + labels: [100, 200, 300], + showMinMax: true } - } - ]; - - const layersContinuous = [ + }, { - id: 0, - title: 'Ramp Layer', + id: 1, + title: 'Ramp Continuous', visible: true, legend: { type: 'continuous_ramp', @@ -220,12 +274,7 @@ const LegendRampTemplate = () => { } ]; - return ( - <> - <Widget layers={layersDiscontinuous}></Widget> - <Widget layers={layersContinuous}></Widget> - </> - ); + return <Template collapsed={false} layers={layers} />; }; const LegendProportionTemplate = () => { @@ -236,12 +285,12 @@ const LegendProportionTemplate = () => { visible: true, legend: { type: 'proportion', - labels: [100, 500] - // avg: 450 + labels: [100, 500], + showMinMax: true } } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; const LegendCustomTemplate = () => { @@ -251,11 +300,14 @@ const LegendCustomTemplate = () => { title: 'Single Layer', visible: true, legend: { - children: <div>Legend custom</div> + type: 'custom' } } ]; - return <Widget layers={layers}></Widget>; + const customLegendTypes = { + custom: () => <div>Custom Legend</div> + }; + return <Template layers={layers} customLegendTypes={customLegendTypes} />; }; const LegendNoChildrenTemplate = () => { @@ -267,16 +319,14 @@ const LegendNoChildrenTemplate = () => { legend: {} } ]; - return <Widget layers={layers}></Widget>; + return <Template layers={layers} />; }; -export const Playground = Template.bind({}); - export const SingleLayer = LegendTemplate.bind({}); export const MultiLayer = LegendMultiTemplate.bind({}); export const MultiLayerCollapsed = LegendMultiTemplateCollapsed.bind({}); export const NotFound = LegendNotFoundTemplate.bind({}); -export const LegendWithOpacityControl = LegendWithOpacityTemplate.bind({}); +export const LegendWithoutOpacityControl = LegendWithoutOpacityTemplate.bind({}); export const Categories = LegendCategoriesTemplate.bind({}); export const CategoriesAsStroke = LegendCategoriesStrokeTemplate.bind({}); export const Icon = LegendIconTemplate.bind({}); diff --git a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendCategories.stories.js b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendCategories.stories.js index eaaafa55d..a14d30e13 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendCategories.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendCategories.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import LegendCategories from '../../../../src/widgets/legend/LegendCategories'; +import LegendCategories from '../../../../src/widgets/legend/legend-types/LegendCategories'; const DEFAULT_LEGEND = { legend: { diff --git a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendIcon.stories.js b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendIcon.stories.js index a894ec4f7..6dd9b02ed 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendIcon.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendIcon.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import LegendIcon from '../../../../src/widgets/legend/LegendIcon'; +import LegendIcon from '../../../../src/widgets/legend/legend-types/LegendIcon'; const ICON = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij4KICAgIDxkZWZzPgogICAgICAgIDxmaWx0ZXIgaWQ9InFsbTY3aXI1MWEiPgogICAgICAgICAgICA8ZmVDb2xvck1hdHJpeCBpbj0iU291cmNlR3JhcGhpYyIgdmFsdWVzPSIwIDAgMCAwIDAuMTcyNTQ5IDAgMCAwIDAgMC4xODgyMzUgMCAwIDAgMCAwLjE5NjA3OCAwIDAgMCAxLjAwMDAwMCAwIi8+CiAgICAgICAgPC9maWx0ZXI+CiAgICA8L2RlZnM+CiAgICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnPgogICAgICAgICAgICA8ZyBmaWx0ZXI9InVybCgjcWxtNjdpcjUxYSkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yNDIgLTQ2NCkgdHJhbnNsYXRlKDIyMiA0NDgpIj4KICAgICAgICAgICAgICAgIDxnIGZpbGw9IiMyQzMwMzIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIwIDE2KSI+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEyIDBjNi42MjcgMCAxMiA1LjM3MyAxMiAxMnMtNS4zNzMgMTItMTIgMTJTMCAxOC42MjcgMCAxMiA1LjM3MyAwIDEyIDB6IiBvcGFjaXR5PSIuMiIvPgogICAgICAgICAgICAgICAgICAgIDxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjQiLz4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+Cg==`; diff --git a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendProportion.stories.js b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendProportion.stories.js index 65d683f35..2ba9cd02f 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendProportion.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendProportion.stories.js @@ -1,5 +1,6 @@ import React from 'react'; -import LegendProportion from '../../../../src/widgets/legend/LegendProportion'; +import LegendProportion from '../../../../src/widgets/legend/legend-types/LegendProportion'; +import { IntlProvider } from 'react-intl'; const DEFAULT_LEGEND = { legend: { @@ -25,7 +26,11 @@ const options = { export default options; const Template = (args) => { - return <LegendProportion {...args} />; + return ( + <IntlProvider locale='en'> + <LegendProportion {...args} /> + </IntlProvider> + ); }; export const Default = Template.bind({}); diff --git a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendRamp.stories.js b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendRamp.stories.js index 234ad5777..7d7de78e9 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/legend/LegendRamp.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/legend/LegendRamp.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import LegendRamp from '../../../../src/widgets/legend/LegendRamp'; +import LegendRamp from '../../../../src/widgets/legend/legend-types/LegendRamp'; const DEFAULT_LEGEND = { legend: { diff --git a/packages/react-ui/storybook/stories/widgetsUI/legendFixtures.js b/packages/react-ui/storybook/stories/widgetsUI/legendFixtures.js new file mode 100644 index 000000000..8d805969b --- /dev/null +++ b/packages/react-ui/storybook/stories/widgetsUI/legendFixtures.js @@ -0,0 +1,215 @@ +import LEGEND_TYPES from '../../../src/widgets/legend/legend-types/LegendTypes'; + +export const fixtures = [ + { + id: 'basemap', + title: 'Basemap', + collapsible: true, + switchable: false, + showOpacityControl: false, + legend: { + type: 'basemap', + attr: 'attr', + select: { + label: 'Select basemap', + value: 'light', + options: [ + { label: 'Light', value: 'light' }, + { label: 'Dark', value: 'dark' } + ] + } + } + }, + { + id: 'applicants', + title: 'Applicants', + visible: false, + switchable: true, + opacity: 0.4, + showOpacityControl: true, + minZoom: 4, + maxZoom: 9, + legend: { + collapsed: false, + type: LEGEND_TYPES.ICON, + icons: [ + `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"> + <rect width="24" height="24" fill="#dd3741" rx="4" /> + </svg>` + ].map((txt) => txt.replace(/#/g, '%23')), + labels: ['Applicants'] + } + }, + { + id: 'avland', + title: 'Available Land', + visible: true, + switchable: true, + legend: [ + { + attr: 'category', + collapsed: false, + type: LEGEND_TYPES.ICON, + icons: [ + `data:image/svg+xml,<svg width="26" height="42" view-box="0 0 26 42" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M24.9898 13.5C24.9966 13.3342 25 13.1675 25 13C25 6.37258 19.6274 1 13 1C6.37258 1 1 6.37258 1 13C1 13.1675 1.00343 13.3342 1.01023 13.5H1C1 15 1.66667 17.1667 2 18L13 39.5L24 18C24.7689 16.4972 25.0079 14.2072 25 13.5H24.9898Z" fill="#D40511"/> + <path d="M24.9898 13.5L23.9906 13.4591L23.948 14.5H24.9898V13.5ZM1.01023 13.5V14.5H2.05205L2.00939 13.459L1.01023 13.5ZM1 13.5V12.5H0V13.5H1ZM2 18L1.07152 18.3714L1.08869 18.4143L1.10975 18.4555L2 18ZM13 39.5L12.1098 39.9555L13 41.6955L13.8902 39.9555L13 39.5ZM24 18L23.1098 17.5445V17.5445L24 18ZM25 13.5L25.9999 13.4889L25.9889 12.5H25V13.5ZM24 13C24 13.1538 23.9968 13.3069 23.9906 13.4591L25.9889 13.5409C25.9963 13.3615 26 13.1811 26 13H24ZM13 2C19.0751 2 24 6.92487 24 13H26C26 5.8203 20.1797 0 13 0V2ZM2 13C2 6.92487 6.92487 2 13 2V0C5.8203 0 0 5.8203 0 13H2ZM2.00939 13.459C2.00315 13.3069 2 13.1538 2 13H0C0 13.1811 0.00371128 13.3615 0.0110667 13.541L2.00939 13.459ZM1 14.5H1.01023V12.5H1V14.5ZM2.92848 17.6286C2.78084 17.2595 2.54409 16.5532 2.34514 15.7575C2.14373 14.9518 2 14.1282 2 13.5H0C0 14.3718 0.189607 15.3815 0.404858 16.2425C0.622579 17.1134 0.885825 17.9071 1.07152 18.3714L2.92848 17.6286ZM13.8902 39.0445L2.89025 17.5445L1.10975 18.4555L12.1098 39.9555L13.8902 39.0445ZM23.1098 17.5445L12.1098 39.0445L13.8902 39.9555L24.8902 18.4555L23.1098 17.5445ZM24.0001 13.5111C24.0031 13.7838 23.9544 14.4669 23.8075 15.2721C23.6602 16.0796 23.43 16.9186 23.1098 17.5445L24.8902 18.4555C25.3389 17.5786 25.6126 16.5212 25.775 15.6311C25.9379 14.7388 26.0048 13.9234 25.9999 13.4889L24.0001 13.5111ZM24.9898 14.5H25V12.5H24.9898V14.5Z" fill="white"/> + <circle id="Ellipse 10" cx="13" cy="13" r="3" fill="white" stroke="white" stroke-width="2"/> + </svg>` + ].map((txt) => txt.replace(/#/g, '%23')), + colors: ['#D40511'], + labels: ['Available land'] + }, + { + attr: 'population', + collapsed: false, + type: LEGEND_TYPES.PROPORTION, + labels: [1, 1000] + } + ] + }, + { + id: 'catchment', + title: 'Catchment Area', + visible: true, + switchable: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.CATEGORY, + colors: [`#FC483F33`], + labels: ['Catchment Area'] + } + }, + { + id: 'cust-loc', + title: 'Customer Locations', + visible: false, + switchable: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.CATEGORY, + colors: ['#C8C8C8'], + labels: ['Others'] + } + }, + { + id: 'employees', + title: 'Employees', + visible: false, + switchable: true, + opacity: 0.4, + showOpacityControl: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.ICON, + icons: [ + `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"> + <rect width="24" height="24" fill="#ffd633" rx="4" /> + </svg>` + ].map((txt) => txt.replace(/#/g, '%23')), + labels: ['Employees'] + } + }, + { + id: 'existing-ops', + title: 'DSC Existing Operations', + visible: true, + switchable: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.ICON, + icons: [ + `data:image/svg+xml,<svg width="154" height="154" viewBox="0 0 154 154" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="76.7998" cy="76.8" r="64" fill="white"/> + <circle cx="76.7996" cy="76.8001" r="51.2" fill="#FFCC00"/> + <circle cx="76.8002" cy="76.7999" r="25.6" fill="#D40511"/> + </svg>` + ].map((txt) => txt.replace(/#/g, '%23')), + labels: ['DSC Existing Operations'] + } + }, + { + id: 'spatial-index', + title: 'Spatial Index', + visible: true, + opacity: 100 / 255, + switchable: true, + showOpacityControl: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.BINS, + labels: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1], + colors: [ + '#ffffe5', + '#f7fcb9', + '#d9f0a3', + '#addd8e', + '#78c679', + '#41ab5d', + '#238443', + '#006837', + '#004529', + '#003529' + ] + } + }, + { + id: 'intermodal-points', + title: 'Intermodal points', + visible: false, + switchable: true, + legend: { + collapsed: false, + type: LEGEND_TYPES.ICON, + attr: 'icon_category', + icons: [ + `data:image/svg+xml,<svg width="128" height="129" viewBox="0 0 128 129" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="64.0003" cy="64.2" r="53.3333" fill="white"/> + <circle cx="63.9997" cy="64.1999" r="42.6667" fill="#0081F4"/> + <mask id="mask0_1694_41838" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="32" y="32" width="64" height="65"> + <rect x="32" y="32.2" width="64" height="64" fill="#D9D9D9"/> + </mask> + <g mask="url(#mask0_1694_41838)"> + <path d="M54.3955 89.8011V86.6011L59.9955 82.4011V67.6011L38.3955 73.8011V69.0011L59.9955 56.6677V42.6011C59.9955 41.49 60.3844 40.5455 61.1622 39.7677C61.94 38.99 62.8844 38.6011 63.9955 38.6011C65.1066 38.6011 66.0511 38.99 66.8288 39.7677C67.6066 40.5455 67.9955 41.49 67.9955 42.6011V56.6677L89.5955 69.0011V73.8011L67.9955 67.6011V82.4011L73.5955 86.6011V89.8011L63.9955 86.6011L54.3955 89.8011Z" fill="white"/> + </g> + </svg>`, + `data:image/svg+xml,<svg width="128" height="129" viewBox="0 0 128 129" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="64.0003" cy="64.2" r="53.3333" fill="white"/> + <circle cx="63.9997" cy="64.1999" r="42.6667" fill="#0081F4"/> + <mask id="mask0_1700_40659" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="32" y="32" width="64" height="65"> + <rect x="32" y="32.2" width="64" height="64" fill="#D9D9D9"/> + </mask> + <g mask="url(#mask0_1700_40659)"> + <path d="M43.1982 86.5995C40.976 86.5995 39.0871 85.8217 37.5316 84.2662C35.976 82.7106 35.1982 80.8217 35.1982 78.5995C35.1982 77.3106 35.476 76.1106 36.0316 74.9995C36.5871 73.8884 37.376 72.9551 38.3982 72.1995V60.9995H44.7982V48.1995H63.9982L75.7316 73.5995C76.0871 74.2217 76.3538 74.8874 76.5316 75.5964C76.7094 76.3055 76.7982 77.0398 76.7982 77.7995C76.7982 80.23 75.9392 82.3043 74.2211 84.0224C72.503 85.7405 70.4287 86.5995 67.9982 86.5995C66.3251 86.5995 64.7846 86.1662 63.3767 85.2995C61.9688 84.4328 60.8775 83.2662 60.1027 81.7995H50.5316C49.9094 83.2662 48.9369 84.4328 47.6142 85.2995C46.2916 86.1662 44.8196 86.5995 43.1982 86.5995ZM79.9982 83.3995V44.9995H84.7982V78.5995H92.7982V83.3995H79.9982ZM43.1982 81.7995C44.1049 81.7995 44.8649 81.4929 45.4782 80.8795C46.0916 80.2662 46.3982 79.5062 46.3982 78.5995C46.3982 77.6928 46.0916 76.9328 45.4782 76.3195C44.8649 75.7062 44.1049 75.3995 43.1982 75.3995C42.2916 75.3995 41.5316 75.7062 40.9182 76.3195C40.3049 76.9328 39.9982 77.6928 39.9982 78.5995C39.9982 79.5062 40.3049 80.2662 40.9182 80.8795C41.5316 81.4929 42.2916 81.7995 43.1982 81.7995ZM67.9982 81.7995C69.1094 81.7995 70.0538 81.4106 70.8316 80.6328C71.6094 79.8551 71.9982 78.9106 71.9982 77.7995C71.9982 76.6884 71.6094 75.744 70.8316 74.9662C70.0538 74.1884 69.1094 73.7995 67.9982 73.7995C66.8871 73.7995 65.9427 74.1884 65.1649 74.9662C64.3871 75.744 63.9982 76.6884 63.9982 77.7995C63.9982 78.9106 64.3871 79.8551 65.1649 80.6328C65.9427 81.4106 66.8871 81.7995 67.9982 81.7995ZM56.3316 68.9995H68.3316L60.9316 52.9995H49.5982V62.5328L56.3316 68.9995Z" fill="white"/> + </g> + </svg>`, + `data:image/svg+xml,<svg width="128" height="129" viewBox="0 0 128 129" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="64.0003" cy="64.2" r="53.3333" fill="white"/> + <circle cx="63.9997" cy="64.1999" r="42.6667" fill="#0081F4"/> + <mask id="mask0_1694_41840" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="32" y="32" width="64" height="65"> + <rect x="32" y="32.2" width="64" height="64" fill="#D9D9D9"/> + </mask> + <g mask="url(#mask0_1694_41840)"> + <path d="M43.1987 83.2671L38.5987 66.3337C38.2431 65.0893 38.3542 63.9115 38.932 62.8004C39.5098 61.6893 40.3987 60.9115 41.5987 60.4671L44.7987 59.3337V46.6004C44.7987 45.2671 45.2653 44.1337 46.1987 43.2004C47.132 42.2671 48.2653 41.8004 49.5987 41.8004H57.5987V35.4004H70.3987V41.8004H78.3987C79.732 41.8004 80.8653 42.2671 81.7987 43.2004C82.732 44.1337 83.1987 45.2671 83.1987 46.6004V59.3337L86.3987 60.4671C87.5542 60.8226 88.432 61.5671 89.032 62.7004C89.632 63.8337 89.7542 65.0448 89.3987 66.3337L84.7987 83.3337C83.4653 83.2448 82.0987 82.9004 80.6987 82.3004C79.2987 81.7004 77.4653 80.6671 75.1987 79.2004C73.332 80.6226 71.4653 81.6782 69.5987 82.3671C67.732 83.0559 65.8653 83.4004 63.9987 83.4004C62.132 83.4004 60.2653 83.0559 58.3987 82.3671C56.532 81.6782 54.6653 80.6226 52.7987 79.2004C50.6209 80.6226 48.8098 81.6337 47.3653 82.2337C45.9209 82.8337 44.532 83.1782 43.1987 83.2671ZM38.3987 93.0004V88.2004H41.5987C43.732 88.2004 45.7431 87.9226 47.632 87.3671C49.5209 86.8115 51.2431 86.0004 52.7987 84.9337C54.3098 86.0004 56.0098 86.8115 57.8987 87.3671C59.7876 87.9226 61.8209 88.2004 63.9987 88.2004C66.132 88.2004 68.1431 87.9226 70.032 87.3671C71.9209 86.8115 73.6431 86.0004 75.1987 84.9337C76.7542 86.0004 78.4765 86.8115 80.3653 87.3671C82.2542 87.9226 84.2653 88.2004 86.3987 88.2004H89.5987V93.0004H86.3987C84.2653 93.0004 82.2542 92.7893 80.3653 92.3671C78.4765 91.9448 76.7542 91.2893 75.1987 90.4004C73.6431 91.2893 71.9209 91.9448 70.032 92.3671C68.1431 92.7893 66.132 93.0004 63.9987 93.0004C61.8209 93.0004 59.7987 92.7893 57.932 92.3671C56.0653 91.9448 54.3542 91.2893 52.7987 90.4004C51.2431 91.2893 49.5209 91.9448 47.632 92.3671C45.7431 92.7893 43.732 93.0004 41.5987 93.0004H38.3987ZM49.5987 57.5337L63.9987 52.3337L78.3987 57.6004V46.6004H49.5987V57.5337Z" fill="white"/> + </g> + </svg>`, + `data:image/svg+xml,<svg width="128" height="129" viewBox="0 0 128 129" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="64.0003" cy="64.2" r="53.3333" fill="white"/> + <circle cx="63.9997" cy="64.1999" r="42.6667" fill="#0081F4"/> + <mask id="mask0_1694_41839" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="32" y="32" width="64" height="65"> + <rect x="32" y="32.2" width="64" height="64" fill="#D9D9D9"/> + </mask> + <g mask="url(#mask0_1694_41839)"> + <path d="M49.6018 89.7988V88.1988L52.9351 84.8655C50.5795 84.4655 48.6351 83.3766 47.1018 81.5988C45.5684 79.8211 44.8018 77.7544 44.8018 75.3988V51.3988C44.8018 48.1544 46.2906 45.7433 49.2684 44.1655C52.2462 42.5877 57.1573 41.7988 64.0018 41.7988C70.8462 41.7988 75.7573 42.5877 78.7351 44.1655C81.7129 45.7433 83.2018 48.1544 83.2018 51.3988V75.3988C83.2018 77.7544 82.4351 79.8211 80.9018 81.5988C79.3684 83.3766 77.424 84.4655 75.0684 84.8655L78.4018 88.1988V89.7988H49.6018ZM49.6018 62.5988H61.6018V54.5988H49.6018V62.5988ZM66.4018 62.5988H78.4018V54.5988H66.4018V62.5988ZM56.0018 76.9988C56.8906 76.9988 57.6462 76.6877 58.2684 76.0655C58.8906 75.4433 59.2018 74.6877 59.2018 73.7988C59.2018 72.9099 58.8906 72.1544 58.2684 71.5322C57.6462 70.9099 56.8906 70.5988 56.0018 70.5988C55.1129 70.5988 54.3573 70.9099 53.7351 71.5322C53.1129 72.1544 52.8018 72.9099 52.8018 73.7988C52.8018 74.6877 53.1129 75.4433 53.7351 76.0655C54.3573 76.6877 55.1129 76.9988 56.0018 76.9988ZM72.0018 76.9988C72.8906 76.9988 73.6462 76.6877 74.2684 76.0655C74.8906 75.4433 75.2018 74.6877 75.2018 73.7988C75.2018 72.9099 74.8906 72.1544 74.2684 71.5322C73.6462 70.9099 72.8906 70.5988 72.0018 70.5988C71.1129 70.5988 70.3573 70.9099 69.7351 71.5322C69.1129 72.1544 68.8018 72.9099 68.8018 73.7988C68.8018 74.6877 69.1129 75.4433 69.7351 76.0655C70.3573 76.6877 71.1129 76.9988 72.0018 76.9988Z" fill="white"/> + </g> + </svg>`, + `data:image/svg+xml,<svg width="128" height="129" viewBox="0 0 128 129" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle opacity="0.5" cx="64.0003" cy="64.2" r="53.3333" fill="white"/> + <circle cx="63.9997" cy="64.1999" r="42.6667" fill="#A5AA99"/> + <circle cx="64.0003" cy="64.2" r="21.3333" fill="white"/> + </svg>` + ].map((txt) => txt.replace(/#/g, '%23')), + labels: ['Airport', 'Dry Port', 'Port', 'Rail Hub', 'Others'] + } + } +]; diff --git a/packages/react-widgets/src/widgets/LegendWidget.js b/packages/react-widgets/src/widgets/LegendWidget.js index 48797b9ba..b13faef27 100644 --- a/packages/react-widgets/src/widgets/LegendWidget.js +++ b/packages/react-widgets/src/widgets/LegendWidget.js @@ -4,32 +4,29 @@ import { LegendWidgetUI } from '@carto/react-ui'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import sortLayers from './utils/sortLayers'; +import { useMediaQuery } from '@mui/material'; /** * Renders a <LegendWidget /> component * @param {object} props * @param {string} [props.title] - Title of the widget. - * @param {Object.<string, Function>} [props.customLayerOptions] - Allow to add custom controls to a legend item to tweak the associated layer. * @param {Object.<string, Function>} [props.customLegendTypes] - Allow to customise by default legend types that can be rendered. * @param {boolean} [props.initialCollapsed] - Define initial collapsed value. false by default. * @param {string[]} [props.layerOrder] - Array of layer identifiers. Defines the order of layer legends. [] by default. - + * @returns {React.ReactNode} */ -function LegendWidget({ - customLayerOptions, - customLegendTypes, - initialCollapsed, - layerOrder = [], - title -}) { +function LegendWidget({ customLegendTypes, initialCollapsed, layerOrder = [], title }) { const dispatch = useDispatch(); const layers = useSelector((state) => sortLayers( Object.values(state.carto.layers).filter((layer) => !!layer.legend), layerOrder - ) + ).filter((l) => !!l.legend) ); + const [collapsed, setCollapsed] = useState(initialCollapsed); + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); + const { zoom, maxZoom, minZoom } = useSelector((state) => state.carto.viewState); if (!layers.length) { return null; @@ -57,7 +54,31 @@ function LegendWidget({ dispatch( updateLayer({ id, - layerAttributes: { legend: { collapsed } } + layerAttributes: { collapsed } + }) + ); + }; + + const handleSelectionChange = ({ id, index, selection }) => { + const layer = layers.find((layer) => layer.id === id); + const isMultiple = Array.isArray(layer.legend); + const legend = isMultiple ? layer.legend : layer.legend[index]; + const newLegend = { + ...legend, + select: { + ...legend.select, + value: selection + } + }; + + dispatch( + updateLayer({ + id, + layerAttributes: { + legend: isMultiple + ? layer.legend.map((l, i) => (i === index ? newLegend : l)) + : newLegend + } }) ); }; @@ -66,13 +87,17 @@ function LegendWidget({ <LegendWidgetUI title={title} customLegendTypes={customLegendTypes} - customLayerOptions={customLayerOptions} layers={layers} - onChangeVisibility={handleChangeVisibility} - onChangeOpacity={handleChangeOpacity} collapsed={collapsed} onChangeCollapsed={setCollapsed} onChangeLegendRowCollapsed={handleChangeLegendRowCollapsed} + onChangeVisibility={handleChangeVisibility} + onChangeOpacity={handleChangeOpacity} + onChangeSelection={handleSelectionChange} + isMobile={isMobile} + currentZoom={zoom} + maxZoom={maxZoom} + minZoom={minZoom} /> ); } diff --git a/tsconfig.json b/tsconfig.json index 7c51700ce..fb6c3a177 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "strict": true, "noImplicitAny": false, "allowJs": true, - "checkJs": true, + "checkJs": false, "moduleResolution": "node", "esModuleInterop": true, "allowSyntheticDefaultImports": true,