Browse Source

UI: Implement image service. Implement image load pipe.

pull/9542/head
Igor Kulikov 3 years ago
parent
commit
3c40cd8ff1
  1. 139
      ui-ngx/src/app/core/http/image.service.ts
  2. 9
      ui-ngx/src/app/core/utils.ts
  3. 4
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html
  4. 13
      ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts
  5. 82
      ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts
  6. 27
      ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts
  7. 58
      ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts
  8. 2
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html
  9. 10
      ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts
  10. 29
      ui-ngx/src/app/shared/models/resource.models.ts
  11. 52
      ui-ngx/src/app/shared/pipe/image.pipe.ts
  12. 1
      ui-ngx/src/app/shared/pipe/public-api.ts
  13. 4
      ui-ngx/src/app/shared/shared.module.ts

139
ui-ngx/src/app/core/http/image.service.ts

@ -0,0 +1,139 @@
///
/// Copyright © 2016-2023 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 { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { PageLink } from '@shared/models/page/page-link';
import { defaultHttpOptionsFromConfig, defaultHttpUploadOptions, RequestConfig } from '@core/http/http-utils';
import { Observable, of } from 'rxjs';
import { PageData } from '@shared/models/page/page-data';
import {
NO_IMAGE_DATA_URI,
ImageResourceInfo,
imageResourceType,
ImageResourceType,
IMAGES_URL_PREFIX, isImageResourceUrl
} from '@shared/models/resource.models';
import { catchError, map, switchMap } from 'rxjs/operators';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { blobToBase64 } from '@core/utils';
@Injectable({
providedIn: 'root'
})
export class ImageService {
constructor(
private http: HttpClient,
private sanitizer: DomSanitizer
) {
}
public uploadImage(file: File, config?: RequestConfig): Observable<ImageResourceInfo> {
if (!config) {
config = {};
}
const formData = new FormData();
formData.append('file', file);
return this.http.post<ImageResourceInfo>('/api/image', formData,
defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest));
}
public updateImage(type: ImageResourceType, key: string, file: File, config?: RequestConfig): Observable<ImageResourceInfo> {
if (!config) {
config = {};
}
const formData = new FormData();
formData.append('file', file);
return this.http.post<ImageResourceInfo>(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, formData,
defaultHttpUploadOptions(config.ignoreLoading, config.ignoreErrors, config.resendRequest));
}
public updateImageInfo(imageInfo: ImageResourceInfo, config?: RequestConfig): Observable<ImageResourceInfo> {
const type = imageResourceType(imageInfo);
const key = encodeURIComponent(imageInfo.resourceKey);
return this.http.put<ImageResourceInfo>(`${IMAGES_URL_PREFIX}/${type}/${key}/info`,
imageInfo, defaultHttpOptionsFromConfig(config));
}
public getImages(pageLink: PageLink, config?: RequestConfig): Observable<PageData<ImageResourceInfo>> {
return this.http.get<PageData<ImageResourceInfo>>(`${IMAGES_URL_PREFIX}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config));
}
public getImageInfo(type: ImageResourceType, key: string, config?: RequestConfig): Observable<ImageResourceInfo> {
return this.http.get<ImageResourceInfo>(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}/info`,
defaultHttpOptionsFromConfig(config));
}
public getImageDataUrl(imageUrl: string, preview = false, asString = false): Observable<SafeUrl | string> {
const parts = imageUrl.split('/');
const key = parts[parts.length - 1];
parts[parts.length - 1] = encodeURIComponent(key);
const encodedUrl = parts.join('/');
const imageLink = preview ? (encodedUrl + '/preview') : encodedUrl;
const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true});
return this.http
.get(imageLink, {...options, ...{ responseType: 'blob' } }).pipe(
switchMap(val => blobToBase64(val).pipe(
map((dataUrl) => asString ? dataUrl : this.sanitizer.bypassSecurityTrustUrl(dataUrl))
)),
catchError(() => of(asString ? NO_IMAGE_DATA_URI : this.sanitizer.bypassSecurityTrustUrl(NO_IMAGE_DATA_URI)))
);
}
public resolveImageUrl(imageUrl: string, preview = false, asString = false): Observable<SafeUrl | string> {
if (isImageResourceUrl(imageUrl)) {
return this.getImageDataUrl(imageUrl, preview, asString);
} else {
return of(asString ? imageUrl : this.sanitizer.bypassSecurityTrustUrl(imageUrl));
}
}
public downloadImage(type: ImageResourceType, key: string): Observable<any> {
return this.http.get(`${IMAGES_URL_PREFIX}/${type}/${encodeURIComponent(key)}`, {
responseType: 'arraybuffer',
observe: 'response'
}).pipe(
map((response) => {
const headers = response.headers;
const filename = headers.get('x-filename');
const contentType = headers.get('content-type');
const linkElement = document.createElement('a');
try {
const blob = new Blob([response.body], {type: contentType});
const url = URL.createObjectURL(blob);
linkElement.setAttribute('href', url);
linkElement.setAttribute('download', filename);
const clickEvent = new MouseEvent('click',
{
view: window,
bubbles: true,
cancelable: false
}
);
linkElement.dispatchEvent(clickEvent);
return null;
} catch (e) {
throw e;
}
})
);
}
public deleteImage(type: ImageResourceType, key: string, config?: RequestConfig) {
return this.http.delete(`${IMAGES_URL_PREFIX}${type}/${encodeURIComponent}`, defaultHttpOptionsFromConfig(config));
}
}

9
ui-ngx/src/app/core/utils.ts

@ -15,7 +15,7 @@
/// ///
import _ from 'lodash'; import _ from 'lodash';
import { Observable, Subject } from 'rxjs'; import { from, Observable, Subject } from 'rxjs';
import { finalize, share } from 'rxjs/operators'; import { finalize, share } from 'rxjs/operators';
import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models';
import { EntityId } from '@shared/models/id/entity-id'; import { EntityId } from '@shared/models/id/entity-id';
@ -209,6 +209,13 @@ export function stringToBase64(value: string): string {
})); }));
} }
export const blobToBase64 = (blob: Blob): Observable<string> => from(new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
}
));
const scrollRegex = /(auto|scroll)/; const scrollRegex = /(auto|scroll)/;
function parentNodes(node: Node, nodes: Node[]): Node[] { function parentNodes(node: Node, nodes: Node[]): Node[] {

4
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.html

@ -51,7 +51,7 @@
</div> </div>
<div class="preview-container"> <div class="preview-container">
<div class="preview-spacer"></div> <div class="preview-spacer"></div>
<img class="preview" [src]="getPreviewImage(item.image)" alt="{{ item.title }}"> <img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ item.title }}">
</div> </div>
</mat-card> </mat-card>
</ng-template> </ng-template>
@ -95,7 +95,7 @@
</div> </div>
<div class="preview-container"> <div class="preview-container">
<div class="preview-spacer"></div> <div class="preview-spacer"></div>
<img class="preview" [src]=getPreviewImage(item.image) alt="{{ item.title }}"> <img class="preview" [src]="item.image | image: {emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ item.title }}">
</div> </div>
</mat-card> </mat-card>
</ng-template> </ng-template>

13
ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts

@ -28,8 +28,7 @@ import {
} from '@shared/models/widget.models'; } from '@shared/models/widget.models';
import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, map, skip } from 'rxjs/operators';
import { BehaviorSubject, combineLatest } from 'rxjs'; import { BehaviorSubject, combineLatest } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { isObject } from '@core/utils';
import { isDefinedAndNotNull, isObject } from '@core/utils';
import { PageLink } from '@shared/models/page/page-link'; import { PageLink } from '@shared/models/page/page-link';
import { Direction } from '@shared/models/page/sort-order'; import { Direction } from '@shared/models/page/sort-order';
import { GridEntitiesFetchFunction, ScrollGridColumns } from '@home/models/datasource/scroll-grid-datasource'; import { GridEntitiesFetchFunction, ScrollGridColumns } from '@home/models/datasource/scroll-grid-datasource';
@ -158,8 +157,7 @@ export class DashboardWidgetSelectComponent implements OnInit {
widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null}; widgetsFilter: BundleWidgetsFilter = {search: '', filter: null, deprecatedFilter: DeprecatedFilter.ACTUAL, widgetsBundleId: null};
constructor(private widgetsService: WidgetService, constructor(private widgetsService: WidgetService,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef) {
private sanitizer: DomSanitizer) {
this.widgetBundlesFetchFunction = (pageSize, page, filter) => { this.widgetBundlesFetchFunction = (pageSize, page, filter) => {
const pageLink = new PageLink(pageSize, page, filter, { const pageLink = new PageLink(pageSize, page, filter, {
@ -241,13 +239,6 @@ export class DashboardWidgetSelectComponent implements OnInit {
} }
} }
getPreviewImage(imageUrl: string | null): SafeUrl | string {
if (isDefinedAndNotNull(imageUrl)) {
return this.sanitizer.bypassSecurityTrustUrl(imageUrl);
}
return '/assets/widget-preview-empty.svg';
}
isObject(value: any): boolean { isObject(value: any): boolean {
return isObject(value); return isObject(value);
} }

82
ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts

@ -25,10 +25,11 @@ import {
isUndefined, isUndefined,
padValue padValue
} from '@core/utils'; } from '@core/utils';
import { Observable, Observer, of } from 'rxjs'; import { Observable, Observer, of, switchMap } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { FormattedData } from '@shared/models/widget.models'; import { FormattedData } from '@shared/models/widget.models';
import L from 'leaflet'; import L from 'leaflet';
import { ImagePipe } from '@shared/pipe/image.pipe';
export function getRatio(firsMoment: number, secondMoment: number, intermediateMoment: number): number { export function getRatio(firsMoment: number, secondMoment: number, intermediateMoment: number): number {
return (intermediateMoment - firsMoment) / (secondMoment - firsMoment); return (intermediateMoment - firsMoment) / (secondMoment - firsMoment);
@ -71,44 +72,59 @@ export function getDefCenterPosition(position): [number, number] {
} }
const imageAspectMap = {}; const imageAspectMap: {[key: string]: ImageWithAspect} = {};
function imageLoader(imageUrl: string): Observable<HTMLImageElement> { const imageLoader = (imageUrl: string): Observable<HTMLImageElement> => new Observable((observer: Observer<HTMLImageElement>) => {
return new Observable((observer: Observer<HTMLImageElement>) => { const image = document.createElement('img'); // support IE
const image = document.createElement('img'); // support IE image.style.position = 'absolute';
image.style.position = 'absolute'; image.style.left = '-99999px';
image.style.left = '-99999px'; image.style.top = '-99999px';
image.style.top = '-99999px'; image.onload = () => {
image.onload = () => { observer.next(image);
observer.next(image); document.body.removeChild(image);
document.body.removeChild(image); observer.complete();
observer.complete(); };
}; image.onerror = err => {
image.onerror = err => { observer.error(err);
observer.error(err); document.body.removeChild(image);
document.body.removeChild(image); observer.complete();
observer.complete(); };
}; document.body.appendChild(image);
document.body.appendChild(image); image.src = imageUrl;
image.src = imageUrl; });
});
const loadImageAspect = (imageUrl: string): Observable<number> =>
imageLoader(imageUrl).pipe(map(image => image.width / image.height));
export interface ImageWithAspect {
url: string;
aspect: number;
} }
export function aspectCache(imageUrl: string): Observable<number> { export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Observable<ImageWithAspect> => {
if (imageUrl?.length) { if (imageUrl?.length) {
const hash = hashCode(imageUrl); const hash = hashCode(imageUrl);
let aspect = imageAspectMap[hash]; let imageWithAspect = imageAspectMap[hash];
if (aspect) { if (imageWithAspect) {
return of(aspect); return of(imageWithAspect);
} else {
return imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).pipe(
switchMap((res) => {
const url = res as string;
return loadImageAspect(url).pipe(
map((aspect) => {
imageWithAspect = {url, aspect};
imageAspectMap[hash] = imageWithAspect;
return imageWithAspect;
})
);
})
);
} }
return imageLoader(imageUrl).pipe(map(image => { } else {
aspect = image.width / image.height; return of(null);
imageAspectMap[hash] = aspect;
return aspect;
}));
} }
return of(0); };
}
export type TranslateFunc = (key: string, defaultTranslation?: string) => string; export type TranslateFunc = (key: string, defaultTranslation?: string) => string;

27
ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts

@ -17,11 +17,12 @@
import L, { LeafletMouseEvent } from 'leaflet'; import L, { LeafletMouseEvent } from 'leaflet';
import { MarkerIconInfo, MarkerIconReadyFunction, MarkerImageInfo, WidgetMarkersSettings, } from './map-models'; import { MarkerIconInfo, MarkerIconReadyFunction, MarkerImageInfo, WidgetMarkersSettings, } from './map-models';
import { bindPopupActions, createTooltip } from './maps-utils'; import { bindPopupActions, createTooltip } from './maps-utils';
import { aspectCache, parseWithTranslation } from './common-maps-utils'; import { loadImageWithAspect, parseWithTranslation } from './common-maps-utils';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { fillDataPattern, isDefined, isDefinedAndNotNull, processDataPattern, safeExecute } from '@core/utils'; import { fillDataPattern, isDefined, isDefinedAndNotNull, processDataPattern, safeExecute } from '@core/utils';
import LeafletMap from './leaflet-map'; import LeafletMap from './leaflet-map';
import { FormattedData } from '@shared/models/widget.models'; import { FormattedData } from '@shared/models/widget.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
export class Marker { export class Marker {
@ -167,16 +168,16 @@ export class Marker {
} }
} }
if (currentImage && currentImage.url) { if (currentImage && currentImage.url) {
aspectCache(currentImage.url).subscribe( loadImageWithAspect(this.map.ctx.$injector.get(ImagePipe), currentImage.url).subscribe(
(aspect) => { (aspectImage) => {
if (aspect) { if (aspectImage?.aspect) {
let width; let width;
let height; let height;
if (aspect > 1) { if (aspectImage.aspect > 1) {
width = currentImage.size; width = currentImage.size;
height = currentImage.size / aspect; height = currentImage.size / aspectImage.aspect;
} else { } else {
width = currentImage.size * aspect; width = currentImage.size * aspectImage.aspect;
height = currentImage.size; height = currentImage.size;
} }
let iconAnchor = currentImage.markerOffset; let iconAnchor = currentImage.markerOffset;
@ -188,7 +189,7 @@ export class Marker {
popupAnchor = [width * this.tooltipOffset[0], height * this.tooltipOffset[1]]; popupAnchor = [width * this.tooltipOffset[0], height * this.tooltipOffset[1]];
} }
const icon = L.icon({ const icon = L.icon({
iconUrl: currentImage.url, iconUrl: aspectImage.url,
iconSize: [width, height], iconSize: [width, height],
iconAnchor, iconAnchor,
popupAnchor popupAnchor
@ -237,7 +238,15 @@ export class Marker {
} }
createColorIconURI(color: tinycolor.Instance): string { createColorIconURI(color: tinycolor.Instance): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-191.35 -351.18 1083.58 1730.46"><path fill-rule="evenodd" clip-rule="evenodd" fill="#${color.toHex()}" stroke="#000" stroke-width="37" stroke-miterlimit="10" d="M351.833 1360.78c-38.766-190.3-107.116-348.665-189.903-495.44C100.523 756.469 29.386 655.978-36.434 550.404c-21.972-35.244-40.934-72.477-62.047-109.054-42.216-73.137-76.444-157.935-74.269-267.932 2.125-107.473 33.208-193.685 78.03-264.173C-21-206.69 102.481-301.745 268.164-326.724c135.466-20.425 262.475 14.082 352.543 66.747 73.6 43.038 130.596 100.528 173.92 168.28 45.22 70.716 76.36 154.26 78.971 263.233 1.337 55.83-7.805 107.532-20.684 150.417-13.034 43.41-33.996 79.695-52.646 118.455-36.406 75.659-82.049 144.981-127.855 214.345-136.437 206.606-264.496 417.31-320.58 706.028z"/><circle fill-rule="evenodd" clip-rule="evenodd" cx="352.891" cy="225.779" r="183.332"/></svg>`; const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-191.35 -351.18 1083.58 1730.46">` +
`<path fill-rule="evenodd" clip-rule="evenodd" fill="#${color.toHex()}" stroke="#000" stroke-width="37" ` +
`stroke-miterlimit="10" d="M351.833 1360.78c-38.766-190.3-107.116-348.665-189.903-495.44C100.523 756.469 ` +
`29.386 655.978-36.434 550.404c-21.972-35.244-40.934-72.477-62.047-109.054-42.216-73.137-76.444-157.935-74.269-267.932 ` +
`2.125-107.473 33.208-193.685 78.03-264.173C-21-206.69 102.481-301.745 268.164-326.724c135.466-20.425 262.475 14.082 ` +
`352.543 66.747 73.6 43.038 130.596 100.528 173.92 168.28 45.22 70.716 76.36 154.26 78.971 263.233 1.337 55.83-7.805 ` +
`107.532-20.684 150.417-13.034 43.41-33.996 79.695-52.646 118.455-36.406 75.659-82.049 144.981-127.855 214.345-136.437 ` +
`206.606-264.496 417.31-320.58 706.028z"/><circle fill-rule="evenodd" ` +
`clip-rule="evenodd" cx="352.891" cy="225.779" r="183.332"/></svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg); return 'data:image/svg+xml;base64,' + btoa(svg);
} }

58
ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts

@ -23,11 +23,10 @@ import {
PosFunction, PosFunction,
WidgetUnitedMapSettings WidgetUnitedMapSettings
} from '../map-models'; } from '../map-models';
import { Observable, ReplaySubject } from 'rxjs'; import { Observable, of, ReplaySubject, switchMap } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators'; import { catchError, map, mergeMap } from 'rxjs/operators';
import { import {
aspectCache, calculateNewPointCoordinate, loadImageWithAspect
calculateNewPointCoordinate
} from '@home/components/widget/lib/maps/common-maps-utils'; } from '@home/components/widget/lib/maps/common-maps-utils';
import { WidgetContext } from '@home/models/widget-component.models'; import { WidgetContext } from '@home/models/widget-component.models';
import { DataSet, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; import { DataSet, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models';
@ -35,6 +34,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { WidgetSubscriptionOptions } from '@core/api/widget-api.models';
import { isDefinedAndNotNull, isEmptyStr, isNotEmptyStr, parseFunction } from '@core/utils'; import { isDefinedAndNotNull, isEmptyStr, isNotEmptyStr, parseFunction } from '@core/utils';
import { EntityDataPageLink } from '@shared/models/query/query.models'; import { EntityDataPageLink } from '@shared/models/query/query.models';
import { ImagePipe } from '@shared/pipe/image.pipe';
const maxZoom = 4; // ? const maxZoom = 4; // ?
@ -122,39 +122,43 @@ export class ImageMap extends LeafletMap {
} }
private imageFromUrl(url: string): Observable<MapImage> { private imageFromUrl(url: string): Observable<MapImage> {
return aspectCache(url).pipe( return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe(
map( aspect => { switchMap( aspectImage => {
const mapImage: MapImage = { if (aspectImage) {
imageUrl: url, return of({
aspect, imageUrl: aspectImage.url,
update: false aspect: aspectImage.aspect,
}; update: false
return mapImage; });
} else {
return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl);
}
} }
), ),
catchError((e) => { catchError((e) => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl))
return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl);
})
); );
} }
private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable<MapImage> { private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable<MapImage> {
return alias.pipe( return alias.pipe(
mergeMap(res => { switchMap(res => {
const url = res[0][0][1];
const mapImage: MapImage = { const mapImage: MapImage = {
imageUrl: res[0][0][1], imageUrl: null,
aspect: null, aspect: null,
update: res[1] update: res[1]
}; };
return aspectCache(mapImage.imageUrl).pipe( return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe(
map((aspect) => { switchMap((aspectImage) => {
mapImage.aspect = aspect; if (aspectImage) {
return mapImage; mapImage.aspect = aspectImage.aspect;
} mapImage.imageUrl = aspectImage.url;
), return of(mapImage);
catchError((e) => { } else {
return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl);
}) }
}),
catchError((e) => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl))
); );
}) })
); );
@ -249,7 +253,7 @@ export class ImageMap extends LeafletMap {
} }
} }
extractPosition(data: FormattedData): {x: number, y: number} { extractPosition(data: FormattedData): {x: number; y: number} {
if (!data) { if (!data) {
return null; return null;
} }
@ -261,7 +265,7 @@ export class ImageMap extends LeafletMap {
return {x: xPos, y: yPos}; return {x: xPos, y: yPos};
} }
positionToLatLng(position: {x: number, y: number}): L.LatLng { positionToLatLng(position: {x: number; y: number}): L.LatLng {
return this.pointToLatLng( return this.pointToLatLng(
position.x * this.width, position.x * this.width,
position.y * this.height); position.y * this.height);

2
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html

@ -62,7 +62,7 @@
<div cdkDrag class="tb-draggable tb-bundle-widget-row" *ngFor="let widget of widgets; trackBy: trackByWidget; <div cdkDrag class="tb-draggable tb-bundle-widget-row" *ngFor="let widget of widgets; trackBy: trackByWidget;
let $index = index; last as isLast;"> let $index = index; last as isLast;">
<div class="tb-bundle-widget-row-details"> <div class="tb-bundle-widget-row-details">
<img class="tb-bundle-widget-image-preview" [src]="getPreviewImage(widget.image)" alt="{{ widget.name }}"> <img class="tb-bundle-widget-image-preview" [src]="widget.image | image: {preview: true, emptyUrl: '/assets/widget-preview-empty.svg'} | async" alt="{{ widget.name }}">
<div class="tb-bundle-widget-details"> <div class="tb-bundle-widget-details">
<div class="tb-bundle-widget-title">{{ widget.name }}</div> <div class="tb-bundle-widget-title">{{ widget.name }}</div>
<div *ngIf="widget.deprecated" class="tb-bundle-widget-deprecated">{{ 'widget.deprecated' | translate }}</div> <div *ngIf="widget.deprecated" class="tb-bundle-widget-deprecated">{{ 'widget.deprecated' | translate }}</div>

10
ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts

@ -28,8 +28,6 @@ import { widgetType as WidgetDataType, WidgetTypeInfo } from '@shared/models/wid
import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { ImportExportService } from '@home/components/import-export/import-export.service'; import { ImportExportService } from '@home/components/import-export/import-export.service';
import { WidgetService } from '@core/http/widget.service'; import { WidgetService } from '@core/http/widget.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { isDefinedAndNotNull } from '@core/utils';
import { FormControl, Validators } from '@angular/forms'; import { FormControl, Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component'; import { SelectWidgetTypeDialogComponent } from '@home/pages/widget/select-widget-type-dialog.component';
@ -61,7 +59,6 @@ export class WidgetsBundleWidgetsComponent extends PageComponent implements OnIn
private route: ActivatedRoute, private route: ActivatedRoute,
private widgetsService: WidgetService, private widgetsService: WidgetService,
private importExport: ImportExportService, private importExport: ImportExportService,
private sanitizer: DomSanitizer,
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private dialog: MatDialog) { private dialog: MatDialog) {
super(store); super(store);
@ -86,13 +83,6 @@ export class WidgetsBundleWidgetsComponent extends PageComponent implements OnIn
ngOnInit(): void { ngOnInit(): void {
} }
getPreviewImage(imageUrl: string | null): SafeUrl | string {
if (isDefinedAndNotNull(imageUrl)) {
return this.sanitizer.bypassSecurityTrustUrl(imageUrl);
}
return '/assets/widget-preview-empty.svg';
}
trackByWidget(index: number, widget: WidgetTypeBundle): any { trackByWidget(index: number, widget: WidgetTypeBundle): any {
return widget; return widget;
} }

29
ui-ngx/src/app/shared/models/resource.models.ts

@ -17,6 +17,8 @@
import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { BaseData, ExportableEntity } from '@shared/models/base-data';
import { TenantId } from '@shared/models/id/tenant-id'; import { TenantId } from '@shared/models/id/tenant-id';
import { TbResourceId } from '@shared/models/id/tb-resource-id'; import { TbResourceId } from '@shared/models/id/tb-resource-id';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseWidgetType } from '@shared/models/widget.models';
export enum ResourceType { export enum ResourceType {
LWM2M_MODEL = 'LWM2M_MODEL', LWM2M_MODEL = 'LWM2M_MODEL',
@ -55,15 +57,40 @@ export const ResourceTypeTranslationMap = new Map<ResourceType, string>(
] ]
); );
export interface ResourceInfo extends Omit<BaseData<TbResourceId>, 'name' | 'label'>, ExportableEntity<TbResourceId> { export interface TbResourceInfo<D> extends Omit<BaseData<TbResourceId>, 'name' | 'label'>, ExportableEntity<TbResourceId> {
tenantId?: TenantId; tenantId?: TenantId;
resourceKey?: string; resourceKey?: string;
title?: string; title?: string;
resourceType: ResourceType; resourceType: ResourceType;
fileName: string; fileName: string;
descriptor?: D;
} }
export type ResourceInfo = TbResourceInfo<any>;
export interface Resource extends ResourceInfo { export interface Resource extends ResourceInfo {
data: string; data: string;
name?: string; name?: string;
} }
export interface ImageDescriptor {
mediaType: string;
width: number;
height: number;
size: number;
etag: string;
previewDescriptor: ImageDescriptor;
}
export type ImageResourceInfo = TbResourceInfo<ImageDescriptor>;
export type ImageResourceType = 'tenant' | 'system';
export const imageResourceType = (imageInfo: ImageResourceInfo): ImageResourceType =>
(!imageInfo.tenantId || imageInfo.tenantId?.id === NULL_UUID) ? 'system' : 'tenant';
export const IMAGES_URL_PREFIX = '/api/images';
export const isImageResourceUrl = (url: string): boolean => url && url.startsWith(IMAGES_URL_PREFIX);
export const NO_IMAGE_DATA_URI = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';

52
ui-ngx/src/app/shared/pipe/image.pipe.ts

@ -0,0 +1,52 @@
///
/// Copyright © 2016-2023 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 { Pipe, PipeTransform } from '@angular/core';
import { ImageService } from '@core/http/image.service';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { isDefinedAndNotNull } from '@core/utils';
import { NO_IMAGE_DATA_URI } from '@shared/models/resource.models';
const LOADING_IMAGE_DATA_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDIwIDIwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPgo=';
@Pipe({
name: 'image'
})
export class ImagePipe implements PipeTransform {
constructor(private imageService: ImageService,
private sanitizer: DomSanitizer) { }
transform(url: string, args?: any): Observable<SafeUrl | string> {
const ignoreLoadingImage = !!args?.ignoreLoadingImage;
const asString = !!args?.asString;
const image$ = ignoreLoadingImage ? new Subject<SafeUrl | string>() : new BehaviorSubject<SafeUrl | string>(LOADING_IMAGE_DATA_URI);
if (isDefinedAndNotNull(url)) {
const preview = !!args?.preview;
this.imageService.resolveImageUrl(url, preview, asString).subscribe((imageUrl) => {
image$.next(imageUrl);
image$.complete();
});
} else {
const emptyUrl = args?.emptyUrl || NO_IMAGE_DATA_URI;
image$.next(asString ? emptyUrl : this.sanitizer.bypassSecurityTrustUrl(emptyUrl));
image$.complete();
}
return image$.asObservable();
}
}

1
ui-ngx/src/app/shared/pipe/public-api.ts

@ -23,3 +23,4 @@ export * from './nospace.pipe';
export * from './truncate.pipe'; export * from './truncate.pipe';
export * from './file-size.pipe'; export * from './file-size.pipe';
export * from './selectable-columns.pipe'; export * from './selectable-columns.pipe';
export * from './image.pipe';

4
ui-ngx/src/app/shared/shared.module.ts

@ -200,6 +200,7 @@ import { ColorPickerPanelComponent } from '@shared/components/color-picker/color
import { TbIconComponent } from '@shared/components/icon.component'; import { TbIconComponent } from '@shared/components/icon.component';
import { HintTooltipIconComponent } from '@shared/components/hint-tooltip-icon.component'; import { HintTooltipIconComponent } from '@shared/components/hint-tooltip-icon.component';
import { StringAutocompleteComponent } from '@shared/components/string-autocomplete.component'; import { StringAutocompleteComponent } from '@shared/components/string-autocomplete.component';
import { ImagePipe } from '@shared/pipe/image.pipe';
export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) {
return markedOptionsService; return markedOptionsService;
@ -217,6 +218,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
DateAgoPipe, DateAgoPipe,
SafePipe, SafePipe,
ShortNumberPipe, ShortNumberPipe,
ImagePipe,
{ {
provide: FlowInjectionToken, provide: FlowInjectionToken,
useValue: Flow useValue: Flow
@ -340,6 +342,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
TbJsonPipe, TbJsonPipe,
FileSizePipe, FileSizePipe,
DateAgoPipe, DateAgoPipe,
ImagePipe,
SafePipe, SafePipe,
ShortNumberPipe, ShortNumberPipe,
SelectableColumnsPipe, SelectableColumnsPipe,
@ -576,6 +579,7 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService)
KeyboardShortcutPipe, KeyboardShortcutPipe,
FileSizePipe, FileSizePipe,
DateAgoPipe, DateAgoPipe,
ImagePipe,
SafePipe, SafePipe,
ShortNumberPipe, ShortNumberPipe,
SelectableColumnsPipe, SelectableColumnsPipe,

Loading…
Cancel
Save