[Maps] Support custom icons in maps (#113144)

Adds support for users to upload their own SVG icons for styling geo_points or clusters in Elastic Maps.

Because Elastic Maps uses WebGL, dynamic styling requires rendering SVG icons as monochromatic PNGs using a Signed Distance Function algorithm. As a result, highly detailed designs and any color definitions in SVGs uploaded to Maps are not retained. Monochromatic SVG icons work best.

Custom icons are appended to the map saved object and are not accessible in other maps. Using the custom icon in another map requires creating a copy of the existing map or uploading the icon to the new map. Custom icons can be added, edited, or deleted in Map Settings. Custom icons can also be added from within the Icon select menu.
This commit is contained in:
Nick Peihl 2022-03-30 00:17:55 -04:00 committed by GitHub
parent 2688cb21f9
commit 118321fff9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 2651 additions and 338 deletions

View file

@ -61,7 +61,6 @@ Available icons
[role="screenshot"]
image::maps/images/maki-icons.png[]
[float]
[[polygon-style-properties]]
==== Polygon style properties

View file

@ -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',

View file

@ -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;

View file

@ -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 {

View file

@ -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);
};

View file

@ -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<MapStoreState, void, AnyAction>,
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<MapStoreState, void, AnyAction>,

View file

@ -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<LayerDescriptor>;
renderStyleEditor(
onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void
onStyleDescriptorChange: (styleDescriptor: StyleDescriptor) => void,
onCustomIconsChange: (customIcons: CustomIcon[]) => void
): ReactElement<any> | 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<any> | null {
const style = this.getStyleForEditing();
if (!style) {
return null;
}
return style.renderEditor(onStyleDescriptorChange);
return style.renderEditor(onStyleDescriptorChange, onCustomIconsChange);
}
getIndexPatternIds(): string[] {

View file

@ -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();

View file

@ -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
);

View file

@ -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', () => {

View file

@ -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;
}

View file

@ -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;

View file

@ -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
);
}

View file

@ -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';

View file

@ -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<any> | null;
}

View file

@ -41,6 +41,18 @@ exports[`Renders SymbolIcon 1`] = `
key="airfield-15#ff0000rgb(106,173,213)"
stroke="rgb(106,173,213)"
style={Object {}}
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>\\\\n<svg version=\\"1.1\\" id=\\"airfield-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">\\\\n <path id=\\"path5\\" d=\\"M6.8182,0.6818H4.7727 C4.0909,0.6818,4.0909,0,4.7727,0h5.4545c0.6818,0,0.6818,0.6818,0,0.6818H8.1818c0,0,0.8182,0.5909,0.8182,1.9545V4h6v2L9,8l-0.5,5 l2.5,1.3182V15H4v-0.6818L6.5,13L6,8L0,6V4h6V2.6364C6,1.2727,6.8182,0.6818,6.8182,0.6818z\\"/>\\\\n</svg>"
symbolId="airfield-15"
/>
`;
exports[`Renders SymbolIcon with custom icon 1`] = `
<SymbolIcon
fill="#00ff00"
key="__kbn__custom_icon_sdf__foobar#00ff00rgb(0,255,0)"
stroke="rgb(0,255,0)"
style={Object {}}
svg="<svg width=\\"200\\" height=\\"250\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path stroke=\\"#000\\" fill=\\"transparent\\" stroke-width=\\"5\\" d=\\"M10 10h30v30H10z\\"/></svg>"
symbolId="__kbn__custom_icon_sdf__foobar"
/>
`;

View file

@ -16,6 +16,7 @@ const EMPTY_VALUE = '';
export interface Break {
color: string;
label: ReactElement<any> | string | number;
svg?: string;
symbolId?: string;
}
@ -66,16 +67,17 @@ export class BreakedLegend extends Component<Props, State> {
return null;
}
const categories = this.props.breaks.map((brk, index) => {
const categories = this.props.breaks.map(({ symbolId, svg, label, color }, index) => {
return (
<EuiFlexItem key={index}>
<Category
styleName={this.props.style.getStyleName()}
label={brk.label}
color={brk.color}
label={label}
color={color}
isLinesOnly={this.props.isLinesOnly}
isPointsOnly={this.props.isPointsOnly}
symbolId={brk.symbolId}
symbolId={symbolId}
svg={svg}
/>
</EuiFlexItem>
);

View file

@ -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}
/>
);
}

View file

@ -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<Props, State> {
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

View file

@ -52,6 +52,22 @@ test('Renders SymbolIcon', () => {
isLinesOnly={false}
strokeColor="rgb(106,173,213)"
symbolId="airfield-15"
svg='<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="airfield-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path id="path5" d="M6.8182,0.6818H4.7727&#xA;&#x9;C4.0909,0.6818,4.0909,0,4.7727,0h5.4545c0.6818,0,0.6818,0.6818,0,0.6818H8.1818c0,0,0.8182,0.5909,0.8182,1.9545V4h6v2L9,8l-0.5,5&#xA;&#x9;l2.5,1.3182V15H4v-0.6818L6.5,13L6,8L0,6V4h6V2.6364C6,1.2727,6.8182,0.6818,6.8182,0.6818z"/>\n</svg>'
/>
);
expect(component).toMatchSnapshot();
});
test('Renders SymbolIcon with custom icon', () => {
const component = shallow(
<VectorIcon
fillColor="#00ff00"
isPointsOnly={true}
isLinesOnly={false}
strokeColor="rgb(0,255,0)"
symbolId="__kbn__custom_icon_sdf__foobar"
svg='<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>'
/>
);

View file

@ -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 <CircleIcon style={style} />;
}
return (
<SymbolIcon
key={`${symbolId}${fillColor}${strokeColor}`}
symbolId={symbolId}
fill={fillColor}
stroke={strokeColor}
style={borderStyle}
/>
);
if (svg) {
return (
<SymbolIcon
key={`${symbolId}${fillColor}${strokeColor}`}
symbolId={symbolId}
fill={fillColor}
stroke={strokeColor}
style={borderStyle}
svg={svg}
/>
);
}
return null;
}

View file

@ -13,9 +13,10 @@ interface Props {
isPointsOnly: boolean;
styles: Array<IStyleProperty<any>>;
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(

View file

@ -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<StaticOptions, DynamicOptions> {
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<StaticOptions | DynamicOptions>;
}

View file

@ -0,0 +1,311 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render a custom icon modal with an existing icon 1`] = `
<EuiModal
className="mapsCustomIconModal"
initialFocus=".mapsCustomIconForm__image"
maxWidth={700}
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>
Edit custom icon
</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup
alignItems="flexStart"
gutterSize="m"
justifyContent="spaceBetween"
>
<EuiFlexItem
className="mapsCustomIconForm"
grow={2}
>
<EuiFormRow
className="mapsCustomIconForm__image"
describedByIds={Array []}
display="rowCompressed"
error=""
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText="SVGs without sharp corners and intricate details work best. Modifying the settings under Advanced options may improve rendering."
isInvalid={false}
labelType="label"
>
<EuiFilePicker
accept=".svg"
className="mapsImageUpload"
compressed={false}
display="large"
initialPromptText="Select or drag and drop an SVG icon"
isInvalid={false}
onChange={[Function]}
required={true}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFormRow
describedByIds={Array []}
display="rowCompressed"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label="Name"
labelType="label"
>
<EuiFieldText
className="mapsCustomIconForm__label"
data-test-subj="mapsCustomIconForm-label"
onChange={[Function]}
required={true}
value="square"
/>
</EuiFormRow>
<EuiSpacer />
<EuiAccordion
arrowDisplay="left"
buttonContent="Advanced options"
buttonElement="button"
element="div"
id="advancedOptionsAccordion"
initialIsOpen={false}
isLoading={false}
isLoadingMessage={false}
paddingSize="xs"
>
<EuiPanel
color="subdued"
paddingSize="s"
>
<EuiFlexGroup
gutterSize="xs"
justifyContent="flexEnd"
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
onClick={[Function]}
size="xs"
>
Reset
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow
className="mapsCustomIconForm__cutoff"
describedByIds={Array []}
display="rowCompressed"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<EuiToolTip
content="Adjusts the balance of the signed distance function between the inside (approaching 1) and outside (approaching 0) of the icon."
delay="regular"
display="inlineBlock"
position="top"
>
<React.Fragment>
Alpha threshold
<EuiIcon
color="subdued"
type="questionInCircle"
/>
</React.Fragment>
</EuiToolTip>
}
labelType="label"
>
<ValidatedRange
className="mapsCutoffRange"
compressed={true}
max={1}
min={0}
onChange={[Function]}
showInput={true}
showLabels={true}
step={0.01}
value={0.3}
/>
</EuiFormRow>
<EuiFormRow
className="mapsCustomIconForm__radius"
describedByIds={Array []}
display="rowCompressed"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<EuiToolTip
content="Adjusts the size of the signed distance function around the Alpha threshold as a percent of icon size."
delay="regular"
display="inlineBlock"
position="top"
>
<React.Fragment>
Radius
<EuiIcon
color="subdued"
type="questionInCircle"
/>
</React.Fragment>
</EuiToolTip>
}
labelType="label"
>
<ValidatedRange
className="mapsRadiusRange"
compressed={true}
max={1}
min={0}
onChange={[Function]}
showInput={true}
showLabels={true}
step={0.01}
value={0.15}
/>
</EuiFormRow>
</EuiPanel>
</EuiAccordion>
</EuiFlexItem>
<EuiFlexItem
className="mapsIconPreview__wrapper mapsCustomIconForm__preview"
grow={false}
>
<IconPreview
cutoff={0.3}
isSvgInvalid={false}
radius={0.15}
svg="<svg width=\\"200\\" height=\\"250\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path stroke=\\"#000\\" fill=\\"transparent\\" stroke-width=\\"5\\" d=\\"M10 10h30v30H10z\\"/></svg>"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup
justifyContent="flexEnd"
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
onClick={[Function]}
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
color="danger"
data-test-subj="mapsCustomIconForm-submit"
onClick={[Function]}
>
Delete
</EuiButton>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
data-test-subj="mapsCustomIconForm-submit"
fill={true}
isDisabled={false}
onClick={[Function]}
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;
exports[`should render an empty custom icon modal 1`] = `
<EuiModal
className="mapsCustomIconModal"
initialFocus=".mapsCustomIconForm__image"
maxWidth={700}
onClose={[Function]}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>
Custom Icon
</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup
alignItems="flexStart"
gutterSize="m"
justifyContent="spaceBetween"
>
<EuiFlexItem
className="mapsCustomIconForm"
grow={2}
>
<EuiFormRow
className="mapsCustomIconForm__image"
describedByIds={Array []}
display="rowCompressed"
error=""
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
helpText="SVGs without sharp corners and intricate details work best. Modifying the settings under Advanced options may improve rendering."
isInvalid={false}
labelType="label"
>
<EuiFilePicker
accept=".svg"
className="mapsImageUpload"
compressed={false}
display="large"
initialPromptText="Select or drag and drop an SVG icon"
isInvalid={false}
onChange={[Function]}
required={true}
/>
</EuiFormRow>
<EuiSpacer />
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup
justifyContent="flexEnd"
>
<EuiFlexItem
grow={false}
>
<EuiButtonEmpty
onClick={[Function]}
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButton
data-test-subj="mapsCustomIconForm-submit"
fill={true}
isDisabled={true}
onClick={[Function]}
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
`;

View file

@ -3,6 +3,7 @@
exports[`Should not render icon map select when isCustomOnly 1`] = `
<Fragment>
<IconStops
customIcons={Array []}
field={
MockField {
"_fieldName": "myField",
@ -15,14 +16,15 @@ exports[`Should not render icon map select when isCustomOnly 1`] = `
Object {
"icon": "circle",
"stop": null,
},
Object {
"icon": undefined,
"stop": "",
"svg": "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"circle-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M14,7.5c0,3.5899-2.9101,6.5-6.5,6.5S1,11.0899,1,7.5S3.9101,1,7.5,1S14,3.9101,14,7.5z\\"/>
</svg>",
},
]
}
onChange={[Function]}
onCustomIconsChange={[Function]}
/>
</Fragment>
`;
@ -62,6 +64,7 @@ exports[`Should render custom stops input when useCustomIconMap 1`] = `
size="s"
/>
<IconStops
customIcons={Array []}
field={
MockField {
"_fieldName": "myField",
@ -82,6 +85,7 @@ exports[`Should render custom stops input when useCustomIconMap 1`] = `
]
}
onChange={[Function]}
onCustomIconsChange={[Function]}
/>
</Fragment>
`;
@ -122,3 +126,40 @@ exports[`Should render default props 1`] = `
/>
</Fragment>
`;
exports[`Should render icon map select with custom icons 1`] = `
<Fragment>
<EuiSuperSelect
compressed={true}
fullWidth={false}
hasDividers={true}
isInvalid={false}
isLoading={false}
onChange={[Function]}
options={
Array [
Object {
"inputDisplay": "Custom icon palette",
"value": "CUSTOM_MAP_ID",
},
Object {
"inputDisplay": <div>
mock filledShapes option
</div>,
"value": "filledShapes",
},
Object {
"inputDisplay": <div>
mock hollowShapes option
</div>,
"value": "hollowShapes",
},
]
}
valueOfSelected="filledShapes"
/>
<EuiSpacer
size="s"
/>
</Fragment>
`;

View file

@ -1,76 +1,204 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Should render icon select 1`] = `
<EuiPopover
anchorPosition="downLeft"
button={
<EuiFormControlLayout
compressed={true}
fullWidth={true}
icon={
Object {
"side": "right",
"type": "arrowDown",
}
}
onKeyDown={[Function]}
readOnly={true}
>
<EuiFieldText
<Fragment>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiFormControlLayout
compressed={true}
fullWidth={true}
onClick={[Function]}
onKeyDown={[Function]}
prepend={
<SymbolIcon
className="mapIconSelectSymbol__inputButton"
fill="rgb(52, 55, 65)"
symbolId="symbol1"
/>
}
readOnly={true}
value="symbol1"
/>
</EuiFormControlLayout>
}
closePopover={[Function]}
display="block"
hasArrow={true}
isOpen={false}
ownFocus={true}
panelPaddingSize="s"
>
<EuiFocusTrap
clickOutsideDisables={true}
>
<EuiSelectable
isPreFiltered={false}
onChange={[Function]}
options={
Array [
icon={
Object {
"label": "symbol1",
"prepend": <SymbolIcon
"side": "right",
"type": "arrowDown",
}
}
onKeyDown={[Function]}
readOnly={true}
>
<EuiFieldText
compressed={true}
fullWidth={true}
onClick={[Function]}
onKeyDown={[Function]}
prepend={
<SymbolIcon
className="mapIconSelectSymbol__inputButton"
fill="rgb(52, 55, 65)"
symbolId="symbol1"
/>,
"value": "symbol1",
},
Object {
"label": "symbol2",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
symbolId="symbol2"
/>,
"value": "symbol2",
},
]
}
searchable={true}
singleSelection={false}
/>
}
readOnly={true}
value="symbol1"
/>
</EuiFormControlLayout>
}
closePopover={[Function]}
display="block"
hasArrow={true}
isOpen={false}
ownFocus={true}
panelPaddingSize="s"
>
<EuiFocusTrap
clickOutsideDisables={true}
>
<Component />
</EuiSelectable>
</EuiFocusTrap>
</EuiPopover>
<EuiSelectable
compressed={true}
isPreFiltered={false}
onChange={[Function]}
options={
Array [
Object {
"isGroupLabel": true,
"label": "Kibana icons",
},
Object {
"key": "symbol1",
"label": "symbol1",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"square-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="symbol1"
/>,
},
Object {
"key": "symbol2",
"label": "symbol2",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"triangle-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path id=\\"path21090-9\\" d=\\"M7.5385,2&#xA;&#x9;C7.2437,2,7.0502,2.1772,6.9231,2.3846l-5.8462,9.5385C1,12,1,12.1538,1,12.3077C1,12.8462,1.3846,13,1.6923,13h11.6154&#xA;&#x9;C13.6923,13,14,12.8462,14,12.3077c0-0.1538,0-0.2308-0.0769-0.3846L8.1538,2.3846C8.028,2.1765,7.7882,2,7.5385,2z\\"/>
</svg>"
symbolId="symbol2"
/>,
},
]
}
searchable={true}
singleSelection={false}
>
<Component />
</EuiSelectable>
</EuiFocusTrap>
</EuiPopover>
</Fragment>
`;
exports[`Should render icon select with custom icons 1`] = `
<Fragment>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiFormControlLayout
compressed={true}
fullWidth={true}
icon={
Object {
"side": "right",
"type": "arrowDown",
}
}
onKeyDown={[Function]}
readOnly={true}
>
<EuiFieldText
compressed={true}
fullWidth={true}
onClick={[Function]}
onKeyDown={[Function]}
prepend={
<SymbolIcon
className="mapIconSelectSymbol__inputButton"
fill="rgb(52, 55, 65)"
svg="<svg width=\\"200\\" height=\\"250\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path stroke=\\"#000\\" fill=\\"transparent\\" stroke-width=\\"5\\" d=\\"M10 10h30v30H10z\\"/></svg>"
symbolId="__kbn__custom_icon_sdf__foobar"
/>
}
readOnly={true}
value="My Custom Icon"
/>
</EuiFormControlLayout>
}
closePopover={[Function]}
display="block"
hasArrow={true}
isOpen={false}
ownFocus={true}
panelPaddingSize="s"
>
<EuiFocusTrap
clickOutsideDisables={true}
>
<EuiSelectable
compressed={true}
isPreFiltered={false}
onChange={[Function]}
options={
Array [
Object {
"isGroupLabel": true,
"label": "Custom icons",
},
Object {
"key": "__kbn__custom_icon_sdf__foobar",
"label": "My Custom Icon",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<svg width=\\"200\\" height=\\"250\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path stroke=\\"#000\\" fill=\\"transparent\\" stroke-width=\\"5\\" d=\\"M10 10h30v30H10z\\"/></svg>"
symbolId="__kbn__custom_icon_sdf__foobar"
/>,
},
Object {
"key": "__kbn__custom_icon_sdf__bizzbuzz",
"label": "My Other Custom Icon",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"531.74\\" height=\\"460.5\\" overflow=\\"visible\\" xml:space=\\"preserve\\"><path stroke=\\"#000\\" d=\\"M.866 460 265.87 1l265.004 459z\\"/></svg>"
symbolId="__kbn__custom_icon_sdf__bizzbuzz"
/>,
},
Object {
"isGroupLabel": true,
"label": "Kibana icons",
},
Object {
"key": "symbol1",
"label": "symbol1",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"square-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="symbol1"
/>,
},
Object {
"key": "symbol2",
"label": "symbol2",
"prepend": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"triangle-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path id=\\"path21090-9\\" d=\\"M7.5385,2&#xA;&#x9;C7.2437,2,7.0502,2.1772,6.9231,2.3846l-5.8462,9.5385C1,12,1,12.1538,1,12.3077C1,12.8462,1.3846,13,1.6923,13h11.6154&#xA;&#x9;C13.6923,13,14,12.8462,14,12.3077c0-0.1538,0-0.2308-0.0769-0.3846L8.1538,2.3846C8.028,2.1765,7.7882,2,7.5385,2z\\"/>
</svg>"
symbolId="symbol2"
/>,
},
]
}
searchable={true}
singleSelection={false}
>
<Component />
</EuiSelectable>
</EuiFocusTrap>
</EuiPopover>
</Fragment>
`;

View file

@ -0,0 +1,8 @@
.mapsCustomIconForm {
min-width: 400px;
}
.mapsCustomIconForm__preview {
max-width: 210px;
min-height: 210px;
}

View file

@ -0,0 +1,3 @@
.mapsCustomIconPreview__mapContainer {
height: 150px;
}

View file

@ -1,3 +1,3 @@
.mapIconSelectSymbol__inputButton {
margin-left: $euiSizeS;
margin: 0 $euiSizeXS;
}

View file

@ -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(<CustomIconModal {...defaultProps} />);
expect(component).toMatchSnapshot();
});
test('should render a custom icon modal with an existing icon', () => {
const component = shallow(
<CustomIconModal
{...defaultProps}
cutoff={0.3}
label="square"
onDelete={() => {}}
radius={0.15}
svg='<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>'
symbolId="__kbn__custom_icon_sdf__foobar"
title="Edit custom icon"
/>
);
expect(component).toMatchSnapshot();
});

View file

@ -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: () => (
<EuiToolTip
content={i18n.translate('xpack.maps.customIconModal.cutoffRangeTooltip', {
defaultMessage:
'Adjusts the balance of the signed distance function between the inside (approaching 1) and outside (approaching 0) of the icon.',
})}
>
<>
{i18n.translate('xpack.maps.customIconModal.cutoffRangeLabel', {
defaultMessage: 'Alpha threshold',
})}{' '}
<EuiIcon color="subdued" type="questionInCircle" />
</>
</EuiToolTip>
),
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: () => (
<EuiToolTip
content={i18n.translate('xpack.maps.customIconModal.raduisRangeTooltip', {
defaultMessage:
'Adjusts the size of the signed distance function around the Alpha threshold as a percent of icon size.',
})}
>
<>
{i18n.translate('xpack.maps.customIconModal.radiusRangeLabel', {
defaultMessage: 'Radius',
})}{' '}
<EuiIcon color="subdued" type="questionInCircle" />
</>
</EuiToolTip>
),
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<Props, State> {
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 (
<EuiAccordion
id="advancedOptionsAccordion"
buttonContent={strings.getAdvancedOptionsLabel()}
paddingSize="xs"
>
<EuiPanel color="subdued" paddingSize="s">
<EuiFlexGroup justifyContent="flexEnd" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="xs" onClick={this._resetAdvancedOptions}>
{strings.getResetButtonLabel()}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow
className="mapsCustomIconForm__cutoff"
label={strings.getCutoffRangeLabel()}
display="rowCompressed"
>
<ValidatedRange
min={0}
max={1}
value={cutoff}
step={0.01}
showInput
showLabels
compressed
className="mapsCutoffRange"
onChange={this._handleCutoffChange}
/>
</EuiFormRow>
<EuiFormRow
className="mapsCustomIconForm__radius"
label={strings.getRadiusRangeLabel()}
display="rowCompressed"
>
<ValidatedRange
min={0}
max={1}
value={radius}
step={0.01}
showInput
showLabels
compressed
className="mapsRadiusRange"
onChange={this._handleRadiusChange}
/>
</EuiFormRow>
</EuiPanel>
</EuiAccordion>
);
}
private _renderIconForm() {
const { label, svg } = this.state;
return svg !== '' ? (
<>
<EuiFormRow label={strings.getNameInputLabel()} display="rowCompressed">
<EuiFieldText
value={label}
className="mapsCustomIconForm__label"
onChange={(e) => this._handleLabelChange(e.target.value)}
required
data-test-subj="mapsCustomIconForm-label"
/>
</EuiFormRow>
<EuiSpacer />
{this._renderAdvancedOptions()}
</>
) : null;
}
private _renderIconPreview() {
const { svg, isFileInvalid, cutoff, radius } = this.state;
return svg !== '' ? (
<EuiFlexItem className="mapsIconPreview__wrapper mapsCustomIconForm__preview" grow={false}>
<IconPreview svg={svg} isSvgInvalid={isFileInvalid} cutoff={cutoff} radius={radius} />
</EuiFlexItem>
) : 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 (
<EuiModal
className="mapsCustomIconModal"
maxWidth={700}
onClose={onCancel}
initialFocus=".mapsCustomIconForm__image"
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h3>{title}</h3>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart" gutterSize="m">
<EuiFlexItem className="mapsCustomIconForm" grow={2}>
<EuiFormRow
className="mapsCustomIconForm__image"
display="rowCompressed"
isInvalid={!!fileError}
error={fileError}
helpText={strings.getImageInputDescription()}
>
<EuiFilePicker
initialPromptText={strings.getImageFilePickerPlaceholder()}
onChange={this._onFileSelect}
className="mapsImageUpload"
accept=".svg"
isInvalid={!!fileError}
required
/>
</EuiFormRow>
<EuiSpacer />
{this._renderIconForm()}
</EuiFlexItem>
{this._renderIconPreview()}
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>{strings.getCancelButtonLabel()}</EuiButtonEmpty>
</EuiFlexItem>
{onDelete && symbolId ? (
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
onClick={() => {
onDelete(symbolId);
}}
data-test-subj="mapsCustomIconForm-submit"
>
{strings.getDeleteButtonLabel()}
</EuiButton>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiButton
fill
onClick={() => {
onSave({ symbolId: symbolId ?? getCustomIconId(), label, svg, cutoff, radius });
}}
data-test-subj="mapsCustomIconForm-submit"
isDisabled={!isComplete}
>
{strings.getSaveButtonLabel()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
}
}

View file

@ -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({
<IconMapSelect
{...styleOptions}
styleProperty={styleProperty}
customIcons={customIcons}
onChange={onIconMapChange}
onCustomIconsChange={onCustomIconsChange}
isCustomOnly={!field.supportsFieldMetaFromLocalData() && !field.supportsFieldMetaFromEs()}
/>
);

View file

@ -52,6 +52,15 @@ const defaultProps = {
styleProperty:
new MockDynamicStyleProperty() as unknown as IDynamicStyleProperty<IconDynamicOptions>,
isCustomOnly: false,
customIconStops: [
{
stop: null,
icon: 'circle',
svg: '<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="circle-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path d="M14,7.5c0,3.5899-2.9101,6.5-6.5,6.5S1,11.0899,1,7.5S3.9101,1,7.5,1S14,3.9101,14,7.5z"/>\n</svg>',
},
],
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(
<IconMapSelect
{...defaultProps}
customIcons={[
{
symbolId: '__kbn__custom_icon_sdf__foobar',
label: 'My Custom Icon',
svg: '<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>',
cutoff: 0.25,
radius: 0.25,
},
{
symbolId: '__kbn__custom_icon_sdf__bizzbuzz',
label: 'My Other Custom Icon',
svg: '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="531.74" height="460.5" overflow="visible" xml:space="preserve"><path stroke="#000" d="M.866 460 265.87 1l265.004 459z"/></svg>',
cutoff: 0.3,
radius: 0.15,
},
]}
customIconStops={[
{
stop: null,
icon: '__kbn__custom_icon_sdf__bizzbuzz',
},
{
stop: 'value1',
icon: 'marker',
},
]}
/>
);

View file

@ -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<IconDynamicOptions>;
useCustomIconMap?: boolean;
@ -86,6 +93,8 @@ export class IconMapSelect extends Component<Props, State> {
getValueSuggestions={this.props.styleProperty.getValueSuggestions}
iconStops={this.state.customIconStops}
onChange={this._onCustomMapChange}
onCustomIconsChange={this.props.onCustomIconsChange}
customIcons={this.props.customIcons}
/>
);
}

View file

@ -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<Props, State> {
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 (
<div>
<EuiFlexItem>
<EuiPanel color="subdued" hasBorder={true} hasShadow={false} grow={true}>
<EuiTitle size="xxxs">
<h4>
<EuiToolTip
content={i18n.translate('xpack.maps.customIconModal.elementPreviewTooltip', {
defaultMessage:
'Dynamic styling requires rendering SVG icons using a signed distance function. As a result, sharp corners and intricate details may not render correctly. You may be able to tweak the Alpha threshold and Radius for better results.',
})}
>
<>
{i18n.translate('xpack.maps.customIconModal.elementPreviewTitle', {
defaultMessage: 'Render preview',
})}{' '}
<EuiIcon color="subdued" type="questionInCircle" />
</>
</EuiToolTip>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiPanel hasBorder={true} hasShadow={false}>
<div
id="mapsCustomIconPreview__mapContainer"
ref={this._setContainerRef}
data-test-subj="mapsCustomIconPreview"
className="mapsCustomIconPreview__mapContainer"
/>
</EuiPanel>
<EuiSpacer size="m" />
<EuiFormRow label="Preview color">
<EuiColorPicker onChange={this._setIconColor} color={iconColor} />
</EuiFormRow>
</EuiPanel>
</EuiFlexItem>
</div>
);
}
}

View file

@ -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 (
<EuiFormControlLayout
icon={{ type: 'arrowDown', side: 'right' }}
@ -77,15 +120,16 @@ export class IconSelect extends Component {
<EuiFieldText
onClick={this._togglePopover}
onKeyDown={this._handleKeyboardActivity}
value={this.props.value}
value={label || value}
compressed
readOnly
fullWidth
prepend={
<SymbolIcon
key={this.props.value}
key={value}
className="mapIconSelectSymbol__inputButton"
symbolId={this.props.value}
symbolId={value}
svg={svg}
fill={getIsDarkMode() ? 'rgb(223, 229, 239)' : 'rgb(52, 55, 65)'}
/>
}
@ -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: (
<SymbolIcon
key={value}
symbolId={value}
fill={getIsDarkMode() ? 'rgb(223, 229, 239)' : 'rgb(52, 55, 65)'}
svg={svg}
/>
),
};
}),
];
const customOptions = this.props.customIcons.map(({ symbolId, label, svg }) => {
return {
value,
key: symbolId,
label,
prepend: (
<SymbolIcon
key={value}
symbolId={value}
key={symbolId}
symbolId={symbolId}
svg={svg}
fill={getIsDarkMode() ? 'rgb(223, 229, 239)' : 'rgb(52, 55, 65)'}
/>
),
};
});
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 (
<EuiSelectable searchable options={options} onChange={this._onIconSelect}>
<EuiSelectable searchable options={options} onChange={this._onIconSelect} compressed={true}>
{(list, search) => (
<div style={{ width: '300px' }}>
<EuiPopoverTitle>{search}</EuiPopoverTitle>
{list}
<EuiPopoverFooter>
{' '}
<EuiButton fullWidth size="s" onClick={this._toggleModal}>
<FormattedMessage
id="xpack.maps.styles.vector.iconSelect.addCustomIconButtonLabel"
defaultMessage="Add custom icon"
/>
</EuiButton>
</EuiPopoverFooter>
</div>
)}
</EuiSelectable>
@ -123,17 +210,28 @@ export class IconSelect extends Component {
render() {
return (
<EuiPopover
ownFocus
button={this._renderPopoverButton()}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
anchorPosition="downLeft"
panelPaddingSize="s"
display="block"
>
<EuiFocusTrap clickOutsideDisables={true}>{this._renderIconSelectable()}</EuiFocusTrap>
</EuiPopover>
<Fragment>
<EuiPopover
ownFocus
button={this._renderPopoverButton()}
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
anchorPosition="downLeft"
panelPaddingSize="s"
display="block"
>
<EuiFocusTrap clickOutsideDisables={true}>{this._renderIconSelectable()}</EuiFocusTrap>
</EuiPopover>
{this.state.isModalVisible ? (
<CustomIconModal
title="Add custom Icon"
cutoff={DEFAULT_CUSTOM_ICON_CUTOFF}
radius={DEFAULT_CUSTOM_ICON_RADIUS}
onSave={this._handleSave}
onCancel={this._hideModal}
/>
) : null}
</Fragment>
);
}
}

View file

@ -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: '<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="square-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path d="M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z"/>\n</svg>',
},
{
value: 'symbol2',
label: 'symbol2',
svg: '<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="triangle-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path id="path21090-9" d="M7.5385,2&#xA;&#x9;C7.2437,2,7.0502,2.1772,6.9231,2.3846l-5.8462,9.5385C1,12,1,12.1538,1,12.3077C1,12.8462,1.3846,13,1.6923,13h11.6154&#xA;&#x9;C13.6923,13,14,12.8462,14,12.3077c0-0.1538,0-0.2308-0.0769-0.3846L8.1538,2.3846C8.028,2.1765,7.7882,2,7.5385,2z"/>\n</svg>',
},
],
};
});
@ -28,7 +36,39 @@ import { shallow } from 'enzyme';
import { IconSelect } from './icon_select';
test('Should render icon select', () => {
const component = shallow(<IconSelect value={'symbol1'} onChange={() => {}} />);
const component = shallow(
<IconSelect customIcons={[]} icon={{ value: 'symbol1' }} onChange={() => {}} />
);
expect(component).toMatchSnapshot();
});
test('Should render icon select with custom icons', () => {
const component = shallow(
<IconSelect
customIcons={[
{
symbolId: '__kbn__custom_icon_sdf__foobar',
label: 'My Custom Icon',
svg: '<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>',
cutoff: 0.25,
radius: 0.25,
},
{
symbolId: '__kbn__custom_icon_sdf__bizzbuzz',
label: 'My Other Custom Icon',
svg: '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="531.74" height="460.5" overflow="visible" xml:space="preserve"><path stroke="#000" d="M.866 460 265.87 1l265.004 459z"/></svg>',
cutoff: 0.3,
radius: 0.15,
},
]}
icon={{
value: '__kbn__custom_icon_sdf__foobar',
svg: '<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>',
label: 'My Custom Icon',
}}
/>
);
expect(component).toMatchSnapshot();
});

View file

@ -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),
});
};
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)],
});
};
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)],
});
};
let deleteButton;
if (iconStops.length > 2 && index !== 0) {
deleteButton = (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label={i18n.translate('xpack.maps.styles.iconStops.deleteButtonAriaLabel', {
defaultMessage: 'Delete',
})}
title={i18n.translate('xpack.maps.styles.iconStops.deleteButtonLabel', {
defaultMessage: 'Delete',
})}
onClick={onRemove}
/>
);
}
let deleteButton;
if (iconStops.length > 2 && index !== 0) {
deleteButton = (
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label={i18n.translate('xpack.maps.styles.iconStops.deleteButtonAriaLabel', {
defaultMessage: 'Delete',
})}
title={i18n.translate('xpack.maps.styles.iconStops.deleteButtonLabel', {
defaultMessage: 'Delete',
})}
onClick={onRemove}
/>
);
}
const iconStopButtons = (
<div>
{deleteButton}
<EuiButtonIcon
iconType="plusInCircle"
color="primary"
aria-label="Add"
title="Add"
onClick={onAdd}
/>
</div>
);
const errors = [];
// TODO check for duplicate values and add error messages here
const stopInput =
index === 0 ? (
<EuiFieldText
aria-label={getOtherCategoryLabel()}
placeholder={getOtherCategoryLabel()}
disabled
compressed
/>
) : (
<StopInput
key={field.getName()} // force new component instance when field changes
field={field}
getValueSuggestions={getValueSuggestions}
value={stop}
onChange={onStopChange}
/>
const iconStopButtons = (
<div>
{deleteButton}
<EuiButtonIcon
iconType="plusInCircle"
color="primary"
aria-label="Add"
title="Add"
onClick={onAdd}
/>
</div>
);
return (
<EuiFormRow
key={index}
className="mapColorStop"
isInvalid={errors.length !== 0}
error={errors}
display="rowCompressed"
>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false} className="mapStyleSettings__fixedBox">
{stopInput}
</EuiFlexItem>
<EuiFlexItem>
<IconSelect onChange={onIconSelect} value={icon} append={iconStopButtons} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
});
const errors = [];
// TODO check for duplicate values and add error messages here
const stopInput =
index === 0 ? (
<EuiFieldText
aria-label={getOtherCategoryLabel()}
placeholder={getOtherCategoryLabel()}
disabled
compressed
/>
) : (
<StopInput
key={field.getName()} // force new component instance when field changes
field={field}
getValueSuggestions={getValueSuggestions}
value={stop}
onChange={onStopChange}
/>
);
return (
<EuiFormRow
key={index}
className="mapColorStop"
isInvalid={errors.length !== 0}
error={errors}
display="rowCompressed"
>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false} className="mapStyleSettings__fixedBox">
{stopInput}
</EuiFlexItem>
<EuiFlexItem>
<IconSelect
onCustomIconsChange={onCustomIconsChange}
customIcons={customIcons}
onChange={onIconSelect}
icon={{ value: icon, svg, label }}
append={iconStopButtons}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
})
.filter((stop) => {
return stop !== undefined;
});
}

View file

@ -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}
</EuiFlexItem>
<EuiFlexItem>
<IconSelect onChange={onChange} value={styleProperty.getOptions().value} />
<IconSelect
customIcons={customIcons}
onChange={onChange}
onCustomIconsChange={onCustomIconsChange}
icon={styleProperty.getOptions()}
/>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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 () => {

View file

@ -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<Props, State> {
<VectorStyleIconEditor
disabled={!hasMarkerOrIcon}
disabledBy={VECTOR_STYLES.ICON_SIZE}
customIcons={this.props.customIcons}
onStaticStyleChange={this._onStaticStyleChange}
onDynamicStyleChange={this._onDynamicStyleChange}
onCustomIconsChange={this.props.onCustomIconsChange}
styleProperty={
this.props.styleProperties[VECTOR_STYLES.ICON] as IStyleProperty<
IconDynamicOptions | IconStaticOptions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const MAKI_ICONS = {
export const MAKI_ICONS: Record<string, { label: string; svg: string }> = {
aerialway: {
label: 'Aerialway',
svg: '<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="aerialway-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path d="M13,5H8V2.6c0.1854-0.1047,0.3325-0.2659,0.42-0.46L13.5,1.5C13.7761,1.5,14,1.2761,14,1s-0.2239-0.5-0.5-0.5L8.28,1.15&#xA;&#x9;C8.0954,0.9037,7.8077,0.7562,7.5,0.75C7.0963,0.752,6.7334,0.9966,6.58,1.37L1.5,2C1.2239,2,1,2.2239,1,2.5S1.2239,3,1.5,3&#xA;&#x9;l5.22-0.65C6.7967,2.4503,6.8917,2.5351,7,2.6V5H2C1.4477,5,1,5.4477,1,6v7c0,0.5523,0.4477,1,1,1h11c0.5523,0,1-0.4477,1-1V6&#xA;&#x9;C14,5.4477,13.5523,5,13,5z M7,11H3V7h4V11z M12,11H8V7h4V11z"/>\n</svg>',

View file

@ -47,6 +47,10 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`]
isPointsOnly={true}
label="US_format"
styleName="icon"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"circle-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="circle"
/>
</EuiFlexItem>
@ -59,6 +63,10 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`]
isPointsOnly={true}
label="CN_format"
styleName="icon"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"marker-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="marker"
/>
</EuiFlexItem>
@ -77,9 +85,93 @@ exports[`renderLegendDetailRow Should render categorical legend with breaks 1`]
</EuiTextColor>
}
styleName="icon"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"square-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="square"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;
exports[`renderLegendDetailRow Should render categorical legend with custom icons in breaks 1`] = `
<div>
<EuiFlexGroup
gutterSize="xs"
justifyContent="spaceBetween"
>
<EuiFlexItem
grow={false}
>
<EuiToolTip
content="foobar_label"
delay="regular"
display="inlineBlock"
position="top"
title="Icon"
>
<EuiText
className="eui-textTruncate"
size="xs"
style={
Object {
"maxWidth": "180px",
}
}
>
<small>
<strong>
foobar_label
</strong>
</small>
</EuiText>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
direction="column"
gutterSize="none"
>
<EuiFlexItem
key="0"
>
<Category
color="grey"
isLinesOnly={false}
isPointsOnly={true}
label="MX_format"
styleName="icon"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"marker-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="marker"
/>
</EuiFlexItem>
<EuiFlexItem
key="1"
>
<Category
color="grey"
isLinesOnly={false}
isPointsOnly={true}
label={
<EuiTextColor
color="success"
>
Other
</EuiTextColor>
}
styleName="icon"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>
<svg version=\\"1.1\\" id=\\"kbn__custom_icon_sdf__foobar-15\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"15px\\" height=\\"15px\\" viewBox=\\"0 0 15 15\\">
<path d=\\"M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z\\"/>
</svg>"
symbolId="kbn__custom_icon_sdf__foobar"
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
`;

View file

@ -324,7 +324,7 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
return ['match', ['to-string', ['get', this.getMbFieldName()]], ...mbStops];
}
_getOrdinalBreaks(symbolId?: string): Break[] {
_getOrdinalBreaks(symbolId?: string, svg?: string): Break[] {
let colorStops: Array<number | string> | null = null;
let getValuePrefix: ((i: number, isNext: boolean) => string) | null = null;
if (this._options.useCustomColorRamp) {
@ -361,6 +361,7 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
color: colors[colors.length - 1],
label: this.formatField(dynamicRound(rangeFieldMeta.max)),
symbolId,
svg,
},
];
}
@ -405,18 +406,20 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
color,
label,
symbolId,
svg,
});
}
return breaks;
}
_getCategoricalBreaks(symbolId?: string): Break[] {
_getCategoricalBreaks(symbolId?: string, svg?: string): Break[] {
const breaks: Break[] = [];
const { stops, defaultColor } = this._getColorPaletteStops();
stops.forEach(({ stop, color }: { stop: string | number | null; color: string | null }) => {
if (stop !== null && color != null) {
breaks.push({
color,
svg,
symbolId,
label: this.formatField(stop),
});
@ -427,17 +430,18 @@ export class DynamicColorProperty extends DynamicStyleProperty<ColorDynamicOptio
color: defaultColor,
label: <EuiTextColor color="success">{getOtherCategoryLabel()}</EuiTextColor>,
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 (
<BreakedLegend

View file

@ -14,7 +14,7 @@ jest.mock('../components/vector_style_editor', () => ({
}));
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([

View file

@ -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<IconDynamicOptions
if (this._options.useCustomIconMap && this._options.customIconStops) {
const stops = [];
for (let i = 1; i < this._options.customIconStops.length; i++) {
const { stop, icon } = this._options.customIconStops[i];
const { stop, icon, iconSource } = this._options.customIconStops[i];
stops.push({
stop,
style: icon,
iconSource,
});
}
@ -115,21 +117,26 @@ export class DynamicIconProperty extends DynamicStyleProperty<IconDynamicOptions
renderLegendDetailRow({ isPointsOnly, isLinesOnly }: LegendProps) {
const { stops, fallbackSymbolId } = this._getPaletteStops();
const breaks = [];
const layerStyle = this._layer.getCurrentStyle() as IVectorStyle;
stops.forEach(({ stop, style }) => {
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: <EuiTextColor color="success">{getOtherCategoryLabel()}</EuiTextColor>,
symbolId: fallbackSymbolId,
svg,
});
}

View file

@ -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';

View file

@ -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<IconStaticOptions> {

View file

@ -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<SizeStaticOptions> {

View file

@ -16,6 +16,7 @@ export type LegendProps = {
isPointsOnly: boolean;
isLinesOnly: boolean;
symbolId?: string;
svg?: string;
};
export interface IStyleProperty<T> {

View file

@ -66,6 +66,10 @@ export class MockStyle implements IStyle {
return null;
}
getIconSvg(symbolId: string) {
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg version="1.1" id="${symbolId}-15" xmlns="http://www.w3.org/2000/svg" width="15px" height="15px" viewBox="0 0 15 15">\n <path d="M13,14H2c-0.5523,0-1-0.4477-1-1V2c0-0.5523,0.4477-1,1-1h11c0.5523,0,1,0.4477,1,1v11C14,13.5523,13.5523,14,13,14z"/>\n</svg>`;
}
getType() {
return LAYER_STYLE_TYPE.VECTOR;
}
@ -109,6 +113,10 @@ export class MockLayer {
return this._style;
}
getCurrentStyle() {
return this._style;
}
getDataRequest() {
return null;
}

View file

@ -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',
});
});
});

View file

@ -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,
});
}
}

View file

@ -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}
/>
</div>
);

View file

@ -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');
});
});

View file

@ -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);

View file

@ -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<boolean>;
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 (
<VectorIcon
borderStyle={borderStyle}
isPointsOnly={isPointsOnly}
isLinesOnly={isLinesOnly}
symbolId={this._getSymbolId()}
strokeColor={strokeColor}
fillColor={fillColor}
symbolId={symbolId}
svg={svg}
/>
);
}
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 (
<VectorStyleLegend
styles={this._getLegendDetailStyleProperties()}
isPointsOnly={this.getIsPointsOnly()}
isLinesOnly={this._getIsLinesOnly()}
symbolId={this._getSymbolId()}
symbolId={symbolId}
svg={svg}
/>
);
}
@ -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,

View file

@ -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<MapStoreState, void, AnyActi
updateStyleDescriptor: (styleDescriptor: StyleDescriptor) => {
dispatch(updateLayerStyleForSelectedLayer(styleDescriptor));
},
updateCustomIcons: (customIcons: CustomIcon[]) => {
dispatch(updateCustomIcons(customIcons));
},
};
}

View file

@ -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;

View file

@ -0,0 +1,125 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should render 1`] = `
<Fragment>
<EuiPanel>
<EuiTitle
size="xs"
>
<h5>
<FormattedMessage
defaultMessage="Custom icons"
id="xpack.maps.mapSettingsPanel.customIconsTitle"
values={Object {}}
/>
</h5>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiText
size="s"
textAlign="center"
>
<p>
<EuiTextColor
color="subdued"
>
<FormattedMessage
defaultMessage="Add a custom icon that can be used in layers in this map."
id="xpack.maps.mapSettingsPanel.customIcons.emptyState.description"
values={Object {}}
/>
</EuiTextColor>
</p>
</EuiText>
<EuiTextAlign
textAlign="center"
>
<EuiButtonEmpty
data-test-subj="mapsCustomIconPanel-add"
iconType="plusInCircleFilled"
onClick={[Function]}
size="xs"
>
<FormattedMessage
defaultMessage="Add"
id="xpack.maps.mapSettingsPanel.customIconsAddIconButton"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiTextAlign>
</EuiPanel>
</Fragment>
`;
exports[`should render with custom icons 1`] = `
<Fragment>
<EuiPanel>
<EuiTitle
size="xs"
>
<h5>
<FormattedMessage
defaultMessage="Custom icons"
id="xpack.maps.mapSettingsPanel.customIconsTitle"
values={Object {}}
/>
</h5>
</EuiTitle>
<EuiSpacer
size="m"
/>
<EuiListGroup
listItems={
Array [
Object {
"extraAction": Object {
"alwaysShow": true,
"iconType": "gear",
"onClick": [Function],
},
"icon": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<svg width=\\"200\\" height=\\"250\\" xmlns=\\"http://www.w3.org/2000/svg\\"><path stroke=\\"#000\\" fill=\\"transparent\\" stroke-width=\\"5\\" d=\\"M10 10h30v30H10z\\"/></svg>"
symbolId="My Custom Icon"
/>,
"key": "__kbn__custom_icon_sdf__foobar",
"label": "My Custom Icon",
},
Object {
"extraAction": Object {
"alwaysShow": true,
"iconType": "gear",
"onClick": [Function],
},
"icon": <SymbolIcon
fill="rgb(52, 55, 65)"
svg="<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><svg xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"531.74\\" height=\\"460.5\\" overflow=\\"visible\\" xml:space=\\"preserve\\"><path stroke=\\"#000\\" d=\\"M.866 460 265.87 1l265.004 459z\\"/></svg>"
symbolId="My Other Custom Icon"
/>,
"key": "__kbn__custom_icon_sdf__bizzbuzz",
"label": "My Other Custom Icon",
},
]
}
/>
<EuiTextAlign
textAlign="center"
>
<EuiButtonEmpty
data-test-subj="mapsCustomIconPanel-add"
iconType="plusInCircleFilled"
onClick={[Function]}
size="xs"
>
<FormattedMessage
defaultMessage="Add"
id="xpack.maps.mapSettingsPanel.customIconsAddIconButton"
values={Object {}}
/>
</EuiButtonEmpty>
</EuiTextAlign>
</EuiPanel>
</Fragment>
`;

View file

@ -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(<CustomIconsPanel {...defaultProps} />);
expect(component).toMatchSnapshot();
});
test('should render with custom icons', async () => {
const customIcons = [
{
symbolId: '__kbn__custom_icon_sdf__foobar',
label: 'My Custom Icon',
svg: '<svg width="200" height="250" xmlns="http://www.w3.org/2000/svg"><path stroke="#000" fill="transparent" stroke-width="5" d="M10 10h30v30H10z"/></svg>',
cutoff: 0.25,
radius: 0.25,
},
{
symbolId: '__kbn__custom_icon_sdf__bizzbuzz',
label: 'My Other Custom Icon',
svg: '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="531.74" height="460.5" overflow="visible" xml:space="preserve"><path stroke="#000" d="M.866 460 265.87 1l265.004 459z"/></svg>',
cutoff: 0.3,
radius: 0.15,
},
];
const component = shallow(<CustomIconsPanel {...defaultProps} customIcons={customIcons} />);
expect(component).toMatchSnapshot();
});

View file

@ -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<Props, State> {
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 (
<CustomIconModal
title={i18n.translate('xpack.maps.mapSettingsPanel.editCustomIcon', {
defaultMessage: 'Edit custom icon',
})}
symbolId={symbolId}
label={label}
svg={svg}
cutoff={cutoff || DEFAULT_CUSTOM_ICON_CUTOFF}
radius={radius || DEFAULT_CUSTOM_ICON_RADIUS}
onSave={this._handleSave}
onCancel={this._hideModal}
onDelete={this._handleDelete}
/>
);
}
return (
<CustomIconModal
title={i18n.translate('xpack.maps.mapSettingsPanel.addCustomIcon', {
defaultMessage: 'Add custom icon',
})}
cutoff={DEFAULT_CUSTOM_ICON_CUTOFF}
radius={DEFAULT_CUSTOM_ICON_RADIUS}
onSave={this._handleSave}
onCancel={this._hideModal}
/>
);
};
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 = (
<Fragment>
<EuiTextAlign textAlign="center">
<EuiButtonEmpty
size="xs"
iconType="plusInCircleFilled"
onClick={() => this._handleNewIcon()}
data-test-subj="mapsCustomIconPanel-add"
>
<FormattedMessage
id="xpack.maps.mapSettingsPanel.customIconsAddIconButton"
defaultMessage="Add"
/>
</EuiButtonEmpty>
</EuiTextAlign>
</Fragment>
);
if (!this.props.customIcons.length) {
return (
<Fragment>
<EuiText size="s" textAlign="center">
<p>
<EuiTextColor color="subdued">
<FormattedMessage
id="xpack.maps.mapSettingsPanel.customIcons.emptyState.description"
defaultMessage="Add a custom icon that can be used in layers in this map."
/>
</EuiTextColor>
</p>
</EuiText>
{addIconButton}
</Fragment>
);
}
const customIconsList = this.props.customIcons.map((icon) => {
const { symbolId, label, svg } = icon;
return {
label,
key: symbolId,
icon: (
<SymbolIcon
symbolId={label}
svg={svg}
fill={getIsDarkMode() ? 'rgb(223, 229, 239)' : 'rgb(52, 55, 65)'}
/>
),
extraAction: {
iconType: 'gear',
alwaysShow: true,
onClick: () => {
this._handleIconEdit(icon);
},
},
};
});
return (
<Fragment>
<EuiListGroup listItems={customIconsList} />
{addIconButton}
</Fragment>
);
};
public render() {
return (
<Fragment>
<EuiPanel>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.maps.mapSettingsPanel.customIconsTitle"
defaultMessage="Custom icons"
/>
</h5>
</EuiTitle>
<EuiSpacer size="m" />
{this._renderCustomIconsList()}
</EuiPanel>
{this._renderModal()}
</Fragment>
);
}
}

View file

@ -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<MapStoreState, void, AnyActi
updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => {
dispatch(updateMapSetting(settingKey, settingValue));
},
updateCustomIcons: (customIcons: CustomIcon[]) => {
dispatch(updateCustomIcons(customIcons));
},
deleteCustomIcon: (symbolId: string) => {
dispatch(deleteCustomIcon(symbolId));
},
};
}

View file

@ -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({
/>
<EuiSpacer size="s" />
<SpatialFiltersPanel settings={settings} updateMapSetting={updateMapSetting} />
<EuiSpacer size="s" />
<CustomIconsPanel
customIcons={customIcons}
updateCustomIcons={updateCustomIcons}
deleteCustomIcon={deleteCustomIcon}
/>
</div>
</div>

View file

@ -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),

View file

@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
}
}
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);

View file

@ -13,6 +13,7 @@ export function getDefaultMapSettings(): MapSettings {
return {
autoFitToDataBounds: false,
backgroundColor: euiThemeVars.euiColorEmptyShade,
customIcons: [],
disableInteractive: false,
disableTooltipControl: false,
hideToolbarOverlay: false,

View file

@ -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<MapViewContext> & {
export type MapSettings = {
autoFitToDataBounds: boolean;
backgroundColor: string;
customIcons: CustomIcon[];
disableInteractive: boolean;
disableTooltipControl: boolean;
hideToolbarOverlay: boolean;

View file

@ -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']);
});
});

View file

@ -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 {

View file

@ -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"