From 97adcde9d18667e9f9ec3eb40a47bd8c2f85253b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 17 May 2024 19:31:35 +0300 Subject: [PATCH] UI: Edit SCADA symbol UI refactoring. --- .../widget/lib/svg/iot-svg.models.ts | 724 +--------------- .../scada-symbol-editor.component.html | 18 + .../scada-symbol-editor.component.scss | 22 + .../scada-symbol-editor.component.ts | 83 ++ .../scada-symbol-tooltip.component.scss | 52 ++ .../scada-symbol-tooltip.components.ts | 295 +++++++ .../scada-symbol/scada-symbol.component.html | 2 +- .../scada-symbol/scada-symbol.component.scss | 7 - .../scada-symbol/scada-symbol.component.ts | 29 +- .../pages/scada-symbol/scada-symbol.models.ts | 799 ++++++++++++++++++ .../pages/scada-symbol/scada-symbol.module.ts | 6 +- ui-ngx/src/styles.scss | 9 +- 12 files changed, 1287 insertions(+), 759 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts index e128a26675..db649d8ad1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/svg/iot-svg.models.ts @@ -15,8 +15,7 @@ /// import { ValueType } from '@shared/models/constants'; -import * as svgjs from '@svgdotjs/svg.js'; -import { Box, Element, Rect, Runner, SVG, Svg, Text, Timeline, Style } from '@svgdotjs/svg.js'; +import { Box, Element, Runner, SVG, Svg, Text } from '@svgdotjs/svg.js'; import '@svgdotjs/svg.panzoom.js'; import { DataToValueType, @@ -34,7 +33,7 @@ import { mergeDeep, parseFunction } from '@core/utils'; -import { BehaviorSubject, forkJoin, from, Observable, Observer } from 'rxjs'; +import { BehaviorSubject, forkJoin, Observable, Observer } from 'rxjs'; import { map, share } from 'rxjs/operators'; import { ValueAction, ValueGetter, ValueSetter } from '@home/components/widget/lib/action/action-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -42,11 +41,6 @@ import { ColorProcessor, constantColor, Font } from '@shared/models/widget-setti import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; import { WidgetAction, WidgetActionType } from '@shared/models/widget.models'; -import { ResizeObserver } from '@juggle/resize-observer'; -import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; -import ITooltipPosition = JQueryTooltipster.ITooltipPosition; -import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; -import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; export interface IotSvgApi { formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; @@ -277,720 +271,6 @@ export type IotSvgObjectSettings = {[id: string]: any}; const parseError = (ctx: WidgetContext, err: any): string => ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; -export class IotSvgEditObject { - - public svgShape: Svg; - private box: Box; - private elements: IotSvgElement[] = []; - private readonly shapeResize$: ResizeObserver; - private performSetup = false; - private hoverFilterStyle: Style; - public scale = 1; - constructor(private rootElement: HTMLElement) { - this.shapeResize$ = new ResizeObserver(() => { - this.resize(); - }); - } - - public setContent(svgContent: string) { - this.shapeResize$.unobserve(this.rootElement); - if (this.svgShape) { - this.elements.length = 0; - this.svgShape.remove(); - } - const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); - this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); - this.svgShape.node.style.overflow = 'visible'; - this.svgShape.node.style['user-select'] = 'none'; - this.box = this.svgShape.bbox(); - this.svgShape.size(this.box.width, this.box.height); - this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); - this.svgShape.style().rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); - this.updateHoverFilterStyle(); - this.performSetup = true; - this.shapeResize$.observe(this.rootElement); - } - - private doSetup() { - this.setupZoomPan(0); - (window as any).SVG = svgjs; - forkJoin([ - from(import('tooltipster')), - from(import('tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js')) - ]).subscribe(() => { - this.setupElements(); - }); - } - - private setupZoomPan(margin: number) { - this.svgShape.on('zoom', (e) => { - const { - detail: { level, focus } - } = e as any; - this.svgShape.zoom(level, focus); - const box = this.restrictToMargins(this.svgShape.viewbox(), margin); - this.svgShape.viewbox(box); - setTimeout(() => { - this.updateTooltipPositions(); - }); - e.preventDefault(); - }); - this.svgShape.on('panning', (e) => { - const box = (e as any).detail.box; - this.svgShape.viewbox(this.restrictToMargins(box, margin)); - setTimeout(() => { - this.updateTooltipPositions(); - }); - e.preventDefault(); - }); - this.svgShape.on('panStart', (e) => { - this.svgShape.node.style.cursor = 'grab'; - }); - this.svgShape.on('panEnd', (e) => { - this.svgShape.node.style.cursor = 'default'; - }); - } - - private restrictToMargins(box: Box, margin: number): Box { - if (box.x < -margin) { - box.x = -margin; - } else if ((box.x + box.width) > (this.box.width + margin)) { - box.x = this.box.width + margin - box.width; - } - if (box.y < -margin) { - box.y = -margin; - } else if ((box.y + box.height) > (this.box.height + margin)) { - box.y = this.box.height + margin - box.height; - } - return box; - } - - private setupElements() { - this.svgShape.children().forEach(child => { - this.addElement(child); - }); - const overlappingGroups: IotSvgElement[][] = []; - for (const el of this.elements) { - for (const other of this.elements) { - if (el !== other && el.overlappingCenters(other)) { - let overlappingGroup: IotSvgElement[]; - for (const list of overlappingGroups) { - if (list.includes(other) || list.includes(el)) { - overlappingGroup = list; - break; - } - } - if (!overlappingGroup) { - overlappingGroup = [el, other]; - overlappingGroups.push(overlappingGroup); - } else { - if (!overlappingGroup.includes(el)) { - overlappingGroup.push(el); - } else if (!overlappingGroup.includes(other)){ - overlappingGroup.push(other); - } - } - } - } - } - for (const group of overlappingGroups) { - const centers = group.map(e => e.box.cy); - const center = centers.reduce((a, b) => a + b, 0) / centers.length; - const textElement = group.find(e => e.isText()); - const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; - if (textElement) { - textElement.setInnerTooltipOffset(0, center); - group.splice(group.indexOf(textElement), 1); - } - let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; - for (const element of group) { - if (textElement && offset === 0) { - offset += elementTooltipMinHeight; - } - element.setInnerTooltipOffset(offset, center); - offset += elementTooltipMinHeight; - } - } - for (const el of this.elements) { - el.init(); - } - } - - private addElement(e: Element) { - if (hasBBox(e)) { - const iotSvgElement = new IotSvgElement(this, e); - this.elements.push(iotSvgElement); - e.children().forEach(child => { - if (!(child.type === 'tspan' && e.type === 'text')) { - this.addElement(child); - } - }, true); - } - } - - public destroy() { - if (this.shapeResize$) { - this.shapeResize$.disconnect(); - } - } - - private resize() { - if (this.svgShape) { - const targetWidth = this.rootElement.getBoundingClientRect().width; - const targetHeight = this.rootElement.getBoundingClientRect().height; - let scale: number; - if (targetWidth < targetHeight) { - scale = targetWidth / this.box.width; - } else { - scale = targetHeight / this.box.height; - } - if (this.scale !== scale) { - this.scale = scale; - this.svgShape.node.style.transform = `scale(${this.scale})`; - this.updateHoverFilterStyle(); - this.updateZoomOptions(); - this.updateTooltipPositions(); - } - if (this.performSetup) { - this.performSetup = false; - this.doSetup(); - } - } - } - - private updateHoverFilterStyle() { - if (this.hoverFilterStyle) { - this.hoverFilterStyle.remove(); - } - const whiteBlur = (2.8 / this.scale).toFixed(2); - const blackBlur = (1.2 / this.scale).toFixed(2); - this.hoverFilterStyle = - this.svgShape.style().rule('.hovered', - { - filter: - `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) - drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) - drop-shadow(0px 0px ${blackBlur}px black)` - } - ); - } - - private updateZoomOptions() { - this.svgShape.panZoom({ - zoomMin: 1, - zoomMax: 4, - zoomFactor: 2 / this.scale - }); - } - - private updateTooltipPositions() { - const container = this.rootElement.getBoundingClientRect(); - for (const e of this.elements) { - e.updateTooltipPosition(container); - } - } - -} - -const hasBBox = (e: Element): boolean => { - try { - if (e.bbox) { - const box = e.bbox(); - return !!box.width || !!box.height; - } else { - return false; - } - } catch (_e) { - return false; - } -}; - -const textTooltip = (el: JQuery, text: string) => { - el.tooltipster({ - theme: ['iot-svg'], - trigger: 'hover', - content: text - }); -}; - -const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( - target.left >= container.left - horizontalGap && - target.left + target.width <= container.left + container.width + horizontalGap && - target.top >= container.top - verticalGap && - target.top + target.height <= container.top + container.height + verticalGap -); - -const elementTooltipMinHeight = 36 + 8; -const elementTooltipMinWidth = 100; - -const groupRectStroke = 2; -const groupRectPadding = 2; - -class IotSvgElement { - - private highlightRect: Rect; - private highlightRectTimeline: Timeline; - - private tooltip: ITooltipsterInstance; - - private tag: string; - - private isEditing = false; - - private innerTooltipOffset = 0; - - public readonly box: Box; - - private highlighted = false; - - private tooltipMouseX: number; - private tooltipMouseY: number; - - constructor(private editObject: IotSvgEditObject, - private element: Element) { - this.tag = element.attr('tb:tag'); - this.box = element.rbox(this.editObject.svgShape); - } - - public init() { - if (this.isGroup()) { - this.highlightRect = - this.editObject.svgShape - .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) - .x(this.box.x - groupRectPadding) - .y(this.box.y - groupRectPadding) - .attr({ - fill: 'none', - rx: this.unscaled(6), - stroke: 'rgba(0, 0, 0, 0.38)', - 'stroke-dasharray': '1', - 'stroke-width': this.unscaled(groupRectStroke), - opacity: 0}); - this.highlightRectTimeline = new Timeline(); - this.highlightRect.timeline(this.highlightRectTimeline); - this.highlightRect.hide(); - } else { - this.element.addClass('tb-element'); - } - this.element.on('mouseenter', (event) => { - this.highlight(); - }); - this.element.on('mouseleave', (event) => { - this.unhighlight(); - }); - if (this.hasTag()) { - this.createTagTooltip(); - } else { - this.createAddTagTooltip(); - } - } - - public overlappingCenters(otherElement: IotSvgElement): boolean { - if (this.isGroup() || otherElement.isGroup()) { - return false; - } - return Math.abs(this.box.cx - otherElement.box.cx) * this.editObject.scale < elementTooltipMinWidth && - Math.abs(this.box.cy - otherElement.box.cy) * this.editObject.scale < elementTooltipMinHeight; - } - - public highlight() { - if (!this.highlighted) { - this.highlighted = true; - if (this.isGroup()) { - this.highlightRectTimeline.finish(); - this.highlightRect - .attr({ - rx: this.unscaled(6), - 'stroke-width': this.unscaled(groupRectStroke) - }); - this.highlightRect.show(); - this.highlightRect.animate(300).attr({opacity: 1}); - } else { - this.element.addClass('hovered'); - } - if (this.hasTag()) { - if (!this.isEditing) { - this.tooltip.reposition(); - } - $(this.tooltip.elementTooltip()).addClass('tb-active'); - } - } - } - - public unhighlight() { - if (this.highlighted) { - this.highlighted = false; - if (this.isGroup()) { - this.highlightRectTimeline.finish(); - this.highlightRect.animate(300).attr({opacity: 0}).after(() => { - this.highlightRect.hide(); - }); - } else { - this.element.removeClass('hovered'); - } - if (this.hasTag()) { - $(this.tooltip.elementTooltip()).removeClass('tb-active'); - } - } - } - - public clearTag() { - this.tooltip.destroy(); - this.tag = null; - this.element.attr('tb:tag', null); - this.unhighlight(); - this.createAddTagTooltip(); - } - - public setTag(tag: string) { - this.tooltip.destroy(); - this.tag = tag; - this.element.attr('tb:tag', tag); - this.createTagTooltip(); - } - - public updateTooltipPosition(container: DOMRect) { - if (this.tooltip && !this.tooltip.status().destroyed) { - this.tooltip.reposition(); - const tooltipElement = this.tooltip.elementTooltip(); - if (tooltipElement) { - if (isDomRectContained(tooltipElement.getBoundingClientRect(), container, - elementTooltipMinWidth, elementTooltipMinHeight)) { - tooltipElement.style.visibility = null; - } else { - tooltipElement.style.visibility = 'hidden'; - } - } - } - } - - public setInnerTooltipOffset(offset: number, center: number) { - this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; - } - - private unscaled(size: number): number { - return size / (this.editObject.scale * this.editObject.svgShape.zoom()); - } - - private scaled(size: number): number { - return size * (this.editObject.scale * this.editObject.svgShape.zoom()); - } - - private createTagTooltip() { - const el = $(this.element.node); - el.tooltipster( - { - arrow: this.isGroup(), - distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, - theme: ['iot-svg'], - delay: 0, - animationDuration: 0, - interactive: true, - trigger: 'custom', - side: 'top', - trackOrigin: false, - content: '', - functionPosition: (instance, helper, position) => - this.innerTagTooltipPosition(instance, helper, position), - functionReady: (instance, helper) => { - const tooltipEl = $(helper.tooltip); - tooltipEl.on('mouseenter', () => { - this.highlight(); - }); - tooltipEl.on('mouseleave', () => { - this.unhighlight(); - }); - } - } - ); - this.tooltip = el.tooltipster('instance'); - this.setupTagPanel(); - } - - private setupTagPanel() { - this.isEditing = false; - this.unhighlight(); - const tagPanel = - $(`
- ${this.element.type}: - ${this.tag} - edit - delete -
`); - const updateTagButton = tagPanel.find('.edit-icon'); - textTooltip(updateTagButton, 'Update tag'); - updateTagButton.on('click', () => { - this.setupEditTagPanel(); - }); - const deleteButton = tagPanel.find('.delete-icon'); - textTooltip(deleteButton, 'Remove tag'); - deleteButton.on('click', () => { - this.clearTag(); - }); - this.tooltip.content(tagPanel); - this.tooltip.open(); - } - - private setupEditTagPanel() { - this.isEditing = true; - const editTagInputPanel = - $(`
- Update tag: - - done - close -
`); - const tagInput = editTagInputPanel.find('input.tag-input'); - const applyTagButton = editTagInputPanel.find('span.apply-icon'); - const closeButton = editTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - tagInput.val(this.tag); - let editPanelClosed = false; - - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - editPanelClosed = true; - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - editPanelClosed = true; - if (newTag) { - this.setTag(newTag); - } else { - this.setupTagPanel(); - } - }); - closeButton.on('click', () => { - editPanelClosed = true; - this.setupTagPanel(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!editPanelClosed) { - editPanelClosed = true; - this.setupTagPanel(); - } - }); - }); - this.tooltip.content(editTagInputPanel); - tagInput.trigger('focus'); - } - - private createAddTagTooltip() { - const el = $(this.element.node); - el.tooltipster( - { - arrow: true, - theme: ['iot-svg', 'tb-active'], - delay: [0, 300], - interactive: true, - trigger: 'hover', - side: ['top', 'left', 'bottom', 'right'], - trackOrigin: false, - content: '', - functionBefore: (instance, helper) => { - const mouseEvent = (helper.event as MouseEvent); - this.tooltipMouseX = mouseEvent.clientX; - this.tooltipMouseY = mouseEvent.clientY; - let side: TooltipPositioningSide; - if (this.isGroup()) { - side = 'top'; - } else { - side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), - mouseEvent.clientX, mouseEvent.clientY); - } - instance.option('side', side); - }, - functionPosition: (instance, helper, position) => - this.innerAddTagTooltipPosition(instance, helper, position), - functionReady: (instance, helper) => { - const tooltipEl = $(helper.tooltip); - tooltipEl.on('mouseenter', () => { - this.highlight(); - }); - tooltipEl.on('mouseleave', () => { - this.unhighlight(); - }); - } - } - ); - this.tooltip = el.tooltipster('instance'); - this.setupAddTagPanel(); - } - - private setupAddTagPanel() { - this.isEditing = false; - const addTagPanel = - $(`
- ${this.element.type}: - -
`); - const addTagButton = addTagPanel.find('.add-tag-button'); - addTagButton.on('click', () => { - this.setupAddTagInputPanel(); - }); - this.tooltip.content(addTagPanel); - this.tooltip.off('closing'); - } - - private setupAddTagInputPanel() { - this.isEditing = true; - const addTagInputPanel = - $(`
- Enter tag: - - done - close -
`); - const tagInput = addTagInputPanel.find('input.tag-input'); - const applyTagButton = addTagInputPanel.find('span.apply-icon'); - const closeButton = addTagInputPanel.find('span.close-icon'); - textTooltip(applyTagButton, 'Apply'); - textTooltip(closeButton, 'Cancel'); - - let addPanelClosed = false; - tagInput.on('keypress', (event) => { - if (event.which === 13) { - const newTag: string = tagInput.val() as string; - if (newTag) { - addPanelClosed = true; - this.setTag(newTag); - } - } - }); - applyTagButton.on('click', () => { - const newTag: string = tagInput.val() as string; - addPanelClosed = true; - if (newTag) { - this.setTag(newTag); - } else { - this.unhighlight(); - this.tooltip.close(); - } - }); - closeButton.on('click', () => { - addPanelClosed = true; - this.unhighlight(); - this.tooltip.close(); - }); - tagInput.on('blur', () => { - setTimeout(() => { - if (!addPanelClosed) { - addPanelClosed = true; - this.tooltip.close(); - } - }); - }); - this.tooltip.content(addTagInputPanel); - this.tooltip.option('delay', [0, 10000000]); - this.tooltip.on('closing', () => { - this.tooltip.option('delay', [0, 300]); - this.setupAddTagPanel(); - }); - tagInput.trigger('focus'); - } - - private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, - position: ITooltipPosition): ITooltipPosition { - const clientRect = helper.origin.getBoundingClientRect(); - if (!this.isGroup()) { - position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 - + this.innerTooltipOffset * this.editObject.svgShape.zoom(); - position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; - } else { - position.distance = this.scaled(groupRectPadding) + groupRectStroke; - position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); - } - return position; - } - - private innerAddTagTooltipPosition(instance: ITooltipsterInstance, - helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { - const distance = 10; - switch (position.side) { - case 'right': - position.coord.top = this.tooltipMouseY - position.size.height / 2; - position.coord.left = this.tooltipMouseX + distance; - position.target = this.tooltipMouseY; - break; - case 'top': - position.coord.top = this.tooltipMouseY - position.size.height - distance; - position.coord.left = this.tooltipMouseX - position.size.width / 2; - position.target = this.tooltipMouseX; - if (this.isGroup()) { - position.coord.top -= elementTooltipMinHeight; - } - break; - case 'left': - position.coord.top = this.tooltipMouseY - position.size.height / 2; - position.coord.left = this.tooltipMouseX - position.size.width - distance; - position.target = this.tooltipMouseY; - break; - case 'bottom': - position.coord.top = this.tooltipMouseY + distance; - position.coord.left = this.tooltipMouseX - position.size.width / 2; - position.target = this.tooltipMouseX; - break; - } - return position; - } - - private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { - let side: TooltipPositioningSide; - const cx = clientRect.left + clientRect.width / 2; - const cy = clientRect.top + clientRect.height / 2; - if (clientRect.width > clientRect.height) { - if (Math.abs(cx - mouseX) > clientRect.width / 4) { - if (mouseX < cx) { - side = 'left'; - } else { - side = 'right'; - } - } else { - if (mouseY < cy) { - side = 'top'; - } else { - side = 'bottom'; - } - } - } else { - if (Math.abs(cy - mouseY) > clientRect.height / 4) { - if (mouseY < cy) { - side = 'top'; - } else { - side = 'bottom'; - } - } else { - if (mouseX < cx) { - side = 'left'; - } else { - side = 'right'; - } - } - } - return side; - } - - private hasTag() { - return !!this.tag; - } - - public isGroup() { - return this.element.type === 'g'; - } - - public isText() { - return this.element.type === 'text'; - } - -} - export class IotSvgObject { private metadata: IotSvgMetadata; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html new file mode 100644 index 0000000000..96e1018eec --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html @@ -0,0 +1,18 @@ + +
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss new file mode 100644 index 0000000000..7bad327b9a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.scss @@ -0,0 +1,22 @@ +/** + * 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. + */ +.tb-scada-symbol-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts new file mode 100644 index 0000000000..6f73ae7ac4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -0,0 +1,83 @@ +/// +/// 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 { + AfterViewInit, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { ScadaSymbolEditObject } from '@home/pages/scada-symbol/scada-symbol.models'; + +@Component({ + selector: 'tb-scada-symbol-editor', + templateUrl: './scada-symbol-editor.component.html', + styleUrls: ['./scada-symbol-editor.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges { + + @ViewChild('iotSvgShape', {static: false}) + iotSvgShape: ElementRef; + + @Input() + svgContent: string; + + scadaSymbolEditObject: ScadaSymbolEditObject; + + private destroy$ = new Subject(); + + constructor(private viewContainerRef: ViewContainerRef) { + } + + ngOnInit(): void { + } + + ngAfterViewInit() { + this.scadaSymbolEditObject = new ScadaSymbolEditObject(this.iotSvgShape.nativeElement, + this.viewContainerRef); + if (this.svgContent) { + this.scadaSymbolEditObject.setContent(this.svgContent); + } + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (propName === 'svgContent') { + if (this.scadaSymbolEditObject) { + this.scadaSymbolEditObject.setContent(this.svgContent); + } + } + } + } + } + + ngOnDestroy() { + this.scadaSymbolEditObject.destroy(); + } + +} + diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss new file mode 100644 index 0000000000..c7fc243dc0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.component.scss @@ -0,0 +1,52 @@ +/** + * 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. + */ +.tb-scada-symbol-tooltip-panel { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .mdc-button { + line-height: 20px; + font-size: 12px; + &.mat-mdc-button-base { + height: 20px; + .mat-mdc-button-touch-target { + height: 20px; + } + } + } + + .mat-mdc-form-field.tb-inline-field { + font-size: 12px; + line-height: 12px; + .mat-mdc-text-field-wrapper { + padding: 0 8px; + .mat-mdc-form-field-flex { + .mat-mdc-form-field-infix { + padding-top: 4px; + padding-bottom: 4px; + min-height: 20px; + .mat-mdc-input-element { + font-size: 12px; + line-height: 12px; + height: 12px; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts new file mode 100644 index 0000000000..cf38552f7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -0,0 +1,295 @@ +/// +/// 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 { + AfterViewInit, + Component, + ComponentRef, + Directive, + ElementRef, + EventEmitter, + Input, + NgModule, + Output, + Type, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ScadaSymbolElement } from '@home/pages/scada-symbol/scada-symbol.models'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { ENTER } from '@angular/cdk/keycodes'; + +@Directive() +abstract class ScadaSymbolPanelComponent implements AfterViewInit { + + @Input() + symbolElement: ScadaSymbolElement; + + @Output() + viewInited = new EventEmitter(); + + protected constructor(public element: ElementRef) { + } + + ngAfterViewInit() { + this.viewInited.emit(); + } +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}: + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { + + @Output() + addTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + public onAddTag() { + this.addTag.emit(); + } + +} + +@Component({ + template: `
+ {{ isAdd ? 'Enter tag:' : 'Update tag:' }} + + + + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { + + @ViewChild('tagField') + tagField: ElementRef; + + @Input() + isAdd: boolean; + + @Input() + tag: string; + + @Output() + apply = new EventEmitter(); + + @Output() + cancel = new EventEmitter(); + + private closed = false; + + constructor(public element: ElementRef) { + super(element); + } + + ngAfterViewInit() { + super.ngAfterViewInit(); + setTimeout(() => { + this.tagField.nativeElement.focus(); + }); + } + + public tagEnter($event: KeyboardEvent) { + if ($event.keyCode === ENTER) { + $event.preventDefault(); + if (this.tag) { + this.closed = true; + this.apply.emit(this.tag); + } + } + } + + public onApply() { + this.closed = true; + if (this.tag) { + this.apply.emit(this.tag); + } else { + this.cancel.emit(); + } + } + + public onCancel() { + this.closed = true; + this.cancel.emit(); + } + + public onBlur() { + setTimeout(() => { + if (!this.closed) { + this.closed = true; + this.cancel.emit(); + } + }, 300); + } + +} + +@Component({ + template: `
+ {{ symbolElement?.element?.type }}: + {{ symbolElement?.tag }} + + +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None +}) +class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit { + + @Output() + updateTag = new EventEmitter(); + + @Output() + removeTag = new EventEmitter(); + + constructor(public element: ElementRef) { + super(element); + } + + public onUpdateTag() { + this.updateTag.emit(); + } + + public onRemoveTag() { + this.removeTag.emit(); + } +} + +@NgModule({ + declarations: + [ + ScadaSymbolAddTagPanelComponent, + ScadaSymbolTagInputPanelComponent, + ScadaSymbolTagPanelComponent + ], + imports: [ + CommonModule, + SharedModule + ] +}) +export class ScadaSymbolTooltipComponentsModule { } + +export const setupAddTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.setEditing(false); + symbolElement.tooltip.off('close'); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolAddTagPanelComponent); + componentRef.instance.addTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, true); + }); +}; + +export const setupTagPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef) => { + symbolElement.setEditing(false); + symbolElement.unhighlight(); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagPanelComponent); + componentRef.instance.updateTag.subscribe(() => { + componentRef.destroy(); + setupTagInputPanelTooltip(symbolElement, container, false); + }); + componentRef.instance.removeTag.subscribe(() => { + componentRef.destroy(); + symbolElement.clearTag(); + }); + symbolElement.tooltip.open(); +}; + +const setupTagInputPanelTooltip = (symbolElement: ScadaSymbolElement, container: ViewContainerRef, isAdd: boolean) => { + symbolElement.setEditing(true); + const componentRef = setTooltipComponent(symbolElement, container, ScadaSymbolTagInputPanelComponent); + + componentRef.instance.isAdd = isAdd; + if (!isAdd) { + componentRef.instance.tag = symbolElement.tag; + } + componentRef.instance.apply.subscribe((newTag) => { + componentRef.destroy(); + if (isAdd) { + symbolElement.tooltip.off('close'); + } + symbolElement.setTag(newTag); + }); + componentRef.instance.cancel.subscribe(() => { + if (isAdd) { + symbolElement.unhighlight(); + symbolElement.tooltip.close(); + } else { + componentRef.destroy(); + setupTagPanelTooltip(symbolElement, container); + } + }); + if (isAdd) { + symbolElement.tooltip.option('delay', [0, 10000000]); + symbolElement.tooltip.on('close', () => { + componentRef.destroy(); + symbolElement.tooltip.option('delay', [0, 300]); + setupAddTagPanelTooltip(symbolElement, container); + }); + } +}; + +const setTooltipComponent = (symbolElement: ScadaSymbolElement, + container: ViewContainerRef, componentType: Type): ComponentRef => { + const componentRef = container.createComponent(componentType); + componentRef.instance.symbolElement = symbolElement; + componentRef.instance.viewInited.subscribe(() => { + if (symbolElement.tooltip.status().open) { + symbolElement.repositionTooltip(); + } + }); + const parentElement = componentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + symbolElement.tooltip.content(content); + return componentRef; +}; diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index 0aa5a589f3..dc4e620f94 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -35,7 +35,7 @@ -
+
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss index 783e2d842b..1f3208cc91 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.scss @@ -24,12 +24,5 @@ min-width: 0; min-height: 0; background: #fff; - .tb-scada-symbol-shape { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index d75ac137e6..0d521d0a4f 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - AfterViewInit, - Component, - ElementRef, - HostBinding, - OnDestroy, - OnInit, - ViewChild, - ViewEncapsulation -} from '@angular/core'; +import { AfterViewInit, Component, HostBinding, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -31,11 +22,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { ScadaSymbolData } from '@home/pages/scada-symbol/scada-symbol.models'; -import { - IotSvgEditObject, - IotSvgMetadata, - parseIotSvgMetadataFromContent -} from '@home/components/widget/lib/svg/iot-svg.models'; +import { IotSvgMetadata, parseIotSvgMetadataFromContent } from '@home/components/widget/lib/svg/iot-svg.models'; @Component({ selector: 'tb-scada-symbol', @@ -48,12 +35,7 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes @HostBinding('style.width') width = '100%'; @HostBinding('style.height') height = '100%'; - @ViewChild('iotSvgShape', {static: false}) - iotSvgShape: ElementRef; - symbolData: ScadaSymbolData; - - iotSvgObjectEdit: IotSvgEditObject; metadata: IotSvgMetadata; private destroy$ = new Subject(); @@ -76,10 +58,6 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes } ngAfterViewInit() { - this.iotSvgObjectEdit = new IotSvgEditObject(this.iotSvgShape.nativeElement); - if (this.symbolData) { - this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); - } } ngOnDestroy() { @@ -94,9 +72,6 @@ export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDes private init() { this.symbolData = this.route.snapshot.data.symbolData; this.metadata = parseIotSvgMetadataFromContent(this.symbolData.svgContent); - if (this.iotSvgObjectEdit) { - this.iotSvgObjectEdit.setContent(this.symbolData.svgContent); - } } } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts index 32b05f2265..d8ee8c1a64 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.models.ts @@ -15,8 +15,807 @@ /// import { ImageResourceInfo } from '@shared/models/resource.models'; +import * as svgjs from '@svgdotjs/svg.js'; +import { Box, Element, Rect, Style, SVG, Svg, Timeline } from '@svgdotjs/svg.js'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; +import { Directive, ElementRef, OnDestroy, Type, ViewContainerRef } from '@angular/core'; +import { forkJoin, from } from 'rxjs'; +import { TbInject } from '@shared/decorators/tb-inject'; +import { + setupAddTagPanelTooltip, + setupTagPanelTooltip +} from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import ITooltipsterHelper = JQueryTooltipster.ITooltipsterHelper; +import ITooltipPosition = JQueryTooltipster.ITooltipPosition; export interface ScadaSymbolData { imageResource: ImageResourceInfo; svgContent: string; } + +export class ScadaSymbolEditObject { + + public svgShape: Svg; + private box: Box; + private elements: ScadaSymbolElement[] = []; + private readonly shapeResize$: ResizeObserver; + private performSetup = false; + private hoverFilterStyle: Style; + public scale = 1; + constructor(private rootElement: HTMLElement, + public viewContainerRef: ViewContainerRef) { + this.shapeResize$ = new ResizeObserver(() => { + this.resize(); + }); + } + + public setContent(svgContent: string) { + this.shapeResize$.unobserve(this.rootElement); + if (this.svgShape) { + this.elements.length = 0; + this.svgShape.remove(); + } + const doc: XMLDocument = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + this.svgShape = SVG().addTo(this.rootElement).svg(doc.documentElement.innerHTML); + this.svgShape.node.style.overflow = 'visible'; + this.svgShape.node.style['user-select'] = 'none'; + this.box = this.svgShape.bbox(); + this.svgShape.size(this.box.width, this.box.height); + this.svgShape.viewbox(`0 0 ${this.box.width} ${this.box.height}`); + this.svgShape.style().rule('.tb-element', {cursor: 'pointer', transition: '0.2s filter ease-in-out'}); + this.updateHoverFilterStyle(); + this.performSetup = true; + this.shapeResize$.observe(this.rootElement); + } + + public editingElement() { + return this.elements.find(e => e.isEditing()); + } + + private doSetup() { + this.setupZoomPan(0); + (window as any).SVG = svgjs; + forkJoin([ + from(import('tooltipster')), + from(import('tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js')) + ]).subscribe(() => { + this.setupElements(); + }); + } + + private setupZoomPan(margin: number) { + this.svgShape.on('zoom', (e) => { + const { + detail: { level, focus } + } = e as any; + this.svgShape.zoom(level, focus); + const box = this.restrictToMargins(this.svgShape.viewbox(), margin); + this.svgShape.viewbox(box); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panning', (e) => { + const box = (e as any).detail.box; + this.svgShape.viewbox(this.restrictToMargins(box, margin)); + setTimeout(() => { + this.updateTooltipPositions(); + }); + e.preventDefault(); + }); + this.svgShape.on('panStart', (e) => { + this.svgShape.node.style.cursor = 'grab'; + }); + this.svgShape.on('panEnd', (e) => { + this.svgShape.node.style.cursor = 'default'; + }); + } + + private restrictToMargins(box: Box, margin: number): Box { + if (box.x < -margin) { + box.x = -margin; + } else if ((box.x + box.width) > (this.box.width + margin)) { + box.x = this.box.width + margin - box.width; + } + if (box.y < -margin) { + box.y = -margin; + } else if ((box.y + box.height) > (this.box.height + margin)) { + box.y = this.box.height + margin - box.height; + } + return box; + } + + private setupElements() { + this.svgShape.children().forEach(child => { + this.addElement(child); + }); + const overlappingGroups: ScadaSymbolElement[][] = []; + for (const el of this.elements) { + for (const other of this.elements) { + if (el !== other && el.overlappingCenters(other)) { + let overlappingGroup: ScadaSymbolElement[]; + for (const list of overlappingGroups) { + if (list.includes(other) || list.includes(el)) { + overlappingGroup = list; + break; + } + } + if (!overlappingGroup) { + overlappingGroup = [el, other]; + overlappingGroups.push(overlappingGroup); + } else { + if (!overlappingGroup.includes(el)) { + overlappingGroup.push(el); + } else if (!overlappingGroup.includes(other)){ + overlappingGroup.push(other); + } + } + } + } + } + for (const group of overlappingGroups) { + const centers = group.map(e => e.box.cy); + const center = centers.reduce((a, b) => a + b, 0) / centers.length; + const textElement = group.find(e => e.isText()); + const slots = textElement ? group.length % 2 === 0 ? (group.length + 1) : group.length : group.length; + if (textElement) { + textElement.setInnerTooltipOffset(0, center); + group.splice(group.indexOf(textElement), 1); + } + let offset = - (elementTooltipMinHeight * slots) / 2 + elementTooltipMinHeight / 2; + for (const element of group) { + if (textElement && offset === 0) { + offset += elementTooltipMinHeight; + } + element.setInnerTooltipOffset(offset, center); + offset += elementTooltipMinHeight; + } + } + for (const el of this.elements) { + el.init(); + } + } + + private addElement(e: Element) { + if (hasBBox(e)) { + const scadaSymbolElement = new ScadaSymbolElement(this, e); + this.elements.push(scadaSymbolElement); + e.children().forEach(child => { + if (!(child.type === 'tspan' && e.type === 'text')) { + this.addElement(child); + } + }, true); + } + } + + public destroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + } + + private resize() { + if (this.svgShape) { + const targetWidth = this.rootElement.getBoundingClientRect().width; + const targetHeight = this.rootElement.getBoundingClientRect().height; + let scale: number; + if (targetWidth < targetHeight) { + scale = targetWidth / this.box.width; + } else { + scale = targetHeight / this.box.height; + } + if (this.scale !== scale) { + this.scale = scale; + this.svgShape.node.style.transform = `scale(${this.scale})`; + this.updateHoverFilterStyle(); + this.updateZoomOptions(); + this.updateTooltipPositions(); + } + if (this.performSetup) { + this.performSetup = false; + this.doSetup(); + } + } + } + + private updateHoverFilterStyle() { + if (this.hoverFilterStyle) { + this.hoverFilterStyle.remove(); + } + const whiteBlur = (2.8 / this.scale).toFixed(2); + const blackBlur = (1.2 / this.scale).toFixed(2); + this.hoverFilterStyle = + this.svgShape.style().rule('.hovered', + { + filter: + `drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${whiteBlur}px white) drop-shadow(0px 0px ${whiteBlur}px white) + drop-shadow(0px 0px ${blackBlur}px black)` + } + ); + } + + private updateZoomOptions() { + this.svgShape.panZoom({ + zoomMin: 1, + zoomMax: 4, + zoomFactor: 2 / this.scale + }); + } + + private updateTooltipPositions() { + const container = this.rootElement.getBoundingClientRect(); + for (const e of this.elements) { + e.updateTooltipPosition(container); + } + } + +} + +const hasBBox = (e: Element): boolean => { + try { + if (e.bbox) { + const box = e.bbox(); + return !!box.width || !!box.height; + } else { + return false; + } + } catch (_e) { + return false; + } +}; + +const textTooltip = (el: JQuery, text: string) => { + el.tooltipster({ + theme: ['iot-svg'], + trigger: 'hover', + content: text + }); +}; + +const isDomRectContained = (target: DOMRect, container: DOMRect, horizontalGap = 0, verticalGap = 0): boolean => ( + target.left >= container.left - horizontalGap && + target.left + target.width <= container.left + container.width + horizontalGap && + target.top >= container.top - verticalGap && + target.top + target.height <= container.top + container.height + verticalGap +); + +const elementTooltipMinHeight = 36 + 8; +const elementTooltipMinWidth = 100; + +const groupRectStroke = 2; +const groupRectPadding = 2; + +export class ScadaSymbolElement { + + private highlightRect: Rect; + private highlightRectTimeline: Timeline; + + public tooltip: ITooltipsterInstance; + + public tag: string; + + private editing = false; + + private innerTooltipOffset = 0; + + public readonly box: Box; + + private highlighted = false; + + private tooltipMouseX: number; + private tooltipMouseY: number; + + constructor(private editObject: ScadaSymbolEditObject, + public element: Element) { + this.tag = element.attr('tb:tag'); + this.box = element.rbox(this.editObject.svgShape); + } + + public init() { + if (this.isGroup()) { + this.highlightRect = + this.editObject.svgShape + .rect(this.box.width + groupRectPadding * 2, this.box.height + groupRectPadding * 2) + .x(this.box.x - groupRectPadding) + .y(this.box.y - groupRectPadding) + .attr({ + fill: 'none', + rx: this.unscaled(6), + stroke: 'rgba(0, 0, 0, 0.38)', + 'stroke-dasharray': '1', + 'stroke-width': this.unscaled(groupRectStroke), + opacity: 0}); + this.highlightRectTimeline = new Timeline(); + this.highlightRect.timeline(this.highlightRectTimeline); + this.highlightRect.hide(); + } else { + this.element.addClass('tb-element'); + } + this.element.on('mouseenter', (event) => { + this.highlight(); + }); + this.element.on('mouseleave', (event) => { + this.unhighlight(); + }); + if (this.hasTag()) { + this.createTagTooltip(); + } else { + this.createAddTagTooltip(); + } + } + + public overlappingCenters(otherElement: ScadaSymbolElement): boolean { + if (this.isGroup() || otherElement.isGroup()) { + return false; + } + return Math.abs(this.box.cx - otherElement.box.cx) * this.editObject.scale < elementTooltipMinWidth && + Math.abs(this.box.cy - otherElement.box.cy) * this.editObject.scale < elementTooltipMinHeight; + } + + public highlight() { + if (!this.highlighted) { + this.highlighted = true; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect + .attr({ + rx: this.unscaled(6), + 'stroke-width': this.unscaled(groupRectStroke) + }); + this.highlightRect.show(); + this.highlightRect.animate(300).attr({opacity: 1}); + } else { + this.element.addClass('hovered'); + } + if (this.hasTag()) { + this.repositionTooltip(); + $(this.tooltip.elementTooltip()).addClass('tb-active'); + } + } + } + + public unhighlight() { + if (this.highlighted) { + this.highlighted = false; + if (this.isGroup()) { + this.highlightRectTimeline.finish(); + this.highlightRect.animate(300).attr({opacity: 0}).after(() => { + this.highlightRect.hide(); + }); + } else { + this.element.removeClass('hovered'); + } + if (this.hasTag() && !this.editing) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + } + + public repositionTooltip() { + const editingElement = this.editObject.editingElement(); + if (!editingElement || editingElement === this) { + this.tooltip.reposition(); + } + } + + public clearTag() { + this.tooltip.destroy(); + this.tag = null; + this.element.attr('tb:tag', null); + this.unhighlight(); + this.createAddTagTooltip(); + } + + public setTag(tag: string) { + this.tooltip.destroy(); + this.tag = tag; + this.element.attr('tb:tag', tag); + this.createTagTooltip(); + } + + public setEditing(editing: boolean) { + this.editing = editing; + if (this.hasTag() && !this.editing && !this.highlighted) { + $(this.tooltip.elementTooltip()).removeClass('tb-active'); + } + } + + public isEditing() { + return this.editing; + } + + public updateTooltipPosition(container: DOMRect) { + if (this.tooltip && !this.tooltip.status().destroyed) { + this.tooltip.reposition(); + const tooltipElement = this.tooltip.elementTooltip(); + if (tooltipElement) { + if (isDomRectContained(tooltipElement.getBoundingClientRect(), container, + elementTooltipMinWidth, elementTooltipMinHeight)) { + tooltipElement.style.visibility = null; + } else { + tooltipElement.style.visibility = 'hidden'; + } + } + } + } + + public setInnerTooltipOffset(offset: number, center: number) { + this.innerTooltipOffset = offset + (center - this.box.cy) * this.editObject.scale; + } + + private unscaled(size: number): number { + return size / (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private scaled(size: number): number { + return size * (this.editObject.scale * this.editObject.svgShape.zoom()); + } + + private createTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + zIndex: 100, + arrow: this.isGroup(), + distance: this.isGroup() ? (this.scaled(groupRectPadding) + groupRectStroke) : 6, + theme: ['iot-svg'], + delay: 0, + animationDuration: 0, + interactive: true, + trigger: 'custom', + side: 'top', + trackOrigin: false, + content: '', + functionPosition: (instance, helper, position) => + this.innerTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupTagPanel(); + } + + private setupTagPanel() { + setupTagPanelTooltip(this, this.editObject.viewContainerRef); + /*this.isEditing = false; + this.unhighlight(); + const tagPanel = + $(`
+ ${this.element.type}: + ${this.tag} + edit + delete +
`); + const updateTagButton = tagPanel.find('.edit-icon'); + textTooltip(updateTagButton, 'Update tag'); + updateTagButton.on('click', () => { + this.setupEditTagPanel(); + }); + const deleteButton = tagPanel.find('.delete-icon'); + textTooltip(deleteButton, 'Remove tag'); + deleteButton.on('click', () => { + this.clearTag(); + }); + this.tooltip.content(tagPanel); + this.tooltip.open();*/ + } + + private setupEditTagPanel() { + this.editing = true; + const editTagInputPanel = + $(`
+ Update tag: + + done + close +
`); + const tagInput = editTagInputPanel.find('input.tag-input'); + const applyTagButton = editTagInputPanel.find('span.apply-icon'); + const closeButton = editTagInputPanel.find('span.close-icon'); + textTooltip(applyTagButton, 'Apply'); + textTooltip(closeButton, 'Cancel'); + tagInput.val(this.tag); + let editPanelClosed = false; + + tagInput.on('keypress', (event) => { + if (event.which === 13) { + const newTag: string = tagInput.val() as string; + if (newTag) { + editPanelClosed = true; + this.setTag(newTag); + } + } + }); + applyTagButton.on('click', () => { + const newTag: string = tagInput.val() as string; + editPanelClosed = true; + if (newTag) { + this.setTag(newTag); + } else { + this.setupTagPanel(); + } + }); + closeButton.on('click', () => { + editPanelClosed = true; + this.setupTagPanel(); + }); + tagInput.on('blur', () => { + setTimeout(() => { + if (!editPanelClosed) { + editPanelClosed = true; + this.setupTagPanel(); + } + }); + }); + this.tooltip.content(editTagInputPanel); + tagInput.trigger('focus'); + } + + private createAddTagTooltip() { + const el = $(this.element.node); + el.tooltipster( + { + zIndex: 100, + arrow: true, + theme: ['iot-svg', 'tb-active'], + delay: [0, 300], + interactive: true, + trigger: 'hover', + side: ['top', 'left', 'bottom', 'right'], + trackOrigin: false, + content: '', + functionBefore: (instance, helper) => { + const mouseEvent = (helper.event as MouseEvent); + this.tooltipMouseX = mouseEvent.clientX; + this.tooltipMouseY = mouseEvent.clientY; + let side: TooltipPositioningSide; + if (this.isGroup()) { + side = 'top'; + } else { + side = this.calculateTooltipSide(helper.origin.getBoundingClientRect(), + mouseEvent.clientX, mouseEvent.clientY); + } + instance.option('side', side); + }, + functionPosition: (instance, helper, position) => + this.innerAddTagTooltipPosition(instance, helper, position), + functionReady: (instance, helper) => { + const tooltipEl = $(helper.tooltip); + tooltipEl.on('mouseenter', () => { + this.highlight(); + }); + tooltipEl.on('mouseleave', () => { + this.unhighlight(); + }); + } + } + ); + this.tooltip = el.tooltipster('instance'); + this.setupAddTagPanel(); + } + + private setupAddTagPanel() { + setupAddTagPanelTooltip(this, this.editObject.viewContainerRef); + /*this.isEditing = false; + this.tooltip.off('close'); + this.editObject.dynamicComponentFactoryService.createDynamicComponent( + class TbTooltipComponentInstance extends TbTooltipContent {}, + `
+ ${this.element.type}: + +
`, + [this.editObject.sharedModule] + ).subscribe(componentData => { + const tooltipComponentRef = + this.editObject.viewContainerRef.createComponent(componentData.componentType, + {index: 0, ngModuleRef: componentData.componentModuleRef}); + tooltipComponentRef.instance.componentType = componentData.componentType; + tooltipComponentRef.instance.dynamicComponentFactoryService = this.editObject.dynamicComponentFactoryService; + tooltipComponentRef.instance.addTag = new EventEmitter(); + tooltipComponentRef.instance.addTag.subscribe(() => { + tooltipComponentRef.destroy(); + this.setupAddTagInputPanel(); + }); + const parentElement = tooltipComponentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + this.tooltip.content(content); + });*/ + /* + const addTagPanel = + $(`
+ ${this.element.type}: + +
`); + const addTagButton = addTagPanel.find('.add-tag-button'); + addTagButton.on('click', () => { + this.setupAddTagInputPanel(); + }); + this.tooltip.content(addTagPanel); + */ + // this.tooltip.off('closing'); + } + + private setupAddTagInputPanel() { + this.editing = true; + const addTagInputPanel = + $(`
+ Enter tag: + + done + close +
`); + const tagInput = addTagInputPanel.find('input.tag-input'); + const applyTagButton = addTagInputPanel.find('span.apply-icon'); + const closeButton = addTagInputPanel.find('span.close-icon'); + textTooltip(applyTagButton, 'Apply'); + textTooltip(closeButton, 'Cancel'); + + let addPanelClosed = false; + tagInput.on('keypress', (event) => { + if (event.which === 13) { + const newTag: string = tagInput.val() as string; + if (newTag) { + addPanelClosed = true; + this.tooltip.off('close'); + this.setTag(newTag); + } + } + }); + applyTagButton.on('click', () => { + const newTag: string = tagInput.val() as string; + addPanelClosed = true; + if (newTag) { + this.tooltip.off('close'); + this.setTag(newTag); + } else { + this.unhighlight(); + this.tooltip.close(); + } + }); + closeButton.on('click', () => { + addPanelClosed = true; + this.unhighlight(); + this.tooltip.close(); + }); + tagInput.on('blur', () => { + setTimeout(() => { + if (!addPanelClosed) { + addPanelClosed = true; + this.tooltip.close(); + } + }); + }); + this.tooltip.content(addTagInputPanel); + this.tooltip.option('delay', [0, 10000000]); + this.tooltip.on('close', () => { + this.tooltip.option('delay', [0, 300]); + this.setupAddTagPanel(); + }); + tagInput.trigger('focus'); + } + + private innerTagTooltipPosition(instance: ITooltipsterInstance, helper: ITooltipsterHelper, + position: ITooltipPosition): ITooltipPosition { + const clientRect = helper.origin.getBoundingClientRect(); + if (!this.isGroup()) { + position.coord.top = clientRect.top + (clientRect.height - position.size.height) / 2 + + this.innerTooltipOffset * this.editObject.svgShape.zoom(); + position.coord.left = clientRect.left + (clientRect.width - position.size.width) / 2; + } else { + position.distance = this.scaled(groupRectPadding) + groupRectStroke; + position.coord.top = clientRect.top - position.size.height - (this.scaled(groupRectPadding) + groupRectStroke); + } + return position; + } + + private innerAddTagTooltipPosition(instance: ITooltipsterInstance, + helper: ITooltipsterHelper, position: ITooltipPosition): ITooltipPosition { + const distance = 10; + switch (position.side) { + case 'right': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX + distance; + position.target = this.tooltipMouseY; + break; + case 'top': + position.coord.top = this.tooltipMouseY - position.size.height - distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + if (this.isGroup()) { + position.coord.top -= elementTooltipMinHeight; + } + break; + case 'left': + position.coord.top = this.tooltipMouseY - position.size.height / 2; + position.coord.left = this.tooltipMouseX - position.size.width - distance; + position.target = this.tooltipMouseY; + break; + case 'bottom': + position.coord.top = this.tooltipMouseY + distance; + position.coord.left = this.tooltipMouseX - position.size.width / 2; + position.target = this.tooltipMouseX; + break; + } + return position; + } + + private calculateTooltipSide(clientRect: DOMRect, mouseX: number, mouseY: number): TooltipPositioningSide { + let side: TooltipPositioningSide; + const cx = clientRect.left + clientRect.width / 2; + const cy = clientRect.top + clientRect.height / 2; + if (clientRect.width > clientRect.height) { + if (Math.abs(cx - mouseX) > clientRect.width / 4) { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } else { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } + } else { + if (Math.abs(cy - mouseY) > clientRect.height / 4) { + if (mouseY < cy) { + side = 'top'; + } else { + side = 'bottom'; + } + } else { + if (mouseX < cx) { + side = 'left'; + } else { + side = 'right'; + } + } + } + return side; + } + + private hasTag() { + return !!this.tag; + } + + public isGroup() { + return this.element.type === 'g'; + } + + public isText() { + return this.element.type === 'text'; + } + +} + +@Directive() +class TbTooltipContent implements OnDestroy { + + componentType: Type; + dynamicComponentFactoryService: DynamicComponentFactoryService; + + [key: string]: any; + constructor(@TbInject(ElementRef) public element: ElementRef) { + } + + ngOnDestroy(): void { + this.dynamicComponentFactoryService.destroyDynamicComponent(this.componentType); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts index 2ed9a0bb4c..9366321392 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.module.ts @@ -19,16 +19,20 @@ import { CommonModule } from '@angular/common'; import { SharedModule } from '@app/shared/shared.module'; import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { ScadaSymbolComponent } from '@home/pages/scada-symbol/scada-symbol.component'; +import { ScadaSymbolEditorComponent } from '@home/pages/scada-symbol/scada-symbol-editor.component'; +import { ScadaSymbolTooltipComponentsModule } from '@home/pages/scada-symbol/scada-symbol-tooltip.components'; @NgModule({ declarations: [ + ScadaSymbolEditorComponent, ScadaSymbolComponent ], imports: [ CommonModule, SharedModule, - HomeComponentsModule + HomeComponentsModule, + ScadaSymbolTooltipComponentsModule ] }) export class ScadaSymbolModule { } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 0c59e74ee5..f741fa5a31 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -466,6 +466,8 @@ mat-icon { background: rgba(0, 0, 0, 0.54); border: 1px solid rgba(0, 0, 0, 0); border-radius: 8px; + display: flex; + align-items: center; .tooltipster-content { padding: 4px 8px; font-size: 12px; @@ -480,7 +482,7 @@ mat-icon { border: 1px solid rgba(0, 0, 0, 0.38); box-shadow: 0 0 10px 6px rgba(0, 0, 0, .2); .tooltipster-content { - color: #000; + color: rgba(0, 0, 0, 0.76); } } .tooltipster-arrow { @@ -549,6 +551,11 @@ mat-icon { } } } + &.tooltipster-left, &.tooltipster-right { + .tooltipster-box { + min-height: 36px; + } + } &.tooltipster-top { .tooltipster-arrow { bottom: -2px;