diff --git a/openmetadata-ui-core-components/src/main/resources/ui/package.json b/openmetadata-ui-core-components/src/main/resources/ui/package.json index 3b1c60d6502d..07ec31ef122b 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/package.json +++ b/openmetadata-ui-core-components/src/main/resources/ui/package.json @@ -42,6 +42,11 @@ "types": "./dist/types/src/icon/index.d.ts", "import": "./dist/icon/index.es.js", "require": "./dist/icon/index.cjs.js" + }, + "./charts": { + "types": "./dist/types/src/charts/index.d.ts", + "import": "./dist/charts/index.es.js", + "require": "./dist/charts/index.cjs.js" } }, "files": [ @@ -102,6 +107,8 @@ "@tailwindcss/typography": ">=0.5.0", "@tailwindcss/vite": ">=4.0.0", "@untitledui/icons": "^0.0.21", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", "i18next": "^21.10.0", "input-otp": "^1.4.2", "react": ">=18.0.0", @@ -116,6 +123,14 @@ "tailwindcss-animate": ">=1.0.0", "tailwindcss-react-aria-components": ">=2.0.0" }, + "peerDependenciesMeta": { + "echarts": { + "optional": true + }, + "echarts-for-react": { + "optional": true + } + }, "devDependencies": { "@eslint/js": "^9.18.0", "@fontsource/inter": "^5.2.8", @@ -143,6 +158,8 @@ "@types/react-dom": "^18.2.0", "@untitledui/icons": "^0.0.21", "@vitejs/plugin-react": "^5.0.1", + "echarts": "^6.1.0", + "echarts-for-react": "^3.0.6", "eslint": "^9.18.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.37.3", diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/area-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/area-chart.tsx new file mode 100644 index 000000000000..fab61527a105 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/area-chart.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CartesianChartBase } from './cartesian-chart-base'; +import { buildAreaOption } from './options/cartesian'; +import type { CartesianChartProps } from './props'; + +/** Area chart: lines filled with a fading gradient of their colour. */ +export const AreaChart = (props: CartesianChartProps) => ( + +); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/bar-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/bar-chart.tsx new file mode 100644 index 000000000000..724500f9a5ff --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/bar-chart.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CartesianChartBase } from './cartesian-chart-base'; +import { buildBarOption } from './options/cartesian'; +import type { BarChartProps } from './props'; + +/** Bar chart, vertical or horizontal. */ +export const BarChart = (props: BarChartProps) => ( + +); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/cartesian-chart-base.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/cartesian-chart-base.tsx new file mode 100644 index 000000000000..873804ce3d25 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/cartesian-chart-base.tsx @@ -0,0 +1,130 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ECElementEvent } from 'echarts'; +import { useCallback, useMemo } from 'react'; +import { EChart } from './echart'; +import type { BarChartProps } from './props'; +import type { CartesianBuildInput, ChartOption, ChartTheme } from './types'; + +type CartesianBuilder = ( + input: CartesianBuildInput, + theme: ChartTheme +) => ChartOption; + +export type CartesianChartBaseProps = BarChartProps & { + build: CartesianBuilder; +}; + +/** + * Shared body of the line, area, bar and composed charts: builds the option + * from props + theme, maps clicks back to the caller's row, and renders + * through `EChart`. + */ +export const CartesianChartBase = ({ + build, + data, + xKey, + series, + ariaLabel, + xAxis, + yAxis, + tooltip, + legend, + referenceLines, + zoom, + option, + layout, + getBarColor, + showValueLabels, + radius, + onPointClick, + height, + isDark, + loading, + emptyState, + className, + 'data-testid': dataTestId, +}: CartesianChartBaseProps) => { + const getOption = useCallback( + (theme: ChartTheme) => + build( + { + data, + xKey, + series, + ariaLabel, + xAxis, + yAxis, + tooltip, + legend, + referenceLines, + zoom, + option, + layout, + getBarColor, + showValueLabels, + radius, + }, + theme + ), + [ + build, + data, + xKey, + series, + ariaLabel, + xAxis, + yAxis, + tooltip, + legend, + referenceLines, + zoom, + option, + layout, + getBarColor, + showValueLabels, + radius, + ] + ); + + const onEvents = useMemo( + () => + onPointClick + ? { + click: (event: ECElementEvent) => { + const datum = data[event.dataIndex]; + if (datum) { + onPointClick(datum, String(event.seriesId), event); + } + }, + } + : undefined, + [data, onPointClick] + ); + + return ( + + ); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/components.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/components.test.tsx new file mode 100644 index 000000000000..41084b264783 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/components.test.tsx @@ -0,0 +1,273 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import type { + BarSeriesOption, + ECElementEvent, + LineSeriesOption, + PieSeriesOption, +} from 'echarts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AreaChart } from './area-chart'; +import { BarChart } from './bar-chart'; +import { ComposedChart } from './composed-chart'; +import { LineChart } from './line-chart'; +import { PieChart } from './pie-chart'; +import type { ChartOption } from './types'; + +const hostProps = vi.hoisted(() => ({ + calls: [] as Array>, +})); + +vi.mock('echarts-for-react/lib/core', () => ({ + default: (props: Record) => { + hostProps.calls.push(props); + + return
; + }, +})); + +const lastHost = () => hostProps.calls[hostProps.calls.length - 1]; +const hostSeries = () => + (lastHost().option as ChartOption).series as Array< + LineSeriesOption & BarSeriesOption + >; +const clickHost = (event: Partial) => + (lastHost().onEvents as Record void>).click( + event as ECElementEvent + ); + +interface Row { + day: string; + passed: number; + failed: number; +} + +const rows: Row[] = [ + { day: 'Mon', passed: 3, failed: 1 }, + { day: 'Tue', passed: 5, failed: 0 }, +]; + +const series = [ + { key: 'passed', name: 'Passed' }, + { key: 'failed', name: 'Failed' }, +]; + +beforeEach(() => { + hostProps.calls.length = 0; +}); + +describe('LineChart', () => { + it('renders line series built from the rows', () => { + render( + + ); + + expect(hostSeries().map((s) => s.type)).toEqual(['line', 'line']); + expect(hostSeries()[0].data).toEqual([3, 5]); + }); + + it('shows the empty state instead of a chart when there are no rows', () => { + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByTestId('echarts-host')).not.toBeInTheDocument(); + }); + + it('passes loading, height and data-testid through', () => { + const { container } = render( + + ); + + expect(screen.getByTestId('runs-chart')).toBeInTheDocument(); + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + }); + + it('maps a clicked point back to its row and series key', () => { + const onPointClick = vi.fn(); + render( + + ); + const event = { dataIndex: 1, seriesId: 'failed' }; + clickHost(event); + + expect(onPointClick).toHaveBeenCalledWith(rows[1], 'failed', event); + }); + + it('maps a click on a time axis the same way', () => { + const onPointClick = vi.fn(); + const timed = [ + { ts: 1000, value: 2 }, + { ts: 2000, value: 4 }, + ]; + render( + + ); + clickHost({ dataIndex: 0, seriesId: 'value' }); + + expect(onPointClick).toHaveBeenCalledWith( + timed[0], + 'value', + expect.anything() + ); + }); + + it('binds no click handler when onPointClick is not given', () => { + render( + + ); + + expect( + (lastHost().onEvents as Record).click + ).toBeUndefined(); + }); +}); + +describe('AreaChart', () => { + it('renders filled line series', () => { + render( + + ); + + expect(hostSeries()[0].type).toBe('line'); + expect(hostSeries()[0].areaStyle).toBeDefined(); + }); +}); + +describe('BarChart', () => { + it('renders bar series', () => { + render( + + ); + + expect(hostSeries().map((s) => s.type)).toEqual(['bar', 'bar']); + }); + + it('maps a click on a horizontal bar back to its row', () => { + const onPointClick = vi.fn(); + render( + + ); + clickHost({ dataIndex: 0, seriesId: 'passed' }); + + expect(onPointClick).toHaveBeenCalledWith( + rows[0], + 'passed', + expect.anything() + ); + }); +}); + +describe('ComposedChart', () => { + it('renders each series with its own type', () => { + render( + + ); + + expect(hostSeries().map((s) => s.type)).toEqual(['bar', 'line']); + }); +}); + +describe('PieChart', () => { + const slices = [ + { name: 'Success', value: 6 }, + { name: 'Failed', value: 2 }, + ]; + const pie = () => + ((lastHost().option as ChartOption).series as PieSeriesOption[])[0]; + + it('renders a pie series from the slices', () => { + render(); + + expect(pie().type).toBe('pie'); + expect((pie().data as Array<{ name: string }>).map((d) => d.name)).toEqual([ + 'Success', + 'Failed', + ]); + }); + + it('renders the centre label over the chart', () => { + render( + 8 tests} + data={slices} + innerRadius="55%" + /> + ); + + expect(screen.getByText('8 tests')).toBeInTheDocument(); + }); + + it('maps a clicked slice back to its datum', () => { + const onSliceClick = vi.fn(); + render( + + ); + clickHost({ dataIndex: 1 }); + + expect(onSliceClick).toHaveBeenCalledWith(slices[1], expect.anything()); + }); + + it('shows the empty state when every slice is zero', () => { + render( + + ); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByTestId('echarts-host')).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/composed-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/composed-chart.tsx new file mode 100644 index 000000000000..a8a1a74fdc0a --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/composed-chart.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CartesianChartBase } from './cartesian-chart-base'; +import { buildComposedOption } from './options/cartesian'; +import type { CartesianChartProps } from './props'; + +/** Composed chart: each series sets its own type (line, area or bar). */ +export const ComposedChart = ( + props: CartesianChartProps +) => ; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.test.tsx new file mode 100644 index 000000000000..6783985e6115 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.test.tsx @@ -0,0 +1,254 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DARK_CHART_THEME, LIGHT_CHART_THEME } from './theme'; +import type { ChartOption, ChartTheme } from './types'; +import { EChart } from './echart'; + +const hostProps = vi.hoisted(() => ({ + calls: [] as Array>, +})); + +// jsdom cannot lay out a real chart, so capture what EChart hands the +// echarts-for-react host instead. +vi.mock('echarts-for-react/lib/core', () => ({ + default: (props: Record) => { + hostProps.calls.push(props); + + return
; + }, +})); + +const lastHost = () => hostProps.calls[hostProps.calls.length - 1]; + +const themeSpy = () => { + const seen: ChartTheme[] = []; + const option = (theme: ChartTheme): ChartOption => { + seen.push(theme); + + return { series: [] }; + }; + + return { seen, option }; +}; + +beforeEach(() => { + hostProps.calls.length = 0; + document.documentElement.classList.remove('dark-mode'); +}); + +afterEach(() => { + document.documentElement.classList.remove('dark-mode'); +}); + +describe('EChart', () => { + it('builds the option with the light theme by default', () => { + const { seen, option } = themeSpy(); + render(); + + expect(seen[seen.length - 1]).toBe(LIGHT_CHART_THEME); + expect(screen.getByTestId('echarts-host')).toBeInTheDocument(); + }); + + it('uses the dark theme inside a .dark-mode ancestor', () => { + const { seen, option } = themeSpy(); + render( +
+ +
+ ); + + expect(seen[seen.length - 1]).toBe(DARK_CHART_THEME); + }); + + it('switches to the dark theme when gets .dark-mode', async () => { + const { seen, option } = themeSpy(); + render(); + + await act(async () => { + document.documentElement.classList.add('dark-mode'); + await Promise.resolve(); + }); + + expect(seen[seen.length - 1]).toBe(DARK_CHART_THEME); + }); + + it('lets the isDark prop override the detected mode', () => { + const { seen, option } = themeSpy(); + render( +
+ +
+ ); + + expect(seen[seen.length - 1]).toBe(LIGHT_CHART_THEME); + }); + + it('accepts a ready-made option object', () => { + render(); + + expect(lastHost().option).toMatchObject({ series: [{ type: 'bar' }] }); + }); + + it('adds an aria description from ariaLabel when the option has none', () => { + render(); + + expect(lastHost().option).toMatchObject({ + aria: { enabled: true, label: { description: 'Weekly runs' } }, + }); + }); + + it('keeps an aria block the option already has', () => { + const aria = { enabled: true, label: { description: 'Custom' } }; + render(); + + expect(lastHost().option).toMatchObject({ aria }); + }); + + it('merges updates without dropping the legend selection', () => { + render(); + + expect(lastHost().notMerge).toBe(false); + expect(lastHost().replaceMerge).toEqual([ + 'series', + 'xAxis', + 'yAxis', + 'grid', + 'dataZoom', + 'visualMap', + ]); + expect(lastHost().opts).toEqual({ renderer: 'svg' }); + }); + + it('passes the modular echarts core and the event handlers through', () => { + const onClick = vi.fn(); + render( + + ); + + expect((lastHost().onEvents as Record).click).toBe( + onClick + ); + expect(lastHost().echarts).toHaveProperty('use'); + }); + + it('re-applies the zoom window the user picked after a re-render', () => { + const option: ChartOption = { + dataZoom: [ + { id: 'zoom-inside', type: 'inside', start: 0, end: 30 }, + { id: 'zoom-slider', type: 'slider', start: 0, end: 30 }, + ], + series: [], + }; + const { rerender } = render(); + act(() => { + (lastHost().onEvents as Record void>).datazoom({ + batch: [{ start: 40, end: 70 }], + }); + }); + rerender(); + + expect(lastHost().option).toMatchObject({ + dataZoom: [ + { id: 'zoom-inside', start: 40, end: 70 }, + { id: 'zoom-slider', start: 40, end: 70 }, + ], + }); + }); + + it("still calls the caller's own datazoom handler", () => { + const onZoom = vi.fn(); + render( + + ); + const event = { start: 10, end: 20 }; + (lastHost().onEvents as Record void>).datazoom( + event + ); + + expect(onZoom).toHaveBeenCalledWith(event); + }); + + it('sizes the chart to 300px tall and full width by default', () => { + render(); + + expect(lastHost().style).toEqual({ height: 300, width: '100%' }); + }); + + it('uses the given height and width', () => { + render( + + ); + + expect(lastHost().style).toEqual({ height: 180, width: 240 }); + }); + + it('shows a skeleton instead of the chart while loading', () => { + const { container } = render( + + ); + + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + expect(screen.queryByTestId('echarts-host')).not.toBeInTheDocument(); + }); + + it('shows the default empty message when there is no data', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('label.no-data-found'); + expect(screen.queryByTestId('echarts-host')).not.toBeInTheDocument(); + }); + + it('shows a custom empty state when given', () => { + render( + Nothing yet} + option={{ series: [] }} + /> + ); + + expect(screen.getByText('Nothing yet')).toBeInTheDocument(); + }); + + it('puts data-testid and className on the wrapper and renders overlay children', () => { + render( + + Total 10 + + ); + + expect(screen.getByTestId('my-chart')).toHaveClass('custom'); + expect(screen.getByText('Total 10')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.tsx new file mode 100644 index 000000000000..c32ccb8f8e56 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echart.tsx @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ReactEChartsCore from 'echarts-for-react/lib/core'; +import type { ECElementEvent } from 'echarts'; +import { ReactNode, useMemo, useRef } from 'react'; +import { Skeleton } from '@/components/base/skeleton/skeleton'; +import { useCoreTranslation } from '@/i18n/useCoreTranslation'; +import { cx } from '@/utils/cx'; +import { applyZoomWindow, ZoomWindow } from './options/common'; +import { REPLACE_MERGE_KEYS } from './options/merge'; +import { echarts, registerChartParts } from './register'; +import { buildChartTheme } from './theme'; +import type { ChartOption, ChartTheme } from './types'; +import { useIsDarkMode } from './use-is-dark-mode'; + +registerChartParts(); + +const DEFAULT_HEIGHT = 300; +const DEFAULT_WIDTH = '100%'; +const RENDER_OPTS = { renderer: 'svg' } as const; + +export interface EChartProps { + /** A ready option, or a function that builds it from the active theme. */ + option: ChartOption | ((theme: ChartTheme) => ChartOption); + /** Accessible name of the chart. Translated by the caller. */ + ariaLabel: string; + height?: number | string; + width?: number | string; + /** Forces a colour mode. Detected from `.dark-mode` when omitted. */ + isDark?: boolean; + onEvents?: Record void>; + loading?: boolean; + isEmpty?: boolean; + /** Shown instead of the chart when `isEmpty`. */ + emptyState?: ReactNode; + className?: string; + 'data-testid'?: string; + /** Overlay drawn above the chart, e.g. a donut's centre label. */ + children?: ReactNode; +} + +interface DataZoomEvent { + start?: number; + end?: number; + batch?: Array<{ start?: number; end?: number }>; +} + +// A slider drag reports start/end on the event; an inside (wheel/drag) zoom +// reports them in `batch`. +const zoomWindowOf = (event: DataZoomEvent): ZoomWindow | undefined => { + const { start, end } = event.batch?.[0] ?? event; + + return start === undefined || end === undefined ? undefined : { start, end }; +}; + +const withAria = (option: ChartOption, ariaLabel: string): ChartOption => + option.aria + ? option + : { ...option, aria: { enabled: true, label: { description: ariaLabel } } }; + +/** + * Lower-layer chart: renders any ECharts option with the charts theme, the + * SVG renderer, and shared loading / empty / accessibility handling. + */ +export const EChart = ({ + option, + ariaLabel, + height = DEFAULT_HEIGHT, + width = DEFAULT_WIDTH, + isDark, + onEvents, + loading = false, + isEmpty = false, + emptyState, + className, + 'data-testid': dataTestId, + children, +}: EChartProps) => { + const { t } = useCoreTranslation(); + const containerRef = useRef(null); + // The range the user zoomed to, re-applied whenever the option is rebuilt. + const zoomRef = useRef(); + const dark = useIsDarkMode(containerRef, isDark); + const theme = buildChartTheme({ isDark: dark }); + const resolved = useMemo( + () => + applyZoomWindow( + withAria( + typeof option === 'function' ? option(theme) : option, + ariaLabel + ), + zoomRef.current + ), + [option, theme, ariaLabel] + ); + const events = useMemo( + () => ({ + ...onEvents, + datazoom: (event: ECElementEvent) => { + zoomRef.current = + zoomWindowOf(event as unknown as DataZoomEvent) ?? zoomRef.current; + onEvents?.datazoom?.(event); + }, + }), + [onEvents] + ); + const size = { height, width }; + const showChart = !loading && !isEmpty; + + return ( +
+ {loading && ( +
+ +
+ )} + {!loading && isEmpty && ( +
+ {emptyState ?? t('label.no-data-found')} +
+ )} + {showChart && ( + + )} + {showChart && children} +
+ ); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echarts-integration.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echarts-integration.test.ts new file mode 100644 index 000000000000..9b9c7199bf14 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/echarts-integration.test.ts @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @vitest-environment node +import { BarChart, LineChart, MapChart } from 'echarts/charts'; +import { + AriaComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + MarkLineComponent, + TooltipComponent, + VisualMapComponent, +} from 'echarts/components'; +import * as echarts from 'echarts/core'; +import { SVGRenderer } from 'echarts/renderers'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildBarOption, + buildComposedOption, + buildLineOption, +} from './options/cartesian'; +import { applyZoomWindow } from './options/common'; +import { buildGeoMapOption } from './options/geo'; +import { REPLACE_MERGE_KEYS } from './options/merge'; +import { LIGHT_CHART_THEME } from './theme'; +import type { CartesianBuildInput, ChartOption, GeoJson } from './types'; + +// These run the builders against a real (server-side) ECharts instance with +// the same merge policy EChart uses, so re-render behaviour is checked on +// ECharts itself rather than on the props handed to a mock. +echarts.use([ + LineChart, + BarChart, + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + MarkLineComponent, + AriaComponent, + MapChart, + VisualMapComponent, + SVGRenderer, +]); + +const MERGE = { notMerge: false, replaceMerge: REPLACE_MERGE_KEYS }; + +interface Row { + day: string; + a: number; + b: number; +} + +const rows: Row[] = Array.from({ length: 40 }, (_, i) => ({ + day: `d${i}`, + a: i, + b: i * 2, +})); + +const input = ( + extra: Partial> = {} +): CartesianBuildInput => ({ + data: rows, + xKey: 'day', + ariaLabel: 'Chart', + series: [ + { key: 'a', name: 'A' }, + { key: 'b', name: 'B' }, + ], + ...extra, +}); + +const charts: echarts.ECharts[] = []; + +const mount = (option: ChartOption) => { + const chart = echarts.init(null as unknown as HTMLElement, null, { + renderer: 'svg', + ssr: true, + width: 600, + height: 300, + }); + charts.push(chart); + chart.setOption(option, MERGE); + + return chart; +}; + +type Model = { + series: Array>; + dataZoom?: Array<{ start: number; end: number }>; + legend: Array<{ selected: Record }>; +}; +const modelOf = (chart: echarts.ECharts) => chart.getOption() as Model; + +afterEach(() => { + charts.splice(0).forEach((chart) => chart.dispose()); +}); + +describe('reference lines on a real chart', () => { + it('stay drawn when the user hides the first series', () => { + const chart = mount( + buildLineOption( + input({ referenceLines: [{ axis: 'y', value: 10, label: 'LIMIT' }] }), + LIGHT_CHART_THEME + ) + ); + chart.dispatchAction({ type: 'legendUnSelect', name: 'A' }); + + expect(chart.renderToSVGString()).toContain('LIMIT'); + }); + + it('disappear when the caller removes them', () => { + const chart = mount( + buildLineOption( + input({ referenceLines: [{ axis: 'y', value: 10, label: 'LIMIT' }] }), + LIGHT_CHART_THEME + ) + ); + chart.setOption(buildLineOption(input(), LIGHT_CHART_THEME), MERGE); + + expect(chart.renderToSVGString()).not.toContain('LIMIT'); + }); +}); + +describe('re-rendering with changed series settings', () => { + it('drops the area fill when a composed series turns into a line', () => { + const chart = mount( + buildComposedOption( + input({ series: [{ key: 'a', name: 'A', type: 'area' }] }), + LIGHT_CHART_THEME + ) + ); + chart.setOption( + buildComposedOption( + input({ series: [{ key: 'a', name: 'A', type: 'line' }] }), + LIGHT_CHART_THEME + ), + MERGE + ); + + expect(modelOf(chart).series[0].areaStyle).toBeFalsy(); + }); + + it('drops a custom tooltip formatter the caller removed', () => { + const chart = mount( + buildLineOption( + input({ tooltip: { valueFormatter: (v) => `${v}%` } }), + LIGHT_CHART_THEME + ) + ); + chart.setOption(buildLineOption(input(), LIGHT_CHART_THEME), MERGE); + + expect(modelOf(chart).series[0].tooltip).toBeFalsy(); + }); + + it('keeps the series the user hid through the legend', () => { + const chart = mount(buildLineOption(input(), LIGHT_CHART_THEME)); + chart.dispatchAction({ type: 'legendUnSelect', name: 'B' }); + chart.setOption(buildLineOption(input(), LIGHT_CHART_THEME), MERGE); + + expect(modelOf(chart).legend[0].selected).toMatchObject({ B: false }); + }); +}); + +describe('zoom window on a real chart', () => { + it('survives a re-render when the current window is re-applied', () => { + const chart = mount( + buildLineOption(input({ zoom: true }), LIGHT_CHART_THEME) + ); + chart.dispatchAction({ type: 'dataZoom', start: 50, end: 80 }); + const [{ start, end }] = modelOf(chart).dataZoom ?? []; + chart.setOption( + applyZoomWindow( + buildLineOption(input({ zoom: true }), LIGHT_CHART_THEME), + { + start, + end, + } + ), + MERGE + ); + + expect(modelOf(chart).dataZoom?.[0]).toMatchObject({ start: 50, end: 80 }); + }); +}); + +describe('geo map on a real chart', () => { + const square = (name: string, x: number): GeoJson['features'][number] => ({ + type: 'Feature', + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [x, 0], + [x + 1, 0], + [x + 1, 1], + [x, 1], + [x, 0], + ], + ], + }, + }); + + it('shades the regions of a caller-registered map', () => { + echarts.registerMap('integration-squares', { + type: 'FeatureCollection', + features: [square('Alpha', 0), square('Beta', 2)], + } as Parameters[1]); + const { option } = buildGeoMapOption( + { + ariaLabel: 'Squares', + mapName: 'integration-squares', + data: [ + { region: 'Alpha', value: 1 }, + { region: 'Beta', value: 9 }, + ], + }, + LIGHT_CHART_THEME + ); + const svg = mount(option).renderToSVGString(); + + // Lowest value → first colour of the range, highest → the last. + expect(svg).toContain('fill="rgb(227,237,253)"'); + expect(svg).toContain('fill="rgb(21,112,239)"'); + }); + + const geoInput = (option?: ChartOption) => ({ + ariaLabel: 'Squares', + mapName: 'integration-squares-2', + data: [ + { region: 'Alpha', value: 1 }, + { region: 'Beta', value: 9 }, + ], + option, + }); + + it('drops a visualMap override the caller removed', () => { + echarts.registerMap('integration-squares-2', { + type: 'FeatureCollection', + features: [square('Alpha', 0), square('Beta', 2)], + } as Parameters[1]); + const chart = mount( + buildGeoMapOption( + geoInput({ visualMap: { text: ['High', 'Low'] } }), + LIGHT_CHART_THEME + ).option + ); + chart.setOption( + buildGeoMapOption(geoInput(), LIGHT_CHART_THEME).option, + MERGE + ); + const model = chart.getOption() as { + visualMap: Array<{ text?: string[] }>; + }; + + expect(model.visualMap[0].text).toBeFalsy(); + }); + + it('removes the colour scale when the same chart switches to bars', () => { + const chart = mount( + buildGeoMapOption(geoInput(), LIGHT_CHART_THEME).option + ); + chart.setOption( + buildBarOption( + { + data: [{ day: 'Mon', a: 3 }], + xKey: 'day', + ariaLabel: 'Bars', + series: [{ key: 'a', name: 'A' }], + }, + LIGHT_CHART_THEME + ), + MERGE + ); + + expect( + (chart.getOption() as { visualMap?: unknown[] }).visualMap ?? [] + ).toHaveLength(0); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.test.ts new file mode 100644 index 000000000000..cf7d7d68e834 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { formatTooltipValue, formatYAxisTick } from './format'; + +describe('formatYAxisTick', () => { + it('adds magnitude suffixes from 1000 up', () => { + expect(formatYAxisTick(0)).toBe('0'); + expect(formatYAxisTick(42)).toBe('42'); + expect(formatYAxisTick(1756)).toBe('1.8K'); + expect(formatYAxisTick(2_500_000)).toBe('2.5M'); + expect(formatYAxisTick(3_000_000_000)).toBe('3B'); + expect(formatYAxisTick(-1756)).toBe('-1.8K'); + }); + + it('never prints a raw float endpoint', () => { + expect(formatYAxisTick(0.8064516129032258)).toBe('0.81'); + expect(formatYAxisTick(12.345678901)).toBe('12.35'); + }); + + it('keeps significant digits for values far below 1', () => { + expect(formatYAxisTick(0.0017084282460136675)).toBe('0.0017'); + expect(formatYAxisTick(0.000123456)).toBe('0.00012'); + }); + + it('leaves already-clean ticks untouched', () => { + expect(formatYAxisTick(0.25)).toBe('0.25'); + expect(formatYAxisTick(-2.5)).toBe('-2.5'); + }); +}); + +describe('formatTooltipValue', () => { + it('rounds non-integers to at most 2 decimals', () => { + expect(formatTooltipValue(1.23456)).toBe('1.23'); + expect(formatTooltipValue(1.5)).toBe('1.5'); + }); + + it('prints integers and strings as they are', () => { + expect(formatTooltipValue(5)).toBe('5'); + expect(formatTooltipValue('Passed')).toBe('Passed'); + }); + + it('prints nothing for missing values', () => { + expect(formatTooltipValue(null)).toBe(''); + expect(formatTooltipValue(undefined)).toBe(''); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.ts new file mode 100644 index 000000000000..33b4a9d10ed2 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/format.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const TICK_SIGNIFICANT_DIGITS = 2; +const MAX_TICK_DECIMALS = 6; +const TOOLTIP_DECIMALS = 2; + +const withSuffix = (value: number, divisor: number, suffix: string): string => + `${(value / divisor).toFixed(1).replace(/\.0$/, '')}${suffix}`; + +/** + * Value-axis tick label: K/M/B suffix from 1000 up, bounded precision below. + * Below 1 the leading zeros are not significant, so the decimals shift to keep + * two real digits — a 0.0017 axis must not collapse to a column of "0". + */ +export const formatYAxisTick = (value: number): string => { + const abs = Math.abs(value); + if (abs >= 1_000_000_000) { + return withSuffix(value, 1_000_000_000, 'B'); + } + if (abs >= 1_000_000) { + return withSuffix(value, 1_000_000, 'M'); + } + if (abs >= 1_000) { + return withSuffix(value, 1_000, 'K'); + } + if (Number.isInteger(value) || !Number.isFinite(value)) { + return String(value); + } + const magnitude = Math.floor(Math.log10(abs)); + const decimals = + magnitude >= 0 + ? TICK_SIGNIFICANT_DIGITS + : Math.min(MAX_TICK_DECIMALS, TICK_SIGNIFICANT_DIGITS - 1 - magnitude); + + return String(Number.parseFloat(value.toFixed(decimals))); +}; + +/** Default tooltip value: non-integers rounded to 2 decimals, missing → ''. */ +export const formatTooltipValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'number' && !Number.isInteger(value)) { + return String(Number.parseFloat(value.toFixed(TOOLTIP_DECIMALS))); + } + + return String(value); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.test.tsx new file mode 100644 index 000000000000..336d29484b71 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.test.tsx @@ -0,0 +1,217 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import type { ECElementEvent, MapSeriesOption } from 'echarts'; +import * as echarts from 'echarts/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GeoMapChart } from './geo-map-chart'; +import type { ChartOption, GeoJson } from './types'; + +const hostProps = vi.hoisted(() => ({ + calls: [] as Array>, +})); + +vi.mock('echarts-for-react/lib/core', () => ({ + default: (props: Record) => { + hostProps.calls.push(props); + + return
; + }, +})); + +const lastHost = () => hostProps.calls[hostProps.calls.length - 1]; + +const square = (name: string, x: number): GeoJson['features'][number] => ({ + type: 'Feature', + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [x, 0], + [x + 1, 0], + [x + 1, 1], + [x, 1], + [x, 0], + ], + ], + }, +}); + +const geoJson: GeoJson = { + type: 'FeatureCollection', + features: [square('Alpha', 0), square('Beta', 2)], +}; + +let mapCounter = 0; +const nextMapName = () => `test-map-${(mapCounter += 1)}`; + +beforeEach(() => { + hostProps.calls.length = 0; +}); + +describe('GeoMapChart', () => { + it('registers the geometry under mapName before drawing it', () => { + const mapName = nextMapName(); + render( + + ); + const series = (lastHost().option as ChartOption) + .series as MapSeriesOption[]; + + expect(echarts.getMap(mapName)).toBeTruthy(); + expect(series[0].map).toBe(mapName); + }); + + it('registers a map name only once, even if new geometry is passed', () => { + const mapName = nextMapName(); + const { rerender } = render( + + ); + rerender( + + ); + + // Still the first, two-feature geometry. + expect( + (echarts.getMap(mapName) as { geoJSON: GeoJson }).geoJSON.features + ).toHaveLength(2); + }); + + it('maps a clicked region back to its summed value', () => { + const onRegionClick = vi.fn(); + render( + (raw === 'A' ? 'Alpha' : raw)} + onRegionClick={onRegionClick} + /> + ); + const event = { name: 'Alpha' } as ECElementEvent; + (lastHost().onEvents as Record void>).click( + event + ); + + expect(onRegionClick).toHaveBeenCalledWith( + { region: 'Alpha', value: 5 }, + event + ); + }); + + it('ignores clicks on regions without data', () => { + const onRegionClick = vi.fn(); + render( + + ); + (lastHost().onEvents as Record void>).click({ + name: 'Beta', + } as ECElementEvent); + + expect(onRegionClick).not.toHaveBeenCalled(); + }); + + it('reports raw values that match no region', () => { + const onUnmatchedRegions = vi.fn(); + render( + (raw === 'Alpha' ? raw : undefined)} + onUnmatchedRegions={onUnmatchedRegions} + /> + ); + + expect(onUnmatchedRegions).toHaveBeenCalledWith(['ZZ']); + }); + + it('shows the empty state when no region matches', () => { + render( + undefined} + /> + ); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByTestId('echarts-host')).not.toBeInTheDocument(); + }); + + it('reports unmatched values once per change, not on every parent render', () => { + const reports: string[][] = []; + const mapName = nextMapName(); + // A parent that passes fresh data, resolver and callback on every render + // and stores what it is told — the wiring most callers will write. + const Parent = ({ tick }: { tick: number }) => { + const [, setUnmatched] = useState([]); + useEffect(() => undefined, [tick]); + + return ( + (raw === 'Alpha' ? raw : undefined)} + onUnmatchedRegions={(raw) => { + reports.push(raw); + setUnmatched(raw); + }} + /> + ); + }; + const { rerender } = render(); + rerender(); + rerender(); + + expect(reports).toEqual([['ZZ']]); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.tsx new file mode 100644 index 000000000000..978ec2982ca1 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/geo-map-chart.tsx @@ -0,0 +1,125 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { ECElementEvent } from 'echarts'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { EChart } from './echart'; +import { buildGeoMapOption, resolveGeoData } from './options/geo'; +import type { GeoMapChartProps } from './props'; +import { registerGeoMap } from './register'; +import type { ChartTheme } from './types'; + +/** + * Choropleth over caller-supplied geometry. The caller owns the map data: + * loading the GeoJSON, trimming it, and resolving raw region values to + * feature names (`resolveRegion`). + */ +export const GeoMapChart = ({ + geoJson, + mapName, + data, + ariaLabel, + resolveRegion, + showScale, + colorRange, + tooltip, + option, + onRegionClick, + onUnmatchedRegions, + height, + isDark, + loading, + emptyState, + className, + 'data-testid': dataTestId, +}: GeoMapChartProps) => { + // Registration is synchronous and must happen before the chart's first + // setOption, which runs in the child's mount — so it happens during render. + // It is idempotent per name. + registerGeoMap(mapName, geoJson); + + const resolved = useMemo( + () => resolveGeoData(data, resolveRegion), + [data, resolveRegion] + ); + const unmatchedKey = resolved.unmatched.join('\u0000'); + // Latest callback in a ref, so a caller passing an inline callback (or fresh + // data) is not re-notified — or looped — on every render. Reports on mount, + // then only when the set of unmatched values changes. + const onUnmatchedRef = useRef(onUnmatchedRegions); + onUnmatchedRef.current = onUnmatchedRegions; + + useEffect(() => { + onUnmatchedRef.current?.(resolved.unmatched); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [unmatchedKey]); + + const getOption = useCallback( + (theme: ChartTheme) => + buildGeoMapOption( + { + data, + ariaLabel, + mapName, + resolveRegion, + showScale, + colorRange, + tooltip, + option, + }, + theme + ).option, + [ + data, + ariaLabel, + mapName, + resolveRegion, + showScale, + colorRange, + tooltip, + option, + ] + ); + + const onEvents = useMemo( + () => + onRegionClick + ? { + click: (event: ECElementEvent) => { + const point = resolved.data.find((d) => d.name === event.name); + if (point) { + onRegionClick( + { region: point.name, value: point.value }, + event + ); + } + }, + } + : undefined, + [resolved, onRegionClick] + ); + + return ( + + ); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/index.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/index.ts new file mode 100644 index 000000000000..c6b12bb2e564 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/index.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// `@openmetadata/ui-core-components/charts` — ECharts-based charts. +// Deliberately not re-exported from the root entry, so apps that render no +// chart never pull echarts into their bundle. + +// Components +export { AreaChart } from './area-chart'; +export { BarChart } from './bar-chart'; +export { ComposedChart } from './composed-chart'; +export { EChart } from './echart'; +export { GeoMapChart } from './geo-map-chart'; +export type { EChartProps } from './echart'; +export { LineChart } from './line-chart'; +export { PieChart } from './pie-chart'; + +// Option builders (pure — also usable outside React, e.g. server rendering) +export { + buildAreaOption, + buildBarOption, + buildCartesianOption, + buildComposedOption, + buildLineOption, + REFERENCE_SERIES_ID, + toNumberOrNull, +} from './options/cartesian'; +export { + applyZoomWindow, + areaGradient, + categoryAxis, + dataZoomFor, + DATAZOOM_THRESHOLD, + gridFor, + hexToRgba, + legendConfig, + referenceLinesToMarkLine, + tooltipConfig, + valueAxis, +} from './options/common'; +export { + buildGeoMapOption, + GEO_COLOR_RANGE, + resolveGeoData, +} from './options/geo'; +export type { ResolvedGeoData } from './options/geo'; +export { mergeOption, REPLACE_MERGE_KEYS } from './options/merge'; +export { buildPieOption, isPieEmpty } from './options/pie'; + +// Palette, theme, formatting +export { formatTooltipValue, formatYAxisTick } from './format'; +export { CHART_PALETTE, getSeriesColor } from './palette'; +export { registerEChartsParts, registerGeoMap } from './register'; +export { buildChartTheme, DARK_CHART_THEME, LIGHT_CHART_THEME } from './theme'; +export { useIsDarkMode } from './use-is-dark-mode'; + +// Types +export type { ZoomWindow } from './options/common'; +export type { + BarChartProps, + CartesianChartProps, + ChartCommonProps, + GeoMapChartProps, + PieChartProps, +} from './props'; +export type { + CartesianBuildInput, + ChartAxisProps, + ChartLegendProps, + ChartOption, + ChartReferenceLine, + ChartSeries, + ChartSeriesType, + ChartTheme, + ChartTooltipProps, + ChartXAxisProps, + ChartYAxisProps, + GeoJson, + GeoMapBuildInput, + GeoMapDatum, + PieBuildInput, + PieDatum, +} from './types'; +export type { + DefaultLabelFormatterCallbackParams, + ECElementEvent, + TooltipComponentFormatterCallbackParams, +} from 'echarts'; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/line-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/line-chart.tsx new file mode 100644 index 000000000000..8152059454ce --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/line-chart.tsx @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CartesianChartBase } from './cartesian-chart-base'; +import { buildLineOption } from './options/cartesian'; +import type { CartesianChartProps } from './props'; + +/** Line chart: one line per series. */ +export const LineChart = (props: CartesianChartProps) => ( + +); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/node-safety.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/node-safety.test.ts new file mode 100644 index 000000000000..8d00af08fa03 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/node-safety.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @vitest-environment node +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { describe, expect, it } from 'vitest'; +import { + buildBarOption, + buildComposedOption, + buildLineOption, +} from './options/cartesian'; +import { buildPieOption } from './options/pie'; +import { buildChartTheme } from './theme'; + +// The Collate email renderer builds chart options in Node (echarts SSR), with +// no DOM. These builders must keep working there. +describe('option builders in a Node environment', () => { + const data = [ + { day: 'Mon', a: 1, b: 2 }, + { day: 'Tue', a: 3, b: 1 }, + ]; + const series = [ + { key: 'a', name: 'A' }, + { key: 'b', name: 'B', type: 'bar' as const }, + ]; + const theme = buildChartTheme(); + + it('has no DOM globals available', () => { + expect(typeof document).toBe('undefined'); + expect(typeof window).toBe('undefined'); + }); + + it('builds cartesian and pie options without a DOM', () => { + const input = { data, xKey: 'day' as const, series, ariaLabel: 'Chart' }; + + expect(buildLineOption(input, theme).series).toHaveLength(2); + expect(buildBarOption(input, theme).series).toHaveLength(2); + expect(buildComposedOption(input, theme).series).toHaveLength(2); + expect( + buildPieOption( + { data: [{ name: 'A', value: 1 }], ariaLabel: 'Pie' }, + theme + ).series + ).toHaveLength(1); + }); + + // The Collate renderer type-checks these files with no React installed, so + // even a type-only React import breaks its build. + it.each([ + 'options/cartesian.ts', + 'options/common.ts', + 'options/geo.ts', + 'options/merge.ts', + 'options/pie.ts', + 'format.ts', + 'palette.ts', + 'theme.ts', + 'types.ts', + ])('%s imports nothing from react', (file) => { + const source = readFileSync(resolve(__dirname, file), 'utf8'); + + expect(source).not.toMatch(/from ['"]react['"]/); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.test.ts new file mode 100644 index 000000000000..802887fadd5d --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.test.ts @@ -0,0 +1,596 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + BarSeriesOption, + DataZoomComponentOption, + LegendComponentOption, + LineSeriesOption, + TooltipComponentOption, + XAXisComponentOption, + YAXisComponentOption, +} from 'echarts'; +import { describe, expect, it } from 'vitest'; +import { CHART_PALETTE } from '../palette'; +import { DARK_CHART_THEME, LIGHT_CHART_THEME } from '../theme'; +import type { ChartOption } from '../types'; +import { + buildAreaOption, + buildBarOption, + buildComposedOption, + buildLineOption, + toNumberOrNull, +} from './cartesian'; + +interface Row { + day: string; + passed: number | null; + failed?: number | string | null; +} + +const rows: Row[] = [ + { day: 'Mon', passed: 3, failed: 1 }, + { day: 'Tue', passed: 5, failed: null }, + { day: 'Wed', passed: 4, failed: 2 }, +]; + +const base = { + data: rows, + xKey: 'day' as const, + ariaLabel: 'Test results', + series: [ + { key: 'passed', name: 'Passed' }, + { key: 'failed', name: 'Failed' }, + ], +}; + +const seriesOf = (option: ChartOption) => + option.series as Array; +const xAxisOf = (option: ChartOption) => option.xAxis as XAXisComponentOption; +const yAxesOf = (option: ChartOption) => + (Array.isArray(option.yAxis) + ? option.yAxis + : [option.yAxis]) as YAXisComponentOption[]; + +describe('toNumberOrNull', () => { + it('keeps finite numbers and numeric strings', () => { + expect(toNumberOrNull(4)).toBe(4); + expect(toNumberOrNull('2.5')).toBe(2.5); + expect(toNumberOrNull(0)).toBe(0); + }); + + it('turns missing and non-numeric values into null', () => { + expect(toNumberOrNull(' ')).toBeNull(); + expect(toNumberOrNull(true)).toBeNull(); + expect(toNumberOrNull([])).toBeNull(); + expect(toNumberOrNull(null)).toBeNull(); + expect(toNumberOrNull(undefined)).toBeNull(); + expect(toNumberOrNull('')).toBeNull(); + expect(toNumberOrNull('abc')).toBeNull(); + expect(toNumberOrNull(Number.NaN)).toBeNull(); + }); +}); + +describe('buildLineOption', () => { + it('maps rows onto a category x-axis and one series per key', () => { + const option = buildLineOption(base, LIGHT_CHART_THEME); + + expect(xAxisOf(option)).toMatchObject({ + type: 'category', + data: ['Mon', 'Tue', 'Wed'], + }); + expect(seriesOf(option).map((s) => [s.id, s.name, s.type])).toEqual([ + ['passed', 'Passed', 'line'], + ['failed', 'Failed', 'line'], + ]); + expect(seriesOf(option)[0].data).toEqual([3, 5, 4]); + }); + + it('leaves a gap for missing or non-numeric values instead of zero', () => { + const option = buildLineOption( + { ...base, data: [...rows, { day: 'Thu', passed: 1, failed: 'n/a' }] }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option)[1].data).toEqual([1, null, 2, null]); + }); + + it('pairs x and y values on a time axis', () => { + const option = buildLineOption( + { + data: [ + { ts: 1000, value: 1 }, + { ts: 2000, value: 3 }, + ], + xKey: 'ts', + ariaLabel: 'Runtime', + series: [{ key: 'value', name: 'Value' }], + xAxis: { type: 'time' }, + }, + LIGHT_CHART_THEME + ); + + expect(xAxisOf(option).type).toBe('time'); + expect(xAxisOf(option)).not.toHaveProperty('data'); + expect(seriesOf(option)[0].data).toEqual([ + [1000, 1], + [2000, 3], + ]); + }); + + it('cycles palette colours past 15 series and lets an explicit colour win', () => { + const series = Array.from({ length: 16 }, (_, i) => ({ + key: `s${i}`, + name: `S${i}`, + })); + series[1] = { ...series[1], color: '#123456' } as (typeof series)[number]; + const option = buildLineOption( + { data: [], xKey: 'x', ariaLabel: 'Many', series }, + LIGHT_CHART_THEME + ); + const colors = seriesOf(option).map( + (s) => (s.itemStyle as { color: string }).color + ); + + expect(colors[0]).toBe(CHART_PALETTE[0]); + expect(colors[1]).toBe('#123456'); + expect(colors[15]).toBe(CHART_PALETTE[0]); + }); + + it('hides dots and smooths lines unless the series says otherwise', () => { + const option = buildLineOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed' }, + { key: 'failed', name: 'Failed', smooth: false, showDots: true }, + ], + }, + LIGHT_CHART_THEME + ); + const [first, second] = seriesOf(option); + + expect([first.smooth, first.showSymbol]).toEqual([true, false]); + expect([second.smooth, second.showSymbol]).toEqual([false, true]); + }); + + it('shows the legend only when there is more than one series', () => { + const one = buildLineOption( + { ...base, series: [base.series[0]] }, + LIGHT_CHART_THEME + ); + const two = buildLineOption(base, LIGHT_CHART_THEME); + + expect((one.legend as LegendComponentOption).show).toBe(false); + expect(two.legend as LegendComponentOption).toMatchObject({ + show: true, + bottom: 0, + data: ['Passed', 'Failed'], + }); + }); + + it('puts the legend on top when asked', () => { + const option = buildLineOption( + { ...base, legend: { position: 'top' } }, + LIGHT_CHART_THEME + ); + + expect(option.legend as LegendComponentOption).toMatchObject({ top: 0 }); + }); + + it('binds the series key into a custom tooltip value formatter', () => { + const option = buildLineOption( + { + ...base, + tooltip: { valueFormatter: (value, key) => `${key}:${value}` }, + }, + LIGHT_CHART_THEME + ); + const tooltip = seriesOf(option)[1].tooltip as { + valueFormatter: (v: number) => string; + }; + + expect(tooltip.valueFormatter(7)).toBe('failed:7'); + }); + + it('uses the axis label as the axis name and applies axis formatters', () => { + const option = buildLineOption( + { + ...base, + xAxis: { label: 'Day', formatter: (v) => `d-${v}` }, + yAxis: { label: 'Count', formatter: (v) => `${v}!` }, + }, + LIGHT_CHART_THEME + ); + const xLabel = xAxisOf(option).axisLabel as { + formatter: (v: string) => string; + }; + const yLabel = yAxesOf(option)[0].axisLabel as { + formatter: (v: number) => string; + }; + + expect(xAxisOf(option).name).toBe('Day'); + expect(yAxesOf(option)[0].name).toBe('Count'); + expect(xLabel.formatter('Mon')).toBe('d-Mon'); + expect(yLabel.formatter(3)).toBe('3!'); + }); + + it('formats value ticks with K/M/B suffixes by default', () => { + const option = buildLineOption(base, LIGHT_CHART_THEME); + const yLabel = yAxesOf(option)[0].axisLabel as { + formatter: (v: number) => string; + }; + + expect(yLabel.formatter(2500)).toBe('2.5K'); + }); + + it('adds a second value axis for series on yAxisIndex 1', () => { + const option = buildLineOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed' }, + { key: 'failed', name: 'Failed', yAxisIndex: 1 }, + ], + yAxis: [{ label: 'Passed' }, { label: 'Failed' }], + }, + LIGHT_CHART_THEME + ); + const axes = yAxesOf(option); + + expect(axes.map((a) => [a.position, a.name])).toEqual([ + ['left', 'Passed'], + ['right', 'Failed'], + ]); + expect(seriesOf(option)[1].yAxisIndex).toBe(1); + }); + + it('draws grid lines from the left axis only when there are two', () => { + const option = buildLineOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed' }, + { key: 'failed', name: 'Failed', yAxisIndex: 1 }, + ], + yAxis: [{}, {}], + }, + LIGHT_CHART_THEME + ); + const [left, right] = yAxesOf(option); + + expect(left.splitLine).toMatchObject({ show: true }); + expect(right.splitLine).toMatchObject({ show: false }); + }); + + it('draws reference lines on their own hidden series, not a data series', () => { + const option = buildLineOption( + { + ...base, + referenceLines: [ + { axis: 'y', value: 80, label: 'Target', color: '#ff0000' }, + { axis: 'x', value: 'Tue' }, + ], + }, + LIGHT_CHART_THEME + ); + const all = seriesOf(option); + const reference = all[all.length - 1]; + const markLine = reference.markLine as { + symbol: string; + silent: boolean; + data: Array>; + }; + + expect(all).toHaveLength(3); + expect(reference).toMatchObject({ + id: '__reference-lines', + type: 'line', + data: [], + silent: true, + xAxisIndex: 0, + yAxisIndex: 0, + }); + expect(markLine.symbol).toBe('none'); + expect(markLine.data[0]).toMatchObject({ + yAxis: 80, + label: { formatter: 'Target' }, + lineStyle: { color: '#ff0000', type: 'dashed' }, + }); + expect(markLine.data[1]).toMatchObject({ xAxis: 'Tue' }); + expect(all[0].markLine).toBeUndefined(); + expect((option.legend as LegendComponentOption).data).toEqual([ + 'Passed', + 'Failed', + ]); + }); + + it('measures reference lines against the first value axis in a composed chart', () => { + const option = buildComposedOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed', type: 'line' }, + { key: 'failed', name: 'Failed', type: 'bar', yAxisIndex: 1 }, + ], + yAxis: [{}, {}], + referenceLines: [{ axis: 'y', value: 4 }], + }, + LIGHT_CHART_THEME + ); + const reference = seriesOf(option).find( + (s) => s.id === '__reference-lines' + ); + + expect(reference?.yAxisIndex).toBe(0); + }); + + it("turns on zoom with 'auto' only above 15 points", () => { + const make = (count: number) => + buildLineOption( + { + ...base, + data: Array.from({ length: count }, (_, i) => ({ + day: `d${i}`, + passed: i, + })), + zoom: 'auto', + }, + LIGHT_CHART_THEME + ); + + expect(make(15).dataZoom).toBeUndefined(); + expect(make(16).dataZoom as DataZoomComponentOption[]).toHaveLength(2); + }); + + it('turns zoom on for any size when zoom is true, off by default', () => { + expect( + buildLineOption({ ...base, zoom: true }, LIGHT_CHART_THEME).dataZoom + ).toBeDefined(); + expect(buildLineOption(base, LIGHT_CHART_THEME).dataZoom).toBeUndefined(); + }); + + it('merges a per-series seriesOption into that series only', () => { + const option = buildLineOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed', seriesOption: { step: 'end' } }, + { key: 'failed', name: 'Failed' }, + ], + }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option)[0].step).toBe('end'); + expect(seriesOf(option)[1].step).toBeUndefined(); + }); + + it('merges the option override last, replacing arrays', () => { + const option = buildLineOption( + { + ...base, + option: { + grid: { top: 99 }, + legend: { data: ['Only'] }, + }, + }, + LIGHT_CHART_THEME + ); + + expect(option.grid).toMatchObject({ top: 99, left: 8 }); + expect((option.legend as LegendComponentOption).data).toEqual(['Only']); + }); + + it('paints axes, grid and tooltip with the dark theme', () => { + const option = buildLineOption(base, DARK_CHART_THEME); + const tooltip = option.tooltip as TooltipComponentOption; + const yAxis = yAxesOf(option)[0]; + + expect(tooltip.backgroundColor).toBe(DARK_CHART_THEME.tooltipBg); + expect(tooltip.borderColor).toBe(DARK_CHART_THEME.tooltipBorder); + expect(yAxis.splitLine).toMatchObject({ + lineStyle: { color: DARK_CHART_THEME.grid }, + }); + expect((yAxis.axisLabel as { color: string }).color).toBe( + DARK_CHART_THEME.axisTick + ); + }); + + it('attaches the tooltip to the body with the default value formatter', () => { + const tooltip = buildLineOption(base, LIGHT_CHART_THEME) + .tooltip as TooltipComponentOption & { + valueFormatter: (v: number) => string; + }; + + expect(tooltip.trigger).toBe('axis'); + expect(tooltip.appendTo).toBe('body'); + expect(tooltip.valueFormatter(1.23456)).toBe('1.23'); + }); + + it('describes the chart for screen readers with its series names', () => { + const option = buildLineOption(base, LIGHT_CHART_THEME); + + expect(option.aria).toEqual({ + enabled: true, + label: { description: 'Test results. Passed, Failed' }, + }); + }); +}); + +describe('buildAreaOption', () => { + it('fills each line with a fading gradient of its colour', () => { + const option = buildAreaOption(base, LIGHT_CHART_THEME); + const area = seriesOf(option)[0].areaStyle as { + color: { colorStops: Array<{ color: string }> }; + }; + + expect(seriesOf(option)[0].type).toBe('line'); + expect(area.color.colorStops.map((s) => s.color)).toEqual([ + 'rgba(21, 112, 239, 0.2)', + 'rgba(21, 112, 239, 0)', + ]); + }); + + it('stacks series that share a stack id', () => { + const option = buildAreaOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed', stack: 'total' }, + { key: 'failed', name: 'Failed', stack: 'total' }, + ], + }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option).map((s) => s.stack)).toEqual(['total', 'total']); + }); +}); + +describe('buildBarOption', () => { + it('builds rounded bars with a max width', () => { + const option = buildBarOption(base, LIGHT_CHART_THEME); + const bar = seriesOf(option)[0]; + + expect(bar.type).toBe('bar'); + expect(bar.barMaxWidth).toBe(60); + expect((bar.itemStyle as { borderRadius: number[] }).borderRadius).toEqual([ + 4, 4, 0, 0, + ]); + }); + + it('squares stacked bars unless a radius is given', () => { + const stacked = buildBarOption( + { + ...base, + series: [{ key: 'passed', name: 'Passed', stack: 'a' }], + }, + LIGHT_CHART_THEME + ); + const rounded = buildBarOption( + { + ...base, + radius: 2, + series: [{ key: 'passed', name: 'Passed', stack: 'a' }], + }, + LIGHT_CHART_THEME + ); + const radiusOf = (o: ChartOption) => + (seriesOf(o)[0].itemStyle as { borderRadius: number[] }).borderRadius; + + expect(radiusOf(stacked)).toEqual([0, 0, 0, 0]); + expect(radiusOf(rounded)).toEqual([2, 2, 0, 0]); + }); + + it('swaps the axes for a horizontal layout', () => { + const option = buildBarOption( + { ...base, layout: 'horizontal' }, + LIGHT_CHART_THEME + ); + const xAxes = ( + Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis] + ) as XAXisComponentOption[]; + + expect(xAxes[0].type).toBe('value'); + // inverse puts the first row at the top, as a ranked list reads. + expect(yAxesOf(option)[0]).toMatchObject({ + type: 'category', + data: ['Mon', 'Tue', 'Wed'], + inverse: true, + }); + expect( + (seriesOf(option)[0].itemStyle as { borderRadius: number[] }).borderRadius + ).toEqual([0, 4, 4, 0]); + }); + + it('colours each bar from getBarColor', () => { + const option = buildBarOption( + { + ...base, + series: [base.series[0]], + getBarColor: (row: Row) => (row.passed === 5 ? '#00ff00' : undefined), + }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option)[0].data).toEqual([ + 3, + { value: 5, itemStyle: { color: '#00ff00' } }, + 4, + ]); + }); + + it('shows value labels above vertical bars and beside horizontal ones', () => { + const vertical = buildBarOption( + { ...base, showValueLabels: true }, + LIGHT_CHART_THEME + ); + const horizontal = buildBarOption( + { + ...base, + layout: 'horizontal', + showValueLabels: (p) => `${p.value}%`, + }, + LIGHT_CHART_THEME + ); + const hLabel = seriesOf(horizontal)[0].label as { + position: string; + formatter: (p: { value: number }) => string; + }; + + expect(seriesOf(vertical)[0].label).toMatchObject({ + show: true, + position: 'top', + }); + expect(hLabel.position).toBe('right'); + expect(hLabel.formatter({ value: 12 })).toBe('12%'); + }); +}); + +describe('buildComposedOption', () => { + it('uses each series type and draws lines above bars', () => { + const option = buildComposedOption( + { + ...base, + series: [ + { key: 'failed', name: 'Failed', type: 'line' }, + { key: 'passed', name: 'Passed', type: 'bar' }, + ], + }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option).map((s) => [s.type, s.z])).toEqual([ + ['bar', 1], + ['line', 3], + ]); + }); + + it('defaults a series without a type to line and supports area', () => { + const option = buildComposedOption( + { + ...base, + series: [ + { key: 'passed', name: 'Passed', type: 'area' }, + { key: 'failed', name: 'Failed' }, + ], + }, + LIGHT_CHART_THEME + ); + + expect(seriesOf(option).map((s) => s.type)).toEqual(['line', 'line']); + expect(seriesOf(option)[0].areaStyle).toBeDefined(); + expect(seriesOf(option)[1].areaStyle).toBeUndefined(); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.ts new file mode 100644 index 000000000000..542ff838f904 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/cartesian.ts @@ -0,0 +1,298 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + BarSeriesOption, + LineSeriesOption, + XAXisComponentOption, + YAXisComponentOption, +} from 'echarts'; +import { getSeriesColor } from '../palette'; +import type { + CartesianBuildInput, + ChartOption, + ChartSeries, + ChartSeriesType, + ChartTheme, + ChartYAxisProps, +} from '../types'; +import { + areaGradient, + categoryAxis, + dataZoomFor, + DATAZOOM_THRESHOLD, + gridFor, + legendConfig, + referenceLinesToMarkLine, + tooltipConfig, + valueAxis, +} from './common'; +import { mergeOption } from './merge'; + +const BAR_MAX_WIDTH = 60; +const DEFAULT_BAR_RADIUS = 4; +const LINE_WIDTH = 2.5; +const Z_BAR = 1; +const Z_LINE = 3; + +type Datum = Record; +type CartesianSeriesOption = LineSeriesOption | BarSeriesOption; + +/** Missing or non-numeric values become `null`, which ECharts draws as a gap. */ +export const toNumberOrNull = (value: unknown): number | null => { + const isNumericString = typeof value === 'string' && value.trim() !== ''; + const n = + typeof value === 'number' || isNumericString ? Number(value) : Number.NaN; + + return Number.isFinite(n) ? n : null; +}; + +interface SeriesContext { + input: CartesianBuildInput; + isTime: boolean; + horizontal: boolean; + composed: boolean; +} + +const pointValue = ( + ctx: SeriesContext, + datum: T, + key: string +) => { + const value = toNumberOrNull((datum as Datum)[key]); + + return ctx.isTime ? [(datum as Datum)[ctx.input.xKey], value] : value; +}; + +const barRadius = (radius: number, horizontal: boolean) => + horizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0]; + +const barLabel = ({ + input, + horizontal, +}: SeriesContext) => { + const { showValueLabels } = input; + if (!showValueLabels) { + return undefined; + } + + return { + show: true, + position: horizontal ? 'right' : 'top', + ...(typeof showValueLabels === 'function' + ? { formatter: showValueLabels } + : {}), + } as BarSeriesOption['label']; +}; + +const barSeries = ( + ctx: SeriesContext, + series: ChartSeries, + color: string +): BarSeriesOption => { + const { input, horizontal } = ctx; + const radius = input.radius ?? (series.stack ? 0 : DEFAULT_BAR_RADIUS); + + return { + type: 'bar', + barMaxWidth: BAR_MAX_WIDTH, + itemStyle: { color, borderRadius: barRadius(radius, horizontal) }, + label: barLabel(ctx), + ...(ctx.composed ? { z: Z_BAR } : {}), + data: input.data.map((datum, index) => { + const value = pointValue(ctx, datum, series.key); + const barColor = input.getBarColor?.(datum, index); + + return barColor ? { value, itemStyle: { color: barColor } } : value; + }) as BarSeriesOption['data'], + }; +}; + +const lineSeries = ( + ctx: SeriesContext, + series: ChartSeries, + color: string, + filled: boolean +): LineSeriesOption => ({ + type: 'line', + smooth: series.smooth ?? true, + showSymbol: series.showDots ?? false, + lineStyle: { color, width: LINE_WIDTH, cap: 'round', join: 'round' }, + itemStyle: { color }, + // Always set, so a re-render that drops the fill clears the old one. + areaStyle: filled ? { color: areaGradient(color) } : undefined, + ...(ctx.composed ? { z: Z_LINE } : {}), + data: ctx.input.data.map((datum) => + pointValue(ctx, datum, series.key) + ) as LineSeriesOption['data'], +}); + +const buildSeries = ( + ctx: SeriesContext, + series: ChartSeries, + index: number, + type: ChartSeriesType +): CartesianSeriesOption => { + const color = series.color ?? getSeriesColor(index); + const body = + type === 'bar' + ? barSeries(ctx, series, color) + : lineSeries(ctx, series, color, type === 'area'); + const { valueFormatter } = ctx.input.tooltip ?? {}; + const common = { + id: series.key, + name: series.name, + stack: series.stack, + [ctx.horizontal ? 'xAxisIndex' : 'yAxisIndex']: series.yAxisIndex ?? 0, + // Always set, so a re-render that drops the formatter clears the old one. + tooltip: valueFormatter + ? { + valueFormatter: (value: number | string) => + valueFormatter(value, series.key), + } + : undefined, + }; + + return mergeOption( + { ...common, ...body } as CartesianSeriesOption, + series.seriesOption + ); +}; + +const valueAxes = ( + theme: ChartTheme, + yAxis: CartesianBuildInput['yAxis'], + horizontal: boolean +): YAXisComponentOption[] => { + const props: ChartYAxisProps[] = Array.isArray(yAxis) ? yAxis : [yAxis ?? {}]; + + return props.map((axis, index) => + valueAxis(theme, axis, horizontal ? 'bottom' : index ? 'right' : 'left') + ); +}; + +export const REFERENCE_SERIES_ID = '__reference-lines'; + +/** + * Reference lines ride on their own empty series on the first axes. On a data + * series they would vanish when the user hides that series through the + * legend, and would be measured against that series' axis. Not listed in the + * legend, so it cannot be toggled. + */ +const referenceSeries = ( + input: CartesianBuildInput, + theme: ChartTheme +): LineSeriesOption[] => + input.referenceLines?.length + ? [ + { + id: REFERENCE_SERIES_ID, + type: 'line', + data: [], + silent: true, + xAxisIndex: 0, + yAxisIndex: 0, + tooltip: { show: false }, + markLine: referenceLinesToMarkLine(input.referenceLines, theme), + }, + ] + : []; + +// Lines are drawn after bars so they sit on top in a composed chart. +const linesLast = (series: CartesianSeriesOption[]) => + [...series].sort( + (a, b) => Number(a.type === 'line') - Number(b.type === 'line') + ); + +/** + * Builds the ECharts option for a line, area, bar or composed chart. + * `defaultType` is the type of a series that does not set its own — only a + * composed chart lets series choose. + */ +export const buildCartesianOption = ( + input: CartesianBuildInput, + theme: ChartTheme, + defaultType: ChartSeriesType, + composed = false +): ChartOption => { + const isTime = input.xAxis?.type === 'time'; + const horizontal = input.layout === 'horizontal'; + const ctx: SeriesContext = { input, isTime, horizontal, composed }; + const built = input.series.map((series, index) => + buildSeries( + ctx, + series, + index, + composed ? series.type ?? defaultType : defaultType + ) + ); + const series = [ + ...(composed ? linesLast(built) : built), + ...referenceSeries(input, theme), + ]; + const names = input.series.map((s) => s.name); + const legend = legendConfig(names, theme, input.legend); + const hasZoom = + input.zoom === true || + (input.zoom === 'auto' && input.data.length > DATAZOOM_THRESHOLD); + const categories = isTime + ? undefined + : input.data.map((datum) => String((datum as Datum)[input.xKey])); + const category = categoryAxis(categories, theme, input.xAxis, horizontal); + const values = valueAxes(theme, input.yAxis, horizontal); + const valueSlot = values.length === 1 ? values[0] : values; + const layout = { legend, horizontal }; + + const option: ChartOption = { + aria: { + enabled: true, + label: { description: `${input.ariaLabel}. ${names.join(', ')}` }, + }, + grid: gridFor({ ...layout, hasZoom }), + tooltip: { + ...tooltipConfig('axis', theme, input.tooltip), + axisPointer: { type: defaultType === 'bar' ? 'shadow' : 'line' }, + }, + legend, + xAxis: horizontal + ? (valueSlot as XAXisComponentOption) + : (category as XAXisComponentOption), + yAxis: horizontal + ? (category as YAXisComponentOption) + : (valueSlot as YAXisComponentOption), + ...(hasZoom ? { dataZoom: dataZoomFor(input.data.length, layout) } : {}), + series, + }; + + return mergeOption(option, input.option); +}; + +export const buildLineOption = ( + input: CartesianBuildInput, + theme: ChartTheme +) => buildCartesianOption(input, theme, 'line'); + +export const buildAreaOption = ( + input: CartesianBuildInput, + theme: ChartTheme +) => buildCartesianOption(input, theme, 'area'); + +export const buildBarOption = ( + input: CartesianBuildInput, + theme: ChartTheme +) => buildCartesianOption(input, theme, 'bar'); + +export const buildComposedOption = ( + input: CartesianBuildInput, + theme: ChartTheme +) => buildCartesianOption(input, theme, 'line', true); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/common.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/common.ts new file mode 100644 index 000000000000..8b47d8df4be4 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/common.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + DataZoomComponentOption, + GridComponentOption, + LegendComponentOption, + MarkLineComponentOption, + TooltipComponentOption, + XAXisComponentOption, + YAXisComponentOption, +} from 'echarts'; +import { formatTooltipValue, formatYAxisTick } from '../format'; +import type { + ChartAxisProps, + ChartLegendProps, + ChartReferenceLine, + ChartTheme, + ChartTooltipProps, +} from '../types'; +import { mergeOption } from './merge'; + +/** Above this many points `zoom: 'auto'` adds a dataZoom window. */ +export const DATAZOOM_THRESHOLD = 15; + +const SLIDER_HEIGHT = 28; +const SLIDER_GAP = 8; +const LEGEND_BAND = 28; +const TOOLTIP_MAX_HEIGHT = 320; + +interface LinearGradient { + type: 'linear'; + x: number; + y: number; + x2: number; + y2: number; + colorStops: Array<{ offset: number; color: string }>; +} + +/** `#RGB` / `#RRGGBB` → `rgba(r, g, b, alpha)`. */ +export const hexToRgba = (hex: string, alpha: number): string => { + const clean = hex.replace('#', ''); + const full = + clean.length === 3 + ? clean + .split('') + .map((c) => c + c) + .join('') + : clean; + const channel = (start: number) => + Number.parseInt(full.slice(start, start + 2), 16) || 0; + + return `rgba(${channel(0)}, ${channel(2)}, ${channel(4)}, ${alpha})`; +}; + +/** Area fill: the series colour at 20% opacity fading to transparent. */ +export const areaGradient = (color: string): LinearGradient => ({ + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: hexToRgba(color, 0.2) }, + { offset: 1, color: hexToRgba(color, 0) }, + ], +}); + +export const tooltipConfig = ( + trigger: 'axis' | 'item', + theme: ChartTheme, + props: ChartTooltipProps = {} +): TooltipComponentOption => ({ + show: props.show ?? true, + trigger, + // Appended to so a card with `overflow: hidden` cannot clip it. + appendTo: 'body', + backgroundColor: theme.tooltipBg, + borderColor: theme.tooltipBorder, + textStyle: { color: theme.tooltipText }, + extraCssText: `max-height:${TOOLTIP_MAX_HEIGHT}px;overflow:auto;`, + valueFormatter: (value) => formatTooltipValue(value), + ...(props.formatter ? { formatter: props.formatter } : {}), +}); + +const axisRest = ({ + label: _label, + formatter: _formatter, + ...rest +}: ChartAxisProps & { type?: unknown }) => { + const { type: _type, ...withoutType } = rest as Record; + + return withoutType; +}; + +export const categoryAxis = ( + categories: string[] | undefined, + theme: ChartTheme, + props: ChartAxisProps & { + type?: 'category' | 'time'; + } = {}, + inverse = false +): XAXisComponentOption => { + const isTime = props.type === 'time'; + const base: XAXisComponentOption = { + type: isTime ? 'time' : 'category', + ...(isTime ? {} : { data: categories }), + ...(inverse ? { inverse: true } : {}), + name: props.label, + nameLocation: 'middle', + nameGap: 32, + nameTextStyle: { color: theme.xAxisTitle, fontSize: 12, fontWeight: 600 }, + axisLabel: { + color: theme.axisTick, + hideOverlap: true, + ...(props.formatter ? { formatter: props.formatter } : {}), + }, + axisTick: { show: false }, + }; + + return mergeOption(base, axisRest(props)); +}; + +export const valueAxis = ( + theme: ChartTheme, + props: ChartAxisProps = {}, + position: 'left' | 'right' | 'bottom' = 'left' +): YAXisComponentOption => { + const vertical = position !== 'bottom'; + const base = { + type: 'value', + position, + name: props.label, + nameLocation: 'middle', + nameGap: vertical ? 44 : 32, + nameRotate: vertical ? (position === 'left' ? 90 : -90) : 0, + nameTextStyle: { color: theme.axisTitle, fontSize: 12, fontWeight: 500 }, + axisLabel: { + color: theme.axisTick, + formatter: props.formatter ?? ((value: number) => formatYAxisTick(value)), + }, + // A right-hand axis would draw a second, misaligned set of grid lines. + splitLine: { show: position !== 'right', lineStyle: { color: theme.grid } }, + } as YAXisComponentOption; + + return mergeOption(base, axisRest(props)); +}; + +export const legendConfig = ( + names: string[], + theme: ChartTheme, + props: ChartLegendProps = {} +): LegendComponentOption => ({ + show: props.show ?? names.length > 1, + type: 'scroll', + [props.position ?? 'bottom']: 0, + data: names, + textStyle: { color: theme.axisText }, +}); + +export interface GridLayout { + hasZoom: boolean; + legend?: LegendComponentOption; + /** Horizontal bars put the zoom slider on the right edge. */ + horizontal?: boolean; +} + +/** + * Plot area. `outerBoundsMode: 'same'` keeps tick labels and axis names inside + * the rect — the ECharts 6 replacement for `containLabel`, which needs the + * legacy module under `echarts/core`. Bands for the legend and zoom slider + * are reserved outside it so they never overlap the labels. + */ +export const gridFor = ({ + hasZoom, + legend, + horizontal, +}: GridLayout): GridComponentOption => { + const legendShown = Boolean(legend?.show); + const legendTop = legendShown && legend?.top === 0; + const legendBottom = legendShown && !legendTop; + const zoomBand = hasZoom ? SLIDER_HEIGHT + SLIDER_GAP : 0; + + return { + left: 8, + right: 16 + (horizontal ? zoomBand : 0), + top: 16 + (legendTop ? LEGEND_BAND : 0), + bottom: 8 + (horizontal ? 0 : zoomBand) + (legendBottom ? LEGEND_BAND : 0), + outerBoundsMode: 'same', + outerBoundsContain: 'all', + }; +}; + +export const dataZoomFor = ( + pointCount: number, + { legend, horizontal }: Omit +): DataZoomComponentOption[] => { + const end = Math.min(100, (DATAZOOM_THRESHOLD / pointCount) * 100); + const legendBottom = Boolean(legend?.show) && legend?.top !== 0; + const axis = horizontal ? { yAxisIndex: 0 } : { xAxisIndex: 0 }; + const slider = horizontal + ? { right: SLIDER_GAP, width: SLIDER_HEIGHT } + : { + bottom: (legendBottom ? LEGEND_BAND : 0) + SLIDER_GAP, + height: SLIDER_HEIGHT, + }; + + // Fixed ids let a re-render re-apply the user's window (applyZoomWindow). + return [ + { id: 'zoom-inside', type: 'inside', start: 0, end, ...axis }, + { id: 'zoom-slider', type: 'slider', start: 0, end, ...axis, ...slider }, + ]; +}; + +export interface ZoomWindow { + start: number; + end: number; +} + +/** + * Sets every dataZoom in `option` to `window`, so a rebuilt option keeps the + * range the user zoomed to instead of jumping back to the default. + */ +export const applyZoomWindow = ( + option: O, + window?: ZoomWindow +): O => { + if (!window || !Array.isArray(option.dataZoom)) { + return option; + } + + return { + ...option, + dataZoom: option.dataZoom.map((zoom) => ({ ...zoom, ...window })), + }; +}; + +export const referenceLinesToMarkLine = ( + lines: ChartReferenceLine[], + theme: ChartTheme +): MarkLineComponentOption => ({ + symbol: 'none', + silent: true, + data: lines.map((line) => ({ + [line.axis === 'x' ? 'xAxis' : 'yAxis']: line.value, + label: { + show: Boolean(line.label), + formatter: line.label, + position: 'insideEndTop', + color: theme.axisText, + }, + lineStyle: { + color: line.color ?? theme.axisText, + type: 'dashed', + width: 1, + }, + })), +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.test.ts new file mode 100644 index 000000000000..ebbec3384fb0 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + MapSeriesOption, + TooltipComponentOption, + VisualMapComponentOption, +} from 'echarts'; +import { describe, expect, it } from 'vitest'; +import { DARK_CHART_THEME, LIGHT_CHART_THEME } from '../theme'; +import type { GeoMapBuildInput } from '../types'; +import { buildGeoMapOption, GEO_COLOR_RANGE, resolveGeoData } from './geo'; + +const ALIASES: Record = { + CA: 'California', + TX: 'Texas', +}; +const resolveAlias = (raw: string) => ALIASES[raw] ?? undefined; + +const base: GeoMapBuildInput = { + ariaLabel: 'Revenue by state', + mapName: 'USA#abc', + data: [ + { region: 'California', value: 10 }, + { region: 'Texas', value: 4 }, + ], +}; + +const mapOf = (input: GeoMapBuildInput, theme = LIGHT_CHART_THEME) => + (buildGeoMapOption(input, theme).option.series as MapSeriesOption[])[0]; +const scaleOf = (input: GeoMapBuildInput) => + buildGeoMapOption(input, LIGHT_CHART_THEME).option + .visualMap as VisualMapComponentOption & { + min: number; + max: number; + show: boolean; + inRange: { color: string[] }; + }; + +describe('resolveGeoData', () => { + it('uses the raw region as the feature name by default', () => { + expect(resolveGeoData(base.data).data).toEqual([ + { name: 'California', value: 10 }, + { name: 'Texas', value: 4 }, + ]); + }); + + it('resolves raw values through resolveRegion', () => { + const { data } = resolveGeoData( + [ + { region: 'CA', value: 3 }, + { region: 'TX', value: 1 }, + ], + resolveAlias + ); + + expect(data).toEqual([ + { name: 'California', value: 3 }, + { name: 'Texas', value: 1 }, + ]); + }); + + it('sums rows that resolve to the same region', () => { + const { data } = resolveGeoData( + [ + { region: 'CA', value: 3 }, + { region: 'California', value: 2 }, + ], + (raw) => resolveAlias(raw) ?? raw + ); + + expect(data).toEqual([{ name: 'California', value: 5 }]); + }); + + it('skips blank regions and non-numeric values', () => { + const { data, unmatched } = resolveGeoData([ + { region: ' ', value: 3 }, + { region: 'Texas', value: Number.NaN }, + { region: 'California', value: 1 }, + ]); + + expect(data).toEqual([{ name: 'California', value: 1 }]); + expect(unmatched).toEqual([]); + }); + + it('collects each unmatched raw value once', () => { + const { unmatched } = resolveGeoData( + [ + { region: 'ZZ', value: 1 }, + { region: 'ZZ', value: 2 }, + { region: 'CA', value: 1 }, + { region: 'Atlantis', value: 1 }, + ], + resolveAlias + ); + + expect(unmatched).toEqual(['ZZ', 'Atlantis']); + }); +}); + +describe('buildGeoMapOption', () => { + it('draws a map series on the registered map with the resolved data', () => { + const map = mapOf(base); + + expect(map).toMatchObject({ + type: 'map', + map: 'USA#abc', + roam: false, + data: [ + { name: 'California', value: 10 }, + { name: 'Texas', value: 4 }, + ], + }); + }); + + it('returns the unmatched raw values next to the option', () => { + const { unmatched } = buildGeoMapOption( + { + ...base, + data: [{ region: 'ZZ', value: 1 }], + resolveRegion: resolveAlias, + }, + LIGHT_CHART_THEME + ); + + expect(unmatched).toEqual(['ZZ']); + }); + + it("spans the colour scale over the data's own range", () => { + const scale = scaleOf(base); + + expect([scale.min, scale.max]).toEqual([4, 10]); + expect(scale.inRange.color).toEqual([...GEO_COLOR_RANGE]); + }); + + it('widens the scale by one either side when every value is equal', () => { + const scale = scaleOf({ + ...base, + data: [ + { region: 'California', value: 5 }, + { region: 'Texas', value: 5 }, + ], + }); + + expect([scale.min, scale.max]).toEqual([4, 6]); + }); + + it('uses a custom colour range', () => { + expect( + scaleOf({ ...base, colorRange: ['#fff', '#000'] }).inRange.color + ).toEqual(['#fff', '#000']); + }); + + it('hides the scale and its bottom band when showScale is false', () => { + const hidden = { ...base, showScale: false }; + + expect(scaleOf(hidden).show).toBe(false); + expect(mapOf(hidden).bottom).toBe(0); + expect(mapOf(base).bottom).toBeGreaterThan(0); + }); + + it('fills regions without data and draws borders from the theme', () => { + expect(mapOf(base, LIGHT_CHART_THEME).itemStyle).toMatchObject({ + areaColor: LIGHT_CHART_THEME.emptyFill, + borderColor: LIGHT_CHART_THEME.segmentBorder, + }); + expect(mapOf(base, DARK_CHART_THEME).itemStyle).toMatchObject({ + areaColor: DARK_CHART_THEME.emptyFill, + borderColor: DARK_CHART_THEME.segmentBorder, + }); + }); + + it('does not leave a clicked region stuck in the selected colour', () => { + expect(mapOf(base).selectedMode).toBe(false); + }); + + it('uses an item tooltip', () => { + const tooltip = buildGeoMapOption(base, LIGHT_CHART_THEME).option + .tooltip as TooltipComponentOption; + + expect(tooltip.trigger).toBe('item'); + }); + + it('describes the chart with the regions it shades', () => { + expect(buildGeoMapOption(base, LIGHT_CHART_THEME).option.aria).toEqual({ + enabled: true, + label: { description: 'Revenue by state. California, Texas' }, + }); + }); + + it('merges the option override last', () => { + const option = buildGeoMapOption( + { ...base, option: { visualMap: { left: 'right' } } }, + LIGHT_CHART_THEME + ).option; + + expect(option.visualMap).toMatchObject({ left: 'right', min: 4 }); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.ts new file mode 100644 index 000000000000..b283f4288b8a --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/geo.ts @@ -0,0 +1,147 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { MapSeriesOption, VisualMapComponentOption } from 'echarts'; +import { CHART_PALETTE } from '../palette'; +import type { + ChartOption, + ChartTheme, + GeoMapBuildInput, + GeoMapDatum, +} from '../types'; +import { tooltipConfig } from './common'; +import { mergeOption } from './merge'; + +/** Default scale: light blue → the primary palette colour. */ +export const GEO_COLOR_RANGE: readonly string[] = [ + '#e3edfd', + '#93b8f9', + CHART_PALETTE[0], +]; + +// Room under the map for the scale bar (24px) plus its end labels above it. +const SCALE_BAND = 76; +const SCALE_THICKNESS = 24; +const SCALE_LENGTH = 220; + +export interface ResolvedGeoData { + /** One entry per GeoJSON feature, values summed across rows. */ + data: Array<{ name: string; value: number }>; + /** Raw region values that resolved to no feature, each listed once. */ + unmatched: string[]; +} + +const identity = (raw: string) => raw; + +/** + * Resolves raw region values to feature names and sums rows per region: a map + * shades a region once, so without summing the last row naming it would win. + * Rows with a blank region or a non-numeric value are skipped. + */ +export const resolveGeoData = ( + rows: GeoMapDatum[], + resolveRegion: (raw: string) => string | undefined = identity +): ResolvedGeoData => { + const totals = new Map(); + const unmatched: string[] = []; + for (const { region, value } of rows) { + const raw = String(region ?? ''); + const usable = raw.trim() !== '' && Number.isFinite(value); + const name = usable ? resolveRegion(raw) : undefined; + if (usable && name === undefined && !unmatched.includes(raw)) { + unmatched.push(raw); + } + if (name !== undefined) { + totals.set(name, (totals.get(name) ?? 0) + value); + } + } + + return { + data: Array.from(totals, ([name, value]) => ({ name, value })), + unmatched, + }; +}; + +// The data's own extent, not clamped to 0, so close values still read as +// different shades. A zero-width domain is widened so the scale still works. +const scaleDomain = (values: number[]) => { + if (!values.length) { + return { min: 0, max: 1 }; + } + const min = Math.min(...values); + const max = Math.max(...values); + + return min === max ? { min: min - 1, max: max + 1 } : { min, max }; +}; + +/** + * Builds a choropleth over a map the caller registered under `mapName`. + * Returns the raw region values that matched no feature next to the option. + */ +export const buildGeoMapOption = ( + input: GeoMapBuildInput, + theme: ChartTheme +): { option: ChartOption; unmatched: string[] } => { + const { data, unmatched } = resolveGeoData(input.data, input.resolveRegion); + const showScale = input.showScale ?? true; + const visualMap: VisualMapComponentOption = { + type: 'continuous', + ...scaleDomain(data.map((point) => point.value)), + show: showScale, + calculable: true, + orient: 'horizontal', + left: 'center', + bottom: 8, + // Horizontal orientation rotates the bar, so itemHeight is its length. + itemWidth: SCALE_THICKNESS, + itemHeight: SCALE_LENGTH, + textStyle: { color: theme.axisText }, + inRange: { color: [...(input.colorRange ?? GEO_COLOR_RANGE)] }, + }; + const series: MapSeriesOption = { + type: 'map', + id: 'geo-map', + map: input.mapName, + roam: false, + // Clicks go to onRegionClick; a sticky selection would hide the shade. + selectedMode: false, + // Fit the map across the whole box, reserving a band for the scale. + left: 0, + right: 0, + top: 0, + bottom: showScale ? SCALE_BAND : 0, + data, + itemStyle: { + areaColor: theme.emptyFill, + borderColor: theme.segmentBorder, + borderWidth: 0.5, + }, + emphasis: { label: { show: true } }, + label: { show: false }, + }; + + const option: ChartOption = { + aria: { + enabled: true, + label: { + description: `${input.ariaLabel}. ${data + .map((point) => point.name) + .join(', ')}`, + }, + }, + tooltip: tooltipConfig('item', theme, input.tooltip), + visualMap, + series: [series], + }; + + return { option: mergeOption(option, input.option), unmatched }; +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.test.ts new file mode 100644 index 000000000000..28402ec4fa06 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { mergeOption } from './merge'; + +describe('mergeOption', () => { + it('merges nested objects key by key', () => { + expect( + mergeOption({ grid: { top: 1, left: 2 } }, { grid: { top: 9 } }) + ).toEqual({ grid: { top: 9, left: 2 } }); + }); + + it('replaces arrays instead of merging them', () => { + expect(mergeOption({ data: [1, 2, 3] }, { data: [4] })).toEqual({ + data: [4], + }); + }); + + it('keeps the base value where the override is undefined', () => { + expect(mergeOption({ a: 1, b: 2 }, { a: undefined, b: 3 })).toEqual({ + a: 1, + b: 3, + }); + }); + + it('does not mutate its inputs', () => { + const base = { grid: { top: 1 } }; + mergeOption(base, { grid: { top: 2 } }); + + expect(base).toEqual({ grid: { top: 1 } }); + }); + + it('returns the base when there is no override', () => { + const base = { a: 1 }; + + expect(mergeOption(base)).toBe(base); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.ts new file mode 100644 index 000000000000..0d917e1d3f38 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/merge.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype; + +/** + * Deep-merges `override` into `base` without mutating either. Plain objects + * merge key by key; arrays, functions and other values replace; `undefined` + * in the override keeps the base value. + */ +export const mergeOption = (base: T, override?: unknown): T => { + if (!isPlainObject(base) || !isPlainObject(override)) { + return override === undefined ? base : (override as T); + } + const result: Record = { ...base }; + for (const [key, value] of Object.entries(override)) { + result[key] = mergeOption(result[key], value); + } + + return result as T; +}; + +/** + * `setOption` `replaceMerge` keys for re-renders: these components are + * replaced from the new option (so removed series, axes, zoom and colour + * scales go away), while the legend is merged so series a user hid stay + * hidden. + */ +export const REPLACE_MERGE_KEYS = [ + 'series', + 'xAxis', + 'yAxis', + 'grid', + 'dataZoom', + 'visualMap', +]; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.test.ts new file mode 100644 index 000000000000..5a4ac9cae038 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LegendComponentOption, + PieSeriesOption, + TooltipComponentOption, +} from 'echarts'; +import { describe, expect, it } from 'vitest'; +import { CHART_PALETTE } from '../palette'; +import { DARK_CHART_THEME, LIGHT_CHART_THEME } from '../theme'; +import type { ChartOption, PieDatum } from '../types'; +import { buildPieOption, isPieEmpty } from './pie'; + +const data: PieDatum[] = [ + { name: 'Success', value: 6, color: '#00aa00' }, + { name: 'Failed', value: 3 }, + { name: 'Aborted', value: 1 }, +]; + +const base = { data, ariaLabel: 'Test status' }; + +const pieOf = (option: ChartOption) => (option.series as PieSeriesOption[])[0]; + +describe('buildPieOption', () => { + it('builds one slice per datum with explicit or palette colours', () => { + const slices = pieOf(buildPieOption(base, LIGHT_CHART_THEME)) + .data as Array<{ name: string; value: number; itemStyle: object }>; + + expect(slices).toEqual([ + { name: 'Success', value: 6, itemStyle: { color: '#00aa00' } }, + { name: 'Failed', value: 3, itemStyle: { color: CHART_PALETTE[1] } }, + { name: 'Aborted', value: 1, itemStyle: { color: CHART_PALETTE[2] } }, + ]); + }); + + it('is a full pie by default and a donut with innerRadius', () => { + expect(pieOf(buildPieOption(base, LIGHT_CHART_THEME)).radius).toEqual([ + 0, + '72%', + ]); + expect( + pieOf(buildPieOption({ ...base, innerRadius: '55%' }, LIGHT_CHART_THEME)) + .radius + ).toEqual(['55%', '72%']); + }); + + it('shows whole-percent slice labels only when asked', () => { + const hidden = pieOf(buildPieOption(base, LIGHT_CHART_THEME)); + const shown = pieOf( + buildPieOption({ ...base, showLabels: true }, LIGHT_CHART_THEME) + ); + const label = shown.label as { + show: boolean; + formatter: (p: { percent: number }) => string; + }; + + expect(hidden.label).toEqual({ show: false }); + expect(hidden.labelLine).toEqual({ show: false }); + expect(label.show).toBe(true); + expect(label.formatter({ percent: 33.4 })).toBe('33%'); + }); + + it('lists slice names in a legend shown by default', () => { + const legend = buildPieOption(base, LIGHT_CHART_THEME) + .legend as LegendComponentOption; + + expect(legend).toMatchObject({ + show: true, + bottom: 0, + data: ['Success', 'Failed', 'Aborted'], + }); + expect( + ( + buildPieOption({ ...base, legend: { show: false } }, LIGHT_CHART_THEME) + .legend as LegendComponentOption + ).show + ).toBe(false); + }); + + it('uses an item tooltip', () => { + const tooltip = buildPieOption(base, LIGHT_CHART_THEME) + .tooltip as TooltipComponentOption; + + expect(tooltip.trigger).toBe('item'); + }); + + it('separates slices with the theme segment border', () => { + const border = (theme: typeof LIGHT_CHART_THEME) => + (pieOf(buildPieOption(base, theme)).itemStyle as { borderColor: string }) + .borderColor; + + expect(border(LIGHT_CHART_THEME)).toBe('#ffffff'); + expect(border(DARK_CHART_THEME)).toBe('#0c0e12'); + }); + + it('describes the chart with its slice names', () => { + expect(buildPieOption(base, LIGHT_CHART_THEME).aria).toEqual({ + enabled: true, + label: { description: 'Test status. Success, Failed, Aborted' }, + }); + }); + + it('merges the option override last', () => { + const option = buildPieOption( + { ...base, option: { legend: { top: 4 } } }, + LIGHT_CHART_THEME + ); + + expect(option.legend).toMatchObject({ top: 4, bottom: 0 }); + }); +}); + +describe('isPieEmpty', () => { + it('is empty with no slices', () => { + expect(isPieEmpty([])).toBe(true); + }); + + it('is empty when every slice is zero', () => { + expect( + isPieEmpty([ + { name: 'A', value: 0 }, + { name: 'B', value: 0 }, + ]) + ).toBe(true); + }); + + it('is not empty when any slice has a value', () => { + expect( + isPieEmpty([ + { name: 'A', value: 0 }, + { name: 'B', value: 2 }, + ]) + ).toBe(false); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.ts new file mode 100644 index 000000000000..6f0708bc5ae2 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/options/pie.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PieSeriesOption } from 'echarts'; +import { getSeriesColor } from '../palette'; +import type { + ChartOption, + ChartTheme, + PieBuildInput, + PieDatum, +} from '../types'; +import { legendConfig, tooltipConfig } from './common'; +import { mergeOption } from './merge'; + +const OUTER_RADIUS = '72%'; + +/** Nothing to draw: no slices, or every slice is zero or negative. */ +export const isPieEmpty = (data: PieDatum[]): boolean => + data.every((datum) => !(datum.value > 0)); + +const sliceLabel = (show: boolean): PieSeriesOption['label'] => + show + ? { + show: true, + formatter: (params: { percent?: number }) => + `${Math.round(params.percent ?? 0)}%`, + } + : { show: false }; + +export const buildPieOption = ( + input: PieBuildInput, + theme: ChartTheme +): ChartOption => { + const names = input.data.map((datum) => datum.name); + const showLabels = Boolean(input.showLabels); + + const option: ChartOption = { + aria: { + enabled: true, + label: { description: `${input.ariaLabel}. ${names.join(', ')}` }, + }, + tooltip: tooltipConfig('item', theme, input.tooltip), + legend: legendConfig(names, theme, { show: true, ...input.legend }), + series: [ + { + type: 'pie', + radius: [input.innerRadius ?? 0, OUTER_RADIUS], + center: ['50%', '50%'], + itemStyle: { borderColor: theme.segmentBorder, borderWidth: 1 }, + label: sliceLabel(showLabels), + labelLine: { show: showLabels }, + data: input.data.map((datum, index) => ({ + name: datum.name, + value: datum.value, + itemStyle: { color: datum.color ?? getSeriesColor(index) }, + })), + }, + ], + }; + + return mergeOption(option, input.option); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.test.ts new file mode 100644 index 000000000000..46e6eec7fc3e --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { CHART_PALETTE, getSeriesColor } from './palette'; + +describe('CHART_PALETTE', () => { + // AI charts persist the colours they were painted with, so changing an entry + // (or the order) silently recolours new charts. Pin the exact list. + it('keeps the 15 series colours in order', () => { + expect(CHART_PALETTE).toEqual([ + '#1570ef', + '#7a5af8', + '#067647', + '#b54708', + '#5925dc', + '#15b79e', + '#f04438', + '#ee46bc', + '#ef6820', + '#175cd3', + '#875bf7', + '#6172f3', + '#17b26a', + '#f79009', + '#cb5a50', + ]); + }); +}); + +describe('getSeriesColor', () => { + it('returns the palette colour at the series index', () => { + expect(getSeriesColor(0)).toBe('#1570ef'); + expect(getSeriesColor(14)).toBe('#cb5a50'); + }); + + it('cycles back to the start past the last colour', () => { + expect(getSeriesColor(15)).toBe('#1570ef'); + expect(getSeriesColor(31)).toBe('#7a5af8'); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.ts new file mode 100644 index 000000000000..e3110fa3e6dd --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/palette.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Default categorical series colours. A series without an explicit colour + * takes `getSeriesColor(index)`. + * + * Moved unchanged from `@collate/chart-core`'s `DEFAULT_CHART_COLORS`. AI charts + * record the colours they were painted with, so changing an entry or the order + * recolours every new chart — the test pins the list. Add colours at the end. + */ +export const CHART_PALETTE: readonly string[] = [ + '#1570ef', // blue-600 — primary brand + '#7a5af8', // purple-500 + '#067647', // success-600 + '#b54708', // warning-700 + '#5925dc', // indigo deep + '#15b79e', // teal + '#f04438', // error-500 + '#ee46bc', // pink + '#ef6820', // orange + '#175cd3', // blue-700 + '#875bf7', // purple-400 + '#6172f3', // indigo-500 + '#17b26a', // success-500 + '#f79009', // warning-500 + '#cb5a50', // muted red +]; + +export const getSeriesColor = (index: number): string => + CHART_PALETTE[index % CHART_PALETTE.length]; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/pie-chart.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/pie-chart.tsx new file mode 100644 index 000000000000..97a9ff6da5cd --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/pie-chart.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ECElementEvent } from 'echarts'; +import { useCallback, useMemo } from 'react'; +import { EChart } from './echart'; +import { buildPieOption, isPieEmpty } from './options/pie'; +import type { PieChartProps } from './props'; +import type { ChartTheme } from './types'; + +/** Pie chart; a donut when `innerRadius` is set. */ +export const PieChart = ({ + data, + ariaLabel, + innerRadius, + centerLabel, + showLabels, + legend, + tooltip, + option, + onSliceClick, + height, + isDark, + loading, + emptyState, + className, + 'data-testid': dataTestId, +}: PieChartProps) => { + const getOption = useCallback( + (theme: ChartTheme) => + buildPieOption( + { data, ariaLabel, innerRadius, showLabels, legend, tooltip, option }, + theme + ), + [data, ariaLabel, innerRadius, showLabels, legend, tooltip, option] + ); + + const onEvents = useMemo( + () => + onSliceClick + ? { + click: (event: ECElementEvent) => { + const datum = data[event.dataIndex]; + if (datum) { + onSliceClick(datum, event); + } + }, + } + : undefined, + [data, onSliceClick] + ); + + return ( + + {centerLabel && ( +
+ {centerLabel} +
+ )} +
+ ); +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/props.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/props.ts new file mode 100644 index 000000000000..c8573dc8a5cf --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/props.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ECElementEvent } from 'echarts'; +import type { ReactNode } from 'react'; +import type { + CartesianBuildInput, + ChartOption, + GeoJson, + GeoMapBuildInput, + GeoMapDatum, + PieBuildInput, +} from './types'; + +/** Props every chart component shares, on top of its build input. */ +export interface ChartCommonProps { + /** Accessible name of the chart. Translated by the caller. */ + ariaLabel: string; + height?: number | string; + /** Forces a colour mode. Detected from `.dark-mode` when omitted. */ + isDark?: boolean; + loading?: boolean; + /** Shown instead of the chart when there is no data. */ + emptyState?: ReactNode; + /** Merged into the built option last. Objects merge, arrays replace. */ + option?: ChartOption; + className?: string; + 'data-testid'?: string; +} + +/** + * Line, area and composed chart props. + * + * The option is rebuilt whenever a prop changes identity, so pass memoized + * `data`, `series`, axis and tooltip props from components that re-render + * often. Legend selection and the zoom window survive a rebuild either way. + */ +export interface CartesianChartProps + extends Omit< + CartesianBuildInput, + 'layout' | 'getBarColor' | 'showValueLabels' | 'radius' + >, + ChartCommonProps { + onPointClick?: (datum: T, seriesKey: string, event: ECElementEvent) => void; +} + +export interface BarChartProps + extends CartesianChartProps, + Pick< + CartesianBuildInput, + 'layout' | 'getBarColor' | 'showValueLabels' | 'radius' + > {} + +export interface PieChartProps extends PieBuildInput, ChartCommonProps { + /** Rendered in the middle of the chart, typically for a donut total. */ + centerLabel?: ReactNode; + onSliceClick?: ( + datum: PieBuildInput['data'][number], + event: ECElementEvent + ) => void; +} + +export interface GeoMapChartProps extends GeoMapBuildInput, ChartCommonProps { + /** Map geometry, already loaded (and trimmed, if wanted) by the caller. */ + geoJson: GeoJson; + /** Receives the resolved region name and its summed value. */ + onRegionClick?: (datum: GeoMapDatum, event: ECElementEvent) => void; + /** Raw region values that matched no feature, e.g. to explain blank areas. */ + onUnmatchedRegions?: (raw: string[]) => void; +} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/register.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/register.ts new file mode 100644 index 000000000000..67f603593c34 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/register.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BarChart, LineChart, MapChart, PieChart } from 'echarts/charts'; +import { + AriaComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + MarkLineComponent, + TooltipComponent, + VisualMapComponent, +} from 'echarts/components'; +import * as echarts from 'echarts/core'; +import { LabelLayout } from 'echarts/features'; +import { SVGRenderer } from 'echarts/renderers'; +import type { GeoJson } from './types'; + +let registered = false; + +/** + * Registers the chart types and components the charts module draws with on + * the shared `echarts/core` instance. Importing the modular entries instead of + * `echarts` keeps unused chart types out of the bundle. Safe to call often. + */ +export const registerChartParts = (): void => { + if (registered) { + return; + } + echarts.use([ + LineChart, + BarChart, + PieChart, + MapChart, + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + MarkLineComponent, + AriaComponent, + VisualMapComponent, + LabelLayout, + SVGRenderer, + ]); + registered = true; +}; + +/** + * Registers extra ECharts parts (e.g. heatmap, sankey, map) on the same core + * instance `EChart` renders with, for callers that draw more than the typed + * components cover. + */ +export const registerEChartsParts = ( + parts: Parameters[0] +): void => { + echarts.use(parts); +}; + +/** + * Registers map geometry under `mapName` on the shared core instance, once. + * A name that is already registered keeps its first geometry, so different + * geometry must use a different name. + */ +export const registerGeoMap = (mapName: string, geoJson: GeoJson): void => { + if (!echarts.getMap(mapName)) { + echarts.registerMap( + mapName, + geoJson as Parameters[1] + ); + } +}; + +export { echarts }; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.test.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.test.ts new file mode 100644 index 000000000000..1fee612dd037 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { buildChartTheme, DARK_CHART_THEME, LIGHT_CHART_THEME } from './theme'; + +describe('buildChartTheme', () => { + it('returns the light theme by default', () => { + expect(buildChartTheme()).toBe(LIGHT_CHART_THEME); + expect(buildChartTheme({ isDark: false })).toBe(LIGHT_CHART_THEME); + }); + + it('returns the dark theme when isDark is set', () => { + expect(buildChartTheme({ isDark: true })).toBe(DARK_CHART_THEME); + }); +}); + +describe('chart themes', () => { + // Values moved unchanged from chart-core's LIGHT/DARK_THEME_COLORS so the + // AI-automation email PNGs keep rendering the same. + it('keeps the light chrome colours', () => { + expect(LIGHT_CHART_THEME).toEqual({ + isDark: false, + axisText: '#6b7280', + axisTick: undefined, + axisTitle: '#535862', + xAxisTitle: '#374151', + grid: '#f1f2f4', + emptyFill: '#f1f2f4', + segmentBorder: '#ffffff', + tooltipBg: '#ffffff', + tooltipText: '#374151', + tooltipBorder: '#e5e7eb', + }); + }); + + it('keeps the dark chrome colours', () => { + expect(DARK_CHART_THEME).toEqual({ + isDark: true, + axisText: '#94979c', + axisTick: '#94979c', + axisTitle: '#cecfd2', + xAxisTitle: '#cecfd2', + grid: '#373a41', + emptyFill: '#22262f', + segmentBorder: '#0c0e12', + tooltipBg: '#22262f', + tooltipText: '#f7f7f7', + tooltipBorder: '#373a41', + }); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.ts new file mode 100644 index 000000000000..cb8fb1bb92e3 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/theme.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ChartTheme } from './types'; + +// Values moved unchanged from chart-core's LIGHT/DARK_THEME_COLORS so the +// headless email renderer keeps producing the same images. +export const LIGHT_CHART_THEME: ChartTheme = { + isDark: false, + axisText: '#6b7280', + axisTick: undefined, + axisTitle: '#535862', + xAxisTitle: '#374151', + grid: '#f1f2f4', + emptyFill: '#f1f2f4', + segmentBorder: '#ffffff', + tooltipBg: '#ffffff', + tooltipText: '#374151', + tooltipBorder: '#e5e7eb', +}; + +export const DARK_CHART_THEME: ChartTheme = { + isDark: true, + axisText: '#94979c', + axisTick: '#94979c', + axisTitle: '#cecfd2', + xAxisTitle: '#cecfd2', + grid: '#373a41', + emptyFill: '#22262f', + segmentBorder: '#0c0e12', + tooltipBg: '#22262f', + tooltipText: '#f7f7f7', + tooltipBorder: '#373a41', +}; + +export const buildChartTheme = ({ + isDark = false, +}: { isDark?: boolean } = {}): ChartTheme => + isDark ? DARK_CHART_THEME : LIGHT_CHART_THEME; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/types.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/types.ts new file mode 100644 index 000000000000..0a6d358f6ef2 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/types.ts @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + AriaComponentOption, + BarSeriesOption, + ComposeOption, + DataZoomComponentOption, + DefaultLabelFormatterCallbackParams, + GridComponentOption, + LegendComponentOption, + LineSeriesOption, + MapSeriesOption, + MarkLineComponentOption, + PieSeriesOption, + TooltipComponentFormatterCallbackParams, + TooltipComponentOption, + VisualMapComponentOption, + XAXisComponentOption, + YAXisComponentOption, +} from 'echarts'; + +// No React here, not even type-only: the Collate email renderer compiles the +// option builders and this file without React installed. + +/** + * Neutral chart chrome (axes, grid, tooltip, borders) for one colour mode. + * Series colours are not part of the theme — they come from the palette and + * stay the same in light and dark. + */ +export interface ChartTheme { + isDark: boolean; + axisText: string; + /** Tick label colour. `undefined` keeps the ECharts default. */ + axisTick?: string; + axisTitle: string; + xAxisTitle: string; + grid: string; + /** Fill for "no value" areas, e.g. regions without data on a map. */ + emptyFill: string; + /** Border between adjacent segments (pie slices, treemap cells). */ + segmentBorder: string; + tooltipBg: string; + tooltipText: string; + tooltipBorder: string; +} + +/** + * The ECharts option shape the charts module builds: only the chart types and + * components `registerChartParts` registers, so an option using anything else + * fails the type check. + */ +export type ChartOption = ComposeOption< + | LineSeriesOption + | BarSeriesOption + | PieSeriesOption + | MapSeriesOption + | GridComponentOption + | TooltipComponentOption + | LegendComponentOption + | DataZoomComponentOption + | MarkLineComponentOption + | AriaComponentOption + | VisualMapComponentOption +>; + +export type ChartSeriesType = 'line' | 'area' | 'bar'; + +export interface ChartSeries { + /** Field read from each datum. */ + key: string; + /** Legend and tooltip label. Translated by the caller. */ + name: string; + /** Defaults to `getSeriesColor(index)`. */ + color?: string; + /** Only read by `ComposedChart`; other charts fix the type. */ + type?: ChartSeriesType; + /** Series with the same stack id are stacked. */ + stack?: string; + /** Value axis the series is drawn against. */ + yAxisIndex?: 0 | 1; + /** Line and area only. Defaults to true. */ + smooth?: boolean; + /** Line and area only. Defaults to false. */ + showDots?: boolean; + /** Merged into this series' ECharts option. */ + seriesOption?: Partial; +} + +/** + * An ECharts axis option plus two shorthands. `label` becomes the axis name; + * `formatter` formats tick labels. + */ +export type ChartAxisProps
= Omit, 'type' | 'data'> & { + label?: string; + formatter?: (value: string | number) => string; +}; + +export type ChartXAxisProps = ChartAxisProps & { + type?: 'category' | 'time'; +}; + +export type ChartYAxisProps = ChartAxisProps; + +export interface ChartTooltipProps { + show?: boolean; + /** Formats one value. Receives the series key of the value. */ + valueFormatter?: (value: number | string, seriesKey: string) => string; + /** Replaces the whole tooltip body. */ + formatter?: (params: TooltipComponentFormatterCallbackParams) => string; +} + +export interface ChartLegendProps { + /** Defaults to true when there is more than one series. */ + show?: boolean; + position?: 'top' | 'bottom'; +} + +export interface ChartReferenceLine { + axis: 'x' | 'y'; + value: number | string; + label?: string; + color?: string; +} + +export interface CartesianBuildInput { + data: T[]; + xKey: keyof T & string; + series: ChartSeries[]; + /** Accessible name of the chart. Translated by the caller. */ + ariaLabel: string; + xAxis?: ChartXAxisProps; + yAxis?: ChartYAxisProps | [ChartYAxisProps, ChartYAxisProps]; + tooltip?: ChartTooltipProps; + legend?: ChartLegendProps; + referenceLines?: ChartReferenceLine[]; + /** `'auto'` turns zoom on above 15 points. Defaults to false. */ + zoom?: boolean | 'auto'; + /** Merged into the built option last. Objects merge, arrays replace. */ + option?: ChartOption; + /** Bar charts only. */ + layout?: 'vertical' | 'horizontal'; + /** Bar charts only. Colour of one bar; `undefined` keeps the series colour. */ + getBarColor?: (datum: T, index: number) => string | undefined; + /** Bar charts only. */ + showValueLabels?: + | boolean + | ((params: DefaultLabelFormatterCallbackParams) => string); + /** Bar charts only. Radius of the bar's outer end. 4, or 0 when stacked. */ + radius?: number; +} + +export interface PieDatum { + /** Slice label. Translated by the caller. */ + name: string; + value: number; + /** Defaults to `getSeriesColor(index)`. */ + color?: string; +} + +export interface PieBuildInput { + data: PieDatum[]; + /** Accessible name of the chart. Translated by the caller. */ + ariaLabel: string; + /** Set for a donut, e.g. `'55%'`. Defaults to a full pie. */ + innerRadius?: number | string; + /** Whole-percent labels beside each slice. */ + showLabels?: boolean; + legend?: ChartLegendProps; + tooltip?: ChartTooltipProps; + /** Merged into the built option last. Objects merge, arrays replace. */ + option?: ChartOption; +} + +/** Minimal GeoJSON FeatureCollection shape ECharts' `registerMap` accepts. */ +export interface GeoJson { + type: 'FeatureCollection'; + features: Array<{ + type: 'Feature'; + properties: { name?: string } & Record; + geometry: unknown; + }>; +} + +export interface GeoMapDatum { + /** Raw region value; resolved to a GeoJSON feature name via `resolveRegion`. */ + region: string; + value: number; +} + +export interface GeoMapBuildInput { + data: GeoMapDatum[]; + /** Accessible name of the chart. Translated by the caller. */ + ariaLabel: string; + /** + * Key the map geometry is registered under. Different geometry (e.g. a + * differently trimmed map) needs a different key. + */ + mapName: string; + /** + * Maps a raw region value (`'CA'`, `'06'`) to its GeoJSON feature name. + * `undefined` means unmatched. Defaults to the raw value itself. + */ + resolveRegion?: (raw: string) => string | undefined; + /** Colour-scale legend under the map. Defaults to true. */ + showScale?: boolean; + /** Low → high colours of the scale. Defaults to `GEO_COLOR_RANGE`. */ + colorRange?: string[]; + tooltip?: ChartTooltipProps; + /** Merged into the built option last. Objects merge, arrays replace. */ + option?: ChartOption; +} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/charts/use-is-dark-mode.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/use-is-dark-mode.ts new file mode 100644 index 000000000000..dbb933c577af --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/charts/use-is-dark-mode.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RefObject, useLayoutEffect, useState } from 'react'; + +const DARK_MODE_CLASS = 'dark-mode'; + +const rootIsDark = () => + typeof document !== 'undefined' && + document.documentElement.classList.contains(DARK_MODE_CLASS); + +/** + * Whether the element renders in dark mode: `override` when given, else a + * `.dark-mode` class on the element's ancestors or on ``. Re-checks when + * the `` class changes, which is how the app theme toggles. + */ +export const useIsDarkMode = ( + ref: RefObject, + override?: boolean +): boolean => { + const [detected, setDetected] = useState(rootIsDark); + + useLayoutEffect(() => { + const update = () => + setDetected( + Boolean(ref.current?.closest(`.${DARK_MODE_CLASS}`)) || rootIsDark() + ); + update(); + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + }, [ref]); + + return override ?? detected; +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Charts.stories.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Charts.stories.tsx new file mode 100644 index 000000000000..7d21285e4b29 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Charts.stories.tsx @@ -0,0 +1,384 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; +import { + AreaChart, + BarChart, + ComposedChart, + EChart, + GeoMapChart, + LineChart, + PieChart, +} from '../charts'; +import type { GeoJson } from '../charts'; +import usStatesGeoJson from './fixtures/us-states.geo.json'; + +const meta = { + title: 'Charts/Overview', + parameters: { layout: 'padded' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +const runs = DAYS.map((day, i) => ({ + day, + success: 40 + ((i * 7) % 13), + failed: 3 + ((i * 5) % 6), + aborted: (i * 3) % 4, +})); + +const runtime = Array.from({ length: 30 }, (_, i) => ({ + ts: Date.UTC(2026, 8, 1 + i), + p50: 120 + Math.round(20 * Math.sin(i / 3)), + p95: 260 + Math.round(40 * Math.cos(i / 4)), +})); + +const completeness = [ + { field: 'Description', delta: 12 }, + { field: 'Owners', delta: -4 }, + { field: 'Tags', delta: 7 }, + { field: 'Glossary terms', delta: -9 }, + { field: 'Domain', delta: 3 }, +]; + +const cost = DAYS.map((day, i) => ({ + day, + storage: 1200 + i * 150, + compute: 800 + ((i * 230) % 700), + queries: 30_000 + i * 4_500, +})); + +const status = [ + { name: 'Success', value: 42, color: '#17b26a' }, + { name: 'Failed', value: 7, color: '#f04438' }, + { name: 'Aborted', value: 3, color: '#f79009' }, +]; + +// A 3×3 grid of made-up regions — real map data belongs to the caller. +const REGION_NAMES = [ + ['North West', 'North', 'North East'], + ['West', 'Central', 'East'], + ['South West', 'South', 'South East'], +]; + +const gridMap: GeoJson = { + type: 'FeatureCollection', + features: REGION_NAMES.flatMap((row, y) => + row.map((name, x) => ({ + type: 'Feature' as const, + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [x, -y], + [x + 1, -y], + [x + 1, -y - 1], + [x, -y - 1], + [x, -y], + ], + ], + }, + })) + ), +}; + +const REGION_CODES: Record = { + NW: 'North West', + N: 'North', + NE: 'North East', + W: 'West', + C: 'Central', + E: 'East', + SW: 'South West', + S: 'South', +}; + +const regionSales = [ + { region: 'NW', value: 120 }, + { region: 'N', value: 340 }, + { region: 'NE', value: 90 }, + { region: 'W', value: 260 }, + { region: 'C', value: 510 }, + { region: 'E', value: 180 }, + { region: 'SW', value: 40 }, + { region: 'S', value: 300 }, + { region: 'Atlantis', value: 10 }, +]; + +// US state geometry (Census-derived, public domain), simplified, with Alaska +// and Hawaii moved into insets under the south-west. +const usStates = usStatesGeoJson as GeoJson; + +const US_STATE_CODES: Record = { + AL: 'Alabama', + AK: 'Alaska', + AZ: 'Arizona', + AR: 'Arkansas', + CA: 'California', + CO: 'Colorado', + CT: 'Connecticut', + DE: 'Delaware', + DC: 'District of Columbia', + FL: 'Florida', + GA: 'Georgia', + HI: 'Hawaii', + ID: 'Idaho', + IL: 'Illinois', + IN: 'Indiana', + IA: 'Iowa', + KS: 'Kansas', + KY: 'Kentucky', + LA: 'Louisiana', + ME: 'Maine', + MD: 'Maryland', + MA: 'Massachusetts', + MI: 'Michigan', + MN: 'Minnesota', + MS: 'Mississippi', + MO: 'Missouri', + MT: 'Montana', + NE: 'Nebraska', + NV: 'Nevada', + NH: 'New Hampshire', + NJ: 'New Jersey', + NM: 'New Mexico', + NY: 'New York', + NC: 'North Carolina', + ND: 'North Dakota', + OH: 'Ohio', + OK: 'Oklahoma', + OR: 'Oregon', + PA: 'Pennsylvania', + RI: 'Rhode Island', + SC: 'South Carolina', + SD: 'South Dakota', + TN: 'Tennessee', + TX: 'Texas', + UT: 'Utah', + VT: 'Vermont', + VA: 'Virginia', + WA: 'Washington', + WV: 'West Virginia', + WI: 'Wisconsin', + WY: 'Wyoming', +}; + +const assetsByState = Object.keys(US_STATE_CODES).map((region, i) => ({ + region, + value: 20 + ((i * 37) % 480), +})); + +const Frame = ({ children }: { children: ReactNode }) => ( +
{children}
+); + +export const Line: Story = { + render: () => ( + + + + ), +}; + +export const LineOnTimeAxisWithZoom: Story = { + render: () => ( + + `${value}s` }} + xAxis={{ type: 'time' }} + xKey="ts" + yAxis={{ label: 'Seconds' }} + zoom="auto" + /> + + ), +}; + +export const StackedArea: Story = { + render: () => ( + + + + ), +}; + +export const BarWithReferenceLine: Story = { + render: () => ( + + + + ), +}; + +export const HorizontalDivergingBar: Story = { + render: () => ( + + (row.delta < 0 ? '#f04438' : '#17b26a')} + layout="horizontal" + referenceLines={[{ axis: 'x', value: 0 }]} + series={[{ key: 'delta', name: 'Change' }]} + showValueLabels={(p) => `${p.value}%`} + xKey="field" + /> + + ), +}; + +export const ComposedTwoAxes: Story = { + render: () => ( + + + + ), +}; + +export const Pie: Story = { + render: () => ( + + + + ), +}; + +export const DonutWithCentreLabel: Story = { + render: () => ( + + + 52 tests + + } + data={status} + innerRadius="55%" + /> + + ), +}; + +export const GeoMap: Story = { + render: () => ( + + REGION_CODES[raw]} + /> + + ), +}; + +export const UsStatesMap: Story = { + render: () => ( + + US_STATE_CODES[code]} + /> + + ), +}; + +export const RawEChart: Story = { + render: () => ( + + ({ + xAxis: { + type: 'category', + data: DAYS, + axisLabel: { color: theme.axisTick }, + }, + yAxis: { + type: 'value', + splitLine: { lineStyle: { color: theme.grid } }, + }, + series: [ + { type: 'bar', data: runs.map((r) => r.success) }, + { type: 'line', data: runs.map((r) => r.failed * 10) }, + ], + })} + /> + + ), +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/fixtures/us-states.geo.json b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/fixtures/us-states.geo.json new file mode 100644 index 000000000000..1b4469382436 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/fixtures/us-states.geo.json @@ -0,0 +1,3672 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "name": "Alabama" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-87.36, 35.0], + [-85.61, 34.98], + [-85.18, 32.86], + [-85.07, 32.58], + [-84.96, 32.42], + [-85.0, 32.32], + [-84.89, 32.26], + [-85.06, 32.14], + [-85.05, 32.01], + [-85.14, 31.84], + [-85.04, 31.54], + [-85.11, 31.28], + [-85.0, 31.0], + [-87.6, 31.0], + [-87.63, 30.87], + [-87.41, 30.67], + [-87.45, 30.51], + [-87.37, 30.43], + [-87.52, 30.28], + [-87.66, 30.25], + [-87.91, 30.41], + [-87.93, 30.66], + [-88.01, 30.69], + [-88.1, 30.5], + [-88.14, 30.32], + [-88.39, 30.37], + [-88.47, 31.9], + [-88.1, 34.89], + [-88.2, 35.0], + [-87.36, 35.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Alaska" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-108.08, 21.41], + [-108.07, 21.47], + [-108.01, 21.43], + [-108.02, 21.37], + [-108.09, 21.37], + [-108.08, 21.41] + ] + ], + [ + [ + [-108.14, 21.53], + [-108.09, 21.48], + [-108.11, 21.41], + [-108.14, 21.53] + ] + ], + [ + [ + [-108.43, 21.94], + [-108.34, 21.93], + [-108.35, 21.87], + [-108.46, 21.89], + [-108.43, 21.94] + ] + ], + [ + [ + [-108.59, 21.9], + [-108.48, 21.89], + [-108.45, 21.81], + [-108.3, 21.67], + [-108.22, 21.46], + [-108.17, 21.43], + [-108.19, 21.24], + [-108.26, 21.25], + [-108.28, 21.33], + [-108.36, 21.38], + [-108.42, 21.38], + [-108.41, 21.32], + [-108.34, 21.31], + [-108.36, 21.23], + [-108.41, 21.24], + [-108.48, 21.34], + [-108.5, 21.45], + [-108.56, 21.45], + [-108.56, 21.49], + [-108.47, 21.53], + [-108.49, 21.6], + [-108.54, 21.61], + [-108.57, 21.77], + [-108.61, 21.73], + [-108.59, 21.9] + ] + ], + [ + [ + [-108.63, 21.58], + [-108.51, 21.57], + [-108.55, 21.51], + [-108.63, 21.58] + ] + ], + [ + [ + [-108.68, 22.13], + [-108.71, 22.17], + [-108.67, 22.2], + [-108.47, 22.16], + [-108.34, 22.03], + [-108.39, 21.96], + [-108.61, 21.94], + [-108.62, 22.1], + [-108.68, 22.13] + ] + ], + [ + [ + [-108.73, 21.95], + [-108.81, 22.1], + [-108.74, 22.1], + [-108.74, 22.14], + [-108.63, 22.07], + [-108.62, 22.0], + [-108.66, 21.99], + [-108.68, 21.91], + [-108.66, 21.89], + [-108.73, 21.76], + [-108.78, 21.9], + [-108.73, 21.95] + ] + ], + [ + [ + [-108.87, 21.87], + [-108.87, 21.83], + [-108.91, 21.85], + [-109.07, 22.08], + [-109.05, 22.13], + [-109.07, 22.23], + [-109.14, 22.31], + [-109.07, 22.39], + [-108.92, 22.32], + [-108.87, 22.05], + [-108.87, 21.87] + ] + ], + [ + [ + [-108.89, 22.65], + [-108.75, 22.62], + [-108.67, 22.48], + [-108.73, 22.5], + [-108.74, 22.46], + [-108.68, 22.41], + [-108.67, 22.31], + [-108.83, 22.17], + [-108.86, 22.17], + [-108.86, 22.36], + [-108.95, 22.72], + [-108.89, 22.65] + ] + ], + [ + [ + [-109.18, 22.29], + [-109.11, 22.22], + [-109.12, 22.17], + [-109.18, 22.16], + [-109.18, 22.29] + ] + ], + [ + [ + [-109.29, 22.64], + [-109.16, 22.68], + [-109.09, 22.63], + [-109.11, 22.56], + [-109.08, 22.61], + [-108.99, 22.59], + [-108.94, 22.55], + [-108.97, 22.47], + [-108.91, 22.36], + [-108.98, 22.34], + [-109.11, 22.43], + [-109.1, 22.34], + [-109.19, 22.32], + [-109.37, 22.53], + [-109.32, 22.67], + [-109.29, 22.64] + ] + ], + [ + [ + [-112.1, 23.44], + [-112.22, 23.3], + [-112.31, 23.27], + [-112.29, 23.34], + [-112.13, 23.47], + [-112.1, 23.44] + ] + ], + [ + [ + [-112.23, 23.59], + [-112.28, 23.42], + [-112.33, 23.45], + [-112.23, 23.59] + ] + ], + [ + [ + [-112.28, 27.46], + [-111.78, 27.43], + [-111.46, 27.35], + [-111.17, 27.42], + [-110.62, 27.21], + [-110.52, 27.22], + [-110.52, 23.48], + [-110.26, 23.43], + [-110.18, 23.5], + [-110.02, 23.5], + [-110.05, 23.4], + [-109.64, 23.06], + [-109.6, 22.92], + [-109.37, 23.03], + [-109.34, 23.15], + [-109.3, 23.15], + [-109.29, 23.21], + [-109.08, 23.28], + [-108.97, 23.19], + [-108.95, 23.07], + [-108.88, 23.06], + [-108.77, 22.9], + [-108.66, 22.85], + [-108.49, 22.62], + [-108.25, 22.25], + [-108.28, 22.2], + [-108.19, 22.18], + [-108.21, 22.11], + [-108.15, 22.08], + [-108.14, 22.0], + [-108.07, 22.01], + [-107.69, 21.81], + [-107.66, 21.76], + [-107.69, 21.59], + [-107.66, 21.47], + [-107.75, 21.33], + [-107.84, 21.25], + [-107.9, 21.28], + [-107.94, 21.44], + [-107.88, 21.48], + [-108.03, 21.46], + [-108.14, 21.54], + [-108.1, 21.64], + [-108.17, 21.61], + [-108.17, 21.56], + [-108.23, 21.6], + [-108.21, 21.74], + [-108.26, 21.7], + [-108.32, 21.79], + [-108.35, 21.77], + [-108.37, 21.85], + [-108.27, 21.9], + [-108.26, 22.03], + [-108.57, 22.23], + [-108.57, 22.39], + [-108.62, 22.41], + [-108.61, 22.48], + [-108.74, 22.63], + [-108.85, 22.64], + [-108.98, 22.76], + [-109.03, 23.04], + [-109.06, 22.97], + [-109.0, 22.81], + [-108.98, 22.64], + [-109.07, 22.72], + [-109.2, 22.71], + [-109.2, 22.81], + [-109.24, 22.89], + [-109.28, 22.86], + [-109.45, 22.95], + [-109.28, 22.83], + [-109.23, 22.71], + [-109.37, 22.7], + [-109.38, 22.64], + [-109.71, 22.87], + [-109.77, 22.97], + [-110.19, 23.16], + [-110.19, 23.22], + [-110.13, 23.23], + [-110.16, 23.31], + [-110.13, 23.36], + [-110.14, 23.38], + [-110.21, 23.29], + [-110.34, 23.24], + [-110.92, 23.39], + [-111.27, 23.36], + [-111.47, 23.44], + [-111.52, 23.54], + [-111.59, 23.53], + [-111.62, 23.48], + [-111.77, 23.58], + [-111.98, 23.46], + [-112.01, 23.52], + [-111.98, 23.55], + [-111.81, 23.59], + [-111.97, 23.66], + [-112.02, 23.78], + [-112.35, 23.67], + [-112.38, 23.69], + [-112.36, 23.76], + [-112.39, 23.76], + [-112.43, 23.68], + [-112.37, 23.65], + [-112.32, 23.54], + [-112.35, 23.47], + [-112.41, 23.46], + [-112.36, 23.45], + [-112.34, 23.36], + [-112.51, 23.34], + [-112.62, 23.35], + [-112.61, 23.39], + [-112.67, 23.32], + [-112.71, 23.36], + [-112.75, 23.31], + [-112.73, 23.28], + [-113.05, 23.14], + [-113.12, 23.05], + [-113.23, 23.07], + [-113.27, 23.02], + [-113.37, 23.06], + [-113.35, 23.13], + [-113.24, 23.15], + [-113.15, 23.23], + [-113.15, 23.27], + [-113.25, 23.21], + [-113.35, 23.27], + [-113.2, 23.51], + [-113.19, 23.58], + [-113.23, 23.65], + [-112.97, 23.78], + [-112.87, 23.73], + [-112.79, 23.77], + [-112.91, 23.86], + [-113.03, 23.88], + [-113.4, 23.64], + [-113.48, 23.48], + [-113.57, 23.43], + [-113.53, 23.39], + [-113.56, 23.33], + [-113.65, 23.32], + [-113.65, 23.24], + [-113.75, 23.24], + [-113.84, 23.18], + [-113.83, 23.13], + [-113.93, 23.11], + [-113.95, 22.99], + [-113.72, 22.91], + [-113.92, 22.75], + [-113.9, 22.71], + [-113.94, 22.64], + [-114.16, 22.57], + [-114.26, 22.44], + [-114.3, 22.47], + [-114.35, 22.38], + [-114.43, 22.39], + [-114.43, 22.34], + [-114.51, 22.33], + [-114.56, 22.15], + [-114.73, 22.07], + [-114.78, 22.1], + [-114.85, 22.0], + [-114.92, 22.02], + [-114.93, 21.97], + [-115.03, 21.95], + [-115.07, 21.9], + [-114.99, 21.88], + [-115.07, 21.75], + [-115.3, 21.71], + [-115.36, 21.6], + [-115.37, 21.69], + [-115.41, 21.7], + [-115.6, 21.55], + [-115.61, 21.59], + [-115.63, 21.54], + [-115.68, 21.57], + [-115.78, 21.5], + [-115.85, 21.51], + [-115.87, 21.61], + [-115.99, 21.39], + [-116.03, 21.42], + [-116.04, 21.37], + [-116.1, 21.38], + [-116.11, 21.46], + [-116.15, 21.48], + [-116.15, 21.36], + [-116.2, 21.33], + [-116.29, 21.4], + [-116.25, 21.34], + [-116.34, 21.28], + [-116.28, 21.24], + [-116.72, 21.13], + [-116.74, 21.2], + [-116.68, 21.23], + [-116.64, 21.32], + [-116.38, 21.38], + [-116.34, 21.32], + [-116.32, 21.41], + [-116.21, 21.43], + [-115.93, 21.72], + [-115.69, 21.76], + [-115.7, 21.69], + [-115.67, 21.65], + [-115.66, 21.7], + [-115.53, 21.67], + [-115.61, 21.76], + [-115.56, 21.86], + [-115.19, 22.1], + [-115.11, 22.08], + [-115.11, 22.17], + [-114.86, 22.4], + [-114.8, 22.76], + [-114.7, 22.84], + [-114.71, 22.91], + [-115.03, 22.82], + [-115.1, 22.93], + [-115.16, 22.84], + [-115.12, 22.75], + [-115.17, 22.72], + [-115.36, 22.93], + [-115.44, 22.87], + [-115.52, 22.92], + [-115.54, 22.99], + [-115.92, 22.78], + [-115.96, 22.82], + [-115.92, 22.87], + [-115.97, 23.11], + [-115.9, 23.16], + [-116.04, 23.4], + [-116.1, 23.43], + [-116.11, 23.36], + [-116.28, 23.3], + [-116.54, 23.31], + [-116.55, 23.37], + [-116.83, 23.59], + [-116.78, 23.64], + [-116.76, 23.72], + [-116.81, 23.7], + [-116.81, 23.75], + [-116.79, 23.79], + [-116.91, 23.8], + [-116.92, 23.87], + [-117.0, 23.93], + [-117.0, 23.98], + [-117.05, 23.96], + [-117.06, 24.01], + [-117.0, 24.03], + [-117.04, 24.09], + [-116.96, 24.09], + [-116.94, 24.22], + [-116.71, 24.42], + [-116.73, 24.48], + [-116.61, 24.65], + [-116.51, 24.66], + [-116.25, 24.58], + [-116.05, 24.75], + [-115.76, 24.76], + [-115.66, 24.89], + [-115.71, 25.05], + [-115.82, 25.17], + [-115.67, 25.2], + [-115.66, 25.25], + [-115.76, 25.33], + [-115.83, 25.27], + [-116.02, 25.23], + [-116.18, 25.09], + [-116.2, 25.16], + [-116.25, 25.18], + [-116.29, 25.12], + [-116.4, 25.19], + [-116.76, 25.13], + [-117.12, 25.21], + [-117.15, 25.25], + [-117.13, 25.31], + [-117.2, 25.35], + [-117.14, 25.43], + [-117.15, 25.5], + [-117.56, 25.59], + [-117.57, 25.63], + [-117.11, 25.83], + [-116.96, 25.8], + [-116.94, 25.84], + [-116.99, 25.89], + [-116.6, 25.99], + [-116.44, 25.98], + [-116.48, 25.84], + [-116.44, 25.78], + [-115.94, 25.77], + [-115.86, 25.86], + [-115.77, 25.84], + [-115.75, 25.89], + [-115.96, 25.9], + [-115.95, 25.96], + [-116.11, 26.06], + [-116.14, 26.12], + [-116.07, 26.13], + [-115.98, 26.07], + [-116.0, 26.02], + [-115.87, 25.94], + [-115.85, 25.98], + [-115.95, 26.05], + [-115.91, 26.16], + [-116.43, 26.21], + [-116.56, 26.42], + [-117.2, 26.7], + [-117.08, 26.79], + [-117.08, 26.91], + [-116.49, 26.95], + [-116.27, 27.11], + [-116.2, 27.29], + [-115.94, 27.48], + [-115.82, 27.46], + [-115.68, 27.5], + [-115.37, 27.68], + [-114.95, 27.69], + [-114.57, 27.9], + [-114.31, 27.83], + [-114.29, 27.79], + [-114.41, 27.75], + [-114.41, 27.68], + [-114.29, 27.7], + [-114.17, 27.82], + [-114.05, 27.76], + [-114.07, 27.71], + [-113.95, 27.67], + [-113.7, 27.73], + [-113.45, 27.7], + [-113.49, 27.6], + [-113.17, 27.51], + [-113.06, 27.56], + [-112.72, 27.57], + [-112.28, 27.46] + ] + ], + [ + [ + [-113.62, 22.57], + [-113.72, 22.58], + [-113.65, 22.68], + [-113.59, 22.69], + [-113.55, 22.79], + [-113.51, 22.7], + [-113.47, 22.73], + [-113.4, 22.68], + [-113.4, 22.62], + [-113.62, 22.57] + ] + ], + [ + [ + [-113.89, 22.38], + [-113.82, 22.43], + [-113.88, 22.44], + [-113.88, 22.49], + [-113.83, 22.52], + [-113.78, 22.45], + [-113.76, 22.55], + [-113.71, 22.52], + [-113.7, 22.56], + [-113.57, 22.56], + [-113.5, 22.52], + [-113.5, 22.4], + [-113.42, 22.41], + [-113.47, 22.33], + [-113.57, 22.36], + [-113.54, 22.31], + [-113.56, 22.27], + [-113.62, 22.29], + [-113.62, 22.21], + [-113.72, 22.16], + [-113.77, 22.19], + [-113.9, 22.06], + [-113.92, 22.15], + [-113.98, 22.1], + [-114.04, 22.16], + [-114.04, 22.24], + [-114.09, 22.27], + [-114.06, 22.36], + [-113.96, 22.42], + [-113.9, 22.42], + [-113.89, 22.38] + ] + ], + [ + [ + [-114.33, 21.73], + [-114.28, 21.72], + [-114.3, 21.64], + [-114.37, 21.65], + [-114.33, 21.73] + ] + ], + [ + [ + [-115.43, 21.47], + [-115.45, 21.39], + [-115.53, 21.32], + [-115.43, 21.47] + ] + ], + [ + [ + [-115.6, 21.5], + [-115.55, 21.5], + [-115.55, 21.46], + [-115.67, 21.41], + [-115.67, 21.51], + [-115.6, 21.5] + ] + ], + [ + [ + [-116.05, 21.35], + [-116.07, 21.3], + [-116.09, 21.33], + [-116.05, 21.35] + ] + ], + [ + [ + [-116.09, 24.81], + [-116.13, 24.77], + [-116.14, 24.81], + [-116.09, 24.81] + ] + ], + [ + [ + [-116.19, 21.16], + [-116.13, 21.14], + [-116.14, 21.11], + [-116.18, 21.11], + [-116.19, 21.16] + ] + ], + [ + [ + [-116.9, 21.08], + [-116.88, 21.03], + [-116.92, 21.01], + [-116.94, 21.06], + [-116.9, 21.08] + ] + ], + [ + [ + [-116.95, 21.02], + [-117.03, 20.98], + [-117.05, 21.01], + [-117.02, 21.05], + [-116.95, 21.02] + ] + ], + [ + [ + [-117.11, 23.5], + [-116.94, 23.47], + [-116.93, 23.36], + [-117.04, 23.26], + [-117.31, 23.36], + [-117.39, 23.44], + [-117.11, 23.5] + ] + ], + [ + [ + [-117.12, 20.96], + [-117.07, 20.93], + [-117.16, 20.85], + [-117.05, 20.87], + [-117.17, 20.77], + [-117.44, 20.66], + [-117.49, 20.7], + [-117.31, 20.77], + [-117.32, 20.8], + [-117.29, 20.85], + [-117.23, 20.83], + [-117.32, 20.89], + [-117.29, 20.94], + [-117.19, 20.97], + [-117.17, 20.91], + [-117.12, 20.96] + ] + ], + [ + [ + [-117.75, 20.62], + [-117.6, 20.77], + [-117.54, 20.79], + [-117.5, 20.71], + [-117.72, 20.55], + [-117.75, 20.62] + ] + ], + [ + [ + [-117.99, 20.52], + [-117.98, 20.48], + [-118.06, 20.5], + [-117.99, 20.52] + ] + ], + [ + [ + [-118.1, 22.25], + [-118.13, 22.21], + [-118.14, 22.25], + [-118.1, 22.25] + ] + ], + [ + [ + [-118.23, 20.44], + [-118.22, 20.4], + [-118.27, 20.38], + [-118.27, 20.41], + [-118.23, 20.44] + ] + ], + [ + [ + [-118.51, 24.85], + [-118.31, 24.79], + [-118.13, 24.83], + [-118.07, 24.76], + [-117.72, 24.68], + [-117.76, 24.62], + [-117.9, 24.62], + [-117.97, 24.54], + [-118.29, 24.73], + [-118.51, 24.71], + [-118.54, 24.75], + [-118.51, 24.85] + ] + ], + [ + [ + [-118.69, 20.32], + [-118.69, 20.27], + [-118.74, 20.26], + [-118.69, 20.32] + ] + ], + [ + [ + [-119.0, 20.22], + [-118.87, 20.19], + [-118.98, 20.17], + [-119.0, 20.22] + ] + ], + [ + [ + [-119.18, 20.27], + [-119.15, 20.33], + [-119.1, 20.29], + [-119.15, 20.25], + [-119.14, 20.21], + [-119.35, 20.18], + [-119.18, 20.27] + ] + ], + [ + [ + [-119.74, 20.1], + [-119.69, 20.11], + [-119.69, 20.06], + [-119.83, 20.01], + [-119.86, 20.08], + [-119.83, 20.09], + [-119.82, 20.15], + [-119.74, 20.1] + ] + ], + [ + [ + [-119.92, 20.14], + [-119.89, 20.12], + [-119.95, 20.03], + [-119.92, 20.14] + ] + ], + [ + [ + [-120.17, 20.13], + [-120.09, 20.08], + [-120.13, 20.02], + [-120.17, 20.13] + ] + ], + [ + [ + [-122.51, 20.56], + [-122.56, 20.49], + [-122.98, 20.47], + [-122.91, 20.56], + [-122.51, 20.56] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Arizona" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-109.04, 37.0], + [-109.05, 31.33], + [-111.07, 31.33], + [-114.82, 32.49], + [-114.72, 32.72], + [-114.52, 32.76], + [-114.47, 32.84], + [-114.52, 33.03], + [-114.66, 33.03], + [-114.73, 33.41], + [-114.52, 33.55], + [-114.5, 33.7], + [-114.54, 33.93], + [-114.42, 34.11], + [-114.26, 34.17], + [-114.14, 34.31], + [-114.33, 34.45], + [-114.47, 34.71], + [-114.63, 34.88], + [-114.63, 35.0], + [-114.57, 35.14], + [-114.6, 35.32], + [-114.68, 35.52], + [-114.74, 36.1], + [-114.37, 36.14], + [-114.25, 36.02], + [-114.15, 36.03], + [-114.05, 36.2], + [-114.05, 37.0], + [-109.04, 37.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Arkansas" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-94.47, 36.5], + [-90.15, 36.5], + [-90.06, 36.3], + [-90.22, 36.18], + [-90.38, 36.0], + [-89.73, 36.0], + [-89.76, 35.81], + [-89.91, 35.76], + [-89.94, 35.6], + [-90.13, 35.44], + [-90.11, 35.2], + [-90.21, 35.02], + [-90.31, 35.0], + [-90.25, 34.91], + [-90.41, 34.83], + [-90.48, 34.66], + [-90.59, 34.62], + [-90.57, 34.42], + [-90.75, 34.37], + [-90.74, 34.3], + [-90.95, 34.14], + [-90.89, 34.03], + [-91.07, 33.87], + [-91.23, 33.56], + [-91.06, 33.43], + [-91.14, 33.35], + [-91.09, 33.14], + [-91.17, 33.0], + [-94.04, 33.02], + [-94.04, 33.55], + [-94.18, 33.59], + [-94.38, 33.54], + [-94.48, 33.64], + [-94.43, 35.4], + [-94.62, 36.5], + [-94.47, 36.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "California" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-123.23, 42.01], + [-120.0, 42.0], + [-120.0, 39.0], + [-117.5, 37.22], + [-115.85, 35.97], + [-114.63, 35.0], + [-114.63, 34.88], + [-114.47, 34.71], + [-114.33, 34.45], + [-114.14, 34.31], + [-114.26, 34.17], + [-114.42, 34.11], + [-114.54, 33.93], + [-114.5, 33.7], + [-114.52, 33.55], + [-114.73, 33.41], + [-114.66, 33.03], + [-114.52, 33.03], + [-114.47, 32.84], + [-114.52, 32.76], + [-114.72, 32.72], + [-117.13, 32.54], + [-117.25, 32.67], + [-117.25, 32.88], + [-117.33, 33.12], + [-117.47, 33.3], + [-117.78, 33.54], + [-118.18, 33.76], + [-118.26, 33.7], + [-118.41, 33.74], + [-118.39, 33.84], + [-118.57, 34.04], + [-118.8, 34.0], + [-119.22, 34.15], + [-119.28, 34.27], + [-119.56, 34.42], + [-119.88, 34.41], + [-120.14, 34.48], + [-120.47, 34.45], + [-120.65, 34.58], + [-120.61, 34.86], + [-120.67, 34.9], + [-120.63, 35.1], + [-120.89, 35.25], + [-120.91, 35.45], + [-121.0, 35.46], + [-121.17, 35.64], + [-121.28, 35.67], + [-121.33, 35.78], + [-121.72, 36.2], + [-121.9, 36.32], + [-121.94, 36.64], + [-121.86, 36.61], + [-121.79, 36.8], + [-121.93, 36.98], + [-122.11, 36.96], + [-122.34, 37.12], + [-122.42, 37.24], + [-122.4, 37.36], + [-122.52, 37.52], + [-122.52, 37.78], + [-122.33, 37.78], + [-122.41, 38.15], + [-122.49, 38.11], + [-122.5, 37.93], + [-122.7, 37.89], + [-122.94, 38.03], + [-122.98, 38.27], + [-123.13, 38.45], + [-123.33, 38.57], + [-123.74, 38.96], + [-123.69, 39.03], + [-123.82, 39.37], + [-123.76, 39.55], + [-123.85, 39.83], + [-124.11, 40.11], + [-124.36, 40.26], + [-124.41, 40.44], + [-124.16, 40.88], + [-124.11, 41.03], + [-124.16, 41.14], + [-124.07, 41.44], + [-124.15, 41.72], + [-124.26, 41.78], + [-124.21, 42.0], + [-123.23, 42.01] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Colorado" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-107.92, 41.0], + [-102.05, 41.0], + [-102.04, 36.99], + [-109.04, 37.0], + [-109.05, 41.0], + [-107.92, 41.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Connecticut" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-73.05, 42.04], + [-71.8, 42.02], + [-71.8, 41.41], + [-71.86, 41.32], + [-71.95, 41.34], + [-72.39, 41.26], + [-72.91, 41.28], + [-73.13, 41.15], + [-73.37, 41.1], + [-73.66, 40.99], + [-73.73, 41.1], + [-73.48, 41.21], + [-73.55, 41.29], + [-73.49, 42.05], + [-73.05, 42.04] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Delaware" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-75.41, 39.8], + [-75.51, 39.68], + [-75.61, 39.62], + [-75.59, 39.46], + [-75.44, 39.31], + [-75.4, 39.07], + [-75.19, 38.81], + [-75.09, 38.8], + [-75.05, 38.45], + [-75.69, 38.46], + [-75.79, 39.72], + [-75.62, 39.83], + [-75.41, 39.8] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "District of Columbia" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.04, 38.99], + [-76.91, 38.9], + [-77.04, 38.79], + [-77.12, 38.93], + [-77.04, 38.99] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Florida" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-85.5, 31.0], + [-85.0, 31.0], + [-84.87, 30.71], + [-82.22, 30.57], + [-82.17, 30.36], + [-82.05, 30.36], + [-82.0, 30.56], + [-82.04, 30.75], + [-81.95, 30.83], + [-81.72, 30.75], + [-81.44, 30.71], + [-81.38, 30.27], + [-81.26, 29.79], + [-80.97, 29.15], + [-80.52, 28.46], + [-80.59, 28.41], + [-80.57, 28.09], + [-80.38, 27.74], + [-80.09, 27.02], + [-80.03, 26.8], + [-80.15, 25.74], + [-80.24, 25.72], + [-80.34, 25.47], + [-80.3, 25.38], + [-80.5, 25.2], + [-80.57, 25.24], + [-80.76, 25.16], + [-81.08, 25.12], + [-81.17, 25.22], + [-81.13, 25.38], + [-81.35, 25.82], + [-81.53, 25.9], + [-81.68, 25.84], + [-81.8, 26.09], + [-81.83, 26.29], + [-82.04, 26.52], + [-82.09, 26.67], + [-82.06, 26.88], + [-82.17, 26.92], + [-82.15, 26.79], + [-82.25, 26.76], + [-82.57, 27.3], + [-82.69, 27.44], + [-82.39, 27.84], + [-82.59, 27.82], + [-82.72, 27.69], + [-82.85, 27.89], + [-82.68, 28.43], + [-82.64, 28.89], + [-82.76, 29.0], + [-82.8, 29.15], + [-82.99, 29.18], + [-83.22, 29.42], + [-83.4, 29.52], + [-83.41, 29.67], + [-83.54, 29.72], + [-83.64, 29.89], + [-84.02, 30.1], + [-84.36, 30.06], + [-84.34, 29.9], + [-84.45, 29.93], + [-84.87, 29.74], + [-85.31, 29.7], + [-85.3, 29.81], + [-85.4, 29.94], + [-85.92, 30.24], + [-86.3, 30.36], + [-86.63, 30.4], + [-87.52, 30.28], + [-87.37, 30.43], + [-87.45, 30.51], + [-87.41, 30.67], + [-87.63, 30.87], + [-87.6, 31.0], + [-85.5, 31.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Georgia" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-83.11, 35.0], + [-83.32, 34.79], + [-83.34, 34.68], + [-83.01, 34.47], + [-82.9, 34.49], + [-82.75, 34.27], + [-82.71, 34.15], + [-82.56, 33.94], + [-82.33, 33.82], + [-82.19, 33.63], + [-81.93, 33.46], + [-81.94, 33.35], + [-81.76, 33.16], + [-81.49, 33.01], + [-81.43, 32.84], + [-81.42, 32.63], + [-81.28, 32.56], + [-81.12, 32.29], + [-81.12, 32.12], + [-80.89, 32.03], + [-81.13, 31.69], + [-81.18, 31.52], + [-81.28, 31.36], + [-81.29, 31.21], + [-81.4, 31.13], + [-81.44, 30.71], + [-81.72, 30.75], + [-81.95, 30.83], + [-82.04, 30.75], + [-82.0, 30.56], + [-82.05, 30.36], + [-82.17, 30.36], + [-82.22, 30.57], + [-84.87, 30.71], + [-85.11, 31.28], + [-85.04, 31.54], + [-85.14, 31.84], + [-85.05, 32.01], + [-85.06, 32.14], + [-84.89, 32.26], + [-85.0, 32.32], + [-84.96, 32.42], + [-85.07, 32.58], + [-85.18, 32.86], + [-85.61, 34.98], + [-83.11, 35.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Hawaii" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-101.93, 20.55], + [-102.18, 20.64], + [-102.22, 20.72], + [-102.19, 20.95], + [-102.36, 21.33], + [-102.23, 21.46], + [-102.13, 21.63], + [-102.2, 21.75], + [-102.18, 21.87], + [-101.9, 21.73], + [-101.58, 21.62], + [-101.39, 21.47], + [-101.39, 21.34], + [-101.11, 21.12], + [-101.28, 20.95], + [-101.6, 20.87], + [-101.81, 20.73], + [-101.93, 20.55] + ] + ], + [ + [ + [-102.89, 22.63], + [-102.77, 22.49], + [-102.62, 22.55], + [-102.3, 22.39], + [-102.35, 22.25], + [-102.68, 22.18], + [-102.75, 22.21], + [-102.76, 22.38], + [-102.93, 22.42], + [-103.0, 22.52], + [-102.89, 22.63] + ] + ], + [ + [ + [-103.28, 22.81], + [-103.38, 22.71], + [-103.61, 22.71], + [-103.54, 22.82], + [-103.28, 22.81] + ] + ], + [ + [ + [-104.25, 23.3], + [-104.14, 23.06], + [-104.2, 22.93], + [-104.41, 22.9], + [-104.55, 23.18], + [-104.43, 23.19], + [-104.25, 23.3] + ] + ], + [ + [ + [-105.77, 23.83], + [-105.65, 23.82], + [-105.6, 23.71], + [-105.63, 23.57], + [-105.75, 23.47], + [-106.06, 23.59], + [-106.03, 23.75], + [-105.77, 23.83] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Idaho" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-116.05, 49.0], + [-116.05, 47.98], + [-115.72, 47.7], + [-115.72, 47.42], + [-115.53, 47.3], + [-115.32, 47.26], + [-115.3, 47.19], + [-114.93, 46.92], + [-114.89, 46.81], + [-114.62, 46.71], + [-114.61, 46.64], + [-114.32, 46.65], + [-114.46, 46.27], + [-114.49, 46.04], + [-114.39, 45.88], + [-114.57, 45.77], + [-114.5, 45.67], + [-114.55, 45.56], + [-114.33, 45.46], + [-114.09, 45.59], + [-113.99, 45.7], + [-113.81, 45.6], + [-113.83, 45.52], + [-113.74, 45.33], + [-113.57, 45.13], + [-113.45, 45.06], + [-113.46, 44.87], + [-113.34, 44.78], + [-113.13, 44.77], + [-113.0, 44.45], + [-112.89, 44.39], + [-112.78, 44.49], + [-112.47, 44.48], + [-112.24, 44.57], + [-112.1, 44.52], + [-111.87, 44.56], + [-111.82, 44.51], + [-111.62, 44.55], + [-111.39, 44.76], + [-111.23, 44.58], + [-111.05, 44.48], + [-111.05, 42.0], + [-117.03, 42.0], + [-117.03, 43.83], + [-116.9, 44.16], + [-116.98, 44.24], + [-117.17, 44.26], + [-117.24, 44.39], + [-117.04, 44.75], + [-116.93, 44.78], + [-116.83, 44.93], + [-116.85, 45.02], + [-116.73, 45.14], + [-116.67, 45.32], + [-116.46, 45.62], + [-116.55, 45.75], + [-116.78, 45.82], + [-116.92, 45.99], + [-116.92, 46.17], + [-117.06, 46.34], + [-117.03, 49.0], + [-116.05, 49.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Illinois" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-90.64, 42.51], + [-87.8, 42.49], + [-87.84, 42.3], + [-87.68, 42.08], + [-87.52, 41.71], + [-87.53, 39.35], + [-87.64, 39.17], + [-87.51, 38.96], + [-87.5, 38.78], + [-87.62, 38.64], + [-87.66, 38.51], + [-87.84, 38.29], + [-87.95, 38.28], + [-87.92, 38.15], + [-88.0, 38.1], + [-88.06, 37.87], + [-88.03, 37.8], + [-88.16, 37.66], + [-88.07, 37.48], + [-88.48, 37.39], + [-88.51, 37.29], + [-88.42, 37.15], + [-88.55, 37.07], + [-88.91, 37.22], + [-89.03, 37.21], + [-89.18, 37.04], + [-89.13, 36.98], + [-89.29, 36.99], + [-89.52, 37.28], + [-89.44, 37.35], + [-89.52, 37.54], + [-89.52, 37.69], + [-89.84, 37.9], + [-89.95, 37.88], + [-90.06, 38.01], + [-90.36, 38.22], + [-90.35, 38.37], + [-90.18, 38.63], + [-90.21, 38.73], + [-90.11, 38.85], + [-90.25, 38.92], + [-90.47, 38.96], + [-90.59, 38.87], + [-90.66, 38.93], + [-90.73, 39.26], + [-91.06, 39.47], + [-91.37, 39.73], + [-91.49, 40.03], + [-91.51, 40.24], + [-91.42, 40.38], + [-91.4, 40.56], + [-91.12, 40.67], + [-91.09, 40.82], + [-90.96, 40.92], + [-90.95, 41.1], + [-91.11, 41.24], + [-91.05, 41.41], + [-90.66, 41.46], + [-90.34, 41.59], + [-90.31, 41.74], + [-90.18, 41.81], + [-90.14, 42.0], + [-90.17, 42.13], + [-90.39, 42.23], + [-90.42, 42.33], + [-90.64, 42.51] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Indiana" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-85.99, 41.76], + [-84.81, 41.76], + [-84.82, 39.1], + [-84.89, 39.06], + [-84.81, 38.79], + [-84.99, 38.78], + [-85.17, 38.69], + [-85.43, 38.73], + [-85.42, 38.53], + [-85.59, 38.45], + [-85.66, 38.33], + [-85.83, 38.28], + [-85.92, 38.02], + [-86.04, 37.96], + [-86.26, 38.05], + [-86.3, 38.17], + [-86.52, 38.04], + [-86.5, 37.93], + [-86.73, 37.89], + [-86.8, 37.99], + [-87.05, 37.89], + [-87.13, 37.79], + [-87.38, 37.94], + [-87.51, 37.9], + [-87.6, 37.98], + [-87.68, 37.9], + [-87.93, 37.89], + [-88.03, 37.8], + [-88.06, 37.87], + [-88.0, 38.1], + [-87.92, 38.15], + [-87.95, 38.28], + [-87.84, 38.29], + [-87.66, 38.51], + [-87.62, 38.64], + [-87.5, 38.78], + [-87.51, 38.96], + [-87.64, 39.17], + [-87.53, 39.35], + [-87.52, 41.71], + [-87.43, 41.64], + [-87.12, 41.64], + [-86.82, 41.76], + [-85.99, 41.76] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Iowa" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-91.37, 43.5], + [-91.22, 43.5], + [-91.2, 43.35], + [-91.06, 43.25], + [-91.18, 43.13], + [-91.14, 42.91], + [-91.07, 42.75], + [-90.71, 42.64], + [-90.64, 42.51], + [-90.42, 42.33], + [-90.39, 42.23], + [-90.17, 42.13], + [-90.14, 42.0], + [-90.18, 41.81], + [-90.31, 41.74], + [-90.34, 41.59], + [-90.66, 41.46], + [-91.05, 41.41], + [-91.11, 41.24], + [-90.95, 41.1], + [-90.96, 40.92], + [-91.09, 40.82], + [-91.12, 40.67], + [-91.4, 40.56], + [-91.42, 40.38], + [-91.53, 40.41], + [-91.73, 40.62], + [-94.63, 40.57], + [-95.77, 40.59], + [-95.88, 40.72], + [-95.83, 40.98], + [-95.93, 41.2], + [-95.92, 41.45], + [-96.1, 41.54], + [-96.12, 41.68], + [-96.06, 41.8], + [-96.13, 41.97], + [-96.26, 42.04], + [-96.45, 42.49], + [-96.63, 42.71], + [-96.54, 42.86], + [-96.51, 43.05], + [-96.43, 43.12], + [-96.56, 43.22], + [-96.53, 43.4], + [-96.58, 43.48], + [-96.45, 43.5], + [-91.37, 43.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Kansas" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-101.91, 40.0], + [-95.31, 40.0], + [-95.21, 39.91], + [-94.88, 39.83], + [-95.11, 39.54], + [-94.98, 39.44], + [-94.82, 39.21], + [-94.61, 39.16], + [-94.62, 37.0], + [-102.04, 36.99], + [-102.05, 40.0], + [-101.91, 40.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Kentucky" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-83.9, 38.77], + [-83.68, 38.63], + [-83.52, 38.7], + [-83.14, 38.63], + [-83.03, 38.73], + [-82.89, 38.76], + [-82.85, 38.59], + [-82.73, 38.56], + [-82.59, 38.42], + [-82.62, 38.12], + [-82.5, 37.93], + [-82.34, 37.78], + [-82.29, 37.67], + [-82.1, 37.55], + [-81.97, 37.54], + [-82.35, 37.27], + [-82.72, 37.12], + [-82.72, 37.04], + [-82.87, 36.98], + [-82.88, 36.89], + [-83.07, 36.85], + [-83.14, 36.74], + [-83.69, 36.58], + [-86.59, 36.66], + [-87.85, 36.63], + [-88.07, 36.68], + [-88.05, 36.5], + [-89.42, 36.5], + [-89.36, 36.62], + [-89.22, 36.58], + [-89.13, 36.98], + [-89.18, 37.04], + [-89.03, 37.21], + [-88.91, 37.22], + [-88.55, 37.07], + [-88.42, 37.15], + [-88.51, 37.29], + [-88.48, 37.39], + [-88.07, 37.48], + [-88.16, 37.66], + [-87.93, 37.89], + [-87.68, 37.9], + [-87.6, 37.98], + [-87.51, 37.9], + [-87.38, 37.94], + [-87.13, 37.79], + [-87.05, 37.89], + [-86.8, 37.99], + [-86.73, 37.89], + [-86.5, 37.93], + [-86.52, 38.04], + [-86.3, 38.17], + [-86.26, 38.05], + [-86.04, 37.96], + [-85.92, 38.02], + [-85.83, 38.28], + [-85.66, 38.33], + [-85.59, 38.45], + [-85.42, 38.53], + [-85.43, 38.73], + [-85.17, 38.69], + [-84.99, 38.78], + [-84.81, 38.79], + [-84.89, 39.06], + [-84.82, 39.1], + [-84.43, 39.1], + [-84.23, 38.9], + [-84.22, 38.81], + [-83.9, 38.77] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Louisiana" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-93.61, 33.02], + [-91.17, 33.0], + [-91.07, 32.89], + [-91.14, 32.84], + [-91.15, 32.64], + [-91.01, 32.51], + [-90.99, 32.22], + [-91.11, 31.99], + [-91.34, 31.85], + [-91.4, 31.62], + [-91.5, 31.64], + [-91.52, 31.28], + [-91.64, 31.27], + [-91.57, 31.07], + [-91.64, 31.0], + [-89.75, 31.0], + [-89.85, 30.67], + [-89.68, 30.45], + [-89.64, 30.29], + [-89.52, 30.18], + [-89.82, 30.04], + [-89.84, 29.95], + [-89.6, 29.88], + [-89.5, 30.04], + [-89.29, 29.88], + [-89.3, 29.75], + [-89.42, 29.7], + [-89.65, 29.75], + [-89.62, 29.66], + [-89.7, 29.51], + [-89.51, 29.39], + [-89.2, 29.35], + [-89.09, 29.2], + [-89.0, 29.18], + [-89.16, 29.01], + [-89.34, 29.04], + [-89.48, 29.22], + [-89.85, 29.31], + [-89.85, 29.48], + [-90.03, 29.43], + [-90.02, 29.28], + [-90.1, 29.15], + [-90.23, 29.13], + [-90.33, 29.28], + [-90.56, 29.28], + [-90.65, 29.13], + [-90.8, 29.09], + [-90.96, 29.18], + [-91.09, 29.19], + [-91.22, 29.44], + [-91.45, 29.55], + [-91.53, 29.53], + [-91.62, 29.74], + [-91.88, 29.71], + [-91.89, 29.84], + [-92.15, 29.72], + [-92.11, 29.62], + [-92.31, 29.54], + [-92.62, 29.58], + [-92.97, 29.72], + [-93.23, 29.78], + [-93.77, 29.73], + [-93.84, 29.69], + [-93.93, 29.79], + [-93.69, 30.14], + [-93.77, 30.33], + [-93.7, 30.44], + [-93.73, 30.58], + [-93.63, 30.68], + [-93.53, 30.94], + [-93.54, 31.15], + [-93.82, 31.56], + [-93.82, 31.78], + [-94.04, 31.99], + [-94.04, 33.02], + [-93.61, 33.02] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Maine" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-70.7, 43.06], + [-70.82, 43.13], + [-70.81, 43.23], + [-70.97, 43.34], + [-71.08, 45.3], + [-70.65, 45.44], + [-70.72, 45.51], + [-70.56, 45.66], + [-70.39, 45.74], + [-70.42, 45.8], + [-70.26, 45.89], + [-70.31, 46.06], + [-70.21, 46.33], + [-70.06, 46.42], + [-70.0, 46.69], + [-69.23, 47.46], + [-69.04, 47.43], + [-69.03, 47.24], + [-68.9, 47.18], + [-68.58, 47.29], + [-68.38, 47.29], + [-68.23, 47.36], + [-67.95, 47.2], + [-67.79, 47.07], + [-67.8, 45.68], + [-67.46, 45.6], + [-67.51, 45.49], + [-67.42, 45.38], + [-67.49, 45.28], + [-67.35, 45.13], + [-67.16, 45.16], + [-66.98, 44.8], + [-67.19, 44.65], + [-67.31, 44.71], + [-67.41, 44.6], + [-67.55, 44.62], + [-67.57, 44.53], + [-67.75, 44.54], + [-68.05, 44.33], + [-68.12, 44.48], + [-68.22, 44.49], + [-68.17, 44.33], + [-68.4, 44.25], + [-68.46, 44.38], + [-68.57, 44.31], + [-68.83, 44.31], + [-68.83, 44.46], + [-68.98, 44.43], + [-68.96, 44.32], + [-69.1, 44.1], + [-69.07, 44.04], + [-69.26, 43.92], + [-69.44, 43.97], + [-69.55, 43.84], + [-69.71, 43.82], + [-69.83, 43.72], + [-69.99, 43.74], + [-70.03, 43.85], + [-70.25, 43.68], + [-70.19, 43.57], + [-70.36, 43.53], + [-70.37, 43.44], + [-70.56, 43.32], + [-70.7, 43.06] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Maryland" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-79.48, 39.72], + [-75.79, 39.72], + [-75.69, 38.46], + [-75.05, 38.45], + [-75.24, 38.03], + [-75.89, 37.91], + [-75.88, 38.07], + [-75.96, 38.14], + [-75.85, 38.21], + [-76.0, 38.37], + [-76.05, 38.3], + [-76.26, 38.32], + [-76.33, 38.5], + [-76.26, 38.5], + [-76.26, 38.74], + [-76.19, 38.83], + [-76.28, 39.15], + [-76.17, 39.33], + [-76.0, 39.37], + [-75.97, 39.56], + [-76.1, 39.54], + [-76.1, 39.44], + [-76.37, 39.31], + [-76.44, 39.2], + [-76.46, 38.91], + [-76.56, 38.77], + [-76.51, 38.54], + [-76.38, 38.38], + [-76.4, 38.26], + [-76.32, 38.14], + [-76.36, 38.06], + [-76.59, 38.22], + [-76.92, 38.29], + [-77.02, 38.45], + [-77.21, 38.36], + [-77.28, 38.48], + [-77.13, 38.63], + [-77.04, 38.79], + [-76.91, 38.9], + [-77.04, 38.99], + [-77.12, 38.93], + [-77.25, 39.03], + [-77.46, 39.08], + [-77.46, 39.22], + [-77.57, 39.31], + [-77.72, 39.32], + [-77.83, 39.6], + [-78.0, 39.6], + [-78.17, 39.69], + [-78.27, 39.62], + [-78.43, 39.62], + [-78.47, 39.51], + [-78.77, 39.59], + [-78.96, 39.44], + [-79.09, 39.47], + [-79.29, 39.3], + [-79.49, 39.21], + [-79.48, 39.72] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Massachusetts" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-70.92, 42.89], + [-70.82, 42.87], + [-70.78, 42.7], + [-70.82, 42.55], + [-70.98, 42.42], + [-70.99, 42.27], + [-70.77, 42.25], + [-70.64, 42.09], + [-70.66, 41.96], + [-70.55, 41.93], + [-70.54, 41.81], + [-70.26, 41.72], + [-69.94, 41.81], + [-70.01, 41.67], + [-70.48, 41.55], + [-70.66, 41.55], + [-70.76, 41.64], + [-70.93, 41.61], + [-70.93, 41.54], + [-71.12, 41.5], + [-71.2, 41.68], + [-71.33, 41.78], + [-71.38, 42.02], + [-73.49, 42.05], + [-73.51, 42.09], + [-73.27, 42.75], + [-71.3, 42.7], + [-71.19, 42.79], + [-70.92, 42.89] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Michigan" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-83.45, 41.73], + [-84.81, 41.69], + [-84.81, 41.76], + [-86.82, 41.76], + [-86.62, 41.89], + [-86.48, 42.12], + [-86.36, 42.25], + [-86.26, 42.44], + [-86.21, 42.72], + [-86.23, 43.01], + [-86.53, 43.59], + [-86.43, 43.81], + [-86.5, 44.08], + [-86.27, 44.34], + [-86.22, 44.57], + [-86.25, 44.69], + [-86.09, 44.74], + [-86.07, 44.9], + [-85.81, 44.95], + [-85.61, 45.13], + [-85.63, 44.77], + [-85.52, 44.75], + [-85.39, 44.93], + [-85.39, 45.24], + [-85.31, 45.31], + [-85.03, 45.36], + [-85.12, 45.58], + [-84.94, 45.76], + [-84.71, 45.77], + [-84.46, 45.65], + [-84.22, 45.64], + [-84.1, 45.49], + [-83.91, 45.48], + [-83.6, 45.35], + [-83.49, 45.36], + [-83.32, 45.14], + [-83.45, 45.03], + [-83.32, 44.88], + [-83.27, 44.71], + [-83.33, 44.34], + [-83.54, 44.25], + [-83.59, 44.05], + [-83.83, 43.99], + [-83.96, 43.76], + [-83.91, 43.67], + [-83.67, 43.59], + [-83.48, 43.71], + [-83.26, 43.97], + [-82.92, 44.07], + [-82.75, 43.99], + [-82.64, 43.85], + [-82.54, 43.44], + [-82.52, 43.23], + [-82.41, 42.98], + [-82.52, 42.61], + [-82.68, 42.56], + [-82.69, 42.69], + [-82.8, 42.65], + [-82.92, 42.35], + [-83.13, 42.24], + [-83.19, 42.01], + [-83.44, 41.81], + [-83.45, 41.73] + ] + ], + [ + [ + [-85.51, 45.73], + [-85.49, 45.61], + [-85.62, 45.59], + [-85.57, 45.76], + [-85.51, 45.73] + ] + ], + [ + [ + [-87.59, 45.1], + [-87.74, 45.2], + [-87.65, 45.34], + [-87.89, 45.36], + [-87.79, 45.5], + [-87.78, 45.68], + [-87.99, 45.8], + [-88.1, 45.92], + [-88.53, 46.02], + [-88.66, 45.99], + [-89.09, 46.14], + [-90.12, 46.34], + [-90.23, 46.51], + [-90.42, 46.57], + [-90.03, 46.67], + [-89.85, 46.79], + [-89.41, 46.84], + [-89.13, 46.99], + [-89.0, 47.0], + [-88.89, 47.1], + [-88.58, 47.25], + [-88.42, 47.37], + [-88.18, 47.46], + [-87.96, 47.38], + [-88.44, 46.97], + [-88.44, 46.79], + [-88.25, 46.93], + [-87.9, 46.91], + [-87.63, 46.81], + [-87.39, 46.54], + [-87.26, 46.49], + [-87.01, 46.53], + [-86.95, 46.47], + [-86.7, 46.44], + [-86.16, 46.67], + [-85.51, 46.68], + [-85.26, 46.75], + [-85.06, 46.76], + [-85.03, 46.48], + [-84.83, 46.44], + [-84.63, 46.49], + [-84.55, 46.42], + [-84.42, 46.5], + [-84.13, 46.53], + [-84.12, 46.18], + [-83.99, 46.03], + [-83.79, 45.99], + [-83.77, 46.09], + [-83.58, 46.09], + [-83.48, 45.99], + [-83.56, 45.91], + [-84.11, 45.98], + [-84.37, 45.93], + [-84.66, 46.05], + [-84.74, 45.94], + [-84.7, 45.85], + [-84.83, 45.87], + [-85.02, 46.01], + [-85.34, 46.09], + [-85.5, 46.1], + [-85.66, 45.97], + [-85.92, 45.93], + [-86.21, 45.96], + [-86.32, 45.91], + [-86.35, 45.8], + [-86.66, 45.7], + [-86.65, 45.83], + [-86.78, 45.86], + [-86.84, 45.73], + [-87.07, 45.72], + [-87.17, 45.66], + [-87.33, 45.42], + [-87.61, 45.12], + [-87.59, 45.1] + ] + ], + [ + [ + [-88.81, 47.98], + [-89.06, 47.85], + [-89.19, 47.83], + [-89.18, 47.94], + [-88.55, 48.17], + [-88.67, 48.01], + [-88.81, 47.98] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Minnesota" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-92.01, 46.71], + [-92.09, 46.75], + [-92.29, 46.67], + [-92.29, 46.08], + [-92.35, 46.02], + [-92.64, 45.93], + [-92.87, 45.72], + [-92.89, 45.58], + [-92.77, 45.57], + [-92.64, 45.44], + [-92.76, 45.29], + [-92.74, 45.12], + [-92.81, 44.75], + [-92.55, 44.57], + [-92.34, 44.55], + [-92.23, 44.44], + [-91.93, 44.33], + [-91.88, 44.2], + [-91.59, 44.03], + [-91.43, 43.99], + [-91.24, 43.78], + [-91.27, 43.62], + [-91.22, 43.5], + [-96.45, 43.5], + [-96.45, 45.3], + [-96.68, 45.41], + [-96.86, 45.6], + [-96.58, 45.82], + [-96.56, 45.93], + [-96.6, 46.33], + [-96.72, 46.44], + [-96.8, 46.66], + [-96.79, 46.92], + [-96.82, 46.97], + [-96.86, 47.61], + [-97.13, 48.14], + [-97.16, 48.55], + [-97.1, 48.68], + [-97.23, 49.0], + [-95.15, 49.0], + [-95.15, 49.38], + [-94.96, 49.37], + [-94.82, 49.3], + [-94.69, 48.78], + [-94.59, 48.72], + [-94.26, 48.7], + [-94.22, 48.65], + [-93.84, 48.63], + [-93.79, 48.52], + [-93.47, 48.55], + [-93.47, 48.59], + [-93.21, 48.64], + [-92.98, 48.62], + [-92.73, 48.54], + [-92.66, 48.44], + [-92.51, 48.45], + [-92.37, 48.22], + [-92.3, 48.32], + [-92.05, 48.36], + [-92.01, 48.27], + [-91.71, 48.2], + [-91.71, 48.11], + [-91.57, 48.04], + [-91.26, 48.08], + [-91.08, 48.18], + [-90.84, 48.24], + [-90.75, 48.09], + [-90.58, 48.12], + [-90.38, 48.09], + [-90.14, 48.11], + [-89.87, 47.99], + [-89.62, 48.01], + [-89.64, 47.95], + [-89.97, 47.83], + [-90.44, 47.73], + [-90.74, 47.63], + [-91.17, 47.37], + [-91.64, 47.03], + [-92.09, 46.79], + [-92.01, 46.71] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Mississippi" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-88.47, 35.0], + [-88.2, 35.0], + [-88.1, 34.89], + [-88.47, 31.9], + [-88.39, 30.37], + [-88.5, 30.32], + [-88.74, 30.35], + [-88.84, 30.41], + [-89.08, 30.37], + [-89.42, 30.25], + [-89.52, 30.18], + [-89.64, 30.29], + [-89.68, 30.45], + [-89.85, 30.67], + [-89.75, 31.0], + [-91.64, 31.0], + [-91.57, 31.07], + [-91.64, 31.27], + [-91.52, 31.28], + [-91.5, 31.64], + [-91.4, 31.62], + [-91.34, 31.85], + [-91.11, 31.99], + [-90.99, 32.22], + [-91.01, 32.51], + [-91.15, 32.64], + [-91.14, 32.84], + [-91.07, 32.89], + [-91.17, 33.0], + [-91.09, 33.14], + [-91.14, 33.35], + [-91.06, 33.43], + [-91.23, 33.56], + [-91.07, 33.87], + [-90.89, 34.03], + [-90.95, 34.14], + [-90.74, 34.3], + [-90.75, 34.37], + [-90.57, 34.42], + [-90.59, 34.62], + [-90.48, 34.66], + [-90.41, 34.83], + [-90.25, 34.91], + [-90.31, 35.0], + [-88.47, 35.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Missouri" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-91.83, 40.61], + [-91.73, 40.62], + [-91.53, 40.41], + [-91.42, 40.38], + [-91.51, 40.24], + [-91.49, 40.03], + [-91.37, 39.73], + [-91.06, 39.47], + [-90.73, 39.26], + [-90.66, 38.93], + [-90.59, 38.87], + [-90.47, 38.96], + [-90.25, 38.92], + [-90.11, 38.85], + [-90.21, 38.73], + [-90.18, 38.63], + [-90.35, 38.37], + [-90.36, 38.22], + [-90.06, 38.01], + [-89.95, 37.88], + [-89.84, 37.9], + [-89.52, 37.69], + [-89.52, 37.54], + [-89.44, 37.35], + [-89.52, 37.28], + [-89.29, 36.99], + [-89.13, 36.98], + [-89.22, 36.58], + [-89.36, 36.62], + [-89.42, 36.5], + [-89.54, 36.5], + [-89.53, 36.25], + [-89.73, 36.0], + [-90.38, 36.0], + [-90.22, 36.18], + [-90.06, 36.3], + [-90.15, 36.5], + [-94.62, 36.5], + [-94.61, 39.16], + [-94.82, 39.21], + [-94.98, 39.44], + [-95.11, 39.54], + [-94.88, 39.83], + [-95.21, 39.91], + [-95.55, 40.26], + [-95.77, 40.59], + [-94.63, 40.57], + [-91.83, 40.61] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Montana" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-104.05, 49.0], + [-104.04, 45.0], + [-111.05, 45.0], + [-111.05, 44.48], + [-111.23, 44.58], + [-111.39, 44.76], + [-111.62, 44.55], + [-111.82, 44.51], + [-111.87, 44.56], + [-112.1, 44.52], + [-112.24, 44.57], + [-112.47, 44.48], + [-112.78, 44.49], + [-112.89, 44.39], + [-113.0, 44.45], + [-113.13, 44.77], + [-113.34, 44.78], + [-113.46, 44.87], + [-113.45, 45.06], + [-113.57, 45.13], + [-113.74, 45.33], + [-113.83, 45.52], + [-113.81, 45.6], + [-113.99, 45.7], + [-114.09, 45.59], + [-114.33, 45.46], + [-114.55, 45.56], + [-114.5, 45.67], + [-114.57, 45.77], + [-114.39, 45.88], + [-114.49, 46.04], + [-114.46, 46.27], + [-114.32, 46.65], + [-114.61, 46.64], + [-114.62, 46.71], + [-114.89, 46.81], + [-114.93, 46.92], + [-115.3, 47.19], + [-115.32, 47.26], + [-115.53, 47.3], + [-115.72, 47.42], + [-115.72, 47.7], + [-116.05, 47.98], + [-116.05, 49.0], + [-104.05, 49.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Nebraska" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-103.32, 43.0], + [-98.5, 43.0], + [-98.47, 42.95], + [-97.95, 42.77], + [-97.83, 42.87], + [-97.22, 42.84], + [-96.69, 42.66], + [-96.63, 42.52], + [-96.45, 42.49], + [-96.26, 42.04], + [-96.13, 41.97], + [-96.06, 41.8], + [-96.12, 41.68], + [-96.1, 41.54], + [-95.92, 41.45], + [-95.93, 41.2], + [-95.83, 40.98], + [-95.88, 40.72], + [-95.55, 40.26], + [-95.31, 40.0], + [-102.05, 40.0], + [-102.05, 41.0], + [-104.05, 41.0], + [-104.05, 43.0], + [-103.32, 43.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Nevada" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-117.03, 42.0], + [-114.04, 42.0], + [-114.05, 36.2], + [-114.15, 36.03], + [-114.25, 36.02], + [-114.37, 36.14], + [-114.74, 36.1], + [-114.68, 35.52], + [-114.6, 35.32], + [-114.57, 35.14], + [-114.63, 35.0], + [-115.85, 35.97], + [-117.5, 37.22], + [-120.0, 39.0], + [-120.0, 42.0], + [-117.03, 42.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "New Hampshire" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-71.08, 45.3], + [-70.97, 43.34], + [-70.81, 43.23], + [-70.82, 43.13], + [-70.7, 43.06], + [-70.82, 42.87], + [-70.92, 42.89], + [-71.19, 42.79], + [-71.3, 42.7], + [-72.46, 42.73], + [-72.54, 42.81], + [-72.53, 42.95], + [-72.45, 43.01], + [-72.46, 43.15], + [-72.38, 43.57], + [-72.2, 43.77], + [-72.12, 43.99], + [-72.03, 44.08], + [-72.03, 44.32], + [-71.7, 44.42], + [-71.54, 44.59], + [-71.63, 44.75], + [-71.49, 44.91], + [-71.5, 45.01], + [-71.36, 45.27], + [-71.13, 45.24], + [-71.08, 45.3] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "New Jersey" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-74.24, 41.14], + [-73.9, 41.0], + [-74.02, 40.71], + [-74.19, 40.64], + [-74.27, 40.49], + [-74.0, 40.41], + [-73.98, 40.3], + [-74.1, 39.76], + [-74.41, 39.36], + [-74.61, 39.25], + [-74.8, 38.99], + [-74.89, 39.16], + [-75.18, 39.24], + [-75.53, 39.46], + [-75.56, 39.63], + [-75.41, 39.8], + [-75.15, 39.89], + [-75.13, 39.96], + [-74.82, 40.13], + [-74.77, 40.22], + [-75.06, 40.42], + [-75.07, 40.54], + [-75.2, 40.58], + [-75.21, 40.69], + [-75.05, 40.87], + [-75.13, 40.97], + [-74.88, 41.18], + [-74.83, 41.29], + [-74.7, 41.36], + [-74.24, 41.14] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "New Mexico" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-107.42, 37.0], + [-103.0, 37.0], + [-103.0, 36.5], + [-103.04, 36.5], + [-103.07, 32.0], + [-106.62, 32.0], + [-106.64, 31.9], + [-106.53, 31.79], + [-108.21, 31.79], + [-108.21, 31.33], + [-109.05, 31.33], + [-109.04, 37.0], + [-107.42, 37.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "New York" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-73.34, 45.01], + [-73.33, 44.8], + [-73.39, 44.62], + [-73.29, 44.44], + [-73.32, 44.25], + [-73.44, 44.04], + [-73.35, 43.77], + [-73.4, 43.69], + [-73.25, 43.52], + [-73.27, 42.75], + [-73.51, 42.09], + [-73.49, 42.05], + [-73.55, 41.29], + [-73.48, 41.21], + [-73.73, 41.1], + [-73.66, 40.99], + [-73.23, 40.91], + [-73.14, 40.97], + [-72.77, 40.97], + [-72.59, 41.0], + [-72.28, 41.16], + [-72.26, 41.04], + [-72.1, 40.99], + [-72.47, 40.85], + [-73.24, 40.63], + [-73.56, 40.58], + [-73.78, 40.59], + [-73.94, 40.54], + [-74.02, 40.71], + [-73.9, 41.0], + [-74.7, 41.36], + [-74.74, 41.43], + [-74.89, 41.44], + [-75.07, 41.61], + [-75.05, 41.75], + [-75.17, 41.87], + [-75.25, 41.86], + [-75.36, 42.0], + [-79.76, 42.0], + [-79.76, 42.27], + [-79.15, 42.55], + [-79.05, 42.69], + [-78.85, 42.78], + [-78.93, 42.95], + [-79.01, 42.99], + [-79.07, 43.26], + [-78.49, 43.38], + [-77.76, 43.34], + [-77.53, 43.23], + [-77.39, 43.28], + [-76.96, 43.27], + [-76.7, 43.34], + [-76.42, 43.52], + [-76.24, 43.53], + [-76.23, 43.8], + [-76.14, 43.96], + [-76.36, 44.07], + [-76.31, 44.2], + [-75.91, 44.37], + [-75.76, 44.51], + [-75.28, 44.85], + [-74.83, 45.02], + [-74.15, 44.99], + [-73.34, 45.01] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "North Carolina" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.98, 36.56], + [-75.87, 36.55], + [-75.75, 36.15], + [-76.03, 36.19], + [-76.07, 36.14], + [-76.41, 36.08], + [-76.46, 36.03], + [-76.68, 36.01], + [-76.67, 35.94], + [-76.4, 35.99], + [-76.36, 35.94], + [-76.06, 35.99], + [-75.96, 35.9], + [-75.78, 35.94], + [-75.72, 35.7], + [-75.78, 35.58], + [-75.9, 35.57], + [-76.15, 35.32], + [-76.48, 35.31], + [-76.54, 35.14], + [-76.39, 34.97], + [-76.28, 34.94], + [-76.49, 34.66], + [-76.67, 34.69], + [-76.99, 34.67], + [-77.21, 34.61], + [-77.56, 34.42], + [-77.83, 34.16], + [-77.97, 33.85], + [-78.18, 33.92], + [-78.54, 33.85], + [-79.68, 34.8], + [-80.8, 34.82], + [-80.78, 34.94], + [-80.93, 35.11], + [-81.04, 35.04], + [-81.04, 35.15], + [-82.28, 35.2], + [-82.55, 35.16], + [-82.76, 35.07], + [-83.11, 35.0], + [-84.32, 34.99], + [-84.29, 35.23], + [-84.1, 35.25], + [-84.02, 35.41], + [-83.77, 35.56], + [-83.5, 35.57], + [-83.25, 35.72], + [-82.99, 35.77], + [-82.78, 36.0], + [-82.64, 36.06], + [-82.61, 35.97], + [-82.22, 36.16], + [-82.04, 36.12], + [-81.91, 36.3], + [-81.72, 36.35], + [-81.68, 36.59], + [-80.98, 36.56] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "North Dakota" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-97.23, 49.0], + [-97.1, 48.68], + [-97.16, 48.55], + [-97.13, 48.14], + [-96.86, 47.61], + [-96.82, 46.97], + [-96.79, 46.92], + [-96.8, 46.66], + [-96.72, 46.44], + [-96.6, 46.33], + [-96.56, 45.93], + [-104.05, 45.94], + [-104.05, 49.0], + [-97.23, 49.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Ohio" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.52, 41.98], + [-80.52, 40.64], + [-80.67, 40.58], + [-80.6, 40.47], + [-80.6, 40.32], + [-80.74, 40.08], + [-80.83, 39.71], + [-81.22, 39.39], + [-81.35, 39.34], + [-81.46, 39.41], + [-81.57, 39.27], + [-81.69, 39.27], + [-81.81, 39.08], + [-81.78, 38.97], + [-81.89, 38.87], + [-82.04, 39.03], + [-82.22, 38.79], + [-82.17, 38.63], + [-82.29, 38.58], + [-82.33, 38.45], + [-82.59, 38.42], + [-82.73, 38.56], + [-82.85, 38.59], + [-82.89, 38.76], + [-83.03, 38.73], + [-83.14, 38.63], + [-83.52, 38.7], + [-83.68, 38.63], + [-83.9, 38.77], + [-84.22, 38.81], + [-84.23, 38.9], + [-84.43, 39.1], + [-84.82, 39.1], + [-84.81, 41.69], + [-83.45, 41.73], + [-83.07, 41.6], + [-82.93, 41.51], + [-82.84, 41.59], + [-82.62, 41.43], + [-82.48, 41.38], + [-82.01, 41.51], + [-81.74, 41.49], + [-81.44, 41.67], + [-81.01, 41.85], + [-80.52, 41.98] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Oklahoma" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-100.09, 37.0], + [-94.62, 37.0], + [-94.62, 36.5], + [-94.43, 35.4], + [-94.48, 33.64], + [-94.87, 33.75], + [-94.97, 33.86], + [-95.22, 33.96], + [-95.29, 33.87], + [-95.55, 33.88], + [-95.6, 33.93], + [-95.84, 33.83], + [-95.94, 33.89], + [-96.15, 33.84], + [-96.35, 33.69], + [-96.42, 33.77], + [-96.63, 33.85], + [-96.85, 33.85], + [-96.92, 33.96], + [-97.17, 33.74], + [-97.26, 33.86], + [-97.37, 33.82], + [-97.46, 33.91], + [-97.69, 33.98], + [-97.87, 33.85], + [-97.95, 33.99], + [-98.09, 34.0], + [-98.17, 34.11], + [-98.36, 34.16], + [-98.49, 34.06], + [-98.57, 34.15], + [-98.77, 34.14], + [-98.99, 34.22], + [-99.19, 34.21], + [-99.26, 34.4], + [-99.58, 34.42], + [-99.7, 34.38], + [-99.92, 34.57], + [-100.0, 34.56], + [-100.0, 36.5], + [-103.0, 36.5], + [-103.0, 37.0], + [-100.09, 37.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Oregon" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-123.21, 46.17], + [-123.12, 46.19], + [-122.9, 46.08], + [-122.81, 45.96], + [-122.76, 45.66], + [-122.25, 45.55], + [-121.81, 45.71], + [-121.54, 45.73], + [-121.22, 45.67], + [-121.18, 45.6], + [-120.64, 45.75], + [-120.51, 45.7], + [-120.21, 45.73], + [-119.96, 45.82], + [-119.53, 45.91], + [-119.13, 45.93], + [-118.99, 46.0], + [-116.92, 45.99], + [-116.78, 45.82], + [-116.55, 45.75], + [-116.46, 45.62], + [-116.67, 45.32], + [-116.73, 45.14], + [-116.85, 45.02], + [-116.83, 44.93], + [-116.93, 44.78], + [-117.04, 44.75], + [-117.24, 44.39], + [-117.17, 44.26], + [-116.98, 44.24], + [-116.9, 44.16], + [-117.03, 43.83], + [-117.03, 42.0], + [-124.21, 42.0], + [-124.36, 42.12], + [-124.43, 42.44], + [-124.42, 42.66], + [-124.55, 42.84], + [-124.45, 43.0], + [-124.38, 43.27], + [-124.24, 43.56], + [-124.17, 43.81], + [-124.06, 44.66], + [-124.08, 44.77], + [-123.98, 45.14], + [-123.94, 45.66], + [-123.99, 45.94], + [-123.95, 46.11], + [-123.55, 46.26], + [-123.37, 46.15], + [-123.21, 46.17] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Pennsylvania" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-79.76, 42.25], + [-79.76, 42.0], + [-75.36, 42.0], + [-75.25, 41.86], + [-75.17, 41.87], + [-75.05, 41.75], + [-75.07, 41.61], + [-74.89, 41.44], + [-74.74, 41.43], + [-74.7, 41.36], + [-74.83, 41.29], + [-74.88, 41.18], + [-75.13, 40.97], + [-75.05, 40.87], + [-75.21, 40.69], + [-75.2, 40.58], + [-75.07, 40.54], + [-75.06, 40.42], + [-74.77, 40.22], + [-74.82, 40.13], + [-75.13, 39.96], + [-75.15, 39.89], + [-75.41, 39.8], + [-75.62, 39.83], + [-75.79, 39.72], + [-80.52, 39.72], + [-80.52, 41.98], + [-79.76, 42.25] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Rhode Island" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-71.2, 41.68], + [-71.12, 41.5], + [-71.32, 41.47], + [-71.2, 41.68] + ] + ], + [ + [ + [-71.53, 42.02], + [-71.38, 42.02], + [-71.33, 41.78], + [-71.22, 41.71], + [-71.34, 41.73], + [-71.45, 41.58], + [-71.48, 41.37], + [-71.86, 41.32], + [-71.8, 41.41], + [-71.8, 42.01], + [-71.53, 42.02] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "South Carolina" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-82.76, 35.07], + [-82.55, 35.16], + [-82.28, 35.2], + [-81.04, 35.15], + [-81.04, 35.04], + [-80.93, 35.11], + [-80.78, 34.94], + [-80.8, 34.82], + [-79.68, 34.8], + [-78.54, 33.85], + [-78.72, 33.8], + [-78.94, 33.64], + [-79.15, 33.38], + [-79.19, 33.17], + [-79.36, 33.01], + [-79.58, 33.01], + [-79.63, 32.89], + [-79.87, 32.76], + [-80.0, 32.61], + [-80.21, 32.55], + [-80.43, 32.4], + [-80.45, 32.33], + [-80.66, 32.25], + [-80.89, 32.03], + [-81.12, 32.12], + [-81.12, 32.29], + [-81.28, 32.56], + [-81.42, 32.63], + [-81.43, 32.84], + [-81.49, 33.01], + [-81.76, 33.16], + [-81.94, 33.35], + [-81.93, 33.46], + [-82.19, 33.63], + [-82.33, 33.82], + [-82.56, 33.94], + [-82.71, 34.15], + [-82.75, 34.27], + [-82.9, 34.49], + [-83.01, 34.47], + [-83.34, 34.68], + [-83.32, 34.79], + [-83.11, 35.0], + [-82.76, 35.07] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "South Dakota" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-104.05, 45.94], + [-96.56, 45.93], + [-96.58, 45.82], + [-96.86, 45.6], + [-96.68, 45.41], + [-96.45, 45.3], + [-96.45, 43.5], + [-96.58, 43.48], + [-96.53, 43.4], + [-96.56, 43.22], + [-96.43, 43.12], + [-96.51, 43.05], + [-96.54, 42.86], + [-96.63, 42.71], + [-96.45, 42.49], + [-96.63, 42.52], + [-96.69, 42.66], + [-97.22, 42.84], + [-97.83, 42.87], + [-97.95, 42.77], + [-98.47, 42.95], + [-98.5, 43.0], + [-104.05, 43.0], + [-104.05, 45.94] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Tennessee" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-88.05, 36.5], + [-88.07, 36.68], + [-87.85, 36.63], + [-86.59, 36.66], + [-84.54, 36.59], + [-81.68, 36.59], + [-81.72, 36.35], + [-81.91, 36.3], + [-82.04, 36.12], + [-82.22, 36.16], + [-82.61, 35.97], + [-82.64, 36.06], + [-82.78, 36.0], + [-82.99, 35.77], + [-83.25, 35.72], + [-83.5, 35.57], + [-83.77, 35.56], + [-84.02, 35.41], + [-84.1, 35.25], + [-84.29, 35.23], + [-84.32, 34.99], + [-90.31, 35.0], + [-90.21, 35.02], + [-90.11, 35.2], + [-90.13, 35.44], + [-89.94, 35.6], + [-89.91, 35.76], + [-89.76, 35.81], + [-89.73, 36.0], + [-89.53, 36.25], + [-89.54, 36.5], + [-88.05, 36.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Texas" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-101.81, 36.5], + [-100.0, 36.5], + [-100.0, 34.56], + [-99.92, 34.57], + [-99.7, 34.38], + [-99.58, 34.42], + [-99.26, 34.4], + [-99.19, 34.21], + [-98.99, 34.22], + [-98.77, 34.14], + [-98.57, 34.15], + [-98.49, 34.06], + [-98.36, 34.16], + [-98.17, 34.11], + [-98.09, 34.0], + [-97.95, 33.99], + [-97.87, 33.85], + [-97.69, 33.98], + [-97.46, 33.91], + [-97.37, 33.82], + [-97.26, 33.86], + [-97.17, 33.74], + [-96.92, 33.96], + [-96.85, 33.85], + [-96.63, 33.85], + [-96.42, 33.77], + [-96.35, 33.69], + [-96.15, 33.84], + [-95.94, 33.89], + [-95.84, 33.83], + [-95.6, 33.93], + [-95.55, 33.88], + [-95.29, 33.87], + [-95.22, 33.96], + [-94.97, 33.86], + [-94.87, 33.75], + [-94.48, 33.64], + [-94.38, 33.54], + [-94.18, 33.59], + [-94.04, 33.55], + [-94.04, 31.99], + [-93.82, 31.78], + [-93.82, 31.56], + [-93.54, 31.15], + [-93.53, 30.94], + [-93.63, 30.68], + [-93.73, 30.58], + [-93.7, 30.44], + [-93.77, 30.33], + [-93.69, 30.14], + [-93.93, 29.79], + [-93.84, 29.69], + [-94.0, 29.68], + [-94.52, 29.55], + [-94.71, 29.62], + [-94.74, 29.79], + [-94.87, 29.67], + [-94.97, 29.7], + [-95.02, 29.56], + [-94.91, 29.5], + [-94.9, 29.31], + [-95.08, 29.11], + [-95.38, 28.87], + [-95.99, 28.6], + [-96.05, 28.65], + [-96.23, 28.58], + [-96.23, 28.64], + [-96.48, 28.6], + [-96.59, 28.72], + [-96.66, 28.7], + [-96.4, 28.44], + [-96.59, 28.36], + [-96.77, 28.41], + [-96.8, 28.23], + [-97.03, 28.04], + [-97.26, 27.69], + [-97.4, 27.33], + [-97.51, 27.36], + [-97.54, 27.23], + [-97.43, 27.26], + [-97.48, 27.0], + [-97.56, 26.99], + [-97.56, 26.84], + [-97.47, 26.76], + [-97.44, 26.46], + [-97.33, 26.35], + [-97.31, 26.16], + [-97.22, 25.99], + [-97.52, 25.89], + [-97.65, 26.02], + [-97.89, 26.07], + [-98.2, 26.06], + [-98.47, 26.22], + [-98.67, 26.24], + [-98.82, 26.37], + [-99.03, 26.41], + [-99.17, 26.54], + [-99.27, 26.84], + [-99.45, 27.02], + [-99.42, 27.17], + [-99.51, 27.34], + [-99.48, 27.48], + [-99.61, 27.64], + [-99.71, 27.66], + [-99.88, 27.8], + [-99.93, 27.98], + [-100.08, 28.14], + [-100.3, 28.28], + [-100.4, 28.58], + [-100.5, 28.66], + [-100.63, 28.91], + [-100.67, 29.1], + [-100.8, 29.24], + [-101.01, 29.37], + [-101.06, 29.46], + [-101.26, 29.54], + [-101.41, 29.75], + [-101.85, 29.8], + [-102.11, 29.79], + [-102.34, 29.87], + [-102.39, 29.77], + [-102.63, 29.73], + [-102.81, 29.52], + [-102.92, 29.19], + [-102.98, 29.18], + [-103.12, 28.99], + [-103.28, 28.98], + [-103.53, 29.14], + [-104.15, 29.38], + [-104.27, 29.51], + [-104.51, 29.64], + [-104.68, 29.92], + [-104.69, 30.18], + [-104.86, 30.39], + [-104.9, 30.57], + [-105.01, 30.69], + [-105.39, 30.86], + [-105.6, 31.09], + [-105.77, 31.17], + [-105.95, 31.36], + [-106.21, 31.47], + [-106.38, 31.73], + [-106.53, 31.79], + [-106.64, 31.9], + [-106.62, 32.0], + [-103.07, 32.0], + [-103.04, 36.5], + [-101.81, 36.5] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Utah" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-112.16, 42.0], + [-111.05, 42.0], + [-111.05, 41.0], + [-109.05, 41.0], + [-109.04, 37.0], + [-114.05, 37.0], + [-114.04, 42.0], + [-112.16, 42.0] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Vermont" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-71.5, 45.01], + [-71.49, 44.91], + [-71.63, 44.75], + [-71.54, 44.59], + [-71.7, 44.42], + [-72.03, 44.32], + [-72.03, 44.08], + [-72.12, 43.99], + [-72.2, 43.77], + [-72.38, 43.57], + [-72.46, 43.15], + [-72.45, 43.01], + [-72.53, 42.95], + [-72.54, 42.81], + [-72.46, 42.73], + [-73.27, 42.75], + [-73.25, 43.52], + [-73.4, 43.69], + [-73.35, 43.77], + [-73.44, 44.04], + [-73.32, 44.25], + [-73.29, 44.44], + [-73.39, 44.62], + [-73.33, 44.8], + [-73.34, 45.01], + [-71.5, 45.01] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Virginia" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-75.4, 38.01], + [-75.24, 38.03], + [-75.38, 37.86], + [-75.51, 37.8], + [-75.59, 37.57], + [-75.8, 37.2], + [-75.97, 37.12], + [-76.03, 37.26], + [-75.94, 37.56], + [-75.67, 37.95], + [-75.4, 38.01] + ] + ], + [ + [ + [-78.35, 39.46], + [-77.83, 39.13], + [-77.72, 39.32], + [-77.57, 39.31], + [-77.46, 39.22], + [-77.46, 39.08], + [-77.25, 39.03], + [-77.12, 38.93], + [-77.04, 38.79], + [-77.13, 38.63], + [-77.25, 38.59], + [-77.33, 38.45], + [-77.28, 38.34], + [-77.01, 38.37], + [-76.96, 38.22], + [-76.61, 38.15], + [-76.51, 38.02], + [-76.24, 37.89], + [-76.36, 37.61], + [-76.25, 37.39], + [-76.38, 37.29], + [-76.4, 37.16], + [-76.27, 37.08], + [-76.41, 36.96], + [-76.62, 37.12], + [-76.67, 37.07], + [-76.49, 36.95], + [-75.99, 36.92], + [-75.87, 36.55], + [-79.51, 36.54], + [-83.67, 36.6], + [-83.14, 36.74], + [-83.07, 36.85], + [-82.88, 36.89], + [-82.87, 36.98], + [-82.72, 37.04], + [-82.72, 37.12], + [-82.35, 37.27], + [-81.97, 37.54], + [-81.99, 37.45], + [-81.85, 37.29], + [-81.68, 37.2], + [-81.55, 37.21], + [-81.36, 37.34], + [-81.23, 37.24], + [-80.97, 37.29], + [-80.51, 37.48], + [-80.47, 37.42], + [-80.3, 37.51], + [-80.29, 37.69], + [-80.18, 37.85], + [-80.0, 38.0], + [-79.92, 38.18], + [-79.72, 38.36], + [-79.65, 38.59], + [-79.48, 38.46], + [-79.31, 38.41], + [-79.21, 38.5], + [-79.0, 38.85], + [-78.87, 38.76], + [-78.4, 39.17], + [-78.35, 39.46] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Washington" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [-117.03, 49.0], + [-117.06, 46.34], + [-116.92, 46.17], + [-116.92, 45.99], + [-118.99, 46.0], + [-119.13, 45.93], + [-119.53, 45.91], + [-119.96, 45.82], + [-120.21, 45.73], + [-120.51, 45.7], + [-120.64, 45.75], + [-121.18, 45.6], + [-121.22, 45.67], + [-121.54, 45.73], + [-121.81, 45.71], + [-122.25, 45.55], + [-122.76, 45.66], + [-122.81, 45.96], + [-122.9, 46.08], + [-123.12, 46.19], + [-123.37, 46.15], + [-123.55, 46.26], + [-123.73, 46.3], + [-123.87, 46.24], + [-124.07, 46.33], + [-124.03, 46.46], + [-123.9, 46.54], + [-124.1, 46.74], + [-124.24, 47.29], + [-124.32, 47.36], + [-124.43, 47.74], + [-124.62, 47.89], + [-124.71, 48.18], + [-124.6, 48.38], + [-123.98, 48.16], + [-123.7, 48.17], + [-123.42, 48.12], + [-123.16, 48.17], + [-123.04, 48.08], + [-122.8, 48.09], + [-122.64, 47.87], + [-122.52, 47.88], + [-122.49, 47.59], + [-122.42, 47.32], + [-122.32, 47.35], + [-122.42, 47.58], + [-122.4, 47.8], + [-122.23, 48.03], + [-122.36, 48.12], + [-122.37, 48.29], + [-122.47, 48.47], + [-122.42, 48.6], + [-122.49, 48.75], + [-122.65, 48.78], + [-122.8, 48.89], + [-122.76, 49.0], + [-117.03, 49.0] + ] + ], + [ + [ + [-122.72, 48.31], + [-122.59, 48.35], + [-122.61, 48.15], + [-122.77, 48.23], + [-122.72, 48.31] + ] + ], + [ + [ + [-123.03, 48.58], + [-122.92, 48.72], + [-122.77, 48.56], + [-122.81, 48.42], + [-123.04, 48.46], + [-123.03, 48.58] + ] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "West Virginia" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-80.52, 40.64], + [-80.52, 39.72], + [-79.48, 39.72], + [-79.49, 39.21], + [-79.29, 39.3], + [-79.09, 39.47], + [-78.96, 39.44], + [-78.77, 39.59], + [-78.47, 39.51], + [-78.43, 39.62], + [-78.27, 39.62], + [-78.17, 39.69], + [-78.0, 39.6], + [-77.83, 39.6], + [-77.72, 39.32], + [-77.83, 39.13], + [-78.35, 39.46], + [-78.4, 39.17], + [-78.87, 38.76], + [-79.0, 38.85], + [-79.21, 38.5], + [-79.31, 38.41], + [-79.48, 38.46], + [-79.65, 38.59], + [-79.72, 38.36], + [-79.92, 38.18], + [-80.0, 38.0], + [-80.18, 37.85], + [-80.29, 37.69], + [-80.3, 37.51], + [-80.47, 37.42], + [-80.51, 37.48], + [-80.97, 37.29], + [-81.23, 37.24], + [-81.36, 37.34], + [-81.55, 37.21], + [-81.68, 37.2], + [-81.85, 37.29], + [-81.99, 37.45], + [-81.97, 37.54], + [-82.1, 37.55], + [-82.29, 37.67], + [-82.34, 37.78], + [-82.5, 37.93], + [-82.62, 38.12], + [-82.59, 38.42], + [-82.33, 38.45], + [-82.29, 38.58], + [-82.17, 38.63], + [-82.22, 38.79], + [-82.04, 39.03], + [-81.89, 38.87], + [-81.78, 38.97], + [-81.81, 39.08], + [-81.69, 39.27], + [-81.57, 39.27], + [-81.46, 39.41], + [-81.35, 39.34], + [-81.22, 39.39], + [-80.83, 39.71], + [-80.74, 40.08], + [-80.6, 40.32], + [-80.6, 40.47], + [-80.67, 40.58], + [-80.52, 40.64] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Wisconsin" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-90.42, 46.57], + [-90.23, 46.51], + [-90.12, 46.34], + [-89.09, 46.14], + [-88.66, 45.99], + [-88.53, 46.02], + [-88.1, 45.92], + [-87.99, 45.8], + [-87.78, 45.68], + [-87.79, 45.5], + [-87.89, 45.36], + [-87.65, 45.34], + [-87.74, 45.2], + [-87.59, 45.1], + [-87.63, 44.97], + [-87.82, 44.95], + [-87.98, 44.72], + [-88.04, 44.56], + [-87.93, 44.54], + [-87.78, 44.64], + [-87.61, 44.84], + [-87.4, 44.91], + [-87.24, 45.17], + [-87.03, 45.22], + [-87.05, 45.09], + [-87.19, 44.97], + [-87.47, 44.55], + [-87.55, 44.32], + [-87.54, 44.16], + [-87.64, 44.1], + [-87.74, 43.88], + [-87.7, 43.69], + [-87.79, 43.56], + [-87.91, 43.25], + [-87.89, 43.0], + [-87.76, 42.78], + [-87.8, 42.49], + [-90.64, 42.51], + [-90.71, 42.64], + [-91.07, 42.75], + [-91.14, 42.91], + [-91.18, 43.13], + [-91.06, 43.25], + [-91.2, 43.35], + [-91.22, 43.5], + [-91.27, 43.62], + [-91.24, 43.78], + [-91.43, 43.99], + [-91.59, 44.03], + [-91.88, 44.2], + [-91.93, 44.33], + [-92.23, 44.44], + [-92.34, 44.55], + [-92.55, 44.57], + [-92.81, 44.75], + [-92.74, 45.12], + [-92.76, 45.29], + [-92.64, 45.44], + [-92.77, 45.57], + [-92.89, 45.58], + [-92.87, 45.72], + [-92.64, 45.93], + [-92.35, 46.02], + [-92.29, 46.08], + [-92.29, 46.67], + [-92.09, 46.75], + [-92.01, 46.71], + [-91.79, 46.69], + [-91.09, 46.86], + [-90.84, 46.96], + [-90.75, 46.89], + [-90.89, 46.75], + [-90.56, 46.58], + [-90.42, 46.57] + ] + ] + } + }, + { + "type": "Feature", + "properties": { "name": "Wyoming" }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-109.08, 45.0], + [-104.06, 45.0], + [-104.05, 41.0], + [-111.05, 41.0], + [-111.05, 45.0], + [-109.08, 45.0] + ] + ] + } + } + ] +} diff --git a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts index 3e556eff3b2a..d380bbaab598 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts +++ b/openmetadata-ui-core-components/src/main/resources/ui/vite.config.ts @@ -108,6 +108,11 @@ export default defineConfig({ 'tailwindcss-react-aria-components', 'i18next', 'react-i18next', + // Charts: `/charts` imports echarts' modular entries (echarts/core, + // echarts/charts, ...) and echarts-for-react/lib/core. Keep every + // sub-path external so consumers resolve their own single copy. + /^echarts(\/.*)?$/, + /^echarts-for-react(\/.*)?$/, ], output: { entryFileNames: (chunkInfo) => { diff --git a/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock b/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock index 80838eff7405..933a916a1f56 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock @@ -3879,6 +3879,22 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +echarts-for-react@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/echarts-for-react/-/echarts-for-react-3.0.6.tgz#267908f53854856833dc39088d2a97ab4bbfad86" + integrity sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg== + dependencies: + fast-deep-equal "^3.1.3" + size-sensor "^1.0.1" + +echarts@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-6.1.0.tgz#ae0f68590f5ebbd728d900907c27acde7c5456d1" + integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA== + dependencies: + tslib "2.3.0" + zrender "6.1.0" + electron-to-chromium@^1.5.402: version "1.5.420" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz#fc66d26a722d6f227e2092acdf38dd55b198cb44" @@ -6498,6 +6514,11 @@ signal-exit@^4.0.1: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +size-sensor@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/size-sensor/-/size-sensor-1.0.3.tgz#4ce525cbc5a098da09852e38b6f851ebb9b5116a" + integrity sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A== + snake-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" @@ -6931,6 +6952,11 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" @@ -7442,3 +7468,10 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zrender@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-6.1.0.tgz#c3ef06e6426d5c28865b7183035680d685e00136" + integrity sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ== + dependencies: + tslib "2.3.0"