18 changed files with 970 additions and 1216 deletions
@ -1,26 +0,0 @@ |
|||||
<!-- |
|
||||
|
|
||||
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. |
|
||||
|
|
||||
--> |
|
||||
<ng-container [formGroup]="scadaTestWidgetConfigForm"> |
|
||||
<tb-target-device formControlName="targetDevice"></tb-target-device> |
|
||||
<tb-scada-object-settings |
|
||||
formControlName="scadaObject" |
|
||||
[aliasController]="aliasController" |
|
||||
[targetDevice]="targetDevice" |
|
||||
[widgetType]="widgetType"> |
|
||||
</tb-scada-object-settings> |
|
||||
</ng-container> |
|
||||
@ -0,0 +1,48 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
--> |
||||
|
<ng-container [formGroup]="iotSvgWidgetConfigForm"> |
||||
|
<tb-target-device formControlName="targetDevice"></tb-target-device> |
||||
|
<tb-iot-svg-object-settings |
||||
|
formControlName="iotSvgObject" |
||||
|
[svgPath]="iotSvgWidgetConfigForm.get('iotSvg').value" |
||||
|
[aliasController]="aliasController" |
||||
|
[targetDevice]="targetDevice" |
||||
|
[widgetType]="widgetType" |
||||
|
[callbacks]="callbacks"> |
||||
|
</tb-iot-svg-object-settings> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widgets.background.background' | translate }}</div> |
||||
|
<tb-background-settings formControlName="background"> |
||||
|
</tb-background-settings> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between column-lt-md"> |
||||
|
<div translate>widget-config.show-card-buttons</div> |
||||
|
<mat-chip-listbox multiple formControlName="cardButtons"> |
||||
|
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
||||
|
</mat-chip-listbox> |
||||
|
</div> |
||||
|
<div class="tb-form-row space-between"> |
||||
|
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
||||
|
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-container> |
||||
@ -1,797 +0,0 @@ |
|||||
///
|
|
||||
/// 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 { ValueType } from '@shared/models/constants'; |
|
||||
import { Box, Element, Runner, Svg, SVG, Timeline, Text } from '@svgdotjs/svg.js'; |
|
||||
import { |
|
||||
DataToValueType, |
|
||||
GetValueAction, |
|
||||
GetValueSettings, SetValueAction, |
|
||||
SetValueSettings, ValueToDataType |
|
||||
} from '@shared/models/action-widget-settings.models'; |
|
||||
import { |
|
||||
formatValue, |
|
||||
insertVariable, |
|
||||
isDefinedAndNotNull, |
|
||||
isNumber, |
|
||||
isNumeric, |
|
||||
isUndefinedOrNull, |
|
||||
mergeDeep, |
|
||||
parseFunction |
|
||||
} from '@core/utils'; |
|
||||
import { BehaviorSubject, forkJoin, Observable } 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'; |
|
||||
import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; |
|
||||
import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
|
||||
|
|
||||
export type ScadaObjectValueType = 'input' | 'constant' | 'property' | 'function' | 'valueFormat'; |
|
||||
|
|
||||
export interface ScadaObjectValueBase { |
|
||||
type: ScadaObjectValueType; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectValueConstant extends ScadaObjectValueBase { |
|
||||
constantValue?: any; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectValueProperty extends ScadaObjectValueBase { |
|
||||
propertyId?: string; |
|
||||
computedPropertyValue?: any; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectValueFunction extends ScadaObjectValueBase { |
|
||||
valueConvertFunction?: string; |
|
||||
valueConverter?: (val: any) => any; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectValueFormat extends ScadaObjectValueBase { |
|
||||
units?: ScadaObjectValue; |
|
||||
decimals?: ScadaObjectValue; |
|
||||
computedUnits?: string; |
|
||||
computedDecimals?: number; |
|
||||
} |
|
||||
|
|
||||
export type ScadaObjectValue = ScadaObjectValueProperty & ScadaObjectValueConstant & ScadaObjectValueFunction & ScadaObjectValueFormat; |
|
||||
|
|
||||
export interface ScadaObjectAttribute { |
|
||||
name: string; |
|
||||
value: ScadaObjectValue; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectText { |
|
||||
content?: ScadaObjectValue; |
|
||||
font?: ScadaObjectValue; |
|
||||
color?: ScadaObjectValue; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectElementState { |
|
||||
tag: string; |
|
||||
inputValue?: string; |
|
||||
show?: ScadaObjectValue; |
|
||||
text?: ScadaObjectText; |
|
||||
attributes?: ScadaObjectAttribute[]; |
|
||||
animate?: number; |
|
||||
addClass?: string; |
|
||||
removeClass?: string; |
|
||||
animationTimeline?: Timeline; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectState { |
|
||||
initial?: boolean; |
|
||||
triggerValues?: string[]; |
|
||||
conditionFunction?: string; |
|
||||
condition?: (values: {[key: string]: any}) => boolean; |
|
||||
state: ScadaObjectElementState[]; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectUpdateState { |
|
||||
updateValue: string; |
|
||||
} |
|
||||
|
|
||||
export enum ScadaObjectBehaviorType { |
|
||||
setValue = 'setValue', |
|
||||
getValue = 'getValue' |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectBehaviorBase { |
|
||||
id: string; |
|
||||
name: string; |
|
||||
type: ScadaObjectBehaviorType; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectBehaviorGet extends ScadaObjectBehaviorBase { |
|
||||
valueType: ValueType; |
|
||||
defaultValue: any; |
|
||||
onUpdate: ScadaObjectUpdateState[]; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { |
|
||||
valueToDataType: ValueToDataType; |
|
||||
constantValue: any; |
|
||||
valueToDataFunction: string; |
|
||||
} |
|
||||
|
|
||||
export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; |
|
||||
|
|
||||
export type ScadaObjectPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; |
|
||||
|
|
||||
export interface ScadaObjectPropertyBase { |
|
||||
id: string; |
|
||||
name: string; |
|
||||
type: ScadaObjectPropertyType; |
|
||||
default: any; |
|
||||
required?: boolean; |
|
||||
subLabel?: string; |
|
||||
divider?: boolean; |
|
||||
fieldSuffix?: string; |
|
||||
disableOnProperty?: string; |
|
||||
rowClass?: string; |
|
||||
fieldClass?: string; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { |
|
||||
min?: number; |
|
||||
max?: number; |
|
||||
step?: number; |
|
||||
} |
|
||||
|
|
||||
export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; |
|
||||
|
|
||||
export interface ScadaObjectCallBehavior { |
|
||||
behaviorId: string; |
|
||||
conditionFunction?: string; |
|
||||
condition?: (value: any) => boolean; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectStateValue { |
|
||||
initialValue: any; |
|
||||
callBehavior?: ScadaObjectCallBehavior[]; |
|
||||
} |
|
||||
|
|
||||
export type ScadaObjectElementActionTrigger = 'click'; |
|
||||
|
|
||||
export type ScadaObjectActionType = 'updateValue'; |
|
||||
export type ScadaObjectActionUpdateValueType = 'toggle' | 'increment' | 'constant'; |
|
||||
|
|
||||
export interface ScadaObjectElementAction { |
|
||||
trigger: ScadaObjectElementActionTrigger; |
|
||||
enabledTriggerValues?: string[]; |
|
||||
enabledConditionFunction?: string; |
|
||||
enabledCondition?: (values: {[key: string]: any}) => boolean; |
|
||||
actionType: ScadaObjectActionType; |
|
||||
updateValueId?: string; |
|
||||
updateValueType?: ScadaObjectActionUpdateValueType; |
|
||||
updateValueConstant?: any; |
|
||||
updateValueInc?: number; |
|
||||
} |
|
||||
|
|
||||
export interface ScadaObjectMetadata { |
|
||||
title: string; |
|
||||
stateValues: {[id: string]: ScadaObjectStateValue}; |
|
||||
actions: {[tag: string]: ScadaObjectElementAction}; |
|
||||
states: {[id: string]: ScadaObjectState}; |
|
||||
behavior: ScadaObjectBehavior[]; |
|
||||
properties: ScadaObjectProperty[]; |
|
||||
} |
|
||||
|
|
||||
export const emptyMetadata: ScadaObjectMetadata = { |
|
||||
title: '', |
|
||||
stateValues: {}, |
|
||||
actions: {}, |
|
||||
states: {}, |
|
||||
behavior: [], |
|
||||
properties: [] |
|
||||
}; |
|
||||
|
|
||||
|
|
||||
export const parseScadaObjectMetadataFromContent = (svgContent: string): ScadaObjectMetadata => { |
|
||||
try { |
|
||||
const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); |
|
||||
return parseScadaObjectMetadataFromDom(svgDoc); |
|
||||
} catch (_e) { |
|
||||
return emptyMetadata; |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const parseScadaObjectMetadataFromDom = (svgDoc: Document): ScadaObjectMetadata => { |
|
||||
try { |
|
||||
const elements = svgDoc.getElementsByTagName('tb:metadata'); |
|
||||
if (elements.length) { |
|
||||
return JSON.parse(elements[0].innerHTML); |
|
||||
} else { |
|
||||
return emptyMetadata; |
|
||||
} |
|
||||
} catch (_e) { |
|
||||
console.error(_e); |
|
||||
return emptyMetadata; |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings<any> => ({ |
|
||||
action: GetValueAction.DO_NOTHING, |
|
||||
defaultValue: get.defaultValue, |
|
||||
executeRpc: { |
|
||||
method: 'getState', |
|
||||
requestTimeout: 5000, |
|
||||
requestPersistent: false, |
|
||||
persistentPollingInterval: 1000 |
|
||||
}, |
|
||||
getAttribute: { |
|
||||
key: 'state', |
|
||||
scope: null |
|
||||
}, |
|
||||
getTimeSeries: { |
|
||||
key: 'state' |
|
||||
}, |
|
||||
dataToValue: { |
|
||||
type: DataToValueType.NONE, |
|
||||
compareToValue: true, |
|
||||
dataToValueFunction: '/* Should return boolean value */\nreturn data;' |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
const defaultSetValueSettings = (set: ScadaObjectBehaviorSet): SetValueSettings => ({ |
|
||||
action: SetValueAction.EXECUTE_RPC, |
|
||||
executeRpc: { |
|
||||
method: 'setState', |
|
||||
requestTimeout: 5000, |
|
||||
requestPersistent: false, |
|
||||
persistentPollingInterval: 1000 |
|
||||
}, |
|
||||
setAttribute: { |
|
||||
key: 'state', |
|
||||
scope: AttributeScope.SERVER_SCOPE |
|
||||
}, |
|
||||
putTimeSeries: { |
|
||||
key: 'state' |
|
||||
}, |
|
||||
valueToData: { |
|
||||
type: set.valueToDataType, |
|
||||
constantValue: set.constantValue, |
|
||||
valueToDataFunction: set.valueToDataFunction ? set.valueToDataFunction : |
|
||||
'/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
export const defaultScadaObjectSettings = (metadata: ScadaObjectMetadata): ScadaObjectSettings => { |
|
||||
const settings: ScadaObjectSettings = {}; |
|
||||
for (const behavior of metadata.behavior) { |
|
||||
if (behavior.type === ScadaObjectBehaviorType.getValue) { |
|
||||
settings[behavior.id] = defaultGetValueSettings(behavior as ScadaObjectBehaviorGet); |
|
||||
} else if (behavior.type === ScadaObjectBehaviorType.setValue) { |
|
||||
settings[behavior.id] = defaultSetValueSettings(behavior as ScadaObjectBehaviorSet); |
|
||||
} |
|
||||
} |
|
||||
for (const property of metadata.properties) { |
|
||||
settings[property.id] = property.default; |
|
||||
} |
|
||||
return settings; |
|
||||
}; |
|
||||
|
|
||||
export type ScadaObjectSettings = {[id: string]: any}; |
|
||||
|
|
||||
export class ScadaObject { |
|
||||
|
|
||||
private metadata: ScadaObjectMetadata; |
|
||||
private settings: ScadaObjectSettings; |
|
||||
|
|
||||
private rootElement: HTMLElement; |
|
||||
private svgShape: Svg; |
|
||||
private box: Box; |
|
||||
private targetWidth: number; |
|
||||
private targetHeight: number; |
|
||||
|
|
||||
private loadingSubject = new BehaviorSubject(false); |
|
||||
private valueGetters: ValueGetter<any>[] = []; |
|
||||
private valueActions: ValueAction[] = []; |
|
||||
private valueSetters: {[behaviorId: string]: ValueSetter<any>} = {}; |
|
||||
|
|
||||
private stateValueSubjects: {[id: string]: BehaviorSubject<any>} = {}; |
|
||||
private stateValues: {[id: string]: any} = {}; |
|
||||
|
|
||||
loading$ = this.loadingSubject.asObservable().pipe(share()); |
|
||||
|
|
||||
constructor(private ctx: WidgetContext, |
|
||||
private svgPath: string, |
|
||||
private inputSettings: ScadaObjectSettings) {} |
|
||||
|
|
||||
public init(): Observable<any> { |
|
||||
return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( |
|
||||
map((inputSvgContent) => { |
|
||||
const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); |
|
||||
this.metadata = parseScadaObjectMetadataFromDom(doc); |
|
||||
const defaults = defaultScadaObjectSettings(this.metadata); |
|
||||
this.settings = mergeDeep<ScadaObjectSettings>({}, defaults, this.inputSettings || {}); |
|
||||
this.prepareMetadata(); |
|
||||
this.prepareSvgShape(doc); |
|
||||
this.initStates(); |
|
||||
}) |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
public addTo(element: HTMLElement) { |
|
||||
this.rootElement = element; |
|
||||
if (this.svgShape) { |
|
||||
this.svgShape.addTo(element); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public destroy() { |
|
||||
for (const stateValueId of Object.keys(this.stateValueSubjects)) { |
|
||||
this.stateValueSubjects[stateValueId].complete(); |
|
||||
this.stateValueSubjects[stateValueId].unsubscribe(); |
|
||||
} |
|
||||
this.valueActions.forEach(v => v.destroy()); |
|
||||
this.loadingSubject.complete(); |
|
||||
this.loadingSubject.unsubscribe(); |
|
||||
} |
|
||||
|
|
||||
public setSize(targetWidth: number, targetHeight: number) { |
|
||||
this.targetWidth = targetWidth; |
|
||||
this.targetHeight = targetHeight; |
|
||||
if (this.svgShape) { |
|
||||
this.resize(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private prepareMetadata() { |
|
||||
for (const stateValueId of Object.keys(this.metadata.stateValues)) { |
|
||||
const stateValue = this.metadata.stateValues[stateValueId]; |
|
||||
if (stateValue.callBehavior) { |
|
||||
stateValue.callBehavior.forEach(callBehavior => { |
|
||||
let condition = () => true; |
|
||||
if (callBehavior.conditionFunction) { |
|
||||
try { |
|
||||
condition = parseFunction(this.insertVariables(callBehavior.conditionFunction), ['value']); |
|
||||
} catch (e) { |
|
||||
condition = () => false; |
|
||||
} |
|
||||
} |
|
||||
callBehavior.condition = condition; |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
for (const stateId of Object.keys(this.metadata.states)) { |
|
||||
const state = this.metadata.states[stateId]; |
|
||||
let condition = () => true; |
|
||||
if (state.conditionFunction) { |
|
||||
try { |
|
||||
condition = parseFunction(this.insertVariables(state.conditionFunction), ['values']); |
|
||||
} catch (e) { |
|
||||
condition = () => false; |
|
||||
} |
|
||||
} |
|
||||
state.condition = condition; |
|
||||
for (const elementState of state.state) { |
|
||||
this.prepareValue(elementState.show); |
|
||||
this.prepareValue(elementState.text?.content); |
|
||||
this.prepareValue(elementState.text?.font); |
|
||||
this.prepareValue(elementState.text?.color); |
|
||||
if (elementState.attributes) { |
|
||||
for (const attribute of elementState.attributes) { |
|
||||
this.prepareValue(attribute.value); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private prepareValue(value: ScadaObjectValue) { |
|
||||
if (value) { |
|
||||
if (value.type === 'function' && value.valueConvertFunction) { |
|
||||
try { |
|
||||
value.valueConverter = parseFunction(this.insertVariables(value.valueConvertFunction), ['value']); |
|
||||
} catch (e) { |
|
||||
value.valueConverter = (v) => v; |
|
||||
} |
|
||||
} else if (value.type === 'property') { |
|
||||
value.computedPropertyValue = this.getPropertyValue(value.propertyId); |
|
||||
} else if (value.type === 'valueFormat') { |
|
||||
if (value.units) { |
|
||||
this.prepareValue(value.units); |
|
||||
} |
|
||||
if (value.decimals) { |
|
||||
this.prepareValue(value.decimals); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private insertVariables(content: string): string { |
|
||||
for (const property of this.metadata.properties) { |
|
||||
const value = this.getPropertyValue(property.id); |
|
||||
content = insertVariable(content, property.id, value); |
|
||||
} |
|
||||
return content; |
|
||||
} |
|
||||
|
|
||||
private prepareSvgShape(doc: XMLDocument) { |
|
||||
const elements = doc.getElementsByTagName('tb:metadata'); |
|
||||
for (let i=0;i<elements.length;i++) { |
|
||||
elements.item(i).remove(); |
|
||||
} |
|
||||
this.svgShape = SVG().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); |
|
||||
if (this.rootElement) { |
|
||||
this.svgShape.addTo(this.rootElement); |
|
||||
} |
|
||||
if (this.targetWidth && this.targetHeight) { |
|
||||
this.resize(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private initStates() { |
|
||||
for (const stateValueId of Object.keys(this.metadata.stateValues)) { |
|
||||
const stateValue = this.metadata.stateValues[stateValueId]; |
|
||||
this.stateValueSubjects[stateValueId] = new BehaviorSubject<any>(stateValue.initialValue); |
|
||||
this.stateValues[stateValueId] = stateValue.initialValue; |
|
||||
this.stateValueSubjects[stateValueId].subscribe(val => { |
|
||||
this.stateValues[stateValueId] = val; |
|
||||
const states = Object.values(this.metadata.states); |
|
||||
const triggerStates = states.filter(s => s.triggerValues && s.triggerValues.includes(stateValueId)); |
|
||||
for (const state of triggerStates) { |
|
||||
if (state.condition(this.stateValues)) { |
|
||||
this.updateState(state); |
|
||||
} |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
for (const tag of Object.keys(this.metadata.actions)) { |
|
||||
const action = this.metadata.actions[tag]; |
|
||||
const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); |
|
||||
switch (action.trigger) { |
|
||||
case 'click': |
|
||||
elements.forEach(e => { |
|
||||
e.attr('cursor', 'pointer'); |
|
||||
e.on('click', () => { |
|
||||
this.triggerAction(action); |
|
||||
}); |
|
||||
}); |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
for (const behavior of this.metadata.behavior) { |
|
||||
if (behavior.type === ScadaObjectBehaviorType.getValue) { |
|
||||
const getBehavior = behavior as ScadaObjectBehaviorGet; |
|
||||
let getValueSettings: GetValueSettings<any> = this.settings[getBehavior.id]; |
|
||||
getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; |
|
||||
const valueGetter = |
|
||||
ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { |
|
||||
next: (val) => {this.onValue(getBehavior.id, val);}, |
|
||||
error: (e) => {} |
|
||||
}); |
|
||||
this.valueGetters.push(valueGetter); |
|
||||
this.valueActions.push(valueGetter); |
|
||||
} else if (behavior.type === ScadaObjectBehaviorType.setValue) { |
|
||||
const setBehavior = behavior as ScadaObjectBehaviorSet; |
|
||||
let setValueSettings: SetValueSettings = this.settings[setBehavior.id]; |
|
||||
setValueSettings = {...setValueSettings, actionLabel: setBehavior.name}; |
|
||||
const valueSetter = ValueSetter.fromSettings<any>(this.ctx, setValueSettings); |
|
||||
this.valueSetters[setBehavior.id] = valueSetter; |
|
||||
this.valueActions.push(valueSetter); |
|
||||
} |
|
||||
} |
|
||||
const initialState = Object.values(this.metadata.states).find(s => s.initial); |
|
||||
if (initialState) { |
|
||||
this.updateState(initialState); |
|
||||
} |
|
||||
if (this.valueGetters.length) { |
|
||||
const getValueObservables: Array<Observable<any>> = []; |
|
||||
this.valueGetters.forEach(valueGetter => { |
|
||||
getValueObservables.push(valueGetter.getValue()); |
|
||||
}); |
|
||||
this.loadingSubject.next(true); |
|
||||
forkJoin(getValueObservables).subscribe( |
|
||||
{ |
|
||||
next: () => { |
|
||||
this.loadingSubject.next(false); |
|
||||
}, |
|
||||
error: () => { |
|
||||
this.loadingSubject.next(false); |
|
||||
} |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private triggerAction(action: ScadaObjectElementAction) { |
|
||||
switch (action.actionType) { |
|
||||
case 'updateValue': |
|
||||
const targetValue = action.updateValueId; |
|
||||
const valueSubject = this.stateValueSubjects[targetValue]; |
|
||||
if (valueSubject) { |
|
||||
const currentVal = valueSubject.value; |
|
||||
let newValue: any; |
|
||||
switch (action.updateValueType) { |
|
||||
case 'toggle': |
|
||||
newValue = !currentVal; |
|
||||
break; |
|
||||
case 'increment': |
|
||||
if (isNumber(currentVal)) { |
|
||||
newValue = currentVal + action.updateValueInc; |
|
||||
} |
|
||||
break; |
|
||||
case 'constant': |
|
||||
newValue = action.updateValueConstant; |
|
||||
break; |
|
||||
} |
|
||||
if (isDefinedAndNotNull(newValue)) { |
|
||||
valueSubject.next(newValue); |
|
||||
const stateValue = this.metadata.stateValues[targetValue]; |
|
||||
if (stateValue.callBehavior) { |
|
||||
const observables: Observable<any>[] = []; |
|
||||
for (const behavior of stateValue.callBehavior) { |
|
||||
if (behavior.condition(newValue)) { |
|
||||
const valueSetter = this.valueSetters[behavior.behaviorId]; |
|
||||
observables.push(valueSetter.setValue(newValue)); |
|
||||
} |
|
||||
} |
|
||||
if (observables.length) { |
|
||||
this.loadingSubject.next(true); |
|
||||
forkJoin(observables).subscribe( |
|
||||
{ |
|
||||
next: () => { |
|
||||
this.loadingSubject.next(false); |
|
||||
}, |
|
||||
error: (err) => { |
|
||||
this.loadingSubject.next(false); |
|
||||
valueSubject.next(currentVal); |
|
||||
} |
|
||||
} |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private resize() { |
|
||||
let scale: number; |
|
||||
if (this.targetWidth < this.targetHeight) { |
|
||||
scale = this.targetWidth / this.box.width; |
|
||||
} else { |
|
||||
scale = this.targetHeight / this.box.height; |
|
||||
} |
|
||||
this.svgShape.node.style.transform = `scale(${scale})`; |
|
||||
} |
|
||||
|
|
||||
private onValue(id: string, value: any) { |
|
||||
const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; |
|
||||
value = this.normalizeValue(value, getBehavior.valueType); |
|
||||
for (const onUpdate of getBehavior.onUpdate) { |
|
||||
const targetStateValueId = onUpdate.updateValue; |
|
||||
this.stateValueSubjects[targetStateValueId].next(value); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private updateState(state: ScadaObjectState) { |
|
||||
if (state) { |
|
||||
for (const elementState of state.state) { |
|
||||
const tag = elementState.tag; |
|
||||
let value; |
|
||||
if (elementState.inputValue) { |
|
||||
value = this.stateValues[elementState.inputValue]; |
|
||||
} |
|
||||
if (elementState.animationTimeline) { |
|
||||
elementState.animationTimeline.finish(); |
|
||||
} |
|
||||
const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); |
|
||||
if (elements.length) { |
|
||||
if (elementState.show) { |
|
||||
const show: boolean = this.computeValue(elementState.show, value); |
|
||||
elements.forEach(e => { |
|
||||
if (show) { |
|
||||
e.show(); |
|
||||
} else { |
|
||||
e.hide(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
if (elementState.addClass) { |
|
||||
elements.forEach(e => { |
|
||||
e.addClass(elementState.addClass); |
|
||||
}); |
|
||||
} |
|
||||
if (elementState.removeClass) { |
|
||||
elements.forEach(e => { |
|
||||
e.removeClass(elementState.removeClass); |
|
||||
}); |
|
||||
} |
|
||||
if (elementState.attributes) { |
|
||||
const attrs = this.computeAttributes(elementState.attributes, value); |
|
||||
elements.forEach(e => { |
|
||||
this.setElementAttributes(elementState, e, attrs, elementState.animate); |
|
||||
}); |
|
||||
} |
|
||||
if (elementState.text) { |
|
||||
if (elementState.text.content) { |
|
||||
const text: string = this.computeValue(elementState.text.content, value); |
|
||||
elements.forEach(e => { |
|
||||
this.setElementText(e, text); |
|
||||
}); |
|
||||
} |
|
||||
if (elementState.text.font || elementState.text.color) { |
|
||||
let font: Font = this.computeValue(elementState.text.font, value); |
|
||||
if (typeof font !== 'object') { |
|
||||
font = undefined; |
|
||||
} |
|
||||
let color: string = this.computeValue(elementState.text.color, value); |
|
||||
if (typeof color !== 'string') { |
|
||||
color = undefined; |
|
||||
} |
|
||||
elements.forEach(e => { |
|
||||
this.setElementFont(e, font, color); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private normalizeValue(value: any, type: ValueType): any { |
|
||||
if (isUndefinedOrNull(value)) { |
|
||||
switch (type) { |
|
||||
case ValueType.STRING: |
|
||||
return ''; |
|
||||
case ValueType.INTEGER: |
|
||||
case ValueType.DOUBLE: |
|
||||
return 0; |
|
||||
case ValueType.BOOLEAN: |
|
||||
return false; |
|
||||
case ValueType.JSON: |
|
||||
return {}; |
|
||||
} |
|
||||
} else { |
|
||||
return value; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private computeAttributes(attributes: ScadaObjectAttribute[], value: any): {[attr: string]: any} { |
|
||||
const res: {[attr: string]: any} = {}; |
|
||||
for (const attribute of attributes) { |
|
||||
const attr = attribute.name; |
|
||||
res[attr] = this.computeValue(attribute.value, value); |
|
||||
} |
|
||||
return res; |
|
||||
} |
|
||||
|
|
||||
private setElementAttributes(elementState: ScadaObjectElementState, element: Element, attrs: {[attr: string]: any}, animate?: number) { |
|
||||
if (isDefinedAndNotNull(animate)) { |
|
||||
this.animation(elementState, element, animate).attr(attrs); |
|
||||
} else { |
|
||||
element.attr(attrs); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private setElementText(element: Element, text: string) { |
|
||||
let textElement: Text; |
|
||||
if (element.type === 'text') { |
|
||||
const children = element.children(); |
|
||||
if (children.length && children[0].type === 'tspan') { |
|
||||
textElement = children[0] as Text; |
|
||||
} else { |
|
||||
textElement = element as Text; |
|
||||
} |
|
||||
} else if (element.type === 'tspan') { |
|
||||
textElement = element as Text; |
|
||||
} |
|
||||
if (textElement) { |
|
||||
textElement.text(text); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private setElementFont(element: Element, font: Font, color: string) { |
|
||||
if (element.type === 'text') { |
|
||||
const textElement = element as Text; |
|
||||
if (font) { |
|
||||
textElement.font({ |
|
||||
family: font.family, |
|
||||
size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? |
|
||||
font.size + font.sizeUnit : null, |
|
||||
weight: font.weight, |
|
||||
style: font.style |
|
||||
}); |
|
||||
} |
|
||||
if (color) { |
|
||||
textElement.fill(color); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private animation(elementState: ScadaObjectElementState, element: Element, duration: number): Runner { |
|
||||
if (!elementState.animationTimeline) { |
|
||||
elementState.animationTimeline = new Timeline(); |
|
||||
} |
|
||||
element.timeline(elementState.animationTimeline); |
|
||||
return element.animate(duration, 0, 'now'); |
|
||||
} |
|
||||
|
|
||||
private computeValue(objectValue: ScadaObjectValue, value: any): any { |
|
||||
if (objectValue) { |
|
||||
switch (objectValue.type) { |
|
||||
case 'input': |
|
||||
return value; |
|
||||
case 'constant': |
|
||||
return objectValue.constantValue; |
|
||||
case 'property': |
|
||||
const property = this.getProperty(objectValue.propertyId); |
|
||||
if (property.type === 'color-settings') { |
|
||||
const colorProcessor: ColorProcessor = objectValue.computedPropertyValue; |
|
||||
colorProcessor.update(value); |
|
||||
return colorProcessor.color; |
|
||||
} else { |
|
||||
return objectValue.computedPropertyValue; |
|
||||
} |
|
||||
case 'function': |
|
||||
try { |
|
||||
return objectValue.valueConverter(value); |
|
||||
} catch (_e) { |
|
||||
return value; |
|
||||
} |
|
||||
case 'valueFormat': |
|
||||
let units = ''; |
|
||||
let decimals = 0; |
|
||||
if (objectValue.units) { |
|
||||
units = this.computeValue(objectValue.units, value); |
|
||||
} |
|
||||
if (objectValue.decimals) { |
|
||||
decimals = this.computeValue(objectValue.decimals, value); |
|
||||
} |
|
||||
return formatValue(value, decimals, units, false); |
|
||||
} |
|
||||
} else { |
|
||||
return ''; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private getProperty(id: string): ScadaObjectProperty { |
|
||||
return this.metadata.properties.find(p => p.id === id); |
|
||||
} |
|
||||
|
|
||||
private getPropertyValue(id: string): any { |
|
||||
const property = this.getProperty(id); |
|
||||
if (property) { |
|
||||
const value = this.settings[id]; |
|
||||
if (isDefinedAndNotNull(value)) { |
|
||||
if (property.type === 'color-settings') { |
|
||||
return ColorProcessor.fromSettings(value); |
|
||||
} |
|
||||
return value; |
|
||||
} else { |
|
||||
switch (property.type) { |
|
||||
case 'string': |
|
||||
return ''; |
|
||||
case 'number': |
|
||||
return 0; |
|
||||
case 'color': |
|
||||
return '#000'; |
|
||||
case 'color-settings': |
|
||||
return constantColor('#000'); |
|
||||
} |
|
||||
} |
|
||||
} else { |
|
||||
return ''; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,634 @@ |
|||||
|
///
|
||||
|
/// 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 { ValueType } from '@shared/models/constants'; |
||||
|
import { Box, Element, Runner, Svg, SVG, Text } from '@svgdotjs/svg.js'; |
||||
|
import { |
||||
|
DataToValueType, |
||||
|
GetValueAction, |
||||
|
GetValueSettings, |
||||
|
SetValueAction, |
||||
|
SetValueSettings, |
||||
|
ValueToDataType |
||||
|
} from '@shared/models/action-widget-settings.models'; |
||||
|
import { formatValue, isDefinedAndNotNull, isUndefinedOrNull, mergeDeep, parseFunction } from '@core/utils'; |
||||
|
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'; |
||||
|
import { ColorProcessor, constantColor, Font } from '@shared/models/widget-settings.models'; |
||||
|
import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; |
||||
|
import { UtilsService } from '@core/services/utils.service'; |
||||
|
import { WidgetAction, WidgetActionType } from '@shared/models/widget.models'; |
||||
|
|
||||
|
export interface IotSvgApi { |
||||
|
formatValue: (value: any, dec?: number, units?: string, showZeroDecimals?: boolean) => string | undefined; |
||||
|
text: (element: Element | Element[], text: string) => void; |
||||
|
font: (element: Element | Element[], font: Font, color: string) => void; |
||||
|
animate: (element: Element, duration: number) => Runner; |
||||
|
disable: (element: Element | Element[]) => void; |
||||
|
enable: (element: Element | Element[]) => void; |
||||
|
callAction: (event: Event, behaviorId: string, value?: any, observer?: Partial<Observer<void>>) => void; |
||||
|
setValue: (valueId: string, value: any) => void; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgContext { |
||||
|
api: IotSvgApi; |
||||
|
tags: {[id: string]: Element[]}; |
||||
|
values: {[id: string]: any}; |
||||
|
properties: {[id: string]: any}; |
||||
|
} |
||||
|
|
||||
|
export type IotSvgStateRenderFunction = (svg: Svg, ctx: IotSvgContext) => void; |
||||
|
|
||||
|
export type IotSvgTagStateRenderFunction = (element: Element, ctx: IotSvgContext) => void; |
||||
|
|
||||
|
export type IotSvgActionTrigger = 'click'; |
||||
|
|
||||
|
export type IotSvgActionFunction = (event: Event, ctx: IotSvgContext) => void; |
||||
|
export interface IotSvgAction { |
||||
|
actionFunction?: string; |
||||
|
action?: IotSvgActionFunction; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgTag { |
||||
|
tag: string; |
||||
|
stateRenderFunction?: string; |
||||
|
stateRender?: IotSvgTagStateRenderFunction; |
||||
|
actions?: {[trigger: string]: IotSvgAction}; |
||||
|
} |
||||
|
|
||||
|
export enum IotSvgBehaviorType { |
||||
|
value = 'value', |
||||
|
action = 'action', |
||||
|
widgetAction = 'widgetAction' |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgBehaviorBase { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: IotSvgBehaviorType; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgBehaviorValue extends IotSvgBehaviorBase { |
||||
|
valueType: ValueType; |
||||
|
defaultValue: any; |
||||
|
valueId: string; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgBehaviorAction extends IotSvgBehaviorBase { |
||||
|
valueToDataType: ValueToDataType; |
||||
|
constantValue: any; |
||||
|
valueToDataFunction: string; |
||||
|
} |
||||
|
|
||||
|
export type IotSvgBehavior = IotSvgBehaviorValue | IotSvgBehaviorAction; |
||||
|
|
||||
|
export type IotSvgPropertyType = 'string' | 'number' | 'color' | 'color-settings' | 'font' | 'units' | 'switch'; |
||||
|
|
||||
|
export interface IotSvgPropertyBase { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: IotSvgPropertyType; |
||||
|
default: any; |
||||
|
required?: boolean; |
||||
|
subLabel?: string; |
||||
|
divider?: boolean; |
||||
|
fieldSuffix?: string; |
||||
|
disableOnProperty?: string; |
||||
|
rowClass?: string; |
||||
|
fieldClass?: string; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgNumberProperty extends IotSvgPropertyBase { |
||||
|
min?: number; |
||||
|
max?: number; |
||||
|
step?: number; |
||||
|
} |
||||
|
|
||||
|
export type IotSvgProperty = IotSvgPropertyBase & IotSvgNumberProperty; |
||||
|
|
||||
|
export type ScadaObjectElementActionTrigger = 'click'; |
||||
|
|
||||
|
export type ScadaObjectActionType = 'updateValue'; |
||||
|
export type ScadaObjectActionUpdateValueType = 'toggle' | 'increment' | 'constant'; |
||||
|
|
||||
|
export interface ScadaObjectElementAction { |
||||
|
trigger: ScadaObjectElementActionTrigger; |
||||
|
enabledTriggerValues?: string[]; |
||||
|
enabledConditionFunction?: string; |
||||
|
enabledCondition?: (values: {[key: string]: any}) => boolean; |
||||
|
actionType: ScadaObjectActionType; |
||||
|
updateValueId?: string; |
||||
|
updateValueType?: ScadaObjectActionUpdateValueType; |
||||
|
updateValueConstant?: any; |
||||
|
updateValueInc?: number; |
||||
|
} |
||||
|
|
||||
|
export interface IotSvgMetadata { |
||||
|
title: string; |
||||
|
stateRenderFunction?: string; |
||||
|
stateRender?: IotSvgStateRenderFunction; |
||||
|
tags: IotSvgTag[]; |
||||
|
behavior: IotSvgBehavior[]; |
||||
|
properties: IotSvgProperty[]; |
||||
|
} |
||||
|
|
||||
|
export const emptyMetadata: IotSvgMetadata = { |
||||
|
title: '', |
||||
|
tags: [], |
||||
|
behavior: [], |
||||
|
properties: [] |
||||
|
}; |
||||
|
|
||||
|
|
||||
|
export const parseScadaObjectMetadataFromContent = (svgContent: string): IotSvgMetadata => { |
||||
|
try { |
||||
|
const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); |
||||
|
return parseScadaObjectMetadataFromDom(svgDoc); |
||||
|
} catch (_e) { |
||||
|
return emptyMetadata; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const parseScadaObjectMetadataFromDom = (svgDoc: Document): IotSvgMetadata => { |
||||
|
try { |
||||
|
const elements = svgDoc.getElementsByTagName('tb:metadata'); |
||||
|
if (elements.length) { |
||||
|
return JSON.parse(elements[0].textContent); |
||||
|
} else { |
||||
|
return emptyMetadata; |
||||
|
} |
||||
|
} catch (_e) { |
||||
|
console.error(_e); |
||||
|
return emptyMetadata; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const defaultGetValueSettings = (get: IotSvgBehaviorValue): GetValueSettings<any> => ({ |
||||
|
action: GetValueAction.DO_NOTHING, |
||||
|
defaultValue: get.defaultValue, |
||||
|
executeRpc: { |
||||
|
method: 'getState', |
||||
|
requestTimeout: 5000, |
||||
|
requestPersistent: false, |
||||
|
persistentPollingInterval: 1000 |
||||
|
}, |
||||
|
getAttribute: { |
||||
|
key: 'state', |
||||
|
scope: null |
||||
|
}, |
||||
|
getTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
dataToValue: { |
||||
|
type: DataToValueType.NONE, |
||||
|
compareToValue: true, |
||||
|
dataToValueFunction: '/* Should return boolean value */\nreturn data;' |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const defaultSetValueSettings = (set: IotSvgBehaviorAction): SetValueSettings => ({ |
||||
|
action: SetValueAction.EXECUTE_RPC, |
||||
|
executeRpc: { |
||||
|
method: 'setState', |
||||
|
requestTimeout: 5000, |
||||
|
requestPersistent: false, |
||||
|
persistentPollingInterval: 1000 |
||||
|
}, |
||||
|
setAttribute: { |
||||
|
key: 'state', |
||||
|
scope: AttributeScope.SERVER_SCOPE |
||||
|
}, |
||||
|
putTimeSeries: { |
||||
|
key: 'state' |
||||
|
}, |
||||
|
valueToData: { |
||||
|
type: set.valueToDataType, |
||||
|
constantValue: set.constantValue, |
||||
|
valueToDataFunction: set.valueToDataFunction ? set.valueToDataFunction : |
||||
|
'/* Convert input boolean value to RPC parameters or attribute/time-series value */\nreturn value;' |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const defaultWidgetActionSettings = (widgetAction: IotSvgBehavior): WidgetAction => ({ |
||||
|
type: WidgetActionType.updateDashboardState, |
||||
|
targetDashboardStateId: null, |
||||
|
openRightLayout: false, |
||||
|
setEntityId: true, |
||||
|
stateEntityParamName: null |
||||
|
}); |
||||
|
|
||||
|
export const defaultScadaObjectSettings = (metadata: IotSvgMetadata): IotSvgObjectSettings => { |
||||
|
const settings: IotSvgObjectSettings = {}; |
||||
|
for (const behavior of metadata.behavior) { |
||||
|
if (behavior.type === IotSvgBehaviorType.value) { |
||||
|
settings[behavior.id] = defaultGetValueSettings(behavior as IotSvgBehaviorValue); |
||||
|
} else if (behavior.type === IotSvgBehaviorType.action) { |
||||
|
settings[behavior.id] = defaultSetValueSettings(behavior as IotSvgBehaviorAction); |
||||
|
} else if (behavior.type === IotSvgBehaviorType.widgetAction) { |
||||
|
settings[behavior.id] = defaultWidgetActionSettings(behavior); |
||||
|
} |
||||
|
} |
||||
|
for (const property of metadata.properties) { |
||||
|
settings[property.id] = property.default; |
||||
|
} |
||||
|
return settings; |
||||
|
}; |
||||
|
|
||||
|
export type IotSvgObjectSettings = {[id: string]: any}; |
||||
|
|
||||
|
const parseError = (ctx: WidgetContext, err: any): string => |
||||
|
ctx.$injector.get(UtilsService).parseException(err).message || 'Unknown Error'; |
||||
|
|
||||
|
export class IotSvgObject { |
||||
|
|
||||
|
private metadata: IotSvgMetadata; |
||||
|
private settings: IotSvgObjectSettings; |
||||
|
private context: IotSvgContext; |
||||
|
|
||||
|
private rootElement: HTMLElement; |
||||
|
private svgShape: Svg; |
||||
|
private box: Box; |
||||
|
private targetWidth: number; |
||||
|
private targetHeight: number; |
||||
|
|
||||
|
private loadingSubject = new BehaviorSubject(false); |
||||
|
private valueGetters: ValueGetter<any>[] = []; |
||||
|
private valueActions: ValueAction[] = []; |
||||
|
private valueSetters: {[behaviorId: string]: ValueSetter<any>} = {}; |
||||
|
|
||||
|
private stateValueSubjects: {[id: string]: BehaviorSubject<any>} = {}; |
||||
|
|
||||
|
loading$ = this.loadingSubject.asObservable().pipe(share()); |
||||
|
|
||||
|
private _onError: (error: string) => void = () => {}; |
||||
|
|
||||
|
constructor(private ctx: WidgetContext, |
||||
|
private svgPath: string, |
||||
|
private inputSettings: IotSvgObjectSettings) {} |
||||
|
|
||||
|
public init(): Observable<any> { |
||||
|
return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( |
||||
|
map((inputSvgContent) => { |
||||
|
const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); |
||||
|
this.metadata = parseScadaObjectMetadataFromDom(doc); |
||||
|
const defaults = defaultScadaObjectSettings(this.metadata); |
||||
|
this.settings = mergeDeep<IotSvgObjectSettings>({}, defaults, this.inputSettings || {}); |
||||
|
this.prepareMetadata(); |
||||
|
this.prepareSvgShape(doc); |
||||
|
this.initialize(); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public onError(onError: (error: string) => void) { |
||||
|
this._onError = onError; |
||||
|
} |
||||
|
|
||||
|
public addTo(element: HTMLElement) { |
||||
|
this.rootElement = element; |
||||
|
if (this.svgShape) { |
||||
|
this.svgShape.addTo(element); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public destroy() { |
||||
|
for (const stateValueId of Object.keys(this.stateValueSubjects)) { |
||||
|
this.stateValueSubjects[stateValueId].complete(); |
||||
|
this.stateValueSubjects[stateValueId].unsubscribe(); |
||||
|
} |
||||
|
this.valueActions.forEach(v => v.destroy()); |
||||
|
this.loadingSubject.complete(); |
||||
|
this.loadingSubject.unsubscribe(); |
||||
|
} |
||||
|
|
||||
|
public setSize(targetWidth: number, targetHeight: number) { |
||||
|
this.targetWidth = targetWidth; |
||||
|
this.targetHeight = targetHeight; |
||||
|
if (this.svgShape) { |
||||
|
this.resize(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private prepareMetadata() { |
||||
|
this.metadata.stateRender = parseFunction(this.metadata.stateRenderFunction, ['svg', 'ctx']) || (() => {}); |
||||
|
for (const tag of this.metadata.tags) { |
||||
|
tag.stateRender = parseFunction(tag.stateRenderFunction, ['element', 'ctx']) || (() => {}); |
||||
|
if (tag.actions) { |
||||
|
for (const trigger of Object.keys(tag.actions)) { |
||||
|
const action = tag.actions[trigger]; |
||||
|
action.action = parseFunction(action.actionFunction, ['event', 'ctx']) || (() => {}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private prepareSvgShape(doc: XMLDocument) { |
||||
|
const elements = doc.getElementsByTagName('tb:metadata'); |
||||
|
for (let i=0;i<elements.length;i++) { |
||||
|
elements.item(i).remove(); |
||||
|
} |
||||
|
this.svgShape = SVG().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); |
||||
|
if (this.rootElement) { |
||||
|
this.svgShape.addTo(this.rootElement); |
||||
|
} |
||||
|
if (this.targetWidth && this.targetHeight) { |
||||
|
this.resize(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private initialize() { |
||||
|
this.context = { |
||||
|
api: { |
||||
|
formatValue, |
||||
|
text: this.setElementText.bind(this), |
||||
|
font: this.setElementFont.bind(this), |
||||
|
animate: this.animate.bind(this), |
||||
|
disable: this.disableElement.bind(this), |
||||
|
enable: this.enableElement.bind(this), |
||||
|
callAction: this.callAction.bind(this), |
||||
|
setValue: this.setValue.bind(this) |
||||
|
}, |
||||
|
tags: {}, |
||||
|
properties: {}, |
||||
|
values: {} |
||||
|
}; |
||||
|
const taggedElements = this.svgShape.find(`[tb\\:tag]`); |
||||
|
for (const element of taggedElements) { |
||||
|
const tag = element.attr('tb:tag'); |
||||
|
let elements = this.context.tags[tag]; |
||||
|
if (!elements) { |
||||
|
elements = []; |
||||
|
this.context.tags[tag] = elements; |
||||
|
} |
||||
|
elements.push(element); |
||||
|
} |
||||
|
for (const property of this.metadata.properties) { |
||||
|
this.context.properties[property.id] = this.getPropertyValue(property.id); |
||||
|
} |
||||
|
for (const tag of this.metadata.tags) { |
||||
|
if (tag.actions) { |
||||
|
const elements = this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); |
||||
|
for (const trigger of Object.keys(tag.actions)) { |
||||
|
const action = tag.actions[trigger]; |
||||
|
elements.forEach(element => { |
||||
|
element.attr('cursor', 'pointer'); |
||||
|
element.on(trigger, (event) => { |
||||
|
action.action(event, this.context); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
for (const behavior of this.metadata.behavior) { |
||||
|
if (behavior.type === IotSvgBehaviorType.value) { |
||||
|
const getBehavior = behavior as IotSvgBehaviorValue; |
||||
|
let getValueSettings: GetValueSettings<any> = this.settings[getBehavior.id]; |
||||
|
getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; |
||||
|
const stateValueSubject = new BehaviorSubject<any>(getValueSettings.defaultValue); |
||||
|
this.stateValueSubjects[getBehavior.valueId] = stateValueSubject; |
||||
|
this.context.values[getBehavior.valueId] = getValueSettings.defaultValue; |
||||
|
stateValueSubject.subscribe((value) => { |
||||
|
this.onStateValueChanged(getBehavior.valueId, value); |
||||
|
}); |
||||
|
const valueGetter = |
||||
|
ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { |
||||
|
next: (val) => {this.onValue(getBehavior.id, val);}, |
||||
|
error: (err) => { |
||||
|
const message = parseError(this.ctx, err); |
||||
|
this._onError(message); |
||||
|
} |
||||
|
}); |
||||
|
this.valueGetters.push(valueGetter); |
||||
|
this.valueActions.push(valueGetter); |
||||
|
} else if (behavior.type === IotSvgBehaviorType.action) { |
||||
|
const setBehavior = behavior as IotSvgBehaviorAction; |
||||
|
let setValueSettings: SetValueSettings = this.settings[setBehavior.id]; |
||||
|
setValueSettings = {...setValueSettings, actionLabel: setBehavior.name}; |
||||
|
const valueSetter = ValueSetter.fromSettings<any>(this.ctx, setValueSettings); |
||||
|
this.valueSetters[setBehavior.id] = valueSetter; |
||||
|
this.valueActions.push(valueSetter); |
||||
|
} else if (behavior.type === IotSvgBehaviorType.widgetAction) { |
||||
|
// TODO:
|
||||
|
} |
||||
|
} |
||||
|
this.renderState(); |
||||
|
if (this.valueGetters.length) { |
||||
|
const getValueObservables: Array<Observable<any>> = []; |
||||
|
this.valueGetters.forEach(valueGetter => { |
||||
|
getValueObservables.push(valueGetter.getValue()); |
||||
|
}); |
||||
|
this.loadingSubject.next(true); |
||||
|
forkJoin(getValueObservables).subscribe( |
||||
|
{ |
||||
|
next: () => { |
||||
|
this.loadingSubject.next(false); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.loadingSubject.next(false); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private callAction(event: Event, behaviorId: string, value?: any, observer?: Partial<Observer<void>>) { |
||||
|
const behavior = this.metadata.behavior.find(b => b.id === behaviorId); |
||||
|
if (behavior) { |
||||
|
if (behavior.type === IotSvgBehaviorType.action) { |
||||
|
const valueSetter = this.valueSetters[behaviorId]; |
||||
|
if (valueSetter) { |
||||
|
this.loadingSubject.next(true); |
||||
|
valueSetter.setValue(value).subscribe( |
||||
|
{ |
||||
|
next: () => { |
||||
|
if (observer?.next) { |
||||
|
observer.next(); |
||||
|
} |
||||
|
this.loadingSubject.next(false); |
||||
|
}, |
||||
|
error: (err) => { |
||||
|
this.loadingSubject.next(false); |
||||
|
if (observer?.error) { |
||||
|
observer.error(err); |
||||
|
} |
||||
|
const message = parseError(this.ctx, err); |
||||
|
this._onError(message); |
||||
|
} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
} else if (behavior.type === IotSvgBehaviorType.widgetAction) { |
||||
|
const widgetAction: WidgetAction = this.settings[behavior.id]; |
||||
|
this.ctx.actionsApi.onWidgetAction(event, widgetAction); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private resize() { |
||||
|
let scale: number; |
||||
|
if (this.targetWidth < this.targetHeight) { |
||||
|
scale = this.targetWidth / this.box.width; |
||||
|
} else { |
||||
|
scale = this.targetHeight / this.box.height; |
||||
|
} |
||||
|
this.svgShape.node.style.transform = `scale(${scale})`; |
||||
|
} |
||||
|
|
||||
|
private onValue(id: string, value: any) { |
||||
|
const valueBehavior = this.metadata.behavior.find(b => b.id === id) as IotSvgBehaviorValue; |
||||
|
value = this.normalizeValue(value, valueBehavior.valueType); |
||||
|
const valueId = valueBehavior.valueId; |
||||
|
this.setValue(valueId, value); |
||||
|
} |
||||
|
|
||||
|
private setValue(valueId: string, value: any) { |
||||
|
const stateValueSubject = this.stateValueSubjects[valueId]; |
||||
|
if (stateValueSubject && stateValueSubject.value !== value) { |
||||
|
stateValueSubject.next(value); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private onStateValueChanged(id: string, value: any) { |
||||
|
if (this.context.values[id] !== value) { |
||||
|
this.context.values[id] = value; |
||||
|
this.renderState(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private renderState(): void { |
||||
|
this.metadata.stateRender(this.svgShape, this.context); |
||||
|
for (const tag of this.metadata.tags) { |
||||
|
const elements = this.svgShape.find(`[tb\\:tag="${tag.tag}"]`); |
||||
|
elements.forEach(element => { |
||||
|
tag.stateRender(element, this.context); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private normalizeValue(value: any, type: ValueType): any { |
||||
|
if (isUndefinedOrNull(value)) { |
||||
|
switch (type) { |
||||
|
case ValueType.STRING: |
||||
|
return ''; |
||||
|
case ValueType.INTEGER: |
||||
|
case ValueType.DOUBLE: |
||||
|
return 0; |
||||
|
case ValueType.BOOLEAN: |
||||
|
return false; |
||||
|
case ValueType.JSON: |
||||
|
return {}; |
||||
|
} |
||||
|
} else { |
||||
|
return value; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private setElementText(e: Element | Element[], text: string) { |
||||
|
this.elements(e).forEach(element => { |
||||
|
let textElement: Text; |
||||
|
if (element.type === 'text') { |
||||
|
const children = element.children(); |
||||
|
if (children.length && children[0].type === 'tspan') { |
||||
|
textElement = children[0] as Text; |
||||
|
} else { |
||||
|
textElement = element as Text; |
||||
|
} |
||||
|
} else if (element.type === 'tspan') { |
||||
|
textElement = element as Text; |
||||
|
} |
||||
|
if (textElement) { |
||||
|
textElement.text(text); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private setElementFont(e: Element | Element[], font: Font, color: string) { |
||||
|
this.elements(e).forEach(element => { |
||||
|
if (element.type === 'text') { |
||||
|
const textElement = element as Text; |
||||
|
if (font) { |
||||
|
textElement.font({ |
||||
|
family: font.family, |
||||
|
size: (isDefinedAndNotNull(font.size) && isDefinedAndNotNull(font.sizeUnit)) ? |
||||
|
font.size + font.sizeUnit : null, |
||||
|
weight: font.weight, |
||||
|
style: font.style |
||||
|
}); |
||||
|
} |
||||
|
if (color) { |
||||
|
textElement.fill(color); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private animate(element: Element, duration: number): Runner { |
||||
|
element.timeline().finish(); |
||||
|
return element.animate(duration, 0, 'now'); |
||||
|
} |
||||
|
|
||||
|
private disableElement(e: Element | Element[]) { |
||||
|
this.elements(e).forEach(element => { |
||||
|
element.attr({'pointer-events': 'none'}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private enableElement(e: Element | Element[]) { |
||||
|
this.elements(e).forEach(element => { |
||||
|
element.attr({'pointer-events': null}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private elements(element: Element | Element[]): Element[] { |
||||
|
return Array.isArray(element) ? element : [element]; |
||||
|
} |
||||
|
|
||||
|
private getProperty(id: string): IotSvgProperty { |
||||
|
return this.metadata.properties.find(p => p.id === id); |
||||
|
} |
||||
|
|
||||
|
private getPropertyValue(id: string): any { |
||||
|
const property = this.getProperty(id); |
||||
|
if (property) { |
||||
|
const value = this.settings[id]; |
||||
|
if (isDefinedAndNotNull(value)) { |
||||
|
if (property.type === 'color-settings') { |
||||
|
return ColorProcessor.fromSettings(value); |
||||
|
} |
||||
|
return value; |
||||
|
} else { |
||||
|
switch (property.type) { |
||||
|
case 'string': |
||||
|
return ''; |
||||
|
case 'number': |
||||
|
return 0; |
||||
|
case 'color': |
||||
|
return '#000'; |
||||
|
case 'color-settings': |
||||
|
return constantColor('#000'); |
||||
|
} |
||||
|
} |
||||
|
} else { |
||||
|
return ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
Loading…
Reference in new issue