Browse Source

UI: Implement map layers sidebar.

pull/12723/head
Igor Kulikov 2 years ago
parent
commit
0bbb55b5cd
  1. 235
      ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts
  2. 35
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts
  3. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts
  4. 2
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts
  5. 124
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss
  6. 88
      ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts
  7. 2
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  8. 44
      ui-ngx/src/typings/leaflet-extend-tb.d.ts

235
ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts

@ -0,0 +1,235 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import L, { TB } from 'leaflet';
class SidebarControl extends L.Control<TB.SidebarControlOptions> {
private readonly sidebar: JQuery<HTMLElement>;
private current = $();
private currentButton = $();
private map: L.Map;
constructor(options: TB.SidebarControlOptions) {
super(options);
this.sidebar = $('<div class="tb-map-sidebar"></div>');
this.options.container.append(this.sidebar);
const position = options?.position || 'topleft';
if (['topleft', 'bottomleft'].includes(position)) {
this.options.container.addClass('tb-sidebar-left');
} else {
this.options.container.addClass('tb-sidebar-right');
}
}
addPane(pane: JQuery<HTMLElement>): this {
pane.hide().appendTo(this.sidebar);
return this;
}
togglePane(pane: JQuery<HTMLElement>, button: JQuery<HTMLElement>) {
const paneWidth = this.options?.paneWidth || 220;
const position = this.options?.position || 'topleft';
this.current.hide().trigger('hide');
this.currentButton.removeClass('active');
if (this.current === pane) {
if (['topleft', 'bottomleft'].includes(position)) {
this.map.panBy([-paneWidth, 0], { animate: false });
}
this.sidebar.hide();
this.current = this.currentButton = $();
} else {
this.sidebar.show();
this.current = pane;
this.currentButton = button || $();
if (['topleft', 'bottomleft'].includes(position)) {
this.map.panBy([paneWidth, 0], { animate: false });
}
}
this.map.invalidateSize({ pan: false, animate: false });
this.current.show().trigger('show');
this.currentButton.addClass('active');
}
addTo(map: L.Map): this {
this.map = map;
return this;
}
}
class SidebarPaneControl<O extends TB.SidebarPaneControlOptions> extends L.Control<O> {
private button: JQuery<HTMLElement>;
private $ui: JQuery<HTMLElement>;
constructor(options: O) {
super(options);
}
onAdd(map: L.Map): HTMLElement {
const $container = $("<div>")
.attr('class', 'leaflet-bar');
this.button = $("<a>")
.attr('class', 'tb-control-button')
.attr('href', '#')
.html('<div class="' + this.options.uiClass + '"></div>')
.on('click', (e) => {
this.toggle(e);
});
if (this.options.buttonTitle) {
this.button.attr('title', this.options.buttonTitle);
}
this.button.appendTo($container);
this.$ui = $('<div>')
.attr('class', this.options.uiClass);
$('<div class="tb-layers-title-container">')
.appendTo(this.$ui)
.append($('<div class="tb-layers-title">')
.text(this.options.paneTitle))
.append($('<div>')
.append($('<button type="button" class="tb-button-close mdc-icon-button mat-mdc-icon-button">' +
'<span class="mat-mdc-button-persistent-ripple mdc-icon-button__ripple"></span>' +
'<span class="material-icons">close</span>' +
'</button>')
.attr('aria-label', 'Close')
.bind('click', (e) => {
this.toggle(e);
})));
this.options.sidebar.addPane(this.$ui);
this.onAddPane(map, this.button, this.$ui, (e) => {
this.toggle(e);
});
return $container[0];
}
public onAddPane(map: L.Map, button: JQuery<HTMLElement>, $ui: JQuery<HTMLElement>, toggle: (e: JQuery.MouseEventBase) => void) {}
private toggle(e: JQuery.MouseEventBase) {
e.stopPropagation();
e.preventDefault();
if (!this.button.hasClass("disabled")) {
this.options.sidebar.togglePane(this.$ui, this.button);
}
//$(".leaflet-control .control-button").tooltip("hide");
}
}
class LayersControl extends SidebarPaneControl<TB.LayersControlOptions> {
constructor(options: TB.LayersControlOptions) {
super(options);
}
public onAddPane(map: L.Map, button: JQuery<HTMLElement>, $ui: JQuery<HTMLElement>, toggle: (e: JQuery.MouseEventBase) => void) {
const layers = this.options.layers;
const baseSection = $("<div>")
.attr('class', 'tb-layers-container')
.appendTo($ui);
layers.forEach((layerData, i) => {
const id = 'map-ui-layer-' + i;
const buttonContainer = $('<div class="tb-layer-card">')
.appendTo(baseSection);
const mapContainer = $('<div class="tb-layer-map">')
.appendTo(buttonContainer);
const input = $('<input type="radio" class="tb-layer-button" name="layer">')
.prop('id', id)
.prop('checked', map.hasLayer(layerData.layer))
.appendTo(buttonContainer);
const item = $('<label class="tb-layer-label">')
.prop('for', id)
.append($('<span>').append(layerData.title))
.appendTo(buttonContainer);
map.whenReady(() => {
const miniMap = L.map(mapContainer[0], { attributionControl: false, zoomControl: false, keyboard: false })
.addLayer(layerData.mini);
miniMap.dragging.disable();
miniMap.touchZoom.disable();
miniMap.doubleClickZoom.disable();
miniMap.scrollWheelZoom.disable();
const moved = () => {
miniMap.setView(map.getCenter(), Math.max(map.getZoom() - 2, 0));
};
const shown = () => {
miniMap.invalidateSize();
miniMap.setView(map.getCenter(), Math.max(map.getZoom() - 2, 0), { animate: false });
map.on('moveend', moved);
};
$ui.on('show', shown);
$ui.on('hide', () => {
map.off('moveend', moved);
})
});
input.on('click', () => {
layers.forEach((other) => {
if (other.layer === layerData.layer) {
map.addLayer(other.layer);
} else {
map.removeLayer(other.layer);
}
});
map.fire('baselayerchange', { layer: layerData.layer });
});
item.on('dblclick', (e) => {
toggle(e);
});
map.on('layeradd layerremove', function () {
input.prop('checked', map.hasLayer(layerData.layer));
});
});
}
}
const sidebar = (options: TB.SidebarControlOptions): SidebarControl => {
return new SidebarControl(options);
}
const sidebarPane = <O extends TB.SidebarPaneControlOptions>(options: O): SidebarPaneControl<O> => {
return new SidebarPaneControl(options);
}
const layers = (options: TB.LayersControlOptions): LayersControl => {
return new LayersControl(options);
}
L.TB = L.TB || {
SidebarControl,
SidebarPaneControl,
LayersControl,
sidebar,
sidebarPane,
layers
}

35
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts

@ -1,3 +1,19 @@
///
/// Copyright © 2016-2024 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import {
defaultOpenStreetMapLayerSettings,
MapLayerSettings,
@ -7,7 +23,7 @@ import {
import { WidgetContext } from '@home/models/widget-component.models';
import { DeepPartial } from '@shared/models/common';
import { mergeDeep } from '@core/utils';
import { Observable, of } from 'rxjs';
import { Observable, of, switchMap } from 'rxjs';
import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe';
import L from 'leaflet';
import { map } from 'rxjs/operators';
@ -53,13 +69,18 @@ export abstract class TbMapLayer<S extends MapLayerSettings> {
protected abstract createLayer(): Observable<L.Layer>;
public loadLayer(): Observable<{title: string, layer: L.Layer}> {
public loadLayer(): Observable<L.TB.LayerData> {
return this.createLayer().pipe(
map((layer) => {
return {
title: this.title(),
layer
};
switchMap((layer) => {
return this.createLayer().pipe(
map((mini) => {
return {
title: this.title(),
layer,
mini
};
})
);
})
);
}

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts

@ -34,5 +34,5 @@ export const mapWidgetDefaultSettings: MapWidgetSettings =
blur: 3
}
},
padding: '12px'
padding: '6px'
} as MapWidgetSettings);

2
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts

@ -123,7 +123,7 @@ export const defaultBaseMapSettings: BaseMapSettings = {
polygons: [],
circles: [],
additionalDataSources: [],
controlsPosition: MapControlsPosition.topleft,
controlsPosition: MapControlsPosition.bottomright,
zoomActions: [MapZoomAction.scroll, MapZoomAction.doubleClick, MapZoomAction.controlButtons],
fitMapBounds: true,
useDefaultCenterPosition: false,

124
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss

@ -1,3 +1,20 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../../../scss/constants';
.tb-map-layout {
display: flex;
width: 100%;
@ -13,7 +30,114 @@
}
.tb-map {
flex: 1;
.leaflet-control {
.tb-control-button {
&.active {
box-shadow: inset 0 -1px 5px 2px #514d4d4f;
}
> div {
width: 30px;
height: 30px;
color: #5B5B5B;
line-height: 30px;
background-repeat: no-repeat;
background-position: center;
&.tb-layers {
background-image: url('data:image/svg+xml,<svg width="20" height="20" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9 9.5L0.75 5L9 0.5L17.25 5L9 9.5ZM9 12.5L1.18125 8.24375L2.75625 7.38125L9 10.7938L15.2438 7.38125L16.8188 8.24375L9 12.5ZM9 15.5L1.18125 11.2438L2.75625 10.3813L9 13.7938L15.2438 10.3813L16.8188 11.2438L9 15.5Z" fill="%235B5B5B"/></svg>');
}
}
}
}
}
.tb-map-sidebar {
.tb-layers {
height: 100%;
width: 220px;
max-width: 220px;
display: flex;
flex-direction: column;
.tb-layers-title-container {
display: flex;
padding: 4px 4px 4px 16px;
justify-content: space-between;
align-items: center;
align-self: stretch;
.tb-layers-title {
color: rgba(0,0,0,0.87);
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
button.tb-button-close {
width: 40px;
min-width: 40px;
height: 40px;
padding: 8px;
color: rgba(0, 0, 0, 0.54);
}
}
.tb-layers-container {
display: flex;
flex: 1;
padding: 4px 16px 16px 16px;
flex-direction: column;
align-items: flex-start;
gap: 12px;
align-self: stretch;
overflow: auto;
.tb-layer-card {
position: relative;
height: 64px;
min-height: 64px;
align-self: stretch;
.tb-layer-map {
position: absolute;
inset: 2px;
border-radius: 2px;
z-index: 0;
}
input.tb-layer-button {
position: absolute;
left: 0;
bottom: 0;
clip: rect(0, 0, 0, 0);
pointer-events: none;
&:checked+label.tb-layer-label {
border: 3px solid $tb-primary-color;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.30), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
margin: 0;
}
}
label.tb-layer-label {
position: absolute;
inset: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid rgba(0, 0, 0, 0.12);
margin: 2px;
&:hover {
border: 3px solid $tb-primary-color;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.30), 0 2px 6px 2px rgba(0, 0, 0, 0.15);
margin: 0;
}
> span {
position: absolute;
left: 0;
bottom: 0;
padding: 0 3px;
border-radius: 0 2px 0 0;
background: rgba(0, 0, 0, 0.56);
color: #fff;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
user-select: none;
}
}
}
}
}
}
}

88
ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts

@ -32,7 +32,8 @@ import { DeepPartial } from '@shared/models/common';
import L from 'leaflet';
import { forkJoin, Observable, of } from 'rxjs';
import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer';
import { map } from 'rxjs/operators';
import { switchMap, tap } from 'rxjs/operators';
import '@home/components/widget/lib/maps/leaflet/leaflet-tb';
export abstract class TbMap<S extends BaseMapSettings> {
@ -52,10 +53,10 @@ export abstract class TbMap<S extends BaseMapSettings> {
protected defaultCenterPosition: [number, number];
protected bounds: L.LatLngBounds;
protected layerControl: L.Control.Layers;
protected mapElement: HTMLElement;
protected sidebarElement: HTMLElement;
private readonly mapResize$: ResizeObserver;
protected constructor(protected ctx: WidgetContext,
protected inputSettings: DeepPartial<S>,
@ -63,34 +64,35 @@ export abstract class TbMap<S extends BaseMapSettings> {
this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S);
$(containerElement).empty();
$(containerElement).addClass('tb-map-layout');
if (this.settings.controlsPosition.endsWith('left')) {
$(containerElement).addClass('tb-sidebar-left');
} else {
$(containerElement).addClass('tb-sidebar-right');
}
const mapElement = $('<div class="tb-map"></div>');
const sidebarElement = $('<div class="tb-map-sidebar"></div>');
$(containerElement).append(mapElement);
$(containerElement).append(sidebarElement);
this.mapResize$ = new ResizeObserver(() => {
this.resize();
});
this.mapResize$.observe(this.containerElement);
this.mapElement = mapElement[0];
this.sidebarElement = sidebarElement[0];
this.defaultCenterPosition = parseCenterPosition(this.settings.defaultCenterPosition);
this.layerControl = L.control.layers({}, {}, {position: this.settings.controlsPosition, collapsed: true});
this.createMap().subscribe((map) => {
this.map = map;
this.createMap().pipe(
switchMap((map) => {
this.map = map;
return this.setupControls();
})
).subscribe(() => {
this.initMap();
});
L.TB = {
sidebar: (s) => { return null;}
};
}
private initMap() {
private setupControls(): Observable<any> {
this.map.zoomControl.setPosition(this.settings.controlsPosition);
this.layerControl.addTo(this.map);
return this.doSetupControls();
}
private initMap() {
this.map.on('move', () => {
this.ctx.updatePopoverPositions();
});
@ -112,11 +114,25 @@ export abstract class TbMap<S extends BaseMapSettings> {
}
}
private resize() {
this.onResize();
this.map?.invalidateSize();
}
protected abstract defaultSettings(): S;
protected abstract createMap(): Observable<L.Map>;
protected abstract onResize(): void;
protected doSetupControls(): Observable<any> {
return of(null);
}
public destroy() {
if (this.mapResize$) {
this.mapResize$.disconnect();
}
if (this.map) {
this.map.remove();
}
@ -137,28 +153,44 @@ class TbGeoMap extends TbMap<GeoMapSettings> {
}
protected createMap(): Observable<L.Map> {
const theMap = L.map(this.mapElement, {
const map = L.map(this.mapElement, {
scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll),
doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick),
zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons)
}).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL);
return of(map);
}
protected onResize(): void {}
protected doSetupControls(): Observable<any> {
return this.loadLayers().pipe(
map((layers) => {
tap((layers) => {
if (layers.length) {
const layer = layers[0];
layer.layer.addTo(theMap);
layer.layer.addTo(this.map);
if (layers.length > 1) {
layers.forEach(l => {
this.layerControl.addBaseLayer(l.layer, l.title);
});
const sidebar = L.TB.sidebar({
container: $(this.containerElement),
position: this.settings.controlsPosition,
paneWidth: 220
}).addTo(this.map);
L.TB.layers({
layers,
sidebar,
position: this.settings.controlsPosition,
uiClass: 'tb-layers',
paneTitle: this.ctx.translate.instant('widgets.maps.map-layers'),
buttonTitle: this.ctx.translate.instant('widgets.maps.layers'),
}).addTo(this.map);
}
}
return theMap;
})
);
}
private loadLayers(): Observable<{title: string, layer: L.Layer}[]> {
private loadLayers(): Observable<L.TB.LayerData[]> {
const layers = this.settings.layers.map(settings => TbMapLayer.fromSettings(this.ctx, settings));
return forkJoin(layers.map(layer => layer.loadLayer()));
}
@ -193,4 +225,6 @@ class TbImageMap extends TbMap<ImageMapSettings> {
}).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL);
return of(map);
}
protected onResize(): void {}
}

2
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -6802,6 +6802,8 @@
"max-value": "Maximum value"
},
"maps": {
"layers": "Layers",
"map-layers": "Map layers",
"select-entity": "Select entity",
"select-entity-hint": "Hint: after selection click at the map to set position",
"tooltips": {

44
ui-ngx/src/typings/leaflet-extend-tb.d.ts

@ -22,6 +22,48 @@ declare module 'leaflet' {
tbMarkerData?: FormattedData;
}
namespace TB {
function sidebar(selector: string): Control;
interface SidebarControlOptions extends ControlOptions {
container: JQuery<HTMLElement>;
paneWidth?: number;
}
class SidebarControl extends Control<SidebarControlOptions> {
constructor(options: SidebarControlOptions);
addPane(pane: JQuery<HTMLElement>): this;
togglePane(pane: JQuery<HTMLElement>, button: JQuery<HTMLElement>): void;
}
interface SidebarPaneControlOptions extends ControlOptions {
sidebar: SidebarControl;
uiClass: string;
buttonTitle?: string;
paneTitle: string;
}
class SidebarPaneControl<O extends SidebarPaneControlOptions> extends Control<O> {
constructor(options: O);
onAddPane(map: Map, button: JQuery<HTMLElement>, $ui: JQuery<HTMLElement>, toggle: (e: JQuery.MouseEventBase) => void);
}
interface LayerData {
title: string;
layer: Layer;
mini: Layer;
}
interface LayersControlOptions extends SidebarPaneControlOptions {
layers: LayerData[];
}
class LayersControl extends SidebarPaneControl<LayersControlOptions> {
constructor(options: LayersControlOptions);
}
function sidebar(options: SidebarControlOptions): SidebarControl;
function sidebarPane<O extends SidebarPaneControlOptions>(options: O): SidebarPaneControl<O>;
function layers(options: LayersControlOptions): LayersControl;
}
}

Loading…
Cancel
Save