22 changed files with 2469 additions and 12 deletions
@ -0,0 +1,45 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2019 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. |
|||
|
|||
--> |
|||
<div class="tb-knob" fxLayout="column" [ngStyle]="{'pointerEvents': ctx.isEdit ? 'none' : 'all'}"> |
|||
<div #knobContainer id="knob-container" fxFlex fxLayout="column" fxLayoutAlign="center center"> |
|||
<div #knob class="knob"> |
|||
<div #knobValueContainer class="value-container" fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #knobValue class="knob-value">{{ value }}</span> |
|||
</div> |
|||
<div class="canvas-background"></div> |
|||
<canvas #canvasBar id="canvasBar"></canvas> |
|||
<div #knobTopPointerContainer class="top-pointer-container"> |
|||
<div #knobTopPointer class="top-pointer"></div> |
|||
</div> |
|||
<div class="top"></div> |
|||
<div #knobErrorContainer class="error-container" [ngStyle]="{'background': error?.length ? 'rgba(255,255,255,0.25)' : 'none'}" |
|||
fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #knobError class="knob-error">{{ error }}</span> |
|||
</div> |
|||
<div #knobTitleContainer class="title-container" fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #knobTitle class="knob-title">{{ title }}</span> |
|||
</div> |
|||
<div #knobMinmaxContainer class="minmax-container" fxLayout="row" fxLayoutAlign="start center"> |
|||
<span class="minmax-label">min</span> |
|||
<span fxFlex></span> |
|||
<span class="minmax-label">max</span> |
|||
</div> |
|||
<div #textMeasure id="text-measure"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,168 @@ |
|||
/** |
|||
* Copyright © 2016-2019 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. |
|||
*/ |
|||
$knob-img: url("./svg/knob.svg") !default; |
|||
|
|||
$bars-margin-pct: percentage(.033) !default; |
|||
$background-margin-pct: percentage(.05) !default; |
|||
$value-container-margin-pct: percentage(.35) !default; |
|||
$error-height: percentage(.05) !default; |
|||
$title-height: percentage(.066) !default; |
|||
$title-container-margin-pct: percentage(.2) !default; |
|||
$title-container-margin-bottom-pct: percentage(.05) !default; |
|||
$minmax-height: percentage(.04) !default; |
|||
$minmax-container-margin-pct: percentage(.18) !default; |
|||
$minmax-container-margin-bottom-pct: percentage(.12) !default; |
|||
|
|||
$background-color: #e6e7e8 !default; |
|||
|
|||
:host { |
|||
.tb-knob { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $background-color; |
|||
|
|||
.knob { |
|||
position: relative; |
|||
|
|||
&[draggable] { |
|||
user-select: none; |
|||
} |
|||
|
|||
#canvasBar { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
left: 0; |
|||
z-index: 2; |
|||
} |
|||
|
|||
.canvas-background { |
|||
position: absolute; |
|||
top: $background-margin-pct; |
|||
right: $background-margin-pct; |
|||
bottom: $background-margin-pct; |
|||
left: $background-margin-pct; |
|||
z-index: 2; |
|||
background: #3f4346; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
.value-container { |
|||
position: absolute; |
|||
top: $value-container-margin-pct; |
|||
right: $value-container-margin-pct; |
|||
bottom: $value-container-margin-pct; |
|||
left: $value-container-margin-pct; |
|||
z-index: 4; |
|||
|
|||
.knob-value { |
|||
font-weight: 500; |
|||
color: #fff; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.error-container { |
|||
position: absolute; |
|||
top: 1%; |
|||
right: 0; |
|||
left: 0; |
|||
z-index: 4; |
|||
height: $error-height; |
|||
|
|||
.knob-error { |
|||
color: #ff3315; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.title-container { |
|||
position: absolute; |
|||
right: $title-container-margin-pct; |
|||
bottom: $title-container-margin-bottom-pct; |
|||
left: $title-container-margin-pct; |
|||
z-index: 4; |
|||
height: $title-height; |
|||
|
|||
.knob-title { |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.minmax-container { |
|||
position: absolute; |
|||
right: $minmax-container-margin-pct; |
|||
bottom: $minmax-container-margin-bottom-pct; |
|||
left: $minmax-container-margin-pct; |
|||
z-index: 4; |
|||
height: $minmax-height; |
|||
|
|||
.minmax-label { |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.top-pointer-container { |
|||
position: absolute; |
|||
top: $bars-margin-pct; |
|||
right: $bars-margin-pct; |
|||
bottom: $bars-margin-pct; |
|||
left: $bars-margin-pct; |
|||
z-index: 3; |
|||
cursor: pointer !important; |
|||
|
|||
.top-pointer { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 22%; |
|||
width: 5%; |
|||
height: 5%; |
|||
margin-top: -2.5%; |
|||
cursor: pointer !important; |
|||
content: ""; |
|||
background-color: #b5b5b5; |
|||
border-radius: 50%; |
|||
box-shadow: 1px 0 2px #040404; |
|||
} |
|||
} |
|||
|
|||
.top { |
|||
position: absolute; |
|||
top: $bars-margin-pct; |
|||
right: $bars-margin-pct; |
|||
bottom: $bars-margin-pct; |
|||
left: $bars-margin-pct; |
|||
z-index: 2; |
|||
cursor: pointer !important; |
|||
background: $knob-img no-repeat; |
|||
background-size: contain; |
|||
} |
|||
|
|||
#text-measure { |
|||
position: absolute; |
|||
width: auto; |
|||
height: auto; |
|||
white-space: nowrap; |
|||
visibility: hidden; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,435 @@ |
|||
///
|
|||
/// Copyright © 2016-2019 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { isDefined, isNumber } from '@core/utils'; |
|||
import { CanvasDigitalGauge, CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge'; |
|||
import GenericOptions = CanvasGauges.GenericOptions; |
|||
import * as tinycolor_ from 'tinycolor2'; |
|||
|
|||
const tinycolor = tinycolor_; |
|||
|
|||
interface KnobSettings { |
|||
minValue: number; |
|||
maxValue: number; |
|||
initialValue: number; |
|||
title: string; |
|||
getValueMethod: string; |
|||
setValueMethod: string; |
|||
requestTimeout: number; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-knob', |
|||
templateUrl: './knob.component.html', |
|||
styleUrls: ['./knob.component.scss'] |
|||
}) |
|||
export class KnobComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
@ViewChild('knob', {static: true}) knobRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobContainer', {static: true}) knobContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobTopPointerContainer', {static: true}) knobTopPointerContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobTopPointer', {static: true}) knobTopPointerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobValueContainer', {static: true}) knobValueContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobValue', {static: true}) knobValueRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobErrorContainer', {static: true}) knobErrorContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobError', {static: true}) knobErrorRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobTitleContainer', {static: true}) knobTitleContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobTitle', {static: true}) knobTitleRef: ElementRef<HTMLElement>; |
|||
@ViewChild('knobMinmaxContainer', {static: true}) knobMinmaxContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef<HTMLElement>; |
|||
@ViewChild('canvasBar', {static: true}) canvasBarElementRef: ElementRef<HTMLElement>; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
value = '0'; |
|||
error = ''; |
|||
title = ''; |
|||
minValue: number; |
|||
maxValue: number; |
|||
|
|||
private startDeg = -1; |
|||
private currentDeg = 0; |
|||
private rotation = 0; |
|||
private lastDeg = 0; |
|||
private moving = false; |
|||
|
|||
private minDeg = -45; |
|||
private maxDeg = 225; |
|||
|
|||
private isSimulated: boolean; |
|||
private requestTimeout: number; |
|||
private getValueMethod: string; |
|||
private setValueMethod: string; |
|||
|
|||
private executingUpdateValue: boolean; |
|||
private scheduledValue: number; |
|||
private rpcValue: number; |
|||
|
|||
private knob: JQuery<HTMLElement>; |
|||
private knobContainer: JQuery<HTMLElement>; |
|||
private knobTopPointerContainer: JQuery<HTMLElement>; |
|||
private knobTopPointer: JQuery<HTMLElement>; |
|||
private knobValueContainer: JQuery<HTMLElement>; |
|||
private knobValue: JQuery<HTMLElement>; |
|||
private knobTitleContainer: JQuery<HTMLElement>; |
|||
private knobTitle: JQuery<HTMLElement>; |
|||
private knobErrorContainer: JQuery<HTMLElement>; |
|||
private knobError: JQuery<HTMLElement>; |
|||
private knobMinmaxContainer: JQuery<HTMLElement>; |
|||
private minmaxLabel: JQuery<HTMLElement>; |
|||
private textMeasure: JQuery<HTMLElement>; |
|||
private canvasBarElement: HTMLElement; |
|||
|
|||
private canvasBar: CanvasDigitalGauge; |
|||
|
|||
private knobResizeListener: any; |
|||
|
|||
constructor(private utils: UtilsService, |
|||
protected store: Store<AppState>) { |
|||
super(store); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.knob = $(this.knobRef.nativeElement); |
|||
this.knobContainer = $(this.knobContainerRef.nativeElement); |
|||
this.knobTopPointerContainer = $(this.knobTopPointerContainerRef.nativeElement); |
|||
this.knobTopPointer = $(this.knobTopPointerRef.nativeElement); |
|||
this.knobValueContainer = $(this.knobValueContainerRef.nativeElement); |
|||
this.knobValue = $(this.knobValueRef.nativeElement); |
|||
this.knobTitleContainer = $(this.knobTitleContainerRef.nativeElement); |
|||
this.knobTitle = $(this.knobTitleRef.nativeElement); |
|||
this.knobErrorContainer = $(this.knobErrorContainerRef.nativeElement); |
|||
this.knobError = $(this.knobErrorRef.nativeElement); |
|||
this.knobMinmaxContainer = $(this.knobMinmaxContainerRef.nativeElement); |
|||
this.minmaxLabel = this.knobMinmaxContainer.find('.minmax-label'); |
|||
this.textMeasure = $(this.textMeasureRef.nativeElement); |
|||
this.canvasBarElement = this.canvasBarElementRef.nativeElement; |
|||
|
|||
this.knobResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener); |
|||
this.init(); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.knobResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener); |
|||
} |
|||
} |
|||
|
|||
private init() { |
|||
const settings: KnobSettings = this.ctx.settings; |
|||
|
|||
this.minValue = isDefined(settings.minValue) ? settings.minValue : 0; |
|||
this.maxValue = isDefined(settings.maxValue) ? settings.maxValue : 100; |
|||
this.title = isDefined(settings.title) ? settings.title : ''; |
|||
|
|||
const levelColors = ['#19ff4b', '#ffff19', '#ff3232']; |
|||
|
|||
const canvasBarData: CanvasDigitalGaugeOptions = { |
|||
renderTo: this.canvasBarElement, |
|||
hideValue: true, |
|||
neonGlowBrightness: 0, |
|||
gaugeWidthScale: 0.4, |
|||
gaugeColor: 'rgba(0, 0, 0, 0)', |
|||
levelColors, |
|||
minValue: this.minValue, |
|||
maxValue: this.maxValue, |
|||
gaugeType: 'donut', |
|||
dashThickness: 2, |
|||
donutStartAngle: 3/4*Math.PI, |
|||
donutEndAngle: 9/4*Math.PI, |
|||
animation: false |
|||
}; |
|||
|
|||
this.canvasBar = new CanvasDigitalGauge(canvasBarData).draw(); |
|||
|
|||
this.knob.on('click', (e) => { |
|||
if (this.moving) { |
|||
this.moving = false; |
|||
return false; |
|||
} |
|||
e.preventDefault(); |
|||
|
|||
const offset = this.knob.offset(); |
|||
const center = { |
|||
y : offset.top + this.knob.height()/2, |
|||
x: offset.left + this.knob.width()/2 |
|||
}; |
|||
const rad2deg = 180/Math.PI; |
|||
const t: Touch = ((e.originalEvent as any).touches) ? (e.originalEvent as any).touches[0] : e; |
|||
|
|||
const a = center.y - t.pageY; |
|||
const b = center.x - t.pageX; |
|||
let deg = Math.atan2(a,b)*rad2deg; |
|||
if(deg < 0){ |
|||
deg = 360 + deg; |
|||
} |
|||
if (deg > this.maxDeg) { |
|||
if (deg - 360 > this.minDeg) { |
|||
deg = deg - 360; |
|||
} else { |
|||
return false; |
|||
} |
|||
} |
|||
this.currentDeg = deg; |
|||
this.lastDeg = deg; |
|||
this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); |
|||
this.turn(this.degreeToRatio(this.currentDeg)); |
|||
this.rotation = this.currentDeg; |
|||
this.startDeg = -1; |
|||
}); |
|||
|
|||
this.knob.on('mousedown touchstart', (e) => { |
|||
e.preventDefault(); |
|||
const offset = this.knob.offset(); |
|||
const center = { |
|||
y : offset.top + this.knob.height()/2, |
|||
x: offset.left + this.knob.width()/2 |
|||
}; |
|||
const rad2deg = 180/Math.PI; |
|||
|
|||
this.knob.on('mousemove.rem touchmove.rem', (ev) => { |
|||
this.moving = true; |
|||
const t: Touch = ((ev.originalEvent as any).touches) ? (ev.originalEvent as any).touches[0] : ev; |
|||
|
|||
const a = center.y - t.pageY; |
|||
const b = center.x - t.pageX; |
|||
let deg = Math.atan2(a,b)*rad2deg; |
|||
if(deg < 0){ |
|||
deg = 360 + deg; |
|||
} |
|||
|
|||
if(this.startDeg === -1){ |
|||
this.startDeg = deg; |
|||
} |
|||
|
|||
let tmp = Math.floor((deg-this.startDeg) + this.rotation); |
|||
|
|||
if(tmp < 0){ |
|||
tmp = 360 + tmp; |
|||
} |
|||
else if(tmp > 359){ |
|||
tmp = tmp % 360; |
|||
} |
|||
|
|||
if (tmp > this.maxDeg) { |
|||
if (tmp - 360 > this.minDeg) { |
|||
tmp = tmp - 360; |
|||
} else { |
|||
const deltaMax = Math.abs(this.maxDeg - this.lastDeg); |
|||
const deltaMin = Math.abs(this.minDeg - this.lastDeg); |
|||
if (deltaMax < deltaMin) { |
|||
tmp = this.maxDeg; |
|||
} else { |
|||
tmp = this.minDeg; |
|||
} |
|||
} |
|||
} |
|||
if(Math.abs(tmp - this.lastDeg) > 180){ |
|||
this.startDeg = deg; |
|||
this.rotation = this.currentDeg; |
|||
return false; |
|||
} |
|||
|
|||
this.currentDeg = tmp; |
|||
this.lastDeg = tmp; |
|||
|
|||
this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); |
|||
this.turn(this.degreeToRatio(this.currentDeg)); |
|||
}); |
|||
|
|||
$(document).on('mouseup.rem touchend.rem',() => { |
|||
this.knob.off('.rem'); |
|||
$(document).off('.rem'); |
|||
this.rotation = this.currentDeg; |
|||
this.startDeg = -1; |
|||
}); |
|||
|
|||
}); |
|||
|
|||
const initialValue = isDefined(settings.initialValue) ? settings.initialValue : this.minValue; |
|||
this.setValue(initialValue); |
|||
|
|||
const subscription = this.ctx.defaultSubscription; |
|||
const rpcEnabled = subscription.rpcEnabled; |
|||
|
|||
this.isSimulated = this.utils.widgetEditMode; |
|||
|
|||
this.requestTimeout = 500; |
|||
if (settings.requestTimeout) { |
|||
this.requestTimeout = settings.requestTimeout; |
|||
} |
|||
this.getValueMethod = 'getValue'; |
|||
if (settings.getValueMethod && settings.getValueMethod.length) { |
|||
this.getValueMethod = settings.getValueMethod; |
|||
} |
|||
this.setValueMethod = 'setValue'; |
|||
if (settings.setValueMethod && settings.setValueMethod.length) { |
|||
this.setValueMethod = settings.setValueMethod; |
|||
} |
|||
if (!rpcEnabled) { |
|||
this.onError('Target device is not set!'); |
|||
} else { |
|||
if (!this.isSimulated) { |
|||
this.rpcRequestValue(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private degreeToRatio(degree: number): number { |
|||
return (degree-this.minDeg)/(this.maxDeg-this.minDeg); |
|||
} |
|||
|
|||
private ratioToDegree(ratio: number): number { |
|||
return this.minDeg + ratio*(this.maxDeg-this.minDeg); |
|||
} |
|||
|
|||
private turn(ratio: number) { |
|||
const value = Number((this.minValue + (this.maxValue - this.minValue)*ratio).toFixed(this.ctx.decimals)); |
|||
if (this.canvasBar.value !== value) { |
|||
this.canvasBar.value = value; |
|||
} |
|||
this.updateColor(this.canvasBar.getValueColor()); |
|||
this.onValue(value); |
|||
} |
|||
|
|||
private resize() { |
|||
const width = this.knobContainer.width(); |
|||
const height = this.knobContainer.height(); |
|||
const size = Math.min(width, height); |
|||
this.knob.css({width: size, height: size}); |
|||
this.canvasBar.update({width: size, height: size} as GenericOptions); |
|||
this.setFontSize(this.knobTitle, this.title, this.knobTitleContainer.height(), this.knobTitleContainer.width()); |
|||
this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); |
|||
const minmaxHeight = this.knobMinmaxContainer.height(); |
|||
this.minmaxLabel.css({fontSize: minmaxHeight+'px', lineHeight: minmaxHeight+'px'}); |
|||
this.checkValueSize(); |
|||
} |
|||
|
|||
private checkValueSize() { |
|||
const fontSize = this.knobValueContainer.height()/3.3; |
|||
const containerWidth = this.knobValueContainer.width(); |
|||
this.setFontSize(this.knobValue, this.value+'', fontSize, containerWidth); |
|||
} |
|||
|
|||
private setFontSize(element: JQuery<HTMLElement>, text: string, fontSize: number, maxWidth: number) { |
|||
let textWidth = this.measureTextWidth(text, fontSize); |
|||
while (textWidth > maxWidth) { |
|||
fontSize--; |
|||
textWidth = this.measureTextWidth(text, fontSize); |
|||
} |
|||
element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
} |
|||
|
|||
private measureTextWidth(text: string, fontSize: number): number { |
|||
this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
this.textMeasure.text(text); |
|||
return this.textMeasure.width(); |
|||
} |
|||
|
|||
private setValue(value: number) { |
|||
const ratio = (value-this.minValue) / (this.maxValue - this.minValue); |
|||
this.rotation = this.lastDeg = this.currentDeg = this.ratioToDegree(ratio); |
|||
this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)'); |
|||
if (this.canvasBar.value !== value) { |
|||
this.canvasBar.value = value; |
|||
} |
|||
this.updateColor(this.canvasBar.getValueColor()); |
|||
this.value = this.formatValue(value); |
|||
this.checkValueSize(); |
|||
} |
|||
|
|||
private updateColor(color: string) { |
|||
const glowColor = tinycolor(color).brighten(30).toHexString(); |
|||
this.knobValue.css({color: glowColor}); |
|||
const textShadow = `${color} 1px 1px 10px, ${glowColor} 1px 1px 10px`; |
|||
this.knobValue.css({textShadow}); |
|||
this.knobTopPointer.css({backgroundColor: glowColor}); |
|||
const boxShadow = `inset 1px 0 2px #040404, 1px 1px 8px 2px ${glowColor}`; |
|||
this.knobTopPointer.css({boxShadow}); |
|||
} |
|||
|
|||
private onValue(value: number) { |
|||
this.value = this.formatValue(value); |
|||
this.checkValueSize(); |
|||
this.rpcUpdateValue(value); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private formatValue(value: any): string { |
|||
return this.ctx.utils.formatValue(value, this.ctx.decimals, this.ctx.units, true); |
|||
} |
|||
|
|||
private rpcRequestValue() { |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( |
|||
(responseBody) => { |
|||
if (isNumber(responseBody)) { |
|||
const numValue = Number(Number(responseBody).toFixed(this.ctx.decimals)); |
|||
this.setValue(numValue); |
|||
} else { |
|||
const errorText = `Unable to parse response: ${responseBody}`; |
|||
this.onError(errorText); |
|||
} |
|||
}, |
|||
() => { |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private rpcUpdateValue(value: number) { |
|||
if (this.executingUpdateValue) { |
|||
this.scheduledValue = value; |
|||
return; |
|||
} else { |
|||
this.scheduledValue = null; |
|||
this.rpcValue = value; |
|||
this.executingUpdateValue = true; |
|||
} |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, value, this.requestTimeout).subscribe( |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { |
|||
this.rpcUpdateValue(this.scheduledValue); |
|||
} |
|||
}, |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private onError(error: string) { |
|||
this.error = error; |
|||
this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width()); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2019 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. |
|||
|
|||
--> |
|||
<div class="tb-led-indicator" fxLayout="column"> |
|||
<div fxFlex="20" #ledTitleContainer class="title-container" fxLayout="row" fxLayoutAlign="center center" [fxShow]="showTitle"> |
|||
<span #ledTitle class="led-title">{{title}}</span> |
|||
</div> |
|||
<div fxFlex="{{showTitle ? 80 : 100}}" |
|||
[ngStyle]="{paddingTop: showTitle ? '5px': '10px'}" #ledContainer id="led-container" fxLayout="column" fxLayoutAlign="center center"> |
|||
<div #led class="led"> |
|||
</div> |
|||
</div> |
|||
<div #ledErrorContainer class="error-container" [ngStyle]="{'background': error?.length ? 'rgba(255,255,255,0.25)' : 'none'}" |
|||
fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #ledError class="led-error">{{ error }}</span> |
|||
</div> |
|||
<div #textMeasure id="text-measure"></div> |
|||
</div> |
|||
@ -0,0 +1,74 @@ |
|||
/** |
|||
* Copyright © 2016-2019 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 "~compass-sass-mixins/lib/compass"; |
|||
|
|||
$error-height: 14px !default; |
|||
|
|||
$background-color: #e6e7e8 !default; |
|||
|
|||
:host { |
|||
.tb-led-indicator { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $background-color; |
|||
|
|||
.title-container { |
|||
.led-title { |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.error-container { |
|||
position: absolute; |
|||
top: 1%; |
|||
right: 0; |
|||
left: 0; |
|||
z-index: 4; |
|||
height: $error-height; |
|||
|
|||
.led-error { |
|||
color: #ff3315; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
#text-measure { |
|||
position: absolute; |
|||
width: auto; |
|||
height: auto; |
|||
white-space: nowrap; |
|||
visibility: hidden; |
|||
} |
|||
|
|||
#led-container { |
|||
padding: 10px; |
|||
|
|||
.led { |
|||
position: relative; |
|||
cursor: pointer; |
|||
background-image: radial-gradient(circle closest-corner at 50% 50%, transparent, rgba(0, 0, 0, .25)); |
|||
border-radius: 50%; |
|||
transition: background-color .5s, box-shadow .5s; |
|||
|
|||
&.disabled { |
|||
background-image: radial-gradient(circle closest-corner at 50% 50%, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,342 @@ |
|||
///
|
|||
/// Copyright © 2016-2019 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { isDefined } from '@core/utils'; |
|||
import * as tinycolor_ from 'tinycolor2'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; |
|||
import { DatasourceType, widgetType } from '@shared/models/widget.models'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
import Timeout = NodeJS.Timeout; |
|||
|
|||
const tinycolor = tinycolor_; |
|||
|
|||
const checkStatusPollingInterval = 10000; |
|||
|
|||
type RetrieveValueMethod = 'attribute' | 'timeseries'; |
|||
|
|||
interface LedIndicatorSettings { |
|||
initialValue: boolean; |
|||
title: string; |
|||
ledColor: string; |
|||
performCheckStatus: boolean; |
|||
checkStatusMethod: string; |
|||
retrieveValueMethod: RetrieveValueMethod; |
|||
valueAttribute: string; |
|||
parseValueFunction: string; |
|||
requestTimeout: number; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-led-indicator', |
|||
templateUrl: './led-indicator.component.html', |
|||
styleUrls: ['./led-indicator.component.scss'] |
|||
}) |
|||
export class LedIndicatorComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
@ViewChild('led', {static: true}) ledRef: ElementRef<HTMLElement>; |
|||
@ViewChild('ledContainer', {static: true}) ledContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef<HTMLElement>; |
|||
@ViewChild('ledTitleContainer', {static: true}) ledTitleContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('ledTitle', {static: true}) ledTitleRef: ElementRef<HTMLElement>; |
|||
@ViewChild('ledErrorContainer', {static: true}) ledErrorContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('ledError', {static: true}) ledErrorRef: ElementRef<HTMLElement>; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
showTitle = false; |
|||
value = false; |
|||
error = ''; |
|||
title = ''; |
|||
|
|||
private valueAttribute: string; |
|||
private ledColor: string; |
|||
private ledMiddleColor: string; |
|||
private disabledColor: string; |
|||
private disabledMiddleColor: string; |
|||
|
|||
private isSimulated: boolean; |
|||
private requestTimeout: number; |
|||
private retrieveValueMethod: RetrieveValueMethod; |
|||
private parseValueFunction: (data: any) => boolean; |
|||
private performCheckStatus: boolean; |
|||
private checkStatusMethod: string; |
|||
|
|||
private destroyed = false; |
|||
private checkStatusTimeoutHandle: Timeout; |
|||
private subscription: IWidgetSubscription; |
|||
|
|||
private led: JQuery<HTMLElement>; |
|||
private ledContainer: JQuery<HTMLElement>; |
|||
private textMeasure: JQuery<HTMLElement>; |
|||
private ledTitleContainer: JQuery<HTMLElement>; |
|||
private ledTitle: JQuery<HTMLElement>; |
|||
private ledErrorContainer: JQuery<HTMLElement>; |
|||
private ledError: JQuery<HTMLElement>; |
|||
|
|||
private ledResizeListener: any; |
|||
|
|||
private subscriptionOptions: WidgetSubscriptionOptions = { |
|||
callbacks: { |
|||
onDataUpdated: this.onDataUpdated.bind(this), |
|||
onDataUpdateError: this.onDataUpdateError.bind(this), |
|||
dataLoading: () => {} |
|||
} |
|||
}; |
|||
|
|||
|
|||
constructor(private utils: UtilsService, |
|||
protected store: Store<AppState>) { |
|||
super(store); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.led = $(this.ledRef.nativeElement); |
|||
this.ledContainer = $(this.ledContainerRef.nativeElement); |
|||
this.textMeasure = $(this.textMeasureRef.nativeElement); |
|||
this.ledTitleContainer = $(this.ledTitleContainerRef.nativeElement); |
|||
this.ledTitle = $(this.ledTitleRef.nativeElement); |
|||
this.ledErrorContainer = $(this.ledErrorContainerRef.nativeElement); |
|||
this.ledError = $(this.ledErrorRef.nativeElement); |
|||
|
|||
this.ledResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener); |
|||
this.init(); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
this.destroyed = true; |
|||
if (this.checkStatusTimeoutHandle) { |
|||
clearTimeout(this.checkStatusTimeoutHandle); |
|||
} |
|||
if (this.subscription) { |
|||
this.ctx.subscriptionApi.removeSubscription(this.subscription.id); |
|||
} |
|||
if (this.ledResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener); |
|||
} |
|||
} |
|||
|
|||
private init() { |
|||
const settings: LedIndicatorSettings = this.ctx.settings; |
|||
this.title = isDefined(settings.title) ? settings.title : ''; |
|||
this.showTitle = !!(this.title && this.title.length); |
|||
|
|||
const origColor = isDefined(settings.ledColor) ? settings.ledColor : 'green'; |
|||
this.valueAttribute = isDefined(settings.valueAttribute) ? settings.valueAttribute : 'value'; |
|||
|
|||
this.ledColor = tinycolor(origColor).brighten(30).toHexString(); |
|||
this.ledMiddleColor = tinycolor(origColor).toHexString(); |
|||
this.disabledColor = tinycolor(origColor).darken(40).toHexString(); |
|||
this.disabledMiddleColor = tinycolor(origColor).darken(60).toHexString(); |
|||
|
|||
const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; |
|||
this.setValue(initialValue, true); |
|||
|
|||
const subscription = this.ctx.defaultSubscription; |
|||
const rpcEnabled = subscription.rpcEnabled; |
|||
|
|||
this.isSimulated = this.utils.widgetEditMode; |
|||
|
|||
this.requestTimeout = 500; |
|||
if (settings.requestTimeout) { |
|||
this.requestTimeout = settings.requestTimeout; |
|||
} |
|||
this.retrieveValueMethod = 'attribute'; |
|||
if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { |
|||
this.retrieveValueMethod = settings.retrieveValueMethod; |
|||
} |
|||
this.parseValueFunction = (data) => !!data; |
|||
if (settings.parseValueFunction && settings.parseValueFunction.length) { |
|||
try { |
|||
this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; |
|||
} catch (e) { |
|||
this.parseValueFunction = (data) => !!data; |
|||
} |
|||
} |
|||
this.performCheckStatus = settings.performCheckStatus !== false; |
|||
if (this.performCheckStatus) { |
|||
this.checkStatusMethod = 'checkStatus'; |
|||
if (settings.checkStatusMethod && settings.checkStatusMethod.length) { |
|||
this.checkStatusMethod = settings.checkStatusMethod; |
|||
} |
|||
} |
|||
if (!rpcEnabled) { |
|||
this.onError('Target device is not set!'); |
|||
} else { |
|||
if (!this.isSimulated) { |
|||
if (this.performCheckStatus) { |
|||
this.rpcCheckStatus(); |
|||
} else { |
|||
this.subscribeForValue(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private resize() { |
|||
const width = this.ledContainer.width(); |
|||
const height = this.ledContainer.height(); |
|||
const size = Math.min(width, height); |
|||
|
|||
this.led.css({width: size, height: size}); |
|||
|
|||
if (this.showTitle) { |
|||
this.setFontSize(this.ledTitle, this.title, this.ledTitleContainer.height() * 2 / 3, this.ledTitleContainer.width()); |
|||
} |
|||
this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width()); |
|||
} |
|||
|
|||
private setFontSize(element: JQuery<HTMLElement>, text: string, fontSize: number, maxWidth: number) { |
|||
let textWidth = this.measureTextWidth(text, fontSize); |
|||
while (textWidth > maxWidth) { |
|||
fontSize--; |
|||
textWidth = this.measureTextWidth(text, fontSize); |
|||
} |
|||
element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
} |
|||
|
|||
private measureTextWidth(text: string, fontSize: number): number { |
|||
this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
this.textMeasure.text(text); |
|||
return this.textMeasure.width(); |
|||
} |
|||
|
|||
private onError(error: string) { |
|||
this.error = error; |
|||
this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width()); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private setValue(value: boolean, forceUpdate?: boolean) { |
|||
if (this.value !== value || forceUpdate) { |
|||
this.value = value; |
|||
this.updateColor(); |
|||
} |
|||
} |
|||
|
|||
private updateColor() { |
|||
const color = this.value ? this.ledColor : this.disabledColor; |
|||
const middleColor = this.value ? this.ledMiddleColor : this.disabledMiddleColor; |
|||
const boxShadow = `#000 0 -1px 6px 1px, inset ${middleColor} 0 -1px 8px, ${color} 0 3px 11px`; |
|||
this.led.css({backgroundColor: color}); |
|||
this.led.css({boxShadow}); |
|||
if (this.value) { |
|||
this.led.removeClass( 'disabled' ); |
|||
} else { |
|||
this.led.addClass( 'disabled' ); |
|||
} |
|||
} |
|||
|
|||
private rpcCheckStatus() { |
|||
if (this.destroyed) { |
|||
return; |
|||
} |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendTwoWayCommand(this.checkStatusMethod, null, this.requestTimeout).subscribe( |
|||
(responseBody) => { |
|||
const status = !!responseBody; |
|||
if (status) { |
|||
if (this.checkStatusTimeoutHandle) { |
|||
clearTimeout(this.checkStatusTimeoutHandle); |
|||
this.checkStatusTimeoutHandle = null; |
|||
} |
|||
this.subscribeForValue(); |
|||
} else { |
|||
const errorText = 'Unknown device status!'; |
|||
this.onError(errorText); |
|||
if (this.checkStatusTimeoutHandle) { |
|||
clearTimeout(this.checkStatusTimeoutHandle); |
|||
} |
|||
this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval); |
|||
} |
|||
}, |
|||
() => { |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
if (this.checkStatusTimeoutHandle) { |
|||
clearTimeout(this.checkStatusTimeoutHandle); |
|||
} |
|||
this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private subscribeForValue() { |
|||
const subscriptionsInfo: SubscriptionInfo[] = [{ |
|||
type: DatasourceType.entity, |
|||
entityType: EntityType.DEVICE, |
|||
entityId: this.ctx.defaultSubscription.targetDeviceId |
|||
}]; |
|||
|
|||
if (this.retrieveValueMethod === 'attribute') { |
|||
subscriptionsInfo[0].attributes = [ |
|||
{name: this.valueAttribute} |
|||
]; |
|||
} else { |
|||
subscriptionsInfo[0].timeseries = [ |
|||
{name: this.valueAttribute} |
|||
]; |
|||
} |
|||
|
|||
this.ctx.subscriptionApi.createSubscriptionFromInfo ( |
|||
widgetType.latest, subscriptionsInfo, this.subscriptionOptions, false, true).subscribe( |
|||
(subscription) => { |
|||
this.subscription = subscription; |
|||
} |
|||
); |
|||
} |
|||
|
|||
private onDataUpdated (subscription: IWidgetSubscription, detectChanges: boolean) { |
|||
let value = false; |
|||
const data = subscription.data; |
|||
if (data.length) { |
|||
const keyData = data[0]; |
|||
if (keyData && keyData.data && keyData.data[0]) { |
|||
const attrValue = keyData.data[0][1]; |
|||
if (attrValue) { |
|||
let parsed = null; |
|||
try { |
|||
parsed = this.parseValueFunction(JSON.parse(attrValue)); |
|||
} catch (e){/**/} |
|||
value = !!parsed; |
|||
} |
|||
} |
|||
} |
|||
this.setValue(value); |
|||
if (detectChanges) { |
|||
this.ctx.detectChanges(); |
|||
} |
|||
} |
|||
|
|||
private onDataUpdateError(subscription: IWidgetSubscription, e: any) { |
|||
const exceptionData = this.utils.parseException(e); |
|||
let errorText = exceptionData.name; |
|||
if (exceptionData.message) { |
|||
errorText += ': ' + exceptionData.message; |
|||
} |
|||
this.onError(errorText); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2019 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. |
|||
|
|||
--> |
|||
<div class="tb-round-switch" fxLayout="column" [ngStyle]="{'pointerEvents': ctx.isEdit ? 'none' : 'all'}"> |
|||
<div #switchTitleContainer fxFlex="20" class="title-container" fxLayout="row" fxLayoutAlign="center center" [fxShow]="showTitle"> |
|||
<span #switchTitle class="switch-title">{{title}}</span> |
|||
</div> |
|||
<div #switchContainer fxFlex="{{showTitle ? 80 : 100}}" [ngStyle]="{paddingTop: showTitle ? '5px': '10px'}" id="switch-container" fxLayout="column" fxLayoutAlign="center center"> |
|||
<div #switch class="switch"> |
|||
<input #onoff type="checkbox" id="{{checkboxId}}" name="onoff" /> |
|||
<div class="back"> |
|||
<label class="but" for="{{checkboxId}}"> |
|||
<span class="on">I</span> |
|||
<span class="off">0</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div #switchErrorContainer class="error-container" [ngStyle]="{'background': error?.length ? 'rgba(255,255,255,0.25)' : 'none'}" |
|||
fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #switchError class="switch-error">{{ error }}</span> |
|||
</div> |
|||
<div #textMeasure id="text-measure"></div> |
|||
</div> |
|||
@ -0,0 +1,198 @@ |
|||
/** |
|||
* Copyright © 2016-2019 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 "~compass-sass-mixins/lib/compass"; |
|||
|
|||
$error-height: 14px !default; |
|||
|
|||
$background-color: #e6e7e8 !default; |
|||
|
|||
:host { |
|||
.tb-round-switch { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $background-color; |
|||
|
|||
.title-container { |
|||
.switch-title { |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.error-container { |
|||
position: absolute; |
|||
top: 1%; |
|||
right: 0; |
|||
left: 0; |
|||
z-index: 4; |
|||
height: $error-height; |
|||
|
|||
.switch-error { |
|||
color: #ff3315; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
#text-measure { |
|||
position: absolute; |
|||
width: auto; |
|||
height: auto; |
|||
white-space: nowrap; |
|||
visibility: hidden; |
|||
} |
|||
|
|||
#switch-container { |
|||
padding: 10px; |
|||
|
|||
.switch { |
|||
position: relative; |
|||
|
|||
box-sizing: border-box; |
|||
width: 260px; |
|||
min-width: 260px; |
|||
height: 260px; |
|||
min-height: 260px; |
|||
padding: 25px; |
|||
font-family: sans-serif; |
|||
font-size: 48px; |
|||
|
|||
color: #424242; |
|||
cursor: pointer; |
|||
background: #ddd; |
|||
background: linear-gradient(180deg, #bbb, #ddd); |
|||
border-radius: 130px; |
|||
|
|||
box-shadow: 0 0 0 8px rgba(0, 0, 0, .1), |
|||
0 0 3px 1px rgba(0, 0, 0, .1), |
|||
inset 0 8px 3px -8px rgba(255, 255, 255, .4); |
|||
|
|||
input { |
|||
display: none; |
|||
} |
|||
|
|||
.on, |
|||
.off { |
|||
position: absolute; |
|||
width: 100%; |
|||
text-align: center; |
|||
|
|||
text-shadow: 1px 1px 4px #4a4a4a; |
|||
} |
|||
|
|||
.on { |
|||
top: 10px; |
|||
font-family: sans-serif; |
|||
color: #444; |
|||
|
|||
transition: all .1s; |
|||
} |
|||
|
|||
.off { |
|||
bottom: 5px; |
|||
|
|||
transition: all .1s; |
|||
|
|||
transform: scaleY(.85); |
|||
} |
|||
|
|||
.but { |
|||
position: relative; |
|||
display: block; |
|||
width: 200px; |
|||
height: 178px; |
|||
font-size: 48px; |
|||
cursor: pointer; |
|||
background-color: #d8d8d8; |
|||
border-bottom-width: 0; |
|||
border-radius: 400px 400px 400px 400px / 400px 400px 300px 300px; |
|||
|
|||
box-shadow: inset 8px 6px 5px -7px #a2a2a2, |
|||
inset -8px 6px 5px -7px #a2a2a2, |
|||
inset 0 -3px 2px -2px rgba(200, 200, 200, .5), |
|||
0 3px 3px -2px #fff, |
|||
inset 0 -230px 60px -200px rgba(255, 255, 255, .2), |
|||
inset 0 220px 40px -200px rgba(0, 0, 0, .3); |
|||
|
|||
transition: all .2s; |
|||
} |
|||
|
|||
.back { |
|||
|
|||
box-sizing: border-box; |
|||
width: 210px; |
|||
height: 210px; |
|||
padding: 4px 4px; |
|||
cursor: pointer; |
|||
background-color: #888787; |
|||
background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); |
|||
border-radius: 105px; |
|||
|
|||
box-shadow: 30px 30px 30px -20px rgba(58, 58, 58, .3), |
|||
-30px 30px 30px -20px rgba(58, 58, 58, .3), |
|||
0 30px 30px 0 rgba(16, 16, 16, .3), |
|||
inset 0 -1px 0 0 #484848; |
|||
|
|||
transition: all .2s; |
|||
} |
|||
|
|||
|
|||
input:checked + .back .on, |
|||
input:checked + .back .off { |
|||
text-shadow: 1px 1px 4px #4a4a4a; |
|||
} |
|||
|
|||
input:checked + .back .on { |
|||
top: 10px; |
|||
color: #4c4c4c; |
|||
|
|||
transform: scaleY(.85); |
|||
} |
|||
|
|||
input:checked + .back .off { |
|||
bottom: 5px; |
|||
color: #444; |
|||
|
|||
transform: scaleY(1); |
|||
} |
|||
|
|||
input:checked + .back .but { |
|||
margin-top: 20px; |
|||
background: #dcdcdc radial-gradient(circle closest-corner at 50% 15%, rgba(0, 0, 0, .3), transparent); |
|||
border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px; |
|||
|
|||
box-shadow: inset 8px -4px 5px -7px #a9a9a9, |
|||
inset -8px -4px 5px -7px #808080, |
|||
0 -3px 8px -4px rgba(50, 50, 50, .4), |
|||
inset 0 3px 4px -2px #9c9c9c, |
|||
inset 0 280px 40px -200px rgba(0, 0, 0, .2), |
|||
inset 0 -200px 40px -200px rgba(180, 180, 180, .2); |
|||
} |
|||
|
|||
input:checked + .back { |
|||
padding: 2px 4px; |
|||
|
|||
background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); |
|||
|
|||
box-shadow: 30px 30px 30px -20px rgba(49, 49, 49, .1), |
|||
-30px 30px 30px -20px rgba(111, 111, 111, .1), |
|||
0 30px 30px 0 rgba(0, 0, 0, .2), |
|||
inset 0 1px 2px 0 rgba(167, 167, 167, .6); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,338 @@ |
|||
///
|
|||
/// Copyright © 2016-2019 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, OnDestroy, OnInit, ViewChild } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { isDefined } from '@core/utils'; |
|||
import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; |
|||
import { DatasourceType, widgetType } from '@shared/models/widget.models'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
|
|||
type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries'; |
|||
|
|||
interface RoundSwitchSettings { |
|||
initialValue: boolean; |
|||
title: string; |
|||
retrieveValueMethod: RetrieveValueMethod; |
|||
valueKey: string; |
|||
getValueMethod: string; |
|||
setValueMethod: string; |
|||
parseValueFunction: string; |
|||
convertValueFunction: string; |
|||
requestTimeout: number; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-round-switch', |
|||
templateUrl: './round-switch.component.html', |
|||
styleUrls: ['./round-switch.component.scss'] |
|||
}) |
|||
export class RoundSwitchComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
@ViewChild('switch', {static: true}) switchElementRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('onoff', {static: true}) onoffRef: ElementRef<HTMLElement>; |
|||
@ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchError', {static: true}) switchErrorRef: ElementRef<HTMLElement>; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
showTitle = false; |
|||
value = false; |
|||
error = ''; |
|||
title = ''; |
|||
checkboxId = 'onoff-' + this.utils.guid(); |
|||
|
|||
private isSimulated: boolean; |
|||
private requestTimeout: number; |
|||
private retrieveValueMethod: RetrieveValueMethod; |
|||
private valueKey: string; |
|||
private parseValueFunction: (data: any) => boolean; |
|||
private convertValueFunction: (value: any) => any; |
|||
private getValueMethod: string; |
|||
private setValueMethod: string; |
|||
|
|||
private valueSubscription: IWidgetSubscription; |
|||
|
|||
private executingUpdateValue: boolean; |
|||
private scheduledValue: boolean; |
|||
private rpcValue: boolean; |
|||
|
|||
private switchElement: JQuery<HTMLElement>; |
|||
private switchContainer: JQuery<HTMLElement>; |
|||
private onoff: JQuery<HTMLElement>; |
|||
private textMeasure: JQuery<HTMLElement>; |
|||
private switchTitleContainer: JQuery<HTMLElement>; |
|||
private switchTitle: JQuery<HTMLElement>; |
|||
private switchErrorContainer: JQuery<HTMLElement>; |
|||
private switchError: JQuery<HTMLElement>; |
|||
|
|||
private switchResizeListener: any; |
|||
|
|||
constructor(private utils: UtilsService, |
|||
protected store: Store<AppState>) { |
|||
super(store); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.switchElement = $(this.switchElementRef.nativeElement); |
|||
this.switchContainer = $(this.switchContainerRef.nativeElement); |
|||
this.onoff = $(this.onoffRef.nativeElement); |
|||
this.textMeasure = $(this.textMeasureRef.nativeElement); |
|||
this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); |
|||
this.switchTitle = $(this.switchTitleRef.nativeElement); |
|||
this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); |
|||
this.switchError = $(this.switchErrorRef.nativeElement); |
|||
|
|||
this.onoff.on('change', () => { |
|||
this.value = this.onoff.prop('checked') === false; |
|||
this.onValue(); |
|||
}); |
|||
|
|||
this.switchResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); |
|||
this.init(); |
|||
// this.ctx.resize = this.resize.bind(this);
|
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.valueSubscription) { |
|||
this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id); |
|||
} |
|||
if (this.switchResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); |
|||
} |
|||
} |
|||
|
|||
private init() { |
|||
const settings: RoundSwitchSettings = this.ctx.settings; |
|||
this.title = isDefined(settings.title) ? settings.title : ''; |
|||
this.showTitle = !!(this.title && this.title.length); |
|||
const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; |
|||
this.setValue(initialValue); |
|||
|
|||
const subscription = this.ctx.defaultSubscription; |
|||
const rpcEnabled = subscription.rpcEnabled; |
|||
|
|||
this.isSimulated = this.utils.widgetEditMode; |
|||
|
|||
this.requestTimeout = 500; |
|||
if (settings.requestTimeout) { |
|||
this.requestTimeout = settings.requestTimeout; |
|||
} |
|||
this.retrieveValueMethod = 'rpc'; |
|||
if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { |
|||
this.retrieveValueMethod = settings.retrieveValueMethod; |
|||
} |
|||
this.valueKey = 'value'; |
|||
if (settings.valueKey && settings.valueKey.length) { |
|||
this.valueKey = settings.valueKey; |
|||
} |
|||
this.parseValueFunction = (data) => !!data; |
|||
if (settings.parseValueFunction && settings.parseValueFunction.length) { |
|||
try { |
|||
this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; |
|||
} catch (e) { |
|||
this.parseValueFunction = (data) => !!data; |
|||
} |
|||
} |
|||
this.convertValueFunction = (value) => value; |
|||
if (settings.convertValueFunction && settings.convertValueFunction.length) { |
|||
try { |
|||
this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any; |
|||
} catch (e) { |
|||
this.convertValueFunction = (value) => value; |
|||
} |
|||
} |
|||
this.getValueMethod = 'getValue'; |
|||
if (settings.getValueMethod && settings.getValueMethod.length) { |
|||
this.getValueMethod = settings.getValueMethod; |
|||
} |
|||
this.setValueMethod = 'setValue'; |
|||
if (settings.setValueMethod && settings.setValueMethod.length) { |
|||
this.setValueMethod = settings.setValueMethod; |
|||
} |
|||
if (!rpcEnabled) { |
|||
this.onError('Target device is not set!'); |
|||
} else { |
|||
if (!this.isSimulated) { |
|||
if (this.retrieveValueMethod === 'rpc') { |
|||
this.rpcRequestValue(); |
|||
} else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') { |
|||
this.subscribeForValue(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
private resize() { |
|||
const width = this.switchContainer.width(); |
|||
const height = this.switchContainer.height(); |
|||
const size = Math.min(width, height); |
|||
const scale = size/260; |
|||
this.switchElement.css({ |
|||
'-webkit-transform': `scale(${scale})`, |
|||
'-moz-transform': `scale(${scale})`, |
|||
'-ms-transform': `scale(${scale})`, |
|||
'-o-transform': `scale(${scale})`, |
|||
transform: `scale(${scale})` |
|||
}); |
|||
if (this.showTitle) { |
|||
this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width()); |
|||
} |
|||
this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); |
|||
} |
|||
|
|||
private setFontSize(element: JQuery<HTMLElement>, text: string, fontSize: number, maxWidth: number) { |
|||
let textWidth = this.measureTextWidth(text, fontSize); |
|||
while (textWidth > maxWidth) { |
|||
fontSize--; |
|||
textWidth = this.measureTextWidth(text, fontSize); |
|||
} |
|||
element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
} |
|||
|
|||
private measureTextWidth(text: string, fontSize: number): number { |
|||
this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
this.textMeasure.text(text); |
|||
return this.textMeasure.width(); |
|||
} |
|||
|
|||
private onError(error: string) { |
|||
this.error = error; |
|||
this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private setValue(value: boolean) { |
|||
this.value = value ? true : false; |
|||
this.onoff.prop('checked', !this.value); |
|||
} |
|||
|
|||
private onValue() { |
|||
this.rpcUpdateValue(this.value); |
|||
} |
|||
|
|||
private rpcRequestValue() { |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( |
|||
(responseBody) => { |
|||
this.setValue(this.parseValueFunction(responseBody)); |
|||
}, |
|||
() => { |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private rpcUpdateValue(value) { |
|||
if (this.executingUpdateValue) { |
|||
this.scheduledValue = value; |
|||
return; |
|||
} else { |
|||
this.scheduledValue = null; |
|||
this.rpcValue = value; |
|||
this.executingUpdateValue = true; |
|||
} |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe( |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { |
|||
this.rpcUpdateValue(this.scheduledValue); |
|||
} |
|||
}, |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private subscribeForValue() { |
|||
const valueSubscriptionInfo: SubscriptionInfo[] = [{ |
|||
type: DatasourceType.entity, |
|||
entityType: EntityType.DEVICE, |
|||
entityId: this.ctx.defaultSubscription.targetDeviceId |
|||
}]; |
|||
if (this.retrieveValueMethod === 'attribute') { |
|||
valueSubscriptionInfo[0].attributes = [ |
|||
{name: this.valueKey} |
|||
]; |
|||
} else { |
|||
valueSubscriptionInfo[0].timeseries = [ |
|||
{name: this.valueKey} |
|||
]; |
|||
} |
|||
const subscriptionOptions: WidgetSubscriptionOptions = { |
|||
callbacks: { |
|||
onDataUpdated: this.onDataUpdated.bind(this), |
|||
onDataUpdateError: this.onDataUpdateError.bind(this) |
|||
} |
|||
}; |
|||
this.ctx.subscriptionApi.createSubscriptionFromInfo ( |
|||
widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe( |
|||
(subscription) => { |
|||
this.valueSubscription = subscription; |
|||
} |
|||
); |
|||
} |
|||
|
|||
private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) { |
|||
let value = false; |
|||
const data = subscription.data; |
|||
if (data.length) { |
|||
const keyData = data[0]; |
|||
if (keyData && keyData.data && keyData.data[0]) { |
|||
const attrValue = keyData.data[0][1]; |
|||
if (attrValue) { |
|||
let parsed = null; |
|||
try { |
|||
parsed = this.parseValueFunction(JSON.parse(attrValue)); |
|||
} catch (e){/**/} |
|||
value = !!parsed; |
|||
} |
|||
} |
|||
} |
|||
this.setValue(value); |
|||
if (detectChanges) { |
|||
this.ctx.detectChanges(); |
|||
} |
|||
} |
|||
|
|||
private onDataUpdateError(subscription: IWidgetSubscription, e: any) { |
|||
const exceptionData = this.utils.parseException(e); |
|||
let errorText = exceptionData.name; |
|||
if (exceptionData.message) { |
|||
errorText += ': ' + exceptionData.message; |
|||
} |
|||
this.onError(errorText); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
///
|
|||
/// Copyright © 2016-2019 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 { NgModule } from '@angular/core'; |
|||
import { CommonModule } from '@angular/common'; |
|||
import { SharedModule } from '@app/shared/shared.module'; |
|||
import { LedIndicatorComponent } from '@home/components/widget/lib/rpc/led-indicator.component'; |
|||
import { RoundSwitchComponent } from '@home/components/widget/lib/rpc/round-switch.component'; |
|||
import { SwitchComponent } from '@home/components/widget/lib/rpc/switch.component'; |
|||
import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: |
|||
[ |
|||
LedIndicatorComponent, |
|||
RoundSwitchComponent, |
|||
SwitchComponent, |
|||
KnobComponent |
|||
], |
|||
imports: [ |
|||
CommonModule, |
|||
SharedModule |
|||
], |
|||
exports: [ |
|||
LedIndicatorComponent, |
|||
RoundSwitchComponent, |
|||
SwitchComponent, |
|||
KnobComponent |
|||
] |
|||
}) |
|||
export class RpcWidgetsModule { } |
|||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
@ -0,0 +1,39 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2019 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. |
|||
|
|||
--> |
|||
<div class="tb-switch" fxLayout="column" [ngStyle]="{'pointerEvents': ctx.isEdit ? 'none' : 'all'}"> |
|||
<div #switchErrorContainer class="error-container" [ngStyle]="{'background': error?.length ? 'rgba(255,255,255,0.25)' : 'none'}" |
|||
fxLayout="row" fxLayoutAlign="center center"> |
|||
<span #switchError class="switch-error">{{ error }}</span> |
|||
</div> |
|||
<div #switchTitleContainer fxFlex="30" class="title-container" fxLayout="row" fxLayoutAlign="center center" [fxShow]="showTitle"> |
|||
<span #switchTitle class="switch-title">{{title}}</span> |
|||
</div> |
|||
<div #switchContainer fxFlex="{{showTitle ? 70 : 100}}" id="switch-container" fxLayout="column" fxLayoutAlign="center center"> |
|||
<div #switch class="switch"> |
|||
<mat-slide-toggle #matSlideToggle [(ngModel)]="value" (change)="onValue()"> |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div #onoffContainer class="onoff-container" fxLayout="row" fxLayoutAlign="center start" [fxShow]="showOnOffLabels"> |
|||
<span #offLabel fxFlex class="off-label" [fxShow]="!value" style="text-align: center;">OFF</span> |
|||
<span fxFlex [fxShow]="value"></span> |
|||
<span #onLabel fxFlex class="on-label" [fxShow]="value" style="text-align: center;">ON</span> |
|||
<span fxFlex [fxShow]="!value"></span> |
|||
</div> |
|||
</div> |
|||
<div #textMeasure id="text-measure"></div> |
|||
</div> |
|||
@ -0,0 +1,147 @@ |
|||
/** |
|||
* Copyright © 2016-2019 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. |
|||
*/ |
|||
|
|||
|
|||
$thumb-img: url("./svg/thumb.svg") !default; |
|||
$thumb-checked-img: url("./svg/thumb-checked.svg") !default; |
|||
$thumb-bar-img: url("./svg/thumb-bar.svg") !default; |
|||
$thumb-bar-checked-img: url("./svg/thumb-bar-checked.svg") !default; |
|||
|
|||
$background-color: #e6e7e8 !default; |
|||
|
|||
$error-height: 14px !default; |
|||
|
|||
:host { |
|||
|
|||
.tb-switch { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $background-color; |
|||
|
|||
.error-container { |
|||
position: absolute; |
|||
top: 1%; |
|||
right: 0; |
|||
left: 0; |
|||
z-index: 4; |
|||
height: $error-height; |
|||
|
|||
.switch-error { |
|||
color: #ff3315; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
.onoff-container { |
|||
height: 100%; |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
|
|||
.off-label { |
|||
color: #b7b5b5; |
|||
} |
|||
|
|||
.on-label { |
|||
color: #ff7e57; |
|||
text-shadow: #ff6e4a 1px 1px 10px, #ffd1c3 1px 1px 10px; |
|||
} |
|||
} |
|||
|
|||
.title-container { |
|||
.switch-title { |
|||
font-weight: 500; |
|||
color: #757575; |
|||
white-space: nowrap; |
|||
} |
|||
} |
|||
|
|||
#switch-container { |
|||
padding-right: 10px; |
|||
padding-left: 10px; |
|||
} |
|||
|
|||
.switch { |
|||
position: relative; |
|||
} |
|||
|
|||
#text-measure { |
|||
position: absolute; |
|||
width: auto; |
|||
height: auto; |
|||
white-space: nowrap; |
|||
visibility: hidden; |
|||
} |
|||
} |
|||
} |
|||
|
|||
:host ::ng-deep { |
|||
.tb-switch { |
|||
.switch { |
|||
mat-slide-toggle { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
left: 0; |
|||
margin: 0; |
|||
|
|||
.mat-slide-toggle-bar { |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $thumb-bar-img no-repeat; |
|||
background-size: contain; |
|||
border-radius: 0; |
|||
} |
|||
|
|||
.mat-slide-toggle-thumb-container { |
|||
top: 5%; |
|||
left: .25%; |
|||
width: 50%; |
|||
height: 90%; |
|||
} |
|||
|
|||
.mat-slide-toggle-thumb { |
|||
top: 0; |
|||
left: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
background: $thumb-img no-repeat; |
|||
background-size: contain; |
|||
border-radius: 0; |
|||
box-shadow: none; |
|||
} |
|||
|
|||
&.mat-checked { |
|||
.mat-slide-toggle-thumb-container { |
|||
transform: translate3d(100%,0,0); |
|||
} |
|||
.mat-slide-toggle-bar { |
|||
background: $thumb-bar-checked-img no-repeat; |
|||
background-size: contain; |
|||
} |
|||
|
|||
.mat-slide-toggle-thumb { |
|||
background: $thumb-checked-img no-repeat; |
|||
background-size: contain; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,354 @@ |
|||
///
|
|||
/// Copyright © 2016-2019 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { isDefined } from '@core/utils'; |
|||
import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; |
|||
import { DatasourceType, widgetType } from '@shared/models/widget.models'; |
|||
import { EntityType } from '@shared/models/entity-type.models'; |
|||
import { MatSlideToggle } from '@angular/material/slide-toggle'; |
|||
|
|||
const switchAspectRation = 2.7893; |
|||
|
|||
type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries'; |
|||
|
|||
interface SwitchSettings { |
|||
initialValue: boolean; |
|||
title: string; |
|||
showOnOffLabels: boolean; |
|||
retrieveValueMethod: RetrieveValueMethod; |
|||
valueKey: string; |
|||
getValueMethod: string; |
|||
setValueMethod: string; |
|||
parseValueFunction: string; |
|||
convertValueFunction: string; |
|||
requestTimeout: number; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-switch', |
|||
templateUrl: './switch.component.html', |
|||
styleUrls: ['./switch.component.scss'] |
|||
}) |
|||
export class SwitchComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
@ViewChild('switch', {static: true}) switchElementRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('matSlideToggle', {static: true}) matSlideToggleRef: MatSlideToggle; |
|||
@ViewChild('onoffContainer', {static: true}) onoffContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('onLabel', {static: true}) onLabelRef: ElementRef<HTMLElement>; |
|||
@ViewChild('offLabel', {static: true}) offLabelRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef<HTMLElement>; |
|||
@ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef<HTMLElement>; |
|||
@ViewChild('switchError', {static: true}) switchErrorRef: ElementRef<HTMLElement>; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
showTitle = false; |
|||
value = false; |
|||
error = ''; |
|||
title = ''; |
|||
showOnOffLabels = false; |
|||
|
|||
private isSimulated: boolean; |
|||
private requestTimeout: number; |
|||
private retrieveValueMethod: RetrieveValueMethod; |
|||
private valueKey: string; |
|||
private parseValueFunction: (data: any) => boolean; |
|||
private convertValueFunction: (value: any) => any; |
|||
private getValueMethod: string; |
|||
private setValueMethod: string; |
|||
|
|||
private valueSubscription: IWidgetSubscription; |
|||
|
|||
private executingUpdateValue: boolean; |
|||
private scheduledValue: boolean; |
|||
private rpcValue: boolean; |
|||
|
|||
private switchElement: JQuery<HTMLElement>; |
|||
private switchContainer: JQuery<HTMLElement>; |
|||
private matSlideToggle: JQuery<HTMLElement>; |
|||
private onoffContainer: JQuery<HTMLElement>; |
|||
private onLabel: JQuery<HTMLElement>; |
|||
private offLabel: JQuery<HTMLElement>; |
|||
private switchTitleContainer: JQuery<HTMLElement>; |
|||
private switchTitle: JQuery<HTMLElement>; |
|||
private textMeasure: JQuery<HTMLElement>; |
|||
private switchErrorContainer: JQuery<HTMLElement>; |
|||
private switchError: JQuery<HTMLElement>; |
|||
|
|||
private switchResizeListener: any; |
|||
|
|||
constructor(private utils: UtilsService, |
|||
protected store: Store<AppState>) { |
|||
super(store); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.switchElement = $(this.switchElementRef.nativeElement); |
|||
this.switchContainer = $(this.switchContainerRef.nativeElement); |
|||
this.matSlideToggle = $(this.matSlideToggleRef._elementRef.nativeElement); |
|||
this.onoffContainer = $(this.onoffContainerRef.nativeElement); |
|||
this.onLabel = $(this.onLabelRef.nativeElement); |
|||
this.offLabel = $(this.offLabelRef.nativeElement); |
|||
this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement); |
|||
this.switchTitle = $(this.switchTitleRef.nativeElement); |
|||
this.textMeasure = $(this.textMeasureRef.nativeElement); |
|||
this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement); |
|||
this.switchError = $(this.switchErrorRef.nativeElement); |
|||
|
|||
this.switchResizeListener = this.resize.bind(this); |
|||
// @ts-ignore
|
|||
addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); |
|||
this.init(); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
if (this.valueSubscription) { |
|||
this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id); |
|||
} |
|||
if (this.switchResizeListener) { |
|||
// @ts-ignore
|
|||
removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener); |
|||
} |
|||
} |
|||
|
|||
private init() { |
|||
const settings: SwitchSettings = this.ctx.settings; |
|||
this.title = isDefined(settings.title) ? settings.title : ''; |
|||
this.showTitle = !!(this.title && this.title.length); |
|||
this.showOnOffLabels = isDefined(settings.showOnOffLabels) ? settings.showOnOffLabels : true; |
|||
const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false; |
|||
this.setValue(initialValue); |
|||
|
|||
const subscription = this.ctx.defaultSubscription; |
|||
const rpcEnabled = subscription.rpcEnabled; |
|||
|
|||
this.isSimulated = this.utils.widgetEditMode; |
|||
|
|||
this.requestTimeout = 500; |
|||
if (settings.requestTimeout) { |
|||
this.requestTimeout = settings.requestTimeout; |
|||
} |
|||
this.retrieveValueMethod = 'rpc'; |
|||
if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) { |
|||
this.retrieveValueMethod = settings.retrieveValueMethod; |
|||
} |
|||
this.valueKey = 'value'; |
|||
if (settings.valueKey && settings.valueKey.length) { |
|||
this.valueKey = settings.valueKey; |
|||
} |
|||
this.parseValueFunction = (data) => !!data; |
|||
if (settings.parseValueFunction && settings.parseValueFunction.length) { |
|||
try { |
|||
this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean; |
|||
} catch (e) { |
|||
this.parseValueFunction = (data) => !!data; |
|||
} |
|||
} |
|||
this.convertValueFunction = (value) => value; |
|||
if (settings.convertValueFunction && settings.convertValueFunction.length) { |
|||
try { |
|||
this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any; |
|||
} catch (e) { |
|||
this.convertValueFunction = (value) => value; |
|||
} |
|||
} |
|||
this.getValueMethod = 'getValue'; |
|||
if (settings.getValueMethod && settings.getValueMethod.length) { |
|||
this.getValueMethod = settings.getValueMethod; |
|||
} |
|||
this.setValueMethod = 'setValue'; |
|||
if (settings.setValueMethod && settings.setValueMethod.length) { |
|||
this.setValueMethod = settings.setValueMethod; |
|||
} |
|||
if (!rpcEnabled) { |
|||
this.onError('Target device is not set!'); |
|||
} else { |
|||
if (!this.isSimulated) { |
|||
if (this.retrieveValueMethod === 'rpc') { |
|||
this.rpcRequestValue(); |
|||
} else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') { |
|||
this.subscribeForValue(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
private resize() { |
|||
let width = this.switchContainer.width(); |
|||
let height = this.switchContainer.height(); |
|||
if (this.showOnOffLabels) { |
|||
height = height*2/3; |
|||
} |
|||
const ratio = width/height; |
|||
if (ratio > switchAspectRation) { |
|||
width = height*switchAspectRation; |
|||
} else { |
|||
height = width/switchAspectRation; |
|||
} |
|||
this.switchElement.css({width, height}); |
|||
this.matSlideToggle.css({width, height, minWidth: width}); |
|||
|
|||
if (this.showTitle) { |
|||
this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width()); |
|||
} |
|||
|
|||
if (this.showOnOffLabels) { |
|||
this.onoffContainer.css({width, height: this.switchContainer.height() / 3}); |
|||
this.setFontSize(this.onLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2); |
|||
this.setFontSize(this.offLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2); |
|||
} |
|||
|
|||
this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); |
|||
} |
|||
|
|||
private setFontSize(element: JQuery<HTMLElement>, text: string, fontSize: number, maxWidth: number) { |
|||
let textWidth = this.measureTextWidth(text, fontSize); |
|||
while (textWidth > maxWidth) { |
|||
fontSize--; |
|||
textWidth = this.measureTextWidth(text, fontSize); |
|||
} |
|||
element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
} |
|||
|
|||
private measureTextWidth(text: string, fontSize: number): number { |
|||
this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); |
|||
this.textMeasure.text(text); |
|||
return this.textMeasure.width(); |
|||
} |
|||
|
|||
private onError(error: string) { |
|||
this.error = error; |
|||
this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width()); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private setValue(value: boolean) { |
|||
this.value = value ? true : false; |
|||
} |
|||
|
|||
public onValue() { |
|||
this.rpcUpdateValue(this.value); |
|||
} |
|||
|
|||
private rpcRequestValue() { |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( |
|||
(responseBody) => { |
|||
this.setValue(this.parseValueFunction(responseBody)); |
|||
}, |
|||
() => { |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private rpcUpdateValue(value) { |
|||
if (this.executingUpdateValue) { |
|||
this.scheduledValue = value; |
|||
return; |
|||
} else { |
|||
this.scheduledValue = null; |
|||
this.rpcValue = value; |
|||
this.executingUpdateValue = true; |
|||
} |
|||
this.error = ''; |
|||
this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe( |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) { |
|||
this.rpcUpdateValue(this.scheduledValue); |
|||
} |
|||
}, |
|||
() => { |
|||
this.executingUpdateValue = false; |
|||
const errorText = this.ctx.defaultSubscription.rpcErrorText; |
|||
this.onError(errorText); |
|||
} |
|||
); |
|||
} |
|||
|
|||
private subscribeForValue() { |
|||
const valueSubscriptionInfo: SubscriptionInfo[] = [{ |
|||
type: DatasourceType.entity, |
|||
entityType: EntityType.DEVICE, |
|||
entityId: this.ctx.defaultSubscription.targetDeviceId |
|||
}]; |
|||
if (this.retrieveValueMethod === 'attribute') { |
|||
valueSubscriptionInfo[0].attributes = [ |
|||
{name: this.valueKey} |
|||
]; |
|||
} else { |
|||
valueSubscriptionInfo[0].timeseries = [ |
|||
{name: this.valueKey} |
|||
]; |
|||
} |
|||
const subscriptionOptions: WidgetSubscriptionOptions = { |
|||
callbacks: { |
|||
onDataUpdated: this.onDataUpdated.bind(this), |
|||
onDataUpdateError: this.onDataUpdateError.bind(this) |
|||
} |
|||
}; |
|||
this.ctx.subscriptionApi.createSubscriptionFromInfo ( |
|||
widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe( |
|||
(subscription) => { |
|||
this.valueSubscription = subscription; |
|||
} |
|||
); |
|||
} |
|||
|
|||
private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) { |
|||
let value = false; |
|||
const data = subscription.data; |
|||
if (data.length) { |
|||
const keyData = data[0]; |
|||
if (keyData && keyData.data && keyData.data[0]) { |
|||
const attrValue = keyData.data[0][1]; |
|||
if (attrValue) { |
|||
let parsed = null; |
|||
try { |
|||
parsed = this.parseValueFunction(JSON.parse(attrValue)); |
|||
} catch (e){/**/} |
|||
value = !!parsed; |
|||
} |
|||
} |
|||
} |
|||
this.setValue(value); |
|||
if (detectChanges) { |
|||
this.ctx.detectChanges(); |
|||
} |
|||
} |
|||
|
|||
private onDataUpdateError(subscription: IWidgetSubscription, e: any) { |
|||
const exceptionData = this.utils.parseException(e); |
|||
let errorText = exceptionData.name; |
|||
if (exceptionData.message) { |
|||
errorText += ': ' + exceptionData.message; |
|||
} |
|||
this.onError(errorText); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue