diff --git a/common/index.ts b/common/index.ts index 9f7b5cc6..eaa19b49 100644 --- a/common/index.ts +++ b/common/index.ts @@ -70,6 +70,13 @@ export const MAP_SAVED_OBJECT_TYPE = 'map'; // TODO: Replace with actual app icon export const MAPS_APP_ICON = 'gisApp'; export const MAPS_VISUALIZATION_DESCRIPTION = 'Create map visualization with multiple layers'; +export const CLUSTER_DEFAULT_FILL_TYPE = 'gradient'; +export const CLUSTER_DEFAULT_PALETTE = 'pallette_1'; +export const CLUSTER_MIN_DEFAULT_RADIUS_SIZE = 50; +export const CLUSTER_MAX_DEFAULT_RADIUS_SIZE = 200; +export const CLUSTER_MIN_BORDER_THICKNESS = 0; +export const CLUSTER_MAX_BORDER_THICKNESS = 100; +export const CLUSTER_DEFAULT_MARKER_BORDER_THICKNESS = 1; // Starting position [lng, lat] and zoom export const MAP_INITIAL_STATE = { @@ -91,12 +98,14 @@ export enum DASHBOARDS_MAPS_LAYER_NAME { OPENSEARCH_MAP = 'OpenSearch map', DOCUMENTS = 'Documents', CUSTOM_MAP = 'Custom map', + CLUSTER = 'Cluster', } export enum DASHBOARDS_MAPS_LAYER_TYPE { OPENSEARCH_MAP = 'opensearch_vector_tile_map', DOCUMENTS = 'documents', CUSTOM_MAP = 'custom_map', + CLUSTER = 'cluster', } export enum DASHBOARDS_CUSTOM_MAPS_LAYER_TYPE { @@ -108,12 +117,15 @@ export enum DASHBOARDS_MAPS_LAYER_ICON { OPENSEARCH_MAP = 'globe', DOCUMENTS = 'document', CUSTOM_MAP = 'globe', + CLUSTER = 'heatmap', } export enum DASHBOARDS_MAPS_LAYER_DESCRIPTION { OPENSEARCH_MAP = 'Use default OpenSearch basemaps.', DOCUMENTS = 'View points, lines, and polygons on the map.', CUSTOM_MAP = 'Configure maps to use a custom map source.', + //TODO: wait ux and writer for content + CLUSTER = 'cluster layer', } export const DOCUMENTS = { @@ -137,6 +149,13 @@ export const CUSTOM_MAP = { description: DASHBOARDS_MAPS_LAYER_DESCRIPTION.CUSTOM_MAP, }; +export const CLUSTER = { + name: DASHBOARDS_MAPS_LAYER_NAME.CLUSTER, + type: DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER, + icon: DASHBOARDS_MAPS_LAYER_ICON.CLUSTER, + description: DASHBOARDS_MAPS_LAYER_DESCRIPTION.CLUSTER, +}; + export interface Layer { name: DASHBOARDS_MAPS_LAYER_NAME; type: DASHBOARDS_MAPS_LAYER_TYPE; @@ -153,6 +172,7 @@ export const LAYER_ICON_TYPE_MAP: { [key: string]: string } = { [DASHBOARDS_MAPS_LAYER_TYPE.OPENSEARCH_MAP]: 'globe', [DASHBOARDS_MAPS_LAYER_TYPE.DOCUMENTS]: 'document', [DASHBOARDS_MAPS_LAYER_TYPE.CUSTOM_MAP]: 'globe', + [DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER]: 'heatmap', }; // refer https://github.com/opensearch-project/i18n-plugin/blob/main/DEVELOPER_GUIDE.md#new-locale for OSD supported languages diff --git a/opensearch_dashboards.json b/opensearch_dashboards.json index e8abd481..5bba45b9 100644 --- a/opensearch_dashboards.json +++ b/opensearch_dashboards.json @@ -4,6 +4,6 @@ "opensearchDashboardsVersion": "3.0.0", "server": true, "ui": true, - "requiredPlugins": ["regionMap", "opensearchDashboardsReact", "opensearchDashboardsUtils", "navigation", "savedObjects", "data", "embeddable", "visualizations"], + "requiredPlugins": ["regionMap", "opensearchDashboardsReact", "opensearchDashboardsUtils", "navigation", "savedObjects", "data", "embeddable", "visualizations","visDefaultEditor"], "optionalPlugins": ["home"] } diff --git a/public/components/add_layer_panel/add_layer_panel.tsx b/public/components/add_layer_panel/add_layer_panel.tsx index c1ebed8e..04c155e2 100644 --- a/public/components/add_layer_panel/add_layer_panel.tsx +++ b/public/components/add_layer_panel/add_layer_panel.tsx @@ -29,6 +29,7 @@ import { Layer, NEW_MAP_LAYER_DEFAULT_PREFIX, MAX_LAYER_LIMIT, + CLUSTER, } from '../../../common'; import { getLayerConfigMap } from '../../utils/getIntialConfig'; import { ConfigSchema } from '../../../common/config'; @@ -67,7 +68,7 @@ export const AddLayerPanel = ({ addLayer(initLayerConfig); } - const dataLayers = [DOCUMENTS]; + const dataLayers = [CLUSTER, DOCUMENTS]; const dataLayerItems = Object.values(dataLayers).map((layerItem, index) => { return ( ; +} + +export const Agg = ({ + agg, + aggIndex, + indexPattern, + schemas, + groupName, + metricAggs, + setAggParamValue, + onAggTypeChange, + state, + setAggsState, + formIsTouched, + timeRange, +}: Props) => { + const aggName = agg?.type?.name; + const setValidity = useCallback( + (isValid: boolean) => { + setAggsState({ + type: AGGS_ACTION_KEYS.VALID, + payload: isValid, + aggId: agg.id, + }); + }, + [agg.id, setAggsState] + ); + const setTouched = useCallback( + (touched: boolean) => { + setAggsState({ + type: AGGS_ACTION_KEYS.TOUCHED, + payload: touched, + aggId: agg.id, + }); + }, + [agg.id, setAggsState] + ); + + // This useEffect is required to update the timeRange value and initiate rerender to keep labels up to date (Issue #57822). + useEffect(() => { + if (timeRange && aggName === 'date_histogram') { + agg?.aggConfigs?.setTimeRange(timeRange); + } + }, [agg, aggName, timeRange]); + + //DefaultEditorAggParams needs indexPattern to render,but it can display a fallback state when we pass a fake indexPattern. + const fallbackIndexPattern = { + getAggregationRestrictions: () => { + return undefined; + }, + } as unknown as IndexPattern; + + return ( + + ); +}; diff --git a/public/components/layer_config/cluster_config/agg_common_props.ts b/public/components/layer_config/cluster_config/agg_common_props.ts new file mode 100644 index 00000000..d6d8e9dc --- /dev/null +++ b/public/components/layer_config/cluster_config/agg_common_props.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern, AggGroupName, TimeRange } from '../../../../../../src/plugins/data/common'; +import { Schema } from '../../../../../../src/plugins/vis_default_editor/public'; +import { IAggConfig, IAggType } from '../../../../../../src/plugins/data/public'; +import { type DefaultEmptyState } from './cluster_layer_source'; + +export interface AggCommonProps { + indexPattern: IndexPattern | null | undefined; + schemas: Schema[]; + groupName: AggGroupName; + metricAggs: IAggConfig[]; + setAggParamValue: ( + aggId: IAggConfig['id'], + paramName: T, + value: IAggConfig['params'][T] + ) => void; + onAggTypeChange: (aggId: IAggConfig['id'], aggType: IAggType) => void; + state: DefaultEmptyState; + timeRange?: TimeRange; + formIsTouched: boolean; +} diff --git a/public/components/layer_config/cluster_config/agg_group.tsx b/public/components/layer_config/cluster_config/agg_group.tsx new file mode 100644 index 00000000..fafbab9e --- /dev/null +++ b/public/components/layer_config/cluster_config/agg_group.tsx @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useEffect, useReducer } from 'react'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { IAggConfig } from '../../../../../../src/plugins/data/public'; +import { Agg } from './agg'; +import { + aggGroupReducer, + initAggsState, + AGGS_ACTION_KEYS, + isInvalidAggsTouched, +} from './agg_group_state'; +import { AggCommonProps } from './agg_common_props'; + +interface Props extends AggCommonProps { + aggs: IAggConfig[]; + setValidity(modelName: string, value: boolean): void; + setTouched(isTouched: boolean): void; +} + +const GROUP_NAME_LABELS = { + metrics: 'Metrics', + buckets: 'Cluster', + none: '', +}; + +export const AggGroup = ({ + aggs, + indexPattern, + schemas, + groupName, + setValidity, + setAggParamValue, + formIsTouched, + onAggTypeChange, + state, + metricAggs, + setTouched, + timeRange, +}: Props) => { + const schemaNames = schemas.map((s) => s.name); + const group: IAggConfig[] = useMemo( + () => aggs.filter((agg: IAggConfig) => agg.schema && schemaNames.includes(agg.schema)) || [], + [aggs, schemaNames] + ); + const [aggsState, setAggsState] = useReducer(aggGroupReducer, group, initAggsState); + const isGroupValid = Object.values(aggsState).every((item) => item.valid); + const isAllAggsTouched = isInvalidAggsTouched(aggsState); + + useEffect(() => { + // when isAllAggsTouched is true, it means that all invalid aggs are touched and we will set ngModel's touched to true + // which indicates that Apply button can be changed to Error button (when all invalid ngModels are touched) + setTouched(isAllAggsTouched); + }, [isAllAggsTouched, setTouched]); + + useEffect(() => { + // when not all invalid aggs are touched and formIsTouched becomes true, it means that Apply button was clicked. + // and in such case we set touched state to true for all aggs + if (formIsTouched && !isAllAggsTouched) { + Object.keys(aggsState).map(([aggId]) => { + setAggsState({ + type: AGGS_ACTION_KEYS.TOUCHED, + payload: true, + aggId, + }); + }); + } + }, [formIsTouched]); + + useEffect(() => { + setValidity(`aggGroup__${groupName}`, isGroupValid); + }, [groupName, isGroupValid, setValidity]); + + return ( + <> + + +

{GROUP_NAME_LABELS[groupName]}

+
+ + <> + {group.map((agg: IAggConfig, index: number) => ( + + ))} + +
+ + ); +}; diff --git a/public/components/layer_config/cluster_config/agg_group_state.tsx b/public/components/layer_config/cluster_config/agg_group_state.tsx new file mode 100644 index 00000000..f999ab57 --- /dev/null +++ b/public/components/layer_config/cluster_config/agg_group_state.tsx @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IAggConfig } from '../../../../../../src/plugins/data/public'; +import { isEmpty } from 'lodash'; + +export enum AGGS_ACTION_KEYS { + TOUCHED = 'aggsTouched', + VALID = 'aggsValid', +} + +interface AggsItem { + touched: boolean; + valid: boolean; +} + +export interface AggsState { + [aggId: string]: AggsItem; +} + +export interface AggsAction { + type: AGGS_ACTION_KEYS; + payload: boolean; + aggId: string; + newState?: AggsState; +} + +function aggGroupReducer(state: AggsState, action: AggsAction): AggsState { + const aggState = state[action.aggId] || { touched: false, valid: true }; + switch (action.type) { + case AGGS_ACTION_KEYS.TOUCHED: + return { ...state, [action.aggId]: { ...aggState, touched: action.payload } }; + case AGGS_ACTION_KEYS.VALID: + return { ...state, [action.aggId]: { ...aggState, valid: action.payload } }; + default: + throw new Error(); + } +} + +function initAggsState(group: IAggConfig[]): AggsState { + return group.reduce((state, agg) => { + state[agg.id] = { touched: false, valid: true }; + return state; + }, {} as AggsState); +} + +function isInvalidAggsTouched(aggsState: AggsState) { + const invalidAggs = Object.values(aggsState).filter((agg) => !agg.valid); + + if (isEmpty(invalidAggs)) { + return false; + } + + return invalidAggs.every((agg) => agg.touched); +} + +export { aggGroupReducer, initAggsState, isInvalidAggsTouched }; diff --git a/public/components/layer_config/cluster_config/cluster_layer_config_panel.tsx b/public/components/layer_config/cluster_config/cluster_layer_config_panel.tsx new file mode 100644 index 00000000..f18a63b6 --- /dev/null +++ b/public/components/layer_config/cluster_config/cluster_layer_config_panel.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useState } from 'react'; +import { EuiSpacer, EuiTabbedContent } from '@elastic/eui'; +import { ClusterLayerSpecification } from '../../../model/mapLayerType'; +import { LayerBasicSettings } from '../layer_basic_settings'; +import { ClusterLayerSource } from './cluster_layer_source'; +import { ClusterLayerStyle } from './style'; +import { IndexPattern, TimeRange } from '../../../../../../src/plugins/data/common'; +import { useCallback } from 'react'; + +interface Props { + selectedLayerConfig: ClusterLayerSpecification; + setSelectedLayerConfig: Function; + setIsUpdateDisabled: Function; + isLayerExists: Function; + timeRange?: TimeRange; +} + +export const ClusterLayerConfigPanel = (props: Props) => { + const [indexPattern, setIndexPattern] = useState(); + const [dataUpdateDisabled, setDataUpdateDisabled] = useState(true); + + const setIsUpdateDisabled = useCallback( + (isUpdateDisabled: boolean, isFromDataPanel = false) => { + //we can't judge whether source is valid only by selectLayerConfig like documents. We need a state to record it. + if (isFromDataPanel) { + setDataUpdateDisabled(isUpdateDisabled); + props.setIsUpdateDisabled(isUpdateDisabled); + } else { + props.setIsUpdateDisabled(dataUpdateDisabled || isUpdateDisabled); + } + }, + [dataUpdateDisabled] + ); + + const newProps = { + ...props, + setIsUpdateDisabled, + indexPattern, + setIndexPattern, + }; + + const tabs = [ + { + id: 'data-source--id', + name: 'Data', + content: ( + + + + + ), + testsubj: 'dataTab', + }, + { + id: 'style--id', + name: 'Style', + content: ( + + + + + ), + testsubj: 'styleTab', + }, + { + id: 'settings--id', + name: 'Settings', + content: ( + + + + + ), + testsubj: 'settingsTab', + }, + ]; + return ; +}; diff --git a/public/components/layer_config/cluster_config/cluster_layer_source.tsx b/public/components/layer_config/cluster_config/cluster_layer_source.tsx new file mode 100644 index 00000000..451c823e --- /dev/null +++ b/public/components/layer_config/cluster_config/cluster_layer_source.tsx @@ -0,0 +1,202 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useOpenSearchDashboards } from '../../../../../../src/plugins/opensearch_dashboards_react/public'; +import { + TimeRange, + IAggConfig, + IndexPattern, + IAggConfigs, +} from '../../../../../../src/plugins/data/public'; +import { ClusterLayerSpecification } from '../../../model/mapLayerType'; +import { DataSourceSection } from './data_source_section'; +import { Schema } from '../../../../../../src/plugins/vis_default_editor/public'; +import { AggGroupNames, CreateAggConfigParams } from '../../../../../../src/plugins/data/common'; +import { getAggs } from '../../../services'; +import { AggGroup } from './agg_group'; +import { MapServices } from '../../../types'; +import { useEffectOnce } from 'react-use'; + +import { useEditorFormState } from './form_state'; +import { schemas, defaultSchemas, defaultAggs } from './config'; +interface Props { + setSelectedLayerConfig: Function; + selectedLayerConfig: ClusterLayerSpecification; + setIsUpdateDisabled: Function; + indexPattern: IndexPattern | null | undefined; + setIndexPattern: Function; + timeRange?: TimeRange; +} + +const defaultState = { + data: {}, + description: '', + title: '', +}; +export type DefaultEmptyState = typeof defaultState; + +export const ClusterLayerSource = ({ + setIsUpdateDisabled, + setSelectedLayerConfig, + selectedLayerConfig, + indexPattern, + setIndexPattern, + timeRange, +}: Props) => { + const { formState, setTouched, setValidity } = useEditorFormState(); + const { + services: { + data: { indexPatterns }, + }, + } = useOpenSearchDashboards(); + const [aggs, setAggs] = useState(selectedLayerConfig.source.aggs ?? null); + + //recover state when mount with available aggs + useEffectOnce(() => { + if (indexPattern && aggs) { + setAggs(getAggs().createAggConfigs(indexPattern, aggs.aggs)); + } + }); + + const initDefaultAggs = (indexPattern: IndexPattern) => { + const metricAggs = getAggs().createAggConfigs(indexPattern, [ + { + id: '1', + schema: 'metric', + type: 'count', + }, + ]); + const aggConfig = metricAggs!.createAggConfig({ schema: 'segment' } as CreateAggConfigParams, { + addToAggConfigs: false, + }); + aggConfig.brandNew = true; + const newAggs = [...metricAggs!.aggs, aggConfig]; + setAggs(getAggs().createAggConfigs(indexPattern!, newAggs)); + }; + const responseAggs = useMemo(() => (aggs ? aggs?.getResponseAggs() : []), [aggs]); + const metricSchemas = (schemas.metrics || []).map((s: Schema) => s.name); + const metricAggs = useMemo(() => { + return responseAggs.filter((agg) => agg.schema && metricSchemas.includes(agg.schema)); + }, [responseAggs, metricSchemas]); + + const setAggParamValue = useCallback( + function (aggId: string, paramName: T, value: any): void { + const newAggs = aggs!.aggs.map((agg) => { + if (agg.id === aggId) { + const parsedAgg = agg.toJSON(); + + return { + ...parsedAgg, + params: { + ...parsedAgg.params, + [paramName]: value, + }, + }; + } + + return agg; + }); + setAggs(getAggs().createAggConfigs(indexPattern!, newAggs)); + }, + [aggs, indexPattern] + ); + useEffect(() => { + if (aggs) { + const source = { ...selectedLayerConfig.source, aggs }; + setSelectedLayerConfig({ ...selectedLayerConfig, source }); + } + }, [aggs]); + + useEffect(() => { + const selectIndexPattern = async () => { + if (selectedLayerConfig.source.indexPatternId) { + const selectedIndexPattern = await indexPatterns.get( + selectedLayerConfig.source.indexPatternId + ); + setIndexPattern(selectedIndexPattern); + } + }; + selectIndexPattern(); + }, [indexPatterns, selectedLayerConfig.source.indexPatternId]); + + // Handle the side effects of index pattern change + useEffect(() => { + const source = { ...selectedLayerConfig.source }; + // when index pattern changed, reset aggs + if (indexPattern && indexPattern.id !== selectedLayerConfig.source.indexPatternId) { + source.indexPatternId = indexPattern.id ?? ''; + source.indexPatternRefName = indexPattern.title; + initDefaultAggs(indexPattern); + setSelectedLayerConfig({ + ...selectedLayerConfig, + source, + }); + } else if ( + indexPattern && + indexPattern.id === selectedLayerConfig.source.indexPatternId && + aggs + ) { + // when reenter panel with indexPattern, it will set indexPattern and should create aggs with this indexPattern. + setAggs(getAggs().createAggConfigs(indexPattern, aggs.aggs)); + } + }, [indexPattern]); + + const onAggTypeChange = useCallback( + (aggId: string, aggType: IAggConfig['type']) => { + const newAggs = aggs!.aggs.map((agg) => { + if (agg.id === aggId) { + agg.type = aggType; + return agg.toJSON(); + } + return agg; + }); + setAggs(getAggs().createAggConfigs(indexPattern!, newAggs)); + }, + [aggs, indexPattern] + ); + + useEffect(() => { + const disableUpdate = formState.invalid || !indexPattern; + setIsUpdateDisabled(disableUpdate, true); + }, [formState.invalid, indexPattern]); + + const commonProps = { + timeRange, + onAggTypeChange, + setAggParamValue, + state: defaultState, + metricAggs, + setValidity, + formIsTouched: formState.touched, + setTouched, + }; + + return ( + <> + + + + + + + + ); +}; diff --git a/public/components/layer_config/cluster_config/config.ts b/public/components/layer_config/cluster_config/config.ts new file mode 100644 index 00000000..136ce2a6 --- /dev/null +++ b/public/components/layer_config/cluster_config/config.ts @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AggGroupNames } from '../../../../../../src/plugins/data/common'; +import { IAggConfig } from '../../../../../../src/plugins/data/public'; + +const metricsSchema = { + group: AggGroupNames.Metrics, + name: 'metric', + title: 'Value', + min: 1, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits'], + defaults: [ + { + schema: 'metric', + type: 'count', + id: '1', + }, + ], + editor: false, + params: [], +}; +const bucketsSchema = { + group: AggGroupNames.Buckets, + name: 'segment', + title: 'Geo coordinates', + aggFilter: ['geohash_grid', 'geotile_grid'], + min: 1, + max: 1, + editor: false, + params: [], + defaults: null, +}; +export const schemas = { + metrics: [metricsSchema], + buckets: [bucketsSchema], + all: [metricsSchema, bucketsSchema], +}; +const defaultMetricsSchema = { + group: AggGroupNames.Metrics, + name: 'metric', + title: 'Value', + min: 1, + max: 1, + aggFilter: [''], + defaults: [], + editor: false, + params: [], +}; +const defaultBucketsSchema = { + group: AggGroupNames.Buckets, + name: 'segment', + title: 'Geo coordinates', + aggFilter: [''], + min: 1, + max: 1, + editor: false, + params: [], + defaults: null, +}; + +//when no indexpattern, we can't create aggs. So we use a default mock schema, this will contain null filters. +export const defaultSchemas = { + metrics: [defaultMetricsSchema], + buckets: [defaultBucketsSchema], +}; + +//when no indexpattern, we can't create aggs. So we use a default aggConfig. +export const defaultAggs = [ + { + aggConfigs: { + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'geotile_grid', + params: {}, + schema: 'segment', + }, + ], + }, + brandNew: undefined, + enabled: true, + id: '1', + params: {}, + parent: undefined, + schema: 'metric', + subAggs: [], + }, + { + aggConfigs: { + typesRegistry: {}, + aggs: [ + { + id: '1', + enabled: true, + type: 'count', + params: {}, + schema: 'metric', + }, + { + id: '2', + enabled: true, + type: 'geotile_grid', + params: {}, + schema: 'segment', + }, + ], + }, + brandNew: undefined, + enabled: true, + id: '2', + params: {}, + parent: undefined, + schema: 'segment', + subAggs: [], + }, +] as unknown as IAggConfig[]; diff --git a/public/components/layer_config/cluster_config/data_source_section.tsx b/public/components/layer_config/cluster_config/data_source_section.tsx new file mode 100644 index 00000000..f4899f5a --- /dev/null +++ b/public/components/layer_config/cluster_config/data_source_section.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiForm, + EuiFlexItem, + EuiFormRow, + EuiComboBoxOptionOption, + EuiTitle, +} from '@elastic/eui'; +import { IndexPattern } from '../../../../../../src/plugins/data/public'; +import { useOpenSearchDashboards } from '../../../../../../src/plugins/opensearch_dashboards_react/public'; +import { MapServices } from '../../../types'; +import { i18n } from '@osd/i18n'; + +interface Props { + indexPattern: IndexPattern | null | undefined; + setIndexPattern: Function; +} + +export const DataSourceSection = ({ indexPattern, setIndexPattern }: Props) => { + const { + services: { + savedObjects: { client: savedObjectsClient }, + data: { + ui: { IndexPatternSelect }, + indexPatterns, + }, + }, + } = useOpenSearchDashboards(); + return ( + + +

Data Source

+
+ + + + + + []) => { + const newIndexPattern = await indexPatterns.get(newIndexPatternId); + setIndexPattern(newIndexPattern); + }} + isClearable={false} + data-test-subj={'indexPatternSelect'} + fullWidth={true} + /> + + + +
+ ); +}; diff --git a/public/components/layer_config/cluster_config/form_state.tsx b/public/components/layer_config/cluster_config/form_state.tsx new file mode 100644 index 00000000..0bf3d105 --- /dev/null +++ b/public/components/layer_config/cluster_config/form_state.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export type SetValidity = (modelName: string, value: boolean) => void; +export type SetTouched = (value: boolean) => void; + +const initialFormState = { + validity: {}, + touched: false, + invalid: false, +}; + +function useEditorFormState() { + const [formState, setFormState] = useState(initialFormState); + + const setValidity: SetValidity = useCallback((modelName, value) => { + setFormState((model) => { + const validity = { + ...model.validity, + [modelName]: value, + }; + + return { + ...model, + validity, + invalid: Object.values(validity).some((valid) => !valid), + }; + }); + }, []); + + const resetValidity = useCallback(() => { + setFormState(initialFormState); + }, []); + + const setTouched = useCallback((touched: boolean) => { + setFormState((model) => ({ + ...model, + touched, + })); + }, []); + + return { + formState, + setValidity, + setTouched, + resetValidity, + }; +} + +export { useEditorFormState }; diff --git a/public/components/layer_config/cluster_config/style/index.tsx b/public/components/layer_config/cluster_config/style/index.tsx new file mode 100644 index 00000000..a64150d9 --- /dev/null +++ b/public/components/layer_config/cluster_config/style/index.tsx @@ -0,0 +1,257 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiFieldNumber, + EuiFormLabel, + EuiSpacer, + EuiColorPicker, + EuiPanel, + EuiFormRow, + EuiRadioGroup, + EuiColorPalettePicker, + EuiForm, + EuiTitle, + euiPaletteColorBlind, + euiPaletteForTemperature, + EuiDualRange, + euiPaletteForStatus, + EuiColorPalettePickerPaletteProps, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { ClusterLayerSpecification } from '../../../../model/mapLayerType'; +import { CLUSTER_MIN_BORDER_THICKNESS, CLUSTER_MAX_BORDER_THICKNESS } from '../../../../../common'; +import { + CLUSTER_MIN_DEFAULT_RADIUS_SIZE, + CLUSTER_MAX_DEFAULT_RADIUS_SIZE, +} from '../../../../../common'; +interface Props { + selectedLayerConfig: ClusterLayerSpecification; + setSelectedLayerConfig: Function; + setIsUpdateDisabled: Function; +} + +export const ClusterLayerStyle = ({ + setSelectedLayerConfig, + selectedLayerConfig, + setIsUpdateDisabled, +}: Props) => { + const [hasInvalidBorderThickness, setHasInvalidBorderThickness] = useState(false); + + const fillTypeOptions = [ + { + id: 'gradient', + label: 'ramp gradient', + }, + { id: 'solid', label: 'solid color' }, + ]; + //TODO: wait designer provide palette props + const palettes: EuiColorPalettePickerPaletteProps[] = [ + { + value: 'pallette_1', + palette: euiPaletteColorBlind(), + type: 'gradient', + }, + { + value: 'pallette_2', + palette: euiPaletteForStatus(5), + type: 'gradient', + }, + { + value: 'pallette_3', + palette: euiPaletteForTemperature(5), + type: 'gradient', + }, + { + value: 'pallette_4', + palette: [ + { + stop: 100, + color: 'white', + }, + { + stop: 250, + color: 'lightgray', + }, + { + stop: 320, + color: 'gray', + }, + { + stop: 470, + color: 'black', + }, + ], + type: 'gradient', + }, + ]; + + const onPaletteChange = (palette: string) => { + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + palette, + }, + }); + }; + const onColorPickerChange = (fillColor: string) => { + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + fillColor, + }, + }); + }; + + useEffect(() => { + const borderThickness = selectedLayerConfig.style.borderThickness; + const invalidThickness = + borderThickness < CLUSTER_MIN_BORDER_THICKNESS || + borderThickness > CLUSTER_MAX_BORDER_THICKNESS; + setHasInvalidBorderThickness(invalidThickness); + }, [selectedLayerConfig.style.borderThickness]); + + useEffect(() => { + const disableUpdate = hasInvalidBorderThickness; + setIsUpdateDisabled(disableUpdate); + }, [hasInvalidBorderThickness]); + + const onFillTypeChange = (fillType: string) => { + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + fillType, + }, + }); + }; + + const onRadiusRangeChange = (radiusRange: [string | number, string | number]) => { + const [min, max] = radiusRange; + const isValueInValid = (value: string | number) => + Number(value) < CLUSTER_MIN_DEFAULT_RADIUS_SIZE || + Number(value) > CLUSTER_MAX_DEFAULT_RADIUS_SIZE; + const validRange = [ + isValueInValid(min) ? CLUSTER_MIN_DEFAULT_RADIUS_SIZE : Number(min), + isValueInValid(max) ? CLUSTER_MAX_DEFAULT_RADIUS_SIZE : Number(max), + ]; + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + radiusRange: validRange, + }, + }); + }; + + const onBorderColorChange = (borderColor: string) => { + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + borderColor, + }, + }); + }; + const onBorderThicknessChange = (e: React.ChangeEvent) => { + setSelectedLayerConfig({ + ...selectedLayerConfig, + style: { + ...selectedLayerConfig.style, + borderThickness: Number(e.target.value), + }, + }); + }; + + return ( + <> + + +

Layer style

+
+ + + Fill type, + }} + /> + + {selectedLayerConfig.style.fillType === 'gradient' ? ( + + + + ) : ( + + + + )} + + + + + + + + + + px} + fullWidth={true} + isInvalid={hasInvalidBorderThickness} + min={CLUSTER_MIN_BORDER_THICKNESS} + max={CLUSTER_MAX_BORDER_THICKNESS} + /> + + +
+ + + ); +}; diff --git a/public/components/layer_config/layer_config_panel.tsx b/public/components/layer_config/layer_config_panel.tsx index 75161450..61bfd145 100644 --- a/public/components/layer_config/layer_config_panel.tsx +++ b/public/components/layer_config/layer_config_panel.tsx @@ -30,6 +30,8 @@ import { DASHBOARDS_MAPS_LAYER_TYPE } from '../../../common'; import { DocumentLayerConfigPanel } from './documents_config/document_layer_config_panel'; import { layersTypeIconMap, layersTypeNameMap } from '../../model/layersFunctions'; import { CustomMapConfigPanel } from './custom_map_config/custom_map_config_panel'; +import { ClusterLayerConfigPanel } from './cluster_config/cluster_layer_config_panel'; +import { TimeRange } from '../../../../../src/plugins/data/public'; interface Props { closeLayerConfigPanel: Function; @@ -43,6 +45,7 @@ interface Props { originLayerConfig: MapLayerSpecification | null; setOriginLayerConfig: Function; setIsUpdatingLayerRender: (isUpdatingLayerRender: boolean) => void; + timeRange?: TimeRange; } export const LayerConfigPanel = ({ @@ -57,6 +60,7 @@ export const LayerConfigPanel = ({ originLayerConfig, setOriginLayerConfig, setIsUpdatingLayerRender, + timeRange, }: Props) => { const [isUpdateDisabled, setIsUpdateDisabled] = useState(false); const [unsavedModalVisible, setUnsavedModalVisible] = useState(false); @@ -150,6 +154,15 @@ export const LayerConfigPanel = ({ isLayerExists={isLayerExists} /> )} + {selectedLayerConfig.type === DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER && ( + + )} diff --git a/public/components/layer_control_panel/layer_control_panel.tsx b/public/components/layer_control_panel/layer_control_panel.tsx index e4f0a65d..f2adb2bb 100644 --- a/public/components/layer_control_panel/layer_control_panel.tsx +++ b/public/components/layer_control_panel/layer_control_panel.tsx @@ -25,7 +25,7 @@ import './layer_control_panel.scss'; import { isEqual } from 'lodash'; import { i18n } from '@osd/i18n'; import { MaplibreRef } from 'public/model/layersFunctions'; -import { IndexPattern } from '../../../../../src/plugins/data/public'; +import { IndexPattern, TimeRange } from '../../../../../src/plugins/data/public'; import { AddLayerPanel } from '../add_layer_panel'; import { LayerConfigPanel } from '../layer_config'; import { MapLayerSpecification } from '../../model/mapLayerType'; @@ -51,6 +51,7 @@ interface Props { selectedLayerConfig: MapLayerSpecification | undefined; setSelectedLayerConfig: (layerConfig: MapLayerSpecification | undefined) => void; setIsUpdatingLayerRender: (isUpdatingLayerRender: boolean) => void; + timeRange?: TimeRange; } export const LayerControlPanel = memo( @@ -64,6 +65,7 @@ export const LayerControlPanel = memo( selectedLayerConfig, setSelectedLayerConfig, setIsUpdatingLayerRender, + timeRange, }: Props) => { const { services } = useOpenSearchDashboards(); @@ -384,6 +386,7 @@ export const LayerControlPanel = memo( originLayerConfig={originLayerConfig} setOriginLayerConfig={setOriginLayerConfig} setIsUpdatingLayerRender={setIsUpdatingLayerRender} + timeRange={timeRange} /> )} )} {mounted && tooltipState === TOOLTIP_STATE.DISPLAY_FEATURES && maplibreRef.current && ( diff --git a/public/model/geo/filter.ts b/public/model/geo/filter.ts index 340fbef6..1ec7ca39 100644 --- a/public/model/geo/filter.ts +++ b/public/model/geo/filter.ts @@ -16,11 +16,7 @@ import { } from '../../../../../src/plugins/data/common'; import { GeoBounds } from '../map/boundary'; -export const buildBBoxFilter = ( - fieldName: string, - mapBounds: GeoBounds, - filterMeta: FilterMeta -): GeoBoundingBoxFilter => { +export const buildBoundingBox = (mapBounds: GeoBounds) => { const bottomRight: LatLon = { lon: mapBounds.bottomRight.lng, lat: mapBounds.bottomRight.lat, @@ -31,10 +27,18 @@ export const buildBBoxFilter = ( lat: mapBounds.topLeft.lat, }; - const boundingBox = { + return { bottom_right: bottomRight, top_left: topLeft, }; +}; + +export const buildBBoxFilter = ( + fieldName: string, + mapBounds: GeoBounds, + filterMeta: FilterMeta +): GeoBoundingBoxFilter => { + const boundingBox = buildBoundingBox(mapBounds); return { meta: { ...filterMeta, diff --git a/public/model/layersFunction.test.ts b/public/model/layersFunction.test.ts index 9367fefa..7db4d50f 100644 --- a/public/model/layersFunction.test.ts +++ b/public/model/layersFunction.test.ts @@ -54,24 +54,29 @@ describe('Exported objects', () => { opensearch_vector_tile_map: OSMLayerFunctions, documents: DocumentLayerFunctions, custom_map: CustomLayerFunctions, + //TODO: will update with layersFunctionsMap + cluster: null, }); expect(layersTypeNameMap).toEqual({ opensearch_vector_tile_map: 'OpenSearch map', documents: 'Documents', custom_map: 'Custom map', + cluster: 'Cluster', }); expect(layersTypeIconMap).toEqual({ opensearch_vector_tile_map: 'globe', documents: 'document', custom_map: 'globe', + cluster: 'heatmap', }); expect(baseLayerTypeLookup).toEqual({ opensearch_vector_tile_map: true, custom_map: true, documents: false, + cluster: false, }); }); }); diff --git a/public/model/layersFunctions.ts b/public/model/layersFunctions.ts index 797518db..83764b04 100644 --- a/public/model/layersFunctions.ts +++ b/public/model/layersFunctions.ts @@ -27,24 +27,29 @@ export const layersFunctionMap: { [key: string]: any } = { [DASHBOARDS_MAPS_LAYER_TYPE.OPENSEARCH_MAP]: OSMLayerFunctions, [DASHBOARDS_MAPS_LAYER_TYPE.DOCUMENTS]: DocumentLayerFunctions, [DASHBOARDS_MAPS_LAYER_TYPE.CUSTOM_MAP]: CustomLayerFunctions, + //TODO: this part in another PR + [DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER]: null, }; export const layersTypeNameMap: { [key: string]: string } = { [DASHBOARDS_MAPS_LAYER_TYPE.OPENSEARCH_MAP]: DASHBOARDS_MAPS_LAYER_NAME.OPENSEARCH_MAP, [DASHBOARDS_MAPS_LAYER_TYPE.DOCUMENTS]: DASHBOARDS_MAPS_LAYER_NAME.DOCUMENTS, [DASHBOARDS_MAPS_LAYER_TYPE.CUSTOM_MAP]: DASHBOARDS_MAPS_LAYER_NAME.CUSTOM_MAP, + [DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER]: DASHBOARDS_MAPS_LAYER_NAME.CLUSTER, }; export const layersTypeIconMap: { [key: string]: string } = { [DASHBOARDS_MAPS_LAYER_TYPE.OPENSEARCH_MAP]: DASHBOARDS_MAPS_LAYER_ICON.OPENSEARCH_MAP, [DASHBOARDS_MAPS_LAYER_TYPE.DOCUMENTS]: DASHBOARDS_MAPS_LAYER_ICON.DOCUMENTS, [DASHBOARDS_MAPS_LAYER_TYPE.CUSTOM_MAP]: DASHBOARDS_MAPS_LAYER_ICON.CUSTOM_MAP, + [DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER]: DASHBOARDS_MAPS_LAYER_ICON.CLUSTER, }; export const baseLayerTypeLookup = { [DASHBOARDS_MAPS_LAYER_TYPE.OPENSEARCH_MAP]: true, [DASHBOARDS_MAPS_LAYER_TYPE.CUSTOM_MAP]: true, [DASHBOARDS_MAPS_LAYER_TYPE.DOCUMENTS]: false, + [DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER]: false, }; export const getDataLayers = (layers: MapLayerSpecification[]): DataLayerSpecification[] => { diff --git a/public/model/mapLayerType.ts b/public/model/mapLayerType.ts index 8a76a84f..9b21fa70 100644 --- a/public/model/mapLayerType.ts +++ b/public/model/mapLayerType.ts @@ -3,16 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Filter } from '../../../../src/plugins/data/public'; +import { Filter, IAggConfigs } from '../../../../src/plugins/data/public'; import { DASHBOARDS_CUSTOM_MAPS_LAYER_TYPE, DASHBOARDS_MAPS_LAYER_TYPE } from '../../common'; /* eslint @typescript-eslint/consistent-type-definitions: ["error", "type"] */ export type MapLayerSpecification = | OSMLayerSpecification | DocumentLayerSpecification - | CustomLayerSpecification; + | CustomLayerSpecification + | ClusterLayerSpecification; -export type DataLayerSpecification = DocumentLayerSpecification; +export type DataLayerSpecification = DocumentLayerSpecification | ClusterLayerSpecification; export type BaseLayerSpecification = OSMLayerSpecification | CustomLayerSpecification; @@ -93,3 +94,20 @@ export type CustomWMSLayerSpecification = AbstractLayerSpecification & { bbox: string; }; }; + +export type ClusterLayerSpecification = AbstractLayerSpecification & { + type: DASHBOARDS_MAPS_LAYER_TYPE.CLUSTER; + source: { + indexPatternRefName: string; + indexPatternId: string; + aggs?: IAggConfigs; + }; + style: { + fillType: 'gradient' | 'solid'; + palette: string; + fillColor: string; + borderColor: string; + borderThickness: number; + radiusRange: [number, number]; + }; +}; diff --git a/public/plugin.tsx b/public/plugin.tsx index ce777fdd..a1cb7c5b 100644 --- a/public/plugin.tsx +++ b/public/plugin.tsx @@ -35,7 +35,7 @@ import { MAPS_VISUALIZATION_DESCRIPTION, } from '../common'; import { MapEmbeddableFactoryDefinition } from './embeddable'; -import { setTimeFilter } from './services'; +import { setTimeFilter, setAggs } from './services'; export class CustomImportMapPlugin implements Plugin @@ -169,6 +169,7 @@ export class CustomImportMapPlugin public start(core: CoreStart, { data }: AppPluginStartDependencies): CustomImportMapPluginStart { setTimeFilter(data.query.timefilter.timefilter); + setAggs(data.search.aggs); return {}; } diff --git a/public/services.ts b/public/services.ts index da4ce07b..bd8111d3 100644 --- a/public/services.ts +++ b/public/services.ts @@ -5,7 +5,7 @@ import { CoreStart } from '../../../src/core/public'; import { createGetterSetter } from '../../../src/plugins/opensearch_dashboards_utils/common'; -import { TimefilterContract } from '../../../src/plugins/data/public'; +import { TimefilterContract, DataPublicPluginStart } from '../../../src/plugins/data/public'; export const postGeojson = async (requestBody: any, http: CoreStart['http']) => { try { @@ -32,3 +32,6 @@ export const getIndex = async (indexName: string, http: CoreStart['http']) => { }; export const [getTimeFilter, setTimeFilter] = createGetterSetter('TimeFilter'); + +export const [getAggs, setAggs] = + createGetterSetter('AggConfigs'); diff --git a/public/utils/getIntialConfig.ts b/public/utils/getIntialConfig.ts index bbe696b3..a5eaf994 100644 --- a/public/utils/getIntialConfig.ts +++ b/public/utils/getIntialConfig.ts @@ -25,7 +25,12 @@ import { DOCUMENTS_DEFAULT_LABEL_COLOR, DOCUMENTS_DEFAULT_LABEL_BORDER_COLOR, DOCUMENTS_NONE_LABEL_BORDER_WIDTH, - DOCUMENTS_DEFAULT_APPLY_GLOBAL_FILTERS, + CLUSTER, + CLUSTER_DEFAULT_FILL_TYPE, + CLUSTER_DEFAULT_PALETTE, + CLUSTER_MIN_DEFAULT_RADIUS_SIZE, + CLUSTER_MAX_DEFAULT_RADIUS_SIZE, + CLUSTER_DEFAULT_MARKER_BORDER_THICKNESS, } from '../../common'; import { MapState } from '../model/mapState'; import { ConfigSchema } from '../../common/config'; @@ -62,7 +67,6 @@ export const getLayerConfigMap = (mapConfig: ConfigSchema) => ({ tooltipFields: DOCUMENTS_DEFAULT_TOOLTIPS, showTooltips: DOCUMENTS_DEFAULT_SHOW_TOOLTIPS, displayTooltipsOnHover: DOCUMENTS_DEFAULT_DISPLAY_TOOLTIPS_ON_HOVER, - applyGlobalFilters: DOCUMENTS_DEFAULT_APPLY_GLOBAL_FILTERS, }, style: { ...getStyleColor(), @@ -100,6 +104,26 @@ export const getLayerConfigMap = (mapConfig: ConfigSchema) => ({ bbox: '', }, }, + [CLUSTER.type]: { + name: '', + description: '', + type: CLUSTER.type, + id: uuidv4(), + zoomRange: [MAP_DEFAULT_MIN_ZOOM, MAP_DEFAULT_MAX_ZOOM], + opacity: MAP_DATA_LAYER_DEFAULT_OPACITY, + visibility: LAYER_VISIBILITY.VISIBLE, + source: { + indexPatternRefName: undefined, + aggs: null, + }, + style: { + ...getStyleColor(), + borderThickness: CLUSTER_DEFAULT_MARKER_BORDER_THICKNESS, + fillType: CLUSTER_DEFAULT_FILL_TYPE, + palette: CLUSTER_DEFAULT_PALETTE, + radiusRange: [CLUSTER_MIN_DEFAULT_RADIUS_SIZE, CLUSTER_MAX_DEFAULT_RADIUS_SIZE], + }, + }, }); const getInitialColor = () => {