Browse Source

Merge branch 'master' into safe-scheduler

pull/11689/head
Dmytro Skarzhynets 2 years ago
committed by GitHub
parent
commit
4428d839ad
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 22
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/FetchMode.java
  2. 149
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java
  3. 17
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java
  4. 644
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java
  5. 9
      ui-ngx/src/app/core/utils.ts
  6. 2
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html
  7. 4
      ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts
  8. 4
      ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts
  9. 739
      ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts
  10. 4
      ui-ngx/src/app/modules/home/models/dashboard-component.models.ts
  11. 60
      ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts

22
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/FetchMode.java

@ -0,0 +1,22 @@
/**
* Copyright © 2016-2024 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.metadata;
public enum FetchMode {
FIRST, ALL, LAST
}

149
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.rule.engine.metadata;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.ListenableFuture;
@ -30,17 +31,17 @@ import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.page.SortOrder.Direction;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@ -51,6 +52,7 @@ import java.util.stream.Collectors;
@RuleNode(type = ComponentType.ENRICHMENT,
name = "originator telemetry",
configClazz = TbGetTelemetryNodeConfiguration.class,
version = 1,
nodeDescription = "Adds message originator telemetry for selected time range into message metadata",
nodeDetails = "Useful when you need to get telemetry data set from the message originator for a specific time range " +
"instead of fetching just the latest telemetry or if you need to get the closest telemetry to the fetch interval start or end. " +
@ -60,53 +62,61 @@ import java.util.stream.Collectors;
configDirective = "tbEnrichmentNodeGetTelemetryFromDatabase")
public class TbGetTelemetryNode implements TbNode {
private static final String DESC_ORDER = "DESC";
private static final String ASC_ORDER = "ASC";
private TbGetTelemetryNodeConfiguration config;
private List<String> tsKeyNames;
private int limit;
private String fetchMode;
private String orderByFetchAll;
private FetchMode fetchMode;
private Direction orderBy;
private Aggregation aggregation;
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
this.config = TbNodeUtils.convert(configuration, TbGetTelemetryNodeConfiguration.class);
tsKeyNames = config.getLatestTsKeyNames();
limit = config.getFetchMode().equals(TbGetTelemetryNodeConfiguration.FETCH_MODE_ALL) ? validateLimit(config.getLimit()) : 1;
if (tsKeyNames.isEmpty()) {
throw new TbNodeException("Telemetry should be specified!", true);
}
fetchMode = config.getFetchMode();
orderByFetchAll = config.getOrderBy();
if (StringUtils.isEmpty(orderByFetchAll)) {
orderByFetchAll = ASC_ORDER;
if (fetchMode == null) {
throw new TbNodeException("FetchMode should be specified!", true);
}
aggregation = parseAggregationConfig(config.getAggregation());
}
Aggregation parseAggregationConfig(String aggName) {
if (StringUtils.isEmpty(aggName) || !fetchMode.equals(TbGetTelemetryNodeConfiguration.FETCH_MODE_ALL)) {
return Aggregation.NONE;
switch (fetchMode) {
case ALL:
limit = validateLimit(config.getLimit());
if (config.getOrderBy() == null) {
throw new TbNodeException("OrderBy should be specified!", true);
}
orderBy = config.getOrderBy();
if (config.getAggregation() == null) {
throw new TbNodeException("Aggregation should be specified!", true);
}
aggregation = config.getAggregation();
break;
case FIRST:
limit = 1;
orderBy = Direction.ASC;
aggregation = Aggregation.NONE;
break;
case LAST:
limit = 1;
orderBy = Direction.DESC;
aggregation = Aggregation.NONE;
break;
}
return Aggregation.valueOf(aggName);
}
@Override
public void onMsg(TbContext ctx, TbMsg msg) throws ExecutionException, InterruptedException, TbNodeException {
if (tsKeyNames.isEmpty()) {
ctx.tellFailure(msg, new IllegalStateException("Telemetry is not selected!"));
} else {
try {
Interval interval = getInterval(msg);
List<String> keys = TbNodeUtils.processPatterns(tsKeyNames, msg);
ListenableFuture<List<TsKvEntry>> list = ctx.getTimeseriesService().findAll(ctx.getTenantId(), msg.getOriginator(), buildQueries(interval, keys));
DonAsynchron.withCallback(list, data -> {
var metaData = updateMetadata(data, msg, keys);
ctx.tellSuccess(TbMsg.transformMsgMetadata(msg, metaData));
}, error -> ctx.tellFailure(msg, error), ctx.getDbCallbackExecutor());
} catch (Exception e) {
ctx.tellFailure(msg, e);
}
public void onMsg(TbContext ctx, TbMsg msg) {
Interval interval = getInterval(msg);
if (interval.getStartTs() > interval.getEndTs()) {
throw new RuntimeException("Interval start should be less than Interval end");
}
List<String> keys = TbNodeUtils.processPatterns(tsKeyNames, msg);
ListenableFuture<List<TsKvEntry>> list = ctx.getTimeseriesService().findAll(ctx.getTenantId(), msg.getOriginator(), buildQueries(interval, keys));
DonAsynchron.withCallback(list, data -> {
var metaData = updateMetadata(data, msg, keys);
ctx.tellSuccess(TbMsg.transformMsgMetadata(msg, metaData));
}, error -> ctx.tellFailure(msg, error), ctx.getDbCallbackExecutor());
}
private List<ReadTsKvQuery> buildQueries(Interval interval, List<String> keys) {
@ -116,24 +126,13 @@ public class TbGetTelemetryNode implements TbNode {
interval.getEndTs() - interval.getStartTs();
return keys.stream()
.map(key -> new BaseReadTsKvQuery(key, interval.getStartTs(), interval.getEndTs(), aggIntervalStep, limit, aggregation, getOrderBy()))
.map(key -> new BaseReadTsKvQuery(key, interval.getStartTs(), interval.getEndTs(), aggIntervalStep, limit, aggregation, orderBy.name()))
.collect(Collectors.toList());
}
private String getOrderBy() {
switch (fetchMode) {
case TbGetTelemetryNodeConfiguration.FETCH_MODE_ALL:
return orderByFetchAll;
case TbGetTelemetryNodeConfiguration.FETCH_MODE_FIRST:
return ASC_ORDER;
default:
return DESC_ORDER;
}
}
private TbMsgMetaData updateMetadata(List<TsKvEntry> entries, TbMsg msg, List<String> keys) {
ObjectNode resultNode = JacksonUtil.newObjectNode(JacksonUtil.ALLOW_UNQUOTED_FIELD_NAMES_MAPPER);
if (TbGetTelemetryNodeConfiguration.FETCH_MODE_ALL.equals(fetchMode)) {
if (FetchMode.ALL.equals(fetchMode)) {
entries.forEach(entry -> processArray(resultNode, entry));
} else {
entries.forEach(entry -> processSingle(resultNode, entry));
@ -174,7 +173,7 @@ public class TbGetTelemetryNode implements TbNode {
return getIntervalFromPatterns(msg);
} else {
Interval interval = new Interval();
long ts = System.currentTimeMillis();
long ts = getCurrentTimeMillis();
interval.setStartTs(ts - TimeUnit.valueOf(config.getStartIntervalTimeUnit()).toMillis(config.getStartInterval()));
interval.setEndTs(ts - TimeUnit.valueOf(config.getEndIntervalTimeUnit()).toMillis(config.getEndInterval()));
return interval;
@ -211,12 +210,15 @@ public class TbGetTelemetryNode implements TbNode {
return pattern.replaceAll("[$\\[{}\\]]", "");
}
private int validateLimit(int limit) {
if (limit != 0) {
return limit;
} else {
return TbGetTelemetryNodeConfiguration.MAX_FETCH_SIZE;
private int validateLimit(int limit) throws TbNodeException {
if (limit < 2 || limit > TbGetTelemetryNodeConfiguration.MAX_FETCH_SIZE) {
throw new TbNodeException("Limit should be in a range from 2 to 1000.", true);
}
return limit;
}
long getCurrentTimeMillis() {
return System.currentTimeMillis();
}
@Data
@ -226,4 +228,47 @@ public class TbGetTelemetryNode implements TbNode {
private Long endTs;
}
@Override
public TbPair<Boolean, JsonNode> upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException {
boolean hasChanges = false;
switch (fromVersion) {
case 0 -> {
if (oldConfiguration.hasNonNull("fetchMode")) {
String fetchMode = oldConfiguration.get("fetchMode").asText();
switch (fetchMode) {
case "FIRST":
((ObjectNode) oldConfiguration).put("orderBy", Direction.ASC.name());
((ObjectNode) oldConfiguration).put("aggregation", Aggregation.NONE.name());
hasChanges = true;
break;
case "LAST":
((ObjectNode) oldConfiguration).put("orderBy", Direction.DESC.name());
((ObjectNode) oldConfiguration).put("aggregation", Aggregation.NONE.name());
hasChanges = true;
break;
case "ALL":
if (oldConfiguration.has("orderBy") &&
(oldConfiguration.get("orderBy").isNull() || oldConfiguration.get("orderBy").asText().isEmpty())) {
((ObjectNode) oldConfiguration).put("orderBy", Direction.ASC.name());
hasChanges = true;
}
if (oldConfiguration.has("aggregation") &&
(oldConfiguration.get("aggregation").isNull() || oldConfiguration.get("aggregation").asText().isEmpty())) {
((ObjectNode) oldConfiguration).put("aggregation", Aggregation.NONE.name());
hasChanges = true;
}
break;
default:
((ObjectNode) oldConfiguration).put("fetchMode", FetchMode.LAST.name());
((ObjectNode) oldConfiguration).put("orderBy", Direction.DESC.name());
((ObjectNode) oldConfiguration).put("aggregation", Aggregation.NONE.name());
hasChanges = true;
break;
}
}
}
}
return new TbPair<>(hasChanges, oldConfiguration);
}
}

17
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java

@ -18,6 +18,7 @@ package org.thingsboard.rule.engine.metadata;
import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.page.SortOrder.Direction;
import java.util.Collections;
import java.util.List;
@ -29,10 +30,6 @@ import java.util.concurrent.TimeUnit;
@Data
public class TbGetTelemetryNodeConfiguration implements NodeConfiguration<TbGetTelemetryNodeConfiguration> {
public static final String FETCH_MODE_FIRST = "FIRST";
public static final String FETCH_MODE_LAST = "LAST";
public static final String FETCH_MODE_ALL = "ALL";
public static final int MAX_FETCH_SIZE = 1000;
private int startInterval;
@ -45,9 +42,9 @@ public class TbGetTelemetryNodeConfiguration implements NodeConfiguration<TbGetT
private String startIntervalTimeUnit;
private String endIntervalTimeUnit;
private String fetchMode; //FIRST, LAST, ALL
private String orderBy; //ASC, DESC
private String aggregation; //MIN, MAX, AVG, SUM, COUNT, NONE;
private FetchMode fetchMode; //FIRST, LAST, ALL
private Direction orderBy; //ASC, DESC
private Aggregation aggregation; //MIN, MAX, AVG, SUM, COUNT, NONE;
private int limit;
private List<String> latestTsKeyNames;
@ -56,7 +53,7 @@ public class TbGetTelemetryNodeConfiguration implements NodeConfiguration<TbGetT
public TbGetTelemetryNodeConfiguration defaultConfiguration() {
TbGetTelemetryNodeConfiguration configuration = new TbGetTelemetryNodeConfiguration();
configuration.setLatestTsKeyNames(Collections.emptyList());
configuration.setFetchMode("FIRST");
configuration.setFetchMode(FetchMode.FIRST);
configuration.setStartIntervalTimeUnit(TimeUnit.MINUTES.name());
configuration.setStartInterval(2);
configuration.setEndIntervalTimeUnit(TimeUnit.MINUTES.name());
@ -64,8 +61,8 @@ public class TbGetTelemetryNodeConfiguration implements NodeConfiguration<TbGetT
configuration.setUseMetadataIntervalPatterns(false);
configuration.setStartIntervalPattern("");
configuration.setEndIntervalPattern("");
configuration.setOrderBy("ASC");
configuration.setAggregation(Aggregation.NONE.name());
configuration.setOrderBy(Direction.ASC);
configuration.setAggregation(Aggregation.NONE);
configuration.setLimit(MAX_FETCH_SIZE);
return configuration;
}

644
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java

@ -15,72 +15,634 @@
*/
package org.thingsboard.rule.engine.metadata;
import org.junit.jupiter.api.Assertions;
import com.google.common.util.concurrent.Futures;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.TestDbCallbackExecutor;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.kv.TsKvQuery;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.page.SortOrder.Direction;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.spy;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willReturn;
@ExtendWith(MockitoExtension.class)
public class TbGetTelemetryNodeTest extends AbstractRuleNodeUpgradeTest {
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("5738401b-9dba-422b-b656-a62fe7431917"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("8a8fd749-b2ec-488b-a6c6-fc66614d8686"));
public class TbGetTelemetryNodeTest {
private final ListeningExecutor executor = new TestDbCallbackExecutor();
TbGetTelemetryNode node;
TbGetTelemetryNodeConfiguration config;
TbNodeConfiguration nodeConfiguration;
TbContext ctx;
private TbGetTelemetryNode node;
private TbGetTelemetryNodeConfiguration config;
@Mock
private TbContext ctxMock;
@Mock
private TimeseriesService timeseriesServiceMock;
@BeforeEach
public void setUp() throws Exception {
ctx = mock(TbContext.class);
public void setUp() {
node = spy(new TbGetTelemetryNode());
config = new TbGetTelemetryNodeConfiguration();
config.setFetchMode("ALL");
nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config));
node.init(ctx, nodeConfiguration);
config = new TbGetTelemetryNodeConfiguration().defaultConfiguration();
config.setLatestTsKeyNames(List.of("temperature"));
}
@Test
public void verifyDefaultConfig() {
config = new TbGetTelemetryNodeConfiguration().defaultConfiguration();
assertThat(config.getStartInterval()).isEqualTo(2);
assertThat(config.getEndInterval()).isEqualTo(1);
assertThat(config.getStartIntervalPattern()).isEqualTo("");
assertThat(config.getEndIntervalPattern()).isEqualTo("");
assertThat(config.isUseMetadataIntervalPatterns()).isFalse();
assertThat(config.getStartIntervalTimeUnit()).isEqualTo(TimeUnit.MINUTES.name());
assertThat(config.getEndIntervalTimeUnit()).isEqualTo(TimeUnit.MINUTES.name());
assertThat(config.getFetchMode()).isEqualTo(FetchMode.FIRST);
assertThat(config.getOrderBy()).isEqualTo(Direction.ASC);
assertThat(config.getAggregation()).isEqualTo(Aggregation.NONE);
assertThat(config.getLimit()).isEqualTo(1000);
assertThat(config.getLatestTsKeyNames()).isEmpty();
}
@Test
public void givenEmptyTsKeyNames_whenInit_thenThrowsException() {
// GIVEN
config.setLatestTsKeyNames(Collections.emptyList());
// WHEN-THEN
assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("Telemetry should be specified!")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@Test
public void givenFetchModeIsNull_whenInit_thenThrowsException() {
// GIVEN
config.setFetchMode(null);
// WHEN-THEN
assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("FetchMode should be specified!")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@Test
public void givenFetchModeAllAndOrderByIsNull_whenInit_thenThrowsException() {
// GIVEN
config.setFetchMode(FetchMode.ALL);
config.setOrderBy(null);
// WHEN-THEN
assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("OrderBy should be specified!")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@ParameterizedTest
@ValueSource(ints = {-1, 0, 1, 1001, 2000})
public void givenFetchModeAllAndLimitIsOutOfRange_whenInit_thenThrowsException(int limit) {
// GIVEN
config.setFetchMode(FetchMode.ALL);
config.setLimit(limit);
willCallRealMethod().given(node).parseAggregationConfig(any());
// WHEN-THEN
assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("Limit should be in a range from 2 to 1000.")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
@Test
public void givenAggregationAsString_whenParseAggregation_thenReturnEnum() {
//compatibility with old configs without "aggregation" parameter
assertThat(node.parseAggregationConfig(null), is(Aggregation.NONE));
assertThat(node.parseAggregationConfig(""), is(Aggregation.NONE));
public void givenFetchModeIsAllAndAggregationIsNull_whenInit_thenThrowsException() {
// GIVEN
config.setFetchMode(FetchMode.ALL);
config.setAggregation(null);
// WHEN-THEN
assertThatThrownBy(() -> node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.hasMessage("Aggregation should be specified!")
.extracting(e -> ((TbNodeException) e).isUnrecoverable())
.isEqualTo(true);
}
//common values
assertThat(node.parseAggregationConfig("MIN"), is(Aggregation.MIN));
assertThat(node.parseAggregationConfig("MAX"), is(Aggregation.MAX));
assertThat(node.parseAggregationConfig("AVG"), is(Aggregation.AVG));
assertThat(node.parseAggregationConfig("SUM"), is(Aggregation.SUM));
assertThat(node.parseAggregationConfig("COUNT"), is(Aggregation.COUNT));
assertThat(node.parseAggregationConfig("NONE"), is(Aggregation.NONE));
@Test
public void givenIntervalStartIsGreaterThanIntervalEnd_whenOnMsg_thenThrowsException() throws TbNodeException {
// GIVEN
config.setStartInterval(1);
config.setEndInterval(2);
//all possible values in future
for (Aggregation aggEnum : Aggregation.values()) {
assertThat(node.parseAggregationConfig(aggEnum.name()), is(aggEnum));
}
// WHEN-THEN
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
assertThatThrownBy(() -> node.onMsg(ctxMock, msg))
.isInstanceOf(RuntimeException.class)
.hasMessage("Interval start should be less than Interval end");
}
@Test
public void givenAggregationWhiteSpace_whenParseAggregation_thenException() {
Assertions.assertThrows(IllegalArgumentException.class, () -> {
node.parseAggregationConfig(" ");
});
public void givenUseMetadataIntervalPatternsIsTrue_whenOnMsg_thenVerifyStartAndEndTsInQuery() throws TbNodeException {
// GIVEN
config.setUseMetadataIntervalPatterns(true);
config.setStartIntervalPattern("${mdStartInterval}");
config.setEndIntervalPattern("$[msgEndInterval]");
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
long startTs = 1719220350000L;
long endTs = 1719220353000L;
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("mdStartInterval", String.valueOf(startTs));
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, "{\"msgEndInterval\":\"" + endTs + "\"}");
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
ReadTsKvQuery actualReadTsKvQuery = actualReadTsKvQueryList.getValue().get(0);
assertThat(actualReadTsKvQuery.getStartTs()).isEqualTo(startTs);
assertThat(actualReadTsKvQuery.getEndTs()).isEqualTo(endTs);
}
@Test
public void givenUseMetadataIntervalPatternsIsFalse_whenOnMsg_thenVerifyStartAndEndTsInQuery() throws TbNodeException {
// GIVEN
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
long ts = System.currentTimeMillis();
willReturn(ts).given(node).getCurrentTimeMillis();
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
ReadTsKvQuery actualReadTsKvQuery = actualReadTsKvQueryList.getValue().get(0);
assertThat(actualReadTsKvQuery.getStartTs()).isEqualTo(ts - TimeUnit.MINUTES.toMillis(config.getStartInterval()));
assertThat(actualReadTsKvQuery.getEndTs()).isEqualTo(ts - TimeUnit.MINUTES.toMillis(config.getEndInterval()));
}
@Test
public void givenTsKeyNamesPatterns_whenOnMsg_thenVerifyTsKeyNamesInQuery() throws TbNodeException {
// GIVEN
config.setLatestTsKeyNames(List.of("temperature", "${mdTsKey}", "$[msgTsKey]"));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("mdTsKey", "humidity");
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, metaData, "{\"msgTsKey\":\"pressure\"}");
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
List<String> actualKeys = actualReadTsKvQueryList.getValue().stream().map(TsKvQuery::getKey).toList();
assertThat(actualKeys).containsExactlyInAnyOrder("temperature", "humidity", "pressure");
}
@ParameterizedTest
@MethodSource
public void givenAggregation_whenOnMsg_thenVerifyAggregationStepInQuery(Aggregation aggregation, Consumer<ReadTsKvQuery> aggregationStepVerifier) throws TbNodeException {
// GIVEN
config.setStartInterval(5);
config.setEndInterval(1);
config.setFetchMode(FetchMode.ALL);
config.setAggregation(aggregation);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
ReadTsKvQuery actualReadTsKvQuery = actualReadTsKvQueryList.getValue().get(0);
aggregationStepVerifier.accept(actualReadTsKvQuery);
}
private static Stream<Arguments> givenAggregation_whenOnMsg_thenVerifyAggregationStepInQuery() {
return Stream.of(
Arguments.of(Aggregation.NONE, (Consumer<ReadTsKvQuery>) query -> assertThat(query.getInterval()).isEqualTo(1)),
Arguments.of(Aggregation.AVG, (Consumer<ReadTsKvQuery>) query -> assertThat(query.getInterval()).isEqualTo(query.getEndTs() - query.getStartTs()))
);
}
@ParameterizedTest
@MethodSource
public void givenFetchModeAndLimit_whenOnMsg_thenVerifyLimitInQuery(FetchMode fetchMode, int limit, Consumer<ReadTsKvQuery> limitInQueryVerifier) throws TbNodeException {
// GIVEN
config.setFetchMode(fetchMode);
config.setLimit(limit);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
ReadTsKvQuery actualReadTsKvQuery = actualReadTsKvQueryList.getValue().get(0);
limitInQueryVerifier.accept(actualReadTsKvQuery);
}
private static Stream<Arguments> givenFetchModeAndLimit_whenOnMsg_thenVerifyLimitInQuery() {
return Stream.of(
Arguments.of(
FetchMode.ALL,
5,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getLimit()).isEqualTo(5)),
Arguments.of(
FetchMode.FIRST,
TbGetTelemetryNodeConfiguration.MAX_FETCH_SIZE,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getLimit()).isEqualTo(1)),
Arguments.of(
FetchMode.LAST,
10,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getLimit()).isEqualTo(1))
);
}
@ParameterizedTest
@MethodSource
public void givenFetchModeAndOrder_whenOnMsg_thenVerifyOrderInQuery(FetchMode fetchMode, Direction orderBy, Consumer<ReadTsKvQuery> orderInQueryVerifier) throws TbNodeException {
// GIVEN
config.setFetchMode(fetchMode);
config.setOrderBy(orderBy);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList()));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<List<ReadTsKvQuery>> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class);
then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture());
ReadTsKvQuery actualReadTsKvQuery = actualReadTsKvQueryList.getValue().get(0);
orderInQueryVerifier.accept(actualReadTsKvQuery);
}
private static Stream<Arguments> givenFetchModeAndOrder_whenOnMsg_thenVerifyOrderInQuery() {
return Stream.of(
Arguments.of(
FetchMode.ALL,
Direction.DESC,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getOrder()).isEqualTo("DESC")),
Arguments.of(
FetchMode.FIRST,
Direction.ASC,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getOrder()).isEqualTo("ASC")),
Arguments.of(
FetchMode.LAST,
Direction.ASC,
(Consumer<ReadTsKvQuery>) query -> assertThat(query.getOrder()).isEqualTo("DESC"))
);
}
@ParameterizedTest
@MethodSource
public void givenInvalidIntervalPatterns_whenOnMsg_thenThrowsException(String startIntervalPattern, String errorMsg) throws TbNodeException {
// GIVEN
config.setUseMetadataIntervalPatterns(true);
config.setStartIntervalPattern(startIntervalPattern);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
// WHEN-THEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, "{\"msgStartInterval\":\"start\"}");
assertThatThrownBy(() -> node.onMsg(ctxMock, msg)).isInstanceOf(IllegalArgumentException.class).hasMessage(errorMsg);
}
private static Stream<Arguments> givenInvalidIntervalPatterns_whenOnMsg_thenThrowsException() {
return Stream.of(
Arguments.of("${mdStartInterval}", "Message value: 'mdStartInterval' is undefined"),
Arguments.of("$[msgStartInterval]", "Message value: 'msgStartInterval' has invalid format")
);
}
@Test
public void givenAggregationIncorrect_whenParseAggregation_thenException() {
Assertions.assertThrows(IllegalArgumentException.class, () -> {
node.parseAggregationConfig("TOP");
});
public void givenFetchModeAll_whenOnMsg_thenTellSuccessAndVerifyMsg() throws TbNodeException {
// GIVEN
config.setLatestTsKeyNames(List.of("temperature", "humidity"));
config.setFetchMode(FetchMode.ALL);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
long ts = System.currentTimeMillis();
List<TsKvEntry> tsKvEntries = List.of(
new BasicTsKvEntry(ts - 5, new DoubleDataEntry("temperature", 23.1)),
new BasicTsKvEntry(ts - 4, new DoubleDataEntry("temperature", 22.4)),
new BasicTsKvEntry(ts - 4, new DoubleDataEntry("humidity", 55.5))
);
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(tsKvEntries));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<TbMsg> actualMsg = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should().tellSuccess(actualMsg.capture());
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("temperature", "[{\"ts\":" + (ts - 5) + ",\"value\":23.1},{\"ts\":" + (ts - 4) + ",\"value\":22.4}]");
metaData.putValue("humidity", "[{\"ts\":" + (ts - 4) + ",\"value\":55.5}]");
TbMsg expectedMsg = TbMsg.transformMsgMetadata(msg, metaData);
assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(expectedMsg);
}
@ParameterizedTest
@ValueSource(strings = {"FIRST", "LAST"})
public void givenFetchMode_whenOnMsg_thenTellSuccessAndVerifyMsg(String fetchMode) throws TbNodeException {
// GIVEN
config.setFetchMode(FetchMode.valueOf(fetchMode));
config.setLatestTsKeyNames(List.of("temperature", "humidity"));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
mockTimeseriesService();
long ts = System.currentTimeMillis();
List<TsKvEntry> tsKvEntries = List.of(
new BasicTsKvEntry(ts - 4, new DoubleDataEntry("temperature", 22.4)),
new BasicTsKvEntry(ts - 4, new DoubleDataEntry("humidity", 55.5))
);
given(timeseriesServiceMock.findAll(any(TenantId.class), any(EntityId.class), anyList())).willReturn(Futures.immediateFuture(tsKvEntries));
// WHEN
TbMsg msg = TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, DEVICE_ID, TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT);
node.onMsg(ctxMock, msg);
// THEN
ArgumentCaptor<TbMsg> actualMsg = ArgumentCaptor.forClass(TbMsg.class);
then(ctxMock).should().tellSuccess(actualMsg.capture());
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("temperature", "\"22.4\"");
metaData.putValue("humidity", "\"55.5\"");
TbMsg expectedMsg = TbMsg.transformMsgMetadata(msg, metaData);
assertThat(actualMsg.getValue()).usingRecursiveComparison().ignoringFields("ctx").isEqualTo(expectedMsg);
}
private void mockTimeseriesService() {
given(ctxMock.getTimeseriesService()).willReturn(timeseriesServiceMock);
given(ctxMock.getTenantId()).willReturn(TENANT_ID);
given(ctxMock.getDbCallbackExecutor()).willReturn(executor);
}
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
// config for version 0 (fetchMode is 'FIRST' and orderBy is 'INVALID_ORDER_BY' and aggregation is 'SUM')
Arguments.of(0,
"""
{
"latestTsKeyNames": [],
"aggregation": "SUM",
"fetchMode": "FIRST",
"orderBy": "INVALID_ORDER_BY",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""",
true,
"""
{
"latestTsKeyNames": [],
"aggregation": "NONE",
"fetchMode": "FIRST",
"orderBy": "ASC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
"""),
// config for version 0 (fetchMode is 'LAST' and orderBy is 'ASC' and aggregation is 'AVG')
Arguments.of(0,
"""
{
"latestTsKeyNames": [],
"aggregation": "AVG",
"fetchMode": "LAST",
"orderBy": "ASC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""",
true,
"""
{
"latestTsKeyNames": [],
"aggregation": "NONE",
"fetchMode": "LAST",
"orderBy": "DESC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
"""),
// config for version 0 (fetchMode is 'ALL' and orderBy is empty and aggregation is null)
Arguments.of(0,
"""
{
"latestTsKeyNames": [],
"aggregation": null,
"fetchMode": "ALL",
"orderBy": "",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""",
true,
"""
{
"latestTsKeyNames": [],
"aggregation": "NONE",
"fetchMode": "ALL",
"orderBy": "ASC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
"""),
// config for version 0 (fetchMode is 'ALL' and orderBy is 'DESC' and aggregation is 'SUM')
Arguments.of(0,
"""
{
"latestTsKeyNames": [],
"aggregation": "SUM",
"fetchMode": "ALL",
"orderBy": "DESC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""",
false,
"""
{
"latestTsKeyNames": [],
"aggregation": "SUM",
"fetchMode": "ALL",
"orderBy": "DESC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
"""),
// config for version 0 (fetchMode is 'INVALID_MODE' and orderBy is 'INVALID_ORDER_BY' and aggregation is 'INVALID_AGGREGATION')
Arguments.of(0,
"""
{
"latestTsKeyNames": [],
"aggregation": "INVALID_AGGREGATION",
"fetchMode": "INVALID_MODE",
"orderBy": "INVALID_ORDER_BY",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""",
true,
"""
{
"latestTsKeyNames": [],
"aggregation": "NONE",
"fetchMode": "LAST",
"orderBy": "DESC",
"limit": 1000,
"useMetadataIntervalPatterns": false,
"startIntervalPattern": "",
"endIntervalPattern": "",
"startInterval": 2,
"startIntervalTimeUnit": "MINUTES",
"endInterval": 1,
"endIntervalTimeUnit": "MINUTES"
}
""")
);
}
@Override
protected TbNode getTestNode() {
return node;
}
}

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

@ -877,6 +877,15 @@ export const getOS = (): string => {
return os;
};
export const isSafari = (): boolean => {
const userAgent = window.navigator.userAgent.toLowerCase();
return /^((?!chrome|android).)*safari/i.test(userAgent);
};
export const isFirefox = (): boolean => {
const userAgent = window.navigator.userAgent.toLowerCase();
return /^((?!seamonkey).)*firefox/i.test(userAgent);
};
export const camelCase = (str: string): string => {
return _.camelCase(str);

2
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html

@ -66,7 +66,7 @@
</mat-menu>
<div [class]="dashboardClass" id="gridster-background">
<gridster #gridster id="gridster-child" [options]="gridsterOpts">
<gridster-item #gridsterItem [item]="widget" [class]="{'tb-noselect': isEdit}" *ngFor="let widget of dashboardWidgets">
<gridster-item #gridsterItem [item]="widget" [class]="{'tb-noselect': isEdit}" *ngFor="let widget of dashboardWidgets; trackBy: trackByWidgetId">
<tb-widget-container
[gridsterItem]="gridsterItem"
[widget]="widget"

4
ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts

@ -558,6 +558,10 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo
}
}
public trackByWidgetId(_index: number, widget: DashboardWidget) {
return widget.widgetId;
}
private scrollToWidget(widget: DashboardWidget, delay?: number) {
const parentElement = this.gridster.el as HTMLElement;
widget.gridsterItemComponent$().subscribe((gridsterItem) => {

4
ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts

@ -41,6 +41,7 @@ import { isDefinedAndNotNull, mergeDeep } from '@core/utils';
import { WidgetContext } from '@home/models/widget-component.models';
import { catchError, share } from 'rxjs/operators';
import { MatIconRegistry } from '@angular/material/icon';
import { RafService } from '@core/services/raf.service';
@Component({
selector: 'tb-scada-symbol-widget',
@ -77,6 +78,7 @@ export class ScadaSymbolWidgetComponent implements OnInit, AfterViewInit, OnDest
protected sanitizer: DomSanitizer,
private imageService: ImageService,
private iconRegistry: MatIconRegistry,
private raf: RafService,
protected cd: ChangeDetectorRef) {
}
@ -141,7 +143,7 @@ export class ScadaSymbolWidgetComponent implements OnInit, AfterViewInit, OnDest
this.noScadaSymbol = true;
this.cd.markForCheck();
} else {
this.scadaSymbolObject = new ScadaSymbolObject(rootElement, this.ctx, this.iconRegistry,
this.scadaSymbolObject = new ScadaSymbolObject(rootElement, this.ctx, this.iconRegistry, this.raf,
content,
this.settings.scadaSymbolObjectSettings, this, simulated);
}

739
ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts

@ -15,7 +15,22 @@
///
import { ValueType } from '@shared/models/constants';
import { Box, Element, Runner, Style, SVG, Svg, Text, Timeline } from '@svgdotjs/svg.js';
import {
Box,
EasingLiteral,
Element,
Matrix,
MatrixExtract,
MatrixTransformParam,
Runner,
Style,
SVG,
Svg,
Text,
Timeline,
TimesParam,
TransformData
} from '@svgdotjs/svg.js';
import '@svgdotjs/svg.panzoom.js';
import {
DataToValueType,
@ -27,9 +42,12 @@ import {
} from '@shared/models/action-widget-settings.models';
import {
createLabelFromSubscriptionEntityInfo,
deepClone,
formatValue,
guid,
isDefinedAndNotNull,
isDefinedAndNotNull, isFirefox,
isNumeric, isSafari,
isUndefined,
isUndefinedOrNull,
mergeDeep,
parseFunction
@ -44,6 +62,7 @@ import { WidgetAction, WidgetActionType, widgetActionTypeTranslationMap } from '
import { catchError, map, take, takeUntil } from 'rxjs/operators';
import { isSvgIcon, splitIconName } from '@shared/models/icon.models';
import { MatIconRegistry } from '@angular/material/icon';
import { RafService } from '@core/services/raf.service';
export interface ScadaSymbolApi {
generateElementId: () => string;
@ -54,11 +73,14 @@ export interface ScadaSymbolApi {
animate: (element: Element, duration: number) => Runner;
resetAnimation: (element: Element) => void;
finishAnimation: (element: Element) => void;
cssAnimate: (element: Element, duration: number) => ScadaSymbolAnimation;
cssAnimation: (element: Element) => ScadaSymbolAnimation | undefined;
resetCssAnimation: (element: Element) => void;
finishCssAnimation: (element: Element) => void;
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;
cssAnimate: CssScadaSymbolAnimations;
}
export interface ScadaSymbolContext {
@ -73,6 +95,7 @@ export type ScadaSymbolStateRenderFunction = (ctx: ScadaSymbolContext, svg: Svg)
export type ScadaSymbolTagStateRenderFunction = (ctx: ScadaSymbolContext, element: Element) => void;
// noinspection JSUnusedGlobalSymbols
export type ScadaSymbolActionTrigger = 'click';
export type ScadaSymbolActionFunction = (ctx: ScadaSymbolContext, element: Element, event: Event) => void;
@ -263,10 +286,10 @@ const parseScadaSymbolMetadataFromDom = (svgDoc: Document): ScadaSymbolMetadata
const svg = svgDoc.getElementsByTagName('svg')[0];
let width = null;
let height = null;
if (svg.viewBox.baseVal.width && svg.viewBox.baseVal.height) {
if (svg.viewBox?.baseVal?.width && svg.viewBox?.baseVal?.height) {
width = svg.viewBox.baseVal.width;
height = svg.viewBox.baseVal.height;
} else if (svg.width.baseVal.value && svg.height.baseVal.value) {
} else if (svg.width?.baseVal?.value && svg.height?.baseVal?.value) {
width = svg.width.baseVal.value;
height = svg.height.baseVal.value;
}
@ -489,9 +512,10 @@ export interface ScadaSymbolObjectCallbacks {
export class ScadaSymbolObject {
private metadata: ScadaSymbolMetadata;
private readonly metadata: ScadaSymbolMetadata;
private settings: ScadaSymbolObjectSettings;
private context: ScadaSymbolContext;
private cssAnimations: CssScadaSymbolAnimations;
private svgShape: Svg;
private box: Box;
@ -512,7 +536,8 @@ export class ScadaSymbolObject {
constructor(private rootElement: HTMLElement,
private ctx: WidgetContext,
private iconRegistry: MatIconRegistry,
private svgContent: string,
private raf: RafService,
private readonly svgContent: string,
private inputSettings: ScadaSymbolObjectSettings,
private callbacks: ScadaSymbolObjectCallbacks,
private simulated: boolean) {
@ -609,6 +634,7 @@ export class ScadaSymbolObject {
}
private init() {
this.cssAnimations = new CssScadaSymbolAnimations(this.svgShape, this.raf);
this.context = {
api: {
generateElementId: () => generateElementId(),
@ -619,11 +645,14 @@ export class ScadaSymbolObject {
animate: this.animate.bind(this),
resetAnimation: this.resetAnimation.bind(this),
finishAnimation: this.finishAnimation.bind(this),
cssAnimate: this.cssAnimate.bind(this),
cssAnimation: this.cssAnimation.bind(this),
resetCssAnimation: this.resetCssAnimation.bind(this),
finishCssAnimation: this.finishCssAnimation.bind(this),
disable: this.disableElement.bind(this),
enable: this.enableElement.bind(this),
callAction: this.callAction.bind(this),
setValue: this.setValue.bind(this),
cssAnimate: new CssScadaSymbolAnimations(this.svgShape)
},
tags: {},
properties: {},
@ -960,6 +989,22 @@ export class ScadaSymbolObject {
element.timeline(new Timeline());
}
private cssAnimate(element: Element, duration: number): ScadaSymbolAnimation {
return this.cssAnimations.animate(element, duration);
}
private cssAnimation(element: Element): ScadaSymbolAnimation | undefined {
return this.cssAnimations.animation(element);
}
private resetCssAnimation(element: Element) {
this.cssAnimations.resetAnimation(element);
}
private finishCssAnimation(element: Element) {
this.cssAnimations.finishAnimation(element);
}
private disableElement(e: Element | Element[]) {
this.elements(e).forEach(element => {
element.attr({'pointer-events': 'none'});
@ -1013,49 +1058,83 @@ export class ScadaSymbolObject {
const scadaSymbolAnimationId = 'scadaSymbolAnimation';
class CssScadaSymbolAnimations {
constructor(private svgShape: Svg) {}
interface ScadaSymbolAnimation {
public rotate(element: Element, rotation: number, duration = 1000, loop = false): CssScadaSymbolAnimation {
this.checkOldAnimation(element);
return this.setupAnimation(element,
new RotateCssScadaSymbolAnimation(this.svgShape, element, loop, rotation || 0)).duration(duration);
}
running(): boolean;
play(): void;
pause(): void;
stop(): void;
finish(): void;
speed(speed: number): ScadaSymbolAnimation;
ease(easing: string): ScadaSymbolAnimation;
loop(times?: number, swing?: boolean): ScadaSymbolAnimation;
public move(element: Element, deltaX: number, deltaY: number, duration = 1000, loop = false): CssScadaSymbolAnimation {
transform(transform: MatrixTransformParam, relative?: boolean): ScadaSymbolAnimation;
rotate(r: number, cx?: number, cy?: number): ScadaSymbolAnimation;
x(x: number): ScadaSymbolAnimation;
y(y: number): ScadaSymbolAnimation;
size(width: number, height: number): ScadaSymbolAnimation;
width(width: number): ScadaSymbolAnimation;
height(height: number): ScadaSymbolAnimation;
move(x: number, y: number): ScadaSymbolAnimation;
dmove(dx: number, dy: number): ScadaSymbolAnimation;
relative(x: number, y: number): ScadaSymbolAnimation;
scale(x: number, y?: number, cx?: number, cy?: number): ScadaSymbolAnimation;
attr(attr: string | object, value?: any): ScadaSymbolAnimation;
}
class CssScadaSymbolAnimations {
constructor(private svgShape: Svg,
private raf: RafService) {}
public animate(element: Element, duration = 1000): ScadaSymbolAnimation {
this.checkOldAnimation(element);
return this.setupAnimation(element,
new MoveCssScadaSymbolAnimation(this.svgShape, element, loop, deltaX, deltaY).duration(duration));
return this.setupAnimation(element, this.createAnimation(element, duration));
}
public attr(element: Element, attrName: string, value: any, duration = 1000, loop = false): CssScadaSymbolAnimation {
this.checkOldAnimation(element);
return this.setupAnimation(element,
new AttrsCssScadaSymbolAnimation(this.svgShape, element, loop, {[attrName]: value}).duration(duration));
public animation(element: Element): ScadaSymbolAnimation | undefined {
return element.remember(scadaSymbolAnimationId);
}
public attrs(element: Element, attr: any, duration = 1000, loop = false): CssScadaSymbolAnimation {
this.checkOldAnimation(element);
return this.setupAnimation(element,
new AttrsCssScadaSymbolAnimation(this.svgShape, element, loop, attr).duration(duration));
public resetAnimation(element: Element) {
const animation: ScadaSymbolAnimation = element.remember(scadaSymbolAnimationId);
if (animation) {
animation.stop();
element.remember(scadaSymbolAnimationId, null);
}
}
public animation(element: Element): CssScadaSymbolAnimation | undefined {
return element.remember(scadaSymbolAnimationId);
public finishAnimation(element: Element) {
const animation: ScadaSymbolAnimation = element.remember(scadaSymbolAnimationId);
if (animation) {
animation.finish();
element.remember(scadaSymbolAnimationId, null);
}
}
private checkOldAnimation(element: Element) {
const previousAnimation: CssScadaSymbolAnimation = element.remember(scadaSymbolAnimationId);
const previousAnimation: ScadaSymbolAnimation = element.remember(scadaSymbolAnimationId);
if (previousAnimation) {
previousAnimation.destroy();
previousAnimation.finish();
}
}
private setupAnimation(element: Element, animation: CssScadaSymbolAnimation): CssScadaSymbolAnimation {
animation.init();
private setupAnimation(element: Element, animation: ScadaSymbolAnimation): ScadaSymbolAnimation {
element.remember(scadaSymbolAnimationId, animation);
return animation;
}
private createAnimation(element: Element, duration: number): ScadaSymbolAnimation {
const fallbackToJs = (isSafari() || isFirefox()) && element.type === 'pattern';
if (fallbackToJs) {
return new JsScadaSymbolAnimation(element, duration);
} else {
return new CssScadaSymbolAnimation(this.svgShape, this.raf, element, duration);
}
}
}
interface ScadaSymbolAnimationKeyframe {
@ -1063,113 +1142,239 @@ interface ScadaSymbolAnimationKeyframe {
style: any;
}
abstract class CssScadaSymbolAnimation {
class CssScadaSymbolAnimation implements ScadaSymbolAnimation {
private _animationName: string;
private _animationStyle: Style;
private _active = false;
private _running = true;
private _speed = 1;
private _duration = 1000;
private readonly _duration: number = 1000;
private _easing = 'linear';
protected constructor(protected svgShape: Svg,
protected element: Element,
protected loop: boolean) {
private _times = 1;
private _swing = false;
private _hasAnimations = false;
private _transform: MatrixTransformParam;
private _relative: boolean;
private _initialTransform: MatrixExtract;
private _transformOriginX: number = null;
private _transformOriginY: number = null;
private _attrs: any;
private _startAttrs: any;
private _endAttrs: any;
private _caf = null;
constructor(private svgShape: Svg,
private raf: RafService,
private element: Element,
duration = 1000) {
this._duration = duration;
}
public init() {
this.prepareAnimation();
public running(): boolean {
return this._active && this._running;
}
public start() {
public play() {
if (!this._running) {
this.updateAnimationStyle('animation-play-state', 'running');
this._running = true;
this.updateAnimationStyle('animation-play-state', this.playStateStyle());
}
}
public pause() {
if (this._running) {
this.updateAnimationStyle('animation-play-state', 'paused');
this._running = false;
this.updateAnimationStyle('animation-play-state', this.playStateStyle());
}
}
public running(): boolean {
return this._running;
public stop() {
this._running = false;
if (this._hasAnimations) {
this.destroy();
this.applyStartAttrs();
}
}
public destroy() {
if (this._animationStyle) {
this._animationStyle.remove();
this.element.removeClass(this._animationName);
this._animationStyle = null;
this._animationName = null;
public finish() {
this._running = false;
if (this._hasAnimations) {
this.destroy();
}
}
public duration(duration: number): CssScadaSymbolAnimation {
this._duration = duration;
this.updateAnimationStyle(this.loop ? 'animation-duration' : 'transition-duration',
Math.round(this._duration / this._speed) + 'ms');
public speed(speed: number): this {
this._speed = speed;
this.updateAnimationStyle('animation-duration',
this.durationStyle());
this.updateAnimationStyle('animation-play-state', this.playStateStyle());
return this;
}
public speed(speed: number): CssScadaSymbolAnimation {
this._speed = speed;
this.updateAnimationStyle(this.loop ? 'animation-duration' : 'transition-duration',
Math.round(this._duration / this._speed) + 'ms');
public ease(easing: string): this {
this._easing = easing;
this.updateAnimationStyle('animation-timing-function', this._easing);
return this;
}
public easing(easing: string): CssScadaSymbolAnimation {
this._easing = easing;
this.updateAnimationStyle(this.loop ? 'animation-timing-function' : 'transition-timing-function', this._easing);
public loop(times = 0, swing = false): this {
this._times = times;
this._swing = swing;
if (this._animationStyle) {
this.createOrUpdateAnimation();
}
return this;
}
public transform(transform: MatrixTransformParam, relative = false): this {
this._hasAnimations = true;
for (const key of Object.keys(transform)) {
const val = transform[key];
if (!isFinite(val) && !Array.isArray(val)) {
delete transform[key];
}
}
if (this._transform) {
this._transform = Object.assign(this._transform, transform);
} else {
this._transform = deepClone(transform);
}
this._relative = relative;
this.createOrUpdateAnimation();
return this;
}
public rotate(r: number, cx?: number, cy?: number): this {
return this.transform({rotate: r, ox: cx, oy: cy}, true);
}
public x(x: number): this {
return this.transform({translateX: x});
}
public y(y: number): this {
return this.transform({translateY: y});
}
public size(width: number, height: number): this {
const box = this.element.bbox();
if (width == null || height == null) {
if (width == null) {
width = box.width / box.height * height;
} else if (height == null) {
height = box.height / box.width * width;
}
}
const scaleX = width / box.width;
const scaleY = height / box.height;
return this.scale(scaleX, scaleY);
}
public width(width: number): this {
return this.size(width, this.element.bbox().height);
}
public height(height: number): this {
return this.size(this.element.bbox().width, height);
}
public move(x: number, y: number): this {
const box = this.element.bbox();
const dx = x - box.x;
const dy = y - box.y;
return this.dmove(dx, dy);
}
public dmove(dx: number, dy: number): this {
return this.transform({translateX: dx, translateY: dy}, true);
}
public relative(x: number, y: number): this {
return this.transform({translateX: x, translateY: y}, true);
}
public scale(x: number, y?: number, cx?: number, cy?: number): this {
return this.transform({scaleX: x, scaleY: isUndefined(y) ? x : y, ox: cx, oy: cy}, true);
}
public attr(attr: string | object, value?: any): this {
this._hasAnimations = true;
if (!this._attrs) {
this._attrs = {};
}
if (typeof attr === 'object') {
for (const key of Object.keys(attr)) {
this._attrs[key] = attr[key];
}
} else {
this._attrs[attr] = value;
}
this.createOrUpdateAnimation();
return this;
}
private createOrUpdateAnimation() {
this.destroy();
this._caf = this.raf.raf(() => this.prepareAnimation());
}
private prepareAnimation() {
this._active = true;
this.prepareTransform();
this.prepareStartEndAttrs();
this._animationName = 'animation_' + generateElementId();
this.element.on('animationend', (evt) => {
if ((evt as any).animationName === this._animationName) {
this.destroy();
}
});
this._animationStyle = this.svgShape.style();
let styles: any;
if (this.loop) {
styles = {
const styles = {
'animation-name': this._animationName,
'animation-duration': this._duration + 'ms',
'animation-duration': this.durationStyle(),
'animation-timing-function': this._easing,
'animation-iteration-count': 'infinite',
'animation-fill-mode': 'forwards',
'animation-play-state': 'running',
...this.animationStyles()
};
} else {
styles = {
'transition-property': this.transitionProperties(),
'transition-duration': this._duration + 'ms',
'transition-timing-function': this._easing,
...this.animationStyles()
};
}
'animation-iteration-count': this._times === 0 ? 'infinite' : this._times,
'animation-play-state': this.playStateStyle()
};
this._animationStyle.rule('.' + this._animationName, styles);
if (this.loop) {
const keyframes = this.animationKeyframes();
let keyframesCss = `\n@keyframes ${this._animationName} {\n`;
for (const keyframe of keyframes) {
let keyframeCss = ` ${keyframe.stop} {\n`;
for (const i of Object.keys(keyframe.style)) {
keyframeCss += ' ' + i + ':' + keyframe.style[i] + ';\n';
}
keyframeCss += ' }\n';
keyframesCss += keyframeCss;
const keyframes = this.animationKeyframes();
let keyframesCss = `\n@keyframes ${this._animationName} {\n`;
for (const keyframe of keyframes) {
let keyframeCss = ` ${keyframe.stop} {\n`;
for (const i of Object.keys(keyframe.style)) {
keyframeCss += ' ' + i + ':' + keyframe.style[i] + ';\n';
}
keyframeCss += ' }\n';
keyframesCss += keyframeCss;
}
keyframesCss += '}';
this._animationStyle.addText(keyframesCss);
setTimeout(() => {
this.element.addClass(this._animationName);
if (!this._swing) {
this.applyEndAttrs();
}
keyframesCss += '}';
this._animationStyle.addText(keyframesCss);
}, 0);
}
private destroy() {
this.element.off('animationend');
this._active = false;
if (this._caf) {
this._caf();
this._caf = null;
}
this.element.addClass(this._animationName);
if (!this.loop) {
this.doTransform();
if (this._animationStyle) {
this._animationStyle.remove();
this.element.removeClass(this._animationName);
this._animationStyle = null;
this._animationName = null;
}
}
@ -1181,130 +1386,312 @@ abstract class CssScadaSymbolAnimation {
}
}
protected animationStyles(): any {
return {};
private durationStyle(): string {
return (this._speed > 0 && this._duration > 0) ? Math.round(
(this._duration / this._speed) * (this._swing ? 2 : 1)
) + 'ms' : '1000ms';
}
protected abstract animationKeyframes(): ScadaSymbolAnimationKeyframe[];
private playStateStyle(): string {
return (this._running && this._speed > 0) ? 'running' : 'paused';
}
protected abstract transitionProperties(): string;
private animationKeyframes(): ScadaSymbolAnimationKeyframe[] {
const keyframes: ScadaSymbolAnimationKeyframe[] = [];
let startStyle: any = {};
let endStyle: any = {};
if (this._transform) {
const transformed = this.transformedData();
startStyle = this.cssTransform();
endStyle = this.cssTransform(transformed);
}
if (this._attrs) {
startStyle = {...startStyle, ...this.currentCssAttrs()};
endStyle = {...endStyle, ...this.toCssAttrs(this._attrs)};
}
keyframes.push({
stop: '0%',
style: startStyle
});
if (this._swing) {
keyframes.push(...[
{
stop: '50%',
style: endStyle
},
{
stop: '100%',
style: startStyle
}]
);
} else {
keyframes.push({
stop: '100%',
style: endStyle
});
}
return keyframes;
}
protected doTransform() {
private prepareStartEndAttrs() {
if (this._attrs) {
this._startAttrs = {...this._startAttrs, ...this.currentSvgAttrs()};
this._endAttrs = {...this._endAttrs, ...this._attrs};
}
}
}
private applyStartAttrs() {
if (this._initialTransform) {
this.element.transform(this._initialTransform);
}
if (this._startAttrs) {
this.element.attr(this._startAttrs);
}
}
class RotateCssScadaSymbolAnimation extends CssScadaSymbolAnimation {
private applyEndAttrs() {
if (this._transform) {
this.element.transform(this._transform, this._relative);
}
if (this._endAttrs) {
this.element.attr(this._endAttrs);
}
}
private prepareTransform() {
if (this._transform) {
this._transformOriginX = this.element.cx();
this._transformOriginY = this.element.cy();
if (isDefinedAndNotNull(this._transform.originX)) {
this._transformOriginX = this._transform.originX;
} else if (isDefinedAndNotNull(this._transform.ox)) {
this._transformOriginX = this._transform.ox;
}
if (isDefinedAndNotNull(this._transform.originY)) {
this._transformOriginY = this._transform.originY;
} else if (isDefinedAndNotNull(this._transform.oy)) {
this._transformOriginX = this._transform.oy;
}
constructor(protected svgShape: Svg,
protected element: Element,
protected loop: boolean,
private rotation: number) {
super(svgShape, element, loop);
this._transformOriginX = this.normFloat(this._transformOriginX);
this._transformOriginY = this.normFloat(this._transformOriginY);
const transformValue: string = this.element.attr('transform');
const hasMatrixTransform = transformValue && transformValue.startsWith('matrix');
if (hasMatrixTransform) {
const matrix = new Matrix(this.element);
this._initialTransform = matrix.decompose(this._transformOriginX, this._transformOriginY);
} else {
this._initialTransform = this.element.transform();
}
this._initialTransform.originX = this._transformOriginX;
this._initialTransform.originY = this._transformOriginY;
for (const key of ['translateX', 'translateY', 'scaleX', 'scaleY', 'rotate']) {
this._initialTransform[key] = this.normFloat(this._initialTransform[key]);
}
for (const key of ['b', 'c']) {
this._initialTransform[key] = this.normFloat(this._initialTransform[key], 0);
}
}
}
protected animationStyles(): any {
return {
'transform-origin': `${this.element.cx()}px ${this.element.cy()}px`
};
private transformedData(): TransformData {
const transformed: TransformData = {};
const transform = this._initialTransform;
for (const key of Object.keys(this._transform)) {
if (this._relative) {
transformed[key] = this.normFloat(transform[key] + this._transform[key]);
} else {
transformed[key] = this.normFloat(this._transform[key]);
}
}
return transformed;
}
protected animationKeyframes(): ScadaSymbolAnimationKeyframe[] {
const transform = this.element.transform();
return [
{
stop: '0%',
style: {
transform: `translate(${transform.translateX}px, ${transform.translateY}px) rotate(${transform.rotate}deg)`
}
},
{
stop: '100%',
style: {
transform: `translate(${transform.translateX}px, ${transform.translateY}px) rotate(${this.rotation}deg)`
}
private currentCssAttrs(): any {
const cssAttrs = {};
const computed = getComputedStyle(this.element.node);
for (const key of Object.keys(this._attrs)) {
const value = computed.getPropertyValue(key);
if (isDefinedAndNotNull(value)) {
cssAttrs[key] = value;
}
];
}
return cssAttrs;
}
private currentSvgAttrs(): any {
const svgAttrs = {};
for (const key of Object.keys(this._attrs)) {
const value = this.element.attr(key);
if (isDefinedAndNotNull(value)) {
svgAttrs[key] = value;
}
}
return svgAttrs;
}
protected transitionProperties(): string {
return 'transform';
private toCssAttrs(attrs: any): any {
const cssAttrs: any = {};
for (const key of Object.keys(attrs)) {
let val = attrs[key];
if (['x', 'y', 'width', 'height'].includes(key)) {
if (isNumeric(val)) {
val += 'px';
}
}
cssAttrs[key] = val;
}
return cssAttrs;
}
protected doTransform() {
const transform = this.element.transform();
this.element.attr({transform: `translate(${transform.translateX} ${transform.translateY}) rotate(${this.rotation})`});
private cssTransform(inputTransform?: TransformData): any {
let transform = this._initialTransform || this.element.transform();
if (inputTransform) {
transform = deepClone(transform);
Object.assign(transform, inputTransform);
}
return {
'transform-origin': `${transform.originX}px ${transform.originY}px`,
transform: `translate(${transform.translateX}px, ${transform.translateY}px) ` +
`skewX(${transform.b}deg) skewY(${transform.c}deg) ` +
`scale(${transform.scaleX}, ${transform.scaleY}) ` +
`rotate(${transform.rotate}deg)`};
}
private normFloat(num: number, digits = 2): number {
const factor = Math.pow(10, digits);
return Math.round((num + Number.EPSILON) * factor) / factor;
}
}
class MoveCssScadaSymbolAnimation extends CssScadaSymbolAnimation {
class JsScadaSymbolAnimation implements ScadaSymbolAnimation {
constructor(protected svgShape: Svg,
protected element: Element,
protected loop: boolean,
private deltaX: number,
private deltaY: number) {
super(svgShape, element, loop);
private readonly _runner: Runner;
private _timeline: Timeline;
private _running = true;
constructor(private element: Element,
duration = 1000) {
this._timeline = this.element.timeline();
this._runner = this.element.animate(duration, 0, 'now').ease('-');
}
protected animationStyles(): any {
return {
'transform-origin': `${this.element.cx()}px ${this.element.cy()}px`
};
public runner(): Runner {
return this._runner;
}
protected animationKeyframes(): ScadaSymbolAnimationKeyframe[] {
const transform = this.element.transform();
return [
{
stop: '0%',
style: {
transform: `translate(${transform.translateX}px, ${transform.translateY}px)`
}
},
{
stop: '100%',
style: {
transform: `translate(${transform.translateX+this.deltaX}px, ${transform.translateY+this.deltaY}px)`
}
}
];
public running(): boolean {
return this._running;
}
protected transitionProperties(): string {
return 'transform';
public play() {
if (!this._running) {
this._timeline.play();
this._running = true;
}
}
protected doTransform() {
this.element.relative(this.deltaX, this.deltaY);
public pause() {
if (this._running) {
this._timeline.pause();
this._running = false;
}
}
}
public stop() {
this._running = false;
this._timeline.stop();
this._timeline = new Timeline();
this.element.timeline(this._timeline);
}
public finish() {
this._running = false;
this._timeline.finish();
this._timeline = new Timeline();
this.element.timeline(this._timeline);
}
public speed(speed: number): this {
this._timeline.speed(speed);
return this;
}
// Runner methods
public ease(easing: string): this {
this._runner.ease(easing as EasingLiteral);
return this;
}
public loop(times: number | TimesParam, swing?: boolean, wait?: number): this {
if (typeof times === 'object') {
this._runner.loop(times);
} else {
this._runner.loop(times, swing, wait);
}
return this;
}
public transform(transform: MatrixTransformParam, relative?: boolean): this {
this._runner.transform(transform, relative);
return this;
}
public rotate(_r: number, _cx?: number, _cy?: number): this {
(this._runner as any).rotate(...arguments);
return this;
}
public x(x: number): this {
this._runner.x(x);
return this;
}
public y(y: number): this {
this._runner.y(y);
return this;
}
class AttrsCssScadaSymbolAnimation extends CssScadaSymbolAnimation {
public size(width: number, height: number): this {
this._runner.size(width, height);
return this;
}
constructor(protected svgShape: Svg,
protected element: Element,
protected loop: boolean,
private attr: any) {
super(svgShape, element, loop);
public width(width: number): this {
this._runner.width(width);
return this;
}
protected animationStyles(): any {
return {};
public height(height: number): this {
this._runner.height(height);
return this;
}
protected animationKeyframes(): ScadaSymbolAnimationKeyframe[] {
return [];
public move(x: number, y: number): this {
this._runner.move(x, y);
return this;
}
protected transitionProperties(): string {
return Object.keys(this.attr).join(' ');
public dmove(dx: number, dy: number): this {
this._runner.dmove(dx, dy);
return this;
}
protected doTransform() {
this.element.attr(this.attr);
public relative(_x: number, _y: number): this {
(this._runner as any).relative(...arguments);
return this;
}
public scale(_x: number, _y?: number, _cx?: number, _cy?: number): this {
(this._runner as any).scale(...arguments);
return this;
}
public attr(a: string | object, v?: string): this {
this._runner.attr(a, v);
return this;
}
}

4
ui-ngx/src/app/modules/home/models/dashboard-component.models.ts

@ -707,7 +707,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
}
set x(x: number) {
if (!this.dashboard.isMobileSize) {
if (!this.dashboard.isMobileSize && this.dashboard.isEdit) {
if (this.widgetLayout) {
this.widgetLayout.col = x;
} else {
@ -728,7 +728,7 @@ export class DashboardWidget implements GridsterItem, IDashboardWidget {
}
set y(y: number) {
if (!this.dashboard.isMobileSize) {
if (!this.dashboard.isMobileSize && this.dashboard.isEdit) {
if (this.widgetLayout) {
this.widgetLayout.row = y;
} else {

60
ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts

@ -1159,6 +1159,66 @@ export const scadaSymbolContextCompletion = (metadata: ScadaSymbolMetadata, tags
type: 'ScadaSymbolApi',
description: 'SCADA symbol API',
children: {
cssAnimate: {
meta: 'function',
description: 'Finishes any previous CSS animation and starts new CSS animation for SVG element.',
args: [
{
name: 'element',
description: 'SVG element',
type: 'Element'
},
{
name: 'duration',
description: 'Animation duration in milliseconds',
type: 'number'
}
],
return: {
description: 'Instance of ScadaSymbolAnimation which has generally similar methods as ' +
'<a href="https://svgjs.dev/docs/3.2/animating/#svg-runner">SVG.Runner</a> to control the animation.',
type: 'ScadaSymbolAnimation'
}
},
cssAnimation: {
meta: 'function',
description: 'Get the current CSS animation applied for the SVG element.',
args: [
{
name: 'element',
description: 'SVG element',
type: 'Element'
}
],
return: {
description: 'Instance of ScadaSymbolAnimation which has generally similar methods as ' +
'<a href="https://svgjs.dev/docs/3.2/animating/#svg-runner">SVG.Runner</a> to control the animation.',
type: 'ScadaSymbolAnimation'
}
},
resetCssAnimation: {
meta: 'function',
description: 'Stops CSS animation if any and restore SVG element initial state, removes CSS animation instance.',
args: [
{
name: 'element',
description: 'SVG element',
type: 'Element'
},
]
},
finishCssAnimation: {
meta: 'function',
description: 'Finishes CSS animation if any, SVG element state updated according to the end animation values, ' +
'removes CSS animation instance.',
args: [
{
name: 'element',
description: 'SVG element',
type: 'Element'
},
]
},
animate: {
meta: 'function',
description: 'Finishes any previous animation and starts new animation for SVG element.',

Loading…
Cancel
Save