[maps] use control group time slider (#146486)

PR replaces Maps time slider implementation with control time slider 

<img width="600" alt="Screen Shot 2022-11-28 at 3 57 24 PM"
src="https://user-images.githubusercontent.com/373691/204398771-54b36093-f0c9-4e3b-8637-ecc8b6dd2429.png">

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2022-12-07 15:14:30 -07:00 committed by GitHub
parent 27077dfca0
commit 8695086d3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 95 additions and 429 deletions

View file

@ -49,6 +49,7 @@ export class TimeSliderControlEmbeddable extends Embeddable<
private getTimezone: ControlsSettingsService['getTimezone'];
private timefilter: ControlsDataService['timefilter'];
private prevTimeRange: TimeRange | undefined;
private readonly waitForControlOutputConsumersToLoad$;
private reduxEmbeddableTools: ReduxEmbeddableTools<
@ -129,9 +130,9 @@ export class TimeSliderControlEmbeddable extends Embeddable<
return;
}
const nextBounds = this.timeRangeToBounds(input.timeRange);
const { actions, dispatch, getState } = this.reduxEmbeddableTools;
if (!_.isEqual(nextBounds, getState().componentState.timeRangeBounds)) {
if (!_.isEqual(input.timeRange, this.prevTimeRange)) {
const { actions, dispatch } = this.reduxEmbeddableTools;
const nextBounds = this.timeRangeToBounds(input.timeRange);
const ticks = getTicks(nextBounds[FROM_INDEX], nextBounds[TO_INDEX], this.getTimezone());
dispatch(
actions.setTimeRangeBounds({
@ -145,6 +146,7 @@ export class TimeSliderControlEmbeddable extends Embeddable<
}
private syncWithTimeRange() {
this.prevTimeRange = this.getInput().timeRange;
const { actions, dispatch, getState } = this.reduxEmbeddableTools;
const stepSize = getState().componentState.stepSize;
const timesliceStartAsPercentageOfTimeRange =

View file

@ -8,6 +8,7 @@
"kibanaVersion": "kibana",
"configPath": ["xpack", "maps"],
"requiredPlugins": [
"controls",
"unifiedSearch",
"lens",
"licensing",

View file

@ -9,7 +9,11 @@ import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { MapContainer } from './map_container';
import { getFlyoutDisplay, getIsFullScreen } from '../../selectors/ui_selectors';
import {
getFlyoutDisplay,
getIsFullScreen,
getIsTimesliderOpen,
} from '../../selectors/ui_selectors';
import { cancelAllInFlightRequests, exitFullScreen } from '../../actions';
import {
areLayersLoaded,
@ -22,6 +26,7 @@ import { MapStoreState } from '../../reducers/store';
function mapStateToProps(state: MapStoreState) {
return {
isTimesliderOpen: getIsTimesliderOpen(state),
areLayersLoaded: areLayersLoaded(state),
flyoutDisplay: getFlyoutDisplay(state),
isFullScreen: getIsFullScreen(state),

View file

@ -13,7 +13,6 @@ import uuid from 'uuid/v4';
import { Filter } from '@kbn/es-query';
import { ActionExecutionContext, Action } from '@kbn/ui-actions-plugin/public';
import { Observable } from 'rxjs';
import moment from 'moment';
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import { MBMap } from '../mb_map';
import { RightSideControls } from '../right_side_controls';
@ -21,7 +20,7 @@ import { Timeslider } from '../timeslider';
import { ToolbarOverlay } from '../toolbar_overlay';
import { EditLayerPanel } from '../edit_layer_panel';
import { AddLayerPanel } from '../add_layer_panel';
import { getData, isScreenshotMode } from '../../kibana_services';
import { isScreenshotMode } from '../../kibana_services';
import { RawValue } from '../../../common/constants';
import { FLYOUT_STATE } from '../../reducers/ui';
import { MapSettings } from '../../../common/descriptor_types';
@ -41,6 +40,7 @@ export interface Props {
exitFullScreen: () => void;
flyoutDisplay: FLYOUT_STATE;
isFullScreen: boolean;
isTimesliderOpen: boolean;
indexPatternIds: string[];
mapInitError: string | null | undefined;
renderTooltipContent?: RenderToolTipContent;
@ -154,13 +154,6 @@ export class MapContainer extends Component<Props, State> {
}, 5000);
};
_updateGlobalTimeRange(data: number[]) {
getData().query.timefilter.timefilter.setTime({
from: moment(data[0]).toISOString(),
to: moment(data[1]).toISOString(),
});
}
render() {
const {
addFilters,
@ -241,13 +234,10 @@ export class MapContainer extends Component<Props, State> {
/>
)}
<RightSideControls />
{this.props.isTimesliderOpen && (
<Timeslider waitForTimesliceToLoad$={this.props.waitUntilTimeLayersLoad$} />
)}
</EuiFlexItem>
<Timeslider
waitForTimesliceToLoad$={this.props.waitUntilTimeLayersLoad$}
updateGlobalTimeRange={this._updateGlobalTimeRange.bind(this)}
/>
<EuiFlexItem
className={classNames('mapMapLayerPanel', {
'mapMapLayerPanel-isVisible': !!flyoutPanel,

View file

@ -1,66 +1,14 @@
$timesliderWidth: 650px;
.mapTimeslider {
@include euiBottomShadowLarge;
position: fixed;
left: 50%;
bottom: $euiSize;
// we could center with translateX but this would impact the animation
margin-left: -($timesliderWidth / 2);
width: $timesliderWidth;
z-index: 99999;
background: $euiColorEmptyShade;
padding: $euiSizeL $euiSize;
border-radius: $euiSize;
}
.mapTimeslider__row {
display: flex;
align-items: center;
& + & {
margin-top: $euiSizeS;
}
&:first-child {
padding: 0 $euiSize;
}
}
.mapTimeslider__close {
position: absolute;
top: $euiSizeXS;
right: $euiSizeXS;
}
left: calc(50% - 400px);
bottom: $euiSize;
z-index: 99999;
.mapTimeslider__timeWindow {
display: flex;
flex: 1;
margin-right: $euiSizeS;
font-size: $euiFontSizeS;
}
.mapTimeslider__controls {
margin-left: $euiSizeS;
.euiButtonIcon + .euiButtonIcon {
margin-left: $euiSizeXS;
.controlFrame__formControlLayout {
@include euiBottomShadowLarge;
}
}
.mapTimeslider__innerPanel {
display: inline-flex;
// background: $euiColorLightestShade;
border-radius: $euiBorderRadiusSmall;
padding: $euiSizeXS;
display: inline-flex;
align-items: center;
}
.mapTimeslider__playButton {
border-radius: 50%;
}
.mapTimeslider--animation {
animation: mapAnimationPulse .6s ease-in-out;
}
}

View file

@ -9,25 +9,20 @@ import { AnyAction } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { connect } from 'react-redux';
import { Timeslider } from './timeslider';
import { closeTimeslider, setQuery } from '../../actions';
import { setQuery } from '../../actions';
import { getTimeFilters } from '../../selectors/map_selectors';
import { getIsTimesliderOpen } from '../../selectors/ui_selectors';
import { MapStoreState } from '../../reducers/store';
import { Timeslice } from '../../../common/descriptor_types';
function mapStateToProps(state: MapStoreState) {
return {
isTimesliderOpen: getIsTimesliderOpen(state),
timeRange: getTimeFilters(state),
};
}
function mapDispatchToProps(dispatch: ThunkDispatch<MapStoreState, void, AnyAction>) {
return {
closeTimeslider: () => {
dispatch(closeTimeslider());
},
setTimeslice: (timeslice: Timeslice) => {
setTimeslice: (timeslice?: Timeslice) => {
dispatch(
setQuery({
forceRefresh: false,

View file

@ -1,30 +0,0 @@
/*
* 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 { getInterval } from './time_utils';
describe('getInterval', () => {
test('should provide interval of 1 day for 7 day range', () => {
expect(getInterval(1617630946622, 1618235746622)).toBe(86400000);
});
test('should provide interval of 3 hours for 24 hour range', () => {
expect(getInterval(1618150382531, 1618236782531)).toBe(10800000);
});
test('should provide interval of 90 minues for 12 hour range', () => {
expect(getInterval(1618193892632, 1618237092632)).toBe(5400000);
});
test('should provide interval of 30 minues for 4 hour range', () => {
expect(getInterval(1618222509189, 1618236909189)).toBe(1800000);
});
test('should provide interval of 10 minues for 1 hour range', () => {
expect(getInterval(1618233266459, 1618236866459)).toBe(600000);
});
});

View file

@ -1,84 +0,0 @@
/*
* 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 moment from 'moment-timezone';
import { EuiRangeTick } from '@elastic/eui/src/components/form/range/range_ticks';
import { calcAutoIntervalNear } from '@kbn/data-plugin/common';
import { getUiSettings } from '../../kibana_services';
function getTimezone() {
const detectedTimezone = moment.tz.guess();
const dateFormatTZ = getUiSettings().get('dateFormat:tz', 'Browser');
return dateFormatTZ === 'Browser' ? detectedTimezone : dateFormatTZ;
}
function getScaledDateFormat(interval: number): string {
if (interval >= moment.duration(1, 'y').asMilliseconds()) {
return 'YYYY';
}
if (interval >= moment.duration(1, 'd').asMilliseconds()) {
return 'MMM D';
}
if (interval >= moment.duration(6, 'h').asMilliseconds()) {
return 'Do HH';
}
if (interval >= moment.duration(1, 'h').asMilliseconds()) {
return 'HH:mm';
}
if (interval >= moment.duration(1, 'm').asMilliseconds()) {
return 'HH:mm';
}
if (interval >= moment.duration(1, 's').asMilliseconds()) {
return 'mm:ss';
}
return 'ss.SSS';
}
export function epochToKbnDateFormat(epoch: number): string {
const dateFormat = getUiSettings().get('dateFormat', 'MMM D, YYYY @ HH:mm:ss.SSS');
const timezone = getTimezone();
return moment.tz(epoch, timezone).format(dateFormat);
}
export function getInterval(min: number, max: number, steps = 6): number {
const duration = max - min;
let interval = calcAutoIntervalNear(steps, duration).asMilliseconds();
// Sometimes auto interval is not quite right and returns 2X or 3X requested ticks
// Adjust the interval to get closer to the requested number of ticks
const actualSteps = duration / interval;
if (actualSteps > steps * 1.5) {
const factor = Math.round(actualSteps / steps);
interval *= factor;
} else if (actualSteps < 5) {
interval *= 0.5;
}
return interval;
}
export function getTicks(min: number, max: number, interval: number): EuiRangeTick[] {
const format = getScaledDateFormat(interval);
const timezone = getTimezone();
let tick = Math.ceil(min / interval) * interval;
const ticks: EuiRangeTick[] = [];
while (tick < max) {
ticks.push({
value: tick,
label: moment.tz(tick, timezone).format(format),
});
tick += interval;
}
return ticks;
}

View file

@ -7,248 +7,105 @@
import _ from 'lodash';
import React, { Component } from 'react';
import { EuiButtonIcon, EuiDualRange, EuiText } from '@elastic/eui';
import { EuiRangeTick } from '@elastic/eui/src/components/form/range/range_ticks';
import { i18n } from '@kbn/i18n';
import { Observable, Subscription } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import {
ControlGroupContainer,
type ControlGroupInput,
type controlGroupInputBuilder,
LazyControlGroupRenderer,
} from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
import { first } from 'rxjs/operators';
import type { TimeRange } from '@kbn/es-query';
import { epochToKbnDateFormat, getInterval, getTicks } from './time_utils';
import { getTimeFilter } from '../../kibana_services';
import { Timeslice } from '../../../common/descriptor_types';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
export interface Props {
closeTimeslider: () => void;
setTimeslice: (timeslice: Timeslice) => void;
isTimesliderOpen: boolean;
setTimeslice: (timeslice?: Timeslice) => void;
timeRange: TimeRange;
waitForTimesliceToLoad$: Observable<void>;
updateGlobalTimeRange: (timeslice: number[]) => void;
}
interface State {
isPaused: boolean;
max: number;
min: number;
range: number;
timeslice: [number, number];
ticks: EuiRangeTick[];
}
function prettyPrintTimeslice(timeslice: [number, number]) {
return `${epochToKbnDateFormat(timeslice[0])} - ${epochToKbnDateFormat(timeslice[1])}`;
}
// Why Timeslider and KeyedTimeslider?
// Using react 'key' property to ensure new KeyedTimeslider instance whenever props.timeRange changes
export function Timeslider(props: Props) {
return props.isTimesliderOpen ? (
<KeyedTimeslider key={`${props.timeRange.from}-${props.timeRange.to}`} {...props} />
) : null;
}
class KeyedTimeslider extends Component<Props, State> {
export class Timeslider extends Component<Props, {}> {
private _isMounted: boolean = false;
private _timeoutId: number | undefined;
private _subscription: Subscription | undefined;
constructor(props: Props) {
super(props);
const timeRangeBounds = getTimeFilter().calculateBounds(props.timeRange);
if (timeRangeBounds.min === undefined || timeRangeBounds.max === undefined) {
throw new Error(
'Unable to create Timeslider component, timeRangeBounds min or max are undefined'
);
}
const min = timeRangeBounds.min.valueOf();
const max = timeRangeBounds.max.valueOf();
const interval = getInterval(min, max);
const timeslice: [number, number] = [min, max];
this.state = {
isPaused: true,
max,
min,
range: interval,
ticks: getTicks(min, max, interval),
timeslice,
};
}
private _controlGroup?: ControlGroupContainer | undefined;
private readonly _subscriptions = new Subscription();
componentWillUnmount() {
this._onPause();
this._isMounted = false;
this._subscriptions.unsubscribe();
}
componentDidUpdate() {
if (
this._controlGroup &&
!_.isEqual(this._controlGroup.getInput().timeRange, this.props.timeRange)
) {
this._controlGroup.updateInput({
timeRange: this.props.timeRange,
});
}
}
componentDidMount() {
this._isMounted = true;
// auto-select range between first tick and second tick
this._onChange([this.state.ticks[0].value, this.state.ticks[1].value]);
}
_doesTimesliceCoverTimerange() {
return this.state.timeslice[0] === this.state.min && this.state.timeslice[1] === this.state.max;
}
_onDualControlChange = (value: [number | string, number | string]) => {
this.setState({ range: (value[1] as number) - (value[0] as number) }, () => {
this._onChange(value as [number, number]);
});
_getInitialInput = async (
initialInput: Partial<ControlGroupInput>,
builder: typeof controlGroupInputBuilder
) => {
builder.addTimeSliderControl(initialInput);
return {
...initialInput,
viewMode: ViewMode.VIEW,
timeRange: this.props.timeRange,
};
};
_onChange = (value: [number, number]) => {
this.setState({
timeslice: value,
});
this._propagateChange(value);
};
_onNext = () => {
const from =
this._doesTimesliceCoverTimerange() || this.state.timeslice[1] === this.state.max
? this.state.ticks[0].value
: this.state.timeslice[1];
const to = from + this.state.range;
this._onChange([from, to <= this.state.max ? to : this.state.max]);
};
_onPrevious = () => {
const to =
this._doesTimesliceCoverTimerange() || this.state.timeslice[0] === this.state.min
? this.state.ticks[this.state.ticks.length - 1].value
: this.state.timeslice[0];
const from = to - this.state.range;
this._onChange([from < this.state.min ? this.state.min : from, to]);
};
_propagateChange = _.debounce((value: [number, number]) => {
if (this._isMounted) {
this.props.setTimeslice({ from: value[0], to: value[1] });
_onLoadComplete = (controlGroup: ControlGroupContainer) => {
if (!this._isMounted) {
return;
}
}, 300);
_onPlay = () => {
this.setState({ isPaused: false });
this._playNextFrame();
this._controlGroup = controlGroup;
this._subscriptions.add(
this._controlGroup
.getOutput$()
.pipe(
distinctUntilChanged(({ timeslice: timesliceA }, { timeslice: timesliceB }) =>
_.isEqual(timesliceA, timesliceB)
)
)
.subscribe(({ timeslice }) => {
// use waitForTimesliceToLoad$ observable to wait until next frame loaded
// .pipe(first()) waits until the first value is emitted from an observable and then automatically unsubscribes
this.props.waitForTimesliceToLoad$.pipe(first()).subscribe(() => {
this._controlGroup!.anyControlOutputConsumerLoading$.next(false);
});
this.props.setTimeslice(
timeslice === undefined
? undefined
: {
from: timeslice[0],
to: timeslice[1],
}
);
})
);
};
_onPause = () => {
this.setState({ isPaused: true });
if (this._subscription) {
this._subscription.unsubscribe();
this._subscription = undefined;
}
if (this._timeoutId) {
clearTimeout(this._timeoutId);
this._timeoutId = undefined;
}
};
_playNextFrame() {
// advance to next frame
this._onNext();
// use waitForTimesliceToLoad$ observable to wait until next frame loaded
// .pipe(first()) waits until the first value is emitted from an observable and then automatically unsubscribes
this._subscription = this.props.waitForTimesliceToLoad$.pipe(first()).subscribe(() => {
if (this.state.isPaused) {
return;
}
// use timeout to display frame for small time period before moving to next frame
this._timeoutId = window.setTimeout(() => {
if (this.state.isPaused) {
return;
}
this._playNextFrame();
}, 1750);
});
}
render() {
return (
<div className="mapTimeslider mapTimeslider--animation">
<div className="mapTimeslider__row">
<EuiButtonIcon
onClick={this.props.closeTimeslider}
iconType="cross"
color="text"
className="mapTimeslider__close"
aria-label={i18n.translate('xpack.maps.timeslider.closeLabel', {
defaultMessage: 'Close timeslider',
})}
/>
<div className="mapTimeslider__timeWindow">
<EuiText size="s">{prettyPrintTimeslice(this.state.timeslice)}</EuiText>
</div>
<EuiButtonIcon
onClick={() => {
this.props.updateGlobalTimeRange(this.state.timeslice);
}}
iconType="calendar"
aria-label={i18n.translate('xpack.maps.timeslider.setGlobalTime', {
defaultMessage: 'Set global time to {timeslice}',
values: { timeslice: prettyPrintTimeslice(this.state.timeslice) },
})}
title={i18n.translate('xpack.maps.timeslider.setGlobalTime', {
defaultMessage: 'Set global time to {timeslice}',
values: { timeslice: prettyPrintTimeslice(this.state.timeslice) },
})}
/>
<div className="mapTimeslider__innerPanel">
<div className="mapTimeslider__controls">
<EuiButtonIcon
onClick={this._onPrevious}
iconType="framePrevious"
color="text"
aria-label={i18n.translate('xpack.maps.timeslider.previousTimeWindowLabel', {
defaultMessage: 'Previous time window',
})}
/>
<EuiButtonIcon
className="mapTimeslider__playButton"
onClick={this.state.isPaused ? this._onPlay : this._onPause}
iconType={this.state.isPaused ? 'playFilled' : 'pause'}
size="s"
display="fill"
aria-label={
this.state.isPaused
? i18n.translate('xpack.maps.timeslider.playLabel', {
defaultMessage: 'Play',
})
: i18n.translate('xpack.maps.timeslider.pauseLabel', {
defaultMessage: 'Pause',
})
}
/>
<EuiButtonIcon
onClick={this._onNext}
iconType="frameNext"
color="text"
aria-label={i18n.translate('xpack.maps.timeslider.nextTimeWindowLabel', {
defaultMessage: 'Next time window',
})}
/>
</div>
</div>
</div>
<div className="mapTimeslider__row">
<EuiDualRange
fullWidth={true}
value={this.state.timeslice}
onChange={this._onDualControlChange}
showTicks={true}
min={this.state.min}
max={this.state.max}
step={1}
ticks={this.state.ticks}
isDraggable
/>
</div>
<ControlGroupRenderer
onLoadComplete={this._onLoadComplete}
getInitialInput={this._getInitialInput}
/>
</div>
);
}

View file

@ -18362,7 +18362,6 @@
"xpack.maps.tiles.resultsCompleteMsg": "{countPrefix}{count} documents trouvés.",
"xpack.maps.tiles.resultsTrimmedMsg": "Résultats limités à {countPrefix}{count} documents.",
"xpack.maps.tileStatusTracker.layerErrorMsg": "Impossible de charger {count} tuiles : {tileErrors}",
"xpack.maps.timeslider.setGlobalTime": "Définir lheure globale sur {timeslice}",
"xpack.maps.tooltip.joinPropertyTooltipContent": "La clé partagée \"{leftFieldName}\" est reliée à {rightSources}.",
"xpack.maps.tooltip.pageNumerText": "{pageNumber} sur {total}",
"xpack.maps.tooltipSelector.addLabelWithCount": "Ajouter {count}",
@ -18937,11 +18936,6 @@
"xpack.maps.tileMap.vis.title": "Carte de coordonnées",
"xpack.maps.tiles.shapeCountMsg": " Ce nombre est approximatif.",
"xpack.maps.tileStatusTracker.tileErrorMsg": "impossible de charger la tuile \"{tileZXYKey}\" : \"{status} {message}\"",
"xpack.maps.timeslider.closeLabel": "Fermer le curseur temporel",
"xpack.maps.timeslider.nextTimeWindowLabel": "Fenêtre temporelle suivante",
"xpack.maps.timeslider.pauseLabel": "Pause",
"xpack.maps.timeslider.playLabel": "Lecture",
"xpack.maps.timeslider.previousTimeWindowLabel": "Fenêtre temporelle précédente",
"xpack.maps.timesliderToggleButton.closeLabel": "Fermer le curseur temporel",
"xpack.maps.timesliderToggleButton.openLabel": "Ouvrir le curseur temporel",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "limites",

View file

@ -18345,7 +18345,6 @@
"xpack.maps.tiles.resultsCompleteMsg": "{countPrefix}{count}個のドキュメントが見つかりました。",
"xpack.maps.tiles.resultsTrimmedMsg": "結果は{countPrefix}{count}個のドキュメントに制限されています。",
"xpack.maps.tileStatusTracker.layerErrorMsg": "{count}個のタイルを読み込めません:{tileErrors}",
"xpack.maps.timeslider.setGlobalTime": "グローバル時刻を{timeslice}に設定",
"xpack.maps.tooltip.joinPropertyTooltipContent": "共有キー'{leftFieldName}'は{rightSources}と結合されます",
"xpack.maps.tooltip.pageNumerText": "{total}ページ中 {pageNumber}ページ",
"xpack.maps.tooltipSelector.addLabelWithCount": "{count} の追加",
@ -18920,11 +18919,6 @@
"xpack.maps.tileMap.vis.title": "座標マップ",
"xpack.maps.tiles.shapeCountMsg": " この数は近似値です。",
"xpack.maps.tileStatusTracker.tileErrorMsg": "タイル'{tileZXYKey}'を読み込めませんでした:'{status} {message}'",
"xpack.maps.timeslider.closeLabel": "時間スライダーを閉じる",
"xpack.maps.timeslider.nextTimeWindowLabel": "次の時間ウィンドウ",
"xpack.maps.timeslider.pauseLabel": "一時停止",
"xpack.maps.timeslider.playLabel": "再生",
"xpack.maps.timeslider.previousTimeWindowLabel": "前の時間ウィンドウ",
"xpack.maps.timesliderToggleButton.closeLabel": "時間スライダーを閉じる",
"xpack.maps.timesliderToggleButton.openLabel": "時間スライダーを開く",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "境界",

View file

@ -18370,7 +18370,6 @@
"xpack.maps.tiles.resultsCompleteMsg": "找到 {countPrefix}{count} 个文档。",
"xpack.maps.tiles.resultsTrimmedMsg": "结果仅限于 {countPrefix}{count} 个文档。",
"xpack.maps.tileStatusTracker.layerErrorMsg": "无法加载 {count} 个磁贴:{tileErrors}",
"xpack.maps.timeslider.setGlobalTime": "将全局时间设置为 {timeslice}",
"xpack.maps.tooltip.joinPropertyTooltipContent": "共享密钥“{leftFieldName}”与 {rightSources} 联结",
"xpack.maps.tooltip.pageNumerText": "第 {pageNumber} 页,共 {total} 页",
"xpack.maps.tooltipSelector.addLabelWithCount": "添加 {count} 个",
@ -18945,11 +18944,6 @@
"xpack.maps.tileMap.vis.title": "坐标地图",
"xpack.maps.tiles.shapeCountMsg": " 此计数为近似值。",
"xpack.maps.tileStatusTracker.tileErrorMsg": "无法加载磁贴“{tileZXYKey}”:“{status} {message}”",
"xpack.maps.timeslider.closeLabel": "关闭时间滑块",
"xpack.maps.timeslider.nextTimeWindowLabel": "下一时间窗口",
"xpack.maps.timeslider.pauseLabel": "暂停",
"xpack.maps.timeslider.playLabel": "播放",
"xpack.maps.timeslider.previousTimeWindowLabel": "上一时间窗口",
"xpack.maps.timesliderToggleButton.closeLabel": "关闭时间滑块",
"xpack.maps.timesliderToggleButton.openLabel": "打开时间滑块",
"xpack.maps.toolbarOverlay.drawBounds.initialGeometryLabel": "边界",