diff --git a/docs/maps/vector-style-properties.asciidoc b/docs/maps/vector-style-properties.asciidoc index be1b2c5e25285..ac06ba32e6e40 100644 --- a/docs/maps/vector-style-properties.asciidoc +++ b/docs/maps/vector-style-properties.asciidoc @@ -61,7 +61,6 @@ Available icons [role="screenshot"] image::maps/images/maki-icons.png[] - [float] [[polygon-style-properties]] ==== Polygon style properties diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index e02fead277f60..b51259307f3a1 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -218,6 +218,17 @@ export enum LABEL_BORDER_SIZES { } export const DEFAULT_ICON = 'marker'; +export const DEFAULT_CUSTOM_ICON_CUTOFF = 0.25; +export const DEFAULT_CUSTOM_ICON_RADIUS = 0.25; +export const CUSTOM_ICON_SIZE = 64; +export const CUSTOM_ICON_PREFIX_SDF = '__kbn__custom_icon_sdf__'; +export const MAKI_ICON_SIZE = 16; +export const HALF_MAKI_ICON_SIZE = MAKI_ICON_SIZE / 2; + +export enum ICON_SOURCE { + CUSTOM = 'CUSTOM', + MAKI = 'MAKI', +} export enum VECTOR_STYLES { SYMBOLIZE_AS = 'symbolizeAs', diff --git a/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts index 1a334448e9208..dce4d0f9df50e 100644 --- a/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/style_property_descriptor_types.ts @@ -10,6 +10,7 @@ import { COLOR_MAP_TYPE, FIELD_ORIGIN, + ICON_SOURCE, LABEL_BORDER_SIZES, SYMBOLIZE_AS_TYPES, VECTOR_STYLES, @@ -60,6 +61,7 @@ export type CategoryColorStop = { export type IconStop = { stop: string | null; icon: string; + iconSource?: ICON_SOURCE; }; export type ColorDynamicOptions = { @@ -108,6 +110,9 @@ export type IconDynamicOptions = { export type IconStaticOptions = { value: string; // icon id + label?: string; + svg?: string; + iconSource?: ICON_SOURCE; }; export type IconStylePropertyDescriptor = @@ -178,6 +183,14 @@ export type SizeStylePropertyDescriptor = options: SizeDynamicOptions; }; +export type CustomIcon = { + symbolId: string; + svg: string; // svg string + label: string; // user given label + cutoff: number; + radius: number; +}; + export type VectorStylePropertiesDescriptor = { [VECTOR_STYLES.SYMBOLIZE_AS]: SymbolizeAsStylePropertyDescriptor; [VECTOR_STYLES.FILL_COLOR]: ColorStylePropertyDescriptor; diff --git a/x-pack/plugins/maps/public/actions/layer_actions.test.ts b/x-pack/plugins/maps/public/actions/layer_actions.test.ts index 06adbed92c0cf..cbec68e5108f8 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.test.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.test.ts @@ -39,6 +39,11 @@ describe('layer_actions', () => { return true; }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../selectors/map_selectors').getCustomIcons = () => { + return []; + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires require('../selectors/map_selectors').createLayerInstance = () => { return { diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index b081ed6d34979..a89172f8ce340 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -11,6 +11,7 @@ import { Query } from 'src/plugins/data/public'; import { MapStoreState } from '../reducers/store'; import { createLayerInstance, + getCustomIcons, getEditState, getLayerById, getLayerList, @@ -174,8 +175,7 @@ export function addLayer(layerDescriptor: LayerDescriptor) { layer: layerDescriptor, }); dispatch(syncDataForLayerId(layerDescriptor.id, false)); - - const layer = createLayerInstance(layerDescriptor); + const layer = createLayerInstance(layerDescriptor, getCustomIcons(getState())); const features = await layer.getLicensedFeatures(); features.forEach(notifyLicensedFeatureUsage); }; diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index cccb49f360622..24a378335bc56 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -14,10 +14,11 @@ import turfBooleanContains from '@turf/boolean-contains'; import { Filter } from '@kbn/es-query'; import { Query, TimeRange } from 'src/plugins/data/public'; import { Geometry, Position } from 'geojson'; -import { asyncForEach } from '@kbn/std'; -import { DRAW_MODE, DRAW_SHAPE } from '../../common/constants'; +import { asyncForEach, asyncMap } from '@kbn/std'; +import { DRAW_MODE, DRAW_SHAPE, LAYER_STYLE_TYPE } from '../../common/constants'; import type { MapExtentState, MapViewContext } from '../reducers/map/types'; import { MapStoreState } from '../reducers/store'; +import { IVectorStyle } from '../classes/styles/vector/vector_style'; import { getDataFilters, getFilters, @@ -60,7 +61,13 @@ import { } from './data_request_actions'; import { addLayer, addLayerWithoutDataSync } from './layer_actions'; import { MapSettings } from '../reducers/map'; -import { DrawState, MapCenterAndZoom, MapExtent, Timeslice } from '../../common/descriptor_types'; +import { + CustomIcon, + DrawState, + MapCenterAndZoom, + MapExtent, + Timeslice, +} from '../../common/descriptor_types'; import { INITIAL_LOCATION } from '../../common/constants'; import { updateTooltipStateForLayer } from './tooltip_actions'; import { isVectorLayer, IVectorLayer } from '../classes/layers/vector_layer'; @@ -108,6 +115,51 @@ export function updateMapSetting( }; } +export function updateCustomIcons(customIcons: CustomIcon[]) { + return { + type: UPDATE_MAP_SETTING, + settingKey: 'customIcons', + settingValue: customIcons.map((icon) => { + return { ...icon, svg: Buffer.from(icon.svg).toString('base64') }; + }), + }; +} + +export function deleteCustomIcon(value: string) { + return async ( + dispatch: ThunkDispatch, + getState: () => MapStoreState + ) => { + const layersContainingCustomIcon = getLayerList(getState()).filter((layer) => { + const style = layer.getCurrentStyle(); + if (!style || style.getType() !== LAYER_STYLE_TYPE.VECTOR) { + return false; + } + return (style as IVectorStyle).isUsingCustomIcon(value); + }); + + if (layersContainingCustomIcon.length > 0) { + const layerList = await asyncMap(layersContainingCustomIcon, async (layer) => { + return await layer.getDisplayName(); + }); + getToasts().addWarning( + i18n.translate('xpack.maps.mapActions.deleteCustomIconWarning', { + defaultMessage: `Unable to delete icon. The icon is in use by the {count, plural, one {layer} other {layers}}: {layerNames}`, + values: { + count: layerList.length, + layerNames: layerList.join(', '), + }, + }) + ); + } else { + const newIcons = getState().map.settings.customIcons.filter( + ({ symbolId }) => symbolId !== value + ); + dispatch(updateMapSetting('customIcons', newIcons)); + } + }; +} + export function mapReady() { return ( dispatch: ThunkDispatch, diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 99afa21a3f003..458a8a159a25d 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -26,6 +26,7 @@ import { import { copyPersistentState } from '../../reducers/copy_persistent_state'; import { Attribution, + CustomIcon, LayerDescriptor, MapExtent, StyleDescriptor, @@ -92,7 +93,8 @@ export interface ILayer { isVisible(): boolean; cloneDescriptor(): Promise; renderStyleEditor( - onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void + onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, + onCustomIconsChange: (customIcons: CustomIcon[]) => void ): ReactElement | null; getInFlightRequestTokens(): symbol[]; getPrevRequestToken(dataId: string): symbol | undefined; @@ -431,13 +433,14 @@ export class AbstractLayer implements ILayer { } renderStyleEditor( - onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void + onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, + onCustomIconsChange: (customIcons: CustomIcon[]) => void ): ReactElement | null { const style = this.getStyleForEditing(); if (!style) { return null; } - return style.renderEditor(onStyleDescriptorChange); + return style.renderEditor(onStyleDescriptorChange, onCustomIconsChange); } getIndexPatternIds(): string[] { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx index ee97f4c243491..f2ef7ca9588be 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.test.tsx @@ -10,6 +10,7 @@ import { BlendedVectorLayer } from './blended_vector_layer'; import { ESSearchSource } from '../../../sources/es_search_source'; import { AbstractESSourceDescriptor, + CustomIcon, ESGeoGridSourceDescriptor, } from '../../../../../common/descriptor_types'; @@ -23,6 +24,8 @@ jest.mock('../../../../kibana_services', () => { const mapColors: string[] = []; +const customIcons: CustomIcon[] = []; + const notClusteredDataRequest = { data: { isSyncClustered: false }, dataId: 'ACTIVE_COUNT_DATA_ID', @@ -51,6 +54,7 @@ describe('getSource', () => { }, mapColors ), + customIcons, }); const source = blendedVectorLayer.getSource(); @@ -72,6 +76,7 @@ describe('getSource', () => { }, mapColors ), + customIcons, }); const source = blendedVectorLayer.getSource(); @@ -112,6 +117,7 @@ describe('getSource', () => { }, mapColors ), + customIcons, }); const source = blendedVectorLayer.getSource(); @@ -132,6 +138,7 @@ describe('cloneDescriptor', () => { }, mapColors ), + customIcons, }); const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); @@ -151,6 +158,7 @@ describe('cloneDescriptor', () => { }, mapColors ), + customIcons, }); const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts index 91421a31219cb..e46c670b677ba 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/blended_vector_layer/blended_vector_layer.ts @@ -31,6 +31,7 @@ import { ISource } from '../../../sources/source'; import { DataRequestContext } from '../../../../actions'; import { DataRequestAbortError } from '../../../util/data_request'; import { + CustomIcon, VectorStyleDescriptor, SizeDynamicOptions, DynamicStylePropertyOptions, @@ -171,6 +172,7 @@ export interface BlendedVectorLayerArguments { chartsPaletteServiceGetColor?: (value: string) => string | null; source: IVectorSource; layerDescriptor: VectorLayerDescriptor; + customIcons: CustomIcon[]; } export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLayer { @@ -207,6 +209,7 @@ export class BlendedVectorLayer extends GeoJsonVectorLayer implements IVectorLay clusterStyleDescriptor, this._clusterSource, this, + options.customIcons, options.chartsPaletteServiceGetColor ); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx index 4e4e76d3634f4..27d377851152e 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.test.tsx @@ -58,7 +58,7 @@ function createLayer( sourceDescriptor, }; const layerDescriptor = MvtVectorLayer.createDescriptor(defaultLayerOptions); - return new MvtVectorLayer({ layerDescriptor, source: mvtSource }); + return new MvtVectorLayer({ layerDescriptor, source: mvtSource, customIcons: [] }); } describe('visiblity', () => { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index 5947013dc39f1..325e302c0941a 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -54,8 +54,8 @@ export class MvtVectorLayer extends AbstractVectorLayer { readonly _source: IMvtVectorSource; - constructor({ layerDescriptor, source }: VectorLayerArguments) { - super({ layerDescriptor, source }); + constructor({ layerDescriptor, source, customIcons }: VectorLayerArguments) { + super({ layerDescriptor, source, customIcons }); this._source = source as IMvtVectorSource; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx index bd2c8a036bf59..5b91e5e49c514 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.test.tsx @@ -86,6 +86,7 @@ describe('cloneDescriptor', () => { const layer = new AbstractVectorLayer({ layerDescriptor, source: new MockSource() as unknown as IVectorSource, + customIcons: [], }); const clonedDescriptor = await layer.cloneDescriptor(); const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; @@ -123,6 +124,7 @@ describe('cloneDescriptor', () => { const layer = new AbstractVectorLayer({ layerDescriptor, source: new MockSource() as unknown as IVectorSource, + customIcons: [], }); const clonedDescriptor = await layer.cloneDescriptor(); const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index b8b90fdf75ff4..17408a2a22df0 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -38,6 +38,7 @@ import { } from '../../util/mb_filter_expressions'; import { AggDescriptor, + CustomIcon, DynamicStylePropertyOptions, DataFilters, ESTermSourceDescriptor, @@ -70,6 +71,7 @@ export interface VectorLayerArguments { source: IVectorSource; joins?: InnerJoin[]; layerDescriptor: VectorLayerDescriptor; + customIcons: CustomIcon[]; chartsPaletteServiceGetColor?: (value: string) => string | null; } @@ -133,6 +135,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { layerDescriptor, source, joins = [], + customIcons, chartsPaletteServiceGetColor, }: VectorLayerArguments) { super({ @@ -144,6 +147,7 @@ export class AbstractVectorLayer extends AbstractLayer implements IVectorLayer { layerDescriptor.style, source, this, + customIcons, chartsPaletteServiceGetColor ); } diff --git a/x-pack/plugins/maps/public/classes/styles/_index.scss b/x-pack/plugins/maps/public/classes/styles/_index.scss index bd1467bed9d4e..1cc5f4423d02f 100644 --- a/x-pack/plugins/maps/public/classes/styles/_index.scss +++ b/x-pack/plugins/maps/public/classes/styles/_index.scss @@ -2,5 +2,7 @@ @import 'vector/components/style_prop_editor'; @import 'vector/components/color/color_stops'; @import 'vector/components/symbol/icon_select'; +@import 'vector/components/symbol/icon_preview'; +@import 'vector/components/symbol/custom_icon_modal'; @import 'vector/components/legend/category'; @import 'vector/components/legend/vector_legend'; diff --git a/x-pack/plugins/maps/public/classes/styles/style.ts b/x-pack/plugins/maps/public/classes/styles/style.ts index c8326f365f42b..bafb0e9c36d75 100644 --- a/x-pack/plugins/maps/public/classes/styles/style.ts +++ b/x-pack/plugins/maps/public/classes/styles/style.ts @@ -6,11 +6,12 @@ */ import { ReactElement } from 'react'; -import { StyleDescriptor } from '../../../common/descriptor_types'; +import { CustomIcon, StyleDescriptor } from '../../../common/descriptor_types'; export interface IStyle { getType(): string; renderEditor( - onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void + onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, + onCustomIconsChange: (customIcons: CustomIcon[]) => void ): ReactElement | null; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/__snapshots__/vector_icon.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/__snapshots__/vector_icon.test.tsx.snap index 656a4eb6ca599..e41b3b09134d4 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/__snapshots__/vector_icon.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/__snapshots__/vector_icon.test.tsx.snap @@ -41,6 +41,18 @@ exports[`Renders SymbolIcon 1`] = ` key="airfield-15#ff0000rgb(106,173,213)" stroke="rgb(106,173,213)" style={Object {}} + svg="\\\\n\\\\n \\\\n" symbolId="airfield-15" /> `; + +exports[`Renders SymbolIcon with custom icon 1`] = ` +" + symbolId="__kbn__custom_icon_sdf__foobar" +/> +`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/breaked_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/breaked_legend.tsx index 5cc4cfa67df01..fb851ae629f62 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/breaked_legend.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/breaked_legend.tsx @@ -16,6 +16,7 @@ const EMPTY_VALUE = ''; export interface Break { color: string; label: ReactElement | string | number; + svg?: string; symbolId?: string; } @@ -66,16 +67,17 @@ export class BreakedLegend extends Component { return null; } - const categories = this.props.breaks.map((brk, index) => { + const categories = this.props.breaks.map(({ symbolId, svg, label, color }, index) => { return ( ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/category.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/category.tsx index cec8b48f505e8..cc544fd4030e7 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/category.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/category.tsx @@ -17,9 +17,18 @@ interface Props { isLinesOnly: boolean; isPointsOnly: boolean; symbolId?: string; + svg?: string; } -export function Category({ styleName, label, color, isLinesOnly, isPointsOnly, symbolId }: Props) { +export function Category({ + styleName, + label, + color, + isLinesOnly, + isPointsOnly, + symbolId, + svg, +}: Props) { function renderIcon() { if (styleName === VECTOR_STYLES.LABEL_COLOR) { return ( @@ -36,6 +45,7 @@ export function Category({ styleName, label, color, isLinesOnly, isPointsOnly, s isLinesOnly={isLinesOnly} strokeColor={color} symbolId={symbolId} + svg={svg} /> ); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx index cec1e8ed40aa2..4cc4d4169d7e0 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx @@ -7,13 +7,14 @@ import React, { Component, CSSProperties } from 'react'; // @ts-expect-error -import { getMakiSymbolSvg, styleSvg, buildSrcUrl } from '../../symbol_utils'; +import { CUSTOM_ICON_PREFIX_SDF, getSymbolSvg, styleSvg, buildSrcUrl } from '../../symbol_utils'; interface Props { symbolId: string; fill?: string; stroke?: string; - style: CSSProperties; + style?: CSSProperties; + svg: string; } interface State { @@ -39,8 +40,7 @@ export class SymbolIcon extends Component { async _loadSymbol() { let imgDataUrl; try { - const svg = getMakiSymbolSvg(this.props.symbolId); - const styledSvg = await styleSvg(svg, this.props.fill, this.props.stroke); + const styledSvg = await styleSvg(this.props.svg, this.props.fill, this.props.stroke); imgDataUrl = buildSrcUrl(styledSvg); } catch (error) { // ignore failures - component will just not display an icon diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.test.tsx index 7907780d0ab4a..09993f8d0e5f3 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.test.tsx @@ -52,6 +52,22 @@ test('Renders SymbolIcon', () => { isLinesOnly={false} strokeColor="rgb(106,173,213)" symbolId="airfield-15" + svg='\n\n \n' + /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('Renders SymbolIcon with custom icon', () => { + const component = shallow( + ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.tsx index 333ba932dc6f3..745d1aae1b8dd 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_icon.tsx @@ -19,6 +19,7 @@ interface Props { isLinesOnly: boolean; strokeColor?: string; symbolId?: string; + svg?: string; } export function VectorIcon({ @@ -28,6 +29,7 @@ export function VectorIcon({ isLinesOnly, strokeColor, symbolId, + svg, }: Props) { if (isLinesOnly) { const style = { @@ -53,13 +55,18 @@ export function VectorIcon({ return ; } - return ( - - ); + if (svg) { + return ( + + ); + } + + return null; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_style_legend.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_style_legend.tsx index 4de64e328eb13..2d282a4b530cb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_style_legend.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/vector_style_legend.tsx @@ -13,9 +13,10 @@ interface Props { isPointsOnly: boolean; styles: Array>; symbolId?: string; + svg?: string; } -export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId }: Props) { +export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId, svg }: Props) { const legendRows = []; for (let i = 0; i < styles.length; i++) { @@ -23,6 +24,7 @@ export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId isLinesOnly, isPointsOnly, symbolId, + svg, }); legendRows.push( diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/style_prop_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/style_prop_editor.tsx index 95be7f56a5597..c239e316d472f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/style_prop_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/style_prop_editor.tsx @@ -17,6 +17,7 @@ import { import { i18n } from '@kbn/i18n'; import { getVectorStyleLabel, getDisabledByMessage } from './get_vector_style_label'; import { STYLE_TYPE, VECTOR_STYLES } from '../../../../../common/constants'; +import { CustomIcon } from '../../../../../common/descriptor_types'; import { IStyleProperty } from '../properties/style_property'; import { StyleField } from '../style_fields_helper'; @@ -27,9 +28,11 @@ export interface Props { defaultDynamicStyleOptions: DynamicOptions; disabled?: boolean; disabledBy?: VECTOR_STYLES; + customIcons?: CustomIcon[]; fields: StyleField[]; onDynamicStyleChange: (propertyName: VECTOR_STYLES, options: DynamicOptions) => void; onStaticStyleChange: (propertyName: VECTOR_STYLES, options: StaticOptions) => void; + onCustomIconsChange?: (customIcons: CustomIcon[]) => void; styleProperty: IStyleProperty; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/custom_icon_modal.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/custom_icon_modal.test.tsx.snap new file mode 100644 index 0000000000000..06e6f24f9ea89 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/custom_icon_modal.test.tsx.snap @@ -0,0 +1,311 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render a custom icon modal with an existing icon 1`] = ` + + + +

+ Edit custom icon +

+
+
+ + + + + + + + + + + + + + + + + Reset + + + + + + Alpha threshold + + + + + } + labelType="label" + > + + + + + Radius + + + + + } + labelType="label" + > + + + + + + + " + /> + + + + + + + + Cancel + + + + + Delete + + + + + Save + + + + +
+`; + +exports[`should render an empty custom icon modal 1`] = ` + + + +

+ Custom Icon +

+
+
+ + + + + + + + + + + + + + + Cancel + + + + + Save + + + + +
+`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_map_select.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_map_select.test.tsx.snap index b0b85268aa1c8..324e4d9dbd453 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_map_select.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_map_select.test.tsx.snap @@ -3,6 +3,7 @@ exports[`Should not render icon map select when isCustomOnly 1`] = ` + + +", }, ] } onChange={[Function]} + onCustomIconsChange={[Function]} /> `; @@ -62,6 +64,7 @@ exports[`Should render custom stops input when useCustomIconMap 1`] = ` size="s" /> `; @@ -122,3 +126,40 @@ exports[`Should render default props 1`] = ` /> `; + +exports[`Should render icon map select with custom icons 1`] = ` + + + mock filledShapes option + , + "value": "filledShapes", + }, + Object { + "inputDisplay":
+ mock hollowShapes option +
, + "value": "hollowShapes", + }, + ] + } + valueOfSelected="filledShapes" + /> + +
+`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap index c0505426d1f63..d9a62dff00423 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/__snapshots__/icon_select.test.js.snap @@ -1,76 +1,204 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Should render icon select 1`] = ` - - + + icon={ + Object { + "side": "right", + "type": "arrowDown", + } } + onKeyDown={[Function]} readOnly={true} - value="symbol1" - /> - - } - closePopover={[Function]} - display="block" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="s" -> - - + , - "value": "symbol1", - }, + /> + } + readOnly={true} + value="symbol1" + /> + + } + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + > + + + + +" + symbolId="symbol1" + />, + }, + Object { + "key": "symbol2", + "label": "symbol2", + "prepend": + + +" + symbolId="symbol2" + />, + }, + ] + } + searchable={true} + singleSelection={false} + > + + + + + +`; + +exports[`Should render icon select with custom icons 1`] = ` + + + , - "value": "symbol2", - }, - ] - } - searchable={true} - singleSelection={false} + svg="" + symbolId="__kbn__custom_icon_sdf__foobar" + /> + } + readOnly={true} + value="My Custom Icon" + /> + + } + closePopover={[Function]} + display="block" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + > + - - - - + " + symbolId="__kbn__custom_icon_sdf__foobar" + />, + }, + Object { + "key": "__kbn__custom_icon_sdf__bizzbuzz", + "label": "My Other Custom Icon", + "prepend": " + symbolId="__kbn__custom_icon_sdf__bizzbuzz" + />, + }, + Object { + "isGroupLabel": true, + "label": "Kibana icons", + }, + Object { + "key": "symbol1", + "label": "symbol1", + "prepend": + + +" + symbolId="symbol1" + />, + }, + Object { + "key": "symbol2", + "label": "symbol2", + "prepend": + + +" + symbolId="symbol2" + />, + }, + ] + } + searchable={true} + singleSelection={false} + > + + + + + `; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_custom_icon_modal.scss b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_custom_icon_modal.scss new file mode 100644 index 0000000000000..e3480d0017435 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_custom_icon_modal.scss @@ -0,0 +1,8 @@ +.mapsCustomIconForm { + min-width: 400px; +} + +.mapsCustomIconForm__preview { + max-width: 210px; + min-height: 210px; +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_preview.scss b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_preview.scss new file mode 100644 index 0000000000000..2204483710eb0 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_preview.scss @@ -0,0 +1,3 @@ +.mapsCustomIconPreview__mapContainer { + height: 150px; +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_select.scss b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_select.scss index 5e69d97131095..bc244131d9314 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_select.scss +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/_icon_select.scss @@ -1,3 +1,3 @@ .mapIconSelectSymbol__inputButton { - margin-left: $euiSizeS; + margin: 0 $euiSizeXS; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.test.tsx new file mode 100644 index 0000000000000..8e21679405c07 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { CustomIconModal } from './custom_icon_modal'; + +const defaultProps = { + cutoff: 0.25, + onCancel: () => {}, + onSave: () => {}, + radius: 0.25, + title: 'Custom Icon', +}; + +test('should render an empty custom icon modal', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render a custom icon modal with an existing icon', () => { + const component = shallow( + {}} + radius={0.15} + svg='' + symbolId="__kbn__custom_icon_sdf__foobar" + title="Edit custom icon" + /> + ); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx new file mode 100644 index 0000000000000..9898ac0cd3e93 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx @@ -0,0 +1,393 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import { + EuiAccordion, + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiPanel, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { IconPreview } from './icon_preview'; +// @ts-expect-error +import { getCustomIconId } from '../../symbol_utils'; +// @ts-expect-error +import { ValidatedRange } from '../../../../../components/validated_range'; +import { CustomIcon } from '../../../../../../common/descriptor_types'; + +const strings = { + getAdvancedOptionsLabel: () => + i18n.translate('xpack.maps.customIconModal.advancedOptionsLabel', { + defaultMessage: 'Advanced options', + }), + getCancelButtonLabel: () => + i18n.translate('xpack.maps.customIconModal.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + getCutoffRangeLabel: () => ( + + <> + {i18n.translate('xpack.maps.customIconModal.cutoffRangeLabel', { + defaultMessage: 'Alpha threshold', + })}{' '} + + + + ), + getDeleteButtonLabel: () => + i18n.translate('xpack.maps.customIconModal.deleteButtonLabel', { + defaultMessage: 'Delete', + }), + getImageFilePickerPlaceholder: () => + i18n.translate('xpack.maps.customIconModal.imageFilePickerPlaceholder', { + defaultMessage: 'Select or drag and drop an SVG icon', + }), + getImageInputDescription: () => + i18n.translate('xpack.maps.customIconModal.imageInputDescription', { + defaultMessage: + 'SVGs without sharp corners and intricate details work best. Modifying the settings under Advanced options may improve rendering.', + }), + getInvalidFileLabel: () => + i18n.translate('xpack.maps.customIconModal.invalidFileError', { + defaultMessage: 'Icon must be in SVG format. Other image types are not supported.', + }), + getNameInputLabel: () => + i18n.translate('xpack.maps.customIconModal.nameInputLabel', { + defaultMessage: 'Name', + }), + getRadiusRangeLabel: () => ( + + <> + {i18n.translate('xpack.maps.customIconModal.radiusRangeLabel', { + defaultMessage: 'Radius', + })}{' '} + + + + ), + getResetButtonLabel: () => + i18n.translate('xpack.maps.customIconModal.resetButtonLabel', { + defaultMessage: 'Reset', + }), + getSaveButtonLabel: () => + i18n.translate('xpack.maps.customIconModal.saveButtonLabel', { + defaultMessage: 'Save', + }), +}; + +function getFileNameWithoutExt(fileName: string) { + const splits = fileName.split('.'); + if (splits.length > 1) { + splits.pop(); + } + return splits.join('.'); +} +interface Props { + /** + * initial value for the id of image added to map + */ + symbolId?: string; + /** + * initial value of the label of the custom element + */ + label?: string; + /** + * initial value of the preview image of the custom element as a base64 dataurl + */ + svg?: string; + /** + * intial value of alpha threshold for signed-distance field + */ + cutoff: number; + /** + * intial value of radius for signed-distance field + */ + radius: number; + /** + * title of the modal + */ + title: string; + /** + * A click handler for the save button + */ + onSave: (icon: CustomIcon) => void; + /** + * A click handler for the cancel button + */ + onCancel: () => void; + /** + * A click handler for the delete button + */ + onDelete?: (symbolId: string) => void; +} + +interface State { + /** + * label of the custom element to be saved + */ + label: string; + /** + * image of the custom element to be saved + */ + svg: string; + + cutoff: number; + radius: number; + isFileInvalid: boolean; +} + +export class CustomIconModal extends Component { + private _isMounted: boolean = false; + + public state = { + label: this.props.label || '', + svg: this.props.svg || '', + cutoff: this.props.cutoff, + radius: this.props.radius, + isFileInvalid: this.props.svg ? false : true, + }; + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + private _handleLabelChange = (value: string) => { + this.setState({ label: value }); + }; + + private _handleCutoffChange = (value: number) => { + this.setState({ cutoff: value }); + }; + + private _handleRadiusChange = (value: number) => { + this.setState({ radius: value }); + }; + + private _resetAdvancedOptions = () => { + this.setState({ radius: this.props.radius, cutoff: this.props.cutoff }); + }; + + private _onFileSelect = async (files: FileList | null) => { + this.setState({ + label: '', + svg: '', + isFileInvalid: false, + }); + + if (files && files.length) { + const file = files[0]; + const { type } = file; + if (type === 'image/svg+xml') { + const label = this.props.label ?? getFileNameWithoutExt(file.name); + try { + const svg = await file.text(); + + if (!this._isMounted) { + return; + } + this.setState({ isFileInvalid: false, label, svg }); + } catch (err) { + if (!this._isMounted) { + return; + } + this.setState({ isFileInvalid: true }); + } + } else { + this.setState({ isFileInvalid: true }); + } + } + }; + + private _renderAdvancedOptions() { + const { cutoff, radius } = this.state; + return ( + + + + + + {strings.getResetButtonLabel()} + + + + + + + + + + + + ); + } + + private _renderIconForm() { + const { label, svg } = this.state; + return svg !== '' ? ( + <> + + this._handleLabelChange(e.target.value)} + required + data-test-subj="mapsCustomIconForm-label" + /> + + + {this._renderAdvancedOptions()} + + ) : null; + } + + private _renderIconPreview() { + const { svg, isFileInvalid, cutoff, radius } = this.state; + return svg !== '' ? ( + + + + ) : null; + } + + public render() { + const { symbolId, onSave, onCancel, onDelete, title } = this.props; + const { label, svg, cutoff, radius, isFileInvalid } = this.state; + const isComplete = label.length !== 0 && svg.length !== 0 && !isFileInvalid; + const fileError = svg && isFileInvalid ? strings.getInvalidFileLabel() : ''; + return ( + + + +

{title}

+
+
+ + + + + + + + {this._renderIconForm()} + + {this._renderIconPreview()} + + + + + + {strings.getCancelButtonLabel()} + + {onDelete && symbolId ? ( + + { + onDelete(symbolId); + }} + data-test-subj="mapsCustomIconForm-submit" + > + {strings.getDeleteButtonLabel()} + + + ) : null} + + { + onSave({ symbolId: symbolId ?? getCustomIconId(), label, svg, cutoff, radius }); + }} + data-test-subj="mapsCustomIconForm-submit" + isDisabled={!isComplete} + > + {strings.getSaveButtonLabel()} + + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js index c7d6928884183..3bc8208e2325e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js @@ -14,6 +14,8 @@ import { IconMapSelect } from './icon_map_select'; export function DynamicIconForm({ fields, onDynamicStyleChange, + onCustomIconsChange, + customIcons, staticDynamicSelect, styleProperty, }) { @@ -44,7 +46,9 @@ export function DynamicIconForm({ ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.test.tsx index be59b2eae9026..e569b0cabb753 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.test.tsx @@ -52,6 +52,15 @@ const defaultProps = { styleProperty: new MockDynamicStyleProperty() as unknown as IDynamicStyleProperty, isCustomOnly: false, + customIconStops: [ + { + stop: null, + icon: 'circle', + svg: '\n\n \n', + }, + ], + customIcons: [], + onCustomIconsChange: () => {}, }; test('Should render default props', () => { @@ -66,8 +75,50 @@ test('Should render custom stops input when useCustomIconMap', () => { {...defaultProps} useCustomIconMap={true} customIconStops={[ - { stop: null, icon: 'circle' }, - { stop: 'value1', icon: 'marker' }, + { + stop: null, + icon: 'circle', + }, + { + stop: 'value1', + icon: 'marker', + }, + ]} + /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('Should render icon map select with custom icons', () => { + const component = shallow( + ', + cutoff: 0.25, + radius: 0.25, + }, + { + symbolId: '__kbn__custom_icon_sdf__bizzbuzz', + label: 'My Other Custom Icon', + svg: '', + cutoff: 0.3, + radius: 0.15, + }, + ]} + customIconStops={[ + { + stop: null, + icon: '__kbn__custom_icon_sdf__bizzbuzz', + }, + { + stop: 'value1', + icon: 'marker', + }, ]} /> ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx index f5a0390c602e9..37b6a9185ad71 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx @@ -13,14 +13,19 @@ import { i18n } from '@kbn/i18n'; import { IconStops } from './icon_stops'; // @ts-expect-error import { getIconPaletteOptions, PREFERRED_ICONS } from '../../symbol_utils'; -import { IconDynamicOptions, IconStop } from '../../../../../../common/descriptor_types'; +import { + CustomIcon, + IconDynamicOptions, + IconStop, +} from '../../../../../../common/descriptor_types'; +import { ICON_SOURCE } from '../../../../../../common/constants'; import { IDynamicStyleProperty } from '../../properties/dynamic_style_property'; const CUSTOM_MAP_ID = 'CUSTOM_MAP_ID'; -const DEFAULT_ICON_STOPS = [ - { stop: null, icon: PREFERRED_ICONS[0] }, // first stop is the "other" category - { stop: '', icon: PREFERRED_ICONS[1] }, +const DEFAULT_ICON_STOPS: IconStop[] = [ + { stop: null, icon: PREFERRED_ICONS[0], iconSource: ICON_SOURCE.MAKI }, // first stop is the "other" category + { stop: '', icon: PREFERRED_ICONS[1], iconSource: ICON_SOURCE.MAKI }, ]; interface StyleOptionChanges { @@ -32,6 +37,8 @@ interface StyleOptionChanges { interface Props { customIconStops?: IconStop[]; iconPaletteId: string | null; + customIcons: CustomIcon[]; + onCustomIconsChange: (customIcons: CustomIcon[]) => void; onChange: ({ customIconStops, iconPaletteId, useCustomIconMap }: StyleOptionChanges) => void; styleProperty: IDynamicStyleProperty; useCustomIconMap?: boolean; @@ -86,6 +93,8 @@ export class IconMapSelect extends Component { getValueSuggestions={this.props.styleProperty.getValueSuggestions} iconStops={this.state.customIconStops} onChange={this._onCustomMapChange} + onCustomIconsChange={this.props.onCustomIconsChange} + customIcons={this.props.customIcons} /> ); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx new file mode 100644 index 0000000000000..f1a5653da612b --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import { + EuiColorPicker, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { mapboxgl, Map as MapboxMap } from '@kbn/mapbox-gl'; +import { i18n } from '@kbn/i18n'; +import { ResizeChecker } from '.././../../../../../../../../src/plugins/kibana_utils/public'; +import { + CUSTOM_ICON_PIXEL_RATIO, + createSdfIcon, + // @ts-expect-error +} from '../../symbol_utils'; + +export interface Props { + svg: string; + cutoff: number; + radius: number; + isSvgInvalid: boolean; +} + +interface State { + map: MapboxMap | null; + iconColor: string; +} + +export class IconPreview extends Component { + static iconId = `iconPreview`; + private _checker?: ResizeChecker; + private _isMounted = false; + private _containerRef: HTMLDivElement | null = null; + + state: State = { + map: null, + iconColor: '#E7664C', + }; + + componentDidMount() { + this._isMounted = true; + this._initializeMap(); + } + + componentDidUpdate(prevProps: Props) { + if ( + this.props.svg !== prevProps.svg || + this.props.cutoff !== prevProps.cutoff || + this.props.radius !== prevProps.radius + ) { + this._syncImageToMap(); + } + } + + componentWillUnmount() { + this._isMounted = false; + if (this._checker) { + this._checker.destroy(); + } + if (this.state.map) { + this.state.map.remove(); + this.state.map = null; + } + } + + _setIconColor = (iconColor: string) => { + this.setState({ iconColor }, () => { + this._syncPaintPropertiesToMap(); + }); + }; + + _setContainerRef = (element: HTMLDivElement) => { + this._containerRef = element; + }; + + async _syncImageToMap() { + if (this._isMounted && this.state.map) { + const map = this.state.map; + const { svg, cutoff, radius, isSvgInvalid } = this.props; + if (!svg || isSvgInvalid) { + map.setLayoutProperty('icon-layer', 'visibility', 'none'); + return; + } + const imageData = await createSdfIcon({ svg, cutoff, radius }); + if (map.hasImage(IconPreview.iconId)) { + // @ts-expect-error + map.updateImage(IconPreview.iconId, imageData); + } else { + map.addImage(IconPreview.iconId, imageData, { + sdf: true, + pixelRatio: CUSTOM_ICON_PIXEL_RATIO, + }); + } + map.setLayoutProperty('icon-layer', 'icon-image', IconPreview.iconId); + map.setLayoutProperty('icon-layer', 'icon-size', 6); + map.setLayoutProperty('icon-layer', 'visibility', 'visible'); + this._syncPaintPropertiesToMap(); + } + } + + _syncPaintPropertiesToMap() { + const { map, iconColor } = this.state; + if (!map) return; + map.setPaintProperty('icon-layer', 'icon-halo-color', '#000000'); + map.setPaintProperty('icon-layer', 'icon-halo-width', 1); + map.setPaintProperty('icon-layer', 'icon-color', iconColor); + map.setLayoutProperty('icon-layer', 'icon-size', 12); + } + + _initResizerChecker() { + this._checker = new ResizeChecker(this._containerRef!); + this._checker.on('resize', () => { + if (this.state.map) { + this.state.map.resize(); + } + }); + } + + _createMapInstance(): MapboxMap { + const map = new mapboxgl.Map({ + container: this._containerRef!, + interactive: false, + center: [0, 0], + zoom: 2, + style: { + version: 8, + name: 'Empty', + sources: {}, + layers: [ + { + id: 'background', + type: 'background', + paint: { + 'background-color': 'rgba(0,0,0,0)', + }, + }, + ], + }, + }); + + map.on('load', () => { + map.addLayer({ + id: 'icon-layer', + type: 'symbol', + source: { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: {}, + }, + }, + }); + this._syncImageToMap(); + }); + + return map; + } + + _initializeMap() { + const map: MapboxMap = this._createMapInstance(); + + this.setState({ map }, () => { + this._initResizerChecker(); + }); + } + + render() { + const iconColor = this.state.iconColor; + return ( +
+ + + +

+ + <> + {i18n.translate('xpack.maps.customIconModal.elementPreviewTitle', { + defaultMessage: 'Render preview', + })}{' '} + + + +

+
+ + +
+ + + + + + + +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.js index cc03eb3d5ef1e..432a36478127f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.js @@ -5,19 +5,28 @@ * 2.0. */ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { + EuiButton, EuiFormControlLayout, EuiFieldText, EuiPopover, EuiPopoverTitle, + EuiPopoverFooter, EuiFocusTrap, keys, EuiSelectable, } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + DEFAULT_CUSTOM_ICON_CUTOFF, + DEFAULT_CUSTOM_ICON_RADIUS, +} from '../../../../../../common/constants'; import { SymbolIcon } from '../legend/symbol_icon'; import { SYMBOL_OPTIONS } from '../../symbol_utils'; import { getIsDarkMode } from '../../../../../kibana_services'; +import { CustomIconModal } from './custom_icon_modal'; function isKeyboardEvent(event) { return typeof event === 'object' && 'keyCode' in event; @@ -26,16 +35,48 @@ function isKeyboardEvent(event) { export class IconSelect extends Component { state = { isPopoverOpen: false, + isModalVisible: false, + }; + + _handleSave = ({ symbolId, svg, cutoff, radius, label }) => { + const icons = [ + ...this.props.customIcons.filter((i) => { + return i.symbolId !== symbolId; + }), + { + symbolId, + svg, + label, + cutoff, + radius, + }, + ]; + this.props.onCustomIconsChange(icons); + this._hideModal(); }; _closePopover = () => { this.setState({ isPopoverOpen: false }); }; + _hideModal = () => { + this.setState({ isModalVisible: false }); + }; + _openPopover = () => { this.setState({ isPopoverOpen: true }); }; + _showModal = () => { + this.setState({ isModalVisible: true }); + }; + + _toggleModal = () => { + this.setState((prevState) => ({ + isModalVisible: !prevState.isModalVisible, + })); + }; + _togglePopover = () => { this.setState((prevState) => ({ isPopoverOpen: !prevState.isPopoverOpen, @@ -59,12 +100,14 @@ export class IconSelect extends Component { }); if (selectedOption) { - this.props.onChange(selectedOption.value); + const { key } = selectedOption; + this.props.onChange({ selectedIconId: key }); } this._closePopover(); }; _renderPopoverButton() { + const { value, svg, label } = this.props.icon; return ( } @@ -95,26 +139,69 @@ export class IconSelect extends Component { } _renderIconSelectable() { - const options = SYMBOL_OPTIONS.map(({ value, label }) => { + const makiOptions = [ + { + label: i18n.translate('xpack.maps.styles.vector.iconSelect.kibanaIconsGroupLabel', { + defaultMessage: 'Kibana icons', + }), + isGroupLabel: true, + }, + ...SYMBOL_OPTIONS.map(({ value, label, svg }) => { + return { + key: value, + label, + prepend: ( + + ), + }; + }), + ]; + + const customOptions = this.props.customIcons.map(({ symbolId, label, svg }) => { return { - value, + key: symbolId, label, prepend: ( ), }; }); + if (customOptions.length) + customOptions.splice(0, 0, { + label: i18n.translate('xpack.maps.styles.vector.iconSelect.customIconsGroupLabel', { + defaultMessage: 'Custom icons', + }), + isGroupLabel: true, + }); + + const options = [...customOptions, ...makiOptions]; + return ( - + {(list, search) => (
{search} {list} + + {' '} + + + +
)}
@@ -123,17 +210,28 @@ export class IconSelect extends Component { render() { return ( - - {this._renderIconSelectable()} - + + + {this._renderIconSelectable()} + + {this.state.isModalVisible ? ( + + ) : null} + ); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.test.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.test.js index ac5b3b7dd9847..5bb5f7f1c7808 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_select.test.js @@ -16,8 +16,16 @@ jest.mock('../../../../../kibana_services', () => { jest.mock('../../symbol_utils', () => { return { SYMBOL_OPTIONS: [ - { value: 'symbol1', label: 'symbol1' }, - { value: 'symbol2', label: 'symbol2' }, + { + value: 'symbol1', + label: 'symbol1', + svg: '\n\n \n', + }, + { + value: 'symbol2', + label: 'symbol2', + svg: '\n\n \n', + }, ], }; }); @@ -28,7 +36,39 @@ import { shallow } from 'enzyme'; import { IconSelect } from './icon_select'; test('Should render icon select', () => { - const component = shallow( {}} />); + const component = shallow( + {}} /> + ); + + expect(component).toMatchSnapshot(); +}); + +test('Should render icon select with custom icons', () => { + const component = shallow( + ', + cutoff: 0.25, + radius: 0.25, + }, + { + symbolId: '__kbn__custom_icon_sdf__bizzbuzz', + label: 'My Other Custom Icon', + svg: '', + cutoff: 0.3, + radius: 0.15, + }, + ]} + icon={{ + value: '__kbn__custom_icon_sdf__foobar', + svg: '', + label: 'My Custom Icon', + }} + /> + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_stops.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_stops.js index 79491d6ededf3..2700a599c3aee 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_stops.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_stops.js @@ -6,13 +6,13 @@ */ import React from 'react'; -import { DEFAULT_ICON } from '../../../../../../common/constants'; +import { DEFAULT_ICON, ICON_SOURCE } from '../../../../../../common/constants'; import { i18n } from '@kbn/i18n'; import { getOtherCategoryLabel } from '../../style_util'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiFieldText } from '@elastic/eui'; import { IconSelect } from './icon_select'; import { StopInput } from '../stop_input'; -import { PREFERRED_ICONS, SYMBOL_OPTIONS } from '../../symbol_utils'; +import { getMakiSymbol, PREFERRED_ICONS, SYMBOL_OPTIONS } from '../../symbol_utils'; function isDuplicateStop(targetStop, iconStops) { const stops = iconStops.filter(({ stop }) => { @@ -43,113 +43,136 @@ export function getFirstUnusedSymbol(iconStops) { return firstUnusedSymbol ? firstUnusedSymbol.value : DEFAULT_ICON; } -export function IconStops({ field, getValueSuggestions, iconStops, onChange }) { - return iconStops.map(({ stop, icon }, index) => { - const onIconSelect = (selectedIconId) => { - const newIconStops = [...iconStops]; - newIconStops[index] = { - ...iconStops[index], - icon: selectedIconId, +export function IconStops({ + field, + getValueSuggestions, + iconStops, + onChange, + onCustomIconsChange, + customIcons, +}) { + return iconStops + .map(({ stop, icon, iconSource }, index) => { + const iconInfo = + iconSource === ICON_SOURCE.CUSTOM + ? customIcons.find(({ symbolId }) => symbolId === icon) + : getMakiSymbol(icon); + if (iconInfo === undefined) return; + const { svg, label } = iconInfo; + const onIconSelect = ({ selectedIconId }) => { + const newIconStops = [...iconStops]; + newIconStops[index] = { + ...iconStops[index], + icon: selectedIconId, + }; + onChange({ customStops: newIconStops }); }; - onChange({ customStops: newIconStops }); - }; - const onStopChange = (newStopValue) => { - const newIconStops = [...iconStops]; - newIconStops[index] = { - ...iconStops[index], - stop: newStopValue, + const onStopChange = (newStopValue) => { + const newIconStops = [...iconStops]; + newIconStops[index] = { + ...iconStops[index], + stop: newStopValue, + }; + onChange({ + customStops: newIconStops, + isInvalid: isDuplicateStop(newStopValue, iconStops), + }); }; - onChange({ - customStops: newIconStops, - isInvalid: isDuplicateStop(newStopValue, iconStops), - }); - }; - const onAdd = () => { - onChange({ - customStops: [ - ...iconStops.slice(0, index + 1), - { - stop: '', - icon: getFirstUnusedSymbol(iconStops), - }, - ...iconStops.slice(index + 1), - ], - }); - }; - const onRemove = () => { - onChange({ - customStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], - }); - }; + const onAdd = () => { + onChange({ + customStops: [ + ...iconStops.slice(0, index + 1), + { + stop: '', + icon: getFirstUnusedSymbol(iconStops), + }, + ...iconStops.slice(index + 1), + ], + }); + }; + const onRemove = () => { + onChange({ + customStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], + }); + }; + + let deleteButton; + if (iconStops.length > 2 && index !== 0) { + deleteButton = ( + + ); + } - let deleteButton; - if (iconStops.length > 2 && index !== 0) { - deleteButton = ( - + const iconStopButtons = ( +
+ {deleteButton} + +
); - } - const iconStopButtons = ( -
- {deleteButton} - -
- ); + const errors = []; + // TODO check for duplicate values and add error messages here - const errors = []; - // TODO check for duplicate values and add error messages here + const stopInput = + index === 0 ? ( + + ) : ( + + ); - const stopInput = - index === 0 ? ( - - ) : ( - + return ( + + + + {stopInput} + + + + + + ); - - return ( - - - - {stopInput} - - - - - - - ); - }); + }) + .filter((stop) => { + return stop !== undefined; + }); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js index 262fb5c7e0991..6ec372496e8be 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js @@ -9,9 +9,17 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IconSelect } from './icon_select'; -export function StaticIconForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { - const onChange = (selectedIconId) => { - onStaticStyleChange(styleProperty.getStyleName(), { value: selectedIconId }); +export function StaticIconForm({ + onStaticStyleChange, + onCustomIconsChange, + customIcons, + staticDynamicSelect, + styleProperty, +}) { + const onChange = ({ selectedIconId }) => { + onStaticStyleChange(styleProperty.getStyleName(), { + value: selectedIconId, + }); }; return ( @@ -20,7 +28,12 @@ export function StaticIconForm({ onStaticStyleChange, staticDynamicSelect, style {staticDynamicSelect}
- + ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.test.tsx index b89f4ee0b2aa0..8edb67703a4d1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.test.tsx @@ -11,6 +11,7 @@ import { StyleProperties, VectorStyleEditor } from './vector_style_editor'; import { getDefaultStaticProperties } from '../vector_style_defaults'; import { IVectorLayer } from '../../../layers/vector_layer'; import { IVectorSource } from '../../../sources/vector_source'; +import { CustomIcon } from '../../../../../common/descriptor_types'; import { FIELD_ORIGIN, LAYER_STYLE_TYPE, @@ -61,7 +62,8 @@ const vectorStyleDescriptor = { const vectorStyle = new VectorStyle( vectorStyleDescriptor, {} as unknown as IVectorSource, - {} as unknown as IVectorLayer + {} as unknown as IVectorLayer, + [] as CustomIcon[] ); const styleProperties: StyleProperties = {}; vectorStyle.getAllStyleProperties().forEach((styleProperty) => { @@ -73,11 +75,13 @@ const defaultProps = { isPointsOnly: true, isLinesOnly: false, onIsTimeAwareChange: (isTimeAware: boolean) => {}, + onCustomIconsChange: (customIcons: CustomIcon[]) => {}, handlePropertyChange: (propertyName: VECTOR_STYLES, stylePropertyDescriptor: unknown) => {}, hasBorder: true, styleProperties, isTimeAware: true, showIsTimeAware: true, + customIcons: [], }; test('should render', async () => { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx index a7488ab13da6c..4431cead9b0d1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx @@ -33,6 +33,7 @@ import { createStyleFieldsHelper, StyleField, StyleFieldsHelper } from '../style import { ColorDynamicOptions, ColorStaticOptions, + CustomIcon, DynamicStylePropertyOptions, IconDynamicOptions, IconStaticOptions, @@ -62,11 +63,13 @@ interface Props { isPointsOnly: boolean; isLinesOnly: boolean; onIsTimeAwareChange: (isTimeAware: boolean) => void; + onCustomIconsChange: (customIcons: CustomIcon[]) => void; handlePropertyChange: (propertyName: VECTOR_STYLES, stylePropertyDescriptor: unknown) => void; hasBorder: boolean; styleProperties: StyleProperties; isTimeAware: boolean; showIsTimeAware: boolean; + customIcons: CustomIcon[]; } interface State { @@ -392,8 +395,10 @@ export class VectorStyleEditor extends Component { = { aerialway: { label: 'Aerialway', svg: '\n\n \n', diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap index a3f23536326aa..a49dd9a494c7f 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_icon_property.test.tsx.snap @@ -47,6 +47,10 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`] isPointsOnly={true} label="US_format" styleName="icon" + svg=" + + +" symbolId="circle" /> @@ -59,6 +63,10 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`] isPointsOnly={true} label="CN_format" styleName="icon" + svg=" + + +" symbolId="marker" /> @@ -77,9 +85,93 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`] } styleName="icon" + svg=" + + +" symbolId="square" />
`; + +exports[`renderLegendDetailRow Should render categorical legend with custom icons in breaks 1`] = ` +
+ + + + + + + foobar_label + + + + + + + + + + + +" + symbolId="marker" + /> + + + + Other + + } + styleName="icon" + svg=" + + +" + symbolId="kbn__custom_icon_sdf__foobar" + /> + + +
+`; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index c9e7cfb6d7e39..0802c78c26933 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -324,7 +324,7 @@ export class DynamicColorProperty extends DynamicStyleProperty | null = null; let getValuePrefix: ((i: number, isNext: boolean) => string) | null = null; if (this._options.useCustomColorRamp) { @@ -361,6 +361,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { if (stop !== null && color != null) { breaks.push({ color, + svg, symbolId, label: this.formatField(stop), }); @@ -427,17 +430,18 @@ export class DynamicColorProperty extends DynamicStyleProperty{getOtherCategoryLabel()}, symbolId, + svg, }); } return breaks; } - renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }: LegendProps) { + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId, svg }: LegendProps) { let breaks: Break[] = []; if (this.isOrdinal()) { - breaks = this._getOrdinalBreaks(symbolId); + breaks = this._getOrdinalBreaks(symbolId, svg); } else if (this.isCategorical()) { - breaks = this._getCategoricalBreaks(symbolId); + breaks = this._getCategoricalBreaks(symbolId, svg); } return ( ({ })); import React from 'react'; -import { RawValue, VECTOR_STYLES } from '../../../../../common/constants'; +import { ICON_SOURCE, RawValue, VECTOR_STYLES } from '../../../../../common/constants'; // @ts-ignore import { DynamicIconProperty } from './dynamic_icon_property'; import { mockField, MockLayer } from './test_helpers/test_util'; @@ -57,7 +57,30 @@ describe('renderLegendDetailRow', () => { const iconStyle = makeProperty({ iconPaletteId: 'filledShapes', }); + const legendRow = iconStyle.renderLegendDetailRow({ isPointsOnly: true, isLinesOnly: false }); + const component = shallow(legendRow); + await new Promise((resolve) => process.nextTick(resolve)); + component.update(); + expect(component).toMatchSnapshot(); + }); + + test('Should render categorical legend with custom icons in breaks', async () => { + const iconStyle = makeProperty({ + useCustomIconMap: true, + customIconStops: [ + { + stop: null, + icon: 'kbn__custom_icon_sdf__foobar', + iconSource: ICON_SOURCE.CUSTOM, + }, + { + stop: 'MX', + icon: 'marker', + iconSource: ICON_SOURCE.MAKI, + }, + ], + }); const legendRow = iconStyle.renderLegendDetailRow({ isPointsOnly: true, isLinesOnly: false }); const component = shallow(legendRow); await new Promise((resolve) => process.nextTick(resolve)); @@ -88,8 +111,16 @@ describe('get mapbox icon-image expression (via internal _getMbIconImageExpressi const iconStyle = makeProperty({ useCustomIconMap: true, customIconStops: [ - { stop: null, icon: 'circle' }, - { stop: 'MX', icon: 'marker' }, + { + stop: null, + icon: 'circle', + iconSource: ICON_SOURCE.MAKI, + }, + { + stop: 'MX', + icon: 'marker', + iconSource: ICON_SOURCE.MAKI, + }, ], }); expect(iconStyle._getMbIconImageExpression()).toEqual([ diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx index c95d5eec069a8..db295f200a148 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { EuiTextColor } from '@elastic/eui'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from './dynamic_style_property'; +import { IVectorStyle } from '../vector_style'; import { getIconPalette, getMakiSymbolAnchor, @@ -48,10 +49,11 @@ export class DynamicIconProperty extends DynamicStyleProperty { if (stop) { + const svg = layerStyle.getIconSvg(style); breaks.push({ color: 'grey', label: this.formatField(stop), symbolId: style, + svg, }); } }); if (fallbackSymbolId) { + const svg = layerStyle.getIconSvg(fallbackSymbolId); breaks.push({ color: 'grey', label: {getOtherCategoryLabel()}, symbolId: fallbackSymbolId, + svg, }); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx index 5ea99e64e8626..89f138ff7744b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.tsx @@ -11,10 +11,11 @@ import { DynamicStyleProperty } from './dynamic_style_property'; import { OrdinalLegend } from '../components/legend/ordinal_legend'; import { makeMbClampedNumberExpression } from '../style_util'; import { + FieldFormatter, HALF_MAKI_ICON_SIZE, - // @ts-expect-error -} from '../symbol_utils'; -import { FieldFormatter, MB_LOOKUP_FUNCTION, VECTOR_STYLES } from '../../../../../common/constants'; + MB_LOOKUP_FUNCTION, + VECTOR_STYLES, +} from '../../../../../common/constants'; import { SizeDynamicOptions } from '../../../../../common/descriptor_types'; import { IField } from '../../../fields/field'; import { IVectorLayer } from '../../../layers/vector_layer'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts index 1ebe35d65ac95..0a2464d8bed8b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts @@ -8,7 +8,7 @@ import type { Map as MbMap } from '@kbn/mapbox-gl'; import { StaticStyleProperty } from './static_style_property'; // @ts-expect-error -import { getMakiSymbolAnchor, getMakiIconId } from '../symbol_utils'; +import { getMakiSymbolAnchor } from '../symbol_utils'; import { IconStaticOptions } from '../../../../../common/descriptor_types'; export class StaticIconProperty extends StaticStyleProperty { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts index 771e0f8f33a0c..74a4ffebea96d 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_size_property.ts @@ -7,11 +7,7 @@ import type { Map as MbMap } from '@kbn/mapbox-gl'; import { StaticStyleProperty } from './static_style_property'; -import { VECTOR_STYLES } from '../../../../../common/constants'; -import { - HALF_MAKI_ICON_SIZE, - // @ts-expect-error -} from '../symbol_utils'; +import { HALF_MAKI_ICON_SIZE, VECTOR_STYLES } from '../../../../../common/constants'; import { SizeStaticOptions } from '../../../../../common/descriptor_types'; export class StaticSizeProperty extends StaticStyleProperty { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts index 41877406f7489..ee3da4e3636b3 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/style_property.ts @@ -16,6 +16,7 @@ export type LegendProps = { isPointsOnly: boolean; isLinesOnly: boolean; symbolId?: string; + svg?: string; }; export interface IStyleProperty { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts index 13455b3e4f840..3d1cad1561a0e 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/test_helpers/test_util.ts @@ -66,6 +66,10 @@ export class MockStyle implements IStyle { return null; } + getIconSvg(symbolId: string) { + return `\n\n \n`; + } + getType() { return LAYER_STYLE_TYPE.VECTOR; } @@ -109,6 +113,10 @@ export class MockLayer { return this._style; } + getCurrentStyle() { + return this._style; + } + getDataRequest() { return null; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts index 46b2e047d0d63..fc57f1b92f5af 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.test.ts @@ -73,53 +73,81 @@ describe('isOnlySingleFeatureType', () => { }); describe('assignCategoriesToPalette', () => { - test('Categories and palette values have same length', () => { + test('Categories and icons have same length', () => { const categories = [ { key: 'alpah', count: 1 }, { key: 'bravo', count: 1 }, { key: 'charlie', count: 1 }, { key: 'delta', count: 1 }, ]; - const paletteValues = ['red', 'orange', 'yellow', 'green']; + const paletteValues = ['circle', 'marker', 'triangle', 'square']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ - { stop: 'alpah', style: 'red' }, - { stop: 'bravo', style: 'orange' }, - { stop: 'charlie', style: 'yellow' }, + { + stop: 'alpah', + style: 'circle', + iconSource: 'MAKI', + }, + { + stop: 'bravo', + style: 'marker', + iconSource: 'MAKI', + }, + { + stop: 'charlie', + style: 'triangle', + iconSource: 'MAKI', + }, ], - fallbackSymbolId: 'green', + fallbackSymbolId: 'square', }); }); - test('Should More categories than palette values', () => { + test('Should More categories than icon values', () => { const categories = [ { key: 'alpah', count: 1 }, { key: 'bravo', count: 1 }, { key: 'charlie', count: 1 }, { key: 'delta', count: 1 }, ]; - const paletteValues = ['red', 'orange', 'yellow']; + const paletteValues = ['circle', 'square', 'triangle']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ - { stop: 'alpah', style: 'red' }, - { stop: 'bravo', style: 'orange' }, + { + stop: 'alpah', + style: 'circle', + iconSource: 'MAKI', + }, + { + stop: 'bravo', + style: 'square', + iconSource: 'MAKI', + }, ], - fallbackSymbolId: 'yellow', + fallbackSymbolId: 'triangle', }); }); - test('Less categories than palette values', () => { + test('Less categories than icon values', () => { const categories = [ { key: 'alpah', count: 1 }, { key: 'bravo', count: 1 }, ]; - const paletteValues = ['red', 'orange', 'yellow', 'green', 'blue']; + const paletteValues = ['circle', 'triangle', 'marker', 'square', 'rectangle']; expect(assignCategoriesToPalette({ categories, paletteValues })).toEqual({ stops: [ - { stop: 'alpah', style: 'red' }, - { stop: 'bravo', style: 'orange' }, + { + stop: 'alpah', + style: 'circle', + iconSource: 'MAKI', + }, + { + stop: 'bravo', + style: 'triangle', + iconSource: 'MAKI', + }, ], - fallbackSymbolId: 'yellow', + fallbackSymbolId: 'marker', }); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index 467fd6b3621a2..11f564f436dd5 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -6,7 +6,12 @@ */ import { i18n } from '@kbn/i18n'; -import { MB_LOOKUP_FUNCTION, VECTOR_SHAPE_TYPE, VECTOR_STYLES } from '../../../../common/constants'; +import { + ICON_SOURCE, + MB_LOOKUP_FUNCTION, + VECTOR_SHAPE_TYPE, + VECTOR_STYLES, +} from '../../../../common/constants'; import { Category } from '../../../../common/descriptor_types'; import { StaticTextProperty } from './properties/static_text_property'; import { DynamicTextProperty } from './properties/dynamic_text_property'; @@ -74,6 +79,7 @@ export function assignCategoriesToPalette({ stops.push({ stop: categories[i].key, style: paletteValues[i], + iconSource: ICON_SOURCE.MAKI, }); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js index 07ac77dc0cb78..af165863ffc9c 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js @@ -7,39 +7,48 @@ import React from 'react'; import xml2js from 'xml2js'; +import uuid from 'uuid/v4'; import { Canvg } from 'canvg'; import calcSDF from 'bitmap-sdf'; +import { + CUSTOM_ICON_SIZE, + CUSTOM_ICON_PREFIX_SDF, + MAKI_ICON_SIZE, +} from '../../../../common/constants'; import { parseXmlString } from '../../../../common/parse_xml_string'; import { SymbolIcon } from './components/legend/symbol_icon'; import { getIsDarkMode } from '../../../kibana_services'; import { MAKI_ICONS } from './maki_icons'; -const MAKI_ICON_SIZE = 16; -export const HALF_MAKI_ICON_SIZE = MAKI_ICON_SIZE / 2; +export const CUSTOM_ICON_PIXEL_RATIO = Math.floor( + window.devicePixelRatio * (CUSTOM_ICON_SIZE / MAKI_ICON_SIZE) * 0.75 +); -export const SYMBOL_OPTIONS = Object.keys(MAKI_ICONS).map((symbolId) => { +export const SYMBOL_OPTIONS = Object.entries(MAKI_ICONS).map(([value, { svg, label }]) => { return { - value: symbolId, - label: symbolId, + value, + label, + svg, }; }); /** - * Converts a SVG icon to a monochrome image using a signed distance function. + * Converts a SVG icon to a PNG image using a signed distance function (SDF). * * @param {string} svgString - SVG icon as string - * @param {number} [cutoff=0.25] - balance between SDF inside 1 and outside 0 of glyph + * @param {number} [renderSize=64] - size of the output PNG (higher provides better resolution but requires more processing) + * @param {number} [cutoff=0.25] - balance between SDF inside 1 and outside 0 of icon * @param {number} [radius=0.25] - size of SDF around the cutoff as percent of output icon size - * @return {ImageData} Monochrome image that can be added to a MapLibre map + * @return {ImageData} image that can be added to a MapLibre map with option `{ sdf: true }` */ -export async function createSdfIcon(svgString, cutoff = 0.25, radius = 0.25) { +export async function createSdfIcon({ svg, renderSize = 64, cutoff = 0.25, radius = 0.25 }) { const buffer = 3; - const size = MAKI_ICON_SIZE + buffer * 4; + const size = renderSize + buffer * 4; const svgCanvas = document.createElement('canvas'); svgCanvas.width = size; svgCanvas.height = size; const svgCtx = svgCanvas.getContext('2d'); - const v = Canvg.fromString(svgCtx, svgString, { + const v = Canvg.fromString(svgCtx, svg, { ignoreDimensions: true, offsetX: buffer / 2, offsetY: buffer / 2, @@ -70,12 +79,8 @@ export async function createSdfIcon(svgString, cutoff = 0.25, radius = 0.25) { return imageData; } -export function getMakiSymbolSvg(symbolId) { - const svg = MAKI_ICONS?.[symbolId]?.svg; - if (!svg) { - throw new Error(`Unable to find symbol: ${symbolId}`); - } - return svg; +export function getMakiSymbol(symbolId) { + return MAKI_ICONS?.[symbolId]; } export function getMakiSymbolAnchor(symbolId) { @@ -89,6 +94,10 @@ export function getMakiSymbolAnchor(symbolId) { } } +export function getCustomIconId() { + return `${CUSTOM_ICON_PREFIX_SDF}${uuid()}`; +} + export function buildSrcUrl(svgString) { const domUrl = window.URL || window.webkitURL || window; const svg = new Blob([svgString], { type: 'image/svg+xml' }); @@ -130,9 +139,9 @@ const ICON_PALETTES = [ // PREFERRED_ICONS is used to provide less random default icon values for forms that need default icon values export const PREFERRED_ICONS = []; ICON_PALETTES.forEach((iconPalette) => { - iconPalette.icons.forEach((iconId) => { - if (!PREFERRED_ICONS.includes(iconId)) { - PREFERRED_ICONS.push(iconId); + iconPalette.icons.forEach((icon) => { + if (!PREFERRED_ICONS.includes(icon)) { + PREFERRED_ICONS.push(icon); } }); }); @@ -154,6 +163,7 @@ export function getIconPaletteOptions() { className="mapIcon" symbolId={iconId} fill={isDarkMode ? 'rgb(223, 229, 239)' : 'rgb(52, 55, 65)'} + svg={getMakiSymbol(iconId).svg} /> ); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js index d5fc3d30a447c..8c85702b19579 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js @@ -5,12 +5,13 @@ * 2.0. */ -import { getMakiSymbolSvg, styleSvg } from './symbol_utils'; +import { getMakiSymbol, styleSvg } from './symbol_utils'; -describe('getMakiSymbolSvg', () => { - it('Should load symbol svg', () => { - const svgString = getMakiSymbolSvg('aerialway'); - expect(svgString.length).toBe(624); +describe('getMakiSymbol', () => { + it('Should load symbol', () => { + const symbol = getMakiSymbol('aerialway'); + expect(symbol.svg.length).toBe(624); + expect(symbol.label).toBe('Aerialway'); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js index 33125019ecc0b..c1aa8e395d8c0 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.test.js @@ -35,6 +35,8 @@ class MockSource { describe('getDescriptorWithUpdatedStyleProps', () => { const previousFieldName = 'doIStillExist'; const mapColors = []; + const layer = {}; + const customIcons = []; const properties = { fillColor: { type: STYLE_TYPE.STATIC, @@ -69,7 +71,7 @@ describe('getDescriptorWithUpdatedStyleProps', () => { describe('When there is no mismatch in configuration', () => { it('Should return no changes when next ordinal fields contain existing style property fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + const vectorStyle = new VectorStyle({ properties }, new MockSource(), layer, customIcons); const nextFields = [new MockField({ fieldName: previousFieldName, dataType: 'number' })]; const { hasChanges } = await vectorStyle.getDescriptorWithUpdatedStyleProps( @@ -83,7 +85,7 @@ describe('getDescriptorWithUpdatedStyleProps', () => { describe('When styles should revert to static styling', () => { it('Should convert dynamic styles to static styles when there are no next fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + const vectorStyle = new VectorStyle({ properties }, new MockSource(), layer, customIcons); const nextFields = []; const { hasChanges, nextStyleDescriptor } = @@ -104,7 +106,7 @@ describe('getDescriptorWithUpdatedStyleProps', () => { }); it('Should convert dynamic ICON_SIZE static style when there are no next ordinal fields', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + const vectorStyle = new VectorStyle({ properties }, new MockSource(), layer, customIcons); const nextFields = [ { @@ -143,7 +145,7 @@ describe('getDescriptorWithUpdatedStyleProps', () => { describe('When styles should not be cleared', () => { it('Should update field in styles when the fields and style combination remains compatible', async () => { - const vectorStyle = new VectorStyle({ properties }, new MockSource()); + const vectorStyle = new VectorStyle({ properties }, new MockSource(), layer, customIcons); const nextFields = [new MockField({ fieldName: 'someOtherField', dataType: 'number' })]; const { hasChanges, nextStyleDescriptor } = @@ -174,6 +176,8 @@ describe('getDescriptorWithUpdatedStyleProps', () => { }); describe('pluckStyleMetaFromSourceDataRequest', () => { + const layer = {}; + const customIcons = []; describe('has features', () => { it('Should identify when feature collection only contains points', async () => { const sourceDataRequest = new DataRequest({ @@ -195,7 +199,7 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { ], }, }); - const vectorStyle = new VectorStyle({}, new MockSource()); + const vectorStyle = new VectorStyle({}, new MockSource(), layer, customIcons); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); @@ -231,7 +235,7 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { ], }, }); - const vectorStyle = new VectorStyle({}, new MockSource()); + const vectorStyle = new VectorStyle({}, new MockSource(), layer, customIcons); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); expect(featuresMeta.geometryTypes.isPointsOnly).toBe(false); @@ -280,7 +284,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { }, }, }, - new MockSource() + new MockSource(), + layer, + customIcons ); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); @@ -304,7 +310,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { }, }, }, - new MockSource() + new MockSource(), + layer, + customIcons ); const styleMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 0e87651e234bc..52209563e9807 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -20,6 +20,7 @@ import { DEFAULT_ICON, FIELD_ORIGIN, GEO_JSON_TYPE, + ICON_SOURCE, KBN_IS_CENTROID_FEATURE, LAYER_STYLE_TYPE, SOURCE_FORMATTERS_DATA_REQUEST_ID, @@ -28,6 +29,8 @@ import { VECTOR_STYLES, } from '../../../../common/constants'; import { StyleMeta } from './style_meta'; +// @ts-expect-error +import { getMakiSymbol, PREFERRED_ICONS } from './symbol_utils'; import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; import { isOnlySingleFeatureType, getHasLabel } from './style_util'; @@ -50,6 +53,7 @@ import { ColorDynamicOptions, ColorStaticOptions, ColorStylePropertyDescriptor, + CustomIcon, DynamicStyleProperties, DynamicStylePropertyOptions, IconDynamicOptions, @@ -99,6 +103,8 @@ export interface IVectorStyle extends IStyle { isTimeAware(): boolean; getPrimaryColor(): string; getIcon(showIncompleteIndicator: boolean): ReactElement; + getIconSvg(symbolId: string): string | undefined; + isUsingCustomIcon(symbolId: string): boolean; hasLegendDetails: () => Promise; renderLegendDetails: () => ReactElement; clearFeatureState: (featureCollection: FeatureCollection, mbMap: MbMap, sourceId: string) => void; @@ -151,6 +157,7 @@ export interface IVectorStyle extends IStyle { export class VectorStyle implements IVectorStyle { private readonly _descriptor: VectorStyleDescriptor; private readonly _layer: IVectorLayer; + private readonly _customIcons: CustomIcon[]; private readonly _source: IVectorSource; private readonly _styleMeta: StyleMeta; @@ -186,10 +193,12 @@ export class VectorStyle implements IVectorStyle { descriptor: VectorStyleDescriptor | null, source: IVectorSource, layer: IVectorLayer, + customIcons: CustomIcon[], chartsPaletteServiceGetColor?: (value: string) => string | null ) { this._source = source; this._layer = layer; + this._customIcons = customIcons; this._descriptor = descriptor ? { ...descriptor, @@ -458,7 +467,10 @@ export class VectorStyle implements IVectorStyle { : (this._lineWidthStyleProperty as StaticSizeProperty).getOptions().size !== 0; } - renderEditor(onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void) { + renderEditor( + onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void, + onCustomIconsChange: (customIcons: CustomIcon[]) => void + ) { const rawProperties = this.getRawProperties(); const handlePropertyChange = (propertyName: VECTOR_STYLES, stylePropertyDescriptor: any) => { rawProperties[propertyName] = stylePropertyDescriptor; // override single property, but preserve the rest @@ -488,8 +500,10 @@ export class VectorStyle implements IVectorStyle { isPointsOnly={this.getIsPointsOnly()} isLinesOnly={this._getIsLinesOnly()} onIsTimeAwareChange={onIsTimeAwareChange} + onCustomIconsChange={onCustomIconsChange} isTimeAware={this.isTimeAware()} showIsTimeAware={propertiesWithFieldMeta.length > 0} + customIcons={this._customIcons} hasBorder={this._hasBorder()} /> ); @@ -697,12 +711,28 @@ export class VectorStyle implements IVectorStyle { return formatters ? formatters[fieldName] : null; }; + getIconSvg(symbolId: string) { + const meta = this._getIconMeta(symbolId); + return meta ? meta.svg : undefined; + } + _getSymbolId() { return this.arePointsSymbolizedAsCircles() || this._iconStyleProperty.isDynamic() ? undefined : (this._iconStyleProperty as StaticIconProperty).getOptions().value; } + _getIconMeta( + symbolId: string + ): { svg: string; label: string; iconSource: ICON_SOURCE } | undefined { + const icon = this._customIcons.find(({ symbolId: value }) => value === symbolId); + if (icon) { + return { ...icon, iconSource: ICON_SOURCE.CUSTOM }; + } + const symbol = getMakiSymbol(symbolId); + return symbol ? { ...symbol, iconSource: ICON_SOURCE.MAKI } : undefined; + } + getPrimaryColor() { const primaryColorKey = this._getIsLinesOnly() ? VECTOR_STYLES.LINE_COLOR @@ -741,18 +771,31 @@ export class VectorStyle implements IVectorStyle { } : {}; + const symbolId = this._getSymbolId(); + const svg = symbolId ? this.getIconSvg(symbolId) : undefined; + return ( ); } + isUsingCustomIcon(symbolId: string) { + if (this._iconStyleProperty.isDynamic()) { + const { customIconStops } = this._iconStyleProperty.getOptions() as IconDynamicOptions; + return customIconStops ? customIconStops.some(({ icon }) => icon === symbolId) : false; + } + const { value } = this._iconStyleProperty.getOptions() as IconStaticOptions; + return value === symbolId; + } + _getLegendDetailStyleProperties = () => { const hasLabel = getHasLabel(this._labelStyleProperty); return this.getDynamicPropertiesArray().filter((styleProperty) => { @@ -783,12 +826,16 @@ export class VectorStyle implements IVectorStyle { } renderLegendDetails() { + const symbolId = this._getSymbolId(); + const svg = symbolId ? this.getIconSvg(symbolId) : undefined; + return ( ); } @@ -1040,9 +1087,28 @@ export class VectorStyle implements IVectorStyle { if (!descriptor || !descriptor.options) { return new StaticIconProperty({ value: DEFAULT_ICON }, VECTOR_STYLES.ICON); } else if (descriptor.type === StaticStyleProperty.type) { - return new StaticIconProperty(descriptor.options as IconStaticOptions, VECTOR_STYLES.ICON); + const { value } = { ...descriptor.options } as IconStaticOptions; + const meta = this._getIconMeta(value); + let svg; + let label; + let iconSource; + if (meta) { + ({ svg, label, iconSource } = meta); + } + return new StaticIconProperty( + { value, svg, label, iconSource } as IconStaticOptions, + VECTOR_STYLES.ICON + ); } else if (descriptor.type === DynamicStyleProperty.type) { - const options = descriptor.options as IconDynamicOptions; + const options = { ...descriptor.options } as IconDynamicOptions; + if (options.customIconStops) { + options.customIconStops.forEach((iconStop) => { + const meta = this._getIconMeta(iconStop.icon); + if (meta) { + iconStop.iconSource = meta.iconSource; + } + }); + } const field = this._makeField(options.field); return new DynamicIconProperty( options, diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/index.ts b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/index.ts index d52689cda141a..f2125f1a30993 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/index.ts +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/index.ts @@ -10,9 +10,9 @@ import { ThunkDispatch } from 'redux-thunk'; import { connect } from 'react-redux'; import { StyleSettings } from './style_settings'; import { getSelectedLayer } from '../../../selectors/map_selectors'; -import { updateLayerStyleForSelectedLayer } from '../../../actions'; +import { updateCustomIcons, updateLayerStyleForSelectedLayer } from '../../../actions'; import { MapStoreState } from '../../../reducers/store'; -import { StyleDescriptor } from '../../../../common/descriptor_types'; +import { CustomIcon, StyleDescriptor } from '../../../../common/descriptor_types'; function mapStateToProps(state: MapStoreState) { return { @@ -25,6 +25,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch { dispatch(updateLayerStyleForSelectedLayer(styleDescriptor)); }, + updateCustomIcons: (customIcons: CustomIcon[]) => { + dispatch(updateCustomIcons(customIcons)); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/style_settings.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/style_settings.tsx index d4f461e7cb3ec..8d399f19a765c 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/style_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/style_settings/style_settings.tsx @@ -10,16 +10,17 @@ import React, { Fragment } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { StyleDescriptor } from '../../../../common/descriptor_types'; +import { CustomIcon, StyleDescriptor } from '../../../../common/descriptor_types'; import { ILayer } from '../../../classes/layers/layer'; export interface Props { layer: ILayer; updateStyleDescriptor: (styleDescriptor: StyleDescriptor) => void; + updateCustomIcons: (customIcons: CustomIcon[]) => void; } -export function StyleSettings({ layer, updateStyleDescriptor }: Props) { - const settingsEditor = layer.renderStyleEditor(updateStyleDescriptor); +export function StyleSettings({ layer, updateStyleDescriptor, updateCustomIcons }: Props) { + const settingsEditor = layer.renderStyleEditor(updateStyleDescriptor, updateCustomIcons); if (!settingsEditor) { return null; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/custom_icons_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/custom_icons_panel.test.tsx.snap new file mode 100644 index 0000000000000..033adb3262115 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/custom_icons_panel.test.tsx.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` + + + +
+ +
+
+ + +

+ + + +

+
+ + + + + +
+
+`; + +exports[`should render with custom icons 1`] = ` + + + +
+ +
+
+ + " + symbolId="My Custom Icon" + />, + "key": "__kbn__custom_icon_sdf__foobar", + "label": "My Custom Icon", + }, + Object { + "extraAction": Object { + "alwaysShow": true, + "iconType": "gear", + "onClick": [Function], + }, + "icon": " + symbolId="My Other Custom Icon" + />, + "key": "__kbn__custom_icon_sdf__bizzbuzz", + "label": "My Other Custom Icon", + }, + ] + } + /> + + + + + +
+
+`; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.test.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.test.tsx new file mode 100644 index 0000000000000..2665a1e1d1858 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('../../kibana_services', () => { + return { + getIsDarkMode() { + return false; + }, + }; +}); + +import React from 'react'; +import { shallow } from 'enzyme'; +import { CustomIconsPanel } from './custom_icons_panel'; + +const defaultProps = { + customIcons: [], + updateCustomIcons: () => {}, + deleteCustomIcon: () => {}, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render with custom icons', async () => { + const customIcons = [ + { + symbolId: '__kbn__custom_icon_sdf__foobar', + label: 'My Custom Icon', + svg: '', + cutoff: 0.25, + radius: 0.25, + }, + { + symbolId: '__kbn__custom_icon_sdf__bizzbuzz', + label: 'My Other Custom Icon', + svg: '', + cutoff: 0.3, + radius: 0.15, + }, + ]; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.tsx new file mode 100644 index 0000000000000..acc205a084b5d --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/custom_icons_panel.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiButtonEmpty, + EuiListGroup, + EuiPanel, + EuiSpacer, + EuiText, + EuiTextAlign, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DEFAULT_CUSTOM_ICON_CUTOFF, DEFAULT_CUSTOM_ICON_RADIUS } from '../../../common/constants'; +import { getIsDarkMode } from '../../kibana_services'; +// @ts-expect-error +import { getCustomIconId } from '../../classes/styles/vector/symbol_utils'; +import { SymbolIcon } from '../../classes/styles/vector/components/legend/symbol_icon'; +import { CustomIconModal } from '../../classes/styles/vector/components/symbol/custom_icon_modal'; +import { CustomIcon } from '../../../common/descriptor_types'; + +interface Props { + customIcons: CustomIcon[]; + updateCustomIcons: (customIcons: CustomIcon[]) => void; + deleteCustomIcon: (symbolId: string) => void; +} + +interface State { + isModalVisible: boolean; + selectedIcon?: CustomIcon; +} + +export class CustomIconsPanel extends Component { + public state = { + isModalVisible: false, + selectedIcon: undefined, + }; + + private _handleIconEdit = (icon: CustomIcon) => { + this.setState({ selectedIcon: icon, isModalVisible: true }); + }; + + private _handleNewIcon = () => { + this.setState({ isModalVisible: true }); + }; + + private _renderModal = () => { + if (!this.state.isModalVisible) { + return null; + } + if (this.state.selectedIcon) { + const { symbolId, label, svg, cutoff, radius } = this.state.selectedIcon; + return ( + + ); + } + return ( + + ); + }; + + private _hideModal = () => { + this.setState({ isModalVisible: false, selectedIcon: undefined }); + }; + + private _handleSave = (icon: CustomIcon) => { + const { symbolId, label, svg, cutoff, radius } = icon; + + const icons = [ + ...this.props.customIcons.filter((i) => { + return i.symbolId !== symbolId; + }), + { + symbolId, + svg, + label, + cutoff, + radius, + }, + ]; + this.props.updateCustomIcons(icons); + this._hideModal(); + }; + + private _handleDelete = (symbolId: string) => { + this.props.deleteCustomIcon(symbolId); + this._hideModal(); + }; + + private _renderCustomIconsList = () => { + const addIconButton = ( + + + this._handleNewIcon()} + data-test-subj="mapsCustomIconPanel-add" + > + + + + + ); + if (!this.props.customIcons.length) { + return ( + + +

+ + + +

+
+ {addIconButton} +
+ ); + } + + const customIconsList = this.props.customIcons.map((icon) => { + const { symbolId, label, svg } = icon; + return { + label, + key: symbolId, + icon: ( + + ), + extraAction: { + iconType: 'gear', + alwaysShow: true, + onClick: () => { + this._handleIconEdit(icon); + }, + }, + }; + }); + + return ( + + + {addIconButton} + + ); + }; + + public render() { + return ( + + + +
+ +
+
+ + {this._renderCustomIconsList()} +
+ {this._renderModal()} +
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts index c858c74c819d5..e10e59e83dea6 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts @@ -11,8 +11,16 @@ import { ThunkDispatch } from 'redux-thunk'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapStoreState } from '../../reducers/store'; import { MapSettingsPanel } from './map_settings_panel'; -import { rollbackMapSettings, updateMapSetting, updateFlyout } from '../../actions'; +import { CustomIcon } from '../../../common/descriptor_types'; import { + deleteCustomIcon, + rollbackMapSettings, + updateCustomIcons, + updateMapSetting, + updateFlyout, +} from '../../actions'; +import { + getCustomIcons, getMapCenter, getMapSettings, getMapZoom, @@ -22,6 +30,7 @@ import { function mapStateToProps(state: MapStoreState) { return { center: getMapCenter(state), + customIcons: getCustomIcons(state), hasMapSettingsChanges: hasMapSettingsChanges(state), settings: getMapSettings(state), zoom: getMapZoom(state), @@ -40,6 +49,12 @@ function mapDispatchToProps(dispatch: ThunkDispatch { dispatch(updateMapSetting(settingKey, settingValue)); }, + updateCustomIcons: (customIcons: CustomIcon[]) => { + dispatch(updateCustomIcons(customIcons)); + }, + deleteCustomIcon: (symbolId: string) => { + dispatch(deleteCustomIcon(symbolId)); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index baee9c4ff48a0..1efa07e280039 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -22,7 +22,8 @@ import { MapSettings } from '../../reducers/map'; import { NavigationPanel } from './navigation_panel'; import { SpatialFiltersPanel } from './spatial_filters_panel'; import { DisplayPanel } from './display_panel'; -import { MapCenter } from '../../../common/descriptor_types'; +import { CustomIconsPanel } from './custom_icons_panel'; +import { CustomIcon, MapCenter } from '../../../common/descriptor_types'; export interface Props { cancelChanges: () => void; @@ -30,7 +31,10 @@ export interface Props { hasMapSettingsChanges: boolean; keepChanges: () => void; settings: MapSettings; + customIcons: CustomIcon[]; updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + updateCustomIcons: (customIcons: CustomIcon[]) => void; + deleteCustomIcon: (symbolId: string) => void; zoom: number; } @@ -40,7 +44,10 @@ export function MapSettingsPanel({ hasMapSettingsChanges, keepChanges, settings, + customIcons, updateMapSetting, + updateCustomIcons, + deleteCustomIcon, zoom, }: Props) { // TODO move common text like Cancel and Close to common i18n translation @@ -77,6 +84,12 @@ export function MapSettingsPanel({ /> + + diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/index.ts b/x-pack/plugins/maps/public/connected_components/mb_map/index.ts index d46d4f53de47f..df03f755d6d2b 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/index.ts +++ b/x-pack/plugins/maps/public/connected_components/mb_map/index.ts @@ -21,6 +21,7 @@ import { updateMetaFromTiles, } from '../../actions'; import { + getCustomIcons, getGoto, getLayerList, getMapReady, @@ -40,6 +41,7 @@ function mapStateToProps(state: MapStoreState) { return { isMapReady: getMapReady(state), settings: getMapSettings(state), + customIcons: getCustomIcons(state), layerList: getLayerList(state), spatialFiltersLayer: getSpatialFiltersLayer(state), goto: getGoto(state), diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index b4c13294e292d..f778fd06cce9b 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -23,12 +23,19 @@ import { ILayer } from '../../classes/layers/layer'; import { IVectorSource } from '../../classes/sources/vector_source'; import { MapSettings } from '../../reducers/map'; import { + CustomIcon, Goto, MapCenterAndZoom, TileMetaFeature, Timeslice, } from '../../../common/descriptor_types'; -import { DECIMAL_DEGREES_PRECISION, RawValue, ZOOM_PRECISION } from '../../../common/constants'; +import { + CUSTOM_ICON_SIZE, + DECIMAL_DEGREES_PRECISION, + MAKI_ICON_SIZE, + RawValue, + ZOOM_PRECISION, +} from '../../../common/constants'; import { getGlyphUrl } from '../../util'; import { syncLayerOrder } from './sort_layers'; @@ -39,12 +46,13 @@ import { TileStatusTracker } from './tile_status_tracker'; import { DrawFeatureControl } from './draw_control/draw_feature_control'; import type { MapExtentState } from '../../reducers/map/types'; // @ts-expect-error -import { createSdfIcon } from '../../classes/styles/vector/symbol_utils'; +import { CUSTOM_ICON_PIXEL_RATIO, createSdfIcon } from '../../classes/styles/vector/symbol_utils'; import { MAKI_ICONS } from '../../classes/styles/vector/maki_icons'; export interface Props { isMapReady: boolean; settings: MapSettings; + customIcons: CustomIcon[]; layerList: ILayer[]; spatialFiltersLayer: ILayer; goto?: Goto | null; @@ -78,6 +86,7 @@ export class MbMap extends Component { private _checker?: ResizeChecker; private _isMounted: boolean = false; private _containerRef: HTMLDivElement | null = null; + private _prevCustomIcons?: CustomIcon[]; private _prevDisableInteractive?: boolean; private _prevLayerList?: ILayer[]; private _prevTimeslice?: Timeslice; @@ -288,7 +297,7 @@ export class MbMap extends Component { const pixelRatio = Math.floor(window.devicePixelRatio); for (const [symbolId, { svg }] of Object.entries(MAKI_ICONS)) { if (!mbMap.hasImage(symbolId)) { - const imageData = await createSdfIcon(svg, 0.25, 0.25); + const imageData = await createSdfIcon({ renderSize: MAKI_ICON_SIZE, svg }); mbMap.addImage(symbolId, imageData, { pixelRatio, sdf: true, @@ -389,6 +398,27 @@ export class MbMap extends Component { } } + if ( + this._prevCustomIcons === undefined || + !_.isEqual(this._prevCustomIcons, this.props.customIcons) + ) { + this._prevCustomIcons = this.props.customIcons; + const mbMap = this.state.mbMap; + for (const { symbolId, svg, cutoff, radius } of this.props.customIcons) { + createSdfIcon({ svg, renderSize: CUSTOM_ICON_SIZE, cutoff, radius }).then( + (imageData: ImageData) => { + // @ts-expect-error MapboxMap type is missing updateImage method + if (mbMap.hasImage(symbolId)) mbMap.updateImage(symbolId, imageData); + else + mbMap.addImage(symbolId, imageData, { + sdf: true, + pixelRatio: CUSTOM_ICON_PIXEL_RATIO, + }); + } + ); + } + } + let zoomRangeChanged = false; if (this.props.settings.minZoom !== this.state.mbMap.getMinZoom()) { this.state.mbMap.setMinZoom(this.props.settings.minZoom); diff --git a/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts index f5af113b3b316..242d0684f565a 100644 --- a/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts @@ -13,6 +13,7 @@ export function getDefaultMapSettings(): MapSettings { return { autoFitToDataBounds: false, backgroundColor: euiThemeVars.euiColorEmptyShade, + customIcons: [], disableInteractive: false, disableTooltipControl: false, hideToolbarOverlay: false, diff --git a/x-pack/plugins/maps/public/reducers/map/types.ts b/x-pack/plugins/maps/public/reducers/map/types.ts index d7fa98c24b46f..9f70c7e67271a 100644 --- a/x-pack/plugins/maps/public/reducers/map/types.ts +++ b/x-pack/plugins/maps/public/reducers/map/types.ts @@ -10,6 +10,7 @@ import type { Query } from 'src/plugins/data/common'; import { Filter } from '@kbn/es-query'; import { + CustomIcon, DrawState, EditState, Goto, @@ -51,6 +52,7 @@ export type MapContext = Partial & { export type MapSettings = { autoFitToDataBounds: boolean; backgroundColor: string; + customIcons: CustomIcon[]; disableInteractive: boolean; disableTooltipControl: boolean; hideToolbarOverlay: boolean; diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index baca2d79b833d..3c08a0e6f19ce 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -35,7 +35,7 @@ import { getQueryableUniqueIndexPatternIds, } from './map_selectors'; -import { LayerDescriptor, VectorLayerDescriptor } from '../../common/descriptor_types'; +import { CustomIcon, LayerDescriptor, VectorLayerDescriptor } from '../../common/descriptor_types'; import { ILayer } from '../classes/layers/layer'; import { Filter } from '@kbn/es-query'; import { ESSearchSource } from '../classes/sources/es_search_source'; @@ -255,8 +255,13 @@ describe('getQueryableUniqueIndexPatternIds', () => { ]; const waitingForMapReadyLayerList: VectorLayerDescriptor[] = [] as unknown as VectorLayerDescriptor[]; + const customIcons: CustomIcon[] = []; expect( - getQueryableUniqueIndexPatternIds.resultFunc(layerList, waitingForMapReadyLayerList) + getQueryableUniqueIndexPatternIds.resultFunc( + layerList, + waitingForMapReadyLayerList, + customIcons + ) ).toEqual(['foo', 'bar']); }); @@ -274,8 +279,13 @@ describe('getQueryableUniqueIndexPatternIds', () => { createWaitLayerDescriptorMock({ indexPatternId: 'fbr' }), createWaitLayerDescriptorMock({ indexPatternId: 'foo' }), ] as unknown as VectorLayerDescriptor[]; + const customIcons: CustomIcon[] = []; expect( - getQueryableUniqueIndexPatternIds.resultFunc(layerList, waitingForMapReadyLayerList) + getQueryableUniqueIndexPatternIds.resultFunc( + layerList, + waitingForMapReadyLayerList, + customIcons + ) ).toEqual(['foo', 'fbr']); }); }); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 9253b27a50f66..f86f3dd927c69 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -43,6 +43,7 @@ import { MapStoreState } from '../reducers/store'; import { AbstractSourceDescriptor, DataRequestDescriptor, + CustomIcon, DrawState, EditState, Goto, @@ -65,6 +66,7 @@ import { getIsReadOnly } from './ui_selectors'; export function createLayerInstance( layerDescriptor: LayerDescriptor, + customIcons: CustomIcon[], inspectorAdapters?: Adapters, chartsPaletteServiceGetColor?: (value: string) => string | null ): ILayer { @@ -86,6 +88,7 @@ export function createLayerInstance( layerDescriptor: vectorLayerDescriptor, source: source as IVectorSource, joins, + customIcons, chartsPaletteServiceGetColor, }); case LAYER_TYPE.EMS_VECTOR_TILE: @@ -99,12 +102,14 @@ export function createLayerInstance( return new BlendedVectorLayer({ layerDescriptor: layerDescriptor as VectorLayerDescriptor, source: source as IVectorSource, + customIcons, chartsPaletteServiceGetColor, }); case LAYER_TYPE.MVT_VECTOR: return new MvtVectorLayer({ layerDescriptor: layerDescriptor as VectorLayerDescriptor, source: source as IVectorSource, + customIcons, }); default: throw new Error(`Unrecognized layerType ${layerDescriptor.type}`); @@ -184,6 +189,14 @@ export const getTimeFilters = ({ map }: MapStoreState): TimeRange => export const getTimeslice = ({ map }: MapStoreState) => map.mapState.timeslice; +export const getCustomIcons = ({ map }: MapStoreState): CustomIcon[] => { + return ( + map.settings.customIcons.map((icon) => { + return { ...icon, svg: Buffer.from(icon.svg, 'base64').toString('utf-8') }; + }) ?? [] + ); +}; + export const getQuery = ({ map }: MapStoreState): Query | undefined => map.mapState.query; export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.filters; @@ -261,7 +274,8 @@ export const getDataFilters = createSelector( export const getSpatialFiltersLayer = createSelector( getFilters, getMapSettings, - (filters, settings) => { + getCustomIcons, + (filters, settings, customIcons) => { const featureCollection: FeatureCollection = { type: 'FeatureCollection', features: extractFeaturesFromFilters(filters), @@ -298,6 +312,7 @@ export const getSpatialFiltersLayer = createSelector( }), }), source: new GeoJsonFileSource(geoJsonSourceDescriptor), + customIcons, }); } ); @@ -306,9 +321,15 @@ export const getLayerList = createSelector( getLayerListRaw, getInspectorAdapters, getChartsPaletteServiceGetColor, - (layerDescriptorList, inspectorAdapters, chartsPaletteServiceGetColor) => { + getCustomIcons, + (layerDescriptorList, inspectorAdapters, chartsPaletteServiceGetColor, customIcons) => { return layerDescriptorList.map((layerDescriptor) => - createLayerInstance(layerDescriptor, inspectorAdapters, chartsPaletteServiceGetColor) + createLayerInstance( + layerDescriptor, + customIcons, + inspectorAdapters, + chartsPaletteServiceGetColor + ) ); } ); @@ -375,12 +396,13 @@ export const getSelectedLayerJoinDescriptors = createSelector(getSelectedLayer, export const getQueryableUniqueIndexPatternIds = createSelector( getLayerList, getWaitingForMapReadyLayerListRaw, - (layerList, waitingForMapReadyLayerList) => { + getCustomIcons, + (layerList, waitingForMapReadyLayerList, customIcons) => { const indexPatternIds: string[] = []; if (waitingForMapReadyLayerList.length) { waitingForMapReadyLayerList.forEach((layerDescriptor) => { - const layer = createLayerInstance(layerDescriptor); + const layer = createLayerInstance(layerDescriptor, customIcons); if (layer.isVisible()) { indexPatternIds.push(...layer.getQueryableIndexPatternIds()); } @@ -399,12 +421,13 @@ export const getQueryableUniqueIndexPatternIds = createSelector( export const getGeoFieldNames = createSelector( getLayerList, getWaitingForMapReadyLayerListRaw, - (layerList, waitingForMapReadyLayerList) => { + getCustomIcons, + (layerList, waitingForMapReadyLayerList, customIcons) => { const geoFieldNames: string[] = []; if (waitingForMapReadyLayerList.length) { waitingForMapReadyLayerList.forEach((layerDescriptor) => { - const layer = createLayerInstance(layerDescriptor); + const layer = createLayerInstance(layerDescriptor, customIcons); geoFieldNames.push(...layer.getGeoFieldNames()); }); } else { diff --git a/yarn.lock b/yarn.lock index c13acd20ed888..56982ee4386bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6562,6 +6562,11 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/raf@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2" + integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw== + "@types/rbush@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-3.0.0.tgz#b6887d99b159e87ae23cd14eceff34f139842aa6" @@ -9737,6 +9742,20 @@ canvg@^3.0.9: stackblur-canvas "^2.0.0" svg-pathdata "^6.0.3" +canvg@^3.0.9: + version "3.0.9" + resolved "https://registry.yarnpkg.com/canvg/-/canvg-3.0.9.tgz#9ba095f158b94b97ca2c9c1c40785b11dc08df6d" + integrity sha512-rDXcnRPuz4QHoCilMeoTxql+fvGqNAxp+qV/KHD8rOiJSAfVjFclbdUNHD2Uqfthr+VMg17bD2bVuk6F07oLGw== + dependencies: + "@babel/runtime" "^7.12.5" + "@types/raf" "^3.4.0" + core-js "^3.8.3" + raf "^3.4.1" + regenerator-runtime "^0.13.7" + rgbcolor "^1.0.1" + stackblur-canvas "^2.0.0" + svg-pathdata "^6.0.3" + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" @@ -10830,6 +10849,11 @@ core-js@^3.0.4, core-js@^3.21.1, core-js@^3.6.5, core-js@^3.8.2, core-js@^3.8.3: resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.21.1.tgz#f2e0ddc1fc43da6f904706e8e955bc19d06a0d94" integrity sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig== +core-js@^3.8.3: + version "3.19.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.19.1.tgz#f6f173cae23e73a7d88fa23b6e9da329276c6641" + integrity sha512-Tnc7E9iKd/b/ff7GFbhwPVzJzPztGrChB8X8GLqoYGdEOG8IpLnK1xPyo3ZoO3HsK6TodJS58VGPOxA+hLHQMg== + core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"