diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/FetchMode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/FetchMode.java new file mode 100644 index 0000000000..57fee39a3f --- /dev/null +++ b/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 + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java index bcf6b1b05a..2b5190563d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNode.java +++ b/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 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 keys = TbNodeUtils.processPatterns(tsKeyNames, msg); - ListenableFuture> 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 keys = TbNodeUtils.processPatterns(tsKeyNames, msg); + ListenableFuture> 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 buildQueries(Interval interval, List 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 entries, TbMsg msg, List 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 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); + } + } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java index c4128a4481..704abeddf6 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeConfiguration.java +++ b/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 { - 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 latestTsKeyNames; @@ -56,7 +53,7 @@ public class TbGetTelemetryNodeConfiguration implements NodeConfiguration 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> 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> 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> actualReadTsKvQueryList = ArgumentCaptor.forClass(List.class); + then(timeseriesServiceMock).should().findAll(eq(TENANT_ID), eq(DEVICE_ID), actualReadTsKvQueryList.capture()); + List actualKeys = actualReadTsKvQueryList.getValue().stream().map(TsKvQuery::getKey).toList(); + assertThat(actualKeys).containsExactlyInAnyOrder("temperature", "humidity", "pressure"); + } + + @ParameterizedTest + @MethodSource + public void givenAggregation_whenOnMsg_thenVerifyAggregationStepInQuery(Aggregation aggregation, Consumer 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> 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 givenAggregation_whenOnMsg_thenVerifyAggregationStepInQuery() { + return Stream.of( + Arguments.of(Aggregation.NONE, (Consumer) query -> assertThat(query.getInterval()).isEqualTo(1)), + Arguments.of(Aggregation.AVG, (Consumer) query -> assertThat(query.getInterval()).isEqualTo(query.getEndTs() - query.getStartTs())) + ); + } + + @ParameterizedTest + @MethodSource + public void givenFetchModeAndLimit_whenOnMsg_thenVerifyLimitInQuery(FetchMode fetchMode, int limit, Consumer 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> 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 givenFetchModeAndLimit_whenOnMsg_thenVerifyLimitInQuery() { + return Stream.of( + Arguments.of( + FetchMode.ALL, + 5, + (Consumer) query -> assertThat(query.getLimit()).isEqualTo(5)), + Arguments.of( + FetchMode.FIRST, + TbGetTelemetryNodeConfiguration.MAX_FETCH_SIZE, + (Consumer) query -> assertThat(query.getLimit()).isEqualTo(1)), + Arguments.of( + FetchMode.LAST, + 10, + (Consumer) query -> assertThat(query.getLimit()).isEqualTo(1)) + ); + } + + @ParameterizedTest + @MethodSource + public void givenFetchModeAndOrder_whenOnMsg_thenVerifyOrderInQuery(FetchMode fetchMode, Direction orderBy, Consumer 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> 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 givenFetchModeAndOrder_whenOnMsg_thenVerifyOrderInQuery() { + return Stream.of( + Arguments.of( + FetchMode.ALL, + Direction.DESC, + (Consumer) query -> assertThat(query.getOrder()).isEqualTo("DESC")), + Arguments.of( + FetchMode.FIRST, + Direction.ASC, + (Consumer) query -> assertThat(query.getOrder()).isEqualTo("ASC")), + Arguments.of( + FetchMode.LAST, + Direction.ASC, + (Consumer) 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 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 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 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 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 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 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; + } } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 3f27e8289d..2ab104f8f4 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/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); diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index 33c8d659f6..91719334df 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -66,7 +66,7 @@
- + { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts index aebb2b427d..e67a1824c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts +++ b/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); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts index 0418665d69..ee867de2ac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol.models.ts +++ b/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>) => 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; } } diff --git a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts b/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts index 0b7b122112..19838773cf 100644 --- a/ui-ngx/src/app/modules/home/models/dashboard-component.models.ts +++ b/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 { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts index 769671c0ac..b4069856b6 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.models.ts +++ b/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 ' + + 'SVG.Runner 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 ' + + 'SVG.Runner 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.',