From 0cc55ab9898f0a1dbadf08ae53158ffc81ab25dd Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Tue, 10 Dec 2024 12:26:44 +0100 Subject: [PATCH 001/127] added tbel metrics, refactored JS stats --- .../server/actors/ActorSystemContext.java | 16 --- .../actors/ruleChain/DefaultTbContext.java | 21 ---- .../service/stats/DefaultJsInvokeStats.java | 83 ---------------- .../src/main/resources/thingsboard.yml | 2 - .../server/actors/JsInvokeStats.java | 44 --------- .../api/AbstractScriptInvokeService.java | 54 +++++++--- .../script/api/ScriptStatCallback.java | 14 +-- .../api/js/AbstractJsInvokeService.java | 5 + .../api/tbel/DefaultTbelInvokeService.java | 6 ++ .../server/common/stats/DefaultCounter.java | 4 + .../server/common/stats/StatsType.java | 1 + .../dashboards/core_and_js_metrics.json | 98 ++++++++++++++++++- .../rule/engine/api/TbContext.java | 6 -- .../rule/engine/action/TbClearAlarmNode.java | 2 - .../rule/engine/action/TbCreateAlarmNode.java | 8 -- .../rule/engine/action/TbLogNode.java | 3 - .../rule/engine/debug/TbMsgGeneratorNode.java | 2 - .../rule/engine/filter/TbJsFilterNode.java | 3 - .../rule/engine/filter/TbJsSwitchNode.java | 3 - .../engine/transform/TbTransformMsgNode.java | 2 - .../engine/action/TbCreateAlarmNodeTest.java | 20 +--- 21 files changed, 162 insertions(+), 235 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java delete mode 100644 common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 1c18a31f68..28f743d1b5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -28,7 +28,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; @@ -399,10 +398,6 @@ public class ActorSystemContext { @Getter private ClaimDevicesService claimDevicesService; - @Autowired - @Getter - private JsInvokeStats jsInvokeStats; - //TODO: separate context for TbCore and TbRuleEngine @Autowired(required = false) @Getter @@ -527,17 +522,6 @@ public class ActorSystemContext { this.localCacheType = "caffeine".equals(cacheType); } - @Scheduled(fixedDelayString = "${actors.statistics.js_print_interval_ms}") - public void printStats() { - if (statisticsEnabled) { - if (jsInvokeStats.getRequests() > 0 || jsInvokeStats.getResponses() > 0 || jsInvokeStats.getFailures() > 0) { - log.info("Rule Engine JS Invoke Stats: requests [{}] responses [{}] failures [{}]", - jsInvokeStats.getRequests(), jsInvokeStats.getResponses(), jsInvokeStats.getFailures()); - jsInvokeStats.reset(); - } - } - } - @Value("${actors.tenant.create_components_on_init:true}") @Getter private boolean tenantComponentsInitEnabled; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 0070eba4a1..2be01c57b7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -608,27 +608,6 @@ public class DefaultTbContext implements TbContext { } } - @Override - public void logJsEvalRequest() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementRequests(); - } - } - - @Override - public void logJsEvalResponse() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementResponses(); - } - } - - @Override - public void logJsEvalFailure() { - if (mainCtx.isStatisticsEnabled()) { - mainCtx.getJsInvokeStats().incrementFailures(); - } - } - @Override public String getServiceId() { return mainCtx.getServiceInfoProvider().getServiceId(); diff --git a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java b/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java deleted file mode 100644 index 3fc389b3d7..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/stats/DefaultJsInvokeStats.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.stats; - -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.thingsboard.server.actors.JsInvokeStats; -import org.thingsboard.server.common.stats.StatsCounter; -import org.thingsboard.server.common.stats.StatsFactory; -import org.thingsboard.server.common.stats.StatsType; - -@Service -public class DefaultJsInvokeStats implements JsInvokeStats { - private static final String REQUESTS = "requests"; - private static final String RESPONSES = "responses"; - private static final String FAILURES = "failures"; - - private StatsCounter requestsCounter; - private StatsCounter responsesCounter; - private StatsCounter failuresCounter; - - @Autowired - private StatsFactory statsFactory; - - @PostConstruct - public void init() { - String key = StatsType.JS_INVOKE.getName(); - this.requestsCounter = statsFactory.createStatsCounter(key, REQUESTS); - this.responsesCounter = statsFactory.createStatsCounter(key, RESPONSES); - this.failuresCounter = statsFactory.createStatsCounter(key, FAILURES); - } - - @Override - public void incrementRequests(int amount) { - requestsCounter.add(amount); - } - - @Override - public void incrementResponses(int amount) { - responsesCounter.add(amount); - } - - @Override - public void incrementFailures(int amount) { - failuresCounter.add(amount); - } - - @Override - public int getRequests() { - return requestsCounter.get(); - } - - @Override - public int getResponses() { - return responsesCounter.get(); - } - - @Override - public int getFailures() { - return failuresCounter.get(); - } - - @Override - public void reset() { - requestsCounter.clear(); - responsesCounter.clear(); - failuresCounter.clear(); - } -} diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ef6d2204d8..fcd61599d3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -500,8 +500,6 @@ actors: statistics: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" - # Frequency of printing the JS executor statistics - js_print_interval_ms: "${ACTORS_JS_STATISTICS_PRINT_INTERVAL_MS:10000}" # Actors statistic persistence frequency in milliseconds persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:3600000}" diff --git a/common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java b/common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java deleted file mode 100644 index 5a175ed761..0000000000 --- a/common/actor/src/main/java/org/thingsboard/server/actors/JsInvokeStats.java +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright © 2016-2024 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors; - -public interface JsInvokeStats { - default void incrementRequests() { - incrementRequests(1); - } - - void incrementRequests(int amount); - - default void incrementResponses() { - incrementResponses(1); - } - - void incrementResponses(int amount); - - default void incrementFailures() { - incrementFailures(1); - } - - void incrementFailures(int amount); - - int getRequests(); - - int getResponses(); - - int getFailures(); - - void reset(); -} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java index 342801f9a8..e778201ee3 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/AbstractScriptInvokeService.java @@ -20,10 +20,14 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.common.stats.StatsType; import java.util.Map; import java.util.UUID; @@ -32,22 +36,31 @@ import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicInteger; import static java.lang.String.format; @Slf4j public abstract class AbstractScriptInvokeService implements ScriptInvokeService { + private static final String REQUESTS = "requests"; + private static final String INVOKE_RESPONSES = "invoke_responses"; + private static final String EVAL_RESPONSES = "eval_responses"; + private static final String FAILURES = "failures"; + private static final String TIMEOUTS = "timeouts"; + protected final Map disabledScripts = new ConcurrentHashMap<>(); - private final AtomicInteger pushedMsgs = new AtomicInteger(0); - private final AtomicInteger invokeMsgs = new AtomicInteger(0); - private final AtomicInteger evalMsgs = new AtomicInteger(0); - protected final AtomicInteger failedMsgs = new AtomicInteger(0); - protected final AtomicInteger timeoutMsgs = new AtomicInteger(0); - private final FutureCallback evalCallback = new ScriptStatCallback<>(evalMsgs, timeoutMsgs, failedMsgs); - private final FutureCallback invokeCallback = new ScriptStatCallback<>(invokeMsgs, timeoutMsgs, failedMsgs); + private StatsCounter requestsCounter; + private StatsCounter invokeResponsesCounter; + private StatsCounter evalResponsesCounter; + private StatsCounter failuresCounter; + private StatsCounter timeoutsCounter; + + private FutureCallback evalCallback; + private FutureCallback invokeCallback; + + @Autowired + private StatsFactory statsFactory; protected ScheduledExecutorService timeoutExecutorService; @@ -76,6 +89,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService protected abstract boolean isScriptPresent(UUID scriptId); protected abstract boolean isExecEnabled(TenantId tenantId); + protected abstract void reportExecution(TenantId tenantId, CustomerId customerId); protected abstract ListenableFuture doEvalScript(TenantId tenantId, ScriptType scriptType, String scriptBody, UUID scriptId, String[] argNames); @@ -85,6 +99,14 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService protected abstract void doRelease(UUID scriptId) throws Exception; public void init() { + String key = getStatsType().getName(); + this.requestsCounter = statsFactory.createStatsCounter(key, REQUESTS); + this.invokeResponsesCounter = statsFactory.createStatsCounter(key, INVOKE_RESPONSES); + this.evalResponsesCounter = statsFactory.createStatsCounter(key, EVAL_RESPONSES); + this.failuresCounter = statsFactory.createStatsCounter(key, FAILURES); + this.timeoutsCounter = statsFactory.createStatsCounter(key, TIMEOUTS); + this.evalCallback = new ScriptStatCallback<>(evalResponsesCounter, timeoutsCounter, failuresCounter); + this.invokeCallback = new ScriptStatCallback<>(invokeResponsesCounter, timeoutsCounter, failuresCounter); if (getMaxEvalRequestsTimeout() > 0 || getMaxInvokeRequestsTimeout() > 0) { timeoutExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("script-timeout"); } @@ -98,11 +120,11 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService public void printStats() { if (isStatsEnabled()) { - int pushed = pushedMsgs.getAndSet(0); - int invoked = invokeMsgs.getAndSet(0); - int evaluated = evalMsgs.getAndSet(0); - int failed = failedMsgs.getAndSet(0); - int timedOut = timeoutMsgs.getAndSet(0); + int pushed = requestsCounter.getAndClear(); + int invoked = invokeResponsesCounter.getAndClear(); + int evaluated = evalResponsesCounter.getAndClear(); + int failed = failuresCounter.getAndClear(); + int timedOut = timeoutsCounter.getAndClear(); if (pushed > 0 || invoked > 0 || evaluated > 0 || failed > 0 || timedOut > 0) { log.info("{}: pushed [{}] received [{}] invoke [{}] eval [{}] failed [{}] timedOut [{}]", getStatsName(), pushed, invoked + evaluated, invoked, evaluated, failed, timedOut); @@ -117,7 +139,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return error(format("Script body exceeds maximum allowed size of %s symbols", getMaxScriptBodySize())); } UUID scriptId = UUID.randomUUID(); - pushedMsgs.incrementAndGet(); + requestsCounter.increment(); return withTimeoutAndStatsCallback(scriptId, null, doEvalScript(tenantId, scriptType, scriptBody, scriptId, argNames), evalCallback, getMaxEvalRequestsTimeout()); } else { @@ -139,7 +161,7 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService return Futures.immediateFailedFuture(handleScriptException(scriptId, null, t)); } reportExecution(tenantId, customerId); - pushedMsgs.incrementAndGet(); + requestsCounter.increment(); log.trace("[{}] InvokeScript uuid {} with timeout {}ms", tenantId, scriptId, getMaxInvokeRequestsTimeout()); var task = doInvokeFunction(scriptId, args); @@ -274,4 +296,6 @@ public abstract class AbstractScriptInvokeService implements ScriptInvokeService private ListenableFuture error(String message) { return Futures.immediateFailedFuture(new RuntimeException(message)); } + + protected abstract StatsType getStatsType(); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java index 0e5fb94a89..308a6858ec 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/ScriptStatCallback.java @@ -19,29 +19,29 @@ import com.google.common.util.concurrent.FutureCallback; import jakarta.annotation.Nullable; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.stats.StatsCounter; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicInteger; @Slf4j @AllArgsConstructor public class ScriptStatCallback implements FutureCallback { - private final AtomicInteger successMsgs; - private final AtomicInteger timeoutMsgs; - private final AtomicInteger failedMsgs; + private final StatsCounter successMsgs; + private final StatsCounter timeoutMsgs; + private final StatsCounter failedMsgs; @Override public void onSuccess(@Nullable T result) { - successMsgs.incrementAndGet(); + successMsgs.increment(); } @Override public void onFailure(Throwable t) { if (t instanceof TimeoutException || (t.getCause() != null && t.getCause() instanceof TimeoutException)) { - timeoutMsgs.incrementAndGet(); + timeoutMsgs.increment(); } else { - failedMsgs.incrementAndGet(); + failedMsgs.increment(); } } } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java index 6849f9d1fa..6ef699378e 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/js/AbstractJsInvokeService.java @@ -26,6 +26,7 @@ import org.thingsboard.script.api.ScriptType; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; @@ -117,4 +118,8 @@ public abstract class AbstractJsInvokeService extends AbstractScriptInvokeServic .hash().toString(); } + @Override + protected StatsType getStatsType() { + return StatsType.JS_INVOKE; + } } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java index 74d6a4e6f5..479f84591e 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java @@ -44,6 +44,7 @@ import org.thingsboard.script.api.TbScriptException; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.StatsType; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; @@ -255,4 +256,9 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem protected long getMaxEvalRequestsTimeout() { return maxInvokeRequestsTimeout * 2; } + + @Override + protected StatsType getStatsType() { + return StatsType.TBEL_INVOKE; + } } diff --git a/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java b/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java index 3334f2aabb..cd69ec04fe 100644 --- a/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java +++ b/common/stats/src/main/java/org/thingsboard/server/common/stats/DefaultCounter.java @@ -41,6 +41,10 @@ public class DefaultCounter { return aiCounter.get(); } + public int getAndClear() { + return aiCounter.getAndSet(0); + } + public void add(int delta){ aiCounter.addAndGet(delta); micrometerCounter.increment(delta); diff --git a/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java b/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java index 3833155c05..b58a4ec608 100644 --- a/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java +++ b/common/stats/src/main/java/org/thingsboard/server/common/stats/StatsType.java @@ -20,6 +20,7 @@ public enum StatsType { CORE("core"), TRANSPORT("transport"), JS_INVOKE("jsInvoke"), + TBEL_INVOKE("tbelInvoke"), RATE_EXECUTOR("rateExecutor"), HOUSEKEEPER("housekeeper"), EDGE("edge"); diff --git a/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json index e04a5c2d84..15ff06ab8c 100644 --- a/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json +++ b/docker/monitoring/grafana/provisioning/dashboards/core_and_js_metrics.json @@ -223,8 +223,8 @@ "fill": 1, "fillGradient": 0, "gridPos": { - "h": 12, - "w": 24, + "h": 10, + "w": 12, "x": 0, "y": 10 }, @@ -303,6 +303,100 @@ "align": false, "alignLevel": null } + }, + { + "aliasColors": {}, + "bars": false, + "dashLength": 10, + "dashes": false, + "datasource": null, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 10 + }, + "hiddenSeries": false, + "id": 19, + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true + }, + "percentage": false, + "pluginVersion": "7.5.4", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "exemplar": true, + "expr": "sum by(statsName) (increase(tbelInvoke_total[1m]))", + "interval": "", + "legendFormat": "{{statsName}}", + "refId": "A" + } + ], + "thresholds": [], + "timeFrom": null, + "timeRegions": [], + "timeShift": null, + "title": "TbelInvoke Stats", + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "buckets": null, + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ], + "yaxis": { + "align": false, + "alignLevel": null + } } ], "schemaVersion": 27, diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index b517bb0051..b78f52892a 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -373,12 +373,6 @@ public interface TbContext { ScriptEngine createScriptEngine(ScriptLanguage scriptLang, String script, String... argNames); - void logJsEvalRequest(); - - void logJsEvalResponse(); - - void logJsEvalFailure(); - String getServiceId(); EventLoopGroup getSharedEventLoop(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java index 622448f494..84091b3a52 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbClearAlarmNode.java @@ -70,10 +70,8 @@ public class TbClearAlarmNode extends TbAbstractAlarmNode clearAlarm(TbContext ctx, TbMsg msg, Alarm alarm) { - ctx.logJsEvalRequest(); ListenableFuture asyncDetails = buildAlarmDetails(msg, alarm.getDetails()); return Futures.transform(asyncDetails, details -> { - ctx.logJsEvalResponse(); AlarmApiCallResult result = ctx.getAlarmService().clearAlarm(ctx.getTenantId(), alarm.getId(), System.currentTimeMillis(), details); if (result.isSuccessful()) { return new TbAlarmResult(false, false, result.isCleared(), result.getAlarm()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index 7e535596cd..4e821163c7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -119,15 +119,11 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode asyncDetails; boolean buildDetails = !config.isUseMessageAlarmData() || config.isOverwriteAlarmDetails(); if (buildDetails) { - ctx.logJsEvalRequest(); asyncDetails = buildAlarmDetails(msg, null); } else { asyncDetails = Futures.immediateFuture(null); } ListenableFuture asyncAlarm = Futures.transform(asyncDetails, details -> { - if (buildDetails) { - ctx.logJsEvalResponse(); - } Alarm newAlarm; if (msgAlarm != null) { newAlarm = msgAlarm; @@ -148,15 +144,11 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode asyncDetails; boolean buildDetails = !config.isUseMessageAlarmData() || config.isOverwriteAlarmDetails(); if (buildDetails) { - ctx.logJsEvalRequest(); asyncDetails = buildAlarmDetails(msg, existingAlarm.getDetails()); } else { asyncDetails = Futures.immediateFuture(null); } ListenableFuture asyncUpdated = Futures.transform(asyncDetails, details -> { - if (buildDetails) { - ctx.logJsEvalResponse(); - } if (msgAlarm != null) { existingAlarm.setSeverity(msgAlarm.getSeverity()); existingAlarm.setPropagate(msgAlarm.isPropagate()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java index 6b07d41a42..8c21018762 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbLogNode.java @@ -76,18 +76,15 @@ public class TbLogNode implements TbNode { return; } - ctx.logJsEvalRequest(); Futures.addCallback(scriptEngine.executeToStringAsync(msg), new FutureCallback() { @Override public void onSuccess(@Nullable String result) { - ctx.logJsEvalResponse(); log.info(result); ctx.tellSuccess(msg); } @Override public void onFailure(Throwable t) { - ctx.logJsEvalResponse(); ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually js responses runs on js callback executor diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java index c7bf1423f8..f303653093 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNode.java @@ -161,10 +161,8 @@ public class TbMsgGeneratorNode implements TbNode { prevMsg = ctx.newMsg(queueName, TbMsg.EMPTY_STRING, originatorId, msg.getCustomerId(), TbMsgMetaData.EMPTY, TbMsg.EMPTY_JSON_OBJECT); } if (initialized.get()) { - ctx.logJsEvalRequest(); return Futures.transformAsync(scriptEngine.executeGenerateAsync(prevMsg), generated -> { log.trace("generate process response, generated {}, config {}", generated, config); - ctx.logJsEvalResponse(); prevMsg = ctx.newMsg(queueName, generated.getType(), originatorId, msg.getCustomerId(), generated.getMetaData(), generated.getData()); return Futures.immediateFuture(prevMsg); }, MoreExecutors.directExecutor()); //usually it runs on js-executor-remote-callback thread pool diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java index 6d5aaa6477..8f8c68aa62 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsFilterNode.java @@ -61,15 +61,12 @@ public class TbJsFilterNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); withCallback(scriptEngine.executeFilterAsync(msg), filterResult -> { - ctx.logJsEvalResponse(); ctx.tellNext(msg, filterResult ? TbNodeConnectionType.TRUE : TbNodeConnectionType.FALSE); }, t -> { ctx.tellFailure(msg, t); - ctx.logJsEvalFailure(); }, ctx.getDbCallbackExecutor()); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java index e866f05bd0..a3c3df642b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbJsSwitchNode.java @@ -62,17 +62,14 @@ public class TbJsSwitchNode implements TbNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); Futures.addCallback(scriptEngine.executeSwitchAsync(msg), new FutureCallback<>() { @Override public void onSuccess(@Nullable Set result) { - ctx.logJsEvalResponse(); processSwitch(ctx, msg, result); } @Override public void onFailure(Throwable t) { - ctx.logJsEvalFailure(); ctx.tellFailure(msg, t); } }, MoreExecutors.directExecutor()); //usually runs in a callbackExecutor diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java index 3227c0c52c..61444fae93 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/transform/TbTransformMsgNode.java @@ -58,13 +58,11 @@ public class TbTransformMsgNode extends TbAbstractTransformNode> transform(TbContext ctx, TbMsg msg) { - ctx.logJsEvalRequest(); return scriptEngine.executeUpdateAsync(msg); } @Override protected void transformFailure(TbContext ctx, TbMsg msg, Throwable t) { - ctx.logJsEvalFailure(); super.transformFailure(ctx, msg, t); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java index c0e27231c4..d1bf5a0840 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java @@ -119,8 +119,8 @@ class TbCreateAlarmNodeTest { delete metadata.prevAlarmDetails; //now metadata is the same as it comes IN this rule node } - - + + return details;"""); assertThat(config.getAlarmDetailsBuildTbel()).isEqualTo(""" \ @@ -131,8 +131,8 @@ class TbCreateAlarmNodeTest { metadata.remove('prevAlarmDetails'); //now metadata is the same as it comes IN this rule node } - - + + return details;"""); assertThat(config.getSeverity()).isEqualTo(AlarmSeverity.CRITICAL.name()); assertThat(config.isPropagate()).isFalse(); @@ -242,9 +242,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); - then(ctxMock).should().logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -411,9 +409,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); then(alarmDetailsScriptMock).should().executeJsonAsync(incomingMsg); - then(ctxMock).should().logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -601,14 +597,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); @@ -773,9 +767,7 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script was not evaluated - then(ctxMock).should(never()).logJsEvalRequest(); then(alarmDetailsScriptMock).should(never()).executeJsonAsync(any()); - then(ctxMock).should(never()).logJsEvalResponse(); // verify we called createAlarm() with correct AlarmCreateOrUpdateActiveRequest then(alarmServiceMock).should().createAlarm(expectedCreateAlarmRequest); @@ -960,14 +952,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(oldAlarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); @@ -1141,14 +1131,12 @@ class TbCreateAlarmNodeTest { // THEN // verify alarm details script evaluation - then(ctxMock).should().logJsEvalRequest(); var dummyMsgCaptor = ArgumentCaptor.forClass(TbMsg.class); then(alarmDetailsScriptMock).should().executeJsonAsync(dummyMsgCaptor.capture()); TbMsg actualDummyMsg = dummyMsgCaptor.getValue(); assertThat(actualDummyMsg.getType()).isEqualTo(incomingMsg.getType()); assertThat(actualDummyMsg.getData()).isEqualTo(incomingMsg.getData()); assertThat(actualDummyMsg.getMetaData().getData()).containsEntry("prevAlarmDetails", JacksonUtil.toString(alarmDetails)); - then(ctxMock).should().logJsEvalResponse(); // verify we called updateAlarm() with correct AlarmUpdateRequest then(alarmServiceMock).should().updateAlarm(expectedUpdateAlarmRequest); From 2eda229b21a38fe14132aee1ee5d9354b5f9173c Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Tue, 10 Dec 2024 13:21:22 +0100 Subject: [PATCH 002/127] fixed tests --- .../server/service/script/RemoteJsInvokeServiceTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java index a0951723e2..fb0161885d 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RemoteJsInvokeServiceTest.java @@ -21,9 +21,13 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.script.api.ScriptType; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.stats.DefaultStatsFactory; +import org.thingsboard.server.common.stats.StatsCounter; +import org.thingsboard.server.common.stats.StatsFactory; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageStateClient; import org.thingsboard.server.gen.js.JsInvokeProtos; @@ -68,6 +72,10 @@ class RemoteJsInvokeServiceTest { remoteJsInvokeService = new RemoteJsInvokeService(Optional.of(apiUsageStateClient), Optional.of(apiUsageReportClient)); jsRequestTemplate = mock(TbQueueRequestTemplate.class); remoteJsInvokeService.requestTemplate = jsRequestTemplate; + StatsFactory statsFactory = mock(StatsFactory.class); + when(statsFactory.createStatsCounter(any(), any())).thenReturn(mock(StatsCounter.class)); + ReflectionTestUtils.setField(remoteJsInvokeService, "statsFactory",statsFactory); + remoteJsInvokeService.init(); } @AfterEach From 7f4a0ef48f3f3a7ab4070b76c43f1dbfcb069f20 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 27 Dec 2024 15:26:19 +0200 Subject: [PATCH 003/127] UI: New maps V3 --- ui-ngx/angular.json | 3 +- .../lib/{maps => maps-legacy}/circle.ts | 8 +- .../common-maps-utils.ts | 0 .../select-entity-dialog.component.html | 0 .../select-entity-dialog.component.scss | 0 .../dialogs/select-entity-dialog.component.ts | 0 .../lib/{maps => maps-legacy}/leaflet-map.ts | 6 +- .../lib/{maps => maps-legacy}/map-models.ts | 0 .../map-widget.interface.ts | 2 +- .../lib/{maps => maps-legacy}/map-widget2.ts | 2 +- .../lib/{maps => maps-legacy}/maps-utils.ts | 2 +- .../lib/{maps => maps-legacy}/markers.scss | 0 .../lib/{maps => maps-legacy}/markers.ts | 0 .../lib/{maps => maps-legacy}/polygon.ts | 2 +- .../lib/{maps => maps-legacy}/polyline.ts | 2 +- .../providers/google-map.ts | 0 .../providers/here-map.ts | 0 .../providers/image-map.ts | 2 +- .../providers/openstreet-map.ts | 0 .../providers/public-api.ts | 2 +- .../providers/tencent-map.ts | 0 .../widget/lib/maps/leaflet/leaflet-tb.ts | 0 .../components/widget/lib/maps/map-layer.ts | 90 +++++ .../widget/lib/maps/map-widget.component.html | 25 ++ .../widget/lib/maps/map-widget.component.scss | 38 +++ .../widget/lib/maps/map-widget.component.ts | 97 ++++++ .../widget/lib/maps/map-widget.models.ts | 38 +++ .../components/widget/lib/maps/map.models.ts | 310 ++++++++++++++++++ .../home/components/widget/lib/maps/map.scss | 19 ++ .../home/components/widget/lib/maps/map.ts | 196 +++++++++++ .../settings/map/circle-settings.component.ts | 2 +- .../map/common-map-settings.component.ts | 2 +- .../google-map-provider-settings.component.ts | 2 +- .../here-map-provider-settings.component.ts | 2 +- .../image-map-provider-settings.component.ts | 2 +- .../map/map-editor-settings.component.ts | 2 +- .../map/map-provider-settings.component.ts | 2 +- .../settings/map/map-settings.component.ts | 2 +- .../map/map-widget-settings.component.ts | 2 +- .../marker-clustering-settings.component.ts | 2 +- .../map/markers-settings.component.ts | 2 +- ...nstreet-map-provider-settings.component.ts | 2 +- .../map/polygon-settings.component.ts | 2 +- .../map/route-map-settings.component.ts | 2 +- .../route-map-widget-settings.component.ts | 2 +- ...tencent-map-provider-settings.component.ts | 2 +- ...rip-animation-common-settings.component.ts | 2 +- ...rip-animation-marker-settings.component.ts | 2 +- .../trip-animation-path-settings.component.ts | 2 +- ...trip-animation-point-settings.component.ts | 2 +- ...rip-animation-widget-settings.component.ts | 2 +- .../trip-animation.component.ts | 10 +- .../widget/widget-component.service.ts | 2 +- .../widget/widget-components.module.ts | 9 +- .../history-selector.component.ts | 2 +- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 3 + 56 files changed, 866 insertions(+), 46 deletions(-) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/circle.ts (96%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/common-maps-utils.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/dialogs/select-entity-dialog.component.html (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/dialogs/select-entity-dialog.component.scss (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/dialogs/select-entity-dialog.component.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/leaflet-map.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/map-models.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/map-widget.interface.ts (91%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/map-widget2.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/maps-utils.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/markers.scss (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/markers.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/polygon.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/polyline.ts (98%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/google-map.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/here-map.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/image-map.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/openstreet-map.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/public-api.ts (93%) rename ui-ngx/src/app/modules/home/components/widget/lib/{maps => maps-legacy}/providers/tencent-map.ts (100%) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index f605bfee8a..2803459f10 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -101,7 +101,8 @@ "node_modules/tooltipster/dist/css/plugins/tooltipster/sideTip/themes/tooltipster-sideTip-shadow.min.css", "node_modules/jstree-bootstrap-theme/dist/themes/proton/style.min.css", "node_modules/leaflet/dist/leaflet.css", - "src/app/modules/home/components/widget/lib/maps/markers.scss", + "src/app/modules/home/components/widget/lib/maps/map.scss", + "src/app/modules/home/components/widget/lib/maps-legacy/markers.scss", "src/app/modules/home/components/widget/lib/home-page/home-page.scss", "node_modules/leaflet.markercluster/dist/MarkerCluster.css", "node_modules/leaflet.markercluster/dist/MarkerCluster.Default.css", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts index 680f3faca6..67e8325f61 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/circle.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/circle.ts @@ -15,10 +15,10 @@ /// import L, { LeafletMouseEvent } from 'leaflet'; -import { CircleData, WidgetCircleSettings } from '@home/components/widget/lib/maps/map-models'; -import { functionValueCalculator, parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; -import { createTooltip } from '@home/components/widget/lib/maps/maps-utils'; +import { CircleData, WidgetCircleSettings } from '@home/components/widget/lib/maps-legacy/map-models'; +import { functionValueCalculator, parseWithTranslation } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; +import { createTooltip } from '@home/components/widget/lib/maps-legacy/maps-utils'; import { FormattedData } from '@shared/models/widget.models'; import { fillDataPattern, processDataPattern, safeExecuteTbFunction } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/common-maps-utils.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/dialogs/select-entity-dialog.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index ee5af0bfb5..2a390a39fc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -43,8 +43,8 @@ import { isJSON, isValidLatLng, LabelSettings -} from '@home/components/widget/lib/maps/maps-utils'; -import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps/common-maps-utils'; +} from '@home/components/widget/lib/maps-legacy/maps-utils'; +import { checkLngLat, createLoadingDiv } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { deepClone, @@ -60,7 +60,7 @@ import { TranslateService } from '@ngx-translate/core'; import { SelectEntityDialogComponent, SelectEntityDialogData -} from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component'; +} from '@home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component'; import { MatDialog } from '@angular/material/dialog'; import { FormattedData, ReplaceInfo } from '@shared/models/widget.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts similarity index 91% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts index 761584c98f..05e1f2b7c0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.interface.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget.interface.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export interface MapWidgetInterface { map?: LeafletMap; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts index 4ca5d4302a..950329d7a5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget2.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/map-widget2.ts @@ -23,7 +23,7 @@ import { Datasource, DatasourceData, FormattedData, WidgetActionDescriptor } fro import { TranslateService } from '@ngx-translate/core'; import { UtilsService } from '@core/services/utils.service'; import { EntityDataPageLink } from '@shared/models/query/query.models'; -import { providerClass } from '@home/components/widget/lib/maps/providers/public-api'; +import { providerClass } from '@home/components/widget/lib/maps-legacy/providers/public-api'; import { isDefined, isDefinedAndNotNull, parseTbFunction } from '@core/utils'; import L from 'leaflet'; import { firstValueFrom, forkJoin, from, Observable, of } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts index d47ed8f110..97132628cd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/maps-utils.ts @@ -18,7 +18,7 @@ import L from 'leaflet'; import { GenericFunction, ShowTooltipAction, WidgetToolipSettings } from './map-models'; import { Datasource, FormattedData } from '@app/shared/models/widget.models'; import { fillDataPattern, isDefinedAndNotNull, isString, processDataPattern, safeExecuteTbFunction } from '@core/utils'; -import { parseWithTranslation } from '@home/components/widget/lib/maps/common-maps-utils'; +import { parseWithTranslation } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { CompiledTbFunction } from '@shared/models/js-function.models'; export function createTooltip(target: L.Layer, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/markers.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts index cb9ba7c35f..1b51a1989e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polygon.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polygon.ts @@ -20,7 +20,7 @@ import { functionValueCalculator, parseWithTranslation } from './common-maps-uti import { WidgetPolygonSettings } from './map-models'; import { FormattedData } from '@shared/models/widget.models'; import { fillDataPattern, processDataPattern, safeExecuteTbFunction } from '@core/utils'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export class Polygon { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts index 347782d210..fc842b6f3d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/polyline.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/polyline.ts @@ -19,7 +19,7 @@ import L, { PolylineDecorator, PolylineDecoratorOptions, Symbol } from 'leaflet' import 'leaflet-polylinedecorator'; import { WidgetPolylineSettings } from './map-models'; -import { functionValueCalculator } from '@home/components/widget/lib/maps/common-maps-utils'; +import { functionValueCalculator } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { FormattedData } from '@shared/models/widget.models'; export class Polyline { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/google-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/google-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/here-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/here-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts index d91a84d3b2..7d3dfdf3f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts @@ -25,7 +25,7 @@ import { } from '../map-models'; import { combineLatest, Observable, of, ReplaySubject, switchMap } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { calculateNewPointCoordinate, loadImageWithAspect } from '@home/components/widget/lib/maps/common-maps-utils'; +import { calculateNewPointCoordinate, loadImageWithAspect } from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { WidgetContext } from '@home/models/widget-component.models'; import { DataSet, DatasourceType, FormattedData, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/openstreet-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/openstreet-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts similarity index 93% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts index 4915002d43..80938e5839 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/public-api.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/public-api.ts @@ -20,7 +20,7 @@ import { GoogleMap } from './google-map'; import { HEREMap } from './here-map'; import { ImageMap } from './image-map'; import { Type } from '@angular/core'; -import LeafletMap from '@home/components/widget/lib/maps/leaflet-map'; +import LeafletMap from '@home/components/widget/lib/maps-legacy/leaflet-map'; export const providerClass: { [key: string]: Type } = { 'openstreet-map': OpenStreetMap, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/providers/tencent-map.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/tencent-map.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts new file mode 100644 index 0000000000..12952bf0d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -0,0 +1,90 @@ +import { + defaultOpenStreetMapLayerSettings, + MapLayerSettings, + MapProvider, OpenStreetLayerType, + OpenStreetMapLayerSettings, openStreetMapLayerTranslationMap +} from '@home/components/widget/lib/maps/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { mergeDeep } from '@core/utils'; +import { Observable, of } from 'rxjs'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import L from 'leaflet'; +import { map } from 'rxjs/operators'; + +export abstract class TbMapLayer { + + static fromSettings(ctx: WidgetContext, + inputSettings: DeepPartial) { + + switch (inputSettings.provider) { + case MapProvider.google: + break; + case MapProvider.openstreet: + return new TbOpenStreetMapLayer(ctx, inputSettings); + case MapProvider.tencent: + break; + case MapProvider.here: + break; + case MapProvider.custom: + break; + } + } + + protected settings: S; + + protected constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial) { + this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S); + } + + protected abstract defaultSettings(): S; + + protected title(): string { + const customTranslate = this.ctx.$injector.get(CustomTranslatePipe); + if (this.settings.label) { + return customTranslate.transform(this.settings.label); + } else { + return this.generateTitle(); + } + } + + protected abstract generateTitle(): string; + + protected abstract createLayer(): Observable; + + public loadLayer(): Observable<{title: string, layer: L.Layer}> { + return this.createLayer().pipe( + map((layer) => { + return { + title: this.title(), + layer + }; + }) + ); + } + +} + +class TbOpenStreetMapLayer extends TbMapLayer { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial) { + super(ctx, inputSettings); + } + + protected defaultSettings(): OpenStreetMapLayerSettings { + return defaultOpenStreetMapLayerSettings; + } + + protected generateTitle(): string { + const layerType = OpenStreetLayerType[this.settings.layerType]; + return this.ctx.translate.instant(openStreetMapLayerTranslationMap.get(layerType)); + } + + protected createLayer(): Observable { + const layer = L.tileLayer.provider(OpenStreetLayerType[this.settings.layerType]); + return of(layer); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html new file mode 100644 index 0000000000..09c44e1841 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.html @@ -0,0 +1,25 @@ + +
+
+ + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss new file mode 100644 index 0000000000..a47b5eb578 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.scss @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-map-panel { + width: 100%; + height: 100%; + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 24px 24px 24px; + > div:not(.tb-map-overlay) { + z-index: 1; + } + .tb-map-overlay { + position: absolute; + top: 12px; + left: 12px; + bottom: 12px; + right: 12px; + } + div.tb-widget-title { + padding: 0; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts new file mode 100644 index 0000000000..58d4d4fcc0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -0,0 +1,97 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnDestroy, + OnInit, + Renderer2, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Observable } from 'rxjs'; +import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { WidgetComponent } from '@home/components/widget/widget.component'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; + +@Component({ + selector: 'tb-map-widget', + templateUrl: './map-widget.component.html', + styleUrls: ['./map-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapWidgetComponent implements OnInit, OnDestroy, AfterViewInit { + + @ViewChild('mapElement', {static: false}) + mapElement: ElementRef; + + settings: MapWidgetSettings; + + @Input() + ctx: WidgetContext; + + @Input() + widgetTitlePanel: TemplateRef; + + backgroundStyle$: Observable; + overlayStyle: ComponentStyle = {}; + padding: string; + + private map: TbMap; + + constructor(public widgetComponent: WidgetComponent, + private imagePipe: ImagePipe, + private sanitizer: DomSanitizer, + private renderer: Renderer2, + private cd: ChangeDetectorRef) { + } + + ngOnInit(): void { + this.ctx.$scope.mapWidget = this; + this.settings = {...mapWidgetDefaultSettings, ...this.ctx.settings}; + + this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); + this.overlayStyle = overlayStyle(this.settings.background.overlay); + this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; + } + + ngAfterViewInit() { + this.map = TbMap.fromSettings(this.ctx, this.settings, this.mapElement.nativeElement); + } + + ngOnDestroy() { + if (this.map) { + this.map.destroy(); + } + } + + public onInit() { + const borderRadius = this.ctx.$widgetElement.css('borderRadius'); + this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; + this.cd.detectChanges(); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts new file mode 100644 index 0000000000..1d7f5a86e3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -0,0 +1,38 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; +import { mergeDeep } from '@core/utils'; + +export interface MapWidgetSettings extends MapSetting { + background: BackgroundSettings; + padding: string; +} + +export const mapWidgetDefaultSettings: MapWidgetSettings = + mergeDeep({} as MapWidgetSettings, defaultMapSettings as MapWidgetSettings, { + background: { + type: BackgroundType.color, + color: '#fff', + overlay: { + enabled: false, + color: 'rgba(255,255,255,0.72)', + blur: 3 + } + }, + padding: '12px' +} as MapWidgetSettings); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts new file mode 100644 index 0000000000..d618240d7a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -0,0 +1,310 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { DataKey, DatasourceType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { mergeDeep } from '@core/utils'; + +export enum MapType { + geoMap = 'geoMap', + image = 'image' +} + +export interface MapDataSourceSettings { + dsType: DatasourceType; + dsEntityType?: EntityType; + dsEntityId?: string; + dsEntityAliasId?: string; + dsFilterId?: string; +} + +export interface MapDataLayerSettings extends MapDataSourceSettings { + additionalDataKeys?: DataKey[]; + group?: string; +} + +export interface MarkersDataLayerSettings extends MapDataLayerSettings { + xKey: DataKey; + yKey: DataKey; +} + +export const defaultMarkersDataLayerSettings = (mapType: MapType): MarkersDataLayerSettings => ({ + dsType: DatasourceType.entity, + xKey: { + name: MapType.geoMap === mapType ? 'latitude' : 'xPos', + label: MapType.geoMap === mapType ? 'latitude' : 'xPos', + type: DataKeyType.attribute + }, + yKey: { + name: MapType.geoMap === mapType ? 'longitude' : 'yPos', + label: MapType.geoMap === mapType ? 'longitude' : 'yPos', + type: DataKeyType.attribute + } +}); + +export interface PolygonsDataLayerSettings extends MapDataLayerSettings { + polygonKey: DataKey; +} + +export const defaultPolygonsDataLayerSettings: PolygonsDataLayerSettings = { + dsType: DatasourceType.entity, + polygonKey: { + name: 'perimeter', + label: 'perimeter', + type: DataKeyType.attribute + } +}; + +export interface CirclesDataLayerSettings extends MapDataLayerSettings { + circleKey: DataKey; +} + +export const defaultCirclesDataLayerSettings: CirclesDataLayerSettings = { + dsType: DatasourceType.entity, + circleKey: { + name: 'perimeter', + label: 'perimeter', + type: DataKeyType.attribute + } +}; + +export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { + dataKeys: DataKey[]; +} + +export enum MapControlsPosition { + topleft = 'topleft', + topright = 'topright', + bottomleft = 'bottomleft', + bottomright = 'bottomright' +} + +export enum MapZoomAction { + scroll = 'scroll', + doubleClick = 'doubleClick', + controlButtons = 'controlButtons' +} + +export interface BaseMapSettings { + mapType: MapType; + markers: MarkersDataLayerSettings[]; + polygons: PolygonsDataLayerSettings[]; + circles: CirclesDataLayerSettings[]; + additionalDataSources: AdditionalMapDataSourceSettings[]; + controlsPosition: MapControlsPosition; + zoomActions: MapZoomAction[]; + fitMapBounds: boolean; + useDefaultCenterPosition: boolean; + defaultCenterPosition?: string; + defaultZoomLevel: number; + mapPageSize: number; +} + +export const DEFAULT_MAP_PAGE_SIZE = 16384; +export const DEFAULT_ZOOM_LEVEL = 8; + +export const defaultBaseMapSettings: BaseMapSettings = { + mapType: MapType.geoMap, + markers: [], + polygons: [], + circles: [], + additionalDataSources: [], + controlsPosition: MapControlsPosition.topleft, + zoomActions: [MapZoomAction.scroll, MapZoomAction.doubleClick, MapZoomAction.controlButtons], + fitMapBounds: true, + useDefaultCenterPosition: false, + defaultCenterPosition: '0,0', + defaultZoomLevel: null, + mapPageSize: DEFAULT_MAP_PAGE_SIZE +}; + +export enum MapProvider { + google = 'google-map', + openstreet = 'openstreet-map', + here = 'here', + tencent = 'tencent-map', + custom = 'custom' +} + +export interface MapLayerSettings { + label?: string; + provider: MapProvider; +} + +export enum GoogleLayerType { + roadmap = 'roadmap', + satellite = 'satellite', + hybrid = 'hybrid', + terrain = 'terrain' +} + +export interface GoogleMapLayerSettings extends MapLayerSettings { + provider: MapProvider.google; + layerType: GoogleLayerType; + apiKey: string; +} + +export const defaultGoogleMapLayerSettings: GoogleMapLayerSettings = { + provider: MapProvider.google, + layerType: GoogleLayerType.roadmap, + apiKey: 'AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q' +}; + +export enum OpenStreetLayerType { + openStreetMapnik = 'OpenStreetMap.Mapnik', + openStreetHot = 'OpenStreetMap.HOT', + esriWorldStreetMap = 'Esri.WorldStreetMap', + esriWorldTopoMap = 'Esri.WorldTopoMap', + esriWorldImagery = 'Esri.WorldImagery', + cartoDbPositron = 'CartoDB.Positron', + cartoDbDarkMatter = 'CartoDB.DarkMatter' +} + +export const openStreetMapLayerTranslationMap = new Map( + [ + [OpenStreetLayerType.openStreetMapnik, 'widgets.maps.openstreet-provider-mapnik'], + [OpenStreetLayerType.openStreetHot, 'widgets.maps.openstreet-provider-hot'], + [OpenStreetLayerType.esriWorldStreetMap, 'widgets.maps.openstreet-provider-esri-street'], + [OpenStreetLayerType.esriWorldTopoMap, 'widgets.maps.openstreet-provider-esri-topo'], + [OpenStreetLayerType.esriWorldImagery, 'widgets.maps.openstreet-provider-esri-imagery'], + [OpenStreetLayerType.cartoDbPositron, 'widgets.maps.openstreet-provider-cartodb-positron'], + [OpenStreetLayerType.cartoDbDarkMatter, 'widgets.maps.openstreet-provider-cartodb-dark-matter'] + ] +); + +export interface OpenStreetMapLayerSettings extends MapLayerSettings { + provider: MapProvider.openstreet; + layerType: OpenStreetLayerType; +} + +export const defaultOpenStreetMapLayerSettings: OpenStreetMapLayerSettings = { + provider: MapProvider.openstreet, + layerType: OpenStreetLayerType.openStreetMapnik +} + +export enum HereLayerType { + hereNormalDay = 'HERE.normalDay', + hereNormalNight = 'HERE.normalNight', + hereHybridDay = 'HERE.hybridDay', + hereTerrainDay = 'HERE.terrainDay' +} + +export interface HereMapLayerSettings extends MapLayerSettings { + provider: MapProvider.here; + layerType: HereLayerType; + apiKey: string; +} + +export const defaultHereMapLayerSettings: HereMapLayerSettings = { + provider: MapProvider.here, + layerType: HereLayerType.hereNormalDay, + apiKey: 'kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE' +} + +export enum TencentLayerType { + roadmap = 'roadmap', + satellite = 'satellite', + hybrid = 'hybrid' +} + +export interface TencentMapLayerSettings extends MapLayerSettings { + provider: MapProvider.tencent; + layerType: TencentLayerType + apiKey: string; +} + +export const defaultTencentMapLayerSettings: TencentMapLayerSettings = { + provider: MapProvider.tencent, + layerType: TencentLayerType.roadmap, + apiKey: '84d6d83e0e51e481e50454ccbe8986b' +} + +export interface CustomMapLayerSettings extends MapLayerSettings { + provider: MapProvider.custom; + tileUrl: string; +} + +export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { + provider: MapProvider.custom, + tileUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' +} + +export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings => { + switch (provider) { + case MapProvider.google: + return defaultGoogleMapLayerSettings; + case MapProvider.openstreet: + return defaultOpenStreetMapLayerSettings; + case MapProvider.here: + return defaultHereMapLayerSettings; + case MapProvider.tencent: + return defaultTencentMapLayerSettings; + case MapProvider.custom: + return defaultCustomMapLayerSettings; + } +}; + +export const defaultMapLayers: MapLayerSettings[] = (Object.keys(OpenStreetLayerType) as OpenStreetLayerType[]).map(type => ({ + provider: MapProvider.openstreet, + layerType: type +})); + +export interface GeoMapSettings extends BaseMapSettings { + layers?: MapLayerSettings[]; +} + +export const defaultGeoMapSettings: GeoMapSettings = { + mapType: MapType.geoMap, + layers: mergeDeep([], defaultMapLayers), + ...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) +}; + +export enum ImageSourceType { + image = 'image', + attribute = 'attribute' +} + +export interface ImageMapSettings extends BaseMapSettings { + imageSourceType?: ImageSourceType; + imageUrl?: string; + imageEntityAlias?: string; + imageUrlAttribute?: string; +} + +export const defaultImageMapSettings: ImageMapSettings = { + mapType: MapType.image, + imageSourceType: ImageSourceType.image, + imageUrl: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==', + ...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) +} + +export type MapSetting = GeoMapSettings & ImageMapSettings; + +export const defaultMapSettings: MapSetting = defaultGeoMapSettings; + +export function parseCenterPosition(position: string | [number, number]): [number, number] { + if (typeof (position) === 'string') { + const parts = position.split(','); + if (parts.length === 2) { + return [Number(parts[0]), Number(parts[1])]; + } + } + if (typeof (position) === 'object') { + return position; + } + return [0, 0]; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss new file mode 100644 index 0000000000..c01ca9e3fc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -0,0 +1,19 @@ +.tb-map-layout { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + flex: 1; + &.tb-sidebar-left { + flex-direction: row-reverse; + } + &.tb-sidebar-right { + flex-direction: row; + } + .tb-map { + flex: 1; + } + .tb-map-sidebar { + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts new file mode 100644 index 0000000000..f4fd0baaac --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -0,0 +1,196 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + BaseMapSettings, + DEFAULT_ZOOM_LEVEL, + defaultGeoMapSettings, + defaultImageMapSettings, + GeoMapSettings, + ImageMapSettings, + MapSetting, + MapType, + MapZoomAction, + parseCenterPosition +} from '@home/components/widget/lib/maps/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { mergeDeep } from '@core/utils'; +import { DeepPartial } from '@shared/models/common'; +import L from 'leaflet'; +import { forkJoin, Observable, of } from 'rxjs'; +import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; +import { map } from 'rxjs/operators'; + +export abstract class TbMap { + + static fromSettings(ctx: WidgetContext, + inputSettings: DeepPartial, + mapElement: HTMLElement): TbMap { + switch (inputSettings.mapType) { + case MapType.geoMap: + return new TbGeoMap(ctx, inputSettings, mapElement); + case MapType.image: + return new TbImageMap(ctx, inputSettings, mapElement); + } + } + + protected settings: S; + protected map: L.Map; + + protected defaultCenterPosition: [number, number]; + protected bounds: L.LatLngBounds; + protected layerControl: L.Control.Layers; + + protected mapElement: HTMLElement; + protected sidebarElement: HTMLElement; + + protected constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S); + $(containerElement).empty(); + $(containerElement).addClass('tb-map-layout'); + if (this.settings.controlsPosition.endsWith('left')) { + $(containerElement).addClass('tb-sidebar-left'); + } else { + $(containerElement).addClass('tb-sidebar-right'); + } + const mapElement = $('
'); + const sidebarElement = $('
'); + $(containerElement).append(mapElement); + $(containerElement).append(sidebarElement); + + this.mapElement = mapElement[0]; + this.sidebarElement = sidebarElement[0]; + + this.defaultCenterPosition = parseCenterPosition(this.settings.defaultCenterPosition); + + this.layerControl = L.control.layers({}, {}, {position: this.settings.controlsPosition, collapsed: true}); + this.createMap().subscribe((map) => { + this.map = map; + this.initMap(); + }); + L.TB = { + sidebar: (s) => { return null;} + }; + } + + private initMap() { + this.map.zoomControl.setPosition(this.settings.controlsPosition); + this.layerControl.addTo(this.map); + this.map.on('move', () => { + this.ctx.updatePopoverPositions(); + }); + this.map.on('zoomstart', () => { + this.ctx.setPopoversHidden(true); + }); + this.map.on('zoomend', () => { + this.ctx.setPopoversHidden(false); + this.ctx.updatePopoverPositions(); + setTimeout(() => { + this.ctx.updatePopoverPositions(); + }); + }); + if (this.settings.useDefaultCenterPosition) { + this.map.panTo(this.defaultCenterPosition); + this.bounds = this.map.getBounds(); + } else { + this.bounds = new L.LatLngBounds(null, null); + } + } + + protected abstract defaultSettings(): S; + + protected abstract createMap(): Observable; + + public destroy() { + if (this.map) { + this.map.remove(); + } + } + +} + +class TbGeoMap extends TbMap { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + super(ctx, inputSettings, containerElement); + } + + protected defaultSettings(): GeoMapSettings { + return defaultGeoMapSettings; + } + + protected createMap(): Observable { + const theMap = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons) + }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); + return this.loadLayers().pipe( + map((layers) => { + if (layers.length) { + const layer = layers[0]; + layer.layer.addTo(theMap); + if (layers.length > 1) { + layers.forEach(l => { + this.layerControl.addBaseLayer(l.layer, l.title); + }); + } + } + return theMap; + }) + ); + } + + private loadLayers(): Observable<{title: string, layer: L.Layer}[]> { + const layers = this.settings.layers.map(settings => TbMapLayer.fromSettings(this.ctx, settings)); + return forkJoin(layers.map(layer => layer.loadLayer())); + } + + +} + +class TbImageMap extends TbMap { + + private maxZoom = 4; + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected mapElement: HTMLElement) { + super(ctx, inputSettings, mapElement); + } + + protected defaultSettings(): ImageMapSettings { + return defaultImageMapSettings; + } + + protected createMap(): Observable { + const map = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + minZoom: 1, + maxZoom: this.maxZoom, + zoom: 1, + crs: L.CRS.Simple, + attributionControl: false + }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); + return of(map); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts index 704f2b3b5b..5708293748 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/circle-settings.component.ts @@ -33,7 +33,7 @@ import { CircleSettings, ShowTooltipAction, showTooltipActionTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts index 09df84593d..2e9fa0dc9f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/common-map-settings.component.ts @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { CommonMapSettings, MapProviders } from '@home/components/widget/lib/maps/map-models'; +import { CommonMapSettings, MapProviders } from '@home/components/widget/lib/maps-legacy/map-models'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts index dde7175b1e..e42849112a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/google-map-provider-settings.component.ts @@ -33,7 +33,7 @@ import { GoogleMapProviderSettings, GoogleMapType, googleMapTypeProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts index a5b63b26ee..2ee44ec2c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/here-map-provider-settings.component.ts @@ -33,7 +33,7 @@ import { HereMapProvider, HereMapProviderSettings, hereMapProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts index 9c655137a0..0117e233f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/image-map-provider-settings.component.ts @@ -28,7 +28,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { ImageMapProviderSettings } from '@home/components/widget/lib/maps/map-models'; +import { ImageMapProviderSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { IAliasController } from '@core/api/widget-api.models'; import { Observable, of } from 'rxjs'; import { catchError, map, mergeMap, publishReplay, refCount, startWith, tap } from 'rxjs/operators'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts index 7fed20ba9e..e448a5c1a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-editor-settings.component.ts @@ -28,7 +28,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { MapEditorSettings } from '@home/components/widget/lib/maps/map-models'; +import { MapEditorSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts index 2bc7b93f76..fffe4aff7d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-provider-settings.component.ts @@ -41,7 +41,7 @@ import { mapProviderTranslationMap, OpenStreetMapProviderSettings, TencentMapProviderSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts index ad4ead491a..bb9da1b3ce 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-settings.component.ts @@ -46,7 +46,7 @@ import { PolygonSettings, PolylineSettings, UnitedMapSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; import { Widget } from '@shared/models/widget.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts index 34980009c3..1ff63ff246 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps/map-models'; +import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ selector: 'tb-map-widget-settings', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts index d648f951d9..c95f70b1d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/marker-clustering-settings.component.ts @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { MarkerClusteringSettings } from '@home/components/widget/lib/maps/map-models'; +import { MarkerClusteringSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.ts index 33ef1526ed..be9296270c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/markers-settings.component.ts @@ -31,7 +31,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MapProviders, MarkersSettings, ShowTooltipAction, showTooltipActionTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.ts index 14675fb5ef..8006ad5e28 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/openstreet-map-provider-settings.component.ts @@ -33,7 +33,7 @@ import { OpenStreetMapProvider, OpenStreetMapProviderSettings, openStreetMapProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts index ae0fd0c1f1..7cc8b9052c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/polygon-settings.component.ts @@ -33,7 +33,7 @@ import { PolygonSettings, ShowTooltipAction, showTooltipActionTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts index 3ccbf9211c..f2f6442e89 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-settings.component.ts @@ -28,7 +28,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { PolylineSettings } from '@home/components/widget/lib/maps/map-models'; +import { PolylineSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts index 8d5e640d59..6a72fc67e5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/route-map-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps/map-models'; +import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ selector: 'tb-route-map-widget-settings', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts index 868af4968c..a7b65cf451 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/tencent-map-provider-settings.component.ts @@ -33,7 +33,7 @@ import { TencentMapProviderSettings, TencentMapType, tencentMapTypeProviderTranslationMap -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts index 1e7c484bdb..4a3b1cf6f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-common-settings.component.ts @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { TripAnimationCommonSettings } from '@home/components/widget/lib/maps/map-models'; +import { TripAnimationCommonSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { Widget } from '@shared/models/widget.models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.ts index 422bcd3a2c..d8d065fe92 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-marker-settings.component.ts @@ -29,7 +29,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; -import { TripAnimationMarkerSettings } from '@home/components/widget/lib/maps/map-models'; +import { TripAnimationMarkerSettings } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts index e8fd9b64c5..9340428838 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-path-settings.component.ts @@ -32,7 +32,7 @@ import { PolylineDecoratorSymbol, polylineDecoratorSymbolTranslationMap, PolylineSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts index 91ef694292..0317379954 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-point-settings.component.ts @@ -33,7 +33,7 @@ import { PolylineDecoratorSymbol, polylineDecoratorSymbolTranslationMap, PolylineSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts index ab4ab92e17..336c776cd4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/trip-animation-widget-settings.component.ts @@ -35,7 +35,7 @@ import { PointsSettings, PolygonSettings, PolylineSettings -} from 'src/app/modules/home/components/widget/lib/maps/map-models'; +} from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; import { extractType } from '@core/utils'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts index 2fbfbc1968..3463141e2b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts @@ -31,7 +31,7 @@ import { defaultTripAnimationSettings, MapProviders, WidgetUnitedTripAnimationSettings -} from '@home/components/widget/lib/maps/map-models'; +} from '@home/components/widget/lib/maps-legacy/map-models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { WidgetContext } from '@app/modules/home/models/widget-component.models'; import { @@ -39,7 +39,7 @@ import { getRatio, interpolateOnLineSegment, parseWithTranslation -} from '@home/components/widget/lib/maps/common-maps-utils'; +} from '@home/components/widget/lib/maps-legacy/common-maps-utils'; import { FormattedData, WidgetConfig } from '@shared/models/widget.models'; import moment from 'moment'; import { @@ -51,7 +51,7 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import { MapWidgetInterface } from '@home/components/widget/lib/maps/map-widget.interface'; +import { MapWidgetInterface } from '@home/components/widget/lib/maps-legacy/map-widget.interface'; import { firstValueFrom, from } from 'rxjs'; interface DataMap { @@ -123,7 +123,7 @@ export class TripAnimationComponent implements OnInit, AfterViewInit, OnDestroy } ngAfterViewInit() { - import('@home/components/widget/lib/maps/map-widget2').then( + import('@home/components/widget/lib/maps-legacy/map-widget2').then( (mod) => { this.mapWidget = new mod.MapWidgetController(MapProviders.openstreet, false, this.ctx, this.mapContainer.nativeElement, false, () => { @@ -378,4 +378,4 @@ export class TripAnimationComponent implements OnInit, AfterViewInit, OnDestroy } } -export let TbTripAnimationWidget = TripAnimationComponent; +export const TbTripAnimationWidget = TripAnimationComponent; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index d9b18d896a..37cc09d93d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -186,7 +186,7 @@ export class WidgetComponentService { (window as any).TbCanvasDigitalGauge = mod.TbCanvasDigitalGauge; })) ); - widgetModulesTasks.push(from(import('@home/components/widget/lib/maps/map-widget2')).pipe( + widgetModulesTasks.push(from(import('@home/components/widget/lib/maps-legacy/map-widget2')).pipe( tap((mod) => { (window as any).TbMapWidgetV2 = mod.TbMapWidgetV2; })) diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 21f2d89dc6..1530561924 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -39,7 +39,7 @@ import { EdgesOverviewWidgetComponent } from '@home/components/widget/lib/edges- import { JsonInputWidgetComponent } from '@home/components/widget/lib/json-input-widget.component'; import { QrCodeWidgetComponent } from '@home/components/widget/lib/qrcode-widget.component'; import { MarkdownWidgetComponent } from '@home/components/widget/lib/markdown-widget.component'; -import { SelectEntityDialogComponent } from '@home/components/widget/lib/maps/dialogs/select-entity-dialog.component'; +import { SelectEntityDialogComponent } from '@home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component'; import { HomePageWidgetsModule } from '@home/components/widget/lib/home-page/home-page-widgets.module'; import { WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; import { FlotWidgetComponent } from '@home/components/widget/lib/flot-widget.component'; @@ -88,6 +88,7 @@ import { import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list.directive'; import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/button/two-segment-button-widget.component'; +import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.component'; @NgModule({ declarations: [ @@ -141,7 +142,8 @@ import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/but LabelValueCardWidgetComponent, UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, - ScadaSymbolWidgetComponent + ScadaSymbolWidgetComponent, + MapWidgetComponent ], imports: [ CommonModule, @@ -202,7 +204,8 @@ import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/but LabelValueCardWidgetComponent, UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, - ScadaSymbolWidgetComponent + ScadaSymbolWidgetComponent, + MapWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule}, diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts index 5486e9b0a2..61322338f1 100644 --- a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts @@ -17,7 +17,7 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { interval } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { HistorySelectSettings } from '@app/modules/home/components/widget/lib/maps/map-models'; +import { HistorySelectSettings } from '@app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ selector: 'tb-history-selector', diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index 5a02c4de08..6ed43edefa 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -21,4 +21,7 @@ declare module 'leaflet' { interface MarkerOptions { tbMarkerData?: FormattedData; } + namespace TB { + function sidebar(selector: string): Control; + } } From 0bbb55b5cd893889072e6b533f5b6934b3995922 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 30 Dec 2024 19:29:15 +0200 Subject: [PATCH 004/127] UI: Implement map layers sidebar. --- .../widget/lib/maps/leaflet/leaflet-tb.ts | 235 ++++++++++++++++++ .../components/widget/lib/maps/map-layer.ts | 35 ++- .../widget/lib/maps/map-widget.models.ts | 2 +- .../components/widget/lib/maps/map.models.ts | 2 +- .../home/components/widget/lib/maps/map.scss | 124 +++++++++ .../home/components/widget/lib/maps/map.ts | 88 +++++-- .../assets/locale/locale.constant-en_US.json | 2 + ui-ngx/src/typings/leaflet-extend-tb.d.ts | 44 +++- 8 files changed, 495 insertions(+), 37 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index e69de29bb2..e458ed8ebc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -0,0 +1,235 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import L, { TB } from 'leaflet'; + +class SidebarControl extends L.Control { + + private readonly sidebar: JQuery; + + private current = $(); + private currentButton = $(); + + private map: L.Map; + + constructor(options: TB.SidebarControlOptions) { + super(options); + this.sidebar = $('
'); + this.options.container.append(this.sidebar); + const position = options?.position || 'topleft'; + if (['topleft', 'bottomleft'].includes(position)) { + this.options.container.addClass('tb-sidebar-left'); + } else { + this.options.container.addClass('tb-sidebar-right'); + } + } + + addPane(pane: JQuery): this { + pane.hide().appendTo(this.sidebar); + return this; + } + + togglePane(pane: JQuery, button: JQuery) { + const paneWidth = this.options?.paneWidth || 220; + const position = this.options?.position || 'topleft'; + + this.current.hide().trigger('hide'); + this.currentButton.removeClass('active'); + + if (this.current === pane) { + if (['topleft', 'bottomleft'].includes(position)) { + this.map.panBy([-paneWidth, 0], { animate: false }); + } + this.sidebar.hide(); + this.current = this.currentButton = $(); + } else { + this.sidebar.show(); + this.current = pane; + this.currentButton = button || $(); + if (['topleft', 'bottomleft'].includes(position)) { + this.map.panBy([paneWidth, 0], { animate: false }); + } + } + this.map.invalidateSize({ pan: false, animate: false }); + this.current.show().trigger('show'); + this.currentButton.addClass('active'); + } + + addTo(map: L.Map): this { + this.map = map; + return this; + } +} + +class SidebarPaneControl extends L.Control { + + private button: JQuery; + private $ui: JQuery; + + constructor(options: O) { + super(options); + } + + onAdd(map: L.Map): HTMLElement { + const $container = $("
") + .attr('class', 'leaflet-bar'); + + this.button = $("") + .attr('class', 'tb-control-button') + .attr('href', '#') + .html('
') + .on('click', (e) => { + this.toggle(e); + }); + if (this.options.buttonTitle) { + this.button.attr('title', this.options.buttonTitle); + } + this.button.appendTo($container); + + this.$ui = $('
') + .attr('class', this.options.uiClass); + + $('
') + .appendTo(this.$ui) + .append($('
') + .text(this.options.paneTitle)) + .append($('
') + .append($('') + .attr('aria-label', 'Close') + .bind('click', (e) => { + this.toggle(e); + }))); + + this.options.sidebar.addPane(this.$ui); + + this.onAddPane(map, this.button, this.$ui, (e) => { + this.toggle(e); + }); + + return $container[0]; + } + + public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) {} + + private toggle(e: JQuery.MouseEventBase) { + e.stopPropagation(); + e.preventDefault(); + if (!this.button.hasClass("disabled")) { + this.options.sidebar.togglePane(this.$ui, this.button); + } + //$(".leaflet-control .control-button").tooltip("hide"); + } +} + +class LayersControl extends SidebarPaneControl { + constructor(options: TB.LayersControlOptions) { + super(options); + } + + public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) { + const layers = this.options.layers; + const baseSection = $("
") + .attr('class', 'tb-layers-container') + .appendTo($ui); + + layers.forEach((layerData, i) => { + const id = 'map-ui-layer-' + i; + const buttonContainer = $('
') + .appendTo(baseSection); + const mapContainer = $('
') + .appendTo(buttonContainer); + const input = $('') + .prop('id', id) + .prop('checked', map.hasLayer(layerData.layer)) + .appendTo(buttonContainer); + + const item = $('
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index 1721a7f068..78dd827403 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -322,7 +322,7 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe private buildHeader() { this.headerOptions.length = 0; - if (this.widgetType !== widgetType.static) { + if (this.displayData) { this.headerOptions.push( { name: this.translate.instant('widget-config.data'), @@ -710,6 +710,10 @@ export class WidgetConfigComponent extends PageComponent implements OnInit, OnDe return this.modelValue?.basicModeDirective?.length && !this.basicModeDirectiveError; } + public get displayData(): boolean { + return !this.modelValue?.typeParameters?.hideDataTab && this.widgetType !== widgetType.static; + } + public get displayAppearance(): boolean { return this.displayAppearanceDataSettings || this.displayAdvancedAppearance; } diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 5950dcf911..e5e125e171 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -185,6 +185,7 @@ export interface WidgetTypeParameters { previewHeight?: string; embedTitlePanel?: boolean; overflowVisible?: boolean; + hideDataTab?: boolean; hideDataSettings?: boolean; defaultDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; defaultLatestDataKeysFunction?: (configComponent: any, configData: any) => DataKey[]; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 67657a20e9..b282e7ce24 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6803,8 +6803,19 @@ "max-value": "Maximum value" }, "maps": { + "map-type": "Map type", + "map-type-map": "Map", + "map-type-image": "Image", + "overlays": "Overlays", + "markers": "Markers", + "polygons": "Polygons", + "circles": "Circles", "layers": "Layers", "map-layers": "Map layers", + "tencent-provider-normal": "Tencent.Normal", + "tencent-provider-satellite": "Tencent.Satellite", + "tencent-provider-terrain": "Tencent.Terrain", + "custom": "Custom", "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", "tooltips": { diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index fd11f317a2..9b8f357c18 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -15,12 +15,21 @@ /// import { FormattedData } from '@shared/models/widget.models'; +import L from 'leaflet'; +import { TileLayerOptions } from 'leaflet'; // redeclare module, maintains compatibility with @types/leaflet declare module 'leaflet' { interface MarkerOptions { tbMarkerData?: FormattedData; } + + interface TileLayer { + _url: string; + _getSubdomain(tilePoint: L.Coords): string; + _globalTileRange: L.Bounds; + } + namespace TB { interface SidebarControlOptions extends ControlOptions { @@ -48,6 +57,7 @@ declare module 'leaflet' { interface LayerData { title: string; + attributionPrefix?: string; layer: Layer; mini: Layer; } @@ -65,5 +75,23 @@ declare module 'leaflet' { function sidebarPane(options: O): SidebarPaneControl; function layers(options: LayersControlOptions): LayersControl; + + namespace TileLayer { + + interface ChinaProvidersData { + [provider: string]: { + [type: string]: string; + Subdomains: string; + }; + } + + class ChinaProvider extends L.TileLayer { + constructor(type: string, options?: TileLayerOptions); + } + } + + namespace tileLayer { + function chinaProvider(type: string, options?: TileLayerOptions): TileLayer.ChinaProvider; + } } } From 9cd62f39ea6298e05946c81b542f64dc2145857c Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 3 Jan 2025 19:51:40 +0200 Subject: [PATCH 007/127] UI: Fix map widget default config. Fix import dialog validation. --- application/src/main/data/json/system/widget_types/map.json | 2 +- .../home/components/widget/lib/maps/map-widget.models.ts | 2 +- ui-ngx/src/app/shared/import-export/import-dialog.component.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index 1d3443c55d..8019b104fb 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -17,7 +17,7 @@ "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet-map\",\"layerType\":\"openStreetMapnik\"},{\"provider\":\"openstreet-map\",\"layerType\":\"openStreetHot\"},{\"provider\":\"openstreet-map\",\"layerType\":\"esriWorldStreetMap\"},{\"provider\":\"openstreet-map\",\"layerType\":\"esriWorldTopoMap\"},{\"provider\":\"openstreet-map\",\"layerType\":\"esriWorldImagery\"},{\"provider\":\"openstreet-map\",\"layerType\":\"cartoDbPositron\"},{\"provider\":\"openstreet-map\",\"layerType\":\"cartoDbDarkMatter\"},{\"provider\":\"google-map\",\"layerType\":\"roadmap\",\"apiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\"},{\"provider\":\"google-map\",\"layerType\":\"satellite\",\"apiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\"},{\"provider\":\"google-map\",\"layerType\":\"hybrid\",\"apiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\"},{\"provider\":\"google-map\",\"layerType\":\"terrain\",\"apiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\"},{\"provider\":\"tencent-map\",\"layerType\":\"tencentNormal\"},{\"provider\":\"tencent-map\",\"layerType\":\"tencentSatellite\"},{\"provider\":\"tencent-map\",\"layerType\":\"tencentTerrain\"},{\"provider\":\"here\",\"layerType\":\"hereNormalDay\",\"apiKey\":\"kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE\"},{\"provider\":\"here\",\"layerType\":\"hereNormalNight\",\"apiKey\":\"kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE\"},{\"provider\":\"here\",\"layerType\":\"hereHybridDay\",\"apiKey\":\"kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE\"},{\"provider\":\"here\",\"layerType\":\"hereTerrainDay\",\"apiKey\":\"kVXykxAfZ6LS4EbCTO02soFVfjA7HoBzNVVH9u7nzoE\"},{\"provider\":\"custom\",\"tileUrl\":\"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\",\"label\":\"Custom 1\"},{\"provider\":\"custom\",\"tileUrl\":\"http://a.tile2.opencyclemap.org/transport/{z}/{x}/{y}.png\",\"label\":\"Custom 2\"},{\"provider\":\"custom\",\"tileUrl\":\"http://b.tile2.opencyclemap.org/transport/{z}/{x}/{y}.png\",\"label\":\"Custom 3\"}],\"markers\":[],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"12px\"},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{}}" }, "resources": [], "scada": false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts index e023b9557c..bc0d790036 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -34,5 +34,5 @@ export const mapWidgetDefaultSettings: MapWidgetSettings = blur: 3 } }, - padding: '6px' + padding: '8px' } as MapWidgetSettings); diff --git a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts index e4256229c4..16800ceadc 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts @@ -78,6 +78,7 @@ export class ImportDialogComponent extends DialogComponent { this.importTypeChanged(); }); + this.importTypeChanged(); } isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { From 632df0de8b83a22463704d21a2d68cd6a42f43c7 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Mon, 6 Jan 2025 12:05:32 +0200 Subject: [PATCH 008/127] Changed gateway dist branch --- application/src/main/resources/thingsboard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 1fa09cd9bb..f8d0cc297e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1271,7 +1271,7 @@ transport: # URL of gateways dashboard repository repository_url: "${TB_GATEWAY_DASHBOARD_SYNC_REPOSITORY_URL:https://github.com/thingsboard/gateway-management-extensions-dist.git}" # Branch of gateways dashboard repository to work with - branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:main}" + branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:develop}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" From 7b02d9079bcfce29d2f714d379b54d606065ce14 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 6 Jan 2025 19:28:33 +0200 Subject: [PATCH 009/127] UI: Map layer settings. --- .../basic/map/map-basic-config.component.ts | 4 +- .../widget/lib/maps/leaflet/leaflet-tb.ts | 9 +- .../components/widget/lib/maps/map-layer.ts | 120 ++++----- .../components/widget/lib/maps/map.models.ts | 182 +++++++++----- .../home/components/widget/lib/maps/map.ts | 8 +- .../common/map/map-layer-row.component.html | 84 +++++++ .../common/map/map-layer-row.component.scss | 36 +++ .../common/map/map-layer-row.component.ts | 230 ++++++++++++++++++ .../map-layer-settings-panel.component.html | 101 ++++++++ .../map-layer-settings-panel.component.scss | 49 ++++ .../map/map-layer-settings-panel.component.ts | 148 +++++++++++ .../common/map/map-layers.component.html | 57 +++++ .../common/map/map-layers.component.scss | 39 +++ .../common/map/map-layers.component.ts | 156 ++++++++++++ .../common/map/map-settings.component.html | 16 +- .../common/map/map-settings.component.ts | 24 +- .../common/widget-settings-common.module.ts | 8 + .../map/map-widget-settings.component.ts | 4 +- .../assets/locale/locale.constant-en_US.json | 76 +++++- 19 files changed, 1193 insertions(+), 158 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts index fb36874e5e..74c57519b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts @@ -21,7 +21,7 @@ import { AppState } from '@core/core.state'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; -import { isUndefined, mergeDeep } from '@core/utils'; +import { isUndefined, mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; import { WidgetConfig } from '@shared/models/widget.models'; @@ -47,7 +47,7 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { } protected onConfigSet(configData: WidgetConfigComponentData) { - const settings: MapWidgetSettings = mergeDeep({} as MapWidgetSettings, + const settings: MapWidgetSettings = mergeDeepIgnoreArray({} as MapWidgetSettings, mapWidgetDefaultSettings, configData.config.settings as MapWidgetSettings); const iconSize = resolveCssSize(configData.config.iconSize); this.mapWidgetConfigForm = this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 1dd32220ba..660cf1dafb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -15,6 +15,7 @@ /// import L, { Coords, TB, TileLayerOptions } from 'leaflet'; +import { guid } from '@core/utils'; class SidebarControl extends L.Control { @@ -133,7 +134,6 @@ class SidebarPaneControl extends L.Contr if (!this.button.hasClass("disabled")) { this.options.sidebar.togglePane(this.$ui, this.button); } - //$(".leaflet-control .control-button").tooltip("hide"); } } @@ -143,13 +143,14 @@ class LayersControl extends SidebarPaneControl { } public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) { + const paneId = guid(); const layers = this.options.layers; const baseSection = $("
") .attr('class', 'tb-layers-container') .appendTo($ui); layers.forEach((layerData, i) => { - const id = 'map-ui-layer-' + i; + const id = `map-ui-layer-${paneId}-${i}`; const buttonContainer = $('
') .appendTo(baseSection); const mapContainer = $('
') @@ -191,7 +192,9 @@ class LayersControl extends SidebarPaneControl { }); - input.on('click', () => { + input.on('click', (e: JQuery.MouseEventBase) => { + e.stopPropagation(); + e.preventDefault(); layers.forEach((other) => { if (other.layer === layerData.layer) { map.addLayer(other.layer); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index c10208b9a7..ddea67c613 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -15,20 +15,18 @@ /// import { - CustomMapLayerSettings, defaultCustomMapLayerSettings, - defaultGoogleMapLayerSettings, defaultHereMapLayerSettings, + CustomMapLayerSettings, + defaultCustomMapLayerSettings, + defaultGoogleMapLayerSettings, + defaultHereMapLayerSettings, + defaultLayerTitle, defaultOpenStreetMapLayerSettings, defaultTencentMapLayerSettings, - GoogleLayerType, GoogleMapLayerSettings, - googleMapLayerTranslationMap, hereLayerTranslationMap, HereLayerType, HereMapLayerSettings, + HereMapLayerSettings, MapLayerSettings, MapProvider, - OpenStreetLayerType, OpenStreetMapLayerSettings, - openStreetMapLayerTranslationMap, - tencentLayerTranslationMap, - TencentLayerType, TencentMapLayerSettings } from '@home/components/widget/lib/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -46,10 +44,10 @@ export abstract class TbMapLayer { inputSettings: DeepPartial) { switch (inputSettings.provider) { - case MapProvider.google: - return new TbGoogleMapLayer(ctx, inputSettings); case MapProvider.openstreet: return new TbOpenStreetMapLayer(ctx, inputSettings); + case MapProvider.google: + return new TbGoogleMapLayer(ctx, inputSettings); case MapProvider.tencent: return new TbTencentMapLayer(ctx, inputSettings); case MapProvider.here: @@ -66,21 +64,6 @@ export abstract class TbMapLayer { this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S); } - protected abstract defaultSettings(): S; - - protected title(): string { - const customTranslate = this.ctx.$injector.get(CustomTranslatePipe); - if (this.settings.label) { - return customTranslate.transform(this.settings.label); - } else { - return this.generateTitle(); - } - } - - protected abstract generateTitle(): string; - - protected abstract createLayer(): Observable; - public loadLayer(theMap: L.Map): Observable { return this.createLayer().pipe( switchMap((layer) => { @@ -107,6 +90,47 @@ export abstract class TbMapLayer { }) ); } + + private title(): string { + const customTranslate = this.ctx.$injector.get(CustomTranslatePipe); + if (this.settings.label) { + return customTranslate.transform(this.settings.label); + } else { + return this.generateTitle(); + } + } + + private generateTitle(): string { + const translationKey = defaultLayerTitle(this.settings); + if (translationKey) { + return this.ctx.translate.instant(translationKey); + } else { + return 'Unknown'; + } + }; + + protected abstract defaultSettings(): S; + + protected abstract createLayer(): Observable; + +} + +class TbOpenStreetMapLayer extends TbMapLayer { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial) { + super(ctx, inputSettings); + } + + protected defaultSettings(): OpenStreetMapLayerSettings { + return defaultOpenStreetMapLayerSettings; + } + + protected createLayer(): Observable { + const layer = L.tileLayer.provider(this.settings.layerType); + return of(layer); + } + } class TbGoogleMapLayer extends TbMapLayer { @@ -122,11 +146,6 @@ class TbGoogleMapLayer extends TbMapLayer { return defaultGoogleMapLayerSettings; } - protected generateTitle(): string { - const layerType = GoogleLayerType[this.settings.layerType]; - return this.ctx.translate.instant(googleMapLayerTranslationMap.get(layerType)); - } - protected createLayer(): Observable { return this.loadGoogle().pipe( map((loaded) => { @@ -162,29 +181,6 @@ class TbGoogleMapLayer extends TbMapLayer { } } -class TbOpenStreetMapLayer extends TbMapLayer { - - constructor(protected ctx: WidgetContext, - protected inputSettings: DeepPartial) { - super(ctx, inputSettings); - } - - protected defaultSettings(): OpenStreetMapLayerSettings { - return defaultOpenStreetMapLayerSettings; - } - - protected generateTitle(): string { - const layerType = OpenStreetLayerType[this.settings.layerType]; - return this.ctx.translate.instant(openStreetMapLayerTranslationMap.get(layerType)); - } - - protected createLayer(): Observable { - const layer = L.tileLayer.provider(OpenStreetLayerType[this.settings.layerType]); - return of(layer); - } - -} - class TbTencentMapLayer extends TbMapLayer { constructor(protected ctx: WidgetContext, @@ -196,13 +192,8 @@ class TbTencentMapLayer extends TbMapLayer { return defaultTencentMapLayerSettings; } - protected generateTitle(): string { - const layerType = TencentLayerType[this.settings.layerType]; - return this.ctx.translate.instant(tencentLayerTranslationMap.get(layerType)); - } - protected createLayer(): Observable { - const layer = L.TB.tileLayer.chinaProvider(TencentLayerType[this.settings.layerType], { + const layer = L.TB.tileLayer.chinaProvider(this.settings.layerType, { attribution: '©2024 Tencent - GS(2023)1171号' }); return of(layer); @@ -221,13 +212,8 @@ class TbHereMapLayer extends TbMapLayer { return defaultHereMapLayerSettings; } - protected generateTitle(): string { - const layerType = HereLayerType[this.settings.layerType]; - return this.ctx.translate.instant(hereLayerTranslationMap.get(layerType)); - } - protected createLayer(): Observable { - const layer = L.tileLayer.provider(HereLayerType[this.settings.layerType], {useV3: true, apiKey: this.settings.apiKey} as any); + const layer = L.tileLayer.provider(this.settings.layerType, {useV3: true, apiKey: this.settings.apiKey} as any); return of(layer); } @@ -244,10 +230,6 @@ class TbCustomMapLayer extends TbMapLayer { return defaultCustomMapLayerSettings; } - protected generateTitle(): string { - return this.ctx.translate.instant('widgets.maps.custom'); - } - protected createLayer(): Observable { const layer = L.tileLayer(this.settings.tileUrl); return of(layer); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts index 66810e0867..48f2362f0d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -18,6 +18,7 @@ import { DataKey, DatasourceType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { mergeDeep } from '@core/utils'; +import { AbstractControl, ValidationErrors } from '@angular/forms'; export enum MapType { geoMap = 'geoMap', @@ -133,46 +134,85 @@ export const defaultBaseMapSettings: BaseMapSettings = { }; export enum MapProvider { - google = 'google-map', - openstreet = 'openstreet-map', + openstreet = 'openstreet', + google = 'google', here = 'here', - tencent = 'tencent-map', + tencent = 'tencent', custom = 'custom' } -export interface MapLayerSettings { - label?: string; - provider: MapProvider; -} +export const mapProviders = Object.keys(MapProvider) as MapProvider[]; -export enum GoogleLayerType { - roadmap = 'roadmap', - satellite = 'satellite', - hybrid = 'hybrid', - terrain = 'terrain' -} - -export const googleMapLayerTranslationMap = new Map( +export const mapProviderTranslationMap = new Map( [ - [GoogleLayerType.roadmap, 'widgets.maps.google-map-type-roadmap'], - [GoogleLayerType.satellite, 'widgets.maps.google-map-type-satelite'], - [GoogleLayerType.hybrid, 'widgets.maps.google-map-type-hybrid'], - [GoogleLayerType.terrain, 'widgets.maps.google-map-type-terrain'] + [MapProvider.openstreet, 'widgets.maps.layer.provider.openstreet.title'], + [MapProvider.google, 'widgets.maps.layer.provider.google.title'], + [MapProvider.here, 'widgets.maps.layer.provider.here.title'], + [MapProvider.tencent, 'widgets.maps.layer.provider.tencent.title'], + [MapProvider.custom, 'widgets.maps.layer.provider.custom.title'] ] ); -export interface GoogleMapLayerSettings extends MapLayerSettings { - provider: MapProvider.google; - layerType: GoogleLayerType; - apiKey: string; +export interface MapLayerSettings { + label?: string; + provider: MapProvider; } -export const defaultGoogleMapLayerSettings: GoogleMapLayerSettings = { - provider: MapProvider.google, - layerType: GoogleLayerType.roadmap, - apiKey: 'AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q' +export const mapLayerValid = (layer: MapLayerSettings): boolean => { + if (!layer.provider) { + return false; + } + switch (layer.provider) { + case MapProvider.openstreet: + const openStreetLayer = layer as OpenStreetMapLayerSettings; + return !!openStreetLayer.layerType; + case MapProvider.google: + const googleLayer = layer as GoogleMapLayerSettings; + return !!googleLayer.layerType && !!googleLayer.apiKey; + case MapProvider.here: + const hereLayer = layer as HereMapLayerSettings; + return !!hereLayer.layerType && !!hereLayer.apiKey; + case MapProvider.tencent: + const tencentLayer = layer as TencentMapLayerSettings; + return !!tencentLayer.layerType; + case MapProvider.custom: + const customLayer = layer as CustomMapLayerSettings; + return !!customLayer.tileUrl; + } }; +export const mapLayerValidator = (control: AbstractControl): ValidationErrors | null => { + const layer: MapLayerSettings = control.value; + if (!mapLayerValid(layer)) { + return { + layer: true + }; + } + return null; +}; + +export const defaultLayerTitle = (layer: MapLayerSettings): string => { + if (!layer.provider) { + return null; + } + switch (layer.provider) { + case MapProvider.openstreet: + const openStreetLayer = layer as OpenStreetMapLayerSettings; + return openStreetMapLayerTranslationMap.get(openStreetLayer.layerType); + case MapProvider.google: + const googleLayer = layer as GoogleMapLayerSettings; + return googleMapLayerTranslationMap.get(googleLayer.layerType); + case MapProvider.here: + const hereLayer = layer as HereMapLayerSettings; + return hereLayerTranslationMap.get(hereLayer.layerType); + case MapProvider.tencent: + const tencentLayer = layer as TencentMapLayerSettings; + return tencentLayerTranslationMap.get(tencentLayer.layerType); + case MapProvider.custom: + return 'widgets.maps.layer.provider.custom.title'; + } +} + export enum OpenStreetLayerType { openStreetMapnik = 'OpenStreetMap.Mapnik', openStreetHot = 'OpenStreetMap.HOT', @@ -183,15 +223,17 @@ export enum OpenStreetLayerType { cartoDbDarkMatter = 'CartoDB.DarkMatter' } +export const openStreetLayerTypes = Object.values(OpenStreetLayerType) as OpenStreetLayerType[]; + export const openStreetMapLayerTranslationMap = new Map( [ - [OpenStreetLayerType.openStreetMapnik, 'widgets.maps.openstreet-provider-mapnik'], - [OpenStreetLayerType.openStreetHot, 'widgets.maps.openstreet-provider-hot'], - [OpenStreetLayerType.esriWorldStreetMap, 'widgets.maps.openstreet-provider-esri-street'], - [OpenStreetLayerType.esriWorldTopoMap, 'widgets.maps.openstreet-provider-esri-topo'], - [OpenStreetLayerType.esriWorldImagery, 'widgets.maps.openstreet-provider-esri-imagery'], - [OpenStreetLayerType.cartoDbPositron, 'widgets.maps.openstreet-provider-cartodb-positron'], - [OpenStreetLayerType.cartoDbDarkMatter, 'widgets.maps.openstreet-provider-cartodb-dark-matter'] + [OpenStreetLayerType.openStreetMapnik, 'widgets.maps.layer.provider.openstreet.mapnik'], + [OpenStreetLayerType.openStreetHot, 'widgets.maps.layer.provider.openstreet.hot'], + [OpenStreetLayerType.esriWorldStreetMap, 'widgets.maps.layer.provider.openstreet.esri-street'], + [OpenStreetLayerType.esriWorldTopoMap, 'widgets.maps.layer.provider.openstreet.esri-topo'], + [OpenStreetLayerType.esriWorldImagery, 'widgets.maps.layer.provider.openstreet.esri-imagery'], + [OpenStreetLayerType.cartoDbPositron, 'widgets.maps.layer.provider.openstreet.cartodb-positron'], + [OpenStreetLayerType.cartoDbDarkMatter, 'widgets.maps.layer.provider.openstreet.cartodb-dark-matter'] ] ); @@ -205,6 +247,36 @@ export const defaultOpenStreetMapLayerSettings: OpenStreetMapLayerSettings = { layerType: OpenStreetLayerType.openStreetMapnik } +export enum GoogleLayerType { + roadmap = 'roadmap', + satellite = 'satellite', + hybrid = 'hybrid', + terrain = 'terrain' +} + +export const googleMapLayerTypes = Object.values(GoogleLayerType) as GoogleLayerType[]; + +export const googleMapLayerTranslationMap = new Map( + [ + [GoogleLayerType.roadmap, 'widgets.maps.layer.provider.google.roadmap'], + [GoogleLayerType.satellite, 'widgets.maps.layer.provider.google.satellite'], + [GoogleLayerType.hybrid, 'widgets.maps.layer.provider.google.hybrid'], + [GoogleLayerType.terrain, 'widgets.maps.layer.provider.google.terrain'] + ] +); + +export interface GoogleMapLayerSettings extends MapLayerSettings { + provider: MapProvider.google; + layerType: GoogleLayerType; + apiKey: string; +} + +export const defaultGoogleMapLayerSettings: GoogleMapLayerSettings = { + provider: MapProvider.google, + layerType: GoogleLayerType.roadmap, + apiKey: 'AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q' +}; + export enum HereLayerType { hereNormalDay = 'HEREv3.normalDay', hereNormalNight = 'HEREv3.normalNight', @@ -212,12 +284,14 @@ export enum HereLayerType { hereTerrainDay = 'HEREv3.terrainDay' } +export const hereLayerTypes = Object.values(HereLayerType) as HereLayerType[]; + export const hereLayerTranslationMap = new Map( [ - [HereLayerType.hereNormalDay, 'widgets.maps.here-map-normal-day'], - [HereLayerType.hereNormalNight, 'widgets.maps.here-map-normal-night'], - [HereLayerType.hereHybridDay, 'widgets.maps.here-map-hybrid-day'], - [HereLayerType.hereTerrainDay, 'widgets.maps.here-map-terrain-day'] + [HereLayerType.hereNormalDay, 'widgets.maps.layer.provider.here.normal-day'], + [HereLayerType.hereNormalNight, 'widgets.maps.layer.provider.here.normal-night'], + [HereLayerType.hereHybridDay, 'widgets.maps.layer.provider.here.hybrid-day'], + [HereLayerType.hereTerrainDay, 'widgets.maps.layer.provider.here.terrain-day'] ] ); @@ -239,11 +313,13 @@ export enum TencentLayerType { tencentTerrain = 'Tencent.Terrain' } +export const tencentLayerTypes = Object.values(TencentLayerType) as TencentLayerType[]; + export const tencentLayerTranslationMap = new Map( [ - [TencentLayerType.tencentNormal, 'widgets.maps.tencent-provider-normal'], - [TencentLayerType.tencentSatellite, 'widgets.maps.tencent-provider-satellite'], - [TencentLayerType.tencentTerrain, 'widgets.maps.tencent-provider-terrain'] + [TencentLayerType.tencentNormal, 'widgets.maps.layer.provider.tencent.normal'], + [TencentLayerType.tencentSatellite, 'widgets.maps.layer.provider.tencent.satellite'], + [TencentLayerType.tencentTerrain, 'widgets.maps.layer.provider.tencent.terrain'] ] ); @@ -269,10 +345,10 @@ export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings => { switch (provider) { - case MapProvider.google: - return defaultGoogleMapLayerSettings; case MapProvider.openstreet: return defaultOpenStreetMapLayerSettings; + case MapProvider.google: + return defaultGoogleMapLayerSettings; case MapProvider.here: return defaultHereMapLayerSettings; case MapProvider.tencent: @@ -284,16 +360,17 @@ export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings export const defaultMapLayers: MapLayerSettings[] = (Object.keys(OpenStreetLayerType) as OpenStreetLayerType[]).map(type => ({ provider: MapProvider.openstreet, - layerType: type -} as MapLayerSettings)).concat((Object.keys(GoogleLayerType) as GoogleLayerType[]).map(type => - mergeDeep({} as MapLayerSettings, defaultGoogleMapLayerSettings, {layerType: type} as GoogleMapLayerSettings)).concat( + layerType: OpenStreetLayerType[type] +} as MapLayerSettings)); +/*.concat((Object.keys(GoogleLayerType) as GoogleLayerType[]).map(type => + mergeDeep({} as MapLayerSettings, defaultGoogleMapLayerSettings, {layerType: GoogleLayerType[type]} as GoogleMapLayerSettings)).concat( (Object.keys(TencentLayerType) as TencentLayerType[]).map(type => ({ provider: MapProvider.tencent, - layerType: type + layerType: TencentLayerType[type] } as MapLayerSettings)) )).concat( (Object.keys(HereLayerType) as HereLayerType[]).map(type => - mergeDeep({} as MapLayerSettings, defaultHereMapLayerSettings, {layerType: type} as HereMapLayerSettings)) + mergeDeep({} as MapLayerSettings, defaultHereMapLayerSettings, {layerType: HereLayerType[type]} as HereMapLayerSettings)) ).concat([ mergeDeep({} as MapLayerSettings, defaultCustomMapLayerSettings, {label: 'Custom 1'} as CustomMapLayerSettings), mergeDeep({} as MapLayerSettings, defaultCustomMapLayerSettings, { @@ -304,14 +381,7 @@ export const defaultMapLayers: MapLayerSettings[] = (Object.keys(OpenStreetLayer tileUrl: 'http://b.tile2.opencyclemap.org/transport/{z}/{x}/{y}.png', label: 'Custom 3' } as CustomMapLayerSettings) -]); - /* - (Object.keys(OpenStreetLayerType) as OpenStreetLayerType[]).map(type => ({ - provider: MapProvider.openstreet, - layerType: type -} as MapLayerSettings)).concat( - (Object.keys(GoogleLayerType) as GoogleLayerType[]).map(type => - mergeDeep({} as GoogleMapLayerSettings, defaultGoogleMapLayerSettings, {layerType: type} as GoogleMapLayerSettings)));*/ +]);*/ export interface GeoMapSettings extends BaseMapSettings { layers?: MapLayerSettings[]; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 1bb64c0ee9..cf61fc34ef 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -27,7 +27,7 @@ import { parseCenterPosition } from '@home/components/widget/lib/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { mergeDeep } from '@core/utils'; +import { mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; @@ -61,7 +61,7 @@ export abstract class TbMap { protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { - this.settings = mergeDeep({} as S, this.defaultSettings(), this.inputSettings as S); + this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); $(containerElement).empty(); $(containerElement).addClass('tb-map-layout'); const mapElement = $('
'); @@ -181,8 +181,8 @@ class TbGeoMap extends TbMap { sidebar, position: this.settings.controlsPosition, uiClass: 'tb-layers', - paneTitle: this.ctx.translate.instant('widgets.maps.map-layers'), - buttonTitle: this.ctx.translate.instant('widgets.maps.layers'), + paneTitle: this.ctx.translate.instant('widgets.maps.layer.map-layers'), + buttonTitle: this.ctx.translate.instant('widgets.maps.layer.layers'), }).addTo(this.map); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html new file mode 100644 index 0000000000..6a00c6aac2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html @@ -0,0 +1,84 @@ + +
+ + + + + + + {{ mapProviderTranslationMap.get(provider) | translate }} + + + + + + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + + + + + +
+ +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss new file mode 100644 index 0000000000..6480756f07 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-form-table-row.tb-map-layer-row { + + .tb-label-field { + flex: 1 1 33.33%; + } + + .tb-provider-field { + flex: 1 1 33.33%; + } + + .tb-layer-field { + flex: 1 1 33.33%; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts new file mode 100644 index 0000000000..eca994af6b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts @@ -0,0 +1,230 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultLayerTitle, + defaultMapLayerSettings, + googleMapLayerTranslationMap, + googleMapLayerTypes, + hereLayerTranslationMap, + hereLayerTypes, + MapLayerSettings, + MapProvider, + mapProviders, + mapProviderTranslationMap, + openStreetLayerTypes, + openStreetMapLayerTranslationMap, + tencentLayerTranslationMap, + tencentLayerTypes +} from '@home/components/widget/lib/maps/map.models'; +import { deepClone } from '@core/utils'; +import { + MapLayerSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; + +@Component({ + selector: 'tb-map-layer-row', + templateUrl: './map-layer-row.component.html', + styleUrls: ['./map-layer-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapLayerRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapLayerRowComponent implements ControlValueAccessor, OnInit { + + MapProvider = MapProvider; + + mapProviders = mapProviders; + + mapProviderTranslationMap = mapProviderTranslationMap; + + openStreetLayerTypes = openStreetLayerTypes; + + openStreetMapLayerTranslationMap = openStreetMapLayerTranslationMap; + + googleMapLayerTypes = googleMapLayerTypes; + + googleMapLayerTranslationMap = googleMapLayerTranslationMap; + + hereLayerTypes = hereLayerTypes; + + hereLayerTranslationMap = hereLayerTranslationMap; + + tencentLayerTypes = tencentLayerTypes; + + tencentLayerTranslationMap = tencentLayerTranslationMap; + + @Input() + disabled: boolean; + + @Output() + layerRemoved = new EventEmitter(); + + layerFormGroup: UntypedFormGroup; + + modelValue: MapLayerSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.layerFormGroup = this.fb.group({ + label: [null, []], + provider: [null, [Validators.required]], + layerType: [null, [Validators.required]], + tileUrl: [null, [Validators.required]], + apiKey: [null, [Validators.required]] + }); + this.layerFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.layerFormGroup.get('provider').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((newProvider: MapProvider) => { + this.onProviderChanged(newProvider); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.layerFormGroup.disable({emitEvent: false}); + } else { + this.layerFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapLayerSettings): void { + this.modelValue = value; + this.layerFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + labelPlaceholder(): string { + let translationKey = defaultLayerTitle(this.modelValue); + if (!translationKey) { + translationKey = 'widget-config.set'; + } + return this.translate.instant(translationKey); + } + + editLayer($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + mapLayerSettings: deepClone(this.modelValue) + }; + const mapLayerSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MapLayerSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + mapLayerSettingsPanelPopover.tbComponentRef.instance.popover = mapLayerSettingsPanelPopover; + mapLayerSettingsPanelPopover.tbComponentRef.instance.mapLayerSettingsApplied.subscribe((layer) => { + mapLayerSettingsPanelPopover.hide(); + this.layerFormGroup.patchValue( + layer, + {emitEvent: false}); + this.updateValidators(); + this.updateModel(); + }); + } + } + + private onProviderChanged(newProvider: MapProvider) { + this.modelValue = {...defaultMapLayerSettings(newProvider), label: this.modelValue.label}; + this.layerFormGroup.patchValue( + this.modelValue, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const provider: MapProvider = this.layerFormGroup.get('provider').value; + if (provider === MapProvider.custom) { + this.layerFormGroup.get('tileUrl').enable({emitEvent: false}); + this.layerFormGroup.get('layerType').disable({emitEvent: false}); + } else { + this.layerFormGroup.get('tileUrl').disable({emitEvent: false}); + this.layerFormGroup.get('layerType').enable({emitEvent: false}); + } + if ([MapProvider.google, MapProvider.here].includes(provider)) { + this.layerFormGroup.get('apiKey').enable({emitEvent: false}); + } else { + this.layerFormGroup.get('apiKey').disable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.layerFormGroup.value; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html new file mode 100644 index 0000000000..ab509e8d15 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.html @@ -0,0 +1,101 @@ + +
+
widgets.maps.layer.layer-settings
+
+
+
widgets.maps.layer.label
+ + + +
+
+
widgets.maps.layer.provider.provider
+ + + + {{ mapProviderTranslationMap.get(provider) | translate }} + + + +
+
+
{{ (MapProvider.custom === layerFormGroup.get('provider').value ? 'widgets.maps.layer.provider.custom.tile-url' : 'widgets.maps.layer.layer') | translate }}
+ + + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + + + + + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + + + + + +
+
+
widgets.maps.layer.credentials.credentials
+
+
widgets.maps.layer.credentials.api-key
+ + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss new file mode 100644 index 0000000000..6411af9025 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.scss @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-map-layer-settings-panel { + width: 540px; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-lt-md} { + width: 90vw; + } + .tb-map-layer-settings-panel-content { + display: flex; + flex-direction: column; + gap: 16px; + overflow: auto; + margin: -10px; + padding: 10px; + } + .tb-map-layer-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-map-layer-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts new file mode 100644 index 0000000000..341cd80388 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts @@ -0,0 +1,148 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultLayerTitle, + defaultMapLayerSettings, + googleMapLayerTranslationMap, + googleMapLayerTypes, + hereLayerTranslationMap, + hereLayerTypes, + MapLayerSettings, + MapProvider, + mapProviders, + mapProviderTranslationMap, + openStreetLayerTypes, + openStreetMapLayerTranslationMap, + tencentLayerTranslationMap, + tencentLayerTypes +} from '@home/components/widget/lib/maps/map.models'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-map-layer-settings-panel', + templateUrl: './map-layer-settings-panel.component.html', + providers: [], + styleUrls: ['./map-layer-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapLayerSettingsPanelComponent implements OnInit { + + MapProvider = MapProvider; + + mapProviders = mapProviders; + + mapProviderTranslationMap = mapProviderTranslationMap; + + openStreetLayerTypes = openStreetLayerTypes; + + openStreetMapLayerTranslationMap = openStreetMapLayerTranslationMap; + + googleMapLayerTypes = googleMapLayerTypes; + + googleMapLayerTranslationMap = googleMapLayerTranslationMap; + + hereLayerTypes = hereLayerTypes; + + hereLayerTranslationMap = hereLayerTranslationMap; + + tencentLayerTypes = tencentLayerTypes; + + tencentLayerTranslationMap = tencentLayerTranslationMap; + + @Input() + mapLayerSettings: MapLayerSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + mapLayerSettingsApplied = new EventEmitter(); + + layerFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.layerFormGroup = this.fb.group( + { + label: [null, []], + provider: [null, [Validators.required]], + layerType: [null, [Validators.required]], + tileUrl: [null, [Validators.required]], + apiKey: [null, [Validators.required]] + } + ); + this.layerFormGroup.patchValue( + this.mapLayerSettings, {emitEvent: false} + ); + this.layerFormGroup.get('provider').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((newProvider: MapProvider) => { + this.onProviderChanged(newProvider); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + labelPlaceholder(): string { + let translationKey = defaultLayerTitle(this.layerFormGroup.value); + if (!translationKey) { + translationKey = 'widget-config.set'; + } + return this.translate.instant(translationKey); + } + + applyLayerSettings() { + const layerSettings: MapLayerSettings = this.layerFormGroup.value; + this.mapLayerSettingsApplied.emit(layerSettings); + } + + private onProviderChanged(newProvider: MapProvider) { + let modelValue: MapLayerSettings = this.layerFormGroup.value; + modelValue = {...defaultMapLayerSettings(newProvider), label: modelValue.label}; + this.layerFormGroup.patchValue( + modelValue, {emitEvent: false} + ); + this.updateValidators(); + } + + private updateValidators() { + const provider: MapProvider = this.layerFormGroup.get('provider').value; + if (provider === MapProvider.custom) { + this.layerFormGroup.get('tileUrl').enable({emitEvent: false}); + this.layerFormGroup.get('layerType').disable({emitEvent: false}); + } else { + this.layerFormGroup.get('tileUrl').disable({emitEvent: false}); + this.layerFormGroup.get('layerType').enable({emitEvent: false}); + } + if ([MapProvider.google, MapProvider.here].includes(provider)) { + this.layerFormGroup.get('apiKey').enable({emitEvent: false}); + } else { + this.layerFormGroup.get('apiKey').disable({emitEvent: false}); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html new file mode 100644 index 0000000000..84765f3ad4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html @@ -0,0 +1,57 @@ + +
+
+
+
widgets.maps.layer.label
+
widgets.maps.layer.provider.provider
+
widgets.maps.layer.layer
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+ + {{ 'widgets.maps.layer.no-layers' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss new file mode 100644 index 0000000000..6737ad2c87 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-map-layers { + .tb-form-table-header-cell { + &.tb-label-header { + flex: 1 1 33.33%; + } + &.tb-provider-header { + flex: 1 1 33.33%; + } + &.tb-layer-header { + flex: 1 1 33.33%; + } + &.tb-actions-header { + width: 120px; + min-width: 120px; + } + } + + .tb-form-table-body { + tb-map-layer-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts new file mode 100644 index 0000000000..9569c324c4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts @@ -0,0 +1,156 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultMapLayerSettings, + MapLayerSettings, + mapLayerValid, + mapLayerValidator, + MapProvider +} from '@home/components/widget/lib/maps/map.models'; + +@Component({ + selector: 'tb-map-layers', + templateUrl: './map-layers.component.html', + styleUrls: ['./map-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapLayersComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + layersFormGroup: UntypedFormGroup; + + get dragEnabled(): boolean { + return this.layersFormArray().controls.length > 1; + } + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.layersFormGroup = this.fb.group({ + layers: [this.fb.array([]), []] + }); + this.layersFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let layers: MapLayerSettings[] = this.layersFormGroup.get('layers').value; + if (layers) { + layers = layers.filter(layer => mapLayerValid(layer)); + } + this.propagateChange(layers); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.layersFormGroup.disable({emitEvent: false}); + } else { + this.layersFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MapLayerSettings[] | undefined): void { + const layers: MapLayerSettings[] = value || []; + this.layersFormGroup.setControl('layers', this.prepareLayersFormArray(layers), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.layersFormGroup.valid; + return valid ? null : { + layers: { + valid: false, + }, + }; + } + + layerDrop(event: CdkDragDrop) { + const layersArray = this.layersFormGroup.get('layers') as UntypedFormArray; + const layer = layersArray.at(event.previousIndex); + layersArray.removeAt(event.previousIndex); + layersArray.insert(event.currentIndex, layer); + } + + layersFormArray(): UntypedFormArray { + return this.layersFormGroup.get('layers') as UntypedFormArray; + } + + trackByLayer(index: number, layerControl: AbstractControl): any { + return layerControl; + } + + removeLayer(index: number) { + (this.layersFormGroup.get('layers') as UntypedFormArray).removeAt(index); + } + + addLayer() { + const layer = mergeDeep({} as MapLayerSettings, + defaultMapLayerSettings(MapProvider.openstreet)); + const layersArray = this.layersFormGroup.get('layers') as UntypedFormArray; + const layerControl = this.fb.control(layer, [mapLayerValidator]); + layersArray.push(layerControl); + } + + private prepareLayersFormArray(layers: MapLayerSettings[]): UntypedFormArray { + const layersControls: Array = []; + layers.forEach((layer) => { + layersControls.push(this.fb.control(layer, [mapLayerValidator])); + }); + return this.fb.array(layersControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index 28fe014dfa..ec606c3736 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -19,24 +19,26 @@
- {{ 'widgets.maps.map-type' | translate }} + {{ 'widgets.maps.map-type.type' | translate }}
- {{ 'widgets.maps.map-type-map' | translate }} - {{ 'widgets.maps.map-type-image' | translate }} + {{ 'widgets.maps.map-type.map' | translate }} + {{ 'widgets.maps.map-type.image' | translate }}
+
- {{ 'widgets.maps.overlays' | translate }} + {{ 'widgets.maps.overlays.overlays' | translate }}
- {{ 'widgets.maps.markers' | translate }} - {{ 'widgets.maps.polygons' | translate }} - {{ 'widgets.maps.circles' | translate }} + {{ 'widgets.maps.overlays.markers' | translate }} + {{ 'widgets.maps.overlays.polygons' | translate }} + {{ 'widgets.maps.overlays.circles' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 8e5244b5ed..bdf24c4549 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -15,7 +15,13 @@ /// import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, UntypedFormControl, + UntypedFormGroup, Validator +} from '@angular/forms'; import { ImageSourceType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge } from 'rxjs'; @@ -29,10 +35,15 @@ import { merge } from 'rxjs'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MapSettingsComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapSettingsComponent), + multi: true } ] }) -export class MapSettingsComponent implements OnInit, ControlValueAccessor { +export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator { MapType = MapType; @@ -110,6 +121,15 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor { this.updateValidators(); } + public validate(c: UntypedFormControl) { + const valid = this.mapSettingsFormGroup.valid; + return valid ? null : { + mapSettings: { + valid: false, + }, + }; + } + private updateValidators() { const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value; const imageSourceType: ImageSourceType = this.mapSettingsFormGroup.get('imageSourceType').value; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index c05b778f94..e9279d853c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -184,6 +184,11 @@ import { DynamicFormArrayComponent } from '@home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component'; import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { MapLayersComponent } from '@home/components/widget/lib/settings/common/map/map-layers.component'; +import { MapLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-layer-row.component'; +import { + MapLayerSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; @NgModule({ declarations: [ @@ -254,6 +259,9 @@ import { MapSettingsComponent } from '@home/components/widget/lib/settings/commo DynamicFormSelectItemRowComponent, DynamicFormComponent, DynamicFormArrayComponent, + MapLayerSettingsPanelComponent, + MapLayerRowComponent, + MapLayersComponent, MapSettingsComponent ], imports: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts index 474f151161..9cef21f147 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { mergeDeep } from '@core/utils'; +import { mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; @Component({ @@ -41,7 +41,7 @@ export class MapWidgetSettingsComponent extends WidgetSettingsComponent { } protected defaultSettings(): WidgetSettings { - return mergeDeep({} as MapWidgetSettings, mapWidgetDefaultSettings); + return mergeDeepIgnoreArray({} as MapWidgetSettings, mapWidgetDefaultSettings); } protected onSettingsSet(settings: WidgetSettings) { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b282e7ce24..da5f80d49f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6803,19 +6803,69 @@ "max-value": "Maximum value" }, "maps": { - "map-type": "Map type", - "map-type-map": "Map", - "map-type-image": "Image", - "overlays": "Overlays", - "markers": "Markers", - "polygons": "Polygons", - "circles": "Circles", - "layers": "Layers", - "map-layers": "Map layers", - "tencent-provider-normal": "Tencent.Normal", - "tencent-provider-satellite": "Tencent.Satellite", - "tencent-provider-terrain": "Tencent.Terrain", - "custom": "Custom", + "map-type": { + "type": "Map type", + "map": "Map", + "image": "Image" + }, + "layer": { + "label": "Label", + "layer": "Layer", + "layers": "Layers", + "map-layers": "Map layers", + "add-layer": "Add layer", + "layer-settings": "Layer settings", + "remove-layer": "Remove layer", + "no-layers": "No layers configured", + "provider": { + "provider": "Provider", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + + }, + "google": { + "title": "Google", + "roadmap": "Roadmap", + "satellite": "Satellite", + "hybrid": "Hybrid", + "terrain": "Terrain" + }, + "here": { + "title": "HERE", + "normal-day": "Normal day", + "normal-night": "Normal night", + "hybrid-day": "Hybrid day", + "terrain-day": "Terrain day" + }, + "tencent": { + "title": "Tencent", + "normal": "Normal", + "satellite": "Satellite", + "terrain": "Terrain" + }, + "custom": { + "title": "Custom", + "tile-url": "Tile URL" + } + }, + "credentials": { + "credentials": "Credentials", + "api-key": "API Key" + } + }, + "overlays": { + "overlays": "Overlays", + "markers": "Markers", + "polygons": "Polygons", + "circles": "Circles" + }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", "tooltips": { From 4044783439358c94ba2b58656e3b62ecb9c5f292 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 6 Jan 2025 19:36:33 +0200 Subject: [PATCH 010/127] UI: Fix layer toggle radio buttons. --- .../home/components/widget/lib/maps/leaflet/leaflet-tb.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 660cf1dafb..966b7326eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -194,7 +194,6 @@ class LayersControl extends SidebarPaneControl { input.on('click', (e: JQuery.MouseEventBase) => { e.stopPropagation(); - e.preventDefault(); layers.forEach((other) => { if (other.layer === layerData.layer) { map.addLayer(other.layer); From e08f05ced3b2ffd40735aeadd1e3ddc3b06d86cd Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 7 Jan 2025 20:17:04 +0200 Subject: [PATCH 011/127] UI: Map data layers implementation. --- ui-ngx/src/app/core/utils.ts | 11 +- ui-ngx/src/app/modules/common/modules-map.ts | 4 +- .../basic/map/map-basic-config.component.html | 8 +- .../config/datasource.component.models.ts | 2 +- .../widget/config/datasource.component.ts | 2 +- .../widget/config/target-device.component.ts | 2 +- .../config/widget-config-components.module.ts | 3 - .../config/widget-config.component.models.ts | 10 +- .../config/widget-settings.component.ts | 9 + .../widget/lib/maps/map-data-layer.ts | 170 ++++++++ .../components/widget/lib/maps/map.models.ts | 213 +++++++++- .../home/components/widget/lib/maps/map.ts | 86 +++- .../alias/entity-alias-select.component.html | 53 ++- .../entity-alias-select.component.models.ts | 0 .../alias/entity-alias-select.component.scss | 0 .../alias/entity-alias-select.component.ts | 11 + .../map/map-data-layer-row.component.html | 129 ++++++ .../map/map-data-layer-row.component.scss | 45 +++ .../map/map-data-layer-row.component.ts | 380 ++++++++++++++++++ .../common/map/map-data-layers.component.html | 51 +++ .../common/map/map-data-layers.component.scss | 42 ++ .../common/map/map-data-layers.component.ts | 177 ++++++++ .../common/map/map-settings.component.html | 14 +- .../common/map/map-settings.component.ts | 22 +- .../common/widget-settings-common.module.ts | 14 +- .../map/map-widget-settings.component.html | 8 +- .../widget/widget-config.component.html | 1 + .../entity/entity-autocomplete.component.html | 22 +- .../entity/entity-autocomplete.component.ts | 7 + ui-ngx/src/app/shared/models/widget.models.ts | 7 +- .../assets/locale/locale.constant-en_US.json | 34 ++ 31 files changed, 1468 insertions(+), 69 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/alias/entity-alias-select.component.html (56%) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/alias/entity-alias-select.component.models.ts (100%) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/alias/entity-alias-select.component.scss (100%) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/alias/entity-alias-select.component.ts (97%) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index fed2de125f..7e2e5d1e52 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -491,11 +491,12 @@ export const createLabelFromSubscriptionEntityInfo = (entityInfo: SubscriptionEn export const hasDatasourceLabelsVariables = (pattern: string): boolean => varsRegex.test(pattern) !== null; -export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number): FormattedData[] { - return _(input).groupBy(el => el.datasource.entityName + el.datasource.entityType) +export function formattedDataFormDatasourceData(input: DatasourceData[], dataIndex?: number, ts?: number, + groupFunction: (el: DatasourceData) => any = (el) => el.datasource.entityName + el.datasource.entityType): FormattedData[] { + return _(input).groupBy(groupFunction) .values().value().map((entityArray, i) => { - const datasource = entityArray[0].datasource; - const obj = formattedDataFromDatasource(datasource, i); + const datasource = entityArray[0].datasource as D; + const obj = formattedDataFromDatasource(datasource, i); entityArray.filter(el => el.data.length).forEach(el => { const index = isDefined(dataIndex) ? dataIndex : el.data.length - 1; const dataSet = isDefined(ts) ? el.data.find(data => data[0] === ts) : el.data[index]; @@ -537,7 +538,7 @@ export function formattedDataArrayFromDatasourceData(input: DatasourceData[]): F }); } -export function formattedDataFromDatasource(datasource: Datasource, dsIndex: number): FormattedData { +export function formattedDataFromDatasource(datasource: D, dsIndex: number): FormattedData { return { entityName: datasource.entityName, deviceName: datasource.entityName, diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 257e1bf348..9e65bdb4db 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -231,7 +231,7 @@ import * as EntityFilterViewComponent from '@home/components/entity/entity-filte import * as EntityAliasDialogComponent from '@home/components/alias/entity-alias-dialog.component'; import * as EntityFilterComponent from '@home/components/entity/entity-filter.component'; import * as RelationFiltersComponent from '@home/components/relation/relation-filters.component'; -import * as EntityAliasSelectComponent from '@home/components/alias/entity-alias-select.component'; +import * as EntityAliasSelectComponent from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; import * as DataKeysComponent from '@home/components/widget/config/data-keys.component'; import * as DataKeyConfigDialogComponent from '@home/components/widget/config/data-key-config-dialog.component'; import * as DataKeyConfigComponent from '@home/components/widget/config/data-key-config.component'; @@ -577,7 +577,7 @@ class ModulesMap implements IModulesMap { '@home/components/alias/entity-alias-dialog.component': EntityAliasDialogComponent, '@home/components/entity/entity-filter.component': EntityFilterComponent, '@home/components/relation/relation-filters.component': RelationFiltersComponent, - '@home/components/alias/entity-alias-select.component': EntityAliasSelectComponent, + '@home/components/widget/lib/settings/common/alias/entity-alias-select.component': EntityAliasSelectComponent, '@home/components/widget/config/data-keys.component': DataKeysComponent, '@home/components/widget/config/data-key-config-dialog.component': DataKeyConfigDialogComponent, '@home/components/widget/config/data-key-config.component': DataKeyConfigComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html index 25acce8553..d2aaf8a3ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -16,7 +16,13 @@ --> - + +
widget-config.appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts index 2028582f52..8745136894 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models'; import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts index b62f462576..11e3af65dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts @@ -37,7 +37,7 @@ import { AlarmSearchStatus } from '@shared/models/alarm.models'; import { Dashboard } from '@shared/models/dashboard.models'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { IAliasController } from '@core/api/widget-api.models'; -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { FilterSelectCallbacks } from '@home/components/filter/filter-select.component.models'; import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; import { EntityType } from '@shared/models/entity-type.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts index 14e3187f61..daccadbdfd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -31,7 +31,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { TargetDevice, TargetDeviceType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { IAliasController } from '@core/api/widget-api.models'; -import { EntityAliasSelectCallbacks } from '@home/components/alias/entity-alias-select.component.models'; +import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts index 4470fc1327..cd0061d22b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config-components.module.ts @@ -24,7 +24,6 @@ import { DataKeyConfigDialogComponent } from '@home/components/widget/config/dat import { DataKeyConfigComponent } from '@home/components/widget/config/data-key-config.component'; import { DatasourceComponent } from '@home/components/widget/config/datasource.component'; import { DatasourcesComponent } from '@home/components/widget/config/datasources.component'; -import { EntityAliasSelectComponent } from '@home/components/alias/entity-alias-select.component'; import { FilterSelectComponent } from '@home/components/filter/filter-select.component'; import { WidgetSettingsModule } from '@home/components/widget/lib/settings/widget-settings.module'; import { WidgetSettingsComponent } from '@home/components/widget/config/widget-settings.component'; @@ -45,7 +44,6 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev DatasourceComponent, DatasourcesComponent, TargetDeviceComponent, - EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, TimewindowStylePanelComponent, @@ -67,7 +65,6 @@ import { TargetDeviceComponent } from '@home/components/widget/config/target-dev DatasourceComponent, DatasourcesComponent, TargetDeviceComponent, - EntityAliasSelectComponent, FilterSelectComponent, TimewindowStyleComponent, TimewindowStylePanelComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts index ec588d69ad..a1c6d2c2b4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-config.component.models.ts @@ -23,7 +23,7 @@ import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { AbstractControl, UntypedFormGroup } from '@angular/forms'; -import { DataKey, DatasourceType, WidgetConfigMode, widgetType } from '@shared/models/widget.models'; +import { DataKey, DatasourceType, Widget, WidgetConfigMode, widgetType } from '@shared/models/widget.models'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { isDefinedAndNotNull } from '@core/utils'; import { IAliasController } from '@core/api/widget-api.models'; @@ -67,6 +67,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.widgetConfigComponent.widgetConfigCallbacks; } + get functionsOnly(): boolean { + return this.widgetConfigComponent.functionsOnly; + } + get widgetType(): widgetType { return this.widgetConfigComponent.widgetType; } @@ -75,6 +79,10 @@ export abstract class BasicWidgetConfigComponent extends PageComponent implement return this.widgetConfigComponent.widgetEditMode; } + get widget(): Widget { + return this.widgetConfigComponent.widget; + } + widgetConfigChangedEmitter = new EventEmitter(); widgetConfigChanged = this.widgetConfigChangedEmitter.asObservable(); diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts index ab63e0e55d..6594e610ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts @@ -75,6 +75,9 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, @Input() callbacks: WidgetConfigCallbacks; + @Input() + functionsOnly: boolean; + @Input() dashboard: Dashboard; @@ -140,6 +143,11 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; } } + if (propName === 'functionsOnly') { + if (this.definedSettingsComponent) { + this.definedSettingsComponent.functionsOnly = this.functionsOnly; + } + } if (propName === 'widgetConfig') { if (this.definedSettingsComponent) { this.definedSettingsComponent.widgetConfig = this.widgetConfig; @@ -229,6 +237,7 @@ export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, this.definedSettingsComponent = this.definedSettingsComponentRef.instance; this.definedSettingsComponent.aliasController = this.aliasController; this.definedSettingsComponent.callbacks = this.callbacks; + this.definedSettingsComponent.functionsOnly = this.functionsOnly; this.definedSettingsComponent.dataKeyCallbacks = this.callbacks; this.definedSettingsComponent.dashboard = this.dashboard; this.definedSettingsComponent.widget = this.widget; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts new file mode 100644 index 0000000000..9d47c3a272 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -0,0 +1,170 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + CirclesDataLayerSettings, + MapDataLayerSettings, mapDataSourceSettingsToDatasource, + MarkersDataLayerSettings, PolygonsDataLayerSettings, TbMapDatasource +} from '@home/components/widget/lib/maps/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import { Observable, of } from 'rxjs'; +import { guid } from '@core/utils'; +import L from 'leaflet'; + +abstract class TbDataLayerItem> { + + protected layer: L.Layer; + +} + +export enum MapDataLayerType { + marker = 'marker', + polygon = 'polygon', + circle = 'circle' +} + +export abstract class TbMapDataLayer { + + protected datasource: TbMapDatasource; + + protected mapDataId = guid(); + + protected constructor(protected map: TbMap, + protected settings: S) { + } + + public setup(): Observable { + this.datasource = mapDataSourceSettingsToDatasource(this.settings); + this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; + this.mapDataId = this.datasource.mapDataIds[0]; + this.datasource = this.setupDatasource(this.datasource); + return this.doSetup(); + } + + public getDatasource(): TbMapDatasource { + return this.datasource; + } + + public updateData(dsData: FormattedData[]) { + const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + this.onData(layerData, dsData); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + return datasource; + } + + public abstract dataLayerType(): MapDataLayerType; + + protected abstract doSetup(): Observable; + + protected abstract onData(layerData: FormattedData[], dsData: FormattedData[]); + +} + +export class TbMarkersDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: MarkersDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.marker; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Markers layer data updated!`); + console.log(data); + this.markerData(data, dsData); + }); + } + + private markerData(data: FormattedData, dsData: FormattedData[]) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + } + +} + +export class TbPolygonsDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: PolygonsDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.polygon; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.polygonKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Polygons layer data updated!`); + console.log(data); + }); + } + +} + +export class TbCirclesDataLayer extends TbMapDataLayer { + + constructor(protected map: TbMap, + protected settings: CirclesDataLayerSettings) { + super(map, settings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.circle; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.circleKey); + return datasource; + } + + protected doSetup(): Observable { + return of(null); + } + + protected onData(layerData: FormattedData[], dsData: FormattedData[]) { + layerData.forEach((data, index) => { + console.log(`[${this.mapDataId}][${index}]: Circles layer data updated!`); + console.log(data); + }); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts index 48f2362f0d..fe7dde4114 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -14,11 +14,11 @@ /// limitations under the License. /// -import { DataKey, DatasourceType } from '@shared/models/widget.models'; -import { EntityType } from '@shared/models/entity-type.models'; +import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { mergeDeep } from '@core/utils'; -import { AbstractControl, ValidationErrors } from '@angular/forms'; +import { guid, mergeDeep } from '@core/utils'; +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { materialColors } from '@shared/models/material.models'; export enum MapType { geoMap = 'geoMap', @@ -27,33 +27,133 @@ export enum MapType { export interface MapDataSourceSettings { dsType: DatasourceType; - dsEntityType?: EntityType; - dsEntityId?: string; + dsDeviceId?: string; dsEntityAliasId?: string; dsFilterId?: string; } +export interface TbMapDatasource extends Datasource { + mapDataIds: string[]; +} + +export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings): TbMapDatasource => { + return { + type: settings.dsType, + deviceId: settings.dsDeviceId, + entityAliasId: settings.dsEntityAliasId, + filterId: settings.dsFilterId, + dataKeys: [], + mapDataIds: [guid()] + }; +}; + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; group?: string; } +export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; + +export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapDataLayerType): boolean => { + if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { + return false; + } + switch (dataLayer.dsType) { + case DatasourceType.function: + break; + case DatasourceType.device: + if (!dataLayer.dsDeviceId) { + return false; + } + break; + case DatasourceType.entity: + if (!dataLayer.dsEntityAliasId) { + return false; + } + break; + } + switch (type) { + case 'markers': + const markersDataLayer = dataLayer as MarkersDataLayerSettings; + if (!markersDataLayer.xKey?.type || !markersDataLayer.xKey?.name || + !markersDataLayer.yKey?.type || !markersDataLayer.xKey?.name) { + return false; + } + break; + case 'polygons': + const polygonsDataLayer = dataLayer as PolygonsDataLayerSettings; + if (!polygonsDataLayer.polygonKey?.type || !polygonsDataLayer.polygonKey?.name) { + return false; + } + break; + case 'circles': + const circlesDataLayer = dataLayer as CirclesDataLayerSettings; + if (!circlesDataLayer.circleKey?.type || !circlesDataLayer.circleKey?.name) { + return false; + } + break; + } + return true; +}; + +export const mapDataLayerValidator = (type: MapDataLayerType): ValidatorFn => { + return (control: AbstractControl): ValidationErrors | null => { + const layer: MapDataLayerSettings = control.value; + if (!mapDataLayerValid(layer, type)) { + return { + layer: true + }; + } + return null; + }; +}; + export interface MarkersDataLayerSettings extends MapDataLayerSettings { xKey: DataKey; yKey: DataKey; } -export const defaultMarkersDataLayerSettings = (mapType: MapType): MarkersDataLayerSettings => ({ - dsType: DatasourceType.entity, +const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerLongitudeFunction = 'var value = prevValue || -90.454350;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerXPosFunction = 'var value = prevValue || 0.2;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + + 'if (time % 5000 < 500) {\n' + + ' value += Math.random() * 0.05 - 0.025;\n' + + '}\n' + + 'return value;'; + +export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, xKey: { - name: MapType.geoMap === mapType ? 'latitude' : 'xPos', + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), label: MapType.geoMap === mapType ? 'latitude' : 'xPos', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLatitudeFunction : defaultMarkerXPosFunction) : undefined, + settings: {}, + color: materialColors[0].value }, yKey: { - name: MapType.geoMap === mapType ? 'longitude' : 'yPos', + name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'longitude' : 'yPos'), label: MapType.geoMap === mapType ? 'longitude' : 'yPos', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLongitudeFunction : defaultMarkerYPosFunction) : undefined, + settings: {}, + color: materialColors[0].value } }); @@ -61,25 +161,40 @@ export interface PolygonsDataLayerSettings extends MapDataLayerSettings { polygonKey: DataKey; } -export const defaultPolygonsDataLayerSettings: PolygonsDataLayerSettings = { - dsType: DatasourceType.entity, +export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, polygonKey: { - name: 'perimeter', + name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value } -}; +}); export interface CirclesDataLayerSettings extends MapDataLayerSettings { circleKey: DataKey; } -export const defaultCirclesDataLayerSettings: CirclesDataLayerSettings = { - dsType: DatasourceType.entity, +export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => ({ + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, circleKey: { - name: 'perimeter', + name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', - type: DataKeyType.attribute + type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + settings: {}, + color: materialColors[0].value + } +}); + +export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { + switch (dataLayerType) { + case 'markers': + return defaultMarkersDataLayerSettings(mapType, functionsOnly); + case 'polygons': + return defaultPolygonsDataLayerSettings(functionsOnly); + case 'circles': + return defaultCirclesDataLayerSettings(functionsOnly); } }; @@ -87,6 +202,14 @@ export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { dataKeys: DataKey[]; } +export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: AdditionalMapDataSourceSettings[]): TbMapDatasource[] => { + return additionalMapDataSources.map(addDs => { + const res = mapDataSourceSettingsToDatasource(addDs); + res.dataKeys = addDs.dataKeys; + return res; + }); +}; + export enum MapControlsPosition { topleft = 'topleft', topright = 'topright', @@ -428,3 +551,51 @@ export function parseCenterPosition(position: string | [number, number]): [numbe } return [0, 0]; } + +export const mergeMapDatasources = (target: TbMapDatasource[], source: TbMapDatasource[]): TbMapDatasource[] => { + const appendDatasources: TbMapDatasource[] = []; + for (const sourceDs of source) { + let merged = false; + for (let i = 0; i < target.length; i++) { + const targetDs = target[i]; + if (mapDatasourceIsSame(targetDs, sourceDs)) { + target[i] = mergeMapDatasource(targetDs, sourceDs); + merged = true; + break; + } + } + if (!merged) { + appendDatasources.push(sourceDs); + } + } + target.push(...appendDatasources); + return target; +}; + +const mapDatasourceIsSame = (ds1: TbMapDatasource, ds2: TbMapDatasource): boolean => { + if (ds1.type === ds2.type) { + switch (ds1.type) { + case DatasourceType.function: + return true; + case DatasourceType.device: + return ds1.deviceId === ds2.deviceId; + case DatasourceType.entity: + return ds1.entityAliasId === ds2.entityAliasId; + } + } + return false; +} + +const mergeMapDatasource = (target: TbMapDatasource, source: TbMapDatasource): TbMapDatasource => { + target.mapDataIds.push(...source.mapDataIds); + const appendKeys: DataKey[] = []; + for (const sourceKey of source.dataKeys) { + const found = + target.dataKeys.find(key => key.type === sourceKey.type && key.name === sourceKey.name && key.label === sourceKey.label); + if (!found) { + appendKeys.push(sourceKey); + } + } + target.dataKeys.push(...appendKeys); + return target; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index cf61fc34ef..c664418ce8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -15,6 +15,7 @@ /// import { + additionalMapDataSourcesToDatasources, BaseMapSettings, DEFAULT_ZOOM_LEVEL, defaultGeoMapSettings, @@ -23,17 +24,27 @@ import { ImageMapSettings, MapSetting, MapType, - MapZoomAction, - parseCenterPosition + MapZoomAction, mergeMapDatasources, + parseCenterPosition, TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; +import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; +import { + MapDataLayerType, + TbCirclesDataLayer, + TbMapDataLayer, + TbMarkersDataLayer, + TbPolygonsDataLayer +} from '@home/components/widget/lib/maps/map-data-layer'; +import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { FormattedData, widgetType } from '@shared/models/widget.models'; +import { EntityDataPageLink } from '@shared/models/query/query.models'; export abstract class TbMap { @@ -54,6 +65,8 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected bounds: L.LatLngBounds; + protected dataLayers: TbMapDataLayer[]; + protected mapElement: HTMLElement; private readonly mapResize$: ResizeObserver; @@ -112,6 +125,73 @@ export abstract class TbMap { } else { this.bounds = new L.LatLngBounds(null, null); } + this.setupDataLayers(); + } + + private setupDataLayers() { + this.dataLayers = []; + if (this.settings.markers) { + this.dataLayers.push(...this.settings.markers.map(settings => new TbMarkersDataLayer(this, settings))); + } + if (this.settings.polygons) { + this.dataLayers.push(...this.settings.polygons.map(settings => new TbPolygonsDataLayer(this, settings))); + } + if (this.settings.circles) { + this.dataLayers.push(...this.settings.circles.map(settings => new TbCirclesDataLayer(this, settings))); + } + if (this.dataLayers.length) { + const setup = this.dataLayers.map(dl => dl.setup()); + forkJoin(setup).subscribe( + () => { + let datasources: TbMapDatasource[]; + for (const layerType of (Object.keys(MapDataLayerType) as MapDataLayerType[])) { + const typeDatasources = this.dataLayers.filter(dl => dl.dataLayerType() === layerType).map(dl => dl.getDatasource()); + if (!datasources) { + datasources = typeDatasources; + } else { + datasources = mergeMapDatasources(datasources, typeDatasources); + } + } + const additionalDatasources = additionalMapDataSourcesToDatasources(this.settings.additionalDataSources); + datasources = mergeMapDatasources(datasources, additionalDatasources); + const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = { + datasources, + hasDataPageLink: true, + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + this.update(subscription); + } + } + }; + this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe( + (dataLayersSubscription) => { + let pageSize = this.settings.mapPageSize; + if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) { + pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize); + } + const pageLink: EntityDataPageLink = { + page: 0, + pageSize, + textSearch: null, + dynamic: true + }; + dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => { + // this.map.resetState(); + }); + dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null); + } + ); + } + ); + } + } + + private update(subscription: IWidgetSubscription) { + const dsData = formattedDataFormDatasourceData(subscription.data, + undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); + this.dataLayers.forEach(dl => dl.updateData(dsData)); } private resize() { diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html similarity index 56% rename from ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html index 008d1aa9fa..db71e367e8 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html @@ -15,9 +15,14 @@ limitations under the License. --> - - {{ 'entity.entity-alias' | translate }} - + {{ 'entity.entity-alias' | translate }} + - - - + + + warning + +
+ +
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss new file mode 100644 index 0000000000..d4ebdbaeed --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.scss @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-form-table-row.tb-map-data-layer-row { + + .tb-source-field { + flex: 1 1 50%; + display: flex; + gap: 12px; + .tb-ds-type-field, .tb-device-field, .tb-entity-alias-field { + flex: 1; + } + } + + .tb-x-pos-field { + flex: 1 1 25%; + } + + .tb-y-pos-field { + flex: 1 1 25%; + } + + .tb-key-field { + flex: 1 1 50%; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts new file mode 100644 index 0000000000..1d9d2014e2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts @@ -0,0 +1,380 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { TranslateService } from '@ngx-translate/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + CirclesDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + MapType, + MarkersDataLayerSettings, + PolygonsDataLayerSettings +} from '@home/components/widget/lib/maps/map.models'; +import { + DataKey, + DataKeyConfigMode, + DatasourceType, + datasourceTypeTranslationMap, + widgetType +} from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/config/data-key-config-dialog.component'; +import { deepClone } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; +import { + EntityAliasSelectCallbacks +} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; + +@Component({ + selector: 'tb-map-data-layer-row', + templateUrl: './map-data-layer-row.component.html', + styleUrls: ['./map-data-layer-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayerRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + EntityType = EntityType; + + MapType = MapType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + get functionsOnly(): boolean { + return this.mapSettingsComponent.functionsOnly; + } + + get aliasController(): IAliasController { + return this.mapSettingsComponent.aliasController; + } + + get dataKeyCallbacks(): DataKeysCallbacks { + return this.mapSettingsComponent.callbacks; + } + + public get entityAliasSelectCallbacks(): EntityAliasSelectCallbacks { + return this.mapSettingsComponent.callbacks; + } + + @Output() + dataLayerRemoved = new EventEmitter(); + + generateDataKey = this._generateDataKey.bind(this); + + dataLayerFormGroup: UntypedFormGroup; + + modelValue: MapDataLayerSettings; + + editDataLayerText: string; + + removeDataLayerText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private dialog: MatDialog, + private translate: TranslateService, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if (this.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + this.dataLayerFormGroup = this.fb.group({ + dsType: [null, [Validators.required]], + dsDeviceId: [null, [Validators.required]], + dsEntityAliasId: [null, [Validators.required]] + }); + switch (this.dataLayerType) { + case 'markers': + this.editDataLayerText = 'widgets.maps.data-layer.marker.marker-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.marker.remove-marker'; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(null, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(null, Validators.required)); + break; + case 'polygons': + this.editDataLayerText = 'widgets.maps.data-layer.polygon.polygon-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.polygon.remove-polygon'; + this.dataLayerFormGroup.addControl('polygonKey', this.fb.control(null, Validators.required)); + break; + case 'circles': + this.editDataLayerText = 'widgets.maps.data-layer.circle.circle-configuration'; + this.removeDataLayerText = 'widgets.maps.data-layer.circle.remove-circle'; + this.dataLayerFormGroup.addControl('circleKey', this.fb.control(null, Validators.required)); + break; + } + this.dataLayerFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.dataLayerFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayerFormGroup.disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: MapDataLayerSettings): void { + this.modelValue = value; + this.dataLayerFormGroup.patchValue( + { + dsType: value?.dsType, + dsDeviceId: value?.dsDeviceId, + dsEntityAliasId: value?.dsEntityAliasId + }, {emitEvent: false} + ); + switch (this.dataLayerType) { + case 'markers': + const markersDataLayer = value as MarkersDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + xKey: markersDataLayer?.xKey, + yKey: markersDataLayer?.yKey + }, {emitEvent: false} + ); + break; + case 'polygons': + const polygonsDataLayer = value as PolygonsDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + polygonKey: polygonsDataLayer?.polygonKey + }, {emitEvent: false} + ); + break; + case 'circles': + const circlesDataLayer = value as CirclesDataLayerSettings; + this.dataLayerFormGroup.patchValue( + { + circleKey: circlesDataLayer?.circleKey + }, {emitEvent: false} + ); + break; + } + this.updateValidators(); + this.cd.markForCheck(); + } + + editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { + const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; + this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(targetDataKey), + dataKeyConfigMode: DataKeyConfigMode.general, + aliasController: this.aliasController, + widgetType: widgetType.latest, + deviceId: this.dataLayerFormGroup.get('dsDeviceId').value, + entityAliasId: this.dataLayerFormGroup.get('dsEntityAliasId').value, + showPostProcessing: true, + callbacks: this.mapSettingsComponent.callbacks, + hideDataKeyColor: true, + hideDataKeyDecimals: true, + hideDataKeyUnits: true, + widget: this.mapSettingsComponent.widget, + dashboard: null, + dataKeySettingsForm: null, + dataKeySettingsDirective: null + } + }).afterClosed().subscribe((updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + } + }); + } + + editDataLayer($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + /*const ctx: any = { + mapLayerSettings: deepClone(this.modelValue) + }; + const mapLayerSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MapLayerSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + mapLayerSettingsPanelPopover.tbComponentRef.instance.popover = mapLayerSettingsPanelPopover; + mapLayerSettingsPanelPopover.tbComponentRef.instance.mapLayerSettingsApplied.subscribe((layer) => { + mapLayerSettingsPanelPopover.hide(); + this.layerFormGroup.patchValue( + layer, + {emitEvent: false}); + this.updateValidators(); + this.updateModel(); + });*/ + } + } + + private _generateDataKey(key: DataKey): DataKey { + key = this.dataKeyCallbacks.generateDataKey(key.name, key.type, null, false, + null); + return key; + } + + private onDsTypeChanged(newDsType: DatasourceType) { + let updateModel = false; + switch (this.dataLayerType) { + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (this.updateDataKeyToNewDsType(xKey, newDsType)) { + this.dataLayerFormGroup.get('xKey').patchValue(xKey, {emitEvent: false}); + updateModel = true; + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (this.updateDataKeyToNewDsType(yKey, newDsType)) { + this.dataLayerFormGroup.get('yKey').patchValue(yKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (this.updateDataKeyToNewDsType(polygonKey, newDsType)) { + this.dataLayerFormGroup.get('polygonKey').patchValue(polygonKey, {emitEvent: false}); + updateModel = true; + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (this.updateDataKeyToNewDsType(circleKey, newDsType)) { + this.dataLayerFormGroup.get('circleKey').patchValue(circleKey, {emitEvent: false}); + updateModel = true; + } + break; + } + this.updateValidators(); + if (updateModel) { + this.updateModel(); + } + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataLayerFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataLayerFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataLayerFormGroup.value}; + this.propagateChange(this.modelValue); + } + + protected readonly datasourceType = DatasourceType; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html new file mode 100644 index 0000000000..0c19ff6a9b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html @@ -0,0 +1,51 @@ + +
+
+
+
widgets.maps.data-layer.source
+
+ {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.latitude-key' : 'widgets.maps.data-layer.marker.x-pos-key') | translate }} +
+
+ {{ (mapType === MapType.geoMap ? 'widgets.maps.data-layer.marker.longitude-key' : 'widgets.maps.data-layer.marker.y-pos-key') | translate }} +
+
widgets.maps.data-layer.polygon.polygon-key
+
widgets.maps.data-layer.circle.circle-key
+
+
+
+
+ + +
+
+
+
+ +
+
+ + {{ noDataLayersText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss new file mode 100644 index 0000000000..7795f9de6e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.scss @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-map-data-layers { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-x-pos-header { + flex: 1 1 25%; + } + &.tb-y-pos-header { + flex: 1 1 25%; + } + &.tb-key-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 80px; + min-width: 80px; + } + } + + .tb-form-table-body { + tb-map-data-layer-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts new file mode 100644 index 0000000000..5c8aa5534d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -0,0 +1,177 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + defaultMapDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + mapDataLayerValid, + mapDataLayerValidator, + MapType +} from '@home/components/widget/lib/maps/map.models'; +import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; + +@Component({ + selector: 'tb-map-data-layers', + templateUrl: './map-data-layers.component.html', + styleUrls: ['./map-data-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Validator { + + MapType = MapType; + + @Input() + disabled: boolean; + + @Input() + mapType: MapType = MapType.geoMap; + + @Input() + dataLayerType: MapDataLayerType = 'markers'; + + get functionsOnly(): boolean { + return this.mapSettingsComponent.functionsOnly; + } + + dataLayersFormGroup: UntypedFormGroup; + + addDataLayerText: string; + + noDataLayersText: string; + + private propagateChange = (_val: any) => {}; + + constructor(private mapSettingsComponent: MapSettingsComponent, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + switch (this.dataLayerType) { + case 'markers': + this.addDataLayerText = 'widgets.maps.data-layer.marker.add-marker'; + this.noDataLayersText = 'widgets.maps.data-layer.marker.no-markers'; + break; + case 'polygons': + this.addDataLayerText = 'widgets.maps.data-layer.polygon.add-polygon'; + this.noDataLayersText = 'widgets.maps.data-layer.polygon.no-polygons'; + break; + case 'circles': + this.addDataLayerText = 'widgets.maps.data-layer.circle.add-circle'; + this.noDataLayersText = 'widgets.maps.data-layer.circle.no-circles'; + break; + } + this.dataLayersFormGroup = this.fb.group({ + dataLayers: [this.fb.array([]), []] + }); + this.dataLayersFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let layers: MapDataLayerSettings[] = this.dataLayersFormGroup.get('dataLayers').value; + if (layers) { + layers = layers.filter(layer => mapDataLayerValid(layer, this.dataLayerType)); + } + this.propagateChange(layers); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataLayersFormGroup.disable({emitEvent: false}); + } else { + this.dataLayersFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MapDataLayerSettings[] | undefined): void { + const dataLayers: MapDataLayerSettings[] = value || []; + this.dataLayersFormGroup.setControl('dataLayers', this.prepareDataLayersFormArray(dataLayers), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.dataLayersFormGroup.valid; + return valid ? null : { + dataLayers: { + valid: false, + }, + }; + } + + dataLayersFormArray(): UntypedFormArray { + return this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + } + + trackByDataLayer(index: number, dataLayerControl: AbstractControl): any { + return dataLayerControl; + } + + removeDataLayer(index: number) { + (this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray).removeAt(index); + } + + addDataLayer() { + const dataLayer = mergeDeep({} as MapDataLayerSettings, + defaultMapDataLayerSettings(this.mapType, this.dataLayerType, this.functionsOnly)); + const dataLayersArray = this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; + const dataLayerControl = this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)]); + dataLayersArray.push(dataLayerControl); + } + + private prepareDataLayersFormArray(dataLayers: MapDataLayerSettings[]): UntypedFormArray { + const dataLayersControls: Array = []; + dataLayers.forEach((dataLayer) => { + dataLayersControls.push(this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)])); + }); + return this.fb.array(dataLayersControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index ec606c3736..9652daaf7b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -34,12 +34,24 @@
{{ 'widgets.maps.overlays.overlays' | translate }}
- {{ 'widgets.maps.overlays.markers' | translate }} {{ 'widgets.maps.overlays.polygons' | translate }} {{ 'widgets.maps.overlays.circles' | translate }}
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index bdf24c4549..864a0f3ea9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -22,9 +22,14 @@ import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validator } from '@angular/forms'; -import { ImageSourceType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; +import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge } from 'rxjs'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { IAliasController } from '@core/api/widget-api.models'; +import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { Widget } from '@shared/models/widget.models'; @Component({ selector: 'tb-map-settings', @@ -50,13 +55,26 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid @Input() disabled: boolean; + @Input() + @coerceBoolean() + functionsOnly = false; + + @Input() + aliasController: IAliasController; + + @Input() + callbacks: WidgetConfigCallbacks; + + @Input() + widget: Widget; + private modelValue: MapSetting; private propagateChange = null; public mapSettingsFormGroup: UntypedFormGroup; - overlaysMode: 'markers' | 'polygons' | 'circles' = 'markers'; + dataLayerMode: MapDataLayerType = 'markers'; constructor(private fb: UntypedFormBuilder, private destroyRef: DestroyRef) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index e9279d853c..7018862d08 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -189,6 +189,12 @@ import { MapLayerRowComponent } from '@home/components/widget/lib/settings/commo import { MapLayerSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; +import { MapDataLayersComponent } from '@home/components/widget/lib/settings/common/map/map-data-layers.component'; +import { MapDataLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-layer-row.component'; +import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; +import { + EntityAliasSelectComponent +} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; @NgModule({ declarations: [ @@ -262,7 +268,10 @@ import { MapLayerSettingsPanelComponent, MapLayerRowComponent, MapLayersComponent, - MapSettingsComponent + MapDataLayerRowComponent, + MapDataLayersComponent, + MapSettingsComponent, + EntityAliasSelectComponent ], imports: [ CommonModule, @@ -337,7 +346,8 @@ import { DynamicFormSelectItemRowComponent, DynamicFormComponent, DynamicFormArrayComponent, - MapSettingsComponent + MapSettingsComponent, + EntityAliasSelectComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html index a2c998ac8c..d34506c9cc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html @@ -16,7 +16,13 @@ --> - + +
widget-config.card-appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 80b41c012e..9e2b1bf1fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -303,6 +303,7 @@ - - {{ label | translate }} + {{ label | translate }} {{ 'entity.create-new' | translate }} + + warning + @@ -59,7 +75,7 @@
- + {{ requiredErrorText | translate }} diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 417fc35bf4..a2920c9822 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -116,6 +116,9 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() requiredText: string; + @Input() + placeholder: string; + @Input() @coerceBoolean() useFullEntityId: boolean; @@ -123,6 +126,10 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit @Input() appearance: MatFormFieldAppearance = 'fill'; + @Input() + @coerceBoolean() + inlineField: boolean; + @Input() @coerceBoolean() required: boolean; diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index e5e125e171..bbb462d9af 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -510,8 +510,8 @@ export const datasourcesHasOnlyComparisonAggregation = (datasources?: Array { + $datasource: D; entityName: string; deviceName: string; entityId: string; @@ -861,6 +861,7 @@ export interface IWidgetSettingsComponent { aliasController: IAliasController; callbacks: WidgetConfigCallbacks; dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; dashboard: Dashboard; widget: Widget; widgetConfig: WidgetConfigComponentData; @@ -882,6 +883,8 @@ export abstract class WidgetSettingsComponent extends PageComponent implements dataKeyCallbacks: DataKeysCallbacks; + functionsOnly: boolean; + dashboard: Dashboard; widget: Widget; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index da5f80d49f..946f338ddd 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2332,6 +2332,7 @@ "alias-required": "Entity alias is required.", "remove-alias": "Remove entity alias", "add-alias": "Add entity alias", + "edit-alias": "Edit entity alias", "entity-list": "Entity list", "entity-type": "Entity type", "entity-types": "Entity types", @@ -6866,6 +6867,39 @@ "polygons": "Polygons", "circles": "Circles" }, + "data-layer": { + "source": "Source", + "marker": { + "latitude-key": "Latitude key", + "longitude-key": "Longitude key", + "x-pos-key": "X position key", + "y-pos-key": "Y position key", + "latitude-key-required": "Latitude key required", + "longitude-key-required": "Longitude key required", + "x-pos-key-required": "X position key required", + "y-pos-key-required": "Y position key required", + "no-markers": "No markers configured", + "add-marker": "Add marker", + "marker-configuration": "Marker configuration", + "remove-marker": "Remove marker" + }, + "polygon": { + "polygon-key": "Polygon key", + "polygon-key-required": "Polygon key required", + "no-polygons": "No polygons configured", + "add-polygon": "Add polygon", + "polygon-configuration": "Polygon configuration", + "remove-polygon": "Remove polygon" + }, + "circle": { + "circle-key": "Circle key", + "circle-key-required": "Circle key required", + "no-circles": "No circles configured", + "add-circle": "Add circle", + "circle-configuration": "Circle configuration", + "remove-circle": "Remove circle" + } + }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", "tooltips": { From c4468eb469527e645bc66389497041a090828d99 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 8 Jan 2025 16:57:34 +0200 Subject: [PATCH 012/127] UI: Map data layers implementation. --- .../alias/aliases-entity-select.component.ts | 3 +- .../widget/lib/maps/map-data-layer.ts | 278 ++++++++++++++++-- .../widget/lib/maps/map-widget.component.ts | 7 +- .../components/widget/lib/maps/map.models.ts | 56 +++- .../home/components/widget/lib/maps/map.ts | 135 ++++++++- 5 files changed, 433 insertions(+), 46 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts index 69a012d9a0..0a6e4c0bbd 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts @@ -93,6 +93,7 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { setTimeout(() => { this.updateDisplayValue(); this.updateEntityAliasesInfo(); + this.cd.detectChanges(); }, 0); } )); @@ -101,6 +102,7 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { setTimeout(() => { this.updateDisplayValue(); this.updateEntityAliasesInfo(); + this.cd.detectChanges(); }, 0); } )); @@ -184,7 +186,6 @@ export class AliasesEntitySelectComponent implements OnInit, OnDestroy { displayValue = this.translate.instant('entity.entities'); } this.displayValue = displayValue; - this.cd.detectChanges(); } private updateEntityAliasesInfo() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts index 9d47c3a272..008a55cd4b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -16,19 +16,49 @@ import { CirclesDataLayerSettings, - MapDataLayerSettings, mapDataSourceSettingsToDatasource, - MarkersDataLayerSettings, PolygonsDataLayerSettings, TbMapDatasource + isCutPolygon, + isValidLatLng, + MapDataLayerSettings, + mapDataSourceSettingsToDatasource, + MapType, + MarkersDataLayerSettings, + PolygonsDataLayerSettings, + TbCircleData, + TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { FormattedData } from '@shared/models/widget.models'; import { Observable, of } from 'rxjs'; -import { guid } from '@core/utils'; -import L from 'leaflet'; +import { guid, isDefinedAndNotNull, isEmptyStr, isNotEmptyStr, isString } from '@core/utils'; +import L, { LatLngBounds } from 'leaflet'; +import { isJSON } from '@home/components/widget/lib/maps-legacy/maps-utils'; -abstract class TbDataLayerItem> { +abstract class TbDataLayerItem> { protected layer: L.Layer; + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: S, + protected dataLayer: L) { + this.layer = this.create(data, dsData); + this.dataLayer.getFeatureGroup().addLayer(this.layer); + } + + protected abstract create(data: FormattedData, dsData: FormattedData[]): L.Layer; + + public abstract update(data: FormattedData, dsData: FormattedData[]): void; + + public remove() { + this.dataLayer.getFeatureGroup().removeLayer(this.layer); + } + + protected updateLayer(newLayer: L.Layer) { + this.dataLayer.getFeatureGroup().removeLayer(this.layer); + this.layer = newLayer; + this.dataLayer.getFeatureGroup().addLayer(this.layer); + } + } export enum MapDataLayerType { @@ -37,12 +67,16 @@ export enum MapDataLayerType { circle = 'circle' } -export abstract class TbMapDataLayer { +export abstract class TbMapDataLayer> { protected datasource: TbMapDatasource; protected mapDataId = guid(); + protected featureGroup = L.featureGroup(); + + protected layerItems = new Map>(); + protected constructor(protected map: TbMap, protected settings: S) { } @@ -59,24 +93,83 @@ export abstract class TbMapDataLayer { return this.datasource; } + public getFeatureGroup(): L.FeatureGroup { + return this.featureGroup; + } + + public getBounds(): LatLngBounds { + return this.featureGroup.getBounds(); + } + public updateData(dsData: FormattedData[]) { const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); - this.onData(layerData, dsData); + const rawItems = layerData.filter(d => this.isValidLayerData(d)); + const toDelete = new Set(Array.from(this.layerItems.keys())); + rawItems.forEach((data, index) => { + let layerItem = this.layerItems.get(data.entityId); + if (layerItem) { + layerItem.update(data, dsData); + } else { + layerItem = this.createLayerItem(data, dsData); + this.layerItems.set(data.entityId, layerItem); + } + toDelete.delete(data.entityId); + }); + toDelete.forEach((key) => { + const item = this.layerItems.get(key); + item.remove(); + this.layerItems.delete(key); + }); } protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { return datasource; } + protected mapType(): MapType { + return this.map.type(); + } + public abstract dataLayerType(): MapDataLayerType; protected abstract doSetup(): Observable; - protected abstract onData(layerData: FormattedData[], dsData: FormattedData[]); + protected abstract isValidLayerData(layerData: FormattedData): boolean; + + protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem; } -export class TbMarkersDataLayer extends TbMapDataLayer { +class TbMarkerDataLayerItem extends TbDataLayerItem { + + private location: L.LatLng; + private marker: L.Marker; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: MarkersDataLayerSettings, + protected dataLayer: TbMarkersDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + this.location = this.dataLayer.extractLocation(data); + this.marker = L.marker(this.location, { + tbMarkerData: data + }); + return this.marker; + } + public update(data: FormattedData, dsData: FormattedData[]): void { + const position = this.dataLayer.extractLocation(data); + if (!this.marker.getLatLng().equals(position)) { + this.location = position; + this.marker.setLatLng(position); + } + } + +} + +export class TbMarkersDataLayer extends TbMapDataLayer { constructor(protected map: TbMap, protected settings: MarkersDataLayerSettings) { @@ -96,22 +189,96 @@ export class TbMarkersDataLayer extends TbMapDataLayer return of(null); } - protected onData(layerData: FormattedData[], dsData: FormattedData[]) { - layerData.forEach((data, index) => { - console.log(`[${this.mapDataId}][${index}]: Markers layer data updated!`); - console.log(data); - this.markerData(data, dsData); - }); + protected isValidLayerData(layerData: FormattedData): boolean { + return !!this.extractPosition(layerData); } - private markerData(data: FormattedData, dsData: FormattedData[]) { - const xKeyVal = data[this.settings.xKey.label]; - const yKeyVal = data[this.settings.yKey.label]; + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { + return new TbMarkerDataLayerItem(data, dsData, this.settings, this); + } + + private extractPosition(data: FormattedData): {x: number; y: number} { + if (data) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + switch (this.mapType()) { + case MapType.geoMap: + if (!isValidLatLng(xKeyVal, yKeyVal)) { + return null; + } + break; + case MapType.image: + if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { + return null; + } + break; + } + return {x: xKeyVal, y: yKeyVal}; + } else { + return null; + } + } + + public extractLocation(data: FormattedData): L.LatLng { + const position = this.extractPosition(data); + if (position) { + return this.map.positionToLatLng(position); + } else { + return null; + } } } -export class TbPolygonsDataLayer extends TbMapDataLayer { +class TbPolygonDataLayerItem extends TbDataLayerItem { + + private polygon: L.Polygon; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: PolygonsDataLayerSettings, + protected dataLayer: TbPolygonsDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + const polyData = this.dataLayer.extractPolygonCoordinates(data); + const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; + this.polygon = polyConstructor(polyData, { + fill: true, + fillColor: '#3a77e7', + color: '#0742ad', + weight: 1, + fillOpacity: 0.4, + opacity: 1 + }); + return this.polygon; + } + public update(data: FormattedData, dsData: FormattedData[]): void { + const polyData = this.dataLayer.extractPolygonCoordinates(data); + if (isCutPolygon(polyData) || polyData.length !== 2) { + if (this.polygon instanceof L.Rectangle) { + this.polygon = L.polygon(polyData, { + fill: true, + fillColor: '#3a77e7', + color: '#0742ad', + weight: 1, + fillOpacity: 0.4, + opacity: 1 + }); + this.updateLayer(this.polygon); + } else { + this.polygon.setLatLngs(polyData); + } + } else if (polyData.length === 2) { + const bounds = new L.LatLngBounds(polyData); + // @ts-ignore + this.leafletPoly.setBounds(bounds); + } + } +} + +export class TbPolygonsDataLayer extends TbMapDataLayer { constructor(protected map: TbMap, protected settings: PolygonsDataLayerSettings) { @@ -131,16 +298,62 @@ export class TbPolygonsDataLayer extends TbMapDataLayer[], dsData: FormattedData[]) { - layerData.forEach((data, index) => { - console.log(`[${this.mapDataId}][${index}]: Polygons layer data updated!`); - console.log(data); + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) + || Array.isArray(layerData[this.settings.polygonKey.label]))); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { + return new TbPolygonDataLayerItem(data, dsData, this.settings, this); + } + + public extractPolygonCoordinates(data: FormattedData) { + let rawPolyData = data[this.settings.polygonKey.label]; + if (isString(rawPolyData)) { + rawPolyData = JSON.parse(rawPolyData); + } + return this.map.toPolygonCoordinates(rawPolyData); + } +} + +class TbCircleDataLayerItem extends TbDataLayerItem { + + private circle: L.Circle; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: CirclesDataLayerSettings, + protected dataLayer: TbCirclesDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + const circleData = this.dataLayer.extractCircleCoordinates(data); + const center = new L.LatLng(circleData.latitude, circleData.longitude); + this.circle = L.circle(center, { + radius: circleData.radius, + fillColor: '#3a77e7', + color: '#0742ad', + weight: 1, + fillOpacity: 0.4, + opacity: 1 }); + return this.circle; } + public update(data: FormattedData, dsData: FormattedData[]): void { + const circleData = this.dataLayer.extractCircleCoordinates(data); + const center = new L.LatLng(circleData.latitude, circleData.longitude); + if (!this.circle.getLatLng().equals(center)) { + this.circle.setLatLng(center); + } + if (this.circle.getRadius() !== circleData.radius) { + this.circle.setRadius(circleData.radius); + } + } } -export class TbCirclesDataLayer extends TbMapDataLayer { +export class TbCirclesDataLayer extends TbMapDataLayer { constructor(protected map: TbMap, protected settings: CirclesDataLayerSettings) { @@ -160,11 +373,18 @@ export class TbCirclesDataLayer extends TbMapDataLayer return of(null); } - protected onData(layerData: FormattedData[], dsData: FormattedData[]) { - layerData.forEach((data, index) => { - console.log(`[${this.mapDataId}][${index}]: Circles layer data updated!`); - console.log(data); - }); + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem { + throw new TbCircleDataLayerItem(data, dsData, this.settings, this); } + public extractCircleCoordinates(data: FormattedData) { + const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); + return this.map.convertCircleData(circleData); + } + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index 58d4d4fcc0..da394f6292 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -43,7 +43,7 @@ import { DomSanitizer } from '@angular/platform-browser'; styleUrls: ['./map-widget.component.scss'], encapsulation: ViewEncapsulation.None }) -export class MapWidgetComponent implements OnInit, OnDestroy, AfterViewInit { +export class MapWidgetComponent implements OnInit, OnDestroy { @ViewChild('mapElement', {static: false}) mapElement: ElementRef; @@ -78,10 +78,6 @@ export class MapWidgetComponent implements OnInit, OnDestroy, AfterViewInit { this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; } - ngAfterViewInit() { - this.map = TbMap.fromSettings(this.ctx, this.settings, this.mapElement.nativeElement); - } - ngOnDestroy() { if (this.map) { this.map.destroy(); @@ -92,6 +88,7 @@ export class MapWidgetComponent implements OnInit, OnDestroy, AfterViewInit { const borderRadius = this.ctx.$widgetElement.css('borderRadius'); this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; this.cd.detectChanges(); + this.map = TbMap.fromSettings(this.ctx, this.settings, this.mapElement.nativeElement); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts index fe7dde4114..ce540ecec0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -16,9 +16,10 @@ import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { guid, mergeDeep } from '@core/utils'; +import { guid, isDefinedAndNotNull, isString, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; +import L from 'leaflet'; export enum MapType { geoMap = 'geoMap', @@ -114,25 +115,25 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings { } const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + - 'if (time % 5000 < 500) {\n' + + 'if (time % 500 < 500) {\n' + ' value += Math.random() * 0.05 - 0.025;\n' + '}\n' + 'return value;'; const defaultMarkerLongitudeFunction = 'var value = prevValue || -90.454350;\n' + - 'if (time % 5000 < 500) {\n' + + 'if (time % 500 < 500) {\n' + ' value += Math.random() * 0.05 - 0.025;\n' + '}\n' + 'return value;'; const defaultMarkerXPosFunction = 'var value = prevValue || 0.2;\n' + - 'if (time % 5000 < 500) {\n' + + 'if (time % 500 < 500) {\n' + ' value += Math.random() * 0.05 - 0.025;\n' + '}\n' + 'return value;'; const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + - 'if (time % 5000 < 500) {\n' + + 'if (time % 500 < 500) {\n' + ' value += Math.random() * 0.05 - 0.025;\n' + '}\n' + 'return value;'; @@ -235,6 +236,7 @@ export interface BaseMapSettings { useDefaultCenterPosition: boolean; defaultCenterPosition?: string; defaultZoomLevel: number; + minZoomLevel: number; mapPageSize: number; } @@ -253,6 +255,7 @@ export const defaultBaseMapSettings: BaseMapSettings = { useDefaultCenterPosition: false, defaultCenterPosition: '0,0', defaultZoomLevel: null, + minZoomLevel: 16, mapPageSize: DEFAULT_MAP_PAGE_SIZE }; @@ -539,7 +542,48 @@ export type MapSetting = GeoMapSettings & ImageMapSettings; export const defaultMapSettings: MapSetting = defaultGeoMapSettings; -export function parseCenterPosition(position: string | [number, number]): [number, number] { +export interface TbCircleData { + latitude: number; + longitude: number; + radius: number; +} + +export const isValidLatitude = (latitude: any): boolean => + isDefinedAndNotNull(latitude) && + !isString(latitude) && + !isNaN(latitude) && isFinite(latitude) && Math.abs(latitude) <= 90; + +export const isValidLongitude = (longitude: any): boolean => + isDefinedAndNotNull(longitude) && + !isString(longitude) && + !isNaN(longitude) && isFinite(longitude) && Math.abs(longitude) <= 180; + +export const isValidLatLng = (latitude: any, longitude: any): boolean => + isValidLatitude(latitude) && isValidLongitude(longitude); + +export const isCutPolygon = (data): boolean => { + return data.length > 1 && Array.isArray(data[0]) && (Array.isArray(data[0][0]) || data[0][0] instanceof L.LatLng); +} + +export const latLngPointToBounds = (point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng => { + const maxLngMap = northEast.lng - offset; + const minLngMap = southWest.lng + offset; + const maxLatMap = northEast.lat - offset; + const minLatMap = southWest.lat + offset; + if (point.lng > maxLngMap) { + point.lng = maxLngMap; + } else if (point.lng < minLngMap) { + point.lng = minLngMap; + } + if (point.lat > maxLatMap) { + point.lat = maxLatMap; + } else if (point.lat < minLatMap) { + point.lat = minLatMap; + } + return point; +} + +export const parseCenterPosition = (position: string | [number, number]): [number, number] => { if (typeof (position) === 'string') { const parts = position.split(','); if (parts.length === 2) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index c664418ce8..bcab794377 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -22,15 +22,19 @@ import { defaultImageMapSettings, GeoMapSettings, ImageMapSettings, + latLngPointToBounds, MapSetting, MapType, - MapZoomAction, mergeMapDatasources, - parseCenterPosition, TbMapDatasource + MapZoomAction, + mergeMapDatasources, + parseCenterPosition, + TbCircleData, + TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; -import L from 'leaflet'; +import L, { LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { map, switchMap, tap } from 'rxjs/operators'; @@ -43,7 +47,7 @@ import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { FormattedData, widgetType } from '@shared/models/widget.models'; +import { widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; export abstract class TbMap { @@ -64,8 +68,12 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected bounds: L.LatLngBounds; + protected ignoreUpdateBounds = false; - protected dataLayers: TbMapDataLayer[]; + protected southWest = new L.LatLng(-Projection.SphericalMercator['MAX_LATITUDE'], -180); + protected northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180); + + protected dataLayers: TbMapDataLayer[]; protected mapElement: HTMLElement; @@ -140,6 +148,10 @@ export abstract class TbMap { this.dataLayers.push(...this.settings.circles.map(settings => new TbCirclesDataLayer(this, settings))); } if (this.dataLayers.length) { + + // TODO: Groups + this.dataLayers.forEach(dl => this.map.addLayer(dl.getFeatureGroup())); + const setup = this.dataLayers.map(dl => dl.setup()); forkJoin(setup).subscribe( () => { @@ -192,6 +204,7 @@ export abstract class TbMap { const dsData = formattedDataFormDatasourceData(subscription.data, undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); this.dataLayers.forEach(dl => dl.updateData(dsData)); + this.updateBounds(); } private resize() { @@ -199,6 +212,49 @@ export abstract class TbMap { this.map?.invalidateSize(); } + private updateBounds() { + const bounds = new L.LatLngBounds(null, null); + this.dataLayers.forEach(dl => bounds.extend(dl.getBounds())); + const mapBounds = this.map.getBounds(); + if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || !this.bounds.equals(bounds) + && this.settings.fitMapBounds ? !mapBounds.contains(bounds) : false)) { + this.bounds = bounds; + if (!this.ignoreUpdateBounds) { + this.fitBounds(bounds); + } + } + } + + private fitBounds(bounds: LatLngBounds, padding?: PointExpression) { + if (bounds.isValid()) { + this.bounds = !!this.bounds ? this.bounds.extend(bounds) : bounds; + if (!this.settings.fitMapBounds && this.settings.defaultZoomLevel) { + this.map.setZoom(this.settings.defaultZoomLevel, { animate: false }); + if (this.settings.useDefaultCenterPosition) { + this.map.panTo(this.defaultCenterPosition, { animate: false }); + } + else { + this.map.panTo(this.bounds.getCenter()); + } + } else { + this.map.once('zoomend', () => { + let minZoom = this.settings.minZoomLevel; + if (this.settings.defaultZoomLevel) { + minZoom = Math.max(minZoom, this.settings.defaultZoomLevel); + } + if (this.map.getZoom() > minZoom) { + this.map.setZoom(minZoom, { animate: false }); + } + }); + if (this.settings.useDefaultCenterPosition) { + this.bounds = this.bounds.extend(this.defaultCenterPosition); + } + this.map.fitBounds(this.bounds, { padding: padding || [50, 50], animate: false }); + this.map.invalidateSize(); + } + } + } + protected abstract defaultSettings(): S; protected abstract createMap(): Observable; @@ -209,6 +265,10 @@ export abstract class TbMap { return of(null); } + public type(): MapType { + return this.settings.mapType; + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); @@ -218,6 +278,12 @@ export abstract class TbMap { } } + public abstract positionToLatLng(position: {x: number; y: number}): L.LatLng; + + public abstract toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any; + + public abstract convertCircleData(circle: TbCircleData): TbCircleData; + } class TbGeoMap extends TbMap { @@ -280,6 +346,28 @@ class TbGeoMap extends TbMap { ); } + public positionToLatLng(position: {x: number; y: number}): L.LatLng { + return L.latLng(position.x, position.y) as L.LatLng; + } + + public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { + return (expression).map((el) => { + if (!Array.isArray(el[0]) && el.length === 2) { + return el; + } else if (Array.isArray(el) && el.length) { + return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + } else { + return null; + } + }).filter(el => !!el); + } + + public convertCircleData(circle: TbCircleData): TbCircleData { + const centerPoint = latLngPointToBounds(new L.LatLng(circle.latitude, circle.longitude), this.southWest, this.northEast); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + return circle; + } } @@ -287,6 +375,9 @@ class TbImageMap extends TbMap { private maxZoom = 4; + private width = 0; + private height = 0; + constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected mapElement: HTMLElement) { @@ -312,4 +403,38 @@ class TbImageMap extends TbMap { } protected onResize(): void {} + + public positionToLatLng(position: {x: number; y: number}): L.LatLng { + return this.pointToLatLng( + position.x * this.width, + position.y * this.height); + } + + public pointToLatLng(x: number, y: number): L.LatLng { + return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); + } + + public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { + return (expression).map((el) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + return this.pointToLatLng( + el[0] * this.width, + el[1] * this.height + ); + } else if (Array.isArray(el) && el.length) { + return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + } else { + return null; + } + }).filter(el => !!el); + } + + public convertCircleData(circle: TbCircleData): TbCircleData { + const centerPoint = this.pointToLatLng(circle.latitude * this.width, circle.longitude * this.height); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + circle.radius = circle.radius * this.width; + return circle; + } + } From d8ea4f680b35502bb22aea96c4fdc1d3feef2490 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 10 Jan 2025 18:03:40 +0200 Subject: [PATCH 013/127] UI: Merkers data layer configuration. --- ui-ngx/src/app/core/utils.ts | 23 +- ui-ngx/src/app/modules/common/modules-map.ts | 16 +- .../add-widget-dialog.component.ts | 2 +- .../dashboard-page/edit-widget.component.ts | 2 +- .../aggregated-data-key-row.component.ts | 4 +- .../aggregated-data-keys-panel.component.ts | 2 +- .../chart/comparison-key-row.component.ts | 2 +- .../basic/common/data-key-row.component.ts | 6 +- .../basic/common/data-keys-panel.component.ts | 2 +- .../widget/config/datasource.component.html | 8 + .../config/datasource.component.models.ts | 4 +- .../widget/config/datasource.component.ts | 4 +- .../widget/config/datasources.component.ts | 2 +- .../config/widget-config-components.module.ts | 17 +- .../widget/lib/chart/time-series-chart.ts | 2 +- .../widget/lib/maps/leaflet/leaflet-tb.ts | 86 ++++- .../widget/lib/maps/map-data-layer.ts | 332 +++++++++++++++++- .../components/widget/lib/maps/map.models.ts | 166 ++++++++- .../home/components/widget/lib/maps/map.scss | 32 +- .../home/components/widget/lib/maps/map.ts | 96 +++-- ...me-series-chart-line-settings.component.ts | 2 +- .../common/advanced-range.component.ts | 2 +- .../chart/chart-bar-settings.component.ts | 2 +- ...me-series-chart-threshold-row.component.ts | 2 +- ...series-chart-thresholds-panel.component.ts | 2 +- .../common/color-range-list.component.ts | 2 +- .../common/color-settings-panel.component.ts | 2 +- .../common/color-settings.component.ts | 2 +- .../filter/filter-select.component.html | 2 +- .../filter/filter-select.component.models.ts | 0 .../common}/filter/filter-select.component.ts | 11 +- .../lib/settings/common/gradient.component.ts | 2 +- .../data-key-config-dialog.component.html | 0 .../data-key-config-dialog.component.scss | 0 .../key}/data-key-config-dialog.component.ts | 2 +- .../key}/data-key-config.component.html | 0 .../common/key}/data-key-config.component.ts | 2 +- .../{ => key}/data-key-input.component.html | 13 +- .../{ => key}/data-key-input.component.scss | 27 +- .../{ => key}/data-key-input.component.ts | 18 +- .../common/key}/data-keys.component.html | 8 +- .../common/key}/data-keys.component.models.ts | 0 .../common/key}/data-keys.component.scss | 31 ++ .../common/key}/data-keys.component.ts | 62 +++- .../map/map-data-layer-dialog.component.html | 198 +++++++++++ .../map/map-data-layer-dialog.component.scss | 41 +++ .../map/map-data-layer-dialog.component.ts | 239 +++++++++++++ .../map/map-data-layer-row.component.html | 40 ++- .../map/map-data-layer-row.component.ts | 126 ++----- .../common/map/map-data-layers.component.html | 1 + .../common/map/map-data-layers.component.ts | 8 +- .../common/map/map-settings.component.html | 3 + .../map/map-settings.component.models.ts | 29 ++ .../common/map/map-settings.component.ts | 60 +++- .../common/value-source-data-key.component.ts | 2 +- .../common/widget-settings-common.module.ts | 28 +- .../widget}/widget-settings.component.html | 0 .../widget}/widget-settings.component.scss | 0 .../widget}/widget-settings.component.ts | 0 .../device-key-autocomplete.component.html | 3 +- .../settings/gauge/tick-value.component.ts | 2 +- .../widget/widget-config.component.ts | 2 +- .../home/models/widget-component.models.ts | 2 +- .../string-items-list.component.html | 2 +- .../string-items-list.component.scss | 22 ++ .../components/string-items-list.component.ts | 7 +- ui-ngx/src/app/shared/models/widget.models.ts | 2 +- .../assets/locale/locale.constant-en_US.json | 3 + ui-ngx/src/typings/leaflet-extend-tb.d.ts | 23 +- 69 files changed, 1556 insertions(+), 289 deletions(-) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/filter/filter-select.component.html (97%) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/filter/filter-select.component.models.ts (100%) rename ui-ngx/src/app/modules/home/components/{ => widget/lib/settings/common}/filter/filter-select.component.ts (95%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-key-config-dialog.component.html (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-key-config-dialog.component.scss (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-key-config-dialog.component.ts (97%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-key-config.component.html (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-key-config.component.ts (99%) rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{ => key}/data-key-input.component.html (92%) rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{ => key}/data-key-input.component.scss (70%) rename ui-ngx/src/app/modules/home/components/widget/lib/settings/common/{ => key}/data-key-input.component.ts (96%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-keys.component.html (97%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-keys.component.models.ts (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-keys.component.scss (81%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/key}/data-keys.component.ts (95%) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/widget}/widget-settings.component.html (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/widget}/widget-settings.component.scss (100%) rename ui-ngx/src/app/modules/home/components/widget/{config => lib/settings/common/widget}/widget-settings.component.ts (100%) create mode 100644 ui-ngx/src/app/shared/components/string-items-list.component.scss diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 7e2e5d1e52..95615cf516 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -17,7 +17,7 @@ import _ from 'lodash'; import { from, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { catchError, finalize, share } from 'rxjs/operators'; -import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; +import { DataKey, Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { EntityId } from '@shared/models/id/entity-id'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models'; @@ -846,7 +846,7 @@ function prepareMessageFromData(data): string { } } -export function genNextLabel(name: string, datasources: Datasource[]): string { +export const genNextLabel = (name: string, datasources: Datasource[]): string => { let label = name; let i = 1; let matches = false; @@ -880,6 +880,25 @@ export function genNextLabel(name: string, datasources: Datasource[]): string { return label; } +export const genNextLabelForDataKeys = (name: string, dataKeys: DataKey[]): string => { + let label = name; + let i = 1; + let matches = false; + if (dataKeys) { + do { + matches = false; + dataKeys.forEach((dataKey) => { + if (dataKey?.label === label) { + i++; + label = name + ' ' + i; + matches = true; + } + }); + } while (matches) + } + return label; +} + export const getOS = (): string => { const userAgent = window.navigator.userAgent.toLowerCase(); const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos|mac_powerpc)/i; diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 9e65bdb4db..040edd19c3 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -232,9 +232,9 @@ import * as EntityAliasDialogComponent from '@home/components/alias/entity-alias import * as EntityFilterComponent from '@home/components/entity/entity-filter.component'; import * as RelationFiltersComponent from '@home/components/relation/relation-filters.component'; import * as EntityAliasSelectComponent from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; -import * as DataKeysComponent from '@home/components/widget/config/data-keys.component'; -import * as DataKeyConfigDialogComponent from '@home/components/widget/config/data-key-config-dialog.component'; -import * as DataKeyConfigComponent from '@home/components/widget/config/data-key-config.component'; +import * as DataKeysComponent from '@home/components/widget/lib/settings/common/key/data-keys.component'; +import * as DataKeyConfigDialogComponent from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import * as DataKeyConfigComponent from '@home/components/widget/lib/settings/common/key/data-key-config.component'; import * as LegendConfigComponent from '@home/components/widget/lib/settings/common/legend-config.component'; import * as ManageWidgetActionsComponent from '@home/components/widget/action/manage-widget-actions.component'; import * as WidgetActionDialogComponent from '@home/components/widget/action/widget-action-dialog.component'; @@ -268,7 +268,7 @@ import * as ComplexFilterPredicateDialogComponent from '@home/components/filter/ import * as KeyFilterDialogComponent from '@home/components/filter/key-filter-dialog.component'; import * as FiltersDialogComponent from '@home/components/filter/filters-dialog.component'; import * as FilterDialogComponent from '@home/components/filter/filter-dialog.component'; -import * as FilterSelectComponent from '@home/components/filter/filter-select.component'; +import * as FilterSelectComponent from '@home/components/widget/lib/settings/common/filter/filter-select.component'; import * as FiltersEditComponent from '@home/components/filter/filters-edit.component'; import * as FiltersEditPanelComponent from '@home/components/filter/filters-edit-panel.component'; import * as UserFilterDialogComponent from '@home/components/filter/user-filter-dialog.component'; @@ -578,9 +578,9 @@ class ModulesMap implements IModulesMap { '@home/components/entity/entity-filter.component': EntityFilterComponent, '@home/components/relation/relation-filters.component': RelationFiltersComponent, '@home/components/widget/lib/settings/common/alias/entity-alias-select.component': EntityAliasSelectComponent, - '@home/components/widget/config/data-keys.component': DataKeysComponent, - '@home/components/widget/config/data-key-config-dialog.component': DataKeyConfigDialogComponent, - '@home/components/widget/config/data-key-config.component': DataKeyConfigComponent, + '@home/components/widget/lib/settings/common/key/data-keys.component': DataKeysComponent, + '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component': DataKeyConfigDialogComponent, + '@home/components/widget/lib/settings/common/key/data-key-config.component': DataKeyConfigComponent, '@home/components/widget/lib/settings/common/legend-config.component': LegendConfigComponent, '@home/components/widget/action/manage-widget-actions.component': ManageWidgetActionsComponent, '@home/components/widget/action/widget-action-dialog.component': WidgetActionDialogComponent, @@ -610,7 +610,7 @@ class ModulesMap implements IModulesMap { '@home/components/filter/key-filter-dialog.component': KeyFilterDialogComponent, '@home/components/filter/filters-dialog.component': FiltersDialogComponent, '@home/components/filter/filter-dialog.component': FilterDialogComponent, - '@home/components/filter/filter-select.component': FilterSelectComponent, + '@home/components/widget/lib/settings/common/filter/filter-select.component': FilterSelectComponent, '@home/components/filter/filters-edit.component': FiltersEditComponent, '@home/components/filter/filters-edit-panel.component': FiltersEditPanelComponent, '@home/components/filter/user-filter-dialog.component': UserFilterDialogComponent, diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts index fe96529a1d..a89df99db1 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.ts @@ -29,7 +29,7 @@ import { WidgetConfigComponentData, WidgetInfo } from '@home/models/widget-compo import { isDefined, isDefinedAndNotNull } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; export interface AddWidgetDialogData { dashboard: Dashboard; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts index 191bf17700..d89de90fef 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts @@ -27,7 +27,7 @@ import { WidgetConfigComponentData } from '../../models/widget-component.models' import { isDefined, isDefinedAndNotNull } from '@core/utils'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts index 5f100dbe3e..76bdddadf3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts @@ -45,7 +45,7 @@ import { TruncatePipe } from '@shared/pipe/truncate.pipe'; import { DataKeyConfigDialogComponent, DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; import { deepClone, formatValue } from '@core/utils'; import { AggregatedValueCardKeyPosition, @@ -59,7 +59,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-aggregated-data-key-row', templateUrl: './aggregated-data-key-row.component.html', - styleUrls: ['./aggregated-data-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./aggregated-data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts index 39481874e3..c6706cc32b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts @@ -38,7 +38,7 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts index 48ffda9c7c..02e397cf85 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts @@ -34,7 +34,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-comparison-key-row', templateUrl: './comparison-key-row.component.html', - styleUrls: ['./comparison-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./comparison-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts index 95332c3791..f749d1201c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts @@ -41,12 +41,12 @@ import { MatDialog } from '@angular/material/dialog'; import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; import { DataKey, DataKeyConfigMode, DatasourceType, Widget, widgetType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { merge } from 'rxjs'; import { DataKeyConfigDialogComponent, DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; import { deepClone } from '@core/utils'; import { Dashboard } from '@shared/models/dashboard.models'; import { IAliasController } from '@core/api/widget-api.models'; @@ -78,7 +78,7 @@ export const dataKeyRowValidator = (control: AbstractControl): ValidationErrors @Component({ selector: 'tb-data-key-row', templateUrl: './data-key-row.component.html', - styleUrls: ['./data-key-row.component.scss', '../../data-keys.component.scss'], + styleUrls: ['./data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts index a9bea410ab..e5ede137bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts @@ -43,7 +43,7 @@ import { dataKeyRowValidator, dataKeyValid } from '@home/components/widget/confi import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { UtilsService } from '@core/services/utils.service'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { TimeSeriesChartYAxisId } from '@home/components/widget/lib/chart/time-series-chart.models'; import { FormProperty } from '@shared/models/dynamic-form.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html index 8af7753525..b19398faab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html @@ -66,6 +66,10 @@ { @@ -26,6 +26,8 @@ class SidebarControl extends L.Control { private map: L.Map; + private buttonContainer: JQuery; + constructor(options: TB.SidebarControlOptions) { super(options); this.sidebar = $('
'); @@ -38,8 +40,9 @@ class SidebarControl extends L.Control { } } - addPane(pane: JQuery): this { + addPane(pane: JQuery, button: JQuery): this { pane.hide().appendTo(this.sidebar); + button.appendTo(this.buttonContainer); return this; } @@ -50,6 +53,7 @@ class SidebarControl extends L.Control { this.current.hide().trigger('hide'); this.currentButton.removeClass('active'); + if (this.current === pane) { if (['topleft', 'bottomleft'].includes(position)) { this.map.panBy([-paneWidth, 0], { animate: false }); @@ -58,20 +62,26 @@ class SidebarControl extends L.Control { this.current = this.currentButton = $(); } else { this.sidebar.show(); - this.current = pane; - this.currentButton = button || $(); - if (['topleft', 'bottomleft'].includes(position)) { + if (['topleft', 'bottomleft'].includes(position) && !this.current.length) { this.map.panBy([paneWidth, 0], { animate: false }); } + this.current = pane; + this.currentButton = button || $(); } - this.map.invalidateSize({ pan: false, animate: false }); this.current.show().trigger('show'); this.currentButton.addClass('active'); + this.map.invalidateSize({ pan: false, animate: false }); + } + + onAdd(map: L.Map): HTMLElement { + this.buttonContainer = $("
") + .attr('class', 'leaflet-bar'); + return this.buttonContainer[0]; } addTo(map: L.Map): this { this.map = map; - return this; + return super.addTo(map); } } @@ -84,9 +94,7 @@ class SidebarPaneControl extends L.Contr super(options); } - onAdd(map: L.Map): HTMLElement { - const $container = $("
") - .attr('class', 'leaflet-bar'); + addTo(map: L.Map): this { this.button = $("") .attr('class', 'tb-control-button') @@ -98,7 +106,6 @@ class SidebarPaneControl extends L.Contr if (this.options.buttonTitle) { this.button.attr('title', this.options.buttonTitle); } - this.button.appendTo($container); this.$ui = $('
') .attr('class', this.options.uiClass); @@ -117,13 +124,13 @@ class SidebarPaneControl extends L.Contr this.toggle(e); }))); - this.options.sidebar.addPane(this.$ui); + this.options.sidebar.addPane(this.$ui, this.button); this.onAddPane(map, this.button, this.$ui, (e) => { this.toggle(e); }); - return $container[0]; + return this; } public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) {} @@ -216,6 +223,53 @@ class LayersControl extends SidebarPaneControl { } } +class GroupsControl extends SidebarPaneControl { + constructor(options: TB.GroupsControlOptions) { + super(options); + } + + public onAddPane(map: L.Map, button: JQuery, $ui: JQuery, toggle: (e: JQuery.MouseEventBase) => void) { + const paneId = guid(); + const groups = this.options.groups; + const baseSection = $("
") + .attr('class', 'tb-layers-container') + .appendTo($ui); + + groups.forEach((groupData, i) => { + const id = `map-group-layer-${paneId}-${i}`; + const checkBoxContainer = $('
') + .appendTo(baseSection); + const input = $('') + .prop('id', id) + .prop('checked', groupData.enabled) + .appendTo(checkBoxContainer); + + $('
+ + {{ requiredText | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss similarity index 70% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss index f8564a46ac..cb01c98c37 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.scss @@ -14,7 +14,7 @@ * limitations under the License. */ .tb-data-key-input { - .mat-mdc-form-field.tb-inline-field.tb-key-field { + .mat-mdc-form-field.tb-key-field { width: 100%; &.mat-form-field-appearance-fill { .mdc-text-field--filled.mdc-text-field--disabled { @@ -23,14 +23,25 @@ } } } - .mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { - padding-left: 8px; - padding-right: 0; - + .mat-mdc-text-field-wrapper { + &:not(.mdc-text-field--outlined) { + padding-left: 8px; + padding-right: 0; + .mat-mdc-form-field-infix { + padding-top: 0; + padding-bottom: 6px; + } + } + &.mdc-text-field--outlined { + .mat-mdc-form-field-infix { + padding-top: 12px; + padding-bottom: 12px; + } + .mdc-evolution-chip-set__chips { + margin-left: 0; + } + } .mat-mdc-form-field-infix { - padding-top: 0; - padding-bottom: 6px; - .mdc-evolution-chip-set .mdc-evolution-chip { margin: 0; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts similarity index 96% rename from ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts index e08ec36d8d..6ea515e096 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/data-key-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts @@ -50,13 +50,14 @@ import { UtilsService } from '@core/services/utils.service'; import { alarmFields } from '@shared/models/alarm.models'; import { filter, map, mergeMap, publishReplay, refCount, share, tap } from 'rxjs/operators'; import { AggregationType } from '@shared/models/time/time.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from './data-keys.component.models'; import { IAliasController } from '@core/api/widget-api.models'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ selector: 'tb-data-key-input', templateUrl: './data-key-input.component.html', - styleUrls: ['./data-key-input.component.scss', '../../../config/data-keys.component.scss'], + styleUrls: ['./data-key-input.component.scss', './data-keys.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -83,6 +84,19 @@ export class DataKeyInputComponent implements ControlValueAccessor, OnInit, OnCh @Input() disabled: boolean; + @Input() + label: string; + + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + subscriptSizing: SubscriptSizing = 'fixed'; + + @Input() + @coerceBoolean() + inlineField = true; + @Input() @coerceBoolean() required = false; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html index 9cb03bde9e..c60d29a9d1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/data-keys.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.html @@ -15,8 +15,10 @@ limitations under the License. --> - - {{placeholder}} + + {{ label ? label : placeholder}}
drag_indicator
-
{{ 'widget-config.appearance' | translate }}
+ +
+
+
{{ 'widgets.maps.data-layer.marker.marker-type' | translate }}
+ + {{ 'widgets.maps.data-layer.marker.marker-type-default' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-image' | translate }} + +
+
+
widgets.maps.data-layer.color
+ +
+
+
widgets.maps.data-layer.marker.image
+ +
+
+
+
widgets.maps.data-layer.marker.marker-offset
+
+
widgets.maps.data-layer.marker.offset-horizontal
+ + + +
widgets.maps.data-layer.marker.offset-vertical
+ + + +
+
+
{{ 'widgets.maps.data-layer.groups' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 9b7ea1b757..afcb164be8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -22,6 +22,7 @@ import { MapDataLayerType, MapType, MarkersDataLayerSettings, + MarkerType, PolygonsDataLayerSettings } from '@home/components/widget/lib/maps/map.models'; import { Store } from '@ngrx/store'; @@ -61,6 +62,8 @@ export class MapDataLayerDialogComponent extends DialogComponent = []; datasourceTypesTranslations = datasourceTypeTranslationMap; @@ -103,7 +106,17 @@ export class MapDataLayerDialogComponent extends DialogComponent + this.updateValidators() + ); break; case 'polygons': const polygonsDataLayer = this.settings as PolygonsDataLayerSettings; @@ -179,6 +192,16 @@ export class MapDataLayerDialogComponent extends DialogComponent +
+
widgets.maps.data-layer.marker.marker-image
+
+ + + {{ 'widgets.maps.data-layer.marker.marker-image-type-image' | translate }} + + + {{ 'widgets.maps.data-layer.marker.marker-image-type-function' | translate }} + + +
+
+
+ +
+
{{ 'widgets.maps.data-layer.marker.custom-marker-image-size' | translate }}
+ + +
px
+
+
+
+
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss new file mode 100644 index 0000000000..e37052f11a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-marker-image-settings-panel { + width: 700px; + max-width: 90vw; + min-height: 300px; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .tb-marker-image-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-form-row { + height: auto; + } + .tb-marker-image-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .tb-marker-image-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts new file mode 100644 index 0000000000..9184f26c5b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/map.models'; + +@Component({ + selector: 'tb-marker-image-settings-panel', + templateUrl: './marker-image-settings-panel.component.html', + providers: [], + styleUrls: ['./marker-image-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerImageSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + markerImageSettings: MarkerImageSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerImageSettingsApplied = new EventEmitter(); + + MarkerImageType = MarkerImageType; + + markerImageSettingsFormGroup: UntypedFormGroup; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store, + private destroyRef: DestroyRef) { + super(store); + } + + ngOnInit(): void { + this.markerImageSettingsFormGroup = this.fb.group( + { + type: [this.markerImageSettings?.type || MarkerImageType.image, []], + image: [this.markerImageSettings?.image, [Validators.required]], + imageSize: [this.markerImageSettings?.imageSize, [Validators.min(1)]], + imageFunction: [this.markerImageSettings?.imageFunction, [Validators.required]], + images: [this.markerImageSettings?.images, []] + } + ); + this.markerImageSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); + this.updateValidators(); + } + + cancel() { + this.popover?.hide(); + } + + applyMarkerImageSettings() { + const markerImageSettings: MarkerImageSettings = this.markerImageSettingsFormGroup.value; + this.markerImageSettingsApplied.emit(markerImageSettings); + } + + private updateValidators() { + const type: MarkerImageType = this.markerImageSettingsFormGroup.get('type').value; + if (type === MarkerImageType.image) { + this.markerImageSettingsFormGroup.get('image').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageSize').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageFunction').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('images').disable({emitEvent: false}); + } else { + this.markerImageSettingsFormGroup.get('image').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageSize').disable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('imageFunction').enable({emitEvent: false}); + this.markerImageSettingsFormGroup.get('images').enable({emitEvent: false}); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html new file mode 100644 index 0000000000..a3e3a41a6c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html @@ -0,0 +1,28 @@ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts new file mode 100644 index 0000000000..c8f2bd2725 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts @@ -0,0 +1,96 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/map.models'; +import { + MarkerImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; + +@Component({ + selector: 'tb-marker-image-settings', + templateUrl: './marker-image-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerImageSettingsComponent), + multi: true + } + ] +}) +export class MarkerImageSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + MarkerImageType = MarkerImageType; + + modelValue: MarkerImageSettings; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private cd: ChangeDetectorRef, + private viewContainerRef: ViewContainerRef) {} + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + writeValue(value: MarkerImageSettings): void { + if (value) { + this.modelValue = value; + } + } + + openImageSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + markerImageSettings: this.modelValue, + }; + const markerImageSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerImageSettingsPanelComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + markerImageSettingsPanelPopover.tbComponentRef.instance.popover = markerImageSettingsPanelPopover; + markerImageSettingsPanelPopover.tbComponentRef.instance.markerImageSettingsApplied.subscribe((markerImageSettings) => { + markerImageSettingsPanelPopover.hide(); + this.modelValue = markerImageSettings; + this.propagateChange(this.modelValue); + this.cd.detectChanges(); + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 2416b32ade..12805131d5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -204,6 +204,18 @@ import { } from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; import { DataKeyConfigComponent } from '@home/components/widget/lib/settings/common/key/data-key-config.component'; import { WidgetSettingsComponent } from '@home/components/widget/lib/settings/common/widget/widget-settings.component'; +import { + DataLayerColorSettingsComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings.component'; +import { + DataLayerColorSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; +import { + MarkerImageSettingsComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings.component'; +import { + MarkerImageSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; @NgModule({ declarations: [ @@ -277,6 +289,10 @@ import { WidgetSettingsComponent } from '@home/components/widget/lib/settings/co MapLayerSettingsPanelComponent, MapLayerRowComponent, MapLayersComponent, + DataLayerColorSettingsComponent, + DataLayerColorSettingsPanelComponent, + MarkerImageSettingsComponent, + MarkerImageSettingsPanelComponent, MapDataLayerDialogComponent, MapDataLayerRowComponent, MapDataLayersComponent, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index a100ae7b18..15609560db 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6875,6 +6875,11 @@ "source": "Source", "additional-data-keys": "Additional data keys", "groups": "Groups", + "color": "Color", + "color-settings": "Color settings", + "color-type-constant": "Constant", + "color-type-function": "Function", + "color-function": "Color function", "marker": { "latitude-key": "Latitude key", "longitude-key": "Longitude key", @@ -6887,7 +6892,20 @@ "no-markers": "No markers configured", "add-marker": "Add marker", "marker-configuration": "Marker configuration", - "remove-marker": "Remove marker" + "remove-marker": "Remove marker", + "marker-type": "Marker type", + "marker-type-default": "Default", + "marker-type-image": "Image", + "image": "Image", + "marker-image": "Marker image", + "marker-image-type-image": "Constant", + "marker-image-type-function": "Function", + "custom-marker-image-size": "Custom marker image size", + "marker-image-function": "Function", + "marker-images": "Marker images", + "marker-offset": "Marker offset", + "offset-horizontal": "Horizontal", + "offset-vertical": "Vertical" }, "polygon": { "polygon-key": "Polygon key", From 304fd26be497af0eeb5d2c46ae37771d2a055d49 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 14 Jan 2025 17:40:22 +0200 Subject: [PATCH 015/127] UI: Map data layer appearance. --- .../widget/lib/maps/map-data-layer.ts | 320 +++++++++++++++--- .../components/widget/lib/maps/map.models.ts | 159 ++++++++- .../home/components/widget/lib/maps/map.ts | 80 ++++- ...data-layer-pattern-settings.component.html | 86 +++++ .../data-layer-pattern-settings.component.ts | 180 ++++++++++ .../map/map-data-layer-dialog.component.html | 31 ++ .../map/map-data-layer-dialog.component.ts | 35 +- .../map/map-data-layer-row.component.html | 5 + .../map/map-data-layer-row.component.scss | 2 +- .../map/map-data-layer-row.component.ts | 5 + .../common/widget-settings-common.module.ts | 4 + .../assets/locale/locale.constant-en_US.json | 21 +- 12 files changed, 846 insertions(+), 82 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts index 2b825e09be..0cabfe9fef 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -19,6 +19,9 @@ import { createColorMarkerURI, DataLayerColorSettings, DataLayerColorType, + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipTrigger, defaultBaseCirclesDataLayerSettings, defaultBaseMarkersDataLayerSettings, defaultBasePolygonsDataLayerSettings, @@ -37,14 +40,15 @@ import { MarkerImageType, MarkersDataLayerSettings, MarkerType, - PolygonsDataLayerSettings, + PolygonsDataLayerSettings, processTooltipTemplate, ShapeDataLayerSettings, TbCircleData, TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { FormattedData } from '@shared/models/widget.models'; -import { Observable, of } from 'rxjs'; +import { Datasource, FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; import { + createLabelFromPattern, guid, isDefined, isDefinedAndNotNull, @@ -55,37 +59,98 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import L, { LatLngBounds } from 'leaflet'; +import L, { LatLngBounds, PathOptions } from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { catchError, map } from 'rxjs/operators'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; abstract class TbDataLayerItem> { protected layer: L.Layer; + protected tooltip: L.Popup; - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: S, - protected dataLayer: L) { + protected constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: S, + protected dataLayer: L) { this.layer = this.create(data, dsData); + if (this.settings.tooltip?.show) { + this.createTooltip(data.$datasource); + this.updateTooltip(data, dsData); + } + this.createEventListeners(data, dsData); this.dataLayer.getFeatureGroup().addLayer(this.layer); } protected abstract create(data: FormattedData, dsData: FormattedData[]): L.Layer; + protected abstract unbindLabel(): void; + + protected abstract bindLabel(content: L.Content): void; + + protected abstract createEventListeners(data: FormattedData, dsData: FormattedData[]): void; + public abstract update(data: FormattedData, dsData: FormattedData[]): void; public remove() { + this.layer.off(); this.dataLayer.getFeatureGroup().removeLayer(this.layer); } - protected updateLayer(newLayer: L.Layer) { - this.dataLayer.getFeatureGroup().removeLayer(this.layer); - this.layer = newLayer; - this.dataLayer.getFeatureGroup().addLayer(this.layer); + protected updateTooltip(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.tooltip.show) { + let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); + tooltipTemplate = processTooltipTemplate(tooltipTemplate); + this.tooltip.setContent(tooltipTemplate); + if (this.tooltip.isOpen() && this.tooltip.getElement()) { + this.bindTooltipActions(data.$datasource); + } + } + } + + protected updateLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.unbindLabel(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
${label}
`; + this.bindLabel(content); + } + } + + private createTooltip(datasource: TbMapDatasource) { + this.tooltip = L.popup(); + this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); + if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { + this.layer.off('click'); + this.layer.on('mouseover', () => { + this.layer.openPopup(); + }); + this.layer.on('mousemove', (e) => { + this.tooltip.setLatLng(e.latlng); + }); + this.layer.on('mouseout', () => { + this.layer.closePopup(); + }); + } + this.layer.on('popupopen', () => { + this.bindTooltipActions(datasource); + (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + } + + private bindTooltipActions(datasource: TbMapDatasource) { + const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); + Array.from(actions).forEach( + (element: HTMLElement) => { + const actionName = element.getAttribute('data-action-name'); + this.dataLayer.getMap().tooltipElementClick(element, actionName, datasource); + }); } } @@ -96,6 +161,76 @@ export enum MapDataLayerType { circle = 'circle' } +class DataLayerPatternProcessor { + + private patternFunction: CompiledTbFunction; + private pattern: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerPatternSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerPatternType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.patternFunction = parsed; + return null; + }) + ); + } else { + this.pattern = this.settings.pattern; + return of(null) + } + } + + public processPattern(data: FormattedData, dsData: FormattedData[]): string { + let pattern: string; + if (this.settings.type === DataLayerPatternType.function) { + pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); + } else { + pattern = this.pattern; + } + const text = createLabelFromPattern(pattern, data); + const customTranslate = this.dataLayer.getCtx().$injector.get(CustomTranslatePipe); + return customTranslate.transform(text); + } + +} + +class DataLayerColorProcessor { + + private colorFunction: CompiledTbFunction; + private color: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerColorSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.colorFunction = parsed; + return null; + }) + ); + } else { + this.color = this.settings.color; + return of(null) + } + } + + public processColor(data: FormattedData, dsData: FormattedData[]): string { + let color: string; + if (this.settings.type === DataLayerColorType.function) { + color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); + } else { + color = this.color; + } + return color; + } + +} + export abstract class TbMapDataLayer> implements L.TB.DataLayer { protected settings: S; @@ -112,6 +247,9 @@ export abstract class TbMapDataLayer, inputSettings: S) { this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); @@ -120,15 +258,22 @@ export abstract class TbMapDataLayer { + public setup(): Observable { this.datasource = mapDataSourceSettingsToDatasource(this.settings); this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; this.mapDataId = this.datasource.mapDataIds[0]; this.datasource = this.setupDatasource(this.datasource); - return this.doSetup(); + return forkJoin( + [ + this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), + this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), + this.doSetup() + ]); } public getDatasource(): TbMapDatasource { @@ -192,6 +337,9 @@ export abstract class TbMapDataLayer { + return this.map; + } protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { return datasource; @@ -205,7 +353,7 @@ export abstract class TbMapDataLayer; - protected abstract doSetup(): Observable; + protected abstract doSetup(): Observable; protected abstract isValidLayerData(layerData: FormattedData): boolean; @@ -236,12 +384,26 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { + this.dataLayer.getMap().markerClick(this.marker, data.$datasource); + } + + protected unbindLabel() { + this.marker.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const position = this.dataLayer.extractLocation(data); if (!this.marker.getLatLng().equals(position)) { this.location = position; this.marker.setLatLng(position); } + this.updateTooltip(data, dsData); this.updateMarkerIcon(data, dsData); } @@ -255,15 +417,10 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]) { - - } - } abstract class MarkerIconProcessor { @@ -448,11 +605,10 @@ export class TbMarkersDataLayer extends TbMapDataLayer { + private polygonContainer: L.FeatureGroup; private polygon: L.Polygon; constructor(data: FormattedData, @@ -532,29 +689,41 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; + const style = this.dataLayer.getShapeStyle(data, dsData); this.polygon = polyConstructor(polyData, { - fill: true, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); - return this.polygon; + + this.polygonContainer = L.featureGroup(); + this.polygon.addTo(this.polygonContainer); + + this.updateLabel(data, dsData); + return this.polygonContainer; + } + + protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { + this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); + } + + protected unbindLabel() { + this.polygonContainer.unbindTooltip(); } + + protected bindLabel(content: L.Content): void { + this.polygonContainer.bindTooltip(content, {className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.polygonContainer.getBounds().getCenter()); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const polyData = this.dataLayer.extractPolygonCoordinates(data); + const style = this.dataLayer.getShapeStyle(data, dsData); if (isCutPolygon(polyData) || polyData.length !== 2) { if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); this.polygon = L.polygon(polyData, { - fill: true, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); - this.updateLayer(this.polygon); + this.polygon.addTo(this.polygonContainer); } else { this.polygon.setLatLngs(polyData); } @@ -563,10 +732,44 @@ class TbPolygonDataLayerItem extends TbDataLayerItem> extends TbMapDataLayer { + + public fillColorProcessor: DataLayerColorProcessor; + public strokeColorProcessor: DataLayerColorProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + super(map, inputSettings); + } + + protected doSetup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); + this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); + return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); + } + + public getShapeStyle(data: FormattedData, dsData: FormattedData[]): L.PathOptions { + const fill = this.fillColorProcessor.processColor(data, dsData); + const stroke = this.strokeColorProcessor.processColor(data, dsData); + const style: L.PathOptions = { + fill: true, + fillColor: fill, + color: stroke, + weight: this.settings.strokeWeight, + fillOpacity: 1, + opacity: 1 + }; + return style; } } -export class TbPolygonsDataLayer extends TbMapDataLayer { +export class TbPolygonsDataLayer extends TbShapesDataLayer { constructor(protected map: TbMap, inputSettings: PolygonsDataLayerSettings) { @@ -586,8 +789,8 @@ export class TbPolygonsDataLayer extends TbMapDataLayer { - return of(null); + protected doSetup(): Observable { + return super.doSetup(); } protected isValidLayerData(layerData: FormattedData): boolean { @@ -622,17 +825,28 @@ class TbCircleDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); + const style = this.dataLayer.getShapeStyle(data, dsData); this.circle = L.circle(center, { radius: circleData.radius, - fillColor: '#3a77e7', - color: '#0742ad', - weight: 1, - fillOpacity: 0.4, - opacity: 1 + ...style }); + this.updateLabel(data, dsData); return this.circle; } + protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { + this.dataLayer.getMap().circleClick(this.circle, data.$datasource); + } + + protected unbindLabel() { + this.circle.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.circle.bindTooltip(content, { className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.circle.getLatLng()); + } + public update(data: FormattedData, dsData: FormattedData[]): void { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); @@ -642,10 +856,14 @@ class TbCircleDataLayerItem extends TbDataLayerItem { +export class TbCirclesDataLayer extends TbShapesDataLayer { constructor(protected map: TbMap, inputSettings: CirclesDataLayerSettings) { @@ -666,7 +884,7 @@ export class TbCirclesDataLayer extends TbMapDataLayer { - return of(null); + return super.doSetup(); } protected isValidLayerData(layerData: FormattedData): boolean { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts index f575576fc6..76264946e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts @@ -46,6 +46,7 @@ export enum MapType { export interface MapDataSourceSettings { dsType: DatasourceType; + dsLabel?: string; dsDeviceId?: string; dsEntityAliasId?: string; dsFilterId?: string; @@ -58,6 +59,7 @@ export interface TbMapDatasource extends Datasource { export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSettings): TbMapDatasource => { return { type: settings.dsType, + name: settings.dsLabel, deviceId: settings.dsDeviceId, entityAliasId: settings.dsEntityAliasId, filterId: settings.dsFilterId, @@ -66,11 +68,64 @@ export const mapDataSourceSettingsToDatasource = (settings: MapDataSourceSetting }; }; + +export enum DataLayerPatternType { + pattern = 'pattern', + function = 'function' +} + +export interface DataLayerPatternSettings { + show: boolean; + type: DataLayerPatternType; + pattern?: string; + patternFunction?: TbFunction; +} + +export enum DataLayerTooltipTrigger { + click = 'click', + hover = 'hover' +} + +export const dataLayerTooltipTriggers = Object.keys(DataLayerTooltipTrigger) as DataLayerTooltipTrigger[]; + +export const dataLayerTooltipTriggerTranslationMap = new Map( + [ + [DataLayerTooltipTrigger.click, 'widgets.maps.data-layer.tooltip-trigger-click'], + [DataLayerTooltipTrigger.hover, 'widgets.maps.data-layer.tooltip-trigger-hover'] + ] +); + +export interface DataLayerTooltipSettings extends DataLayerPatternSettings { + trigger: DataLayerTooltipTrigger; + autoclose: boolean; + offsetX: number; + offsetY: number; +} + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; + label: DataLayerPatternSettings; + tooltip: DataLayerTooltipSettings; groups?: string[]; } +export const defaultBaseDataLayerSettings: Partial = { + label: { + show: true, + type: DataLayerPatternType.pattern, + pattern: '${entityName}' + }, + tooltip: { + show: true, + trigger: DataLayerTooltipTrigger.click, + autoclose: true, + type: DataLayerPatternType.pattern, + pattern: '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details', + offsetX: 0, + offsetY: -1 + } +} + export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; export const mapDataLayerValid = (dataLayer: MapDataLayerSettings, type: MapDataLayerType): boolean => { @@ -192,6 +247,7 @@ const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First point' : '', xKey: { name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), label: MapType.geoMap === mapType ? 'latitude' : 'xPos', @@ -210,27 +266,34 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly } } as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings as MarkersDataLayerSettings); -export const defaultBaseMarkersDataLayerSettings: Partial = { +export const defaultBaseMarkersDataLayerSettings: Partial = mergeDeep({ markerType: MarkerType.default, markerColor: { type: DataLayerColorType.constant, - color: '#FE7569', + color: '#307FE5', }, markerImage: { type: MarkerImageType.image, - image: createColorMarkerURI(tinycolor('#FE7569')), + image: createColorMarkerURI(tinycolor('#307FE5')), imageSize: 34 }, markerOffsetX: 0.5, markerOffsetY: 1 -}; +} as MarkersDataLayerSettings, defaultBaseDataLayerSettings); + +export interface ShapeDataLayerSettings extends MapDataLayerSettings { + fillColor: DataLayerColorSettings; + strokeColor: DataLayerColorSettings; + strokeWeight: number; +} -export interface PolygonsDataLayerSettings extends MapDataLayerSettings { +export interface PolygonsDataLayerSettings extends ShapeDataLayerSettings { polygonKey: DataKey; } export const defaultPolygonsDataLayerSettings = (functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First polygon' : '', polygonKey: { name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', @@ -240,16 +303,26 @@ export const defaultPolygonsDataLayerSettings = (functionsOnly = false): Polygon } } as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings as PolygonsDataLayerSettings); -export const defaultBasePolygonsDataLayerSettings: Partial = { - -} - -export interface CirclesDataLayerSettings extends MapDataLayerSettings { +export const defaultBasePolygonsDataLayerSettings: Partial = mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings, + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) + +export interface CirclesDataLayerSettings extends ShapeDataLayerSettings { circleKey: DataKey; } export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'First circle' : '', circleKey: { name: functionsOnly ? 'f(x)' : 'perimeter', label: 'perimeter', @@ -259,9 +332,18 @@ export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesD } } as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings as CirclesDataLayerSettings); -export const defaultBaseCirclesDataLayerSettings: Partial = { - -} +export const defaultBaseCirclesDataLayerSettings: Partial = mergeDeep({ + fillColor: { + type: DataLayerColorType.constant, + color: 'rgba(51,136,255,0.2)', + }, + strokeColor: { + type: DataLayerColorType.constant, + color: '#3388ff', + }, + strokeWeight: 3 +} as Partial, defaultBaseDataLayerSettings, + {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { switch (dataLayerType) { @@ -274,6 +356,17 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map } }; +export const defaultBaseMapDataLayerSettings = (dataLayerType: MapDataLayerType): T => { + switch (dataLayerType) { + case 'markers': + return defaultBaseMarkersDataLayerSettings as T; + case 'polygons': + return defaultBasePolygonsDataLayerSettings as T; + case 'circles': + return defaultBaseCirclesDataLayerSettings as T; + } +} + export interface AdditionalMapDataSourceSettings extends MapDataSourceSettings { dataKeys: DataKey[]; } @@ -617,6 +710,8 @@ export type MapSetting = GeoMapSettings & ImageMapSettings; export const defaultMapSettings: MapSetting = defaultGeoMapSettings; +export type MapActionHandler = ($event: Event, datasource: TbMapDatasource) => void; + export interface MarkerImageInfo { url: string; size: number; @@ -722,7 +817,7 @@ const mapDatasourceIsSame = (ds1: TbMapDatasource, ds2: TbMapDatasource): boolea if (ds1.type === ds2.type) { switch (ds1.type) { case DatasourceType.function: - return true; + return ds1.name === ds2.name; case DatasourceType.device: case DatasourceType.entity: if (ds1.filterId === ds2.filterId) { @@ -804,3 +899,39 @@ export const loadImageWithAspect = (imagePipe: ImagePipe, imageUrl: string): Obs return of(null); } }; + +const linkActionRegex = /([^<]*)<\/link-act>/g; +const buttonActionRegex = /([^<]*)<\/button-act>/g; + +const createTooltipLinkElement = (actionName: string, actionText: string): string => { + return `
${actionText}`; +} + +const creatTooltipButtonElement = (actionName: string, actionText: string): string => { + return ``; +} + +export const processTooltipTemplate = (template: string): string => { + let actionTags: string; + let actionText: string; + let actionName: string; + let action: string; + + let match = linkActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = createTooltipLinkElement(actionName, actionText); + template = template.replace(actionTags, action); + match = linkActionRegex.exec(template); + } + + match = buttonActionRegex.exec(template); + while (match !== null) { + [actionTags, actionName, actionText] = match; + action = creatTooltipButtonElement(actionName, actionText); + template = template.replace(actionTags, action); + match = buttonActionRegex.exec(template); + } + + return template; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 973d035683..a85406daa9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -22,7 +22,7 @@ import { defaultImageMapSettings, GeoMapSettings, ImageMapSettings, - latLngPointToBounds, + latLngPointToBounds, MapActionHandler, MapSetting, MapType, MapZoomAction, @@ -34,7 +34,7 @@ import { import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; -import L, { LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; +import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, PointExpression, Projection } from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { map, switchMap, tap } from 'rxjs/operators'; @@ -47,7 +47,7 @@ import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { widgetType } from '@shared/models/widget.models'; +import { Datasource, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; @@ -82,12 +82,23 @@ export abstract class TbMap { private readonly mapResize$: ResizeObserver; + private readonly tooltipActions: { [name: string]: MapActionHandler }; + private readonly markerClickActions: { [name: string]: MapActionHandler }; + private readonly polygonClickActions: { [name: string]: MapActionHandler }; + private readonly circleClickActions: { [name: string]: MapActionHandler }; + private tooltipInstances: ITooltipsterInstance[] = []; protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); + + this.tooltipActions = this.loadActions('tooltipAction'); + this.markerClickActions = this.loadActions('markerClick'); + this.polygonClickActions = this.loadActions('polygonClick'); + this.circleClickActions = this.loadActions('circleClick'); + $(containerElement).empty(); $(containerElement).addClass('tb-map-layout'); const mapElement = $('
'); @@ -331,12 +342,33 @@ export abstract class TbMap { if (this.settings.useDefaultCenterPosition) { bounds = bounds.extend(this.defaultCenterPosition); } - this.map.fitBounds(bounds, { padding: [10, 10], animate: false }); + this.map.fitBounds(bounds, { padding: [50, 50], animate: false }); this.map.invalidateSize(); } } } + private loadActions(name: string): { [name: string]: MapActionHandler } { + const descriptors = this.ctx.actionsApi.getActionDescriptors(name); + const actions: { [name: string]: MapActionHandler } = {}; + descriptors.forEach(descriptor => { + actions[descriptor.name] = ($event: Event, datasource: TbMapDatasource) => this.onCustomAction(descriptor, $event, datasource); + }); + return actions; + } + + private onCustomAction(descriptor: WidgetActionDescriptor, $event: Event, entityInfo: TbMapDatasource) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + const { entityId, entityName, entityLabel, entityType } = entityInfo; + this.ctx.actionsApi.handleWidgetAction($event, descriptor, { + entityType, + id: entityId + }, entityName, null, entityLabel); + } + protected abstract defaultSettings(): S; protected abstract createMap(): Observable; @@ -370,6 +402,46 @@ export abstract class TbMap { return this.settings.mapType; } + public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { + if (element && this.tooltipActions[action]) { + element.onclick = ($event) => + { + this.tooltipActions[action]($event, datasource); + return false; + }; + } + } + + public markerClick(marker: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.markerClickActions).length) { + marker.on('click', (event: LeafletMouseEvent) => { + for (const action in this.markerClickActions) { + this.markerClickActions[action](event.originalEvent, datasource); + } + }); + } + } + + public polygonClick(polygon: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.polygonClickActions).length) { + polygon.on('click', (event: LeafletMouseEvent) => { + for (const action in this.polygonClickActions) { + this.polygonClickActions[action](event.originalEvent, datasource); + } + }); + } + } + + public circleClick(circle: L.Layer, datasource: TbMapDatasource): void { + if (Object.keys(this.circleClickActions).length) { + circle.on('click', (event: LeafletMouseEvent) => { + for (const action in this.circleClickActions) { + this.circleClickActions[action](event.originalEvent, datasource); + } + }); + } + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html new file mode 100644 index 0000000000..892558b047 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -0,0 +1,86 @@ + + +
+ + + +
+ + {{ (patternType === 'label' ? 'widgets.maps.data-layer.label' : 'widgets.maps.data-layer.tooltip') | translate }} + + + {{ 'widgets.maps.data-layer.pattern-type-pattern' | translate }} + {{ 'widgets.maps.data-layer.pattern-type-function' | translate }} + +
+
+
+ + + + + + +
+
widgets.maps.data-layer.tooltip-trigger
+ + + + {{ dataLayerTooltipTriggerTranslationMap.get(trigger) | translate }} + + + +
+
+ + {{ 'widgets.maps.data-layer.auto-close-tooltips' | translate }} + +
+
+
widgets.maps.data-layer.tooltip-offset
+
+
widgets.maps.data-layer.tooltip-offset-horizontal
+ + + +
widgets.maps.data-layer.tooltip-offset-vertical
+ + + +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts new file mode 100644 index 0000000000..204e1db59a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -0,0 +1,180 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { merge } from 'rxjs'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap +} from '@home/components/widget/lib/maps/map.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-data-layer-pattern-settings', + templateUrl: './data-layer-pattern-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + } + ] +}) +export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + DataLayerPatternType = DataLayerPatternType; + + dataLayerTooltipTriggers = dataLayerTooltipTriggers; + + dataLayerTooltipTriggerTranslationMap = dataLayerTooltipTriggerTranslationMap; + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + @Input() + patternType: 'label' | 'tooltip' = 'label'; + + @Input() + @coerceBoolean() + hasTooltipOffset = false; + + private modelValue: DataLayerPatternSettings | DataLayerTooltipSettings; + + private propagateChange = null; + + public patternSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.patternSettingsFormGroup = this.fb.group({ + show: [null, []], + type: [null, []], + pattern: [null, [Validators.required]], + patternFunction: [null, [Validators.required]] + }); + if (this.patternType === 'tooltip') { + this.patternSettingsFormGroup.addControl('trigger', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('autoclose', this.fb.control(null, [])); + if (this.hasTooltipOffset) { + this.patternSettingsFormGroup.addControl('offsetX', this.fb.control(null, [])); + this.patternSettingsFormGroup.addControl('offsetY', this.fb.control(null, [])); + } + } + this.patternSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.patternSettingsFormGroup.get('show').valueChanges, + this.patternSettingsFormGroup.get('type').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.patternSettingsFormGroup.disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: DataLayerPatternSettings | DataLayerTooltipSettings): void { + this.modelValue = value; + this.patternSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.settingsExpanded = this.patternSettingsFormGroup.get('show').value; + this.patternSettingsFormGroup.get('show').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((show) => { + this.settingsExpanded = show; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.patternSettingsFormGroup.valid; + return valid ? null : { + [this.patternType]: { + valid: false, + }, + }; + } + + private updateValidators() { + const show: boolean = this.patternSettingsFormGroup.get('show').value; + const type: DataLayerPatternType = this.patternSettingsFormGroup.get('type').value; + if (show) { + this.patternSettingsFormGroup.enable({emitEvent: false}); + if (type === DataLayerPatternType.pattern) { + this.patternSettingsFormGroup.get('pattern').enable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').disable({emitEvent: false}); + } else { + this.patternSettingsFormGroup.get('pattern').disable({emitEvent: false}); + this.patternSettingsFormGroup.get('patternFunction').enable({emitEvent: false}); + } + } else { + this.patternSettingsFormGroup.disable({emitEvent: false}); + this.patternSettingsFormGroup.get('show').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.patternSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 711a004e4e..9ecaac60a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -34,6 +34,12 @@
+ + datasource.label + +
+ +
+
widgets.maps.data-layer.fill-color
+ +
+
+
widgets.maps.data-layer.stroke
+
+ + + px + + +
+
+
+ + + +
+
DataKey = (key) => key; + private requiredValue: boolean; get required(): boolean { return this.requiredValue || !this.optDataKeys || this.isCountDatasource; @@ -222,7 +242,6 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange private keysValidator = this._keysValidator.bind(this); constructor(@SkipSelf() private errorStateMatcher: ErrorStateMatcher, - private datasourceComponent: DatasourceComponent, private translate: TranslateService, private utils: UtilsService, private dialog: MatDialog, @@ -463,8 +482,13 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange } private addFromChipValue(chip: DataKey) { - const key = this.callbacks.generateDataKey(chip.name, chip.type, this.dataKeySettingsForm, this.latestDataKeys, - this.datakeySettingsFunction); + let key: DataKey; + if (this.generateKey) { + key = this.generateKey(chip); + } else { + key = this.callbacks.generateDataKey(chip.name, chip.type, this.dataKeySettingsForm, this.latestDataKeys, + this.datakeySettingsFunction); + } this.addKey(key); } @@ -687,7 +711,7 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange } get dragDisabled(): boolean { - return this.keys.length < 2; + return this.keys.length < 2 || this.disableDrag; } get maxDataKeysSet(): boolean { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html new file mode 100644 index 0000000000..872b94a20a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -0,0 +1,198 @@ + +
+ +

{{dialogTitle}}

+ + +
+
+
+
+
{{ 'widget-config.datasource' | translate }}
+ + {{ datasourceTypesTranslations.get(type) | translate }} + +
+
+ + + + + + +
+
+
+
{{ 'datakey.keys' | translate }}
+
+
+ + + + +
+ + + + + + +
+
+
+
{{ 'widget-config.appearance' | translate }}
+
+
+
{{ 'widgets.maps.data-layer.groups' | translate }}
+ + +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss new file mode 100644 index 0000000000..8fc035a151 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.scss @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-map-data-layer-dialog { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-trailing-icon-color: rgba(0,0,0,0.38); + .tb-inline-chips { + --mat-form-field-container-vertical-padding: 12px; + .mat-mdc-text-field-wrapper { + &.mdc-text-field--outlined { + padding-left: 16px; + padding-right: 16px; + .mat-mdc-form-field-infix { + .mdc-evolution-chip-set .mdc-evolution-chip { + margin: 0; + } + .mdc-evolution-chip-set__chips { + gap: 8px; + margin-left: 0; + } + input.mat-mdc-chip-input { + height: 32px; + margin-left: 0; + } + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts new file mode 100644 index 0000000000..9b7ea1b757 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -0,0 +1,239 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { + CirclesDataLayerSettings, + MapDataLayerSettings, + MapDataLayerType, + MapType, + MarkersDataLayerSettings, + PolygonsDataLayerSettings +} from '@home/components/widget/lib/maps/map.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { genNextLabelForDataKeys } from '@core/utils'; + +export interface MapDataLayerDialogData { + settings: MapDataLayerSettings; + mapType: MapType; + dataLayerType: MapDataLayerType; + context: MapSettingsContext; +} + +@Component({ + selector: 'tb-map-data-layer-dialog', + templateUrl: './map-data-layer-dialog.component.html', + styleUrls: ['./map-data-layer-dialog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapDataLayerDialogComponent extends DialogComponent { + + DatasourceType = DatasourceType; + + EntityType = EntityType; + + MapType = MapType; + + DataKeyType = DataKeyType; + + widgetType = widgetType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + dataLayerFormGroup: UntypedFormGroup; + + settings = this.data.settings; + mapType = this.data.mapType; + dataLayerType = this.data.dataLayerType; + context = this.data.context; + + generateAdditionalDataKey = this.generateDataKey.bind(this); + + dialogTitle: string; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private destroyRef: DestroyRef) { + super(store, router, dialogRef); + + if (this.context.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + + this.dataLayerFormGroup = this.fb.group({ + dsType: [this.settings.dsType, [Validators.required]], + dsDeviceId: [this.settings.dsDeviceId, [Validators.required]], + dsEntityAliasId: [this.settings.dsEntityAliasId, [Validators.required]], + dsFilterId: [this.settings.dsFilterId, []], + additionalDataKeys: [this.settings.additionalDataKeys, []], + groups: [this.settings.groups, []] + }); + + switch (this.dataLayerType) { + case 'markers': + const markersDataLayer = this.settings as MarkersDataLayerSettings; + this.dialogTitle = 'widgets.maps.data-layer.marker.marker-configuration'; + this.dataLayerFormGroup.addControl('xKey', this.fb.control(markersDataLayer.xKey, Validators.required)); + this.dataLayerFormGroup.addControl('yKey', this.fb.control(markersDataLayer.yKey, Validators.required)); + break; + case 'polygons': + const polygonsDataLayer = this.settings as PolygonsDataLayerSettings; + this.dialogTitle = 'widgets.maps.data-layer.polygon.polygon-configuration'; + this.dataLayerFormGroup.addControl('polygonKey', this.fb.control(polygonsDataLayer.polygonKey, Validators.required)); + break; + case 'circles': + const circlesDataLayer = this.settings as CirclesDataLayerSettings; + this.dialogTitle = 'widgets.maps.data-layer.circle.circle-configuration'; + this.dataLayerFormGroup.addControl('circleKey', this.fb.control(circlesDataLayer.circleKey, Validators.required)); + break; + } + this.dataLayerFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + this.updateValidators(); + } + + private onDsTypeChanged(newDsType: DatasourceType) { + switch (this.dataLayerType) { + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (this.updateDataKeyToNewDsType(xKey, newDsType)) { + this.dataLayerFormGroup.get('xKey').patchValue(xKey, {emitEvent: false}); + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (this.updateDataKeyToNewDsType(yKey, newDsType)) { + this.dataLayerFormGroup.get('yKey').patchValue(yKey, {emitEvent: false}); + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (this.updateDataKeyToNewDsType(polygonKey, newDsType)) { + this.dataLayerFormGroup.get('polygonKey').patchValue(polygonKey, {emitEvent: false}); + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (this.updateDataKeyToNewDsType(circleKey, newDsType)) { + this.dataLayerFormGroup.get('circleKey').patchValue(circleKey, {emitEvent: false}); + } + break; + } + this.updateValidators(); + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataLayerFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataLayerFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataLayerFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataLayerFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { + const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; + this.context.editKey(targetDataKey, + this.dataLayerFormGroup.get('dsDeviceId').value, this.dataLayerFormGroup.get('dsEntityAliasId').value).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + } + } + ); + } + + private generateDataKey(key: DataKey): DataKey { + const dataKey = this.context.callbacks.generateDataKey(key.name, key.type, null, false, null); + const dataKeys: DataKey[] = []; + switch (this.dataLayerType) { + case 'markers': + const xKey: DataKey = this.dataLayerFormGroup.get('xKey').value; + if (xKey) { + dataKeys.push(xKey); + } + const yKey: DataKey = this.dataLayerFormGroup.get('yKey').value; + if (yKey) { + dataKeys.push(yKey); + } + break; + case 'polygons': + const polygonKey: DataKey = this.dataLayerFormGroup.get('polygonKey').value; + if (polygonKey) { + dataKeys.push(polygonKey); + } + break; + case 'circles': + const circleKey: DataKey = this.dataLayerFormGroup.get('circleKey').value; + if (circleKey) { + dataKeys.push(circleKey); + } + break; + } + const additionalKeys: DataKey[] = this.dataLayerFormGroup.get('additionalDataKeys').value; + if (additionalKeys) { + dataKeys.push(...additionalKeys); + } + dataKey.label = genNextLabelForDataKeys(dataKey.label, dataKeys); + return dataKey; + } + + cancel(): void { + this.dialogRef.close(null); + } + + save(): void { + const settings: MapDataLayerSettings = this.dataLayerFormGroup.getRawValue(); + this.dialogRef.close(settings); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html index fcbda3bead..e55df4c8f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.html @@ -38,9 +38,9 @@ *ngIf="dataLayerFormGroup.get('dsType').value === DatasourceType.entity" inlineField tbRequired - [aliasController]="aliasController" + [aliasController]="context.aliasController" formControlName="dsEntityAliasId" - [callbacks]="entityAliasSelectCallbacks"> + [callbacks]="context.callbacks">
@@ -67,11 +68,12 @@ [datasourceType]="dataLayerFormGroup.get('dsType').value" [entityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [deviceId]="dataLayerFormGroup.get('dsDeviceId').value" - [aliasController]="aliasController" - [dataKeyType]="functionsOnly ? DataKeyType.function : null" + [aliasController]="context.aliasController" + [widgetType]="widgetType.latest" + [dataKeyType]="context.functionsOnly ? DataKeyType.function : null" [dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]" - [callbacks]="dataKeyCallbacks" - [generateKey]="generateDataKey" + [callbacks]="context.callbacks" + [generateKey]="context.generateDataKey" (keyEdit)="editKey('yKey')" formControlName="yKey"> @@ -83,11 +85,12 @@ [datasourceType]="dataLayerFormGroup.get('dsType').value" [entityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [deviceId]="dataLayerFormGroup.get('dsDeviceId').value" - [aliasController]="aliasController" - [dataKeyType]="functionsOnly ? DataKeyType.function : null" + [aliasController]="context.aliasController" + [widgetType]="widgetType.latest" + [dataKeyType]="context.functionsOnly ? DataKeyType.function : null" [dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]" - [callbacks]="dataKeyCallbacks" - [generateKey]="generateDataKey" + [callbacks]="context.callbacks" + [generateKey]="context.generateDataKey" (keyEdit)="editKey('polygonKey')" formControlName="polygonKey"> @@ -99,11 +102,12 @@ [datasourceType]="dataLayerFormGroup.get('dsType').value" [entityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [deviceId]="dataLayerFormGroup.get('dsDeviceId').value" - [aliasController]="aliasController" - [dataKeyType]="functionsOnly ? DataKeyType.function : null" + [aliasController]="context.aliasController" + [widgetType]="widgetType.latest" + [dataKeyType]="context.functionsOnly ? DataKeyType.function : null" [dataKeyTypes]="[DataKeyType.attribute, DataKeyType.timeseries]" - [callbacks]="dataKeyCallbacks" - [generateKey]="generateDataKey" + [callbacks]="context.callbacks" + [generateKey]="context.generateDataKey" (keyEdit)="editKey('circleKey')" formControlName="circleKey"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts index 1d9d2014e2..814796cf09 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts @@ -23,8 +23,6 @@ import { Input, OnInit, Output, - Renderer2, - ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { @@ -35,8 +33,6 @@ import { Validators } from '@angular/forms'; import { MatButton } from '@angular/material/button'; -import { TbPopoverService } from '@shared/components/popover.service'; -import { TranslateService } from '@ngx-translate/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CirclesDataLayerSettings, @@ -46,27 +42,17 @@ import { MarkersDataLayerSettings, PolygonsDataLayerSettings } from '@home/components/widget/lib/maps/map.models'; -import { - DataKey, - DataKeyConfigMode, - DatasourceType, - datasourceTypeTranslationMap, - widgetType -} from '@shared/models/widget.models'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; -import { IAliasController } from '@core/api/widget-api.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; -import { - DataKeyConfigDialogComponent, - DataKeyConfigDialogData -} from '@home/components/widget/config/data-key-config-dialog.component'; import { deepClone } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; import { - EntityAliasSelectCallbacks -} from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; + MapDataLayerDialogComponent, + MapDataLayerDialogData +} from '@home/components/widget/lib/settings/common/map/map-data-layer-dialog.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ selector: 'tb-map-data-layer-row', @@ -90,6 +76,8 @@ export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { MapType = MapType; + widgetType = widgetType; + datasourceTypes: Array = []; datasourceTypesTranslations = datasourceTypeTranslationMap; @@ -102,27 +90,12 @@ export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { @Input() dataLayerType: MapDataLayerType = 'markers'; - get functionsOnly(): boolean { - return this.mapSettingsComponent.functionsOnly; - } - - get aliasController(): IAliasController { - return this.mapSettingsComponent.aliasController; - } - - get dataKeyCallbacks(): DataKeysCallbacks { - return this.mapSettingsComponent.callbacks; - } - - public get entityAliasSelectCallbacks(): EntityAliasSelectCallbacks { - return this.mapSettingsComponent.callbacks; - } + @Input() + context: MapSettingsContext; @Output() dataLayerRemoved = new EventEmitter(); - generateDataKey = this._generateDataKey.bind(this); - dataLayerFormGroup: UntypedFormGroup; modelValue: MapDataLayerSettings; @@ -136,16 +109,12 @@ export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { constructor(private mapSettingsComponent: MapSettingsComponent, private fb: UntypedFormBuilder, private dialog: MatDialog, - private translate: TranslateService, - private popoverService: TbPopoverService, - private renderer: Renderer2, - private viewContainerRef: ViewContainerRef, private cd: ChangeDetectorRef, private destroyRef: DestroyRef) { } ngOnInit() { - if (this.functionsOnly) { + if (this.context.functionsOnly) { this.datasourceTypes = [DatasourceType.function]; } else { this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; @@ -244,66 +213,37 @@ export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { editKey(keyType: 'xKey' | 'yKey' | 'polygonKey' | 'circleKey') { const targetDataKey: DataKey = this.dataLayerFormGroup.get(keyType).value; - this.dialog.open(DataKeyConfigDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - dataKey: deepClone(targetDataKey), - dataKeyConfigMode: DataKeyConfigMode.general, - aliasController: this.aliasController, - widgetType: widgetType.latest, - deviceId: this.dataLayerFormGroup.get('dsDeviceId').value, - entityAliasId: this.dataLayerFormGroup.get('dsEntityAliasId').value, - showPostProcessing: true, - callbacks: this.mapSettingsComponent.callbacks, - hideDataKeyColor: true, - hideDataKeyDecimals: true, - hideDataKeyUnits: true, - widget: this.mapSettingsComponent.widget, - dashboard: null, - dataKeySettingsForm: null, - dataKeySettingsDirective: null + this.context.editKey(targetDataKey, + this.dataLayerFormGroup.get('dsDeviceId').value, this.dataLayerFormGroup.get('dsEntityAliasId').value).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); } - }).afterClosed().subscribe((updatedDataKey) => { - if (updatedDataKey) { - this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); } - }); + ); } editDataLayer($event: Event, matButton: MatButton) { if ($event) { $event.stopPropagation(); } - const trigger = matButton._elementRef.nativeElement; - if (this.popoverService.hasPopover(trigger)) { - this.popoverService.hidePopover(trigger); - } else { - /*const ctx: any = { - mapLayerSettings: deepClone(this.modelValue) - }; - const mapLayerSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, - this.viewContainerRef, MapLayerSettingsPanelComponent, ['leftOnly', 'leftTopOnly', 'leftBottomOnly'], true, null, - ctx, - {}, - {}, {}, true); - mapLayerSettingsPanelPopover.tbComponentRef.instance.popover = mapLayerSettingsPanelPopover; - mapLayerSettingsPanelPopover.tbComponentRef.instance.mapLayerSettingsApplied.subscribe((layer) => { - mapLayerSettingsPanelPopover.hide(); - this.layerFormGroup.patchValue( - layer, - {emitEvent: false}); + this.dialog.open(MapDataLayerDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + settings: deepClone(this.modelValue), + mapType: this.mapType, + dataLayerType: this.dataLayerType, + context: this.context + } + }).afterClosed().subscribe((settings) => { + if (settings) { + this.modelValue = settings; + this.dataLayerFormGroup.patchValue(settings); this.updateValidators(); - this.updateModel(); - });*/ - } - } - - private _generateDataKey(key: DataKey): DataKey { - key = this.dataKeyCallbacks.generateDataKey(key.name, key.type, null, false, - null); - return key; + } + }); } private onDsTypeChanged(newDsType: DatasourceType) { @@ -375,6 +315,4 @@ export class MapDataLayerRowComponent implements ControlValueAccessor, OnInit { this.modelValue = {...this.modelValue, ...this.dataLayerFormGroup.value}; this.propagateChange(this.modelValue); } - - protected readonly datasourceType = DatasourceType; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html index 0c19ff6a9b..02bd08e7bd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.html @@ -34,6 +34,7 @@ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts index 5c8aa5534d..09d3c0a2dc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -37,6 +37,7 @@ import { MapType } from '@home/components/widget/lib/maps/map.models'; import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ selector: 'tb-map-data-layers', @@ -69,9 +70,8 @@ export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Val @Input() dataLayerType: MapDataLayerType = 'markers'; - get functionsOnly(): boolean { - return this.mapSettingsComponent.functionsOnly; - } + @Input() + context: MapSettingsContext; dataLayersFormGroup: UntypedFormGroup; @@ -161,7 +161,7 @@ export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Val addDataLayer() { const dataLayer = mergeDeep({} as MapDataLayerSettings, - defaultMapDataLayerSettings(this.mapType, this.dataLayerType, this.functionsOnly)); + defaultMapDataLayerSettings(this.mapType, this.dataLayerType, this.context.functionsOnly)); const dataLayersArray = this.dataLayersFormGroup.get('dataLayers') as UntypedFormArray; const dataLayerControl = this.fb.control(dataLayer, [mapDataLayerValidator(this.dataLayerType)]); dataLayersArray.push(dataLayerControl); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index 9652daaf7b..dd0b91b11e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -44,14 +44,17 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts new file mode 100644 index 0000000000..931a424ca3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.models.ts @@ -0,0 +1,29 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { IAliasController } from '@core/api/widget-api.models'; +import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; +import { DataKey, Widget } from '@shared/models/widget.models'; +import { Observable } from 'rxjs'; + +export interface MapSettingsContext { + functionsOnly: boolean; + aliasController: IAliasController; + callbacks: WidgetConfigCallbacks; + widget: Widget; + editKey: (key: DataKey, deviceId: string, entityAliasId: string) => Observable; + generateDataKey: (key: DataKey) => DataKey; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 864a0f3ea9..ece61c0992 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -19,17 +19,25 @@ import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, - UntypedFormBuilder, UntypedFormControl, - UntypedFormGroup, Validator + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator } from '@angular/forms'; import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { merge } from 'rxjs'; +import { merge, Observable } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; import { IAliasController } from '@core/api/widget-api.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; -import { Widget } from '@shared/models/widget.models'; +import { DataKey, DataKeyConfigMode, Widget, widgetType } from '@shared/models/widget.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { + DataKeyConfigDialogComponent, + DataKeyConfigDialogData +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import { deepClone } from '@core/utils'; +import { MatDialog } from '@angular/material/dialog'; @Component({ selector: 'tb-map-settings', @@ -68,6 +76,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid @Input() widget: Widget; + context: MapSettingsContext; + private modelValue: MapSetting; private propagateChange = null; @@ -77,10 +87,21 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid dataLayerMode: MapDataLayerType = 'markers'; constructor(private fb: UntypedFormBuilder, + private dialog: MatDialog, private destroyRef: DestroyRef) { } ngOnInit(): void { + + this.context = { + functionsOnly: this.functionsOnly, + aliasController: this.aliasController, + callbacks: this.callbacks, + widget: this.widget, + editKey: this.editKey.bind(this), + generateDataKey: this.generateDataKey.bind(this) + }; + this.mapSettingsFormGroup = this.fb.group({ mapType: [null, []], layers: [null, []], @@ -176,4 +197,33 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid this.modelValue = this.mapSettingsFormGroup.getRawValue(); this.propagateChange(this.modelValue); } + + private editKey(key: DataKey, deviceId: string, entityAliasId: string): Observable { + return this.dialog.open(DataKeyConfigDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + dataKey: deepClone(key), + dataKeyConfigMode: DataKeyConfigMode.general, + aliasController: this.aliasController, + widgetType: widgetType.latest, + deviceId, + entityAliasId, + showPostProcessing: true, + callbacks: this.callbacks, + hideDataKeyColor: true, + hideDataKeyDecimals: true, + hideDataKeyUnits: true, + widget: this.widget, + dashboard: null, + dataKeySettingsForm: null, + dataKeySettingsDirective: null + } + }).afterClosed(); + } + + private generateDataKey(key: DataKey): DataKey { + return this.callbacks.generateDataKey(key.name, key.type, null, false, null); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts index a541291b08..807e38019b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts @@ -29,7 +29,7 @@ import { AppState } from '@core/core.state'; import { IAliasController } from '@core/api/widget-api.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { DataKey, Datasource, DatasourceType } from '@app/shared/models/widget.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { ValueSourceConfig, ValueSourceType, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 7018862d08..2416b32ade 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -102,7 +102,7 @@ import { import { TimeSeriesChartThresholdRowComponent } from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component'; -import { DataKeyInputComponent } from '@home/components/widget/lib/settings/common/data-key-input.component'; +import { DataKeyInputComponent } from '@home/components/widget/lib/settings/common/key/data-key-input.component'; import { EntityAliasInputComponent } from '@home/components/widget/lib/settings/common/entity-alias-input.component'; import { TimeSeriesChartThresholdSettingsPanelComponent @@ -191,10 +191,19 @@ import { } from '@home/components/widget/lib/settings/common/map/map-layer-settings-panel.component'; import { MapDataLayersComponent } from '@home/components/widget/lib/settings/common/map/map-data-layers.component'; import { MapDataLayerRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-layer-row.component'; -import { WidgetConfigComponentsModule } from '@home/components/widget/config/widget-config-components.module'; import { EntityAliasSelectComponent } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component'; +import { + MapDataLayerDialogComponent +} from '@home/components/widget/lib/settings/common/map/map-data-layer-dialog.component'; +import { FilterSelectComponent } from '@home/components/widget/lib/settings/common/filter/filter-select.component'; +import { DataKeysComponent } from '@home/components/widget/lib/settings/common/key/data-keys.component'; +import { + DataKeyConfigDialogComponent +} from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; +import { DataKeyConfigComponent } from '@home/components/widget/lib/settings/common/key/data-key-config.component'; +import { WidgetSettingsComponent } from '@home/components/widget/lib/settings/common/widget/widget-settings.component'; @NgModule({ declarations: [ @@ -268,10 +277,16 @@ import { MapLayerSettingsPanelComponent, MapLayerRowComponent, MapLayersComponent, + MapDataLayerDialogComponent, MapDataLayerRowComponent, MapDataLayersComponent, MapSettingsComponent, - EntityAliasSelectComponent + EntityAliasSelectComponent, + FilterSelectComponent, + DataKeysComponent, + DataKeyConfigDialogComponent, + DataKeyConfigComponent, + WidgetSettingsComponent ], imports: [ CommonModule, @@ -347,7 +362,12 @@ import { DynamicFormComponent, DynamicFormArrayComponent, MapSettingsComponent, - EntityAliasSelectComponent + EntityAliasSelectComponent, + FilterSelectComponent, + DataKeysComponent, + DataKeyConfigDialogComponent, + DataKeyConfigComponent, + WidgetSettingsComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.html rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.scss similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.scss rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.scss diff --git a/ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/config/widget-settings.component.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html index 668613012e..ae42e2df2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html @@ -15,11 +15,10 @@ limitations under the License. --> - {{ (keyType === dataKeyType.attribute ? attributeLabel diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts index 200c4d749a..5c7c2f6052 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { IAliasController } from '@core/api/widget-api.models'; -import { DataKeysCallbacks } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { Datasource } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index 78dd827403..215a33cd4a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -81,7 +81,7 @@ import { ToggleHeaderOption } from '@shared/components/toggle-header.component'; import { coerceBoolean } from '@shared/decorators/coercion'; import { basicWidgetConfigComponentsMap } from '@home/components/widget/config/basic/basic-widget-config.module'; import { TimewindowConfigData } from '@home/components/widget/config/timewindow-config-panel.component'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { defaultFormProperties, FormProperty } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import Timeout = NodeJS.Timeout; diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index b1958cb3db..c599fbf16d 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -102,7 +102,7 @@ import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time- import { SharedTelemetrySubscriber, TelemetrySubscriber } from '@shared/models/telemetry/telemetry.models'; import { UserId } from '@shared/models/id/user-id'; import { UserSettingsService } from '@core/http/user-settings.service'; -import { DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { UtilsService } from '@core/services/utils.service'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { FormProperty } from '@shared/models/dynamic-form.models'; diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.html b/ui-ngx/src/app/shared/components/string-items-list.component.html index eb4c15fc18..cf8ba1d150 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.html +++ b/ui-ngx/src/app/shared/components/string-items-list.component.html @@ -16,7 +16,7 @@ --> diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.scss b/ui-ngx/src/app/shared/components/string-items-list.component.scss new file mode 100644 index 0000000000..70e487f998 --- /dev/null +++ b/ui-ngx/src/app/shared/components/string-items-list.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-string-items-list { + .mat-mdc-standard-chip { + .mdc-evolution-chip__cell--primary, .mat-mdc-chip-action-label { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.ts b/ui-ngx/src/app/shared/components/string-items-list.component.ts index 4121a7e8d7..b1447825ed 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.ts +++ b/ui-ngx/src/app/shared/components/string-items-list.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { AbstractControl, ControlValueAccessor, @@ -38,14 +38,15 @@ export interface StringItemsOption { @Component({ selector: 'tb-string-items-list', templateUrl: './string-items-list.component.html', - styleUrls: [], + styleUrls: ['./string-items-list.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StringItemsListComponent), multi: true } - ] + ], + encapsulation: ViewEncapsulation.None }) export class StringItemsListComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index bbb462d9af..8b6558a11f 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -42,7 +42,7 @@ import { WidgetConfigComponentData } from '@home/models/widget-component.models' import { ComponentStyle, Font, TimewindowStyle } from '@shared/models/widget-settings.models'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { HasTenantId, HasVersion } from '@shared/models/entity.models'; -import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/config/data-keys.component.models'; +import { DataKeysCallbacks, DataKeySettingsFunction } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-config.component.models'; import { TbFunction } from '@shared/models/js-function.models'; import { FormProperty, jsonFormSchemaToFormProperties } from '@shared/models/dynamic-form.models'; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 946f338ddd..7ac6789d6b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1349,6 +1349,7 @@ "general": "General", "advanced": "Advanced", "key": "Key", + "keys": "Keys", "label": "Label", "color": "Color", "units": "Special symbol to show next to value", @@ -6869,6 +6870,8 @@ }, "data-layer": { "source": "Source", + "additional-data-keys": "Additional data keys", + "groups": "Groups", "marker": { "latitude-key": "Latitude key", "longitude-key": "Longitude key", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index 9b8f357c18..d1ba09b66c 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -39,7 +39,7 @@ declare module 'leaflet' { class SidebarControl extends Control { constructor(options: SidebarControlOptions); - addPane(pane: JQuery): this; + addPane(pane: JQuery, button: JQuery): this; togglePane(pane: JQuery, button: JQuery): void; } @@ -70,12 +70,33 @@ declare module 'leaflet' { constructor(options: LayersControlOptions); } + interface DataLayer { + toggleGroup(group: string): boolean; + } + + interface GroupData { + title: string; + group: string; + enabled: boolean; + dataLayers: DataLayer[]; + } + + interface GroupsControlOptions extends SidebarPaneControlOptions { + groups: GroupData[]; + } + + class GroupsControl extends SidebarPaneControl { + constructor(options: GroupsControlOptions); + } + function sidebar(options: SidebarControlOptions): SidebarControl; function sidebarPane(options: O): SidebarPaneControl; function layers(options: LayersControlOptions): LayersControl; + function groups(options: GroupsControlOptions): GroupsControl; + namespace TileLayer { interface ChinaProvidersData { From 2e5031faecb57160001b24f562af3aeb696b0542 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 13 Jan 2025 19:44:52 +0200 Subject: [PATCH 014/127] UI: Marker settings. --- .../widget/lib/maps/leaflet/leaflet-tb.ts | 1 + .../widget/lib/maps/map-data-layer.ts | 16 +-- .../components/widget/lib/maps/map.models.ts | 21 +++- .../home/components/widget/lib/maps/map.scss | 58 +++++++-- .../home/components/widget/lib/maps/map.ts | 52 ++++++++ ...-layer-color-settings-panel.component.html | 69 +++++++++++ ...-layer-color-settings-panel.component.scss | 53 ++++++++ ...ta-layer-color-settings-panel.component.ts | 82 +++++++++++++ .../data-layer-color-settings.component.html | 30 +++++ .../data-layer-color-settings.component.ts | 113 ++++++++++++++++++ .../map/map-data-layer-dialog.component.html | 34 ++++++ .../map/map-data-layer-dialog.component.ts | 25 +++- ...marker-image-settings-panel.component.html | 72 +++++++++++ ...marker-image-settings-panel.component.scss | 53 ++++++++ .../marker-image-settings-panel.component.ts | 101 ++++++++++++++++ .../map/marker-image-settings.component.html | 28 +++++ .../map/marker-image-settings.component.ts | 96 +++++++++++++++ .../common/widget-settings-common.module.ts | 16 +++ .../assets/locale/locale.constant-en_US.json | 20 +++- 19 files changed, 916 insertions(+), 24 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index a4bb547ec7..84b0d2a688 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -99,6 +99,7 @@ class SidebarPaneControl extends L.Contr this.button = $("
") .attr('class', 'tb-control-button') .attr('href', '#') + .attr('role', 'button') .html('
') .on('click', (e) => { this.toggle(e); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts index 767e44c90c..2b825e09be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -16,6 +16,7 @@ import { CirclesDataLayerSettings, + createColorMarkerURI, DataLayerColorSettings, DataLayerColorType, defaultBaseCirclesDataLayerSettings, @@ -487,19 +488,6 @@ export class TbMarkersDataLayer extends TbMapDataLayer` + - ``; - return 'data:image/svg+xml;base64,' + btoa(svg); - } - public createDefaultMarkerIcon(): MarkerIconInfo { const color = this.settings.markerColor.color || '#FE7569'; return this.createColoredMarkerIcon(tinycolor(color)); @@ -509,7 +497,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer { + const svg = `` + + ``; + return 'data:image/svg+xml;base64,' + btoa(svg); +} export enum MapType { geoMap = 'geoMap', @@ -200,7 +214,12 @@ export const defaultBaseMarkersDataLayerSettings: Partial div { + background: $tb-primary-color; // primary color + } } > div { width: 30px; height: 30px; - color: #5B5B5B; + background: rgba(0, 0, 0, 0.54); line-height: 30px; - background-repeat: no-repeat; - background-position: center; + mask-repeat: no-repeat; + mask-position: center; &.tb-layers { - background-image: url('data:image/svg+xml,'); + mask-image: url('data:image/svg+xml,'); } &.tb-groups { - background-image: url('data:image/svg+xml,'); + mask-image: url('data:image/svg+xml,'); } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index b61f5c8afd..973d035683 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -50,6 +50,7 @@ import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget import { widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; export abstract class TbMap { @@ -81,6 +82,8 @@ export abstract class TbMap { private readonly mapResize$: ResizeObserver; + private tooltipInstances: ITooltipsterInstance[] = []; + protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { @@ -133,6 +136,7 @@ export abstract class TbMap { this.map.panTo(this.defaultCenterPosition); } this.setupDataLayers(); + this.createdControlButtonTooltip(); } private setupDataLayers() { @@ -229,6 +233,51 @@ export abstract class TbMap { } } + private createdControlButtonTooltip() { + import('tooltipster').then(() => { + if ($.tooltipster) { + this.tooltipInstances.forEach((instance) => { + instance.destroy(); + }); + this.tooltipInstances = []; + } + $(this.mapElement) + .find('a[role="button"]:not(.leaflet-pm-action)') + .each((_index, element) => { + let title: string; + if (element.title) { + title = element.title; + $(element).removeAttr('title'); + } else if (element.parentElement.title) { + title = element.parentElement.title; + $(element).parent().removeAttr('title'); + } + const tooltip = $(element).tooltipster( + { + content: title, + theme: 'tooltipster-shadow', + delay: 10, + triggerClose: { + click: true, + tap: true, + scroll: true, + mouseleave: true + }, + side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left', + distance: 2, + trackOrigin: true, + functionBefore: (instance, helper) => { + if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) { + return false; + } + }, + } + ); + this.tooltipInstances.push(tooltip.tooltipster('instance')); + }); + }); + } + private update(subscription: IWidgetSubscription) { const dsData = formattedDataFormDatasourceData(subscription.data, undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); @@ -328,6 +377,9 @@ export abstract class TbMap { if (this.map) { this.map.remove(); } + this.tooltipInstances.forEach((instance) => { + instance.destroy(); + }); } public abstract positionToLatLng(position: {x: number; y: number}): L.LatLng; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html new file mode 100644 index 0000000000..8b19ac16cd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html @@ -0,0 +1,69 @@ + +
+
widgets.maps.data-layer.color-settings
+
+ + + {{ 'widgets.maps.data-layer.color-type-constant' | translate }} + + + {{ 'widgets.maps.data-layer.color-type-function' | translate }} + + +
+
+
widgets.maps.data-layer.color
+ + +
+
+
+
+ +
+
+ + + +
+
+ + +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss new file mode 100644 index 0000000000..a1c9d5bacc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import '../../../../../../../../../scss/constants'; + +.tb-data-layer-color-settings-panel { + width: 700px; + max-width: 90vw; + min-height: 300px; + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .tb-data-layer-color-settings-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + .tb-form-row { + height: auto; + } + .tb-data-layer-color-settings-panel-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + } + .tb-data-layer-color-settings-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts new file mode 100644 index 0000000000..ff41d2772b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -0,0 +1,82 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; + +@Component({ + selector: 'tb-data-layer-color-settings-panel', + templateUrl: './data-layer-color-settings-panel.component.html', + providers: [], + styleUrls: ['./data-layer-color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class DataLayerColorSettingsPanelComponent extends PageComponent implements OnInit { + + @Input() + colorSettings: DataLayerColorSettings; + + @Input() + popover: TbPopoverComponent; + + @Output() + colorSettingsApplied = new EventEmitter(); + + DataLayerColorType = DataLayerColorType; + + colorSettingsFormGroup: UntypedFormGroup; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + protected store: Store, + private destroyRef: DestroyRef) { + super(store); + } + + ngOnInit(): void { + this.colorSettingsFormGroup = this.fb.group( + { + type: [this.colorSettings?.type || DataLayerColorType.constant, []], + color: [this.colorSettings?.color, []], + colorFunction: [this.colorSettings?.colorFunction, []] + } + ); + this.colorSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); + } + + cancel() { + this.popover?.hide(); + } + + applyColorSettings() { + const colorSettings: DataLayerColorSettings = this.colorSettingsFormGroup.value; + this.colorSettingsApplied.emit(colorSettings); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html new file mode 100644 index 0000000000..2122c580f3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html @@ -0,0 +1,30 @@ + + + +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts new file mode 100644 index 0000000000..79249a5cee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -0,0 +1,113 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, Renderer2, ViewContainerRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ComponentStyle } from '@shared/models/widget-settings.models'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; +import { + DataLayerColorSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; + +@Component({ + selector: 'tb-data-layer-color-settings', + templateUrl: './data-layer-color-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerColorSettingsComponent), + multi: true + } + ] +}) +export class DataLayerColorSettingsComponent implements ControlValueAccessor { + + @Input() + disabled: boolean; + + DataLayerColorType = DataLayerColorType; + + modelValue: DataLayerColorSettings; + + colorStyle: ComponentStyle = {}; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef) {} + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + this.updateColorStyle(); + } + + writeValue(value: DataLayerColorSettings): void { + if (value) { + this.modelValue = value; + this.updateColorStyle(); + } + } + + openColorSettingsPopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const ctx: any = { + colorSettings: this.modelValue, + }; + const colorSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, DataLayerColorSettingsPanelComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + colorSettingsPanelPopover.tbComponentRef.instance.popover = colorSettingsPanelPopover; + colorSettingsPanelPopover.tbComponentRef.instance.colorSettingsApplied.subscribe((colorSettings) => { + colorSettingsPanelPopover.hide(); + this.modelValue = colorSettings; + this.updateColorStyle(); + this.propagateChange(this.modelValue); + }); + } + } + + private updateColorStyle() { + if (!this.disabled && this.modelValue) { + if (this.modelValue.type === DataLayerColorType.constant) { + this.colorStyle = {backgroundColor: this.modelValue.color}; + } else { + this.colorStyle = {}; + } + } else { + this.colorStyle = {}; + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 872b94a20a..711a004e4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -170,6 +170,40 @@
{{ 'widgets.maps.data-layer.groups' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index afcb164be8..6b15b49580 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -17,13 +17,13 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { DialogComponent } from '@shared/components/dialog.component'; import { - CirclesDataLayerSettings, + CirclesDataLayerSettings, defaultBaseMapDataLayerSettings, MapDataLayerSettings, MapDataLayerType, MapType, MarkersDataLayerSettings, MarkerType, - PolygonsDataLayerSettings + PolygonsDataLayerSettings, ShapeDataLayerSettings } from '@home/components/widget/lib/maps/map.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -35,7 +35,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityType } from '@shared/models/entity-type.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; -import { genNextLabelForDataKeys } from '@core/utils'; +import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; export interface MapDataLayerDialogData { settings: MapDataLayerSettings; @@ -92,12 +92,18 @@ export class MapDataLayerDialogComponent extends DialogComponent(this.dataLayerType), this.settings); + this.dataLayerFormGroup = this.fb.group({ dsType: [this.settings.dsType, [Validators.required]], + dsLabel: [this.settings.dsLabel, []], dsDeviceId: [this.settings.dsDeviceId, [Validators.required]], dsEntityAliasId: [this.settings.dsEntityAliasId, [Validators.required]], dsFilterId: [this.settings.dsFilterId, []], additionalDataKeys: [this.settings.additionalDataKeys, []], + label: [this.settings.label, []], + tooltip: [this.settings.tooltip, []], groups: [this.settings.groups, []] }); @@ -119,14 +125,20 @@ export class MapDataLayerDialogComponent extends DialogComponent + + + Link text')", + "tooltip-function": "Tooltip function", + "tooltip-trigger": "Tooltip trigger", + "tooltip-trigger-click": "Show tooltip on click", + "tooltip-trigger-hover": "Show tooltip on hover", + "auto-close-tooltips": "Auto-close tooltips", + "tooltip-offset": "Tooltip offset", + "tooltip-offset-horizontal": "Horizontal", + "tooltip-offset-vertical": "Vertical", "marker": { "latitude-key": "Latitude key", "longitude-key": "Longitude key", @@ -6898,10 +6915,10 @@ "marker-type-image": "Image", "image": "Image", "marker-image": "Marker image", - "marker-image-type-image": "Constant", + "marker-image-type-image": "Image", "marker-image-type-function": "Function", "custom-marker-image-size": "Custom marker image size", - "marker-image-function": "Function", + "marker-image-function": "Marker image function", "marker-images": "Marker images", "marker-offset": "Marker offset", "offset-horizontal": "Horizontal", From 09d14440facc4fa7c79646621013d5a140980563 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 20 Jan 2025 19:45:41 +0200 Subject: [PATCH 016/127] UI: Map: Marker shape visualization. --- .../widget/lib/maps/map-data-layer.ts | 144 +++++++++---- .../components/widget/lib/maps/map.models.ts | 60 ++++-- .../home/components/widget/lib/maps/map.scss | 6 + .../widget/lib/maps/marker-shape.models.ts | 143 ++++++++++++ .../map/map-data-layer-dialog.component.html | 13 +- .../map/map-data-layer-dialog.component.ts | 15 +- .../map/marker-shape-settings.component.html | 34 +++ .../map/marker-shape-settings.component.ts | 204 ++++++++++++++++++ .../common/map/marker-shapes.component.html | 38 ++++ .../common/map/marker-shapes.component.scss | 39 ++++ .../common/map/marker-shapes.component.ts | 84 ++++++++ .../common/widget-settings-common.module.ts | 6 + .../assets/locale/locale.constant-en_US.json | 6 +- ui-ngx/src/assets/markers/shape1.svg | 19 ++ ui-ngx/src/assets/markers/shape10.svg | 16 ++ ui-ngx/src/assets/markers/shape2.svg | 17 ++ ui-ngx/src/assets/markers/shape3.svg | 19 ++ ui-ngx/src/assets/markers/shape4.svg | 21 ++ ui-ngx/src/assets/markers/shape5.svg | 20 ++ ui-ngx/src/assets/markers/shape6.svg | 22 ++ ui-ngx/src/assets/markers/shape7.svg | 28 +++ ui-ngx/src/assets/markers/shape8.svg | 17 ++ ui-ngx/src/assets/markers/shape9.svg | 19 ++ 23 files changed, 918 insertions(+), 72 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts create mode 100644 ui-ngx/src/assets/markers/shape1.svg create mode 100644 ui-ngx/src/assets/markers/shape10.svg create mode 100644 ui-ngx/src/assets/markers/shape2.svg create mode 100644 ui-ngx/src/assets/markers/shape3.svg create mode 100644 ui-ngx/src/assets/markers/shape4.svg create mode 100644 ui-ngx/src/assets/markers/shape5.svg create mode 100644 ui-ngx/src/assets/markers/shape6.svg create mode 100644 ui-ngx/src/assets/markers/shape7.svg create mode 100644 ui-ngx/src/assets/markers/shape8.svg create mode 100644 ui-ngx/src/assets/markers/shape9.svg diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts index 0cabfe9fef..a730c74adf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts @@ -15,8 +15,8 @@ /// import { + BaseMarkerShapeSettings, CirclesDataLayerSettings, - createColorMarkerURI, DataLayerColorSettings, DataLayerColorType, DataLayerPatternSettings, @@ -34,18 +34,22 @@ import { MapStringFunction, MapType, MarkerIconInfo, + MarkerIconSettings, MarkerImageFunction, MarkerImageInfo, MarkerImageSettings, MarkerImageType, MarkersDataLayerSettings, + MarkerShapeSettings, MarkerType, - PolygonsDataLayerSettings, processTooltipTemplate, ShapeDataLayerSettings, + PolygonsDataLayerSettings, + processTooltipTemplate, + ShapeDataLayerSettings, TbCircleData, TbMapDatasource } from '@home/components/widget/lib/maps/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { Datasource, FormattedData } from '@shared/models/widget.models'; +import { FormattedData } from '@shared/models/widget.models'; import { forkJoin, Observable, of } from 'rxjs'; import { createLabelFromPattern, @@ -59,13 +63,20 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import L, { LatLngBounds, PathOptions } from 'leaflet'; +import L, { divIcon, LatLngBounds } from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; -import { catchError, map } from 'rxjs/operators'; +import { catchError, map, switchMap } from 'rxjs/operators'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI, + MarkerShape +} from '@home/components/widget/lib/maps/marker-shape.models'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; abstract class TbDataLayerItem> { @@ -317,7 +328,7 @@ export abstract class TbMapDataLayer d.$datasource.mapDataIds.includes(this.mapDataId)); const rawItems = layerData.filter(d => this.isValidLayerData(d)); const toDelete = new Set(Array.from(this.layerItems.keys())); - rawItems.forEach((data, index) => { + rawItems.forEach((data) => { let layerItem = this.layerItems.get(data.entityId); if (layerItem) { layerItem.update(data, dsData); @@ -428,8 +439,10 @@ abstract class MarkerIconProcessor { static fromSettings(dataLayer: TbMarkersDataLayer, settings: MarkersDataLayerSettings): MarkerIconProcessor { switch (settings.markerType) { - case MarkerType.default: - return new ColorMarkerIconProcessor(dataLayer, settings.markerColor); + case MarkerType.shape: + return new ShapeMarkerIconProcessor(dataLayer, settings.markerShape); + case MarkerType.icon: + return new IconMarkerIconProcessor(dataLayer, settings.markerIcon); case MarkerType.image: return new ImageMarkerIconProcessor(dataLayer, settings.markerImage); } @@ -445,46 +458,80 @@ abstract class MarkerIconProcessor { } -class ColorMarkerIconProcessor extends MarkerIconProcessor { +abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { private markerColorFunction: CompiledTbFunction; private defaultMarkerIconInfo: MarkerIconInfo; - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: DataLayerColorSettings) { + protected constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: S) { super(dataLayer, settings); } public setup(): Observable { - if (this.settings.type === DataLayerColorType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( map((parsed) => { this.markerColorFunction = parsed; return null; }) ); } else { - const color = tinycolor(this.settings.color); - this.defaultMarkerIconInfo = this.dataLayer.createColoredMarkerIcon(color); - return of(null) + const color = tinycolor(colorSettings.color); + return this.createMarkerShape(color, this.settings.size).pipe( + map((info) => { + this.defaultMarkerIconInfo = info; + return null; + } + )); } } public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { - if (this.settings.type === DataLayerColorType.function) { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); let color: tinycolor.Instance; if (isDefinedAndNotNull(functionColor)) { color = tinycolor(functionColor); } else { - color = tinycolor(this.settings.color); + color = tinycolor(colorSettings.color); } - return of(this.dataLayer.createColoredMarkerIcon(color)); + return this.createMarkerShape(color, this.settings.size); } else { return of(this.defaultMarkerIconInfo); } } + + protected abstract createMarkerShape(color: tinycolor.Instance, size: number): Observable; +} + +class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerShapeSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); + } + +} + +class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerIconSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); + } + } class ImageMarkerIconProcessor extends MarkerIconProcessor { @@ -532,7 +579,7 @@ class ImageMarkerIconProcessor extends MarkerIconProcessor private loadMarkerIconInfo(image: MarkerImageInfo): Observable { if (image && image.url) { return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( - map((aspectImage) => { + switchMap((aspectImage) => { if (aspectImage?.aspect) { let width: number; let height: number; @@ -561,15 +608,15 @@ class ImageMarkerIconProcessor extends MarkerIconProcessor size: [width, height], icon }; - return iconInfo; + return of(iconInfo); } else { return this.dataLayer.createDefaultMarkerIcon(); } }), - catchError(() => of(this.dataLayer.createDefaultMarkerIcon())) + catchError(() => this.dataLayer.createDefaultMarkerIcon()) ); } else { - return of(this.dataLayer.createDefaultMarkerIcon()); + return this.dataLayer.createDefaultMarkerIcon(); } } @@ -644,24 +691,42 @@ export class TbMarkersDataLayer extends TbMapDataLayer { + const color = this.settings.markerShape?.color?.color || '#307FE5'; + return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color)); } - public createColoredMarkerIcon(color: tinycolor.Instance): MarkerIconInfo { - return { - size: [21, 34], - icon: L.icon({ - iconUrl: createColorMarkerURI(color), - iconSize: [21, 34], - iconAnchor: [21 * this.markerOffset[0], 34 * this.markerOffset[1]], - popupAnchor: [21 * this.tooltipOffset[0], 34 * this.tooltipOffset[1]], - shadowUrl: 'assets/shadow.png', - shadowSize: [40, 37], - shadowAnchor: [12, 35] + public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( + map((iconUrl) => { + return { + size: [size, size], + icon: L.icon({ + iconUrl, + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; }) - }; + ); + } + + public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( + map((element) => { + return { + size: [size, size], + icon: L.divIcon({ + html: element.outerHTML, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); } public extractLocation(data: FormattedData): L.LatLng { @@ -757,7 +822,7 @@ abstract class TbShapesDataLayer, dsData: FormattedData[]): L.PathOptions { const fill = this.fillColorProcessor.processColor(data, dsData); const stroke = this.strokeColorProcessor.processColor(data, dsData); - const style: L.PathOptions = { + return { fill: true, fillColor: fill, color: stroke, @@ -765,7 +830,6 @@ abstract class TbShapesDataLayer { - const svg = `` + - ``; - return 'data:image/svg+xml;base64,' + btoa(svg); -} +import { MarkerShape } from '@home/components/widget/lib/maps/marker-shape.models'; export enum MapType { geoMap = 'geoMap', @@ -183,7 +170,8 @@ export const mapDataLayerValidator = (type: MapDataLayerType): ValidatorFn => { }; export enum MarkerType { - default = 'default', + shape = 'shape', + icon = 'icon', image = 'image' } @@ -211,11 +199,25 @@ export interface MarkerImageSettings { images?: string[]; } +export interface BaseMarkerShapeSettings { + size: number; + color: DataLayerColorSettings; +} + +export interface MarkerShapeSettings extends BaseMarkerShapeSettings { + shape: MarkerShape; +} + +export interface MarkerIconSettings extends BaseMarkerShapeSettings { + icon: string; +} + export interface MarkersDataLayerSettings extends MapDataLayerSettings { xKey: DataKey; yKey: DataKey; markerType: MarkerType; - markerColor: DataLayerColorSettings; + markerShape?: MarkerShapeSettings; + markerIcon?: MarkerIconSettings; markerImage?: MarkerImageSettings; markerOffsetX: number; markerOffsetY: number; @@ -267,14 +269,26 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly } as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings as MarkersDataLayerSettings); export const defaultBaseMarkersDataLayerSettings: Partial = mergeDeep({ - markerType: MarkerType.default, - markerColor: { - type: DataLayerColorType.constant, - color: '#307FE5', + markerType: MarkerType.shape, + markerShape: { + shape: MarkerShape.markerShape1, + size: 34, + color: { + type: DataLayerColorType.constant, + color: '#307FE5', + } + }, + markerIcon: { + icon: 'mdi:lightbulb-on', + size: 48, + color: { + type: DataLayerColorType.constant, + color: '#307FE5', + } }, markerImage: { type: MarkerImageType.image, - image: createColorMarkerURI(tinycolor('#307FE5')), + image: '/assets/markers/shape1.svg', imageSize: 34 }, markerOffsetX: 0.5, @@ -720,7 +734,7 @@ export interface MarkerImageInfo { } export interface MarkerIconInfo { - icon: Icon; + icon: L.Icon; size: [number, number]; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 65cc297eac..2928f6205a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -95,6 +95,12 @@ } } } + .leaflet-marker-icon { + &.tb-marker-div-icon { + background: none; + border: none; + } + } } .tb-map-sidebar { .tb-layers, .tb-groups { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts new file mode 100644 index 0000000000..8de7e3d013 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts @@ -0,0 +1,143 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import tinycolor from 'tinycolor2'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Observable, of, switchMap } from 'rxjs'; +import { catchError, map, take } from 'rxjs/operators'; +import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; +import { Element, Text, G } from '@svgdotjs/svg.js'; + +export enum MarkerShape { + markerShape1 = 'markerShape1', + markerShape2 = 'markerShape2', + markerShape3 = 'markerShape3', + markerShape4 = 'markerShape4', + markerShape5 = 'markerShape5', + markerShape6 = 'markerShape6', + markerShape7 = 'markerShape7', + markerShape8 = 'markerShape8', + markerShape9 = 'markerShape9', + markerShape10 = 'markerShape10' +} + +export const markerShapeMap = new Map( + [ + [MarkerShape.markerShape1, '/assets/markers/shape1.svg'], + [MarkerShape.markerShape2, '/assets/markers/shape2.svg'], + [MarkerShape.markerShape3, '/assets/markers/shape3.svg'], + [MarkerShape.markerShape4, '/assets/markers/shape4.svg'], + [MarkerShape.markerShape5, '/assets/markers/shape5.svg'], + [MarkerShape.markerShape6, '/assets/markers/shape6.svg'], + [MarkerShape.markerShape7, '/assets/markers/shape7.svg'], + [MarkerShape.markerShape8, '/assets/markers/shape8.svg'], + [MarkerShape.markerShape9, '/assets/markers/shape9.svg'], + [MarkerShape.markerShape10, '/assets/markers/shape10.svg'] + ] +); + +const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { + const markerAssetUrl = markerShapeMap.get(shape); + const safeUrl = domSanitizer.bypassSecurityTrustResourceUrl(markerAssetUrl); + return iconRegistry.getSvgIconFromUrl(safeUrl).pipe( + map((svgElement) => { + const colorElements = Array.from(svgElement.getElementsByClassName('marker-color')); + colorElements.forEach(el => { + el.setAttribute('fill', '#'+color.toHex()); + }); + const strokeElements = Array.from(svgElement.getElementsByClassName('marker-stroke')); + strokeElements.forEach(el => { + el.setAttribute('stroke', '#'+color.toHex()); + }); + return svgElement; + }) + ); +} + + +export const createColorMarkerShapeURI = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { + return createColorMarkerShape(iconRegistry, domSanitizer, shape, color).pipe( + map((svgElement) => { + const svg = svgElement.outerHTML; + return 'data:image/svg+xml;base64,' + btoa(svg); + }) + ); +} + +const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: number, color: tinycolor.Instance): Observable => { + const isSvg = isSvgIcon(icon); + const iconColor = tinycolor.mix(color, tinycolor('rgba(0,0,0,0.38)')); + if (isSvg) { + const [namespace, iconName] = splitIconName(icon); + return iconRegistry + .getNamedSvgIcon(iconName, namespace) + .pipe( + take(1), + map((svgElement) => { + const element = new Element(svgElement.firstChild); + element.fill('#'+iconColor.toHex()); + //const box = element.bbox(); + const scale = size / 24;//box.height; + element.scale(scale); + return element; + }), + catchError(() => of(null)) + ); + } else { + const iconName = splitIconName(icon)[1]; + const textElement = new Text(document.createElementNS('http://www.w3.org/2000/svg', 'text')); + const fontSetClasses = ( + iconRegistry.getDefaultFontSetClass() + ).filter(className => className.length > 0); + fontSetClasses.forEach(className => textElement.addClass(className)); + textElement.font({size: `${size}px`}); + textElement.attr({ + style: `font-size: ${size}px`, + 'text-anchor': 'start' + }); + textElement.fill('#'+iconColor.toHex()); + const tspan = textElement.tspan(iconName); + tspan.attr({ + 'dominant-baseline': 'hanging' + }); + return of(textElement); + } +} + +export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, icon: string, color: tinycolor.Instance): Observable => { + return createColorMarkerShape(iconRegistry, domSanitizer, MarkerShape.markerShape6, color).pipe( + switchMap((svgElement) => { + return createIconElement(iconRegistry, icon, 12, color).pipe( + map((iconElement) => { + let elements = svgElement.getElementsByClassName('marker-icon-container'); + if (iconElement && elements.length) { + const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.add(iconElement); + const box = iconElement.bbox(); + iconElement.translate(-box.cx, -box.cy); + } + elements = svgElement.getElementsByClassName('marker-icon-background'); + if (elements.length) { + (elements[0] as SVGGElement).style.display = ''; + } + return svgElement; + }) + ); + }) + ); +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 9ecaac60a7..b84d61f354 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -181,13 +181,18 @@
{{ 'widgets.maps.data-layer.marker.marker-type' | translate }}
- {{ 'widgets.maps.data-layer.marker.marker-type-default' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-shape' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-icon' | translate }} {{ 'widgets.maps.data-layer.marker.marker-type-image' | translate }}
-
-
widgets.maps.data-layer.color
- +
+
widgets.maps.data-layer.marker.shape
+ +
+
+
widgets.maps.data-layer.marker.icon
+
widgets.maps.data-layer.marker.image
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 6b15b49580..52bec3c42f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -114,7 +114,8 @@ export class MapDataLayerDialogComponent extends DialogComponent +
+ + +
px
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts new file mode 100644 index 0000000000..cd8ddb25b9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts @@ -0,0 +1,204 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + Input, + OnInit, + Renderer2, + ViewContainerRef +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@home/components/widget/lib/maps/map.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI +} from '@home/components/widget/lib/maps/marker-shape.models'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; +import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; +import { MaterialIconsComponent } from '@shared/components/material-icons.component'; + +@Component({ + selector: 'tb-marker-shape-settings', + templateUrl: './marker-shape-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerShapeSettingsComponent), + multi: true + } + ] +}) +export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnInit { + + MarkerType = MarkerType; + + @Input() + disabled: boolean; + + @Input() + markerType: MarkerType; + + modelValue: MarkerShapeSettings | MarkerIconSettings; + + public shapeSettingsFormGroup: UntypedFormGroup; + + public shapePreview$: Observable; + public iconPreview$: Observable; + + private propagateChange: (v: any) => void = () => { }; + + constructor(private popoverService: TbPopoverService, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer, + private renderer: Renderer2, + private cd: ChangeDetectorRef, + private viewContainerRef: ViewContainerRef) {} + + ngOnInit(): void { + this.shapeSettingsFormGroup = this.fb.group({ + size: [null, [Validators.required, Validators.min(1)]], + color: [null, [Validators.required]] + }); + if (this.markerType === MarkerType.shape) { + this.shapeSettingsFormGroup.addControl('shape', this.fb.control(null, [Validators.required])); + } + if (this.markerType === MarkerType.icon) { + this.shapeSettingsFormGroup.addControl('icon', this.fb.control(null, [Validators.required])); + } + this.shapeSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.shapeSettingsFormGroup.disable({emitEvent: false}); + } else { + this.shapeSettingsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: MarkerShapeSettings | MarkerIconSettings): void { + this.modelValue = value; + this.shapeSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updatePreview(); + } + + openShapePopup($event: Event, matButton: MatButton) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + if (this.markerType === MarkerType.shape) { + const ctx: any = { + shape: (this.modelValue as MarkerShapeSettings).shape, + color: this.modelValue.color.color + }; + const markerShapesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerShapesComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + markerShapesPopover.tbComponentRef.instance.popover = markerShapesPopover; + markerShapesPopover.tbComponentRef.instance.markerShapeSelected.subscribe((shape) => { + markerShapesPopover.hide(); + this.shapeSettingsFormGroup.get('shape').patchValue( + shape + ); + }); + } else if (this.markerType === MarkerType.icon) { + const ctx: any = { + selectedIcon: (this.modelValue as MarkerIconSettings).icon, + iconClearButton: false + }; + const materialIconsPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MaterialIconsComponent, 'left', true, null, + ctx, + {}, + {}, {}, true); + materialIconsPopover.tbComponentRef.instance.popover = materialIconsPopover; + materialIconsPopover.tbComponentRef.instance.iconSelected.subscribe((icon) => { + materialIconsPopover.hide(); + this.shapeSettingsFormGroup.get('icon').patchValue( + icon + ); + }); + } + } + } + + private updateModel() { + this.modelValue = this.shapeSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + this.updatePreview(); + } + + private updatePreview() { + const color = this.modelValue.color.color; + if (this.markerType === MarkerType.shape) { + const shape = (this.modelValue as MarkerShapeSettings).shape; + this.shapePreview$ = createColorMarkerShapeURI(this.iconRegistry, this.domSanitizer, shape, tinycolor(color)).pipe( + map((url) => { + return this.domSanitizer.bypassSecurityTrustUrl(url); + }), + share() + ); + } else if (this.markerType === MarkerType.icon) { + const icon = (this.modelValue as MarkerIconSettings).icon; + this.iconPreview$ = createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, icon, tinycolor(color)).pipe( + map((element) => { + return this.domSanitizer.bypassSecurityTrustHtml(element.outerHTML); + }), + share() + ); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html new file mode 100644 index 0000000000..09d7b654e8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html @@ -0,0 +1,38 @@ + +
+
widgets.maps.data-layer.marker.marker-shapes
+
+ + + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss new file mode 100644 index 0000000000..3e9dfb9a52 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-marker-shapes-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + .tb-marker-shapes-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + button.mat-mdc-button-base.tb-select-shape-button { + width: 42px; + min-width: 42px; + height: 42px; + padding: 4px; + img.tb-marker-shape { + width: 34px; + height: 34px; + object-fit: contain; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts new file mode 100644 index 0000000000..b225bf535c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts @@ -0,0 +1,84 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/lib/maps/marker-shape.models'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; + +interface MarkerShapeInfo { + shape: MarkerShape; + url$: Observable; +} + +@Component({ + selector: 'tb-marker-shapes', + templateUrl: './marker-shapes.component.html', + providers: [], + styleUrls: ['./marker-shapes.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerShapesComponent extends PageComponent implements OnInit { + + @Input() + shape: MarkerShape; + + @Input() + color: string; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerShapeSelected = new EventEmitter(); + + shapes: MarkerShapeInfo[]; + + constructor(protected store: Store, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer) { + super(store); + } + + ngOnInit(): void { + this.shapes = (Object.keys(MarkerShape) as MarkerShape[]).map((shape) => { + return { + shape, + url$: createColorMarkerShapeURI(this.iconRegistry, this.domSanitizer, shape, tinycolor(this.color)).pipe( + map((url) => { + return this.domSanitizer.bypassSecurityTrustUrl(url); + }), + share() + ) + }; + }); + } + + cancel() { + this.popover?.hide(); + } + + selectShape(shape: MarkerShape) { + this.markerShapeSelected.emit(shape); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 91186e45ac..583f3e399c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -219,6 +219,10 @@ import { import { DataLayerPatternSettingsComponent } from '@home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component'; +import { + MarkerShapeSettingsComponent +} from '@home/components/widget/lib/settings/common/map/marker-shape-settings.component'; +import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; @NgModule({ declarations: [ @@ -295,6 +299,8 @@ import { DataLayerColorSettingsComponent, DataLayerColorSettingsPanelComponent, DataLayerPatternSettingsComponent, + MarkerShapeSettingsComponent, + MarkerShapesComponent, MarkerImageSettingsComponent, MarkerImageSettingsPanelComponent, MapDataLayerDialogComponent, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 413a409b65..b01a2598d9 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6911,9 +6911,13 @@ "marker-configuration": "Marker configuration", "remove-marker": "Remove marker", "marker-type": "Marker type", - "marker-type-default": "Default", + "marker-type-shape": "Shape", + "marker-type-icon": "Icon", "marker-type-image": "Image", + "shape": "Shape", + "icon": "Icon", "image": "Image", + "marker-shapes": "Marker shapes", "marker-image": "Marker image", "marker-image-type-image": "Image", "marker-image-type-function": "Function", diff --git a/ui-ngx/src/assets/markers/shape1.svg b/ui-ngx/src/assets/markers/shape1.svg new file mode 100644 index 0000000000..fafd5638e7 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape1.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape10.svg b/ui-ngx/src/assets/markers/shape10.svg new file mode 100644 index 0000000000..f0d8b68d2f --- /dev/null +++ b/ui-ngx/src/assets/markers/shape10.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape2.svg b/ui-ngx/src/assets/markers/shape2.svg new file mode 100644 index 0000000000..6777c1810e --- /dev/null +++ b/ui-ngx/src/assets/markers/shape2.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape3.svg b/ui-ngx/src/assets/markers/shape3.svg new file mode 100644 index 0000000000..eb2fa563b7 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape3.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape4.svg b/ui-ngx/src/assets/markers/shape4.svg new file mode 100644 index 0000000000..5303d47ba6 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape4.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape5.svg b/ui-ngx/src/assets/markers/shape5.svg new file mode 100644 index 0000000000..db3c2e0118 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape5.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape6.svg b/ui-ngx/src/assets/markers/shape6.svg new file mode 100644 index 0000000000..f4de6d140d --- /dev/null +++ b/ui-ngx/src/assets/markers/shape6.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape7.svg b/ui-ngx/src/assets/markers/shape7.svg new file mode 100644 index 0000000000..c7df181b78 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape7.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape8.svg b/ui-ngx/src/assets/markers/shape8.svg new file mode 100644 index 0000000000..1fecf058d9 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape8.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape9.svg b/ui-ngx/src/assets/markers/shape9.svg new file mode 100644 index 0000000000..f44a6b3626 --- /dev/null +++ b/ui-ngx/src/assets/markers/shape9.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + From cd8d172e122a572e23f6995344e9f33527199caa Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 21 Jan 2025 19:42:21 +0200 Subject: [PATCH 017/127] UI: Implement map markers clustering. --- .../widget/lib/maps-legacy/leaflet-map.ts | 8 +- .../widget/lib/maps-legacy/markers.ts | 4 +- .../lib/maps/data-layer/circles-data-layer.ts | 120 +++ .../lib/maps/data-layer/map-data-layer.ts | 361 +++++++ .../lib/maps/data-layer/markers-data-layer.ts | 506 +++++++++ .../maps/data-layer/polygons-data-layer.ts | 135 +++ .../lib/maps/data-layer/shapes-data-layer.ts | 52 + .../widget/lib/maps/leaflet/leaflet-tb.ts | 5 + .../widget/lib/maps/map-data-layer.ts | 968 ------------------ .../components/widget/lib/maps/map-layer.ts | 2 +- .../widget/lib/maps/map-widget.component.ts | 2 +- .../widget/lib/maps/map-widget.models.ts | 2 +- .../home/components/widget/lib/maps/map.scss | 11 + .../home/components/widget/lib/maps/map.ts | 26 +- .../lib/maps/{ => models}/map.models.ts | 34 +- .../maps/{ => models}/marker-shape.models.ts | 0 ...ta-layer-color-settings-panel.component.ts | 2 +- .../data-layer-color-settings.component.ts | 2 +- .../data-layer-pattern-settings.component.ts | 2 +- .../map/map-data-layer-dialog.component.html | 4 + .../map/map-data-layer-dialog.component.ts | 3 +- .../map/map-data-layer-row.component.ts | 2 +- .../common/map/map-data-layers.component.ts | 2 +- .../common/map/map-layer-row.component.ts | 2 +- .../map/map-layer-settings-panel.component.ts | 2 +- .../common/map/map-layers.component.ts | 2 +- .../common/map/map-settings.component.ts | 2 +- .../marker-clustering-settings.component.html | 98 ++ .../marker-clustering-settings.component.ts | 156 +++ .../marker-image-settings-panel.component.ts | 2 +- .../map/marker-image-settings.component.ts | 2 +- .../map/marker-shape-settings.component.ts | 4 +- .../common/map/marker-shapes.component.ts | 2 +- .../common/widget-settings-common.module.ts | 4 + .../assets/locale/locale.constant-en_US.json | 16 +- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 4 +- 36 files changed, 1536 insertions(+), 1013 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts delete mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts rename ui-ngx/src/app/modules/home/components/widget/lib/maps/{ => models}/map.models.ts (97%) rename ui-ngx/src/app/modules/home/components/widget/lib/maps/{ => models}/marker-shape.models.ts (100%) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index 2a390a39fc..9c522abf94 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -16,9 +16,7 @@ import L, { FeatureGroup, LatLngBounds, LatLngTuple, PointExpression, Projection } from 'leaflet'; import tinycolor from 'tinycolor2'; -import 'leaflet-providers'; -import 'leaflet.markercluster'; -import '@geoman-io/leaflet-geoman-free'; +import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { CircleData, @@ -125,10 +123,6 @@ export default abstract class LeafletMap { private initMarkerClusterSettings() { const markerClusteringSettings: WidgetMarkerClusteringSettings = this.options; if (markerClusteringSettings.useClusterMarkers) { - // disabled marker cluster icon - (L as any).MarkerCluster = (L as any).MarkerCluster.extend({ - options: { pmIgnore: true, ...L.Icon.prototype.options } - }); this.clusteringSettings = { spiderfyOnMaxZoom: markerClusteringSettings.spiderfyOnMaxZoom, zoomToBoundsOnClick: markerClusteringSettings.zoomOnClick, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts index d62b2b9217..c642768a4f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/markers.ts @@ -52,7 +52,7 @@ export class Marker { this.leafletMarker = L.marker(this.location, { pmIgnore: !settings.draggableMarker, snapIgnore: !snappable, - tbMarkerData: this.data + tbMarkerData: this.data as any }); this.markerOffset = [ @@ -101,7 +101,7 @@ export class Marker { setDataSources(data: FormattedData, dataSources: FormattedData[]) { this.data = data; this.dataSources = dataSources; - this.leafletMarker.options.tbMarkerData = data; + this.leafletMarker.options.tbMarkerData = data as any; } updateMarkerTooltip(data: FormattedData) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts new file mode 100644 index 0000000000..d53d30e6e5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -0,0 +1,120 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + CirclesDataLayerSettings, + defaultBaseCirclesDataLayerSettings, isJSON, TbCircleData, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr } from '@core/utils'; +import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbCircleDataLayerItem extends TbDataLayerItem { + + private circle: L.Circle; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: CirclesDataLayerSettings, + protected dataLayer: TbCirclesDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + const circleData = this.dataLayer.extractCircleCoordinates(data); + const center = new L.LatLng(circleData.latitude, circleData.longitude); + const style = this.dataLayer.getShapeStyle(data, dsData); + this.circle = L.circle(center, { + radius: circleData.radius, + ...style + }); + this.updateLabel(data, dsData); + return this.circle; + } + + protected createEventListeners(data: FormattedData, _dsData: FormattedData[]): void { + this.dataLayer.getMap().circleClick(this.circle, data.$datasource); + } + + protected unbindLabel() { + this.circle.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.circle.bindTooltip(content, { className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.circle.getLatLng()); + } + + public update(data: FormattedData, dsData: FormattedData[]): void { + const circleData = this.dataLayer.extractCircleCoordinates(data); + const center = new L.LatLng(circleData.latitude, circleData.longitude); + if (!this.circle.getLatLng().equals(center)) { + this.circle.setLatLng(center); + } + if (this.circle.getRadius() !== circleData.radius) { + this.circle.setRadius(circleData.radius); + } + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + const style = this.dataLayer.getShapeStyle(data, dsData); + this.circle.setStyle(style); + } +} + +export class TbCirclesDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: CirclesDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.circle; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.circleKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBaseCirclesDataLayerSettings; + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem { + throw new TbCircleDataLayerItem(data, dsData, this.settings, this); + } + + public extractCircleCoordinates(data: FormattedData) { + const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); + return this.map.convertCircleData(circleData); + } + + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts new file mode 100644 index 0000000000..c14937ef00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -0,0 +1,361 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + DataLayerColorSettings, + DataLayerColorType, + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipTrigger, + MapDataLayerSettings, + mapDataSourceSettingsToDatasource, + MapStringFunction, + MapType, + processTooltipTemplate, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { + createLabelFromPattern, + guid, + isDefined, + mergeDeepIgnoreArray, + parseTbFunction, + safeExecuteTbFunction +} from '@core/utils'; +import L, { LatLngBounds } from 'leaflet'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { map } from 'rxjs/operators'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; + +export abstract class TbDataLayerItem, L extends L.Layer = L.Layer> { + + protected layer: L; + protected tooltip: L.Popup; + + protected constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: S, + protected dataLayer: D) { + this.layer = this.create(data, dsData); + if (this.settings.tooltip?.show) { + this.createTooltip(data.$datasource); + this.updateTooltip(data, dsData); + } + this.createEventListeners(data, dsData); + try { + this.dataLayer.getDataLayerContainer().addLayer(this.layer); + } catch (e) { + console.warn(e); + } + } + + protected abstract create(data: FormattedData, dsData: FormattedData[]): L; + + protected abstract unbindLabel(): void; + + protected abstract bindLabel(content: L.Content): void; + + protected abstract createEventListeners(data: FormattedData, dsData: FormattedData[]): void; + + public abstract update(data: FormattedData, dsData: FormattedData[]): void; + + public remove() { + this.layer.off(); + this.dataLayer.getDataLayerContainer().removeLayer(this.layer); + } + + public getLayer(): L { + return this.layer; + } + + protected updateTooltip(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.tooltip.show) { + let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); + tooltipTemplate = processTooltipTemplate(tooltipTemplate); + this.tooltip.setContent(tooltipTemplate); + if (this.tooltip.isOpen() && this.tooltip.getElement()) { + this.bindTooltipActions(data.$datasource); + } + } + } + + protected updateLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.unbindLabel(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
${label}
`; + this.bindLabel(content); + } + } + + private createTooltip(datasource: TbMapDatasource) { + this.tooltip = L.popup(); + this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); + if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { + this.layer.off('click'); + this.layer.on('mouseover', () => { + this.layer.openPopup(); + }); + this.layer.on('mousemove', (e) => { + this.tooltip.setLatLng(e.latlng); + }); + this.layer.on('mouseout', () => { + this.layer.closePopup(); + }); + } + this.layer.on('popupopen', () => { + this.bindTooltipActions(datasource); + (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + } + + private bindTooltipActions(datasource: TbMapDatasource) { + const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); + Array.from(actions).forEach( + (element: HTMLElement) => { + const actionName = element.getAttribute('data-action-name'); + this.dataLayer.getMap().tooltipElementClick(element, actionName, datasource); + }); + } + +} + +export enum MapDataLayerType { + marker = 'marker', + polygon = 'polygon', + circle = 'circle' +} + +class DataLayerPatternProcessor { + + private patternFunction: CompiledTbFunction; + private pattern: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerPatternSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerPatternType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.patternFunction = parsed; + return null; + }) + ); + } else { + this.pattern = this.settings.pattern; + return of(null) + } + } + + public processPattern(data: FormattedData, dsData: FormattedData[]): string { + let pattern: string; + if (this.settings.type === DataLayerPatternType.function) { + pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); + } else { + pattern = this.pattern; + } + const text = createLabelFromPattern(pattern, data); + const customTranslate = this.dataLayer.getCtx().$injector.get(CustomTranslatePipe); + return customTranslate.transform(text); + } + +} + +export class DataLayerColorProcessor { + + private colorFunction: CompiledTbFunction; + private color: string; + + constructor(private dataLayer: TbMapDataLayer, + private settings: DataLayerColorSettings) {} + + public setup(): Observable { + if (this.settings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.colorFunction = parsed; + return null; + }) + ); + } else { + this.color = this.settings.color; + return of(null) + } + } + + public processColor(data: FormattedData, dsData: FormattedData[]): string { + let color: string; + if (this.settings.type === DataLayerColorType.function) { + color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); + } else { + color = this.color; + } + return color; + } + +} + +export abstract class TbMapDataLayer, L extends L.Layer = L.Layer> implements L.TB.DataLayer { + + protected settings: S; + + protected datasource: TbMapDatasource; + + protected mapDataId = guid(); + + protected dataLayerContainer: L.FeatureGroup; + + protected layerItems = new Map>(); + + protected groupsState: {[group: string]: boolean} = {}; + + protected enabled = true; + + public dataLayerLabelProcessor: DataLayerPatternProcessor; + public dataLayerTooltipProcessor: DataLayerPatternProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); + if (this.settings.groups?.length) { + this.settings.groups.forEach((group) => { + this.groupsState[group] = true; + }); + } + this.dataLayerContainer = this.createDataLayerContainer(); + this.dataLayerLabelProcessor = this.settings.label.show ? new DataLayerPatternProcessor(this, this.settings.label) : null; + this.dataLayerTooltipProcessor = this.settings.tooltip.show ? new DataLayerPatternProcessor(this, this.settings.tooltip): null; + this.map.getMap().addLayer(this.dataLayerContainer); + } + + public setup(): Observable { + this.datasource = mapDataSourceSettingsToDatasource(this.settings); + this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; + this.mapDataId = this.datasource.mapDataIds[0]; + this.datasource = this.setupDatasource(this.datasource); + return forkJoin( + [ + this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), + this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), + this.doSetup() + ]); + } + + public getDatasource(): TbMapDatasource { + return this.datasource; + } + + public getDataLayerContainer(): L.FeatureGroup { + return this.dataLayerContainer; + } + + public getBounds(): LatLngBounds { + return this.dataLayerContainer.getBounds(); + } + + public isEnabled(): boolean { + return this.enabled; + } + + public getGroups(): string[] { + return this.settings.groups || []; + } + + public toggleGroup(group: string): boolean { + if (isDefined(this.groupsState[group])) { + this.groupsState[group] = !this.groupsState[group]; + const enabled = Object.values(this.groupsState).some(v => v); + if (this.enabled !== enabled) { + this.enabled = enabled; + if (this.enabled) { + this.map.getMap().addLayer(this.dataLayerContainer); + } else { + this.map.getMap().removeLayer(this.dataLayerContainer); + } + return true; + } + } + return false; + } + + public updateData(dsData: FormattedData[]) { + const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + const rawItems = layerData.filter(d => this.isValidLayerData(d)); + const toDelete = new Set(Array.from(this.layerItems.keys())); + const updatedItems: TbDataLayerItem[] = []; + rawItems.forEach((data) => { + let layerItem = this.layerItems.get(data.entityId); + if (layerItem) { + layerItem.update(data, dsData); + updatedItems.push(layerItem); + } else { + layerItem = this.createLayerItem(data, dsData); + this.layerItems.set(data.entityId, layerItem); + } + toDelete.delete(data.entityId); + }); + toDelete.forEach((key) => { + const item = this.layerItems.get(key); + item.remove(); + this.layerItems.delete(key); + }); + if (updatedItems.length) { + this.layerItemsUpdated(updatedItems); + } + } + + public getCtx(): WidgetContext { + return this.map.getCtx(); + } + public getMap(): TbMap { + return this.map; + } + + protected createDataLayerContainer(): L.FeatureGroup { + return L.featureGroup(); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + return datasource; + } + + protected layerItemsUpdated(_updatedItems: TbDataLayerItem[]): void { + } + + protected mapType(): MapType { + return this.map.type(); + } + + public abstract dataLayerType(): MapDataLayerType; + + protected abstract defaultBaseSettings(): Partial; + + protected abstract doSetup(): Observable; + + protected abstract isValidLayerData(layerData: FormattedData): boolean; + + protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem; + +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts new file mode 100644 index 0000000000..f97a6e31b3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -0,0 +1,506 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + BaseMarkerShapeSettings, + ClusterMarkerColorFunction, + DataLayerColorType, + defaultBaseMarkersDataLayerSettings, isValidLatLng, + loadImageWithAspect, + MapStringFunction, MapType, + MarkerIconInfo, + MarkerIconSettings, + MarkerImageFunction, + MarkerImageInfo, + MarkerImageSettings, + MarkerImageType, + MarkersDataLayerSettings, + MarkerShapeSettings, + MarkerType, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L, { FeatureGroup } from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { isDefined, isDefinedAndNotNull, isEmptyStr, parseTbFunction, safeExecuteTbFunction } from '@core/utils'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import tinycolor from 'tinycolor2'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI, + MarkerShape +} from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { MapDataLayerType, TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbMarkerDataLayerItem extends TbDataLayerItem { + + private location: L.LatLng; + private marker: L.Marker; + private labelOffset: L.PointTuple; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: MarkersDataLayerSettings, + protected dataLayer: TbMarkersDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Marker { + this.location = this.dataLayer.extractLocation(data); + this.marker = L.marker(this.location, { + tbMarkerData: data + }); + + this.updateMarkerIcon(data, dsData); + + return this.marker; + } + + protected createEventListeners(data: FormattedData, _dsData: FormattedData[]): void { + this.dataLayer.getMap().markerClick(this.marker, data.$datasource); + } + + protected unbindLabel() { + this.marker.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + + public update(data: FormattedData, dsData: FormattedData[]): void { + const position = this.dataLayer.extractLocation(data); + if (!this.marker.getLatLng().equals(position)) { + this.location = position; + this.marker.setLatLng(position); + } + this.marker.options.tbMarkerData = data; + this.updateTooltip(data, dsData); + this.updateMarkerIcon(data, dsData); + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( + (iconInfo) => { + this.marker.setIcon(iconInfo.icon); + const anchor = iconInfo.icon.options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateLabel(data, dsData); + } + ); + } +} + +abstract class MarkerIconProcessor { + + static fromSettings(dataLayer: TbMarkersDataLayer, + settings: MarkersDataLayerSettings): MarkerIconProcessor { + switch (settings.markerType) { + case MarkerType.shape: + return new ShapeMarkerIconProcessor(dataLayer, settings.markerShape); + case MarkerType.icon: + return new IconMarkerIconProcessor(dataLayer, settings.markerIcon); + case MarkerType.image: + return new ImageMarkerIconProcessor(dataLayer, settings.markerImage); + } + } + + protected constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: S) {} + + public abstract setup(): Observable; + + public abstract createMarkerIcon(data: FormattedData, + dsData: FormattedData[]): Observable; + +} + +abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { + + private markerColorFunction: CompiledTbFunction; + + private defaultMarkerIconInfo: MarkerIconInfo; + + protected constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: S) { + super(dataLayer, settings); + } + + public setup(): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.markerColorFunction = parsed; + return null; + }) + ); + } else { + const color = tinycolor(colorSettings.color); + return this.createMarkerShape(color, this.settings.size).pipe( + map((info) => { + this.defaultMarkerIconInfo = info; + return null; + } + )); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); + let color: tinycolor.Instance; + if (isDefinedAndNotNull(functionColor)) { + color = tinycolor(functionColor); + } else { + color = tinycolor(colorSettings.color); + } + return this.createMarkerShape(color, this.settings.size); + } else { + return of(this.defaultMarkerIconInfo); + } + } + + protected abstract createMarkerShape(color: tinycolor.Instance, size: number): Observable; +} + +class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerShapeSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); + } + +} + +class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerIconSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { + return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); + } + +} + +class ImageMarkerIconProcessor extends MarkerIconProcessor { + + private markerImageFunction: CompiledTbFunction; + + private defaultMarkerIconInfo: MarkerIconInfo; + + constructor(protected dataLayer: TbMarkersDataLayer, + protected settings: MarkerImageSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + if (this.settings.type === MarkerImageType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( + map((parsed) => { + this.markerImageFunction = parsed; + return null; + }) + ); + } else { + const currentImage: MarkerImageInfo = { + url: this.settings.image, + size: this.settings.imageSize || 34 + }; + return this.loadMarkerIconInfo(currentImage).pipe( + map((iconInfo) => { + this.defaultMarkerIconInfo = iconInfo; + return null; + } + )); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + if (this.settings.type === MarkerImageType.function) { + const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); + return this.loadMarkerIconInfo(currentImage); + } else { + return of(this.defaultMarkerIconInfo); + } + } + + private loadMarkerIconInfo(image: MarkerImageInfo): Observable { + if (image && image.url) { + return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( + switchMap((aspectImage) => { + if (aspectImage?.aspect) { + let width: number; + let height: number; + if (aspectImage.aspect > 1) { + width = image.size; + height = image.size / aspectImage.aspect; + } else { + width = image.size * aspectImage.aspect; + height = image.size; + } + let iconAnchor = image.markerOffset; + let popupAnchor = image.tooltipOffset; + if (!iconAnchor) { + iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]]; + } + if (!popupAnchor) { + popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]]; + } + const icon = L.icon({ + iconUrl: aspectImage.url, + iconSize: [width, height], + iconAnchor, + popupAnchor + }); + const iconInfo: MarkerIconInfo = { + size: [width, height], + icon + }; + return of(iconInfo); + } else { + return this.dataLayer.createDefaultMarkerIcon(); + } + }), + catchError(() => this.dataLayer.createDefaultMarkerIcon()) + ); + } else { + return this.dataLayer.createDefaultMarkerIcon(); + } + } + +} + +export class TbMarkersDataLayer extends TbMapDataLayer { + + public markerIconProcessor: MarkerIconProcessor; + + public markerOffset: L.LatLngTuple; + public tooltipOffset: L.LatLngTuple; + + private markersClusterContainer: L.MarkerClusterGroup; + private clusterMarkerColorFunction: CompiledTbFunction; + + constructor(protected map: TbMap, + inputSettings: MarkersDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.marker; + } + + protected createDataLayerContainer(): FeatureGroup { + if (this.settings.markerClustering?.enable) { + return this.createMarkersClusterContainer(); + } else { + return super.createDataLayerContainer(); + } + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBaseMarkersDataLayerSettings; + } + + protected doSetup(): Observable { + this.markerOffset = [ + isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, + isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, + ]; + this.tooltipOffset = [ + isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, + isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, + ]; + this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings); + const setup$: Observable[] = []; + if (this.settings.markerClustering?.enable && this.settings.markerClustering.useClusterMarkerColorFunction) { + setup$.push( + parseTbFunction(this.getCtx().http, this.settings.markerClustering.clusterMarkerColorFunction, ['data', 'childCount']).pipe( + map((parsed) => { + this.clusterMarkerColorFunction = parsed; + return null; + }) + ) + ); + } + setup$.push(this.markerIconProcessor.setup()); + return forkJoin(setup$).pipe(map(() => null)); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return !!this.extractPosition(layerData); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { + return new TbMarkerDataLayerItem(data, dsData, this.settings, this); + } + + protected layerItemsUpdated(updatedItems: TbDataLayerItem[]) { + if (this.settings.markerClustering?.enable) { + this.markersClusterContainer.refreshClusters(updatedItems.map(item => item.getLayer())); + } + super.layerItemsUpdated(updatedItems); + } + + private createMarkersClusterContainer(): L.FeatureGroup { + const markerClusterOptions: L.MarkerClusterGroupOptions = { + spiderfyOnMaxZoom: this.settings.markerClustering?.spiderfyOnMaxZoom, + zoomToBoundsOnClick: this.settings.markerClustering?.zoomOnClick, + showCoverageOnHover: this.settings.markerClustering?.showCoverageOnHover, + removeOutsideVisibleBounds: this.settings.markerClustering?.lazyLoad, + animate: this.settings.markerClustering?.zoomAnimation, + chunkedLoading: this.settings.markerClustering?.chunkedLoad, + pmIgnore: true, + spiderLegPolylineOptions: { + pmIgnore: true + }, + polygonOptions: { + pmIgnore: true + } + }; + if (this.settings.markerClustering?.useClusterMarkerColorFunction) { + markerClusterOptions.iconCreateFunction = (cluster) => { + const childCount = cluster.getChildCount(); + const data = cluster.getAllChildMarkers().map(clusterMarker => clusterMarker.options.tbMarkerData); + const markerColor: string = this.clusterMarkerColorFunction ? + safeExecuteTbFunction(this.clusterMarkerColorFunction, [data, childCount]) : null; + if (isDefinedAndNotNull(markerColor) && tinycolor(markerColor).isValid()) { + const parsedColor = tinycolor(markerColor); + return L.divIcon({ + html: `
` + + `
` + childCount + '
', + iconSize: new L.Point(40, 40), + className: 'tb-cluster-marker-container' + }); + } else { + let c = ' marker-cluster-'; + if (childCount < 10) { + c += 'small'; + } else if (childCount < 100) { + c += 'medium'; + } else { + c += 'large'; + } + return new L.DivIcon({ + html: '
' + childCount + '
', + className: 'marker-cluster' + c, + iconSize: new L.Point(40, 40) + }); + } + } + } + if (this.settings.markerClustering?.maxClusterRadius && this.settings.markerClustering.maxClusterRadius > 0) { + markerClusterOptions.maxClusterRadius = Math.floor(this.settings.markerClustering.maxClusterRadius); + } + if (this.settings.markerClustering?.maxZoom && this.settings.markerClustering.maxZoom >= 0 && this.settings.markerClustering.maxZoom < 19) { + markerClusterOptions.disableClusteringAtZoom = Math.floor(this.settings.markerClustering.maxZoom); + } + this.markersClusterContainer = new L.MarkerClusterGroup(markerClusterOptions); + return this.markersClusterContainer; + } + + private extractPosition(data: FormattedData): {x: number; y: number} { + if (data) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + switch (this.mapType()) { + case MapType.geoMap: + if (!isValidLatLng(xKeyVal, yKeyVal)) { + return null; + } + break; + case MapType.image: + if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { + return null; + } + break; + } + return {x: xKeyVal, y: yKeyVal}; + } else { + return null; + } + } + + public createDefaultMarkerIcon(): Observable { + const color = this.settings.markerShape?.color?.color || '#307FE5'; + return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color)); + } + + public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( + map((iconUrl) => { + return { + size: [size, size], + icon: L.icon({ + iconUrl, + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + + public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, size = 34): Observable { + return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( + map((element) => { + return { + size: [size, size], + icon: L.divIcon({ + html: element.outerHTML, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + + public extractLocation(data: FormattedData): L.LatLng { + const position = this.extractPosition(data); + if (position) { + return this.map.positionToLatLng(position); + } else { + return null; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts new file mode 100644 index 0000000000..de938a2809 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -0,0 +1,135 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + defaultBasePolygonsDataLayerSettings, + isCutPolygon, isJSON, + PolygonsDataLayerSettings, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { FormattedData } from '@shared/models/widget.models'; +import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { Observable } from 'rxjs'; +import { isNotEmptyStr, isString } from '@core/utils'; +import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +class TbPolygonDataLayerItem extends TbDataLayerItem { + + private polygonContainer: L.FeatureGroup; + private polygon: L.Polygon; + + constructor(data: FormattedData, + dsData: FormattedData[], + protected settings: PolygonsDataLayerSettings, + protected dataLayer: TbPolygonsDataLayer) { + super(data, dsData, settings, dataLayer); + } + + protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { + const polyData = this.dataLayer.extractPolygonCoordinates(data); + const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; + const style = this.dataLayer.getShapeStyle(data, dsData); + this.polygon = polyConstructor(polyData, { + ...style + }); + + this.polygonContainer = L.featureGroup(); + this.polygon.addTo(this.polygonContainer); + + this.updateLabel(data, dsData); + return this.polygonContainer; + } + + protected createEventListeners(data: FormattedData, _dsData: FormattedData[]): void { + this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); + } + + protected unbindLabel() { + this.polygonContainer.unbindTooltip(); + } + + protected bindLabel(content: L.Content): void { + this.polygonContainer.bindTooltip(content, {className: 'tb-polygon-label', permanent: true, direction: 'center'}) + .openTooltip(this.polygonContainer.getBounds().getCenter()); + } + + public update(data: FormattedData, dsData: FormattedData[]): void { + const polyData = this.dataLayer.extractPolygonCoordinates(data); + const style = this.dataLayer.getShapeStyle(data, dsData); + if (isCutPolygon(polyData) || polyData.length !== 2) { + if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); + this.polygon = L.polygon(polyData, { + ...style + }); + this.polygon.addTo(this.polygonContainer); + } else { + this.polygon.setLatLngs(polyData); + } + } else if (polyData.length === 2) { + const bounds = new L.LatLngBounds(polyData); + // @ts-ignore + this.leafletPoly.setBounds(bounds); + } + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + this.polygon.setStyle(style); + } +} + +export class TbPolygonsDataLayer extends TbShapesDataLayer { + + constructor(protected map: TbMap, + inputSettings: PolygonsDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.polygon; + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys.push(this.settings.polygonKey); + return datasource; + } + + protected defaultBaseSettings(): Partial { + return defaultBasePolygonsDataLayerSettings; + } + + protected doSetup(): Observable { + return super.doSetup(); + } + + protected isValidLayerData(layerData: FormattedData): boolean { + return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) + || Array.isArray(layerData[this.settings.polygonKey.label]))); + } + + protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { + return new TbPolygonDataLayerItem(data, dsData, this.settings, this); + } + + public extractPolygonCoordinates(data: FormattedData) { + let rawPolyData = data[this.settings.polygonKey.label]; + if (isString(rawPolyData)) { + rawPolyData = JSON.parse(rawPolyData); + } + return this.map.toPolygonCoordinates(rawPolyData); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts new file mode 100644 index 0000000000..30db8a0ac4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts @@ -0,0 +1,52 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { ShapeDataLayerSettings, TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { forkJoin, Observable } from 'rxjs'; +import { FormattedData } from '@shared/models/widget.models'; +import { DataLayerColorProcessor, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export abstract class TbShapesDataLayer> extends TbMapDataLayer { + + public fillColorProcessor: DataLayerColorProcessor; + public strokeColorProcessor: DataLayerColorProcessor; + + protected constructor(protected map: TbMap, + inputSettings: S) { + super(map, inputSettings); + } + + protected doSetup(): Observable { + this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); + this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); + return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); + } + + public getShapeStyle(data: FormattedData, dsData: FormattedData[]): L.PathOptions { + const fill = this.fillColorProcessor.processColor(data, dsData); + const stroke = this.strokeColorProcessor.processColor(data, dsData); + return { + fill: true, + fillColor: fill, + color: stroke, + weight: this.settings.strokeWeight, + fillOpacity: 1, + opacity: 1 + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 84b0d2a688..0aafc0889c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -16,6 +16,11 @@ import L, { Coords, TB, TileLayerOptions } from 'leaflet'; import { guid } from '@core/utils'; +import 'leaflet-providers'; +import '@geoman-io/leaflet-geoman-free'; +import 'leaflet.markercluster'; + +L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); class SidebarControl extends L.Control { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts deleted file mode 100644 index a730c74adf..0000000000 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-data-layer.ts +++ /dev/null @@ -1,968 +0,0 @@ -/// -/// Copyright © 2016-2024 The Thingsboard Authors -/// -/// Licensed under the Apache License, Version 2.0 (the "License"); -/// you may not use this file except in compliance with the License. -/// You may obtain a copy of the License at -/// -/// http://www.apache.org/licenses/LICENSE-2.0 -/// -/// Unless required by applicable law or agreed to in writing, software -/// distributed under the License is distributed on an "AS IS" BASIS, -/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -/// See the License for the specific language governing permissions and -/// limitations under the License. -/// - -import { - BaseMarkerShapeSettings, - CirclesDataLayerSettings, - DataLayerColorSettings, - DataLayerColorType, - DataLayerPatternSettings, - DataLayerPatternType, - DataLayerTooltipTrigger, - defaultBaseCirclesDataLayerSettings, - defaultBaseMarkersDataLayerSettings, - defaultBasePolygonsDataLayerSettings, - isCutPolygon, - isJSON, - isValidLatLng, - loadImageWithAspect, - MapDataLayerSettings, - mapDataSourceSettingsToDatasource, - MapStringFunction, - MapType, - MarkerIconInfo, - MarkerIconSettings, - MarkerImageFunction, - MarkerImageInfo, - MarkerImageSettings, - MarkerImageType, - MarkersDataLayerSettings, - MarkerShapeSettings, - MarkerType, - PolygonsDataLayerSettings, - processTooltipTemplate, - ShapeDataLayerSettings, - TbCircleData, - TbMapDatasource -} from '@home/components/widget/lib/maps/map.models'; -import { TbMap } from '@home/components/widget/lib/maps/map'; -import { FormattedData } from '@shared/models/widget.models'; -import { forkJoin, Observable, of } from 'rxjs'; -import { - createLabelFromPattern, - guid, - isDefined, - isDefinedAndNotNull, - isEmptyStr, - isNotEmptyStr, - isString, - mergeDeepIgnoreArray, - parseTbFunction, - safeExecuteTbFunction -} from '@core/utils'; -import L, { divIcon, LatLngBounds } from 'leaflet'; -import { CompiledTbFunction } from '@shared/models/js-function.models'; -import { catchError, map, switchMap } from 'rxjs/operators'; -import tinycolor from 'tinycolor2'; -import { WidgetContext } from '@home/models/widget-component.models'; -import { ImagePipe } from '@shared/pipe/image.pipe'; -import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; -import { - createColorMarkerIconElement, - createColorMarkerShapeURI, - MarkerShape -} from '@home/components/widget/lib/maps/marker-shape.models'; -import { MatIconRegistry } from '@angular/material/icon'; -import { DomSanitizer } from '@angular/platform-browser'; - -abstract class TbDataLayerItem> { - - protected layer: L.Layer; - protected tooltip: L.Popup; - - protected constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: S, - protected dataLayer: L) { - this.layer = this.create(data, dsData); - if (this.settings.tooltip?.show) { - this.createTooltip(data.$datasource); - this.updateTooltip(data, dsData); - } - this.createEventListeners(data, dsData); - this.dataLayer.getFeatureGroup().addLayer(this.layer); - } - - protected abstract create(data: FormattedData, dsData: FormattedData[]): L.Layer; - - protected abstract unbindLabel(): void; - - protected abstract bindLabel(content: L.Content): void; - - protected abstract createEventListeners(data: FormattedData, dsData: FormattedData[]): void; - - public abstract update(data: FormattedData, dsData: FormattedData[]): void; - - public remove() { - this.layer.off(); - this.dataLayer.getFeatureGroup().removeLayer(this.layer); - } - - protected updateTooltip(data: FormattedData, dsData: FormattedData[]) { - if (this.settings.tooltip.show) { - let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); - tooltipTemplate = processTooltipTemplate(tooltipTemplate); - this.tooltip.setContent(tooltipTemplate); - if (this.tooltip.isOpen() && this.tooltip.getElement()) { - this.bindTooltipActions(data.$datasource); - } - } - } - - protected updateLabel(data: FormattedData, dsData: FormattedData[]) { - if (this.settings.label.show) { - this.unbindLabel(); - const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); - const labelColor = this.dataLayer.getCtx().widgetConfig.color; - const content: L.Content = `
${label}
`; - this.bindLabel(content); - } - } - - private createTooltip(datasource: TbMapDatasource) { - this.tooltip = L.popup(); - this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); - if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { - this.layer.off('click'); - this.layer.on('mouseover', () => { - this.layer.openPopup(); - }); - this.layer.on('mousemove', (e) => { - this.tooltip.setLatLng(e.latlng); - }); - this.layer.on('mouseout', () => { - this.layer.closePopup(); - }); - } - this.layer.on('popupopen', () => { - this.bindTooltipActions(datasource); - (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { - event.preventDefault(); - }); - }); - } - - private bindTooltipActions(datasource: TbMapDatasource) { - const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); - Array.from(actions).forEach( - (element: HTMLElement) => { - const actionName = element.getAttribute('data-action-name'); - this.dataLayer.getMap().tooltipElementClick(element, actionName, datasource); - }); - } - -} - -export enum MapDataLayerType { - marker = 'marker', - polygon = 'polygon', - circle = 'circle' -} - -class DataLayerPatternProcessor { - - private patternFunction: CompiledTbFunction; - private pattern: string; - - constructor(private dataLayer: TbMapDataLayer, - private settings: DataLayerPatternSettings) {} - - public setup(): Observable { - if (this.settings.type === DataLayerPatternType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.patternFunction, ['data', 'dsData']).pipe( - map((parsed) => { - this.patternFunction = parsed; - return null; - }) - ); - } else { - this.pattern = this.settings.pattern; - return of(null) - } - } - - public processPattern(data: FormattedData, dsData: FormattedData[]): string { - let pattern: string; - if (this.settings.type === DataLayerPatternType.function) { - pattern = safeExecuteTbFunction(this.patternFunction, [data, dsData]); - } else { - pattern = this.pattern; - } - const text = createLabelFromPattern(pattern, data); - const customTranslate = this.dataLayer.getCtx().$injector.get(CustomTranslatePipe); - return customTranslate.transform(text); - } - -} - -class DataLayerColorProcessor { - - private colorFunction: CompiledTbFunction; - private color: string; - - constructor(private dataLayer: TbMapDataLayer, - private settings: DataLayerColorSettings) {} - - public setup(): Observable { - if (this.settings.type === DataLayerColorType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( - map((parsed) => { - this.colorFunction = parsed; - return null; - }) - ); - } else { - this.color = this.settings.color; - return of(null) - } - } - - public processColor(data: FormattedData, dsData: FormattedData[]): string { - let color: string; - if (this.settings.type === DataLayerColorType.function) { - color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); - } else { - color = this.color; - } - return color; - } - -} - -export abstract class TbMapDataLayer> implements L.TB.DataLayer { - - protected settings: S; - - protected datasource: TbMapDatasource; - - protected mapDataId = guid(); - - protected featureGroup = L.featureGroup(); - - protected layerItems = new Map>(); - - protected groupsState: {[group: string]: boolean} = {}; - - protected enabled = true; - - public dataLayerLabelProcessor: DataLayerPatternProcessor; - public dataLayerTooltipProcessor: DataLayerPatternProcessor; - - protected constructor(protected map: TbMap, - inputSettings: S) { - this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); - if (this.settings.groups?.length) { - this.settings.groups.forEach((group) => { - this.groupsState[group] = true; - }); - } - this.dataLayerLabelProcessor = this.settings.label.show ? new DataLayerPatternProcessor(this, this.settings.label) : null; - this.dataLayerTooltipProcessor = this.settings.tooltip.show ? new DataLayerPatternProcessor(this, this.settings.tooltip): null; - this.map.getMap().addLayer(this.featureGroup); - } - - public setup(): Observable { - this.datasource = mapDataSourceSettingsToDatasource(this.settings); - this.datasource.dataKeys = this.settings.additionalDataKeys ? [...this.settings.additionalDataKeys] : []; - this.mapDataId = this.datasource.mapDataIds[0]; - this.datasource = this.setupDatasource(this.datasource); - return forkJoin( - [ - this.dataLayerLabelProcessor ? this.dataLayerLabelProcessor.setup() : of(null), - this.dataLayerTooltipProcessor ? this.dataLayerTooltipProcessor.setup() : of(null), - this.doSetup() - ]); - } - - public getDatasource(): TbMapDatasource { - return this.datasource; - } - - public getFeatureGroup(): L.FeatureGroup { - return this.featureGroup; - } - - public getBounds(): LatLngBounds { - return this.featureGroup.getBounds(); - } - - public isEnabled(): boolean { - return this.enabled; - } - - public getGroups(): string[] { - return this.settings.groups || []; - } - - public toggleGroup(group: string): boolean { - if (isDefined(this.groupsState[group])) { - this.groupsState[group] = !this.groupsState[group]; - const enabled = Object.values(this.groupsState).some(v => v); - if (this.enabled !== enabled) { - this.enabled = enabled; - if (this.enabled) { - this.map.getMap().addLayer(this.featureGroup); - } else { - this.map.getMap().removeLayer(this.featureGroup); - } - return true; - } - } - return false; - } - - public updateData(dsData: FormattedData[]) { - const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); - const rawItems = layerData.filter(d => this.isValidLayerData(d)); - const toDelete = new Set(Array.from(this.layerItems.keys())); - rawItems.forEach((data) => { - let layerItem = this.layerItems.get(data.entityId); - if (layerItem) { - layerItem.update(data, dsData); - } else { - layerItem = this.createLayerItem(data, dsData); - this.layerItems.set(data.entityId, layerItem); - } - toDelete.delete(data.entityId); - }); - toDelete.forEach((key) => { - const item = this.layerItems.get(key); - item.remove(); - this.layerItems.delete(key); - }); - } - - public getCtx(): WidgetContext { - return this.map.getCtx(); - } - public getMap(): TbMap { - return this.map; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - return datasource; - } - - protected mapType(): MapType { - return this.map.type(); - } - - public abstract dataLayerType(): MapDataLayerType; - - protected abstract defaultBaseSettings(): Partial; - - protected abstract doSetup(): Observable; - - protected abstract isValidLayerData(layerData: FormattedData): boolean; - - protected abstract createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem; - -} - -class TbMarkerDataLayerItem extends TbDataLayerItem { - - private location: L.LatLng; - private marker: L.Marker; - private labelOffset: L.PointTuple; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: MarkersDataLayerSettings, - protected dataLayer: TbMarkersDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { - this.location = this.dataLayer.extractLocation(data); - this.marker = L.marker(this.location, { - tbMarkerData: data - }); - - this.updateMarkerIcon(data, dsData); - - return this.marker; - } - - protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { - this.dataLayer.getMap().markerClick(this.marker, data.$datasource); - } - - protected unbindLabel() { - this.marker.unbindTooltip(); - } - - protected bindLabel(content: L.Content): void { - this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); - } - - public update(data: FormattedData, dsData: FormattedData[]): void { - const position = this.dataLayer.extractLocation(data); - if (!this.marker.getLatLng().equals(position)) { - this.location = position; - this.marker.setLatLng(position); - } - this.updateTooltip(data, dsData); - this.updateMarkerIcon(data, dsData); - } - - private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { - this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( - (iconInfo) => { - this.marker.setIcon(iconInfo.icon); - const anchor = iconInfo.icon.options.iconAnchor; - if (anchor && Array.isArray(anchor)) { - this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; - } else { - this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; - } - this.updateLabel(data, dsData); - } - ); - } -} - -abstract class MarkerIconProcessor { - - static fromSettings(dataLayer: TbMarkersDataLayer, - settings: MarkersDataLayerSettings): MarkerIconProcessor { - switch (settings.markerType) { - case MarkerType.shape: - return new ShapeMarkerIconProcessor(dataLayer, settings.markerShape); - case MarkerType.icon: - return new IconMarkerIconProcessor(dataLayer, settings.markerIcon); - case MarkerType.image: - return new ImageMarkerIconProcessor(dataLayer, settings.markerImage); - } - } - - protected constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: S) {} - - public abstract setup(): Observable; - - public abstract createMarkerIcon(data: FormattedData, - dsData: FormattedData[]): Observable; - -} - -abstract class BaseColorMarkerShapeProcessor extends MarkerIconProcessor { - - private markerColorFunction: CompiledTbFunction; - - private defaultMarkerIconInfo: MarkerIconInfo; - - protected constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: S) { - super(dataLayer, settings); - } - - public setup(): Observable { - const colorSettings = this.settings.color; - if (colorSettings.type === DataLayerColorType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( - map((parsed) => { - this.markerColorFunction = parsed; - return null; - }) - ); - } else { - const color = tinycolor(colorSettings.color); - return this.createMarkerShape(color, this.settings.size).pipe( - map((info) => { - this.defaultMarkerIconInfo = info; - return null; - } - )); - } - } - - public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { - const colorSettings = this.settings.color; - if (colorSettings.type === DataLayerColorType.function) { - const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); - let color: tinycolor.Instance; - if (isDefinedAndNotNull(functionColor)) { - color = tinycolor(functionColor); - } else { - color = tinycolor(colorSettings.color); - } - return this.createMarkerShape(color, this.settings.size); - } else { - return of(this.defaultMarkerIconInfo); - } - } - - protected abstract createMarkerShape(color: tinycolor.Instance, size: number): Observable; -} - -class ShapeMarkerIconProcessor extends BaseColorMarkerShapeProcessor { - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerShapeSettings) { - super(dataLayer, settings); - } - - protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { - return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, size); - } - -} - -class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerIconSettings) { - super(dataLayer, settings); - } - - protected createMarkerShape(color: tinycolor.Instance, size: number): Observable { - return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, size); - } - -} - -class ImageMarkerIconProcessor extends MarkerIconProcessor { - - private markerImageFunction: CompiledTbFunction; - - private defaultMarkerIconInfo: MarkerIconInfo; - - constructor(protected dataLayer: TbMarkersDataLayer, - protected settings: MarkerImageSettings) { - super(dataLayer, settings); - } - - public setup(): Observable { - if (this.settings.type === MarkerImageType.function) { - return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( - map((parsed) => { - this.markerImageFunction = parsed; - return null; - }) - ); - } else { - const currentImage: MarkerImageInfo = { - url: this.settings.image, - size: this.settings.imageSize || 34 - }; - return this.loadMarkerIconInfo(currentImage).pipe( - map((iconInfo) => { - this.defaultMarkerIconInfo = iconInfo; - return null; - } - )); - } - } - - public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { - if (this.settings.type === MarkerImageType.function) { - const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); - return this.loadMarkerIconInfo(currentImage); - } else { - return of(this.defaultMarkerIconInfo); - } - } - - private loadMarkerIconInfo(image: MarkerImageInfo): Observable { - if (image && image.url) { - return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( - switchMap((aspectImage) => { - if (aspectImage?.aspect) { - let width: number; - let height: number; - if (aspectImage.aspect > 1) { - width = image.size; - height = image.size / aspectImage.aspect; - } else { - width = image.size * aspectImage.aspect; - height = image.size; - } - let iconAnchor = image.markerOffset; - let popupAnchor = image.tooltipOffset; - if (!iconAnchor) { - iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]]; - } - if (!popupAnchor) { - popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]]; - } - const icon = L.icon({ - iconUrl: aspectImage.url, - iconSize: [width, height], - iconAnchor, - popupAnchor - }); - const iconInfo: MarkerIconInfo = { - size: [width, height], - icon - }; - return of(iconInfo); - } else { - return this.dataLayer.createDefaultMarkerIcon(); - } - }), - catchError(() => this.dataLayer.createDefaultMarkerIcon()) - ); - } else { - return this.dataLayer.createDefaultMarkerIcon(); - } - } - -} - -export class TbMarkersDataLayer extends TbMapDataLayer { - - public markerIconProcessor: MarkerIconProcessor; - - public markerOffset: L.LatLngTuple; - public tooltipOffset: L.LatLngTuple; - - constructor(protected map: TbMap, - inputSettings: MarkersDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.marker; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.xKey, this.settings.yKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBaseMarkersDataLayerSettings; - } - - protected doSetup(): Observable { - this.markerOffset = [ - isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, - isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, - ]; - this.tooltipOffset = [ - isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, - isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, - ]; - - this.markerIconProcessor = MarkerIconProcessor.fromSettings(this, this.settings); - return this.markerIconProcessor.setup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return !!this.extractPosition(layerData); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { - return new TbMarkerDataLayerItem(data, dsData, this.settings, this); - } - - private extractPosition(data: FormattedData): {x: number; y: number} { - if (data) { - const xKeyVal = data[this.settings.xKey.label]; - const yKeyVal = data[this.settings.yKey.label]; - switch (this.mapType()) { - case MapType.geoMap: - if (!isValidLatLng(xKeyVal, yKeyVal)) { - return null; - } - break; - case MapType.image: - if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { - return null; - } - break; - } - return {x: xKeyVal, y: yKeyVal}; - } else { - return null; - } - } - - public createDefaultMarkerIcon(): Observable { - const color = this.settings.markerShape?.color?.color || '#307FE5'; - return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color)); - } - - public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, size = 34): Observable { - return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( - map((iconUrl) => { - return { - size: [size, size], - icon: L.icon({ - iconUrl, - iconSize: [size, size], - iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], - popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] - }) - }; - }) - ); - } - - public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, size = 34): Observable { - return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( - map((element) => { - return { - size: [size, size], - icon: L.divIcon({ - html: element.outerHTML, - className: 'tb-marker-div-icon', - iconSize: [size, size], - iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], - popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] - }) - }; - }) - ); - } - - public extractLocation(data: FormattedData): L.LatLng { - const position = this.extractPosition(data); - if (position) { - return this.map.positionToLatLng(position); - } else { - return null; - } - } -} - -class TbPolygonDataLayerItem extends TbDataLayerItem { - - private polygonContainer: L.FeatureGroup; - private polygon: L.Polygon; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: PolygonsDataLayerSettings, - protected dataLayer: TbPolygonsDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { - const polyData = this.dataLayer.extractPolygonCoordinates(data); - const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; - const style = this.dataLayer.getShapeStyle(data, dsData); - this.polygon = polyConstructor(polyData, { - ...style - }); - - this.polygonContainer = L.featureGroup(); - this.polygon.addTo(this.polygonContainer); - - this.updateLabel(data, dsData); - return this.polygonContainer; - } - - protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { - this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); - } - - protected unbindLabel() { - this.polygonContainer.unbindTooltip(); - } - - protected bindLabel(content: L.Content): void { - this.polygonContainer.bindTooltip(content, {className: 'tb-polygon-label', permanent: true, direction: 'center'}) - .openTooltip(this.polygonContainer.getBounds().getCenter()); - } - - public update(data: FormattedData, dsData: FormattedData[]): void { - const polyData = this.dataLayer.extractPolygonCoordinates(data); - const style = this.dataLayer.getShapeStyle(data, dsData); - if (isCutPolygon(polyData) || polyData.length !== 2) { - if (this.polygon instanceof L.Rectangle) { - this.polygonContainer.removeLayer(this.polygon); - this.polygon = L.polygon(polyData, { - ...style - }); - this.polygon.addTo(this.polygonContainer); - } else { - this.polygon.setLatLngs(polyData); - } - } else if (polyData.length === 2) { - const bounds = new L.LatLngBounds(polyData); - // @ts-ignore - this.leafletPoly.setBounds(bounds); - } - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - this.polygon.setStyle(style); - } -} - -abstract class TbShapesDataLayer> extends TbMapDataLayer { - - public fillColorProcessor: DataLayerColorProcessor; - public strokeColorProcessor: DataLayerColorProcessor; - - protected constructor(protected map: TbMap, - inputSettings: S) { - super(map, inputSettings); - } - - protected doSetup(): Observable { - this.fillColorProcessor = new DataLayerColorProcessor(this, this.settings.fillColor); - this.strokeColorProcessor = new DataLayerColorProcessor(this, this.settings.strokeColor); - return forkJoin([this.fillColorProcessor.setup(), this.strokeColorProcessor.setup()]); - } - - public getShapeStyle(data: FormattedData, dsData: FormattedData[]): L.PathOptions { - const fill = this.fillColorProcessor.processColor(data, dsData); - const stroke = this.strokeColorProcessor.processColor(data, dsData); - return { - fill: true, - fillColor: fill, - color: stroke, - weight: this.settings.strokeWeight, - fillOpacity: 1, - opacity: 1 - }; - } -} - -export class TbPolygonsDataLayer extends TbShapesDataLayer { - - constructor(protected map: TbMap, - inputSettings: PolygonsDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.polygon; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.polygonKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBasePolygonsDataLayerSettings; - } - - protected doSetup(): Observable { - return super.doSetup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return layerData && ((isNotEmptyStr(layerData[this.settings.polygonKey.label]) && !isJSON(layerData[this.settings.polygonKey.label]) - || Array.isArray(layerData[this.settings.polygonKey.label]))); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbPolygonDataLayerItem { - return new TbPolygonDataLayerItem(data, dsData, this.settings, this); - } - - public extractPolygonCoordinates(data: FormattedData) { - let rawPolyData = data[this.settings.polygonKey.label]; - if (isString(rawPolyData)) { - rawPolyData = JSON.parse(rawPolyData); - } - return this.map.toPolygonCoordinates(rawPolyData); - } -} - -class TbCircleDataLayerItem extends TbDataLayerItem { - - private circle: L.Circle; - - constructor(data: FormattedData, - dsData: FormattedData[], - protected settings: CirclesDataLayerSettings, - protected dataLayer: TbCirclesDataLayer) { - super(data, dsData, settings, dataLayer); - } - - protected create(data: FormattedData, dsData: FormattedData[]): L.Layer { - const circleData = this.dataLayer.extractCircleCoordinates(data); - const center = new L.LatLng(circleData.latitude, circleData.longitude); - const style = this.dataLayer.getShapeStyle(data, dsData); - this.circle = L.circle(center, { - radius: circleData.radius, - ...style - }); - this.updateLabel(data, dsData); - return this.circle; - } - - protected createEventListeners(data: FormattedData, dsData: FormattedData[]): void { - this.dataLayer.getMap().circleClick(this.circle, data.$datasource); - } - - protected unbindLabel() { - this.circle.unbindTooltip(); - } - - protected bindLabel(content: L.Content): void { - this.circle.bindTooltip(content, { className: 'tb-polygon-label', permanent: true, direction: 'center'}) - .openTooltip(this.circle.getLatLng()); - } - - public update(data: FormattedData, dsData: FormattedData[]): void { - const circleData = this.dataLayer.extractCircleCoordinates(data); - const center = new L.LatLng(circleData.latitude, circleData.longitude); - if (!this.circle.getLatLng().equals(center)) { - this.circle.setLatLng(center); - } - if (this.circle.getRadius() !== circleData.radius) { - this.circle.setRadius(circleData.radius); - } - this.updateTooltip(data, dsData); - this.updateLabel(data, dsData); - const style = this.dataLayer.getShapeStyle(data, dsData); - this.circle.setStyle(style); - } -} - -export class TbCirclesDataLayer extends TbShapesDataLayer { - - constructor(protected map: TbMap, - inputSettings: CirclesDataLayerSettings) { - super(map, inputSettings); - } - - public dataLayerType(): MapDataLayerType { - return MapDataLayerType.circle; - } - - protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { - datasource.dataKeys.push(this.settings.circleKey); - return datasource; - } - - protected defaultBaseSettings(): Partial { - return defaultBaseCirclesDataLayerSettings; - } - - protected doSetup(): Observable { - return super.doSetup(); - } - - protected isValidLayerData(layerData: FormattedData): boolean { - return layerData && isNotEmptyStr(layerData[this.settings.circleKey.label]) && isJSON(layerData[this.settings.circleKey.label]); - } - - protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbDataLayerItem { - throw new TbCircleDataLayerItem(data, dsData, this.settings, this); - } - - public extractCircleCoordinates(data: FormattedData) { - const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); - return this.map.convertCircleData(circleData); - } - - -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index ddea67c613..05ec4d3c19 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -28,7 +28,7 @@ import { MapProvider, OpenStreetMapLayerSettings, TencentMapLayerSettings -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { mergeDeep } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index da394f6292..95f5e576c4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -32,7 +32,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Observable } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { MapSetting } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetComponent } from '@home/components/widget/widget.component'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts index bc0d790036..30daf48cdc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/map.models'; +import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/models/map.models'; import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; import { mergeDeep } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 2928f6205a..b44fcd75b2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -101,6 +101,17 @@ border: none; } } + .tb-cluster-marker-container { + border: none; + background-color: transparent; + } + .tb-cluster-marker-element { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + } } .tb-map-sidebar { .tb-layers, .tb-groups { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index a85406daa9..6531c81839 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -22,7 +22,8 @@ import { defaultImageMapSettings, GeoMapSettings, ImageMapSettings, - latLngPointToBounds, MapActionHandler, + latLngPointToBounds, + MapActionHandler, MapSetting, MapType, MapZoomAction, @@ -30,26 +31,23 @@ import { parseCenterPosition, TbCircleData, TbMapDatasource -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; -import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, PointExpression, Projection } from 'leaflet'; +import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; -import { - MapDataLayerType, - TbCirclesDataLayer, - TbMapDataLayer, - TbMarkersDataLayer, - TbPolygonsDataLayer -} from '@home/components/widget/lib/maps/map-data-layer'; +import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { Datasource, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; +import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/data-layer/polygons-data-layer'; +import { TbCirclesDataLayer } from '@home/components/widget/lib/maps/data-layer/circles-data-layer'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; export abstract class TbMap { @@ -277,7 +275,7 @@ export abstract class TbMap { side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left', distance: 2, trackOrigin: true, - functionBefore: (instance, helper) => { + functionBefore: (_instance, helper) => { if (helper.origin.ariaDisabled === 'true' || helper.origin.parentElement.classList.contains('active')) { return false; } @@ -478,7 +476,9 @@ class TbGeoMap extends TbMap { const map = L.map(this.mapElement, { scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), - zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons) + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL, + center: this.defaultCenterPosition }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); return of(map); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts similarity index 97% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index f6cdd500e1..526c67945b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -24,7 +24,7 @@ import { TbFunction } from '@shared/models/js-function.models'; import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; -import { MarkerShape } from '@home/components/widget/lib/maps/marker-shape.models'; +import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; export enum MapType { geoMap = 'geoMap', @@ -211,6 +211,20 @@ export interface MarkerShapeSettings extends BaseMarkerShapeSettings { export interface MarkerIconSettings extends BaseMarkerShapeSettings { icon: string; } +export interface MarkerClusteringSettings { + enable: boolean; + zoomOnClick: boolean; + maxZoom: number; + maxClusterRadius: number; + zoomAnimation: boolean; + showCoverageOnHover: boolean; + spiderfyOnMaxZoom: boolean; + chunkedLoad: boolean; + lazyLoad: boolean; + useClusterMarkerColorFunction: boolean; + clusterMarkerColorFunction: TbFunction; +} + export interface MarkersDataLayerSettings extends MapDataLayerSettings { xKey: DataKey; @@ -221,6 +235,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings { markerImage?: MarkerImageSettings; markerOffsetX: number; markerOffsetY: number; + markerClustering: MarkerClusteringSettings; } const defaultMarkerLatitudeFunction = 'var value = prevValue || 15.833293;\n' + @@ -292,7 +307,20 @@ export const defaultBaseMarkersDataLayerSettings: Partial, export type MarkerImageFunction = (data: FormattedData, markerImages: string[], dsData: FormattedData[]) => MarkerImageInfo; +export type ClusterMarkerColorFunction = (data: FormattedData[], childCount: number) => string; + export interface TbCircleData { latitude: number; longitude: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts similarity index 100% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/marker-shape.models.ts rename to ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts index ff41d2772b..d8c64942ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; @Component({ selector: 'tb-data-layer-color-settings-panel', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts index 79249a5cee..0d7643fff1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -19,7 +19,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ComponentStyle } from '@shared/models/widget-settings.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; import { DataLayerColorSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index 204e1db59a..ee00d92043 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -32,7 +32,7 @@ import { DataLayerPatternSettings, DataLayerPatternType, DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap -} from '@home/components/widget/lib/maps/map.models'; +} from '@home/components/widget/lib/maps/models/map.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index b84d61f354..dc9c069882 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -251,6 +251,10 @@ formControlName="groups">
+ +
', + `
` + childCount + '
', iconSize: new L.Point(40, 40), className: 'tb-cluster-marker-container' }); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 526c67945b..9dd6065ad6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -421,6 +421,31 @@ export const additionalMapDataSourcesToDatasources = (additionalMapDataSources: }); }; +export const additionalMapDataSourceValid = (dataSource: AdditionalMapDataSourceSettings): boolean => { + if (!dataSource.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataSource.dsType)) { + return false; + } + return !!dataSource.dataKeys?.length; +}; + +export const additionalMapDataSourceValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const dataSource: AdditionalMapDataSourceSettings = control.value; + if (!additionalMapDataSourceValid(dataSource)) { + return { + dataSource: true + }; + } + return null; +}; + +export const defaultAdditionalMapDataSourceSettings = (functionsOnly = false): AdditionalMapDataSourceSettings => { + return { + dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, + dsLabel: functionsOnly ? 'Additional data' : '', + dataKeys: [] + }; +}; + export enum MapControlsPosition { topleft = 'topleft', topright = 'topright', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts index 8de7e3d013..9e8749bba7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts @@ -58,10 +58,12 @@ const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: Dom const colorElements = Array.from(svgElement.getElementsByClassName('marker-color')); colorElements.forEach(el => { el.setAttribute('fill', '#'+color.toHex()); + el.setAttribute('fill-opacity', `${color.getAlpha()}`); }); const strokeElements = Array.from(svgElement.getElementsByClassName('marker-stroke')); strokeElements.forEach(el => { el.setAttribute('stroke', '#'+color.toHex()); + el.setAttribute('stroke-opacity', `${color.getAlpha()}`); }); return svgElement; }) @@ -90,8 +92,8 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu map((svgElement) => { const element = new Element(svgElement.firstChild); element.fill('#'+iconColor.toHex()); - //const box = element.bbox(); - const scale = size / 24;//box.height; + element.attr('fill-opacity', iconColor.getAlpha()); + const scale = size / 24; element.scale(scale); return element; }), @@ -110,6 +112,7 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu 'text-anchor': 'start' }); textElement.fill('#'+iconColor.toHex()); + textElement.attr('fill-opacity', iconColor.getAlpha()); const tspan = textElement.tspan(iconName); tspan.attr({ 'dominant-baseline': 'hanging' diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html index 789ba36855..2349c14472 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - - {{ label ? label : placeholder}} + [class]="{'tb-inline-field': inlineField, + 'tb-suffix-absolute': (inlineField && !keys?.length)}" + [appearance]="inlineField ? 'fill' : appearance" + [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> + {{ label ? label : placeholder}}
+ + warning + - + {{ requiredText }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts index 61c45fd574..cd5669292d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts @@ -110,6 +110,10 @@ export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChange @Input() subscriptSizing: SubscriptSizing = 'fixed'; + @Input() + @coerceBoolean() + inlineField = false; + @Input() @coerceBoolean() hideDataKeyLabel: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 98ab235dd7..d00325b0a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -176,6 +176,16 @@ export class MapDataLayerDialogComponent extends DialogComponent +
+
+ + + + {{ datasourceTypesTranslations.get(type) | translate }} + + + + + + + + + + +
+ + +
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss new file mode 100644 index 0000000000..e86005c768 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-form-table-row.tb-map-data-source-row { + + .tb-source-field { + flex: 1 1 50%; + display: flex; + gap: 12px; + .tb-ds-type-field, .tb-label-field, .tb-device-field, .tb-entity-alias-field { + flex: 1; + } + } + + .tb-data-keys-field { + flex: 1 1 50%; + } + + .tb-remove-button { + width: 40px; + min-width: 40px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts new file mode 100644 index 0000000000..37ba1e0c38 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts @@ -0,0 +1,207 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + forwardRef, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AdditionalMapDataSourceSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { genNextLabelForDataKeys } from '@core/utils'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-source-row', + templateUrl: './map-data-source-row.component.html', + styleUrls: ['./map-data-source-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourceRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataSourceRowComponent implements ControlValueAccessor, OnInit { + + DatasourceType = DatasourceType; + DataKeyType = DataKeyType; + + EntityType = EntityType; + + widgetType = widgetType; + + datasourceTypes: Array = []; + datasourceTypesTranslations = datasourceTypeTranslationMap; + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + @Output() + dataSourceRemoved = new EventEmitter(); + + dataSourceFormGroup: UntypedFormGroup; + + generateAdditionalDataKey = this.generateDataKey.bind(this); + + modelValue: AdditionalMapDataSourceSettings; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private cd: ChangeDetectorRef, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + if (this.context.functionsOnly) { + this.datasourceTypes = [DatasourceType.function]; + } else { + this.datasourceTypes = [DatasourceType.function, DatasourceType.device, DatasourceType.entity]; + } + this.dataSourceFormGroup = this.fb.group({ + dsType: [null, [Validators.required]], + dsLabel: [null, []], + dsDeviceId: [null, [Validators.required]], + dsEntityAliasId: [null, [Validators.required]], + dataKeys: [null, [Validators.required]] + }); + this.dataSourceFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => this.updateModel() + ); + this.dataSourceFormGroup.get('dsType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (newDsType: DatasourceType) => this.onDsTypeChanged(newDsType) + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataSourceFormGroup.disable({emitEvent: false}); + } else { + this.dataSourceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: AdditionalMapDataSourceSettings): void { + this.modelValue = value; + this.dataSourceFormGroup.patchValue( + { + dsType: value?.dsType, + dsLabel: value?.dsLabel, + dsDeviceId: value?.dsDeviceId, + dsEntityAliasId: value?.dsEntityAliasId, + dataKeys: value?.dataKeys + }, {emitEvent: false} + ); + this.updateValidators(); + this.cd.markForCheck(); + } + + private generateDataKey(key: DataKey): DataKey { + const dataKey = this.context.callbacks.generateDataKey(key.name, key.type, null, false, null); + const dataKeys: DataKey[] = this.dataSourceFormGroup.get('dataKeys').value || []; + dataKey.label = genNextLabelForDataKeys(dataKey.label, dataKeys); + return dataKey; + } + + private onDsTypeChanged(newDsType: DatasourceType) { + let updateModel = false; + const dataKeys: DataKey[] = this.dataSourceFormGroup.get('dataKeys').value; + if (dataKeys?.length) { + for (const key of dataKeys) { + updateModel = this.updateDataKeyToNewDsType(key, newDsType) || updateModel; + } + if (updateModel) { + this.dataSourceFormGroup.get('dataKeys').patchValue(dataKeys, {emitEvent: false}); + } + } + this.updateValidators(); + if (updateModel) { + this.updateModel(); + } + } + + private updateDataKeyToNewDsType(dataKey: DataKey, newDsType: DatasourceType): boolean { + if (newDsType === DatasourceType.function) { + if (dataKey.type !== DataKeyType.function) { + dataKey.type = DataKeyType.function; + return true; + } + } else { + if (dataKey.type === DataKeyType.function) { + dataKey.type = DataKeyType.attribute; + return true; + } + } + return false; + } + + private updateValidators() { + const dsType: DatasourceType = this.dataSourceFormGroup.get('dsType').value; + if (dsType === DatasourceType.function) { + this.dataSourceFormGroup.get('dsLabel').enable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else if (dsType === DatasourceType.device) { + this.dataSourceFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').enable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').disable({emitEvent: false}); + } else { + this.dataSourceFormGroup.get('dsLabel').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsDeviceId').disable({emitEvent: false}); + this.dataSourceFormGroup.get('dsEntityAliasId').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = {...this.modelValue, ...this.dataSourceFormGroup.value}; + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html new file mode 100644 index 0000000000..93eb05fb21 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html @@ -0,0 +1,43 @@ + +
+
+
+
widgets.maps.data-layer.source
+
widgets.maps.data-layer.data-keys
+
+
+
+
+ + +
+
+
+
+ +
+
+ + {{ 'widgets.maps.data-layer.no-datasources' | translate }} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss new file mode 100644 index 0000000000..1b5bd95d3e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-map-data-sources { + .tb-form-table-header-cell { + &.tb-source-header { + flex: 1 1 50%; + } + &.tb-data-keys-header { + flex: 1 1 50%; + } + &.tb-actions-header { + width: 40px; + min-width: 40px; + } + } + + .tb-form-table-body { + tb-map-data-source-row { + overflow: hidden; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts new file mode 100644 index 0000000000..4e0dea7d5a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts @@ -0,0 +1,147 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator +} from '@angular/forms'; +import { mergeDeep } from '@core/utils'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AdditionalMapDataSourceSettings, + additionalMapDataSourceValid, + additionalMapDataSourceValidator, + defaultAdditionalMapDataSourceSettings +} from '@home/components/widget/lib/maps/models/map.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-map-data-sources', + templateUrl: './map-data-sources.component.html', + styleUrls: ['./map-data-sources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapDataSourcesComponent implements ControlValueAccessor, OnInit, Validator { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + dataSourcesFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.dataSourcesFormGroup = this.fb.group({ + dataSources: [this.fb.array([]), []] + }); + this.dataSourcesFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + () => { + let dataSources: AdditionalMapDataSourceSettings[] = this.dataSourcesFormGroup.get('dataSources').value; + if (dataSources) { + dataSources = dataSources.filter(dataSource => additionalMapDataSourceValid(dataSource)); + } + this.propagateChange(dataSources); + } + ); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.dataSourcesFormGroup.disable({emitEvent: false}); + } else { + this.dataSourcesFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: AdditionalMapDataSourceSettings[] | undefined): void { + const dataSources: AdditionalMapDataSourceSettings[] = value || []; + this.dataSourcesFormGroup.setControl('dataSources', this.prepareDataSourcesFormArray(dataSources), {emitEvent: false}); + } + + public validate(c: UntypedFormControl) { + const valid = this.dataSourcesFormGroup.valid; + return valid ? null : { + dataSources: { + valid: false, + }, + }; + } + + dataSourcesFormArray(): UntypedFormArray { + return this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray; + } + + trackByDataSource(index: number, dataSourceControl: AbstractControl): any { + return dataSourceControl; + } + + removeDataSource(index: number) { + (this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray).removeAt(index); + } + + addDataSource() { + const dataSource = mergeDeep({} as AdditionalMapDataSourceSettings, + defaultAdditionalMapDataSourceSettings(this.context.functionsOnly)); + const dataSourcesArray = this.dataSourcesFormGroup.get('dataSources') as UntypedFormArray; + const dataSourceControl = this.fb.control(dataSource, [additionalMapDataSourceValidator]); + dataSourcesArray.push(dataSourceControl); + } + + private prepareDataSourcesFormArray(dataSources: AdditionalMapDataSourceSettings[]): UntypedFormArray { + const dataSourcesControls: Array = []; + dataSources.forEach((dataSource) => { + dataSourcesControls.push(this.fb.control(dataSource, [additionalMapDataSourceValidator])); + }); + return this.fb.array(dataSourcesControls); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index dd0b91b11e..34c383aaf5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -57,4 +57,12 @@ [context]="context" [mapType]="mapSettingsFormGroup.get('mapType').value">
+
+
+ {{ 'widgets.maps.data-layer.additional-datasources' | translate }} +
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index fffa911bdb..c31bddd8f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -226,6 +226,10 @@ import { MarkerShapesComponent } from '@home/components/widget/lib/settings/comm import { MarkerClusteringSettingsComponent } from '@home/components/widget/lib/settings/common/map/marker-clustering-settings.component'; +import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/common/map/map-data-sources.component'; +import { + MapDataSourceRowComponent +} from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; @NgModule({ declarations: [ @@ -310,6 +314,8 @@ import { MapDataLayerDialogComponent, MapDataLayerRowComponent, MapDataLayersComponent, + MapDataSourceRowComponent, + MapDataSourcesComponent, MapSettingsComponent, EntityAliasSelectComponent, FilterSelectComponent, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9397ba5ee5..876bc51d6b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6874,6 +6874,11 @@ "data-layer": { "source": "Source", "additional-data-keys": "Additional data keys", + "additional-datasources": "Additional datasources", + "data-keys": "Data keys", + "add-datasource": "Add datasource", + "no-datasources": "No datasources configured", + "remove-datasource": "Remove datasource", "groups": "Groups", "color": "Color", "fill-color": "Fill color", From 787d681f6f881eb7a859e108aa5919cb83dc993f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 22 Jan 2025 17:17:23 +0200 Subject: [PATCH 019/127] UI: Map - Improve marker shape opacity --- .../components/widget/lib/maps/models/marker-shape.models.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts index 9e8749bba7..8b0b31ba50 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts @@ -136,6 +136,7 @@ export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domS elements = svgElement.getElementsByClassName('marker-icon-background'); if (elements.length) { (elements[0] as SVGGElement).style.display = ''; + (elements[0] as SVGGElement).setAttribute('fill-opacity', `${color.getAlpha()}`); } return svgElement; }) From 0f848722a2961c27f6236287f9dc712cc3271629 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 22 Jan 2025 17:25:13 +0200 Subject: [PATCH 020/127] UI: Map - Improve marker shape opacity --- .../widget/lib/maps/models/marker-shape.models.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts index 8b0b31ba50..f972582201 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts @@ -82,7 +82,8 @@ export const createColorMarkerShapeURI = (iconRegistry: MatIconRegistry, domSani const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: number, color: tinycolor.Instance): Observable => { const isSvg = isSvgIcon(icon); - const iconColor = tinycolor.mix(color, tinycolor('rgba(0,0,0,0.38)')); + const iconAlpha = color.getAlpha(); + const iconColor = tinycolor.mix(color.clone().setAlpha(1), tinycolor('rgba(0,0,0,0.38)')); if (isSvg) { const [namespace, iconName] = splitIconName(icon); return iconRegistry @@ -92,7 +93,7 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu map((svgElement) => { const element = new Element(svgElement.firstChild); element.fill('#'+iconColor.toHex()); - element.attr('fill-opacity', iconColor.getAlpha()); + element.attr('fill-opacity', iconAlpha); const scale = size / 24; element.scale(scale); return element; @@ -112,7 +113,7 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu 'text-anchor': 'start' }); textElement.fill('#'+iconColor.toHex()); - textElement.attr('fill-opacity', iconColor.getAlpha()); + textElement.attr('fill-opacity', iconAlpha); const tspan = textElement.tspan(iconName); tspan.attr({ 'dominant-baseline': 'hanging' From ab997ea8e82aeddf2dceb3eb600e7ada47734389 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 22 Jan 2025 18:03:24 +0200 Subject: [PATCH 021/127] UI: minor changes --- ui-ngx/src/app/shared/import-export/import-dialog.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts index 16800ceadc..e4256229c4 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts @@ -78,7 +78,6 @@ export class ImportDialogComponent extends DialogComponent { this.importTypeChanged(); }); - this.importTypeChanged(); } isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { From c0eda85e105ed0d6d041220d5ce7e87a7731972f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 23 Jan 2025 18:00:52 +0200 Subject: [PATCH 022/127] UI: Map: image map. --- .../data/json/system/widget_bundles/maps.json | 1 + .../json/system/widget_types/image_map.json | 55 ++-- .../widget_types/image_map_deprecated.json | 102 ++++++ .../data/json/system/widget_types/map.json | 81 ++++- .../basic/map/map-basic-config.component.ts | 13 + .../lib/maps-legacy/providers/image-map.ts | 15 +- .../lib/maps/data-layer/circles-data-layer.ts | 27 +- .../lib/maps/data-layer/map-data-layer.ts | 23 +- .../lib/maps/data-layer/markers-data-layer.ts | 57 +++- .../maps/data-layer/polygons-data-layer.ts | 33 +- .../components/widget/lib/maps/geo-map.ts | 141 +++++++++ .../components/widget/lib/maps/image-map.ts | 293 ++++++++++++++++++ .../widget/lib/maps/map-widget.component.ts | 8 +- .../widget/lib/maps/map-widget.models.ts | 18 +- .../home/components/widget/lib/maps/map.scss | 23 +- .../home/components/widget/lib/maps/map.ts | 224 ++----------- .../widget/lib/maps/models/map.models.ts | 121 ++++++-- .../common/key/data-key-input.component.html | 2 +- .../common/key/data-keys.component.ts | 2 +- .../image-map-source-settings.component.html | 62 ++++ .../image-map-source-settings.component.ts | 157 ++++++++++ .../map/map-data-layer-dialog.component.html | 11 + .../map/map-data-layer-dialog.component.ts | 18 +- .../common/map/map-settings.component.html | 62 +++- .../common/map/map-settings.component.ts | 81 +++-- .../common/widget-settings-common.module.ts | 4 + .../assets/locale/locale.constant-en_US.json | 29 ++ 27 files changed, 1307 insertions(+), 356 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/image_map_deprecated.json create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts diff --git a/application/src/main/data/json/system/widget_bundles/maps.json b/application/src/main/data/json/system/widget_bundles/maps.json index 07831f4ef4..7060499f24 100644 --- a/application/src/main/data/json/system/widget_bundles/maps.json +++ b/application/src/main/data/json/system/widget_bundles/maps.json @@ -10,6 +10,7 @@ }, "widgetTypeFqns": [ "map", + "image_map", "maps_v2.openstreetmap", "maps_v2.google_maps", "maps_v2.image_map", diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index 23cf7f7a14..3330045721 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -1,36 +1,24 @@ { - "fqn": "maps_v2.image_map", + "fqn": "image_map", "name": "Image Map", - "deprecated": true, + "deprecated": false, "image": "tb-image;/api/images/system/image_map_system_widget_image.png", "description": "Displays the indoor or relative location of the entities on the image map. Useful to display floor maps, smart parking, etc. Entity coordinates are expected to be in the range from 0 to 1. Highly customizable via custom markers, marker tooltips, and widget actions. ", "descriptor": { "type": "latest", "sizeX": 8.5, - "sizeY": 6.5, + "sizeY": 6, "resources": [], - "templateHtml": "", - "templateCss": ".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n", - "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('image-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", - "settingsSchema": "", - "dataKeySettingsSchema": "", - "settingsDirective": "tb-map-widget-settings-legacy", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"image-map\",\"mapImageUrl\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false},\"title\":\"Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n markerClick: {\n name: 'widget-action.marker-click',\n multiple: false\n },\n polygonClick: {\n name: 'widget-action.polygon-click',\n multiple: false\n },\n circleClick: {\n name: 'widget-action.circle-click',\n multiple: false\n },\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", + "settingsForm": [], + "dataKeySettingsForm": [], + "settingsDirective": "tb-map-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"layers\":[],\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null},\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[{\"name\":\"testTag\",\"icon\":\"more_horiz\",\"useShowWidgetActionFunction\":null,\"showWidgetActionFunction\":\"return true;\",\"type\":\"custom\",\"customFunction\":\"console.log('It works!!!');\\n\\nconsole.log(entityName);\\n\\nconsole.log(additionalParams);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"f9b4925a-818c-15d2-6220-cf2f317bc7fe\"}]}}" }, - "tags": [ - "building", - "interior", - "venue", - "inside", - "room", - "office", - "manufacturing", - "floor", - "plant", - "storage", - "warehouse", - "depot" - ], "resources": [ { "link": "/api/images/system/image_map_system_widget_image.png", @@ -38,7 +26,7 @@ "type": "IMAGE", "subType": "IMAGE", "fileName": "image_map_system_widget_image.png", - "publicResourceKey": "hDdSISQr6elribOYD6T3uePXZI5WvNtM", + "publicResourceKey": "otJxNhSbraXccAZhnPzmfOIdEXra5Hf5", "mediaType": "image/png", "data": "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", "public": true @@ -49,7 +37,7 @@ "type": "IMAGE", "subType": "IMAGE", "fileName": "image_map_system_widget_map_image.svg", - "publicResourceKey": "QKRIYhDeBGwjaeIS601VvNLSsvZ25DRj", + "publicResourceKey": "P4hZLRjmo2P1RjGPZ4CrCMxh6xOQFQr5", "mediaType": "image/svg+xml", "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTEzNC41IiBoZWlnaHQ9Ijc2Mi43OCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI3LjA3MSAtMzA3LjkpIj4KICA8ZyBmaWxsPSJub25lIj4KICAgPHBhdGggZD0ibTkwNi4wMyA3MDYuMTMgMy40MjkyIDE3Ljc5Nm0tODgwLjg5IDQxLjEyMWMxNTAuNDQgNi44MzM0IDE0Ni4zOS0yNi4zMzQgMTY2LjQzLTI5LjMyIDM2LjE0NC01LjM4NDggMTE0LjI5LTYuNTI1NCAxNDguMzMtOC42MjM1IDQzLjM3OC0yLjY3MzggMTQxLjc2LTExLjIzMSAxODguODYtMTkuODM0IDM5LjgxMS03LjI3MjggMjIxLjM3LTAuODYyMzUgMzE5LjA3LTAuODYyMzUgNzAuODI3IDAgMTQ2LjkyLTEuNzI0NyAyMTguMTgtMS43MjQ3LTMxLjYyIDAgMTE3Ljg2LTIuNTg3MSA4Ni4yMzYtMi41ODcxbS0yNS4wOTEtNjguMTI2Yy01Mi44IDM0Ljc4NS02NS44OTUgNTEuNzQ5LTk1LjYzOSA4MS40OTMtMjQuOTMxIDI0LjkzMS0xNDAuNC0xOS4xMzktMTc4Ljk0IDM2LjY1LTEyLjI4MSAxNy43NzctNDcuMDAzIDQ2LjU0Ny02NS4xMDggNTkuMDcxLTIwLjEwNSAxMy45MDgtNTYuMDM3IDQ0Ljk1Ny02Ny43NjkgNzMuMDc4LTQuODAxNSAxMS41MDktMTMuMzggMzUuOTkzLTIzLjQ0OSA0Ni4wNjItMTAuNDk3IDEwLjQ5Ny0zOC4zNzcgNi4zODU3LTQ0LjAyMyAxNy42NDgtMTkuMDA1IDM3LjkwOC0yNS40NjUgMTAwLjkyLTY3LjYxOCAxMDIuMDVtMTkuMjgyLTYyNC4wMWMzNC42NTktMS44NzM4IDg0LjAyNyA3LjM5MTMgMTA5LjktNC4yODU0IDEzLjI4Mi01Ljk5NDEgNDEuNDA3LTIuNDYxNCA2Ni44MjktMi4zMjA1IDM1LjMyMiAwLjE5NTc4IDY0LjM4MiAwLjYzNDc3IDEwMS45MiA1LjAyMzIgMjUuMDMgMi45MjY1IDQ0LjY2MyAzNC4yODcgNTguNTI3IDUwLjY0NCAxNy4wOTkgMjAuMTczIDYyLjc2NC0xLjcxNDcgNjYuMzA2IDMyLjEzNCA1LjEwMjcgNDguNzY2LTYuMzI4NCA3OC42MzcgNi4xNDExIDk3LjM0MiAxOS45NjkgMjkuOTU0IDUwLjQ4NiAxNy44NTYgNDQuNjE5IDgzLjk3MW0tNDcyLjQ1LTM3OC43OWM0LjY0MzUgMjMuNzI5IDE1LjA2OSA3Mi43NzYgMTkuMDYxIDEzMC42NCAwLjg3MjA2IDEyLjY0IDUuNDQ3MiAyNC45OTMgNC4yMjIzIDQ1LjI3OC0yLjUxNzIgNDEuNjg4LTE1LjcxNyA0My42NzctMTUuMDkxIDYwLjM2NSAxLjQzMiAzOC4xODIgMzAuNjE0IDkzLjgzNyAzMC42MTQgMTM5LjcgMCAyNC4xODEtMi42Njk2IDExNS4zOSA3LjMzIDEzNS4zOSAwLjE1OTExIDAuMzE4MjEgMTAuMDY1IDM1Ljg4MyAxMC43NzkgNDkuMTU0IDAuOTQzNzggMTcuNTI1LTI0LjQ3OCAzOS40Ny0yOC4wMjcgNDYuNTY3LTUuNDc3NyAxMC45NTUtMzYuOTczIDEwLjg4Mi00MC4xIDI0LjE0Ni0zLjg2ODggMTYuNDE1LTMuODY2MyA0My43OTcgNC4wNDY1IDU5LjQ0MW05Ny4zMzctNjkxLjAxYy01LjAxMzMgMzUuNTE2LTQzLjY1OSAxMS4zMTctNTguNTM5IDIzLjc4MS0yMS4zMyAxNy44NjktNjIuNSAzMS40MzItNzAuMTI0IDM1LjM2Ny0zNS4wODggMTguMTA4LTExMC40Ny0xNS4xNDItMTI1LjYxIDQuMjY4NC0xNS45NTEgMjAuNDQ3LTAuMDczNSA2MS40NjYtOS4xNDY3IDg0LjE0OS02LjAzNTcgMTUuMDg5LTE4Ljg3NyAyMy4wMTctMjcuNDQgMzIuOTI4LTE5Ljc0OCAyMi44NTYtNjkuOTc0IDY5LjgyNC04NC43NTkgMTAwLTcuNDk3NCAxNS4zMDQtMy4yODQzIDQ0LjQyLTMuNDcwNSA2My4zNDMtMC4xMjc5MyAxMi45OTQtMC44MTAxNSAyMy4xMDQgMi40MDM0IDI4LjI3NiA0Ljk2MTYgNy45ODU4IDIzLjcyIDI4LjExMiAyNC4yMzkgNTAuNjExIDAuMjk0MTEgMTIuNzcxIDAuMDEzMyA3OC41OTEgMy4wNDg5IDg3LjY1NSAyLjMxMjYgNi45MDU1IDQuMjIgMjYuNTY1IDEwLjIxNCAzNi41ODcgMTEuMzU0IDE4Ljk4NCA0LjM4NzQgNDAuMTU3IDI3Ljg5NyA1My41MDggMTkuMDUgMTAuODE5IDQ2Ljg3OCAxMi4yMTkgODEuOTI2IDE0LjQ2MSAzMy43MDMgMi4xNTU5IDYxLjUxMi0xLjQzMDQgNzYuOTIxIDYuMTQxMSAxMS41ODUgNS42OTI3IDguNTgxNSAxNy45MzMgMTQuMjk1IDI5LjM2MSA1LjY0MDQgMTEuMjgxIDMxLjUwMyAxMS4xNTYgNDEuODA0IDQzLjQ1NSA3LjYwNTkgMjMuODQ3IDMuMDg1OSA0NC4xNTcgNi43MDc2IDY1Ljg4NyIgc3Ryb2tlPSIjMzY0ZTU5IiBzdHJva2Utd2lkdGg9IjMiLz4KICAgPHBhdGggZD0ibTQzLjI3OCA1MTcuOTVzMjMwLjg1LTMuNjM4IDI1MC4wMS0zLjY1ODdjNy40ODIyLThlLTMgOC42MTk1IDUuMTUxOSAxNC4wMjEgMTEuNDU5IDI0LjU5NiAyOC43MTkgOTMuOTEgMTEyLjk0IDkzLjkxIDExMi45NCIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogICA8cGF0aCBkPSJtMzUuOTYxIDU3Ny43czE2NS41Mi0xLjY4NDUgMjQ4Ljc4LTEuNjg0NWM0Ljk0NzUgMCA3LjcyOTktMi44ODMzIDEwLjUzOC01LjcyOTggOS42NjExLTkuNzk0MiAyNS42MzItMjguNTkgMjUuNjMyLTI4LjU5IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPC9nPgogIDxwYXRoIGQ9Im0zOC40IDY0MS43MyAzOTMuMzEtNC4yNjg0IiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0iIzMzNiIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxwYXRoIGQ9Im0zOS4wMDkgNzA0LjU0IDQ4NC4xNi02LjcwNzYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSIjMzM2IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2Ij4KICAgPGcgc3Ryb2tlLXdpZHRoPSIxcHgiPgogICAgPHBhdGggZD0ibTMwMy45NiA2ODIuNTkgMTQ2LjggMS44MjkzYzEwLjUzNCAwLjEzMTI3IDE0LjM0NC0yLjYzNzQgMjUuNDg3LTYuMzcyOCAxMC40MTItMy40OTAzIDMxLjQyNC0yLjY5OSA0MS4zODUtMi43NzM4bDQwNS41Ni0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDI2LjIyIDMxNC44OWMyLjA2NzUgOS4wNTI3IDEuODQxOCA1MS43MjggNi41MDc5IDc0LjgzNSAxLjY3NDggOC4yOTM0IDguNjc1MSAxNC4wNjYgMTAuMDU1IDE0Ljg1OSA0LjkwMTUgMi44MTQ2IDEwLjgxNSA4LjE0OTggMTMuMDQ2IDE2LjA4OCA2Ljc1NzggMjQuMDQ2IDAuODc5NzIgNjguNDUyIDAuODc5NzIgMTEwLjY5IDAgNi4wOTc4IDEuNjYwMSAzMC4xNDctMi4xNTU5IDMzLjk2My0yLjU0MDggMi41NDA4LTAuMjgxNjMgMTIuOTkxLTMuNDM2OCAxNi4xNDRsLTkuODQ5NCA5Ljg0MzFjLTEwLjM2NyAxMC4zNi0xMS41OSA2LjUyNjEtMTcuNzM4IDE4LjgyMy0zLjU2NzcgNy4xMzU0IDUuNDAyNCAyMC42NzIgNy4zNTQzIDI0LjU3NiAxLjkzMjEgMy44NjQzLTEuODQyMiA0Ljc3NzctMS43OTI0IDcuNDQ2MyAwLjI1Mjg2IDEzLjU0NSAyLjI5NzUgMzczLjkzIDIuMjk3NSAzNzMuOTMiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzY1LjI0IDUxOS43OCA0LjExNiA1MDIuMTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMTE2LjUzIDUwNC4xOSAzLjg4MDYgMzEwLjk2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTMxNy42OCA1NzYuNDkgMTMwLjE5IDEuNTI0NGM0LjUxMDggMy4yNDE3IDIwLjM0NSA3Ljk2ODUgMjcuNzQ1IDQuMjY4NCAzLjE1NTUtMS41Nzc3IDkuNDE5LTUuMzg4MiAxNC4wMjUtMy45NjM2IDQuMjY3IDEuMzE5OCA2LjAxNjkgMy4xMTYzIDEwLjM2NiAzLjA0ODkgMTAuMzA0LTAuMTU5NzUgMjAuMjEyIDAuMzg3NDEgMzAuNDg5IDAuMzA0ODkgMTc3Ljg5LTEuNDI4MyAzNTYuNTktMi4xMzI1IDUzNC43Ny0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDc1LjMxIDU4Mi44OWMtMy40NDQyIDExLjM1MS0yLjEwMzQgMTIuNDM0IDMuNjU4NiAyMS4wMzcgMy43OTQ0IDUuNjY1NiA1MC44NjMgMTMuMDM4IDQxLjQ2NSAyNy4xMzUtMTAuNTM3IDE1LjgwNS0yMi44OTctNS40Nzc3LTMzLjg0My0xLjgyOTMtNS40NTI0IDEuODE3NC03LjM0OSA1LjQ1NjMtMy42NTg3IDkuMTQ2NiAyLjgwNjggMi44MDY4IDQuMDQ4IDEuODA0IDYuNTIwMyA1LjEwMDQiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjAxIDYzNi44NWM4LjMxOSAxMy4xMSAxOC44NDYgMTQuNjM1IDM1LjY3MiAxNC42MzUgMi45Mzg2IDAgNy44Ny0wLjkzMzcxIDEwLjY3MSAwIDExLjM1OSAzLjc4NjQgMjcuMTk0IDEwLjI3NiAzNi4yMDIgMjEuMTI5IDguMjggOS45NzY2IDEwLjI1MyAyMy44ODMgNy43MDIgMzcuMTA0LTYuMTY5OSAzMS45OC0xNi43MTQgNTYuOTg5LTE5LjA0NCA4Ni41NjktMS4zNDggMTcuMTE5IDQuNTA5NiAyMi41MzUgMTEuMDcxIDMzLjkyOSAxMC42NyAxOC41MjcgOC43MjQ1IDE0LjIgOC41NzE0IDM0LjI4Ni0wLjEzOTYzIDE4LjMxOSAwIDYwLjI2NCAwIDgwLjcxNCIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNTEgNjU4Ljk2Yy0xMC42ODEgMC45MDQ1NC03LjEwOC01LjYwMjYtMTAuODI0LTguMDc5Ni00Ljc4NDUtMy4xODk3LTEyLjIyNy0xLjI1MS0xNi43NjktNS43OTI5LTAuNjY2MTItMC42NjYxMi04LjgwOTctNC4xMDg4LTEwLjE3NC0yLjc0NC04LjM2NDYgOC4zNjQ2LTMuMDQ4OSAyMC41NTItMy4wNDg5IDMzLjUzOGwzLjAyMiAzMzkuNyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MTcuOTkgNjUxLjAzYy0wLjIyMTcxLTIuNzAxOCAxLjkwMzUtNS41NjIxIDMuMzUzOC03LjAxMjQgMS43OTk0LTEuNzk5NCA2LjkyMjkgMS4wMDQyIDguODQxOC0wLjkxNDY2IDAuMjg3NjUtMC4yODc2NiAwLjg0MzI5LTExLjE2NCAwLjIyODY2LTEzLjU2OC0yLjA2NDgtOC4wNzQyLTIuMDU4LTI4LjY1Ny0yLjA1OC0zOC43MjF2LTczLjE3MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNjYgNjc1LjQyLTAuNDU3MzMtMzEuNTU2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc2Ni4zMiA1NzkuNjQgMC40MzExOCAxMy43OThjMy4xMzY0IDQuNjY5MiAzLjAxODIgOS42MDA3IDMuMDE4MiAxNi4zODV2MTU3LjM4IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTExMjIuOSA3NjUuOTFjLTIwMi4zMSA0LjY5MDUtNDAzLjc0LTEuMTEzOC02MDUuOTUgMy4zNTM5LTEwLjg2NCAwLjI0MDAyLTMuMzYxNS04LjU4NjMtMjguNTM3LTguNTg2MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im04NjAuMDEgNzM3LjA3cy05Ny40NDggMC44NTgwNi0xNDcuNTcgMC44NTgwNmMtNS4yNjg2IDAtNC41MTU1LTguMzI5OS03LjMwMDktOC4zMjk5LTMuOTc0NCAwLTguNjI5MiAwLjAyMDEtMTAuNTA5IDAuMDM1OS0yLjMzNDggMC4wMTk3LTEuODEwOSA4LjM2Ni00LjE0NTggOC4zNjY5LTQ2LjE2OSAwLjAxODgtMTY3LjQxLTEuMzA4LTE3NS4wNS0xLjMwOC00LjQyOTYgMC04LjU3NjMtNi40Mzk3LTEzLjEzMi02LjQzOTdoLTE0LjM5NSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im02NzUuMDEgODMxLjE3LTAuNjA5NzgtNTIxLjc3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc5OS40IDMxMy4wNiAxLjIxOTYgNDk1Ljg3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTczNi41OSAzMTIuNDUtMS4yMTk2IDcxNi40OSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MzAuMDMgNjQzLjQ2IDM5Mi4zNy0zLjAxODIiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtODU5LjQ1IDMxNC45IDEuMjkzNSA1MDcuOTgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICAgPHBhdGggZD0ibTkyMS41NCAzMTAuNTkgMS43MjQ3IDUzMS43NSIgY29sb3I9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgIDxnIHN0cm9rZS13aWR0aD0iMXB4Ij4KICAgIDxwYXRoIGQ9Im03MzYuMjkgNDUzLjMxIDE4NS42OC0wLjMwNDg5IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTEwNjAuOCA1MTQuOTdzLTM2My4yOC01LjYyNjItNTQ0LjY1IDIuNTIxOGMtNC4xNzc4IDAuMTg3NjktMTIuNSAxLjA2NzEtMTIuNSAxLjA2NzEtMS41NzEgMC4xMzQxLTIuMDAwOS0yLjMyNS0yLjU5MTYtMy41MDYyLTAuMDk2Ny0wLjE5MzQzLTcuMDYwOC0xLjkzMzQtNy42MjIyLTEuMzcyLTIuODkzMSAyLjg5MzEtNy42MzE3IDQuMjQ4Ny0xMi4xOTYgNC4xMTZsLTExMi4wNS0zLjI1NzgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzk5LjgyIDQ3OS42MSAxMS42NDIgNS42MDUzYzIuOTg0MSAxLjQzNjggNi41Mjg4LTAuNDc3MTIgOS45MTcxLTAuNDMxMThsMTI3LjIgMS43MjQ3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTUxOS4yNSA1MTcuMTItMC40MzExOS0yMDguNjkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjkzIDM4OS43MWMxMS4wNDUgMCAzNS41MzMgMC42MTkyNyA0Mi41OC0xLjAwNCA4LjQwNTItMS45MzYyIDcuMDY2LTYuOTUzOCAxNC4xOTctNi45NTM4IDcuODA5NSAwIDYuNTQyOSA4LjA2MjQgMjAuMTQyIDguMDYyNCAxMy45OTEgMCA0NC45NzcgMC4zNzg4NiA2My45NCAwLjM3ODg2IDEyLjA4NCAwIDgyLjAwMyAwLjMwNDg5IDkzLjYwMSAwLjMwNDg5IDguNzYwNSAwIDEzLjE2LTIuMjg4MyAyMS4zNDItNy4wMTI0IDcuMTk1Mi00LjE1NDEgMi4wNTQ2LTkuNDkxNCAyMC40MjgtOC44NDE4IDIzLjE0NSAwLjgxODMzIDEyLjY0MyAxNC4wMjUgMzIuMzE4IDE0LjAyNWgxNTAuOTJjMTQuMzMyIDAtNC4xMTkxLTEzLjExIDI5LjI2OS0xMy40MTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4Ij4KICAgPHRleHQgeD0iNTg4LjY3OTU3IiB5PSI3MzUuODA0NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU4OC42Nzk1NyIgeT0iNzM1LjgwNDYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TGluY29sbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3MDkuODcxODMiIHk9Ii04MDIuMzc3MzgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjcwOS44NzE4MyIgeT0iLTgwMi4zNzczOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldvb2RsYXduPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTYyLjExOTI2IiB5PSItNzcxLjk2ODE0IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjIuMTE5MjYiIHk9Ii03NzEuOTY4MTQiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5FZGdlbW9vcjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5OC4zMDQ4NyIgeT0iLTczOC4zNjY0NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk4LjMwNDg3IiB5PSItNzM4LjM2NjQ2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTkyLjEyMjg2IiB5PSItNjc3LjIwMzk4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1OTIuMTIyODYiIHk9Ii02NzcuMjAzOTgiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5IaWxsc2lkZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5Ny4zMjcwOSIgeT0iLTg2Mi42MTQwNyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk3LjMyNzA5IiB5PSItODYyLjYxNDA3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Um9jazwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU4Ny4zNzAxOCIgeT0iLTkyNi4xMzY2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1ODcuMzcwMTgiIHk9Ii05MjYuMTM2NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkNlbnRyYWw8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODczLjgzMjI4IiB5PSI1NzcuMDMyNDciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijg3My44MzIyOCIgeT0iNTc3LjAzMjQ3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI4NzUuOTY2NDkiIHk9IjUxMC4yNjE4MSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODc1Ljk2NjQ5IiB5PSI1MTAuMjYxODEiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yMXN0PC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9Ijg4MS4zMTY1OSIgeT0iNDUwLjE5ODc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI4ODEuMzE2NTkiIHk9IjQ1MC4xOTg3NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjI5dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNjE1Ljc5MjQ4IiB5PSIzODcuNzQ3MTYiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjYxNS43OTI0OCIgeT0iMzg3Ljc0NzE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Mzd0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI0ODQuNjkwMzciIHk9IjQ4MS42NTI4NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDg0LjY5MDM3IiB5PSI0ODEuNjUyODYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yNXRoPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU2My4wNDY3NSIgeT0iNTEzLjM2MTMzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjMuMDQ2NzUiIHk9IjUxMy4zNjEzMyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI0MzMuNTgwNzUiIHk9Ii00NjAuNzMzMTIiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjQzMy41ODA3NSIgeT0iLTQ2MC43MzMxMiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkFtaWRvbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjQwNS41MzA5OCIgeT0iLTUyMy41NDAxNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDA1LjUzMDk4IiB5PSItNTIzLjU0MDE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QXJrYW5zYXM8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3NDUuNDg0NjIiIHk9Ii0zNzIuNTg1OTQiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc0NS40ODQ2MiIgeT0iLTM3Mi41ODU5NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTYuNzI4MzMiIHk9Ii01MzEuMjU5MjgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5Ni43MjgzMyIgeT0iLTUzMS4yNTkyOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldhY288L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTUuNDM0ODEiIHk9Ii0xMjIuNTAyOTUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5NS40MzQ4MSIgeT0iLTEyMi41MDI5NSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hemllPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgeD0iNjk1Ljc3Mjk1IiB5PSIxNjIuMDY4NzciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY5NS43NzI5NSIgeT0iMTYyLjA2ODc3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Wm9vPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjI0MC41ODk5NyIgeT0iNTc0LjQ0NTQzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIyNDAuNTg5OTciIHk9IjU3NC40NDU0MyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjA2LjAzMTc1IiB5PSI1MTEuNjM2NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIwNi4wMzE3NSIgeT0iNTExLjYzNjYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjYyMC40NDMxMiIgeT0iLTUwNi42ODIxOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNjIwLjQ0MzEyIiB5PSItNTA2LjY4MjE5IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TmltczwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzNzAuMjE2ODYiIHk9IjY5OC44NDAwOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iMzcwLjIxNjg2IiB5PSI2OTguODQwMDkiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NYXBsZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCBkPSJtMzY3LjkxIDEwMTBoMjYzLjAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxnIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCI+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzM2LjI2NzQ2IiB5PSItNDMzLjEzNzc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI3MzYuMjY3NDYiIHk9Ii00MzMuMTM3NzYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NZXJpZGlhbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI1NzIuODMyMTUiIHk9IjY0MC4yMDUyNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTcyLjgzMjE1IiB5PSI2NDAuMjA1MjYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5Eb3VnbGFzPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjQ5OS40ODk2MiIgeT0iMTAwOC42MDY5IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI0OTkuNDg5NjIiIHk9IjEwMDguNjA2OSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjQ3dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjE2LjY0NTQzIiB5PSI3MjUuOTgyOTciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIxNi42NDU0MyIgeT0iNzI1Ljk4Mjk3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9Ijc3NC44NzU2MSIgeT0iLTUwOC4xODk3MyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNzc0Ljg3NTYxIiB5PSItNTA4LjE4OTczIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TWNDbGVhbjwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDI4Ny4zNikiIGQ9Im0zNjQuMTYgNjU4LjQzIDI5OS41MS0xLjAxMDJjNi40OTg3LTAuMDIxOSA2Ljk3NzIgOS4yNTQxIDE2LjU5NiA5LjM5MjUgMTIuMDU0IDAuMTczMzkgMjkuMTExLTAuNTM1NzIgNTQuMTE0LTAuMzAxMSIgY29sb3I9IiMwMDAwMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzMzNiIgc3Ryb2tlLXdpZHRoPSIxcHgiLz4KICA8dGV4dCB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hY0FydGh1cjwvdHNwYW4+PC90ZXh0PgogIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzgwLjg0NjA3IiB5PSItNDkwLjI0NTk3IiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc4MC44NDYwNyIgeT0iLTQ5MC4yNDU5NyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPlNlbmVjYTwvdHNwYW4+PC90ZXh0PgogIDxwYXRoIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMjg3LjM2KSIgZD0ibTM2Ny43IDUzNy4yMSAxNDEuMjgtMS4wMTAyYzYuNDktMC4wNDY0IDEyLjc4MSA3LjIzNTQgMTkuMTkzIDcuMzIzNiA1NS45MjQgMC43Njg5IDE1OC42OS0wLjE3MzMzIDIzNi41MS0xLjAxMDIgNy44Mzk2LTAuMDg0MyAyMi42MzEtMTkuODU0IDMwLjMwNS0yMC40NTYgMjIuMjY2LTEuMzUxOCA0NS4xNzktMC41MDUwNyA2Ny42OC0wLjUwNTA3IDE2LjE0Ny0wLjYzMjQxIDMuNjEwMiAyMC43MDggMjYuNzY5IDIwLjcwOGwyNDMuNDUtMS4wMTAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDx0ZXh0IHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+UGF3bmVlPC90c3Bhbj48L3RleHQ+CiAgPHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAyODcuMzYpIiBkPSJtNTU0LjI5IDcyMS40My00LjI4NTctMTc4LjIxLTIuODU3MS00NDAuNzEtMC4zNTcxNC03OS4yODYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1MjkuNjI1MzEiIHk9Ii01NTAuODQ3NzgiIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTI5LjYyNTMxIiB5PSItNTUwLjg0Nzc4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QnJvYWR3YXk8L3RzcGFuPjwvdGV4dD4KIDwvZz4KPC9zdmc+Cg==", "public": true @@ -98,5 +86,20 @@ "data": "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", "public": true } + ], + "scada": false, + "tags": [ + "building", + "interior", + "venue", + "inside", + "room", + "office", + "manufacturing", + "floor", + "plant", + "storage", + "warehouse", + "depot" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/image_map_deprecated.json b/application/src/main/data/json/system/widget_types/image_map_deprecated.json new file mode 100644 index 0000000000..23cf7f7a14 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/image_map_deprecated.json @@ -0,0 +1,102 @@ +{ + "fqn": "maps_v2.image_map", + "name": "Image Map", + "deprecated": true, + "image": "tb-image;/api/images/system/image_map_system_widget_image.png", + "description": "Displays the indoor or relative location of the entities on the image map. Useful to display floor maps, smart parking, etc. Entity coordinates are expected to be in the range from 0 to 1. Highly customizable via custom markers, marker tooltips, and widget actions. ", + "descriptor": { + "type": "latest", + "sizeX": 8.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "", + "templateCss": ".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.map = new TbMapWidgetV2('image-map', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.actionSources = function() {\n return TbMapWidgetV2.actionSources();\n}\n\nself.onDestroy = function() {\n self.ctx.map.destroy();\n}\n\nself.typeParameters = function() {\n return {\n hasDataPageLink: true\n };\n}", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-map-widget-settings-legacy", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.680594833308841,\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.9430343126300238,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.1784452363910778,\"funcBody\":\"return \\\"colorpin\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]},{\"type\":\"function\",\"name\":\"Second point\",\"entityAliasId\":null,\"filterId\":null,\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"xPos\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"yPos\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.773875863339494,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.405822538899673,\"funcBody\":\"return \\\"thermometer\\\";\",\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"provider\":\"image-map\",\"mapImageUrl\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"xPosKeyName\":\"xPos\",\"yPosKeyName\":\"yPos\",\"defaultCenterPosition\":\"0,0\",\"disableScrollZooming\":false,\"disableDoubleClickZooming\":false,\"disableZoomControl\":false,\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"mapPageSize\":16384,\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"posFunction\":\"return {x: origXPos, y: origYPos};\",\"draggableMarker\":false,\"showLabel\":true,\"useLabelFunction\":false,\"label\":\"${entityName}\",\"showTooltip\":true,\"showTooltipAction\":\"click\",\"autocloseTooltip\":true,\"useTooltipFunction\":false,\"tooltipPattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See advanced settings for details\",\"tooltipOffsetX\":0,\"tooltipOffsetY\":-1,\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageSize\":34,\"markerImageFunction\":\"var type = dsData[dsIndex]['Type'];\\nif (type == 'thermometer') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"],\"showPolygon\":false,\"polygonKeyName\":\"perimeter\",\"editablePolygon\":false,\"showPolygonLabel\":false,\"usePolygonLabelFunction\":false,\"polygonLabel\":\"${entityName}\",\"showPolygonTooltip\":false,\"showPolygonTooltipAction\":\"click\",\"autoClosePolygonTooltip\":true,\"usePolygonTooltipFunction\":false,\"polygonTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"polygonColor\":\"#3388ff\",\"polygonOpacity\":0.2,\"usePolygonColorFunction\":false,\"polygonStrokeColor\":\"#3388ff\",\"polygonStrokeOpacity\":1,\"polygonStrokeWeight\":3,\"usePolygonStrokeColorFunction\":false,\"showCircle\":false,\"circleKeyName\":\"perimeter\",\"editableCircle\":false,\"showCircleLabel\":false,\"useCircleLabelFunction\":false,\"circleLabel\":\"${entityName}\",\"showCircleTooltip\":false,\"showCircleTooltipAction\":\"click\",\"autoCloseCircleTooltip\":true,\"useCircleTooltipFunction\":false,\"circleTooltipPattern\":\"${entityName}

TimeStamp: ${ts:7}\",\"circleFillColor\":\"#3388ff\",\"circleFillColorOpacity\":0.2,\"useCircleFillColorFunction\":false,\"circleStrokeColor\":\"#3388ff\",\"circleStrokeOpacity\":1,\"circleStrokeWeight\":3,\"useCircleStrokeColorFunction\":false},\"title\":\"Image Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" + }, + "tags": [ + "building", + "interior", + "venue", + "inside", + "room", + "office", + "manufacturing", + "floor", + "plant", + "storage", + "warehouse", + "depot" + ], + "resources": [ + { + "link": "/api/images/system/image_map_system_widget_image.png", + "title": "\"Image Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "image_map_system_widget_image.png", + "publicResourceKey": "hDdSISQr6elribOYD6T3uePXZI5WvNtM", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/image_map_system_widget_map_image.svg", + "title": "\"Image Map\" system widget map image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "image_map_system_widget_map_image.svg", + "publicResourceKey": "QKRIYhDeBGwjaeIS601VvNLSsvZ25DRj", + "mediaType": "image/svg+xml", + "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTEzNC41IiBoZWlnaHQ9Ijc2Mi43OCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI3LjA3MSAtMzA3LjkpIj4KICA8ZyBmaWxsPSJub25lIj4KICAgPHBhdGggZD0ibTkwNi4wMyA3MDYuMTMgMy40MjkyIDE3Ljc5Nm0tODgwLjg5IDQxLjEyMWMxNTAuNDQgNi44MzM0IDE0Ni4zOS0yNi4zMzQgMTY2LjQzLTI5LjMyIDM2LjE0NC01LjM4NDggMTE0LjI5LTYuNTI1NCAxNDguMzMtOC42MjM1IDQzLjM3OC0yLjY3MzggMTQxLjc2LTExLjIzMSAxODguODYtMTkuODM0IDM5LjgxMS03LjI3MjggMjIxLjM3LTAuODYyMzUgMzE5LjA3LTAuODYyMzUgNzAuODI3IDAgMTQ2LjkyLTEuNzI0NyAyMTguMTgtMS43MjQ3LTMxLjYyIDAgMTE3Ljg2LTIuNTg3MSA4Ni4yMzYtMi41ODcxbS0yNS4wOTEtNjguMTI2Yy01Mi44IDM0Ljc4NS02NS44OTUgNTEuNzQ5LTk1LjYzOSA4MS40OTMtMjQuOTMxIDI0LjkzMS0xNDAuNC0xOS4xMzktMTc4Ljk0IDM2LjY1LTEyLjI4MSAxNy43NzctNDcuMDAzIDQ2LjU0Ny02NS4xMDggNTkuMDcxLTIwLjEwNSAxMy45MDgtNTYuMDM3IDQ0Ljk1Ny02Ny43NjkgNzMuMDc4LTQuODAxNSAxMS41MDktMTMuMzggMzUuOTkzLTIzLjQ0OSA0Ni4wNjItMTAuNDk3IDEwLjQ5Ny0zOC4zNzcgNi4zODU3LTQ0LjAyMyAxNy42NDgtMTkuMDA1IDM3LjkwOC0yNS40NjUgMTAwLjkyLTY3LjYxOCAxMDIuMDVtMTkuMjgyLTYyNC4wMWMzNC42NTktMS44NzM4IDg0LjAyNyA3LjM5MTMgMTA5LjktNC4yODU0IDEzLjI4Mi01Ljk5NDEgNDEuNDA3LTIuNDYxNCA2Ni44MjktMi4zMjA1IDM1LjMyMiAwLjE5NTc4IDY0LjM4MiAwLjYzNDc3IDEwMS45MiA1LjAyMzIgMjUuMDMgMi45MjY1IDQ0LjY2MyAzNC4yODcgNTguNTI3IDUwLjY0NCAxNy4wOTkgMjAuMTczIDYyLjc2NC0xLjcxNDcgNjYuMzA2IDMyLjEzNCA1LjEwMjcgNDguNzY2LTYuMzI4NCA3OC42MzcgNi4xNDExIDk3LjM0MiAxOS45NjkgMjkuOTU0IDUwLjQ4NiAxNy44NTYgNDQuNjE5IDgzLjk3MW0tNDcyLjQ1LTM3OC43OWM0LjY0MzUgMjMuNzI5IDE1LjA2OSA3Mi43NzYgMTkuMDYxIDEzMC42NCAwLjg3MjA2IDEyLjY0IDUuNDQ3MiAyNC45OTMgNC4yMjIzIDQ1LjI3OC0yLjUxNzIgNDEuNjg4LTE1LjcxNyA0My42NzctMTUuMDkxIDYwLjM2NSAxLjQzMiAzOC4xODIgMzAuNjE0IDkzLjgzNyAzMC42MTQgMTM5LjcgMCAyNC4xODEtMi42Njk2IDExNS4zOSA3LjMzIDEzNS4zOSAwLjE1OTExIDAuMzE4MjEgMTAuMDY1IDM1Ljg4MyAxMC43NzkgNDkuMTU0IDAuOTQzNzggMTcuNTI1LTI0LjQ3OCAzOS40Ny0yOC4wMjcgNDYuNTY3LTUuNDc3NyAxMC45NTUtMzYuOTczIDEwLjg4Mi00MC4xIDI0LjE0Ni0zLjg2ODggMTYuNDE1LTMuODY2MyA0My43OTcgNC4wNDY1IDU5LjQ0MW05Ny4zMzctNjkxLjAxYy01LjAxMzMgMzUuNTE2LTQzLjY1OSAxMS4zMTctNTguNTM5IDIzLjc4MS0yMS4zMyAxNy44NjktNjIuNSAzMS40MzItNzAuMTI0IDM1LjM2Ny0zNS4wODggMTguMTA4LTExMC40Ny0xNS4xNDItMTI1LjYxIDQuMjY4NC0xNS45NTEgMjAuNDQ3LTAuMDczNSA2MS40NjYtOS4xNDY3IDg0LjE0OS02LjAzNTcgMTUuMDg5LTE4Ljg3NyAyMy4wMTctMjcuNDQgMzIuOTI4LTE5Ljc0OCAyMi44NTYtNjkuOTc0IDY5LjgyNC04NC43NTkgMTAwLTcuNDk3NCAxNS4zMDQtMy4yODQzIDQ0LjQyLTMuNDcwNSA2My4zNDMtMC4xMjc5MyAxMi45OTQtMC44MTAxNSAyMy4xMDQgMi40MDM0IDI4LjI3NiA0Ljk2MTYgNy45ODU4IDIzLjcyIDI4LjExMiAyNC4yMzkgNTAuNjExIDAuMjk0MTEgMTIuNzcxIDAuMDEzMyA3OC41OTEgMy4wNDg5IDg3LjY1NSAyLjMxMjYgNi45MDU1IDQuMjIgMjYuNTY1IDEwLjIxNCAzNi41ODcgMTEuMzU0IDE4Ljk4NCA0LjM4NzQgNDAuMTU3IDI3Ljg5NyA1My41MDggMTkuMDUgMTAuODE5IDQ2Ljg3OCAxMi4yMTkgODEuOTI2IDE0LjQ2MSAzMy43MDMgMi4xNTU5IDYxLjUxMi0xLjQzMDQgNzYuOTIxIDYuMTQxMSAxMS41ODUgNS42OTI3IDguNTgxNSAxNy45MzMgMTQuMjk1IDI5LjM2MSA1LjY0MDQgMTEuMjgxIDMxLjUwMyAxMS4xNTYgNDEuODA0IDQzLjQ1NSA3LjYwNTkgMjMuODQ3IDMuMDg1OSA0NC4xNTcgNi43MDc2IDY1Ljg4NyIgc3Ryb2tlPSIjMzY0ZTU5IiBzdHJva2Utd2lkdGg9IjMiLz4KICAgPHBhdGggZD0ibTQzLjI3OCA1MTcuOTVzMjMwLjg1LTMuNjM4IDI1MC4wMS0zLjY1ODdjNy40ODIyLThlLTMgOC42MTk1IDUuMTUxOSAxNC4wMjEgMTEuNDU5IDI0LjU5NiAyOC43MTkgOTMuOTEgMTEyLjk0IDkzLjkxIDExMi45NCIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogICA8cGF0aCBkPSJtMzUuOTYxIDU3Ny43czE2NS41Mi0xLjY4NDUgMjQ4Ljc4LTEuNjg0NWM0Ljk0NzUgMCA3LjcyOTktMi44ODMzIDEwLjUzOC01LjcyOTggOS42NjExLTkuNzk0MiAyNS42MzItMjguNTkgMjUuNjMyLTI4LjU5IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPC9nPgogIDxwYXRoIGQ9Im0zOC40IDY0MS43MyAzOTMuMzEtNC4yNjg0IiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0iIzMzNiIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxwYXRoIGQ9Im0zOS4wMDkgNzA0LjU0IDQ4NC4xNi02LjcwNzYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSIjMzM2IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2Ij4KICAgPGcgc3Ryb2tlLXdpZHRoPSIxcHgiPgogICAgPHBhdGggZD0ibTMwMy45NiA2ODIuNTkgMTQ2LjggMS44MjkzYzEwLjUzNCAwLjEzMTI3IDE0LjM0NC0yLjYzNzQgMjUuNDg3LTYuMzcyOCAxMC40MTItMy40OTAzIDMxLjQyNC0yLjY5OSA0MS4zODUtMi43NzM4bDQwNS41Ni0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDI2LjIyIDMxNC44OWMyLjA2NzUgOS4wNTI3IDEuODQxOCA1MS43MjggNi41MDc5IDc0LjgzNSAxLjY3NDggOC4yOTM0IDguNjc1MSAxNC4wNjYgMTAuMDU1IDE0Ljg1OSA0LjkwMTUgMi44MTQ2IDEwLjgxNSA4LjE0OTggMTMuMDQ2IDE2LjA4OCA2Ljc1NzggMjQuMDQ2IDAuODc5NzIgNjguNDUyIDAuODc5NzIgMTEwLjY5IDAgNi4wOTc4IDEuNjYwMSAzMC4xNDctMi4xNTU5IDMzLjk2My0yLjU0MDggMi41NDA4LTAuMjgxNjMgMTIuOTkxLTMuNDM2OCAxNi4xNDRsLTkuODQ5NCA5Ljg0MzFjLTEwLjM2NyAxMC4zNi0xMS41OSA2LjUyNjEtMTcuNzM4IDE4LjgyMy0zLjU2NzcgNy4xMzU0IDUuNDAyNCAyMC42NzIgNy4zNTQzIDI0LjU3NiAxLjkzMjEgMy44NjQzLTEuODQyMiA0Ljc3NzctMS43OTI0IDcuNDQ2MyAwLjI1Mjg2IDEzLjU0NSAyLjI5NzUgMzczLjkzIDIuMjk3NSAzNzMuOTMiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzY1LjI0IDUxOS43OCA0LjExNiA1MDIuMTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMTE2LjUzIDUwNC4xOSAzLjg4MDYgMzEwLjk2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTMxNy42OCA1NzYuNDkgMTMwLjE5IDEuNTI0NGM0LjUxMDggMy4yNDE3IDIwLjM0NSA3Ljk2ODUgMjcuNzQ1IDQuMjY4NCAzLjE1NTUtMS41Nzc3IDkuNDE5LTUuMzg4MiAxNC4wMjUtMy45NjM2IDQuMjY3IDEuMzE5OCA2LjAxNjkgMy4xMTYzIDEwLjM2NiAzLjA0ODkgMTAuMzA0LTAuMTU5NzUgMjAuMjEyIDAuMzg3NDEgMzAuNDg5IDAuMzA0ODkgMTc3Ljg5LTEuNDI4MyAzNTYuNTktMi4xMzI1IDUzNC43Ny0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDc1LjMxIDU4Mi44OWMtMy40NDQyIDExLjM1MS0yLjEwMzQgMTIuNDM0IDMuNjU4NiAyMS4wMzcgMy43OTQ0IDUuNjY1NiA1MC44NjMgMTMuMDM4IDQxLjQ2NSAyNy4xMzUtMTAuNTM3IDE1LjgwNS0yMi44OTctNS40Nzc3LTMzLjg0My0xLjgyOTMtNS40NTI0IDEuODE3NC03LjM0OSA1LjQ1NjMtMy42NTg3IDkuMTQ2NiAyLjgwNjggMi44MDY4IDQuMDQ4IDEuODA0IDYuNTIwMyA1LjEwMDQiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjAxIDYzNi44NWM4LjMxOSAxMy4xMSAxOC44NDYgMTQuNjM1IDM1LjY3MiAxNC42MzUgMi45Mzg2IDAgNy44Ny0wLjkzMzcxIDEwLjY3MSAwIDExLjM1OSAzLjc4NjQgMjcuMTk0IDEwLjI3NiAzNi4yMDIgMjEuMTI5IDguMjggOS45NzY2IDEwLjI1MyAyMy44ODMgNy43MDIgMzcuMTA0LTYuMTY5OSAzMS45OC0xNi43MTQgNTYuOTg5LTE5LjA0NCA4Ni41NjktMS4zNDggMTcuMTE5IDQuNTA5NiAyMi41MzUgMTEuMDcxIDMzLjkyOSAxMC42NyAxOC41MjcgOC43MjQ1IDE0LjIgOC41NzE0IDM0LjI4Ni0wLjEzOTYzIDE4LjMxOSAwIDYwLjI2NCAwIDgwLjcxNCIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNTEgNjU4Ljk2Yy0xMC42ODEgMC45MDQ1NC03LjEwOC01LjYwMjYtMTAuODI0LTguMDc5Ni00Ljc4NDUtMy4xODk3LTEyLjIyNy0xLjI1MS0xNi43NjktNS43OTI5LTAuNjY2MTItMC42NjYxMi04LjgwOTctNC4xMDg4LTEwLjE3NC0yLjc0NC04LjM2NDYgOC4zNjQ2LTMuMDQ4OSAyMC41NTItMy4wNDg5IDMzLjUzOGwzLjAyMiAzMzkuNyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MTcuOTkgNjUxLjAzYy0wLjIyMTcxLTIuNzAxOCAxLjkwMzUtNS41NjIxIDMuMzUzOC03LjAxMjQgMS43OTk0LTEuNzk5NCA2LjkyMjkgMS4wMDQyIDguODQxOC0wLjkxNDY2IDAuMjg3NjUtMC4yODc2NiAwLjg0MzI5LTExLjE2NCAwLjIyODY2LTEzLjU2OC0yLjA2NDgtOC4wNzQyLTIuMDU4LTI4LjY1Ny0yLjA1OC0zOC43MjF2LTczLjE3MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNjYgNjc1LjQyLTAuNDU3MzMtMzEuNTU2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc2Ni4zMiA1NzkuNjQgMC40MzExOCAxMy43OThjMy4xMzY0IDQuNjY5MiAzLjAxODIgOS42MDA3IDMuMDE4MiAxNi4zODV2MTU3LjM4IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTExMjIuOSA3NjUuOTFjLTIwMi4zMSA0LjY5MDUtNDAzLjc0LTEuMTEzOC02MDUuOTUgMy4zNTM5LTEwLjg2NCAwLjI0MDAyLTMuMzYxNS04LjU4NjMtMjguNTM3LTguNTg2MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im04NjAuMDEgNzM3LjA3cy05Ny40NDggMC44NTgwNi0xNDcuNTcgMC44NTgwNmMtNS4yNjg2IDAtNC41MTU1LTguMzI5OS03LjMwMDktOC4zMjk5LTMuOTc0NCAwLTguNjI5MiAwLjAyMDEtMTAuNTA5IDAuMDM1OS0yLjMzNDggMC4wMTk3LTEuODEwOSA4LjM2Ni00LjE0NTggOC4zNjY5LTQ2LjE2OSAwLjAxODgtMTY3LjQxLTEuMzA4LTE3NS4wNS0xLjMwOC00LjQyOTYgMC04LjU3NjMtNi40Mzk3LTEzLjEzMi02LjQzOTdoLTE0LjM5NSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im02NzUuMDEgODMxLjE3LTAuNjA5NzgtNTIxLjc3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc5OS40IDMxMy4wNiAxLjIxOTYgNDk1Ljg3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTczNi41OSAzMTIuNDUtMS4yMTk2IDcxNi40OSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MzAuMDMgNjQzLjQ2IDM5Mi4zNy0zLjAxODIiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtODU5LjQ1IDMxNC45IDEuMjkzNSA1MDcuOTgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICAgPHBhdGggZD0ibTkyMS41NCAzMTAuNTkgMS43MjQ3IDUzMS43NSIgY29sb3I9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgIDxnIHN0cm9rZS13aWR0aD0iMXB4Ij4KICAgIDxwYXRoIGQ9Im03MzYuMjkgNDUzLjMxIDE4NS42OC0wLjMwNDg5IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTEwNjAuOCA1MTQuOTdzLTM2My4yOC01LjYyNjItNTQ0LjY1IDIuNTIxOGMtNC4xNzc4IDAuMTg3NjktMTIuNSAxLjA2NzEtMTIuNSAxLjA2NzEtMS41NzEgMC4xMzQxLTIuMDAwOS0yLjMyNS0yLjU5MTYtMy41MDYyLTAuMDk2Ny0wLjE5MzQzLTcuMDYwOC0xLjkzMzQtNy42MjIyLTEuMzcyLTIuODkzMSAyLjg5MzEtNy42MzE3IDQuMjQ4Ny0xMi4xOTYgNC4xMTZsLTExMi4wNS0zLjI1NzgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzk5LjgyIDQ3OS42MSAxMS42NDIgNS42MDUzYzIuOTg0MSAxLjQzNjggNi41Mjg4LTAuNDc3MTIgOS45MTcxLTAuNDMxMThsMTI3LjIgMS43MjQ3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTUxOS4yNSA1MTcuMTItMC40MzExOS0yMDguNjkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjkzIDM4OS43MWMxMS4wNDUgMCAzNS41MzMgMC42MTkyNyA0Mi41OC0xLjAwNCA4LjQwNTItMS45MzYyIDcuMDY2LTYuOTUzOCAxNC4xOTctNi45NTM4IDcuODA5NSAwIDYuNTQyOSA4LjA2MjQgMjAuMTQyIDguMDYyNCAxMy45OTEgMCA0NC45NzcgMC4zNzg4NiA2My45NCAwLjM3ODg2IDEyLjA4NCAwIDgyLjAwMyAwLjMwNDg5IDkzLjYwMSAwLjMwNDg5IDguNzYwNSAwIDEzLjE2LTIuMjg4MyAyMS4zNDItNy4wMTI0IDcuMTk1Mi00LjE1NDEgMi4wNTQ2LTkuNDkxNCAyMC40MjgtOC44NDE4IDIzLjE0NSAwLjgxODMzIDEyLjY0MyAxNC4wMjUgMzIuMzE4IDE0LjAyNWgxNTAuOTJjMTQuMzMyIDAtNC4xMTkxLTEzLjExIDI5LjI2OS0xMy40MTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4Ij4KICAgPHRleHQgeD0iNTg4LjY3OTU3IiB5PSI3MzUuODA0NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU4OC42Nzk1NyIgeT0iNzM1LjgwNDYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TGluY29sbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3MDkuODcxODMiIHk9Ii04MDIuMzc3MzgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjcwOS44NzE4MyIgeT0iLTgwMi4zNzczOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldvb2RsYXduPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTYyLjExOTI2IiB5PSItNzcxLjk2ODE0IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjIuMTE5MjYiIHk9Ii03NzEuOTY4MTQiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5FZGdlbW9vcjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5OC4zMDQ4NyIgeT0iLTczOC4zNjY0NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk4LjMwNDg3IiB5PSItNzM4LjM2NjQ2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTkyLjEyMjg2IiB5PSItNjc3LjIwMzk4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1OTIuMTIyODYiIHk9Ii02NzcuMjAzOTgiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5IaWxsc2lkZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5Ny4zMjcwOSIgeT0iLTg2Mi42MTQwNyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk3LjMyNzA5IiB5PSItODYyLjYxNDA3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Um9jazwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU4Ny4zNzAxOCIgeT0iLTkyNi4xMzY2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1ODcuMzcwMTgiIHk9Ii05MjYuMTM2NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkNlbnRyYWw8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODczLjgzMjI4IiB5PSI1NzcuMDMyNDciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijg3My44MzIyOCIgeT0iNTc3LjAzMjQ3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI4NzUuOTY2NDkiIHk9IjUxMC4yNjE4MSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODc1Ljk2NjQ5IiB5PSI1MTAuMjYxODEiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yMXN0PC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9Ijg4MS4zMTY1OSIgeT0iNDUwLjE5ODc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI4ODEuMzE2NTkiIHk9IjQ1MC4xOTg3NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjI5dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNjE1Ljc5MjQ4IiB5PSIzODcuNzQ3MTYiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjYxNS43OTI0OCIgeT0iMzg3Ljc0NzE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Mzd0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI0ODQuNjkwMzciIHk9IjQ4MS42NTI4NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDg0LjY5MDM3IiB5PSI0ODEuNjUyODYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yNXRoPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU2My4wNDY3NSIgeT0iNTEzLjM2MTMzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjMuMDQ2NzUiIHk9IjUxMy4zNjEzMyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI0MzMuNTgwNzUiIHk9Ii00NjAuNzMzMTIiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjQzMy41ODA3NSIgeT0iLTQ2MC43MzMxMiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkFtaWRvbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjQwNS41MzA5OCIgeT0iLTUyMy41NDAxNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDA1LjUzMDk4IiB5PSItNTIzLjU0MDE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QXJrYW5zYXM8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3NDUuNDg0NjIiIHk9Ii0zNzIuNTg1OTQiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc0NS40ODQ2MiIgeT0iLTM3Mi41ODU5NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTYuNzI4MzMiIHk9Ii01MzEuMjU5MjgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5Ni43MjgzMyIgeT0iLTUzMS4yNTkyOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldhY288L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTUuNDM0ODEiIHk9Ii0xMjIuNTAyOTUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5NS40MzQ4MSIgeT0iLTEyMi41MDI5NSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hemllPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgeD0iNjk1Ljc3Mjk1IiB5PSIxNjIuMDY4NzciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY5NS43NzI5NSIgeT0iMTYyLjA2ODc3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Wm9vPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjI0MC41ODk5NyIgeT0iNTc0LjQ0NTQzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIyNDAuNTg5OTciIHk9IjU3NC40NDU0MyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjA2LjAzMTc1IiB5PSI1MTEuNjM2NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIwNi4wMzE3NSIgeT0iNTExLjYzNjYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjYyMC40NDMxMiIgeT0iLTUwNi42ODIxOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNjIwLjQ0MzEyIiB5PSItNTA2LjY4MjE5IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TmltczwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzNzAuMjE2ODYiIHk9IjY5OC44NDAwOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iMzcwLjIxNjg2IiB5PSI2OTguODQwMDkiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NYXBsZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCBkPSJtMzY3LjkxIDEwMTBoMjYzLjAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxnIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCI+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzM2LjI2NzQ2IiB5PSItNDMzLjEzNzc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI3MzYuMjY3NDYiIHk9Ii00MzMuMTM3NzYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NZXJpZGlhbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI1NzIuODMyMTUiIHk9IjY0MC4yMDUyNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTcyLjgzMjE1IiB5PSI2NDAuMjA1MjYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5Eb3VnbGFzPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjQ5OS40ODk2MiIgeT0iMTAwOC42MDY5IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI0OTkuNDg5NjIiIHk9IjEwMDguNjA2OSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjQ3dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjE2LjY0NTQzIiB5PSI3MjUuOTgyOTciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIxNi42NDU0MyIgeT0iNzI1Ljk4Mjk3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9Ijc3NC44NzU2MSIgeT0iLTUwOC4xODk3MyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNzc0Ljg3NTYxIiB5PSItNTA4LjE4OTczIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TWNDbGVhbjwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDI4Ny4zNikiIGQ9Im0zNjQuMTYgNjU4LjQzIDI5OS41MS0xLjAxMDJjNi40OTg3LTAuMDIxOSA2Ljk3NzIgOS4yNTQxIDE2LjU5NiA5LjM5MjUgMTIuMDU0IDAuMTczMzkgMjkuMTExLTAuNTM1NzIgNTQuMTE0LTAuMzAxMSIgY29sb3I9IiMwMDAwMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzMzNiIgc3Ryb2tlLXdpZHRoPSIxcHgiLz4KICA8dGV4dCB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hY0FydGh1cjwvdHNwYW4+PC90ZXh0PgogIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzgwLjg0NjA3IiB5PSItNDkwLjI0NTk3IiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc4MC44NDYwNyIgeT0iLTQ5MC4yNDU5NyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPlNlbmVjYTwvdHNwYW4+PC90ZXh0PgogIDxwYXRoIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMjg3LjM2KSIgZD0ibTM2Ny43IDUzNy4yMSAxNDEuMjgtMS4wMTAyYzYuNDktMC4wNDY0IDEyLjc4MSA3LjIzNTQgMTkuMTkzIDcuMzIzNiA1NS45MjQgMC43Njg5IDE1OC42OS0wLjE3MzMzIDIzNi41MS0xLjAxMDIgNy44Mzk2LTAuMDg0MyAyMi42MzEtMTkuODU0IDMwLjMwNS0yMC40NTYgMjIuMjY2LTEuMzUxOCA0NS4xNzktMC41MDUwNyA2Ny42OC0wLjUwNTA3IDE2LjE0Ny0wLjYzMjQxIDMuNjEwMiAyMC43MDggMjYuNzY5IDIwLjcwOGwyNDMuNDUtMS4wMTAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDx0ZXh0IHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+UGF3bmVlPC90c3Bhbj48L3RleHQ+CiAgPHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAyODcuMzYpIiBkPSJtNTU0LjI5IDcyMS40My00LjI4NTctMTc4LjIxLTIuODU3MS00NDAuNzEtMC4zNTcxNC03OS4yODYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1MjkuNjI1MzEiIHk9Ii01NTAuODQ3NzgiIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTI5LjYyNTMxIiB5PSItNTUwLjg0Nzc4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QnJvYWR3YXk8L3RzcGFuPjwvdGV4dD4KIDwvZz4KPC9zdmc+Cg==", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_0.png", + "title": "Map marker image 0", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_0.png", + "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_1.png", + "title": "Map marker image 1", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_1.png", + "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_2.png", + "title": "Map marker image 2", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_2.png", + "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_3.png", + "title": "Map marker image 3", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_3.png", + "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAB4AAAB/CAYAAAD4mHJdAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACWAAAAlgB7MGOJQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAxNSURBVGiB7Zp7kFTllcB/5/a93dMz3T0PemYIDgoCPhZ5iaD4wNkFjQjRRMlLTNbSlKlyzZpobSVbFRPUbNVWSRCWuKvlxqybtbIrukp4SATZCAgospEBgeElj4EZ5t3d0+++37d/9O2ZnqEHQZzZSlXfqlMz/c253+875zvfOefeHuH8L6u83P+AwH0lJZ4pbrenEiCVSnYmEsndGl4NhSKvAJkLmPPcV0VFxZjKivKPv77wXr274WN9uvm0PnHihD5y9IhuPNioN216Vy+Yf6eurAj8b2Vl5aXnM6d8loLf7w9apvHhyy//29jZ9fW0t7fpdWtWN7Wdao4qpaiqDpbdXF9fV1paKpu3bGbxk08eSWXU9ZFIpOPirC33v7xs+TIdiUT0Pz239NjeaTOTHXXjdb4cuP6W5DOLFx/7aNdH+oknfqQryv0vXZTFfr8/GKyqaN7XeMhc//ba6NSfPFXqS6fESJ29jdGAX69+9KHY9OnTyxbec08mHInWhsPhzsHmNs4FNgxdf+NNN5sAh3/7n40dCxeKedUsOr6x8CzdsnBEQu9sPABwzTWTTMNQ9eec+1x/FDEuGTduHABXtreOKutJYyiFqq4tqD+5O3wJQF1dHSij7nODtdZuj9cLgMfGOpcuQInSFoDldqNFez43eCivIrgILoKL4CK4CC6Ci+AiuAgugovgIrgILoKL4CK4CC6Ci+A/B7B5vor6Mz4PNnbRYAAtoCQLUMMFVobuBWOALWdjVIGxiwbbZC3WkrXWLqAzJBZrR5T0LWTgdSHfdF1YcIlG57t8oM5nfov1OcCKPmDW1Rfi2IsA5yI5F9WFXF0o0i8arARwggsBu4BbhwaM6g0ujXY+9b+GLqrzLR5E5wsH2ziB5QRXoW8lCy3mosH553iwlDlEe9znai2DpMyhAJ+PxUNTJMhZm51+WM9xvsWFXD2kx0nl9rjQ4oYC3C+4BoEMnasl39Vn6wxRdcqbXApXpwupWBcEVgLKGLw6DU1w5bkaCjcChcYuHozuLYtqEFfroXC1TZ67GcbjlEuZWjSIHr6ozjZ7/y/VSWOLdgJIF9zjQl3JFwDOXn1lsYDOULm6X+YaROcLB6s8+LC2tzqvoc+Wx0L2nT/6wlIm5y6LQ9bs5TLXsO5x7jG192lxuJq9bCOg0aIRGcYEkt9lCsPp6lxlMsBlFE4ghcYuGoxznHKFYNjKYq7Zy5XFYW32lMtCBGzbLlwWLwB83m/2NNC44R0iFaP503+8jO1UqHz5wiwW0aNzvysgdPJTQr/7dFD9fHD+vecN9vl8NaYpv546ZeqCBx98CMhGbPXEqZRfcTWmyySTjuO2TMora/B4Sji+832OnWoGYMGCBez88IMfHD50eExG6Yd6enraBjJcAwf8fv+Vbsv1Pz9f/NT1y1esQCnNPz6zeGuy6WBN+MRRrwp1YMR6MOIJMqEuOj49xNFd2zh5aD9SVpr44PCJXVOmXXvpHfPm4fP7rtz98Z/usSz3+lQq1e/fnvuFSHl5+VjTNLb96lfPj6yv/0t2bN/eufJnj+37Uql1c/1Xv8WM279CaZn/rJcBGoj1hNm+7k22rF5JcyK1edp3Hps0bfq0yj0Ne/jFL55pVopZ3d3dx88C19bWlqVS8Z2Lf/7U1XNvu51Vb72x7/irz9fUBEcEv/03PyFYPRJDgZHt9XpvzG8QlAFnWppY+S9LaOnsaPPOWdhxx7z5V320cydLl/7yE2+pb+bp06dj/VxtWbJ03h13zr/r7rtZu2bNwVP/9cKYMiHwtW8+QNAbwOiOIN09SCiChCKQL+EIKhxBhcN4EGpGjuJww66yxNH9gePac+zGm26sikQiNY379/kSydT63uCqrCybXB6oeuS+RYvYv29f/OTKFz1+dIlXXFQrCznRjNhkRfdJzmIMEAExsqbUmh68holWGXf43deMg6NHJ+5btKjkgw8//IFh8lJnZ88nBoBWxpPf+e53DYC1Ly5bVSb6Mo8WSrQgx5uRY6cHSDMcz0q/vx/PSTNeJXi04EOPfe93L70JcP/99xu2bfwUwPT5fNU+n++rM2fO5P3332+uS3V9y9KCG8FSmtjRo3iN0uz+qqylemDnLhpDQDsFJGrHMG2F2xAyGi5Nhr65Y8f21unTZ9T4yrz3KqVHGC4X91x33XUmwN7N775nApbuk90nD5BpbUbaWqG9Dd3eju5o6y/t7dDehrS1kmltYffJ/ViA25nDBcbeLZs2AUyaNNkyDL5minDL7Nm3opSiNtQ0yUQwESydlXg6xc70Sf5CewliYSD9TqHu/anpIMUnJIiLjSVCGjAFTA21odNTlFLMunEWO7bvuMUUjKkTrriCvXv3RDyiJxpacGVXSc56W2uO6DhtKkmFFsocHchmtKhoukURNrJPG5YDdAEuDYaAV/TVjY0HesaNG+8Tw5hmuC1zFEBLS0urkQ3QPtFgILgQTC0IkAZSgEJQCClnTBwdF4KBOPf2iQBnzrS2GYaBZblGmWK4/ADxWCzqoS85iDOZDFiMS2ddV5Kz2EkGhgwECYLOzqOzxy0W7YkAiBgBw7btFIC3tMw/2JsrnS9OI5B2pPdt0AC9gdVZZxkBANu2k0Ymk2kDCI6oqsw1c/nNu8rVW8l+2ZFCkxRNzMhKUjQpNBlnv23nXfbAeTRQHayudMBtBlod6OrqZNz4CeVprcKqd4KsZBxgGk1KNEmBmGiijsScsZRo0s4CMnn3284CMqJCY8aOCXR2dqK0PmBokQ3r1q7D7/dLq7tyY8axMCOatDNZFqhJiCbuWNsLNrJjCUcnt4C0ZOew0WTQnDYr3/X5fLJx4wZE5B1DKVm1a9dHAIyYesPYjEBa+vYwJZAUSAgkHAtjookaWcl9Togm4eim8u5PS9YDNVNmXg7QsLsBreX3RjgcPtzW1rarubmZ+QvumtahXJvzrUzmWRvrZ61yxNnvPKuTA6xvt13bvjxv/tSW5mY6Ozt3hkKhoy4Ar6ek6dChg4vm3nY7oZJAJnG4oUIQESdD5Ud0v30XSBlZC1OGdjyTA/darwK3LcxcPm585ZJnl9ATinwvnkweNgC6wuF1x44d27R3714WfOWucZGrb3g7kee+eJ6LewPLcXU0bzwuuf2G3P3NoyevnzP3tsv3NOylqenkHzvD4fWQ197aikeW/nJJd1dnJ4//9On57V+a8Hoib7K4kQeUAWL0D7RcsJ2oqHv9wUcfu7Orq5MVK5Z3KS0P53j96lsgEPjyiKqKtW/891uu2tpalvzDMxsTW96s9yhMC8HUOCkxm07JO/fZk5A9dkmDTOSqWe/99fcfmRPtifHY3z6a6Q5F7gyFQhsKggFGjKh4wFviffG11153T59xHVu3bg3968/+7g9V3ae+0Zv0kX49l3ISjA2ccpe/NXvR9+uvnX5tRdOpJv7+xz9OxnpiD3d0d/97PqcXrLWeBcwGLnv11d96n3j88QVPPf108KHvPUwmk+HttWu71q96Y0dozzajJBUfXyqMA4gpfShmeY54JkzX19/6VzfMmDmjMpPOsOqtVbzyym9alz23fM23Fy1KACeAP4rIBwCitb4MeAQY5SxEt7a2qIaGBn70wx+OTKXTc5Y+t8w1d85cdN5KtdbYSqGVImPbJOIxotEo6/+wniXPPmsH/L4Ny5etaJk46Rqprq7JPTgooBn4Z9FaPw9UAHR1dSnbTsuZMy1GMpnItLZ2GFu3bq5d/fvVc0ZUjZB7F36d2fW3MmHCFZguF0pr0uk0Bxsb2bL5PV5fuZLuUEjfdffdG2+66ebW6mCVLvP5qa4OAoYEg8Gcg7tNIAIEADHdJnbcxmNZ6UQ05nK7TT1x4sRYRVV1/FTTqdLVa9bywgsvEImESKfSAFiWhT9QzqhL6rh25g3UjbokPnJkTaKkxFRaa8NtGbaIy+Up8eS2VgEx0VpXO66+HKfdbW9vV93d7RKNJl3xeNQOd4d1Mp0i3B3yRCKRsmgiYSVTaa9orS23lfR5vany8vKYLxCIeyxLKqoqtddbKh6PSVVVtQ4Gg5IHPQI8nx9ck4CbgSuBarJnvARsiUai4XBPmGQyqbWGRCxh2VrZAKYYLtNjZUyXSxsuU6oqyg1fwO91nhUSzvQdwB5gm4h8UvA4OYsoByYDY4EaoBLwAN7sYiDvZ4LsqUo60uNIK3AY2CMioYGM/wPREY0iGUY58wAAAABJRU5ErkJggg==", + "public": true + } + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index 8019b104fb..e4aba10c61 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -2,24 +2,87 @@ "fqn": "map", "name": "Map", "deprecated": false, - "image": null, - "description": null, + "image": "tb-image;/api/images/system/openstreet_map_system_widget_image.png", + "description": "Displays the location of the entities on Map. Allows to choose among existing tile providers or configure own server. Some providers require additional licenses. Highly customizable via custom markers, marker tooltips, and widget actions. ", "descriptor": { "type": "latest", - "sizeX": 7.5, - "sizeY": 5.5, + "sizeX": 8.5, + "sizeY": 6, "resources": [], "templateHtml": "\n", - "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n markerClick: {\n name: 'widget-action.marker-click',\n multiple: false\n },\n polygonClick: {\n name: 'widget-action.polygon-click',\n multiple: false\n },\n circleClick: {\n name: 'widget-action.circle-click',\n multiple: false\n },\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"imageSourceType\":null,\"imageUrl\":null,\"imageEntityAlias\":null,\"imageUrlAttribute\":null,\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[{\"name\":\"testTag\",\"icon\":\"more_horiz\",\"useShowWidgetActionFunction\":null,\"showWidgetActionFunction\":\"return true;\",\"type\":\"custom\",\"customFunction\":\"console.log('It works!!!');\\n\\nconsole.log(entityName);\\n\\nconsole.log(additionalParams);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"f9b4925a-818c-15d2-6220-cf2f317bc7fe\"}]}}" }, - "resources": [], + "resources": [ + { + "link": "/api/images/system/map_marker_image_0.png", + "title": "Map marker image 0", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_0.png", + "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_1.png", + "title": "Map marker image 1", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_1.png", + "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAB4AAAB/CAYAAAD4mHJdAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACWAAAAlgB7MGOJQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAA3vSURBVGiB7Vt7cFzVef+dc+/d90OrJyO/JSO/4ncxxfULMCYIAyEW08amJJgmM4GmnZjJdNq4gcSGzLQxk3bsaWcaaIHyR8CJrWAbpjgG/AhINsbYxkaSDY6xJFvSrrS7Wu3uvfecr3+cu1pbXhkJs/4nujNndufec77f+d7fd+4uw8gvIxwOfocBaz0e91yXyx0BgKyZiWUz5kcEvBKPJ18EYI+C5rWvkpKSyZGS8LGHGtbQR8ePUUdnB50/f57OfnqWWlpbaN++39O99fdQpCR0NBKJTBwJTfZFE4LBYLmh8+YXXvifKctWrEBPTze9+cbu8/3JVMoWNjwer3/ZsuUTvV4P239gP36yceNZW9CtyWQyei262hcB+7zurU/99Ge3r1nTgJdfevFsqr8/Wlc3rWbGzFkV8+fPr1iwYEEJgLadO3cmbr/jjohh6KXHPjxamsmar39pjoPBYHl5aUnnqZY2/b1Dh9LdPd39kUgk6PP5PD6fH36/Dz6fDx6PF+fOfdZ9+pPTgbq6Ou+aBx+0k/0DVYlEIjYcbX4tYM5pxeK/WKIDwM7Gxt0TJox/dtLESXC53JuHzvV4PBVHDjfvAYDZs+fonMsV16R9rYeM8XG1tbUAgMrKsrDP659DRJ5gMNhbaH5NTU0IAMaPHw9IPv5LAxORy+31AgBcLsO41lwAcLu9BgAYLheIkftLAxfzGgMeAx4DHgMeAx4DHgMeAx4DHgMeAx4DHgMeAx4D/lME1ke7gDF8ltbOHe3W923oEwYi1jxftWfZWgAziwacZkd2pfyN96XN5IIu7dMtIKA9/TI+zqCnFps2Alg5UlojFnVqIHZUlO2sl4RyC4CU+SEEylux8Z/iyc7mrxw4U7UnYwvGpXMYKIgNGdwXC/76C48oRw3sDWfnCgIkARJXcpwbvpA1e6T0Rq5jDr8EAHKA6OpjUOJwfeXAJAEhAXAGgEPKq+dIMVJqowDO4RAAC0rHV21u5LijAJaABAOIAY5Oh15iFMgj1zEpcUuuXjpIWeCouxjAtnIZcGKA5AVFbRfazPUC50QrKe8+Qy8qiqjBYIODA5DgBd1pBO9WRg9sy7yOhXBca+icYrgTOUGOiKnIVdCdisAxJGBTPsYW0nHRrJqgfNmGVtiqaeR1xchF7Vgz40q/BUNmISlcL7CUgJAMnOUiVwEdF0PURIAAVHaC8ucbAiwcQAb1KQpwXMjFrhtYMcOVO8lhOB457ujcKZd9hBguSYwcelTupKyaQWKYJFEU4xJw/Dhfcw29ilSBcNjEoTucFnSnkeOOvvTJpcVC1cYoGB5NAGEQTukjMAzHoghJghyWCRjenYoTuZjKx8xJiwU4LrSZ6waWpIoBjTuRqxDHRUkSUMWAJAZp6QU5FqOw65HHapG3bGVcBTZXDI5VnFaFgBL1yC34uoBJqEJeIwD2MMY1ilZidAFEMlDOqm9UdpJ0ZawumI+LU9ArwhyqWxyNz14XsBAMUnLVH0ttGB0XococdCGWE3XhOV85MF1WV2OY3omK0S2SkxgYAZYYJoAUpcqEEjG/Ru80isA1ysMXYNCnCum4aKUPgTu90w3sFinXL6nO/MadCAhiKloxBjFMeSuK0S1Kylv1cE1bUVoYyHwhoI6bCswpjjuxK5u2G2lcti2jzNCRTluioHEVw52EBA5/2LKsLBL+h2gs/o+Fjpa+MqtmjCbkqQJSYFF3T3zRsPMvA75i7UiBA4FApa6z5+fNnbd6/frHADghk7QdlhAHdMY0KXkZAHAuozaRMDRtKYMdAYDVq1fjcHPTD860nZlsS3qsv7+/+6pNDr0RDAanGTrf85Onnq75/uNPIJ1O4+dbnj34Ot6B4eFLqksqUeEvgcflAREhZabR09+Li/EorLQ4eFv317D2oW8t0XUdu3a9jud/9auztqD6ZDLZOixwOByeouv8D1u3brtpxYrb0XS4Kfbj3//8VHC8d0nDLXfj67OWIeQJgDGADfoOAxHQl05i14l92PHBXiTPp/c/OrFh9vwF8yMnjp/A5s2bOqXEbX19fX+8CriqqspvmunDTz/10xkr71qFnY07Tr1i7aqsLg2Vb6h/GOPCpdAYgTPlNLmF5AzpvBRp74viX3a/hO6+ge47+hZG61fVTz9y+DCee27Lx15fYFFHR8cAcNkPuw2DPXfP1+vvvf+BB7Br967WX9Mbk70eCn33zlWoCrsgKAFBCdgy/2nLBCyZgCUSMGUSpkzC0G1MrKzE0XMt/la9I0QnM+cWL15cmkwmK1tOnwpksuabg8YVifjnhEOlj69dtw6nT51Kv2q96fYG4fG7gbJwFhn7cxicIJgEZwAfEiokGASpWG1KhvIwg1/91ti1N9DEJ7ZOzKxdt87T1Nz8A67jv2Kx/o85AJDk//zXjzzCAeA/D7zU6PZjkkuXcBuEjN2OrGiHabfDFB2w7HZYoh3mVaMDWWdu1m6Hy5Bw6RIuP6b87+HXdgDAww8/zIXgGwFADwQCFYFA4BuLFi3CoUN/6LRmyL/y6gSXTtC4QDTVgQo/B5iEJFJ6Rt64lI6Vfi3JYBFHd1JA5wIunUNIQvpr/C+bm5u65s9fWBnwe9dISWVc0/DNhQsX6gDwTuuhd3WNYOSGTjjSehGp7EVYsguWuJQfssu51wVTXIIpLsGWlzBgXsSRM5dg6Hk6uk787Zb39gHA7NlzDM7xoM4Yli5fvgJSSiRmmbP9HNA0Qm4D6axEc6uJ6eOzuCloQuOOjlneqiUx2BK4lDBwut2DTFaHoXFYGilaHEjMMOdKKXHb4tvw/nvvL9UZ+Lyb6+pw/PjxpOZhsziX0DigcYLG1QaEBD69ZKA7wRHx2/C7BDSNwEi9AEmZGmJJA/1Z9SJM12hwvcYBzgmaj89obW3pr62dGmCcz+cuQ68GgEtdl7oYU40CZwSeW+As1rmy5KzNkbY1WILDlOp71ubgnKA7czVO4NyhwQhcFS7o6urq5pzDMLRqnXEtCACpdCrFHOHlAsTgYEq0nCnj0jnBY6i8KCTLBxbmzB2yPkczmU4lAYAxHtKFECYAPeDzBQZD4GU+motMueXklECWc7QkSaVDGoTAVetz8AGfLwQAQoisbtt2N4BJZaVlpZQjkntdS8w5UFOFni0YLMGhWfny1rbVPVuoOVKyK9ZeTrMsUl7qAHdzkPyktzeG2tqbw8KihCQlPjVUl2hLBkswmDZD1mJIWxwDWTXSFkfWUs8sZ64QzlqHjiRA2tQ7ZcqUYCwWgyT6hBNjb+3ZvQehUIi52tje3M6FyHHIYNkOqM2RsTjS2cuAs+pe1uYKPLcBkduA+m60sH1+v5/t3fsWGGP/x6VkjR98cAQAMNc7bXJepAyWzWHaimjW4siYDGmTY8DkGMhqapgcaVM9yw5ugMOyeX4DkmGub1otABz/6DiI2O94IpE4E+3p+aCzsxP333PfAvOi2G8JBtMRbU68GZMj44Ao0BzXmgOsRk7spq1oWILB6rQP3nt3/byLnZ2IxWKH4/H4pxoAeFzuC21tretW3rUKnk5mtWiflzAGxhgDQ66IYyrnOnqzBFfDZjAdLk1HMnkpMWRNLldmFomamtrIL/71F+iPJ/8mnc2e4QDQm0jsOXfu3L6TJ0/ivtX3T607M26P6SzMWI5eB7ktPHLPc/MV5xwTjpe9sfLOu2pOHD+JCxc+fyeWSLyZdzCoWsvjNpqef/6F8KTJU/DDLT/a3jM90eDWCS5dqmDvxF7NCRSAOikQhCuMUXHMEDjm3v7jb/+oIRrtxpMbnuzNmvatiUSi7QpgAAiFQneXlZbs3rGjUauorMSmLc+8dShy7HbDELqeA3bC4GCScHxWSMDOgVuaPb2t+t3vPfK9O1P9A/j7v3vC7ov318fj8bdyWFf8YCSbzZ7VNHb+tVdfrV911ypt/bcfq52J2uTBg+//LhWwZ0nJYTtWf6WrcccDGFgLdn5nwkPVD9Q/MLOzsxNPbvhhNpUc+G5vPL7jcqxBjonozwEsBzD5lVde9jy5YcPqTZufKX90/WOwbRv7330nsffDt08dSB41EkZyHPfwmwBAZuTFsBm48GeuWfai2oUzp02fFjKzJhp3NuLFF/+765e//Pfd31q71gLwGYC3GWNNAMCIaBKAJwBUO3uQnZ2d/MyZNv1vn/j+LUuXLq/Z/MyzCIfDTmxW8Y+IVFyWqjKRQkDYNqKxGDb97GkcOXLk7LZt/9F8c12dqKqqYM4LYALQCWAbI6J/A1AGgKK9vSBhoa8vEe+N9TwejcZYU1MTfrN9O6puqkJDw0NYtnwFpk6dCsZUMrFtG22trTiw/11s3/4aotEo1jQ04NZFt6KsrJTCoZKtJaWRiGG4KBKJ5BJWnw4gDedAx+0yMJCywLnQGWOSMabV1NbikUfX40J7B367sxFbt25DMhGHZZkgAC7DhWAojOpx4zF3wS0YP64aVZUVYCoQSN2la4bhIsNlcOS73H5GRBUAHgcwBYABAD09PZROp1gq2V8WTybq4vH4xEQ8oSWSSfSnUkinM7As9RdUw9Dh9XoR8PsQCgYRCodESTj0x1Aw2OrxBXsDgYBdXl6eM2IB4CyAbZcb12wASwBMB1Dq7C4ACJZIJHstM5PWdC2TTmcom80wEtySAFwupum6wbxeDxeCuT0et8/v94UBTTrSJABRAKcAHGCMnbrKjy/bRBjAHAATAFQ5NuAF4IFqAtyOKzKo83MLgAkgA2AAQB+ADgCfAzjBGIsPxfh/6wbDK7xbMFYAAAAASUVORK5CYII=", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_2.png", + "title": "Map marker image 2", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_2.png", + "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/map_marker_image_3.png", + "title": "Map marker image 3", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "map_marker_image_3.png", + "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", + "mediaType": "image/png", + "data": "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", + "public": true + }, + { + "link": "/api/images/system/openstreet_map_system_widget_image.png", + "title": "\"OpenStreet Map\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "openstreet_map_system_widget_image.png", + "publicResourceKey": "Uyd9JmVKUcCF6PchzgNnAOjx9WvVN5ZE", + "mediaType": "image/png", + "data": "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", + "public": true + } + ], "scada": false, - "tags": null + "tags": [ + "mapping", + "gps", + "navigation", + "geolocation", + "satellite", + "directions" + ] } \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts index 74c57519b7..34cca1f8ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.ts @@ -46,6 +46,19 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { return this.mapWidgetConfigForm; } + protected setupDefaults(configData: WidgetConfigComponentData) { + const settings = configData.config.settings as MapWidgetSettings; + if (settings?.markers?.length) { + settings.markers = []; + } + if (settings?.polygons?.length) { + settings.polygons = []; + } + if (settings?.circles?.length) { + settings.circles = []; + } + } + protected onConfigSet(configData: WidgetConfigComponentData) { const settings: MapWidgetSettings = mergeDeepIgnoreArray({} as MapWidgetSettings, mapWidgetDefaultSettings, configData.config.settings as MapWidgetSettings); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts index 7d3dfdf3f4..fcac189aab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/providers/image-map.ts @@ -127,32 +127,33 @@ export class ImageMap extends LeafletMap { return this.imageFromAlias(result); } - private imageFromUrl(url: string): Observable { + private imageFromUrl(url: string, update = false): Observable { return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( switchMap( aspectImage => { if (aspectImage) { return of({ imageUrl: aspectImage.url, aspect: aspectImage.aspect, - update: false + update }); } else { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update); } } ), - catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) + catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update)) ); } private imageFromAlias(alias: Observable<[DataSet, boolean]>): Observable { return alias.pipe( switchMap(res => { + const update = res[1]; const url = res[0][0][1]; const mapImage: MapImage = { imageUrl: null, aspect: null, - update: res[1] + update }; return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( switchMap((aspectImage) => { @@ -161,10 +162,10 @@ export class ImageMap extends LeafletMap { mapImage.imageUrl = aspectImage.url; return of(mapImage); } else { - return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl); + return this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update); } }), - catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl)) + catchError(() => this.imageFromUrl(defaultImageMapProviderSettings.mapImageUrl, update)) ); }) ); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index d53d30e6e5..313af2c4fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -30,6 +30,7 @@ import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/m class TbCircleDataLayerItem extends TbDataLayerItem { private circle: L.Circle; + private circleStyle: L.PathOptions; constructor(data: FormattedData, dsData: FormattedData[], @@ -41,10 +42,10 @@ class TbCircleDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); - const style = this.dataLayer.getShapeStyle(data, dsData); + this.circleStyle = this.dataLayer.getShapeStyle(data, dsData); this.circle = L.circle(center, { radius: circleData.radius, - ...style + ...this.circleStyle }); this.updateLabel(data, dsData); return this.circle; @@ -63,7 +64,19 @@ class TbCircleDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { + protected doUpdate(data: FormattedData, dsData: FormattedData[]): void { + this.circleStyle = this.dataLayer.getShapeStyle(data, dsData); + this.updateCircleShape(data); + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + this.circle.setStyle(this.circleStyle); + } + + protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { + this.updateCircleShape(data); + } + + private updateCircleShape(data: FormattedData) { const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); if (!this.circle.getLatLng().equals(center)) { @@ -72,10 +85,6 @@ class TbCircleDataLayerItem extends TbDataLayerItem { - return defaultBaseCirclesDataLayerSettings; + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseCirclesDataLayerSettings(map.type()); } protected doSetup(): Observable { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index c14937ef00..6d52838751 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -48,11 +48,13 @@ export abstract class TbDataLayerItem; protected constructor(data: FormattedData, dsData: FormattedData[], protected settings: S, protected dataLayer: D) { + this.data = data; this.layer = this.create(data, dsData); if (this.settings.tooltip?.show) { this.createTooltip(data.$datasource); @@ -68,13 +70,24 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]): L; + protected abstract doUpdate(data: FormattedData, dsData: FormattedData[]): void; + + protected abstract doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void; + protected abstract unbindLabel(): void; protected abstract bindLabel(content: L.Content): void; protected abstract createEventListeners(data: FormattedData, dsData: FormattedData[]): void; - public abstract update(data: FormattedData, dsData: FormattedData[]): void; + public invalidateCoordinates(): void { + this.doInvalidateCoordinates(this.data, this.dataLayer.getMap().getData()); + } + + public update(data: FormattedData, dsData: FormattedData[]): void { + this.data = data; + this.doUpdate(data, dsData); + } public remove() { this.layer.off(); @@ -237,7 +250,7 @@ export abstract class TbMapDataLayer, inputSettings: S) { - this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings() as S, inputSettings); + this.settings = mergeDeepIgnoreArray({} as S, this.defaultBaseSettings(map) as S, inputSettings); if (this.settings.groups?.length) { this.settings.groups.forEach((group) => { this.groupsState[group] = true; @@ -325,6 +338,10 @@ export abstract class TbMapDataLayer item.invalidateCoordinates()); + } + public getCtx(): WidgetContext { return this.map.getCtx(); } @@ -349,7 +366,7 @@ export abstract class TbMapDataLayer; + protected abstract defaultBaseSettings(map: TbMap): Partial; protected abstract doSetup(): Observable; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index 56c45164dc..0a8c4e1983 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -18,15 +18,18 @@ import { BaseMarkerShapeSettings, ClusterMarkerColorFunction, DataLayerColorType, - defaultBaseMarkersDataLayerSettings, isValidLatLng, + defaultBaseMarkersDataLayerSettings, + isValidLatLng, loadImageWithAspect, - MapStringFunction, MapType, + MapStringFunction, + MapType, MarkerIconInfo, MarkerIconSettings, MarkerImageFunction, MarkerImageInfo, MarkerImageSettings, MarkerImageType, + MarkerPositionFunction, MarkersDataLayerSettings, MarkerShapeSettings, MarkerType, @@ -48,7 +51,12 @@ import { } from '@home/components/widget/lib/maps/models/marker-shape.models'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; -import { MapDataLayerType, TbDataLayerItem, TbMapDataLayer } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + MapDataLayerType, + TbDataLayerItem, + TbMapDataLayer +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; class TbMarkerDataLayerItem extends TbDataLayerItem { @@ -64,7 +72,7 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Marker { - this.location = this.dataLayer.extractLocation(data); + this.location = this.dataLayer.extractLocation(data, dsData); this.marker = L.marker(this.location, { tbMarkerData: data }); @@ -86,15 +94,23 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { - const position = this.dataLayer.extractLocation(data); + protected doUpdate(data: FormattedData, dsData: FormattedData[]): void { + this.marker.options.tbMarkerData = data; + this.updateMarkerPosition(data, dsData); + this.updateTooltip(data, dsData); + this.updateMarkerIcon(data, dsData); + } + + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { + this.updateMarkerPosition(data, dsData); + } + + private updateMarkerPosition(data: FormattedData, dsData: FormattedData[]) { + const position = this.dataLayer.extractLocation(data, dsData); if (!this.marker.getLatLng().equals(position)) { this.location = position; this.marker.setLatLng(position); } - this.marker.options.tbMarkerData = data; - this.updateTooltip(data, dsData); - this.updateMarkerIcon(data, dsData); } private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { @@ -310,6 +326,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer; + private positionFunction: CompiledTbFunction; constructor(protected map: TbMap, inputSettings: MarkersDataLayerSettings) { @@ -333,8 +350,8 @@ export class TbMarkersDataLayer extends TbMapDataLayer { - return defaultBaseMarkersDataLayerSettings; + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseMarkersDataLayerSettings(map.type()); } protected doSetup(): Observable { @@ -358,6 +375,16 @@ export class TbMarkersDataLayer extends TbMapDataLayer(this.getCtx().http, this.settings.positionFunction, ['origXPos', 'origYPos', 'data', 'dsData', 'aspect']).pipe( + map((parsed) => { + this.positionFunction = parsed; + return null; + }) + ) + ); + } setup$.push(this.markerIconProcessor.setup()); return forkJoin(setup$).pipe(map(() => null)); } @@ -496,9 +523,13 @@ export class TbMarkersDataLayer extends TbMapDataLayer): L.LatLng { - const position = this.extractPosition(data); + public extractLocation(data: FormattedData, dsData: FormattedData[]): L.LatLng { + let position = this.extractPosition(data); if (position) { + if (this.map.type() === MapType.image && this.positionFunction) { + const imageMap = this.map as TbImageMap; + position = this.positionFunction.execute(position.x, position.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0}; + } return this.map.positionToLatLng(position); } else { return null; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index de938a2809..419a3e0d4c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -32,6 +32,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[], @@ -43,9 +44,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Layer { const polyData = this.dataLayer.extractPolygonCoordinates(data); const polyConstructor = isCutPolygon(polyData) || polyData.length !== 2 ? L.polygon : L.rectangle; - const style = this.dataLayer.getShapeStyle(data, dsData); + this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData); this.polygon = polyConstructor(polyData, { - ...style + ...this.polygonStyle }); this.polygonContainer = L.featureGroup(); @@ -68,14 +69,25 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { + protected doUpdate(data: FormattedData, dsData: FormattedData[]): void { + this.polygonStyle = this.dataLayer.getShapeStyle(data, dsData); + this.updatePolygonShape(data); + this.updateTooltip(data, dsData); + this.updateLabel(data, dsData); + this.polygon.setStyle(this.polygonStyle); + } + + protected doInvalidateCoordinates(data: FormattedData, _dsData: FormattedData[]): void { + this.updatePolygonShape(data); + } + + private updatePolygonShape(data: FormattedData) { const polyData = this.dataLayer.extractPolygonCoordinates(data); - const style = this.dataLayer.getShapeStyle(data, dsData); if (isCutPolygon(polyData) || polyData.length !== 2) { if (this.polygon instanceof L.Rectangle) { this.polygonContainer.removeLayer(this.polygon); this.polygon = L.polygon(polyData, { - ...style + ...this.polygonStyle }); this.polygon.addTo(this.polygonContainer); } else { @@ -83,13 +95,10 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { @@ -108,8 +117,8 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer { - return defaultBasePolygonsDataLayerSettings; + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBasePolygonsDataLayerSettings(map.type()); } protected doSetup(): Observable { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts new file mode 100644 index 0000000000..ee5baeb109 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -0,0 +1,141 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + DEFAULT_ZOOM_LEVEL, + defaultGeoMapSettings, + GeoMapSettings, latLngPointToBounds, + MapZoomAction, TbCircleData +} from '@home/components/widget/lib/maps/models/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { forkJoin, Observable, of } from 'rxjs'; +import L, { LatLngBounds, LatLngTuple } from 'leaflet'; +import { map, tap } from 'rxjs/operators'; +import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; +import { TbMap } from '@home/components/widget/lib/maps/map'; + +export class TbGeoMap extends TbMap { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + super(ctx, inputSettings, containerElement); + } + + protected defaultSettings(): GeoMapSettings { + return defaultGeoMapSettings; + } + + protected createMap(): Observable { + const map = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL, + center: this.defaultCenterPosition + }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); + return of(map); + } + + protected onResize(): void {} + + protected fitBounds(bounds: LatLngBounds) { + if (bounds.isValid()) { + if (!this.settings.fitMapBounds && this.settings.defaultZoomLevel) { + this.map.setZoom(this.settings.defaultZoomLevel, { animate: false }); + if (this.settings.useDefaultCenterPosition) { + this.map.panTo(this.defaultCenterPosition, { animate: false }); + } + else { + this.map.panTo(bounds.getCenter()); + } + } else { + this.map.once('zoomend', () => { + let minZoom = this.settings.minZoomLevel; + if (this.settings.defaultZoomLevel) { + minZoom = Math.max(minZoom, this.settings.defaultZoomLevel); + } + if (this.map.getZoom() > minZoom) { + this.map.setZoom(minZoom, { animate: false }); + } + }); + if (this.settings.useDefaultCenterPosition) { + bounds = bounds.extend(this.defaultCenterPosition); + } + this.map.fitBounds(bounds, { padding: [50, 50], animate: false }); + this.map.invalidateSize(); + } + } + } + + protected doSetupControls(): Observable { + return this.loadLayers().pipe( + tap((layers: L.TB.LayerData[]) => { + if (layers.length) { + const layer = layers[0]; + layer.layer.addTo(this.map); + this.map.attributionControl.setPrefix(layer.attributionPrefix); + if (layers.length > 1) { + const sidebar = this.getSidebar(); + L.TB.layers({ + layers, + sidebar, + position: this.settings.controlsPosition, + uiClass: 'tb-layers', + paneTitle: this.ctx.translate.instant('widgets.maps.layer.map-layers'), + buttonTitle: this.ctx.translate.instant('widgets.maps.layer.layers'), + }).addTo(this.map); + } + } + }) + ); + + } + + private loadLayers(): Observable { + const layers = this.settings.layers.map(settings => TbMapLayer.fromSettings(this.ctx, settings)); + return forkJoin(layers.map(layer => layer.loadLayer(this.map))).pipe( + map((layersData) => { + return layersData.filter(l => l !== null); + }) + ); + } + + public positionToLatLng(position: {x: number; y: number}): L.LatLng { + return L.latLng(position.x, position.y) as L.LatLng; + } + + public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { + return (expression).map((el) => { + if (!Array.isArray(el[0]) && el.length === 2) { + return el; + } else if (Array.isArray(el) && el.length) { + return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + } else { + return null; + } + }).filter(el => !!el); + } + + public convertCircleData(circle: TbCircleData): TbCircleData { + const centerPoint = latLngPointToBounds(new L.LatLng(circle.latitude, circle.longitude), this.southWest, this.northEast); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + return circle; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts new file mode 100644 index 0000000000..e781f1c62a --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -0,0 +1,293 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + defaultImageMapSettings, + defaultImageMapSourceSettings, + ImageMapSettings, + imageMapSourceSettingsToDatasource, + ImageSourceType, + loadImageWithAspect, + MapZoomAction, + TbCircleData +} from '@home/components/widget/lib/maps/models/map.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { Observable, of, ReplaySubject, switchMap } from 'rxjs'; +import L, { LatLngBounds, LatLngLiteral, LatLngTuple } from 'leaflet'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { catchError } from 'rxjs/operators'; +import { DataSet, widgetType } from '@shared/models/widget.models'; +import { WidgetSubscriptionOptions } from '@core/api/widget-api.models'; +import { isNotEmptyStr } from '@core/utils'; +import { EntityDataPageLink } from '@shared/models/query/query.models'; + +interface ImageLayerData { + imageUrl: string; + aspect: number; + update?: boolean; +} + +export class TbImageMap extends TbMap { + + private maxZoom: number; + private width: number; + private height: number; + private imageLayerData: ImageLayerData; + private initMapSubject: ReplaySubject; + + private imageOverlay: L.ImageOverlay; + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial, + protected containerElement: HTMLElement) { + super(ctx, inputSettings, containerElement); + } + + protected defaultSettings(): ImageMapSettings { + return defaultImageMapSettings; + } + + protected createMap(): Observable { + this.maxZoom = 4; + this.width = 0; + this.height = 0; + this.imageLayerData = { + imageUrl: null, + aspect: 0 + }; + this.initMapSubject = new ReplaySubject(); + this.loadImageLayerData().subscribe((data) => { + this.imageLayerData = data; + if (this.imageLayerData.update) { + this.onResize(true); + } else { + this.onResize(); + this.initMapSubject.next(this.map); + this.initMapSubject.complete(); + } + }); + return this.initMapSubject.asObservable(); + } + + protected onResize(updateImage?: boolean): void { + let width = this.mapElement.clientWidth; + if (width > 0 && this.imageLayerData.aspect) { + let height = Math.round(width / this.imageLayerData.aspect); + const imageMapHeight = this.mapElement.clientHeight; + if (imageMapHeight > 0 && height > imageMapHeight) { + height = imageMapHeight; + width = Math.round(height * this.imageLayerData.aspect); + } + width *= this.maxZoom; + const prevWidth = this.width; + const prevHeight = this.height; + if (this.width !== width || updateImage) { + this.width = width; + this.height = Math.round(width / this.imageLayerData.aspect); + if (!this.map) { + this.doCreateMap(updateImage); + } else { + const lastCenterPos = this.latLngToPoint(this.map.getCenter()); + lastCenterPos.x /= prevWidth; + lastCenterPos.y /= prevHeight; + this.updateMaxBounds(updateImage, lastCenterPos); + (this.map as any)._enforcingBounds = true; + this.map.invalidateSize(false); + (this.map as any)._enforcingBounds = false; + this.invalidateDataLayersCoordinates(); + } + } + } + } + + protected fitBounds(_bounds: LatLngBounds) {} + + public positionToLatLng(position: {x: number; y: number}): L.LatLng { + return this.pointToLatLng( + position.x * this.width, + position.y * this.height); + } + + public pointToLatLng(x: number, y: number): L.LatLng { + return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); + } + + private latLngToPoint(latLng: LatLngLiteral): L.Point { + return L.CRS.Simple.latLngToPoint(latLng, this.maxZoom - 1); + } + + public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { + return (expression).map((el) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { + return this.pointToLatLng( + el[0] * this.width, + el[1] * this.height + ); + } else if (Array.isArray(el) && el.length) { + return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + } else { + return null; + } + }).filter(el => !!el); + } + + public convertCircleData(circle: TbCircleData): TbCircleData { + const centerPoint = this.pointToLatLng(circle.latitude * this.width, circle.longitude * this.height); + circle.latitude = centerPoint.lat; + circle.longitude = centerPoint.lng; + circle.radius = circle.radius * this.width; + return circle; + } + + public getAspect(): number { + return this.imageLayerData.aspect; + } + + private doCreateMap(updateImage?: boolean) { + if (!this.map && this.imageLayerData.aspect > 0) { + const center = this.pointToLatLng(this.width / 2, this.height / 2); + this.map = L.map(this.mapElement, { + scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), + doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), + zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), + minZoom: 1, + maxZoom: this.maxZoom, + zoom: 1, + center, + crs: L.CRS.Simple, + attributionControl: false + }); + this.updateMaxBounds(updateImage); + } + } + + private updateMaxBounds(updateImage?: boolean, lastCenterPos?: L.Point) { + const w = this.width; + const h = this.height; + this.southWest = this.pointToLatLng(0, h); + this.northEast = this.pointToLatLng(w, 0); + const bounds = new L.LatLngBounds(this.southWest, this.northEast); + + if (updateImage && this.imageOverlay) { + this.imageOverlay.remove(); + this.imageOverlay = null; + } + + if (this.imageOverlay) { + this.imageOverlay.setBounds(bounds); + } else { + this.imageOverlay = L.imageOverlay(this.imageLayerData.imageUrl, bounds).addTo(this.map); + } + const padding = 200 * this.maxZoom; + const southWest = this.pointToLatLng(-padding, h + padding); + const northEast = this.pointToLatLng(w + padding, -padding); + const maxBounds = new L.LatLngBounds(southWest, northEast); + (this.map as any)._enforcingBounds = true; + this.map.setMaxBounds(maxBounds); + if (lastCenterPos) { + lastCenterPos.x *= w; + lastCenterPos.y *= h; + const center = this.pointToLatLng(lastCenterPos.x, lastCenterPos.y); + this.map.panTo(center, { animate: false }); + } + (this.map as any)._enforcingBounds = false; + } + + private loadImageLayerData(): Observable { + const imageSource = this.settings.imageSource; + if (imageSource.sourceType === ImageSourceType.image) { + return this.imageFromUrl(imageSource.url); + } else { + const datasource = imageMapSourceSettingsToDatasource(imageSource); + const result = new ReplaySubject<[DataSet, boolean]>(); + let isUpdate = false; + const imageUrlSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: [datasource], + hasDataPageLink: true, + singleEntity: true, + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + if (isNotEmptyStr(subscription.data[0]?.data[0]?.[1])) { + result.next([subscription.data[0].data, isUpdate]); + } else { + result.next([[[0, imageSource.url]], isUpdate]); + } + isUpdate = true; + } + } + }; + this.ctx.subscriptionApi.createSubscription(imageUrlSubscriptionOptions, true).subscribe((subscription) => { + const pageLink: EntityDataPageLink = { + page: 0, + pageSize: 1, + textSearch: null, + dynamic: true + }; + subscription.subscribeAllForPaginatedData(pageLink, null); + }); + return this.imageFromEntityData(result); + } + } + + private imageFromUrl(url: string, update = false): Observable { + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap( aspectImage => { + if (aspectImage) { + return of({ + imageUrl: aspectImage.url, + aspect: aspectImage.aspect, + update + }); + } else { + return this.imageFromUrl(defaultImageMapSourceSettings.url, update); + } + } + ), + catchError(() => this.imageFromUrl(defaultImageMapSourceSettings.url, update)) + ); + } + + private imageFromEntityData(entityData: Observable<[DataSet, boolean]>): Observable { + return entityData.pipe( + switchMap(res => { + const update = res[1]; + const url = res[0][0][1]; + const layerData: ImageLayerData = { + imageUrl: null, + aspect: null, + update + }; + return loadImageWithAspect(this.ctx.$injector.get(ImagePipe), url).pipe( + switchMap((aspectImage) => { + if (aspectImage) { + layerData.aspect = aspectImage.aspect; + layerData.imageUrl = aspectImage.url; + return of(layerData); + } else { + return this.imageFromUrl(defaultImageMapSourceSettings.url, update); + } + }), + catchError(() => this.imageFromUrl(defaultImageMapSourceSettings.url, update)) + ); + }) + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index 95f5e576c4..baab86f783 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -27,7 +27,11 @@ import { ViewChild, ViewEncapsulation } from '@angular/core'; -import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; +import { + createMap, + mapWidgetDefaultSettings, + MapWidgetSettings +} from '@home/components/widget/lib/maps/map-widget.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { Observable } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; @@ -88,7 +92,7 @@ export class MapWidgetComponent implements OnInit, OnDestroy { const borderRadius = this.ctx.$widgetElement.css('borderRadius'); this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; this.cd.detectChanges(); - this.map = TbMap.fromSettings(this.ctx, this.settings, this.mapElement.nativeElement); + this.map = createMap(this.ctx, this.settings, this.mapElement.nativeElement); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts index 30daf48cdc..49953335f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -14,9 +14,14 @@ /// limitations under the License. /// -import { defaultMapSettings, MapSetting } from '@home/components/widget/lib/maps/models/map.models'; +import { defaultMapSettings, MapSetting, MapType } from '@home/components/widget/lib/maps/models/map.models'; import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; import { mergeDeep } from '@core/utils'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { DeepPartial } from '@shared/models/common'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { TbGeoMap } from '@home/components/widget/lib/maps/geo-map'; +import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; export interface MapWidgetSettings extends MapSetting { background: BackgroundSettings; @@ -36,3 +41,14 @@ export const mapWidgetDefaultSettings: MapWidgetSettings = }, padding: '8px' } as MapWidgetSettings); + +export const createMap = (ctx: WidgetContext, + inputSettings: DeepPartial, + mapElement: HTMLElement): TbMap => { + switch (inputSettings.mapType) { + case MapType.geoMap: + return new TbGeoMap(ctx, inputSettings, mapElement); + case MapType.image: + return new TbImageMap(ctx, inputSettings, mapElement); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index b44fcd75b2..aabdf47810 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -50,15 +50,20 @@ border-bottom: none; position: relative; background: transparent; - &:hover { - border-bottom: none; - color: $tb-primary-color; // primary color - &:before { - content: ""; - position: absolute; - inset: 0; - border-radius: 50%; - background-color: rgba(0, 105, 92, 0.10); + &.leaflet-disabled { + color: rgba(0, 0, 0, 0.18); + } + &:not(.leaflet-disabled) { + &:hover { + border-bottom: none; + color: $tb-primary-color; // primary color + &:before { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: rgba(0, 105, 92, 0.10); + } } } &:first-child { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 6531c81839..350aeaf85d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -17,16 +17,8 @@ import { additionalMapDataSourcesToDatasources, BaseMapSettings, - DEFAULT_ZOOM_LEVEL, - defaultGeoMapSettings, - defaultImageMapSettings, - GeoMapSettings, - ImageMapSettings, - latLngPointToBounds, MapActionHandler, - MapSetting, MapType, - MapZoomAction, mergeMapDatasources, parseCenterPosition, TbCircleData, @@ -37,12 +29,11 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr import { DeepPartial } from '@shared/models/common'; import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; -import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; @@ -52,17 +43,6 @@ import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; export abstract class TbMap { - static fromSettings(ctx: WidgetContext, - inputSettings: DeepPartial, - mapElement: HTMLElement): TbMap { - switch (inputSettings.mapType) { - case MapType.geoMap: - return new TbGeoMap(ctx, inputSettings, mapElement); - case MapType.image: - return new TbImageMap(ctx, inputSettings, mapElement); - } - } - protected settings: S; protected map: L.Map; @@ -73,6 +53,7 @@ export abstract class TbMap { protected northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180); protected dataLayers: TbMapDataLayer[]; + protected dsData: FormattedData[]; protected mapElement: HTMLElement; @@ -122,7 +103,9 @@ export abstract class TbMap { } private setupControls(): Observable { - this.map.zoomControl.setPosition(this.settings.controlsPosition); + if (this.map.zoomControl) { + this.map.zoomControl.setPosition(this.settings.controlsPosition); + } return this.doSetupControls(); } @@ -288,9 +271,9 @@ export abstract class TbMap { } private update(subscription: IWidgetSubscription) { - const dsData = formattedDataFormDatasourceData(subscription.data, + this.dsData = formattedDataFormDatasourceData(subscription.data, undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); - this.dataLayers.forEach(dl => dl.updateData(dsData)); + this.dataLayers.forEach(dl => dl.updateData(this.dsData)); this.updateBounds(); } @@ -306,7 +289,6 @@ export abstract class TbMap { if (dataLayersBounds.length) { bounds = new L.LatLngBounds(null, null); dataLayersBounds.forEach(b => bounds.extend(b)); - const mapBounds = this.map.getBounds(); if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) { if (!this.ignoreUpdateBounds) { @@ -317,35 +299,6 @@ export abstract class TbMap { } } - private fitBounds(bounds: LatLngBounds) { - if (bounds.isValid()) { - if (!this.settings.fitMapBounds && this.settings.defaultZoomLevel) { - this.map.setZoom(this.settings.defaultZoomLevel, { animate: false }); - if (this.settings.useDefaultCenterPosition) { - this.map.panTo(this.defaultCenterPosition, { animate: false }); - } - else { - this.map.panTo(bounds.getCenter()); - } - } else { - this.map.once('zoomend', () => { - let minZoom = this.settings.minZoomLevel; - if (this.settings.defaultZoomLevel) { - minZoom = Math.max(minZoom, this.settings.defaultZoomLevel); - } - if (this.map.getZoom() > minZoom) { - this.map.setZoom(minZoom, { animate: false }); - } - }); - if (this.settings.useDefaultCenterPosition) { - bounds = bounds.extend(this.defaultCenterPosition); - } - this.map.fitBounds(bounds, { padding: [50, 50], animate: false }); - this.map.invalidateSize(); - } - } - } - private loadActions(name: string): { [name: string]: MapActionHandler } { const descriptors = this.ctx.actionsApi.getActionDescriptors(name); const actions: { [name: string]: MapActionHandler } = {}; @@ -373,10 +326,16 @@ export abstract class TbMap { protected abstract onResize(): void; + protected abstract fitBounds(bounds: LatLngBounds): void; + protected doSetupControls(): Observable { return of(null); } + protected invalidateDataLayersCoordinates(): void { + this.dataLayers.forEach(dl => dl.invalidateCoordinates()); + } + protected getSidebar(): L.TB.SidebarControl { if (!this.sidebar) { this.sidebar = L.TB.sidebar({ @@ -392,6 +351,10 @@ export abstract class TbMap { return this.ctx; } + public getData(): FormattedData[] { + return this.dsData; + } + public getMap(): L.Map { return this.map; } @@ -459,154 +422,3 @@ export abstract class TbMap { public abstract convertCircleData(circle: TbCircleData): TbCircleData; } - -class TbGeoMap extends TbMap { - - constructor(protected ctx: WidgetContext, - protected inputSettings: DeepPartial, - protected containerElement: HTMLElement) { - super(ctx, inputSettings, containerElement); - } - - protected defaultSettings(): GeoMapSettings { - return defaultGeoMapSettings; - } - - protected createMap(): Observable { - const map = L.map(this.mapElement, { - scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), - doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), - zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), - zoom: this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL, - center: this.defaultCenterPosition - }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); - return of(map); - } - - protected onResize(): void {} - - protected doSetupControls(): Observable { - return this.loadLayers().pipe( - tap((layers: L.TB.LayerData[]) => { - if (layers.length) { - const layer = layers[0]; - layer.layer.addTo(this.map); - this.map.attributionControl.setPrefix(layer.attributionPrefix); - if (layers.length > 1) { - const sidebar = this.getSidebar(); - L.TB.layers({ - layers, - sidebar, - position: this.settings.controlsPosition, - uiClass: 'tb-layers', - paneTitle: this.ctx.translate.instant('widgets.maps.layer.map-layers'), - buttonTitle: this.ctx.translate.instant('widgets.maps.layer.layers'), - }).addTo(this.map); - } - } - }) - ); - - } - - private loadLayers(): Observable { - const layers = this.settings.layers.map(settings => TbMapLayer.fromSettings(this.ctx, settings)); - return forkJoin(layers.map(layer => layer.loadLayer(this.map))).pipe( - map((layersData) => { - return layersData.filter(l => l !== null); - }) - ); - } - - public positionToLatLng(position: {x: number; y: number}): L.LatLng { - return L.latLng(position.x, position.y) as L.LatLng; - } - - public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { - return (expression).map((el) => { - if (!Array.isArray(el[0]) && el.length === 2) { - return el; - } else if (Array.isArray(el) && el.length) { - return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); - } else { - return null; - } - }).filter(el => !!el); - } - - public convertCircleData(circle: TbCircleData): TbCircleData { - const centerPoint = latLngPointToBounds(new L.LatLng(circle.latitude, circle.longitude), this.southWest, this.northEast); - circle.latitude = centerPoint.lat; - circle.longitude = centerPoint.lng; - return circle; - } - -} - -class TbImageMap extends TbMap { - - private maxZoom = 4; - - private width = 0; - private height = 0; - - constructor(protected ctx: WidgetContext, - protected inputSettings: DeepPartial, - protected mapElement: HTMLElement) { - super(ctx, inputSettings, mapElement); - } - - protected defaultSettings(): ImageMapSettings { - return defaultImageMapSettings; - } - - protected createMap(): Observable { - const map = L.map(this.mapElement, { - scrollWheelZoom: this.settings.zoomActions.includes(MapZoomAction.scroll), - doubleClickZoom: this.settings.zoomActions.includes(MapZoomAction.doubleClick), - zoomControl: this.settings.zoomActions.includes(MapZoomAction.controlButtons), - minZoom: 1, - maxZoom: this.maxZoom, - zoom: 1, - crs: L.CRS.Simple, - attributionControl: false - }).setView(this.defaultCenterPosition, this.settings.defaultZoomLevel || DEFAULT_ZOOM_LEVEL); - return of(map); - } - - protected onResize(): void {} - - public positionToLatLng(position: {x: number; y: number}): L.LatLng { - return this.pointToLatLng( - position.x * this.width, - position.y * this.height); - } - - public pointToLatLng(x: number, y: number): L.LatLng { - return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); - } - - public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { - return (expression).map((el) => { - if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { - return this.pointToLatLng( - el[0] * this.width, - el[1] * this.height - ); - } else if (Array.isArray(el) && el.length) { - return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); - } else { - return null; - } - }).filter(el => !!el); - } - - public convertCircleData(circle: TbCircleData): TbCircleData { - const centerPoint = this.pointToLatLng(circle.latitude * this.width, circle.longitude * this.height); - circle.latitude = centerPoint.lat; - circle.longitude = centerPoint.lng; - circle.radius = circle.radius * this.width; - return circle; - } - -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 9dd6065ad6..09f6f0c488 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -16,7 +16,7 @@ import { DataKey, Datasource, DatasourceType, FormattedData } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { guid, hashCode, isDefinedAndNotNull, isString, mergeDeep } from '@core/utils'; +import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; import L from 'leaflet'; @@ -96,7 +96,7 @@ export interface MapDataLayerSettings extends MapDataSourceSettings { groups?: string[]; } -export const defaultBaseDataLayerSettings: Partial = { +export const defaultBaseDataLayerSettings = (mapType: MapType): Partial => ({ label: { show: true, type: DataLayerPatternType.pattern, @@ -107,11 +107,13 @@ export const defaultBaseDataLayerSettings: Partial = { trigger: DataLayerTooltipTrigger.click, autoclose: true, type: DataLayerPatternType.pattern, - pattern: '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details', + pattern: mapType === MapType.geoMap ? + '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details' + : '${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details', offsetX: 0, offsetY: -1 } -} +}) export type MapDataLayerType = 'markers' | 'polygons' | 'circles'; @@ -235,6 +237,7 @@ export interface MarkersDataLayerSettings extends MapDataLayerSettings { markerImage?: MarkerImageSettings; markerOffsetX: number; markerOffsetY: number; + positionFunction?: TbFunction; markerClustering: MarkerClusteringSettings; } @@ -281,9 +284,9 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly settings: {}, color: materialColors[0].value } -} as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings as MarkersDataLayerSettings); +} as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings(mapType) as MarkersDataLayerSettings); -export const defaultBaseMarkersDataLayerSettings: Partial = mergeDeep({ +export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ markerType: MarkerType.shape, markerShape: { shape: MarkerShape.markerShape1, @@ -308,6 +311,7 @@ export const defaultBaseMarkersDataLayerSettings: Partial mergeDeep({ +export const defaultPolygonsDataLayerSettings = (mapType: MapType, functionsOnly = false): PolygonsDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, dsLabel: functionsOnly ? 'First polygon' : '', polygonKey: { @@ -343,9 +347,9 @@ export const defaultPolygonsDataLayerSettings = (functionsOnly = false): Polygon settings: {}, color: materialColors[0].value } -} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings as PolygonsDataLayerSettings); +} as PolygonsDataLayerSettings, defaultBasePolygonsDataLayerSettings(mapType) as PolygonsDataLayerSettings); -export const defaultBasePolygonsDataLayerSettings: Partial = mergeDeep({ +export const defaultBasePolygonsDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ fillColor: { type: DataLayerColorType.constant, color: 'rgba(51,136,255,0.2)', @@ -355,14 +359,14 @@ export const defaultBasePolygonsDataLayerSettings: Partial, defaultBaseDataLayerSettings, +} as Partial, defaultBaseDataLayerSettings(mapType), {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) export interface CirclesDataLayerSettings extends ShapeDataLayerSettings { circleKey: DataKey; } -export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ +export const defaultCirclesDataLayerSettings = (mapType: MapType, functionsOnly = false): CirclesDataLayerSettings => mergeDeep({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, dsLabel: functionsOnly ? 'First circle' : '', circleKey: { @@ -372,9 +376,9 @@ export const defaultCirclesDataLayerSettings = (functionsOnly = false): CirclesD settings: {}, color: materialColors[0].value } -} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings as CirclesDataLayerSettings); +} as CirclesDataLayerSettings, defaultBaseCirclesDataLayerSettings(mapType) as CirclesDataLayerSettings); -export const defaultBaseCirclesDataLayerSettings: Partial = mergeDeep({ +export const defaultBaseCirclesDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ fillColor: { type: DataLayerColorType.constant, color: 'rgba(51,136,255,0.2)', @@ -384,7 +388,7 @@ export const defaultBaseCirclesDataLayerSettings: Partial, defaultBaseDataLayerSettings, +} as Partial, defaultBaseDataLayerSettings(mapType), {label: {show: false}, tooltip: {show: false, pattern: '${entityName}

TimeStamp: ${ts:7}'}} as Partial) export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType, functionsOnly = false): MapDataLayerSettings => { @@ -392,20 +396,20 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map case 'markers': return defaultMarkersDataLayerSettings(mapType, functionsOnly); case 'polygons': - return defaultPolygonsDataLayerSettings(functionsOnly); + return defaultPolygonsDataLayerSettings(mapType, functionsOnly); case 'circles': - return defaultCirclesDataLayerSettings(functionsOnly); + return defaultCirclesDataLayerSettings(mapType, functionsOnly); } }; -export const defaultBaseMapDataLayerSettings = (dataLayerType: MapDataLayerType): T => { +export const defaultBaseMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType): T => { switch (dataLayerType) { case 'markers': - return defaultBaseMarkersDataLayerSettings as T; + return defaultBaseMarkersDataLayerSettings(mapType) as T; case 'polygons': - return defaultBasePolygonsDataLayerSettings as T; + return defaultBasePolygonsDataLayerSettings(mapType) as T; case 'circles': - return defaultBaseCirclesDataLayerSettings as T; + return defaultBaseCirclesDataLayerSettings(mapType) as T; } } @@ -453,12 +457,33 @@ export enum MapControlsPosition { bottomright = 'bottomright' } +export const mapControlPositions = Object.keys(MapControlsPosition) as MapControlsPosition[]; + +export const mapControlsPositionTranslationMap = new Map( + [ + [MapControlsPosition.topleft, 'widgets.maps.control.position-topleft'], + [MapControlsPosition.topright, 'widgets.maps.control.position-topright'], + [MapControlsPosition.bottomleft, 'widgets.maps.control.position-bottomleft'], + [MapControlsPosition.bottomright, 'widgets.maps.control.position-bottomright'] + ] +); + export enum MapZoomAction { scroll = 'scroll', doubleClick = 'doubleClick', controlButtons = 'controlButtons' } +export const mapZoomActions = Object.keys(MapZoomAction) as MapZoomAction[]; + +export const mapZoomActionTranslationMap = new Map( + [ + [MapZoomAction.scroll, 'widgets.maps.control.zoom-scroll'], + [MapZoomAction.doubleClick, 'widgets.maps.control.zoom-double-click'], + [MapZoomAction.controlButtons, 'widgets.maps.control.zoom-control-buttons'] + ] +); + export interface BaseMapSettings { mapType: MapType; markers: MarkersDataLayerSettings[]; @@ -756,20 +781,57 @@ export const defaultGeoMapSettings: GeoMapSettings = { export enum ImageSourceType { image = 'image', - attribute = 'attribute' + entityKey = 'entityKey' +} + +export interface ImageMapSourceSettings { + sourceType: ImageSourceType; + url?: string; + entityAliasId?: string; + entityKey?: DataKey; } +export const imageMapSourceSettingsValid = (imageSource: ImageMapSourceSettings): boolean => { + if (!imageSource?.sourceType) { + return false; + } else if (imageSource.sourceType === ImageSourceType.image) { + return isNotEmptyStr(imageSource.url); + } else { + return isNotEmptyStr(imageSource.entityAliasId) && !!imageSource.entityKey; + } +} + +export const imageMapSourceSettingsValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + const imageSource: ImageMapSourceSettings = control.value; + if (!imageMapSourceSettingsValid(imageSource)) { + return { + imageMapSource: true + }; + } + return null; +}; + +export const defaultImageMapSourceSettings: ImageMapSourceSettings = { + sourceType: ImageSourceType.image, + url: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==', +}; + +export const imageMapSourceSettingsToDatasource = (settings: ImageMapSourceSettings): Datasource => { + return { + type: DatasourceType.entity, + name: '', + entityAliasId: settings.entityAliasId, + dataKeys: [settings.entityKey] + }; +}; + export interface ImageMapSettings extends BaseMapSettings { - imageSourceType?: ImageSourceType; - imageUrl?: string; - imageEntityAlias?: string; - imageUrlAttribute?: string; + imageSource?: ImageMapSourceSettings; } export const defaultImageMapSettings: ImageMapSettings = { mapType: MapType.image, - imageSourceType: ImageSourceType.image, - imageUrl: 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==', + imageSource: mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings), ...mergeDeep({} as BaseMapSettings, defaultBaseMapSettings) } @@ -799,6 +861,9 @@ export type MarkerImageFunction = (data: FormattedData, markerI export type ClusterMarkerColorFunction = (data: FormattedData[], childCount: number) => string; +export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData, + dsData: FormattedData[], aspect: number) => { x: number, y: number }; + export interface TbCircleData { latitude: number; longitude: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html index 2349c14472..90e0ed7328 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - DataKey = (key) => key; + generateKey: (key: DataKey) => DataKey; private requiredValue: boolean; get required(): boolean { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html new file mode 100644 index 0000000000..6229b6696e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html @@ -0,0 +1,62 @@ + +
+
+
+ {{ 'widgets.maps.image.image-source' | translate }} +
+ + {{ 'widgets.maps.image.image-source-image' | translate }} + {{ 'widgets.maps.image.image-source-entity-key' | translate }} + +
+ + + +
+
widgets.maps.image.source-entity-alias
+ + +
+
+
widgets.maps.image.image-url-key
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts new file mode 100644 index 0000000000..13dc2b839e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts @@ -0,0 +1,157 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ImageMapSourceSettings, ImageSourceType } from '@home/components/widget/lib/maps/models/map.models'; +import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; + +@Component({ + selector: 'tb-image-map-source-settings', + templateUrl: './image-map-source-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + } + ] +}) +export class ImageMapSourceSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + ImageSourceType = ImageSourceType; + DatasourceType = DatasourceType; + widgetType = widgetType; + DataKeyType = DataKeyType; + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + private modelValue: ImageMapSourceSettings; + + private propagateChange = null; + + public imageMapSourceFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + this.imageMapSourceFormGroup = this.fb.group({ + sourceType: [null, [Validators.required]], + url: [null, [Validators.required]], + entityAliasId: [null, [Validators.required]], + entityKey: [null, [Validators.required]] + }); + this.imageMapSourceFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + this.imageMapSourceFormGroup.get('sourceType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.imageMapSourceFormGroup.disable({emitEvent: false}); + } else { + this.imageMapSourceFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: ImageMapSourceSettings): void { + this.modelValue = value; + this.imageMapSourceFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + } + + editKey() { + const entityKey: DataKey = this.imageMapSourceFormGroup.get('entityKey').value; + this.context.editKey(entityKey, + null, this.imageMapSourceFormGroup.get('entityAliasId').value).subscribe( + (updatedDataKey) => { + if (updatedDataKey) { + this.imageMapSourceFormGroup.get('entityKey').patchValue(updatedDataKey); + } + } + ); + } + + public validate(c: UntypedFormControl) { + const valid = this.imageMapSourceFormGroup.valid; + return valid ? null : { + imageMapSource: { + valid: false, + }, + }; + } + + + private updateValidators() { + const sourceType: ImageSourceType = this.imageMapSourceFormGroup.get('sourceType').value; + if (sourceType === ImageSourceType.image) { + this.imageMapSourceFormGroup.get('url').enable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityAliasId').disable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityKey').disable({emitEvent: false}); + } else { + this.imageMapSourceFormGroup.get('url').disable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityAliasId').enable({emitEvent: false}); + this.imageMapSourceFormGroup.get('entityKey').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.imageMapSourceFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index dc9c069882..8742eff68c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -214,6 +214,17 @@
+
+
widgets.maps.data-layer.marker.position-conversion
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index d00325b0a7..b57f92fc35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -17,13 +17,15 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core'; import { DialogComponent } from '@shared/components/dialog.component'; import { - CirclesDataLayerSettings, defaultBaseMapDataLayerSettings, + CirclesDataLayerSettings, + defaultBaseMapDataLayerSettings, MapDataLayerSettings, MapDataLayerType, MapType, MarkersDataLayerSettings, MarkerType, - PolygonsDataLayerSettings, ShapeDataLayerSettings + PolygonsDataLayerSettings, + ShapeDataLayerSettings } from '@home/components/widget/lib/maps/models/map.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -36,6 +38,8 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityType } from '@shared/models/entity-type.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; +import { MapProviders } from '@home/components/widget/lib/maps-legacy/map-models'; +import { WidgetService } from '@core/http/widget.service'; export interface MapDataLayerDialogData { settings: MapDataLayerSettings; @@ -76,6 +80,8 @@ export class MapDataLayerDialogComponent extends DialogComponent, @@ -83,6 +89,7 @@ export class MapDataLayerDialogComponent extends DialogComponent, private fb: FormBuilder, + private widgetService: WidgetService, private destroyRef: DestroyRef) { super(store, router, dialogRef); @@ -93,7 +100,7 @@ export class MapDataLayerDialogComponent extends DialogComponent(this.dataLayerType), this.settings); + defaultBaseMapDataLayerSettings(this.mapType, this.dataLayerType), this.settings); this.dataLayerFormGroup = this.fb.group({ dsType: [this.settings.dsType, [Validators.required]], @@ -119,6 +126,9 @@ export class MapDataLayerDialogComponent extends DialogComponent
+ formControlName="layers"> + + +
@@ -65,4 +70,59 @@ [context]="context">
+
+
+ {{ 'widgets.maps.control.map-controls' | translate }} +
+
+
widgets.maps.control.position
+ + + + {{ mapControlsPositionTranslationMap.get(position) | translate }} + + + +
+
+
widgets.maps.control.zoom-actions
+ + + {{ mapZoomActionTranslationMap.get(action) | translate }} + + +
+
+
+
+ {{ 'widgets.maps.common.common-map-settings' | translate }} +
+ +
+ + {{ 'widgets.maps.common.fit-map-bounds' | translate }} + +
+
+ + {{ 'widgets.maps.common.default-map-center-position' | translate }} + + + + +
+
+
widgets.maps.common.default-map-zoom-level
+ + + +
+
+
+
widgets.maps.common.entities-limit
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index e2abdee4ff..3b5c57ecbe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -22,9 +22,20 @@ import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - Validator + Validator, + Validators } from '@angular/forms'; -import { ImageSourceType, MapDataLayerType, MapSetting, MapType } from '@home/components/widget/lib/maps/models/map.models'; +import { + defaultImageMapSourceSettings, + ImageMapSourceSettings, imageMapSourceSettingsValidator, + mapControlPositions, + mapControlsPositionTranslationMap, + MapDataLayerType, + MapSetting, + MapType, + mapZoomActions, + mapZoomActionTranslationMap +} from '@home/components/widget/lib/maps/models/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge, Observable } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; @@ -36,7 +47,7 @@ import { DataKeyConfigDialogComponent, DataKeyConfigDialogData } from '@home/components/widget/lib/settings/common/key/data-key-config-dialog.component'; -import { deepClone } from '@core/utils'; +import { deepClone, mergeDeep } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; @Component({ @@ -58,6 +69,14 @@ import { MatDialog } from '@angular/material/dialog'; }) export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator { + mapControlPositions = mapControlPositions; + + mapZoomActions = mapZoomActions; + + mapControlsPositionTranslationMap = mapControlsPositionTranslationMap; + + mapZoomActionTranslationMap = mapZoomActionTranslationMap; + MapType = MapType; @Input() @@ -105,10 +124,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid this.mapSettingsFormGroup = this.fb.group({ mapType: [null, []], layers: [null, []], - imageSourceType: [null, []], - imageUrl: [null, []], - imageEntityAlias: [null, []], - imageUrlAttribute: [null, []], + imageSource: [null, [imageMapSourceSettingsValidator]], markers: [null, []], polygons: [null, []], circles: [null, []], @@ -118,8 +134,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid fitMapBounds: [null, []], useDefaultCenterPosition: [null, []], defaultCenterPosition: [null, []], - defaultZoomLevel: [null, []], - mapPageSize: [null, []] + defaultZoomLevel: [null, [Validators.min(0), Validators.max(20)]], + mapPageSize: [null, [Validators.min(1), Validators.required]] }); this.mapSettingsFormGroup.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) @@ -127,12 +143,17 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid this.updateModel(); }); merge(this.mapSettingsFormGroup.get('mapType').valueChanges, - this.mapSettingsFormGroup.get('imageSourceType').valueChanges + this.mapSettingsFormGroup.get('useDefaultCenterPosition').valueChanges ).pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(() => { this.updateValidators(); }); + this.mapSettingsFormGroup.get('mapType').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((mapType: MapType) => { + this.mapTypeChanged(mapType); + }); } registerOnChange(fn: any): void { @@ -160,7 +181,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid this.updateValidators(); } - public validate(c: UntypedFormControl) { + public validate(_c: UntypedFormControl) { const valid = this.mapSettingsFormGroup.valid; return valid ? null : { mapSettings: { @@ -171,24 +192,34 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid private updateValidators() { const mapType: MapType = this.mapSettingsFormGroup.get('mapType').value; - const imageSourceType: ImageSourceType = this.mapSettingsFormGroup.get('imageSourceType').value; if (mapType === MapType.geoMap) { this.mapSettingsFormGroup.get('layers').enable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageSourceType').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageUrl').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageUrlAttribute').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('imageSource').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('fitMapBounds').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('useDefaultCenterPosition').enable({emitEvent: false}); + const useDefaultCenterPosition: boolean = this.mapSettingsFormGroup.get('useDefaultCenterPosition').value; + if (useDefaultCenterPosition) { + this.mapSettingsFormGroup.get('defaultCenterPosition').enable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false}); + } + this.mapSettingsFormGroup.get('defaultZoomLevel').enable({emitEvent: false}); } else { this.mapSettingsFormGroup.get('layers').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageSourceType').enable({emitEvent: false}); - if (imageSourceType === ImageSourceType.image) { - this.mapSettingsFormGroup.get('imageUrl').enable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageEntityAlias').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageUrlAttribute').disable({emitEvent: false}); - } else { - this.mapSettingsFormGroup.get('imageUrl').disable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageEntityAlias').enable({emitEvent: false}); - this.mapSettingsFormGroup.get('imageUrlAttribute').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('imageSource').enable({emitEvent: false}); + this.mapSettingsFormGroup.get('fitMapBounds').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('useDefaultCenterPosition').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('defaultCenterPosition').disable({emitEvent: false}); + this.mapSettingsFormGroup.get('defaultZoomLevel').disable({emitEvent: false}); + } + } + + private mapTypeChanged(mapType: MapType): void { + if (mapType === MapType.image) { + let imageSource: ImageMapSourceSettings = this.mapSettingsFormGroup.get('imageSource').value; + if (!imageSource?.sourceType) { + imageSource = mergeDeep({} as ImageMapSourceSettings, defaultImageMapSourceSettings); + this.mapSettingsFormGroup.get('imageSource').patchValue(imageSource); } } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index c31bddd8f1..8dd52cb1fe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -230,6 +230,9 @@ import { MapDataSourcesComponent } from '@home/components/widget/lib/settings/co import { MapDataSourceRowComponent } from '@home/components/widget/lib/settings/common/map/map-data-source-row.component'; +import { + ImageMapSourceSettingsComponent +} from '@home/components/widget/lib/settings/common/map/image-map-source-settings.component'; @NgModule({ declarations: [ @@ -303,6 +306,7 @@ import { MapLayerSettingsPanelComponent, MapLayerRowComponent, MapLayersComponent, + ImageMapSourceSettingsComponent, DataLayerColorSettingsComponent, DataLayerColorSettingsPanelComponent, DataLayerPatternSettingsComponent, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index e96d92d6b0..ad986ea403 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7548,6 +7548,33 @@ "map": "Map", "image": "Image" }, + "image": { + "image-source": "Image source", + "image-source-image": "Image", + "image-source-entity-key": "Entity key", + "source-entity-alias": "Source entity alias", + "image-url-key": "Image URL key", + "image-url-key-required": "Image URL key is required" + }, + "control": { + "map-controls": "Map controls", + "position": "Position", + "position-topleft": "Top-left", + "position-topright": "Top-right", + "position-bottomleft": "Bottom-left", + "position-bottomright": "Bottom-right", + "zoom-actions": "Zoom actions", + "zoom-scroll": "Scroll", + "zoom-double-click": "Double click", + "zoom-control-buttons": "Control buttons" + }, + "common": { + "common-map-settings": "Common map settings", + "fit-map-bounds": "Fit map bounds to cover all markers", + "default-map-center-position": "Default map center position", + "default-map-zoom-level": "Default map zoom level", + "entities-limit": "Limit of entities to load" + }, "layer": { "label": "Label", "layer": "Layer", @@ -7667,6 +7694,8 @@ "marker-offset": "Marker offset", "offset-horizontal": "Horizontal", "offset-vertical": "Vertical", + "position-conversion": "Position conversion", + "position-conversion-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each", "clustering": { "use-map-markers-clustering": "Use map markers clustering", "zoom-on-cluster-click": "Zoom when clicking on a cluster", From 5d3cafc65d3ccb9725e70854ac59bd071e5a9401 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 24 Jan 2025 20:48:12 +0200 Subject: [PATCH 023/127] UI: Map layers drag mode. --- .../lib/maps/data-layer/circles-data-layer.ts | 57 ++++++++- .../lib/maps/data-layer/map-data-layer.ts | 114 ++++++++++++++++- .../lib/maps/data-layer/markers-data-layer.ts | 115 ++++++++++++++---- .../maps/data-layer/polygons-data-layer.ts | 78 ++++++++++-- .../components/widget/lib/maps/geo-map.ts | 63 ++++++++-- .../components/widget/lib/maps/image-map.ts | 79 +++++++++--- .../home/components/widget/lib/maps/map.scss | 27 ++++ .../home/components/widget/lib/maps/map.ts | 86 +++++++++++-- .../widget/lib/maps/models/map.models.ts | 71 ++++++++++- .../map/map-data-layer-dialog.component.html | 16 +++ .../map/map-data-layer-dialog.component.ts | 27 +++- .../assets/locale/locale.constant-en_US.json | 15 ++- 12 files changed, 666 insertions(+), 82 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 313af2c4fb..f1c364c66f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -31,6 +31,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem, dsData: FormattedData[], @@ -45,7 +46,8 @@ class TbCircleDataLayerItem extends TbDataLayerItem { + this.editing = true; + }); + this.circle.on('pm:dragend', () => { + this.saveCircleCoordinates(); + this.editing = false; + }); + } + + protected disableDrag(): void { + this.circle.pm.disableLayerDrag(); + this.circle.off('pm:dragstart'); + this.circle.off('pm:dragend'); + } + + private saveCircleCoordinates() { + const center = this.circle.getLatLng(); + const radius = this.circle.getRadius(); + this.dataLayer.saveCircleCoordinates(this.data, center, radius); + } + private updateCircleShape(data: FormattedData) { + if (this.editing) { + return; + } const circleData = this.dataLayer.extractCircleCoordinates(data); const center = new L.LatLng(circleData.latitude, circleData.longitude); if (!this.circle.getLatLng().equals(center)) { @@ -117,13 +157,22 @@ export class TbCirclesDataLayer extends TbShapesDataLayer, dsData: FormattedData[]): TbDataLayerItem { - throw new TbCircleDataLayerItem(data, dsData, this.settings, this); + return new TbCircleDataLayerItem(data, dsData, this.settings, this); } public extractCircleCoordinates(data: FormattedData) { const circleData: TbCircleData = JSON.parse(data[this.settings.circleKey.label]); - return this.map.convertCircleData(circleData); + return this.map.circleDataToCoordinates(circleData); } - + public saveCircleCoordinates(data: FormattedData, center: L.LatLng, radius: number): void { + const converted = this.map.coordinatesToCircleData(center, radius); + const circleData = [ + { + dataKey: this.settings.circleKey, + value: converted + } + ]; + this.map.saveItemData(data.$datasource, circleData).subscribe(); + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 6d52838751..269d8fc294 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -17,6 +17,7 @@ import { DataLayerColorSettings, DataLayerColorType, + DataLayerEditAction, DataLayerPatternSettings, DataLayerPatternType, DataLayerTooltipTrigger, @@ -63,6 +64,7 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]): void; + protected abstract addItemClass(clazz: string): void; + + protected abstract removeItemClass(clazz: string): void; + + protected abstract enableDrag(): void; + + protected abstract disableDrag(): void; + + protected enableEdit(): void { + if (this.dataLayer.isHoverable()) { + this.addItemClass('tb-hoverable'); + } + if (this.dataLayer.isDragEnabled()) { + this.enableDrag(); + this.addItemClass('tb-draggable'); + } + } + + protected disableEdit(): void { + if (this.dataLayer.isHoverable()) { + this.removeItemClass('tb-hoverable'); + } + if (this.dataLayer.isDragEnabled()) { + this.disableDrag(); + this.removeItemClass('tb-draggable'); + } + } + public invalidateCoordinates(): void { this.doInvalidateCoordinates(this.data, this.dataLayer.getMap().getData()); } + public editModeUpdated() { + if (this.dataLayer.isEditMode()) { + this.enableEdit(); + } else { + this.disableEdit(); + } + } + public update(data: FormattedData, dsData: FormattedData[]): void { this.data = data; this.doUpdate(data, dsData); @@ -245,6 +283,17 @@ export abstract class TbMapDataLayer { @@ -291,6 +352,38 @@ export abstract class TbMapDataLayer item.editModeUpdated()); + } + public abstract dataLayerType(): MapDataLayerType; protected abstract defaultBaseSettings(map: TbMap): Partial; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index 0a8c4e1983..8f7753b31e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -60,9 +60,10 @@ import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; class TbMarkerDataLayerItem extends TbDataLayerItem { - private location: L.LatLng; private marker: L.Marker; private labelOffset: L.PointTuple; + private iconClassList: string[]; + private moving = false; constructor(data: FormattedData, dsData: FormattedData[], @@ -72,8 +73,9 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): L.Marker { - this.location = this.dataLayer.extractLocation(data, dsData); - this.marker = L.marker(this.location, { + this.iconClassList = []; + const location = this.dataLayer.extractLocation(data, dsData); + this.marker = L.marker(location, { tbMarkerData: data }); @@ -96,26 +98,69 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]): void { this.marker.options.tbMarkerData = data; - this.updateMarkerPosition(data, dsData); + this.updateMarkerLocation(data, dsData); this.updateTooltip(data, dsData); this.updateMarkerIcon(data, dsData); } protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { - this.updateMarkerPosition(data, dsData); + this.updateMarkerLocation(data, dsData); } - private updateMarkerPosition(data: FormattedData, dsData: FormattedData[]) { - const position = this.dataLayer.extractLocation(data, dsData); - if (!this.marker.getLatLng().equals(position)) { - this.location = position; - this.marker.setLatLng(position); + protected addItemClass(clazz: string): void { + if (!this.iconClassList.includes(clazz)) { + this.iconClassList.push(clazz); + this.marker.options.icon.options.className = this.updateIconClasses(this.marker.options.icon.options.className); + if ((this.marker as any)._icon) { + L.DomUtil.addClass((this.marker as any)._icon, clazz); + } + } + } + + protected removeItemClass(clazz: string): void { + const index = this.iconClassList.indexOf(clazz); + if (index !== -1) { + this.iconClassList.splice(index, 1); + this.marker.options.icon.options.className = this.updateIconClasses(this.marker.options.icon.options.className); + if ((this.marker as any)._icon) { + L.DomUtil.removeClass((this.marker as any)._icon, clazz); + } + } + } + + protected enableDrag(): void { + this.marker.options.draggable = true; + this.marker.on('dragstart', () => { + this.moving = true; + }); + this.marker.on('dragend', () => { + this.saveMarkerLocation(); + this.moving = false; + }); + } + + protected disableDrag(): void { + this.marker.options.draggable = false; + this.marker.off('dragstart'); + this.marker.off('dragend'); + } + + private saveMarkerLocation() { + const location = this.marker.getLatLng(); + this.dataLayer.saveMarkerLocation(this.data, location); + } + + private updateMarkerLocation(data: FormattedData, dsData: FormattedData[]) { + const location = this.dataLayer.extractLocation(data, dsData); + if (!this.marker.getLatLng().equals(location) && !this.moving) { + this.marker.setLatLng(location); } } private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( (iconInfo) => { + iconInfo.icon.options.className = this.updateIconClasses(iconInfo.icon.options.className); this.marker.setIcon(iconInfo.icon); const anchor = iconInfo.icon.options.iconAnchor; if (anchor && Array.isArray(anchor)) { @@ -124,9 +169,23 @@ class TbMarkerDataLayerItem extends TbDataLayerItem { + if (!classes.includes(clazz)) { + classes.push(clazz); + } + }); + return classes.join(' '); + } } abstract class MarkerIconProcessor { @@ -390,7 +449,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer): boolean { - return !!this.extractPosition(layerData); + return !!this.extractLocationData(layerData); } protected createLayerItem(data: FormattedData, dsData: FormattedData[]): TbMarkerDataLayerItem { @@ -412,13 +471,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer { @@ -463,7 +516,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer): {x: number; y: number} { + private extractLocationData(data: FormattedData): {x: number; y: number} { if (data) { const xKeyVal = data[this.settings.xKey.label]; const yKeyVal = data[this.settings.yKey.label]; @@ -524,15 +577,31 @@ export class TbMarkersDataLayer extends TbMapDataLayer, dsData: FormattedData[]): L.LatLng { - let position = this.extractPosition(data); - if (position) { + let locationData = this.extractLocationData(data); + if (locationData) { if (this.map.type() === MapType.image && this.positionFunction) { const imageMap = this.map as TbImageMap; - position = this.positionFunction.execute(position.x, position.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0}; + locationData = this.positionFunction.execute(locationData.x, locationData.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0}; } - return this.map.positionToLatLng(position); + return this.map.locationDataToLatLng(locationData); } else { return null; } } + + public saveMarkerLocation(data: FormattedData, position: L.LatLng): void { + const converted = this.map.latLngToLocationData(position); + const locationData = [ + { + dataKey: this.settings.xKey, + value: converted.x + }, + { + dataKey: this.settings.yKey, + value: converted.y + } + ]; + this.map.saveItemData(data.$datasource, locationData).subscribe(); + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index 419a3e0d4c..789795149c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -18,7 +18,7 @@ import { defaultBasePolygonsDataLayerSettings, isCutPolygon, isJSON, PolygonsDataLayerSettings, - TbMapDatasource + TbMapDatasource, TbPolyData, TbPolygonCoordinates, TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import L from 'leaflet'; import { FormattedData } from '@shared/models/widget.models'; @@ -33,6 +33,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, dsData: FormattedData[], @@ -45,8 +46,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { + this.editing = true; + }); + this.polygon.on('pm:dragend', () => { + this.savePolygonCoordinates(); + this.editing = false; + }); + } + + protected disableDrag(): void { + this.polygon.pm.disableLayerDrag(); + this.polygon.off('pm:dragstart'); + this.polygon.off('pm:dragend'); + } + + private savePolygonCoordinates() { + let coordinates: TbPolygonCoordinates = this.polygon.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0] as TbPolygonCoordinates; + } + if (this.polygon instanceof L.Rectangle && !isCutPolygon(coordinates)) { + const bounds = this.polygon.getBounds(); + const boundsArray = [bounds.getNorthWest(), bounds.getNorthEast(), bounds.getSouthWest(), bounds.getSouthEast()]; + if (coordinates.every(point => boundsArray.find(boundPoint => boundPoint.equals(point as L.LatLng)) !== undefined)) { + coordinates = [bounds.getNorthWest(), bounds.getSouthEast()]; + } + } + this.dataLayer.savePolygonCoordinates(this.data, coordinates); + } + private updatePolygonShape(data: FormattedData) { - const polyData = this.dataLayer.extractPolygonCoordinates(data); + if (this.editing) { + return; + } + const polyData = this.dataLayer.extractPolygonCoordinates(data) as TbPolyData; if (isCutPolygon(polyData) || polyData.length !== 2) { if (this.polygon instanceof L.Rectangle) { this.polygonContainer.removeLayer(this.polygon); this.polygon = L.polygon(polyData, { - ...this.polygonStyle + ...this.polygonStyle, + snapIgnore: !this.dataLayer.isSnappable() }); this.polygon.addTo(this.polygonContainer); + this.editModeUpdated(); } else { this.polygon.setLatLngs(polyData); } } else if (polyData.length === 2) { - const bounds = new L.LatLngBounds(polyData); + const bounds = new L.LatLngBounds(polyData as L.LatLngTuple[]); (this.polygon as L.Rectangle).setBounds(bounds); } } @@ -134,11 +185,22 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer) { + public extractPolygonCoordinates(data: FormattedData): TbPolygonRawCoordinates { let rawPolyData = data[this.settings.polygonKey.label]; if (isString(rawPolyData)) { rawPolyData = JSON.parse(rawPolyData); } - return this.map.toPolygonCoordinates(rawPolyData); + return this.map.polygonDataToCoordinates(rawPolyData); + } + + public savePolygonCoordinates(data: FormattedData, coordinates: TbPolygonCoordinates): void { + const converted = this.map.coordinatesToPolygonData(coordinates); + const polygonData = [ + { + dataKey: this.settings.polygonKey, + value: converted + } + ]; + this.map.saveItemData(data.$datasource, polygonData).subscribe(); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts index ee5baeb109..746d576fe2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -15,15 +15,22 @@ /// import { + checkLngLat, DEFAULT_ZOOM_LEVEL, defaultGeoMapSettings, - GeoMapSettings, latLngPointToBounds, - MapZoomAction, TbCircleData + GeoMapSettings, + latLngPointToBounds, + MapZoomAction, + TbCircleData, + TbPolygonCoordinate, + TbPolygonCoordinates, + TbPolygonRawCoordinate, + TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { forkJoin, Observable, of } from 'rxjs'; -import L, { LatLngBounds, LatLngTuple } from 'leaflet'; +import L from 'leaflet'; import { map, tap } from 'rxjs/operators'; import { TbMapLayer } from '@home/components/widget/lib/maps/map-layer'; import { TbMap } from '@home/components/widget/lib/maps/map'; @@ -53,7 +60,7 @@ export class TbGeoMap extends TbMap { protected onResize(): void {} - protected fitBounds(bounds: LatLngBounds) { + protected fitBounds(bounds: L.LatLngBounds) { if (bounds.isValid()) { if (!this.settings.fitMapBounds && this.settings.defaultZoomLevel) { this.map.setZoom(this.settings.defaultZoomLevel, { animate: false }); @@ -115,27 +122,63 @@ export class TbGeoMap extends TbMap { ); } - public positionToLatLng(position: {x: number; y: number}): L.LatLng { + public locationDataToLatLng(position: {x: number; y: number}): L.LatLng { return L.latLng(position.x, position.y) as L.LatLng; } - public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { - return (expression).map((el) => { - if (!Array.isArray(el[0]) && el.length === 2) { + public latLngToLocationData(position: L.LatLng): {x: number; y: number} { + position = position ? checkLngLat(position, this.southWest, this.northEast, 0) : {lat: null, lng: null} as L.LatLng; + return { + x: position.lat, + y: position.lng + } + } + + public polygonDataToCoordinates(expression: TbPolygonRawCoordinates): TbPolygonRawCoordinates { + return (expression).map((el: TbPolygonRawCoordinate) => { + if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { return el; } else if (Array.isArray(el) && el.length) { - return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + return this.polygonDataToCoordinates(el as TbPolygonRawCoordinates) as TbPolygonRawCoordinate; } else { return null; } }).filter(el => !!el); } - public convertCircleData(circle: TbCircleData): TbCircleData { + public coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates { + if (coordinates.length) { + return coordinates.map((point: TbPolygonCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolygonData(point) as TbPolygonRawCoordinate; + } else { + const convertPoint = checkLngLat(point, this.southWest, this.northEast); + return [convertPoint.lat, convertPoint.lng]; + } + }); + } + return []; + } + + public circleDataToCoordinates(circle: TbCircleData): TbCircleData { const centerPoint = latLngPointToBounds(new L.LatLng(circle.latitude, circle.longitude), this.southWest, this.northEast); circle.latitude = centerPoint.lat; circle.longitude = centerPoint.lng; return circle; } + public coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData { + let circleData: TbCircleData = null; + if (center) { + const position = checkLngLat(center, this.southWest, this.northEast); + circleData = { + latitude: position.lat, + longitude: position.lng, + radius + }; + } + return circleData; + } + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts index e781f1c62a..6bd2e24afa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -15,6 +15,7 @@ /// import { + calculateNewPointCoordinate, defaultImageMapSettings, defaultImageMapSourceSettings, ImageMapSettings, @@ -22,12 +23,12 @@ import { ImageSourceType, loadImageWithAspect, MapZoomAction, - TbCircleData + TbCircleData, TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { Observable, of, ReplaySubject, switchMap } from 'rxjs'; -import L, { LatLngBounds, LatLngLiteral, LatLngTuple } from 'leaflet'; +import L from 'leaflet'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { catchError } from 'rxjs/operators'; @@ -115,38 +116,62 @@ export class TbImageMap extends TbMap { } } - protected fitBounds(_bounds: LatLngBounds) {} + protected fitBounds(_bounds: L.LatLngBounds) {} - public positionToLatLng(position: {x: number; y: number}): L.LatLng { + public locationDataToLatLng(position: {x: number; y: number}): L.LatLng { return this.pointToLatLng( position.x * this.width, position.y * this.height); } - public pointToLatLng(x: number, y: number): L.LatLng { - return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); - } - - private latLngToPoint(latLng: LatLngLiteral): L.Point { - return L.CRS.Simple.latLngToPoint(latLng, this.maxZoom - 1); + public latLngToLocationData(position: L.LatLng): {x: number; y: number} { + if (!position) { + return { + x: null, + y: null + }; + } + const point = this.latLngToPoint(position); + const posX = calculateNewPointCoordinate(point.x, this.width); + const posY = calculateNewPointCoordinate(point.y, this.height); + return { + x: posX, + y: posY + }; } - public toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any { - return (expression).map((el) => { + public polygonDataToCoordinates(expression: TbPolygonRawCoordinates): TbPolygonRawCoordinates { + return expression.map((el: TbPolygonRawCoordinate) => { if (!Array.isArray(el[0]) && !Array.isArray(el[1]) && el.length === 2) { - return this.pointToLatLng( + const latLng = this.pointToLatLng( el[0] * this.width, el[1] * this.height ); + return [latLng.lat, latLng.lng] as TbPolygonRawCoordinate; } else if (Array.isArray(el) && el.length) { - return this.toPolygonCoordinates(el as LatLngTuple[] | LatLngTuple[][]); + return this.polygonDataToCoordinates(el as TbPolygonRawCoordinates) as TbPolygonRawCoordinate; } else { return null; } }).filter(el => !!el); } - public convertCircleData(circle: TbCircleData): TbCircleData { + public coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates { + if (coordinates.length) { + return coordinates.map((point: TbPolygonCoordinate) => { + if (Array.isArray(point)) { + return this.coordinatesToPolygonData(point) as TbPolygonRawCoordinate; + } else { + const pos = this.latLngToPoint(point); + return [calculateNewPointCoordinate(pos.x, this.width), calculateNewPointCoordinate(pos.y, this.height)]; + } + }); + } else { + return []; + } + } + + public circleDataToCoordinates(circle: TbCircleData): TbCircleData { const centerPoint = this.pointToLatLng(circle.latitude * this.width, circle.longitude * this.height); circle.latitude = centerPoint.lat; circle.longitude = centerPoint.lng; @@ -154,10 +179,34 @@ export class TbImageMap extends TbMap { return circle; } + public coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData { + let circleData: TbCircleData = null; + if (center) { + const point = this.latLngToPoint(center); + const posX = calculateNewPointCoordinate(point.x, this.width); + const posY = calculateNewPointCoordinate(point.y, this.height); + const convertedRadius = calculateNewPointCoordinate(radius, this.width); + circleData = { + latitude: posX, + longitude: posY, + radius: convertedRadius + }; + } + return circleData; + } + public getAspect(): number { return this.imageLayerData.aspect; } + private pointToLatLng(x: number, y: number): L.LatLng { + return L.CRS.Simple.pointToLatLng({ x, y } as L.PointExpression, this.maxZoom - 1); + } + + private latLngToPoint(latLng: L.LatLngLiteral): L.Point { + return L.CRS.Simple.latLngToPoint(latLng, this.maxZoom - 1); + } + private doCreateMap(updateImage?: boolean) { if (!this.map && this.imageLayerData.aspect > 0) { const center = this.pointToLatLng(this.width / 2, this.height / 2); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index aabdf47810..238d91f03f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -15,6 +15,9 @@ */ @import '../../../../../../../scss/constants'; +//$map-element-hover-color: #307FE5; +$map-element-hover-color: rgba(0,0,0,0.56); + .tb-map-layout { display: flex; width: 100%; @@ -104,6 +107,30 @@ &.tb-marker-div-icon { background: none; border: none; + &.tb-draggable { + cursor: move; + } + &.tb-hoverable { + svg { + transition: filter 0.2s; + } + &:hover { + svg { + filter: drop-shadow( 0 0 4px $map-element-hover-color); + } + } + } + } + } + img.leaflet-marker-icon, path { + &.tb-draggable { + cursor: move; + } + &.tb-hoverable { + transition: filter 0.2s; + &:hover { + filter: drop-shadow( 0 0 4px $map-element-hover-color); + } } } .tb-cluster-marker-container { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 350aeaf85d..672682591c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -17,17 +17,18 @@ import { additionalMapDataSourcesToDatasources, BaseMapSettings, + DataKeyValuePair, MapActionHandler, MapType, mergeMapDatasources, parseCenterPosition, TbCircleData, - TbMapDatasource + TbMapDatasource, TbPolygonCoordinates, TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; -import L, { LatLngBounds, LatLngTuple, LeafletMouseEvent, Projection } from 'leaflet'; +import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; @@ -39,6 +40,9 @@ import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; import { TbPolygonsDataLayer } from '@home/components/widget/lib/maps/data-layer/polygons-data-layer'; import { TbCirclesDataLayer } from '@home/components/widget/lib/maps/data-layer/circles-data-layer'; +import { AttributeService } from '@core/http/attribute.service'; +import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; export abstract class TbMap { @@ -49,8 +53,8 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected ignoreUpdateBounds = false; - protected southWest = new L.LatLng(-Projection.SphericalMercator['MAX_LATITUDE'], -180); - protected northEast = new L.LatLng(Projection.SphericalMercator['MAX_LATITUDE'], 180); + protected southWest = new L.LatLng(-L.Projection.SphericalMercator['MAX_LATITUDE'], -180); + protected northEast = new L.LatLng(L.Projection.SphericalMercator['MAX_LATITUDE'], 180); protected dataLayers: TbMapDataLayer[]; protected dsData: FormattedData[]; @@ -128,6 +132,7 @@ export abstract class TbMap { this.map.panTo(this.defaultCenterPosition); } this.setupDataLayers(); + this.setupEditMode(); this.createdControlButtonTooltip(); } @@ -225,6 +230,13 @@ export abstract class TbMap { } } + private setupEditMode() { + const dragEnabled = this.dataLayers.some(dl => dl.isDragEnabled()); + if (dragEnabled) { + //this.map.pm.enableGlobalDragMode(); + } + } + private createdControlButtonTooltip() { import('tooltipster').then(() => { if ($.tooltipster) { @@ -326,7 +338,7 @@ export abstract class TbMap { protected abstract onResize(): void; - protected abstract fitBounds(bounds: LatLngBounds): void; + protected abstract fitBounds(bounds: L.LatLngBounds): void; protected doSetupControls(): Observable { return of(null); @@ -375,7 +387,7 @@ export abstract class TbMap { public markerClick(marker: L.Layer, datasource: TbMapDatasource): void { if (Object.keys(this.markerClickActions).length) { - marker.on('click', (event: LeafletMouseEvent) => { + marker.on('click', (event: L.LeafletMouseEvent) => { for (const action in this.markerClickActions) { this.markerClickActions[action](event.originalEvent, datasource); } @@ -385,7 +397,7 @@ export abstract class TbMap { public polygonClick(polygon: L.Layer, datasource: TbMapDatasource): void { if (Object.keys(this.polygonClickActions).length) { - polygon.on('click', (event: LeafletMouseEvent) => { + polygon.on('click', (event: L.LeafletMouseEvent) => { for (const action in this.polygonClickActions) { this.polygonClickActions[action](event.originalEvent, datasource); } @@ -395,7 +407,7 @@ export abstract class TbMap { public circleClick(circle: L.Layer, datasource: TbMapDatasource): void { if (Object.keys(this.circleClickActions).length) { - circle.on('click', (event: LeafletMouseEvent) => { + circle.on('click', (event: L.LeafletMouseEvent) => { for (const action in this.circleClickActions) { this.circleClickActions[action](event.originalEvent, datasource); } @@ -403,6 +415,50 @@ export abstract class TbMap { } } + public saveItemData(datasource: TbMapDatasource, data: DataKeyValuePair[]): Observable { + const attributeService = this.ctx.$injector.get(AttributeService); + const attributes: AttributeData[] = []; + const timeseries: AttributeData[] = []; + const entityId: EntityId = { + entityType: datasource.entityType, + id: datasource.entityId + }; + data.forEach(pair => { + const key = pair.dataKey; + if (key.type === DataKeyType.attribute) { + attributes.push({ + key: key.name, + value: pair.value + }); + } else if (key.type === DataKeyType.timeseries) { + timeseries.push({ + key: key.name, + value: pair.value + }); + } + }); + const observables: Observable[] = []; + if (timeseries.length) { + observables.push(attributeService.saveEntityTimeseries( + entityId, + LatestTelemetry.LATEST_TELEMETRY, + timeseries + )); + } + if (attributes.length) { + observables.push(attributeService.saveEntityAttributes( + entityId, + AttributeScope.SERVER_SCOPE, + attributes + )); + } + if (observables.length) { + return forkJoin(observables); + } else { + return of(null); + } + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); @@ -415,10 +471,18 @@ export abstract class TbMap { }); } - public abstract positionToLatLng(position: {x: number; y: number}): L.LatLng; + public abstract locationDataToLatLng(position: {x: number; y: number}): L.LatLng; + + public abstract latLngToLocationData(position: L.LatLng): {x: number; y: number}; + + public abstract polygonDataToCoordinates(coordinates: TbPolygonRawCoordinates): TbPolygonRawCoordinates; + + public abstract coordinatesToPolygonData(coordinates: TbPolygonCoordinates): TbPolygonRawCoordinates; + + public abstract circleDataToCoordinates(circle: TbCircleData): TbCircleData; + + public abstract coordinatesToCircleData(center: L.LatLng, radius: number): TbCircleData; - public abstract toPolygonCoordinates(expression: (LatLngTuple | LatLngTuple[] | LatLngTuple[][])[]): any; - public abstract convertCircleData(circle: TbCircleData): TbCircleData; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 09f6f0c488..1f43e83c85 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -19,7 +19,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; -import L from 'leaflet'; +import L, { LatLngExpression } from 'leaflet'; import { TbFunction } from '@shared/models/js-function.models'; import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -89,11 +89,35 @@ export interface DataLayerTooltipSettings extends DataLayerPatternSettings { offsetY: number; } +export enum DataLayerEditAction { + add = 'add', + edit = 'edit', + move = 'move', + remove = 'remove' +} + +export const dataLayerEditActions = Object.keys(DataLayerEditAction) as DataLayerEditAction[]; + +export const dataLayerEditActionTranslationMap = new Map( + [ + [DataLayerEditAction.add, 'widgets.maps.data-layer.action-add'], + [DataLayerEditAction.edit, 'widgets.maps.data-layer.action-edit'], + [DataLayerEditAction.move, 'widgets.maps.data-layer.action-move'], + [DataLayerEditAction.remove, 'widgets.maps.data-layer.action-remove'] + ] +); + +export interface DataLayerEditSettings { + enabledActions: DataLayerEditAction[]; + snappable: boolean; +} + export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; label: DataLayerPatternSettings; tooltip: DataLayerTooltipSettings; groups?: string[]; + edit: DataLayerEditSettings; } export const defaultBaseDataLayerSettings = (mapType: MapType): Partial => ({ @@ -112,6 +136,10 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details', offsetX: 0, offsetY: -1 + }, + edit: { + enabledActions: [], + snappable: false } }) @@ -864,12 +892,23 @@ export type ClusterMarkerColorFunction = (data: FormattedData[] export type MarkerPositionFunction = (origXPos: number, origYPos: number, data: FormattedData, dsData: FormattedData[], aspect: number) => { x: number, y: number }; +export type TbPolygonRawCoordinate = L.LatLngTuple | L.LatLngTuple[] | L.LatLngTuple[][]; +export type TbPolygonRawCoordinates = TbPolygonRawCoordinate[]; +export type TbPolyData = L.LatLngTuple[] | L.LatLngTuple[][] | L.LatLngTuple[][][]; +export type TbPolygonCoordinate = L.LatLng | L.LatLng[] | L.LatLng[][]; +export type TbPolygonCoordinates = TbPolygonCoordinate[]; + export interface TbCircleData { latitude: number; longitude: number; radius: number; } +export type DataKeyValuePair = { + dataKey: DataKey; + value: any; +} + export const isJSON = (data: string): boolean => { try { const parseData = JSON.parse(data); @@ -892,7 +931,7 @@ export const isValidLongitude = (longitude: any): boolean => export const isValidLatLng = (latitude: any, longitude: any): boolean => isValidLatitude(latitude) && isValidLongitude(longitude); -export const isCutPolygon = (data): boolean => { +export const isCutPolygon = (data: TbPolygonCoordinates | TbPolygonRawCoordinates): boolean => { return data.length > 1 && Array.isArray(data[0]) && (Array.isArray(data[0][0]) || data[0][0] instanceof L.LatLng); } @@ -1069,3 +1108,31 @@ export const processTooltipTemplate = (template: string): string => { return template; } + +export function calculateNewPointCoordinate(coordinate: number, imageSize: number): number { + let pointCoordinate = coordinate / imageSize; + if (pointCoordinate < 0) { + pointCoordinate = 0; + } else if (pointCoordinate > 1) { + pointCoordinate = 1; + } + return pointCoordinate; +} + +export function checkLngLat(point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng { + const maxLngMap = northEast.lng - offset; + const minLngMap = southWest.lng + offset; + const maxLatMap = northEast.lat - offset; + const minLatMap = southWest.lat + offset; + if (point.lng > maxLngMap) { + point.lng = maxLngMap; + } else if (point.lng < minLngMap) { + point.lng = minLngMap; + } + if (point.lat > maxLatMap) { + point.lat = maxLatMap; + } else if (point.lat < minLatMap) { + point.lat = minLatMap; + } + return point; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 8742eff68c..cc17006027 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -262,6 +262,22 @@ formControlName="groups">
+
+
{{ dataLayerEditTitle | translate }}
+
+
widgets.maps.data-layer.edit-instruments
+ + + {{ dataLayerEditActionTranslationMap.get(action) | translate }} + + +
+
+ + {{ 'widgets.maps.data-layer.enable-snapping' | translate }} + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index b57f92fc35..fe37953422 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -18,10 +18,12 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core' import { DialogComponent } from '@shared/components/dialog.component'; import { CirclesDataLayerSettings, + DataLayerEditAction, dataLayerEditActions, + dataLayerEditActionTranslationMap, defaultBaseMapDataLayerSettings, MapDataLayerSettings, MapDataLayerType, - MapType, + MapType, mapZoomActions, mapZoomActionTranslationMap, MarkersDataLayerSettings, MarkerType, PolygonsDataLayerSettings, @@ -71,6 +73,10 @@ export class MapDataLayerDialogComponent extends DialogComponent = []; datasourceTypesTranslations = datasourceTypeTranslationMap; + dataLayerEditTitle: string; + dataLayerEditActions: Array = []; + dataLayerEditActionTranslationMap = dataLayerEditActionTranslationMap; + dataLayerFormGroup: UntypedFormGroup; settings = this.data.settings; @@ -111,13 +117,19 @@ export class MapDataLayerDialogComponent extends DialogComponent Date: Mon, 27 Jan 2025 10:41:29 +0200 Subject: [PATCH 024/127] UI: Map - handle layer snapping --- .../lib/maps/data-layer/circles-data-layer.ts | 3 ++ .../lib/maps/data-layer/map-data-layer.ts | 1 + .../lib/maps/data-layer/markers-data-layer.ts | 45 ++++++++++++++----- .../maps/data-layer/polygons-data-layer.ts | 3 ++ 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index f1c364c66f..caccea87a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -91,6 +91,9 @@ class TbCircleDataLayerItem extends TbDataLayerItem { this.editing = true; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 269d8fc294..f08fd0bca8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -314,6 +314,7 @@ export abstract class TbMapDataLayer { - this.moving = true; - }); - this.marker.on('dragend', () => { - this.saveMarkerLocation(); - this.moving = false; - }); + if (this.settings.markerClustering?.enable) { + this.marker.options.draggable = true; + this.marker.on('dragstart', () => { + this.moving = true; + }); + this.marker.on('dragend', () => { + this.saveMarkerLocation(); + this.moving = false; + }); + } else { + this.marker.pm.setOptions({ + snappable: this.dataLayer.isSnappable() + }); + this.marker.pm.enableLayerDrag(); + this.marker.on('pm:dragstart', () => { + this.moving = true; + }); + this.marker.on('pm:dragend', () => { + this.saveMarkerLocation(); + this.moving = false; + }); + } } protected disableDrag(): void { - this.marker.options.draggable = false; - this.marker.off('dragstart'); - this.marker.off('dragend'); + if (this.settings.markerClustering?.enable) { + this.marker.options.draggable = false; + this.marker.off('dragstart'); + this.marker.off('dragend'); + } else { + this.marker.pm.disableLayerDrag(); + this.marker.off('pm:dragstart'); + this.marker.off('pm:dragend'); + } } private saveMarkerLocation() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index 789795149c..b16952a317 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -96,6 +96,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { this.editing = true; From 4ab9fb9f7b44aa5d351ca56b14cb6a78f0e67c7f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 27 Jan 2025 17:56:33 +0200 Subject: [PATCH 025/127] UI: Map editor toolbar. --- .../lib/maps/data-layer/circles-data-layer.ts | 15 ++- .../lib/maps/data-layer/map-data-layer.ts | 96 ++++++++++++-- .../lib/maps/data-layer/markers-data-layer.ts | 47 +++++-- .../maps/data-layer/polygons-data-layer.ts | 25 +++- .../widget/lib/maps/leaflet/leaflet-tb.ts | 90 +++++++++++++- .../home/components/widget/lib/maps/map.scss | 72 +++++++++-- .../home/components/widget/lib/maps/map.ts | 117 ++++++++++++++---- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 28 ++++- 8 files changed, 425 insertions(+), 65 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index caccea87a7..c0dcb8e980 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -45,6 +45,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().circleClick(this.circle, data.$datasource); + this.dataLayer.getMap().circleClick(this, data.$datasource); } protected unbindLabel() { @@ -62,7 +63,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem, center: L.LatLng, radius: number): void { - const converted = this.map.coordinatesToCircleData(center, radius); + const converted = center ? this.map.coordinatesToCircleData(center, radius) : null; const circleData = [ { dataKey: this.settings.circleKey, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index f08fd0bca8..9597464e9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -39,17 +39,19 @@ import { parseTbFunction, safeExecuteTbFunction } from '@core/utils'; -import L, { LatLngBounds } from 'leaflet'; +import L from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { map } from 'rxjs/operators'; import { WidgetContext } from '@home/models/widget-component.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; -export abstract class TbDataLayerItem, L extends L.Layer = L.Layer> { +export abstract class TbDataLayerItem = TbMapDataLayer, L extends L.Layer = L.Layer> { protected layer: L; protected tooltip: L.Popup; protected data: FormattedData; + protected selected = false; protected constructor(data: FormattedData, dsData: FormattedData[], @@ -61,6 +63,7 @@ export abstract class TbDataLayerItem { + if (!this.isEditing()) { + this.dataLayer.getMap().selectItem(this); + } + }); + this.layer.on('remove', () => { + if (this.selected) { + this.dataLayer.getMap().deselectItem(); + } + }); + } + } + protected enableEdit(): void { if (this.dataLayer.isHoverable()) { this.addItemClass('tb-hoverable'); @@ -110,16 +128,57 @@ export abstract class TbDataLayerItem { + this.removeDataItem(); + }, + iconClass: 'tb-remove' + }); + } + return buttons; + } else { + return []; + } + } + + public deselect() { + if (this.selected) { + this.selected = false; + this.layer.closePopup(); + this.updateSelectedState(); + } + } + + public isSelected() { + return this.selected; + } + public editModeUpdated() { if (this.dataLayer.isEditMode()) { this.enableEdit(); } else { this.disableEdit(); } + this.updateSelectedState(); } public update(data: FormattedData, dsData: FormattedData[]): void { @@ -128,14 +187,25 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]) { if (this.settings.tooltip.show) { let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); @@ -157,13 +227,25 @@ export abstract class TbDataLayerItem { + if (this.tooltip.isOpen()) { + this.layer.closePopup(); + } else if (!this.isEditing()) { + this.layer.openPopup(); + } + }); + } else if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { this.layer.on('mouseover', () => { - this.layer.openPopup(); + if (!this.isEditing()) { + this.layer.openPopup(); + } }); this.layer.on('mousemove', (e) => { this.tooltip.setLatLng(e.latlng); @@ -345,7 +427,7 @@ export abstract class TbMapDataLayer, _dsData: FormattedData[]): void { - this.dataLayer.getMap().markerClick(this.marker, data.$datasource); + this.dataLayer.getMap().markerClick(this, data.$datasource); } protected unbindLabel() { @@ -122,7 +128,7 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]) { this.dataLayer.markerIconProcessor.createMarkerIcon(data, dsData).subscribe( (iconInfo) => { - iconInfo.icon.options.className = this.updateIconClasses(iconInfo.icon.options.className); - this.marker.setIcon(iconInfo.icon); - const anchor = iconInfo.icon.options.iconAnchor; + let icon: L.Icon | L.DivIcon; + const options = deepClone(iconInfo.icon.options); + options.className = this.updateIconClasses(options.className); + if (iconInfo.icon instanceof L.Icon) { + icon = L.icon(options as L.IconOptions); + } else { + icon = L.divIcon(options); + } + this.marker.setIcon(icon); + const anchor = options.iconAnchor; if (anchor && Array.isArray(anchor)) { this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; } else { @@ -195,11 +216,17 @@ class TbMarkerDataLayerItem extends TbDataLayerItem { if (!classes.includes(clazz)) { classes.push(clazz); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index b16952a317..58d51782fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -48,7 +48,8 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().polygonClick(this.polygonContainer, data.$datasource); + this.dataLayer.getMap().polygonClick(this, data.$datasource); } protected unbindLabel() { @@ -103,7 +104,12 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { this.editing = true; }); - this.polygon.on('pm:dragend', () => { + this.polygon.on('pm:drag', () => { + if (this.tooltip?.isOpen()) { + this.tooltip.setLatLng(this.polygon.getBounds().getCenter()); + } + }); + this.polygon.on('pm:dragend', (e) => { this.savePolygonCoordinates(); this.editing = false; }); @@ -115,6 +121,14 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, coordinates: TbPolygonCoordinates): void { - const converted = this.map.coordinatesToPolygonData(coordinates); + const converted = coordinates ? this.map.coordinatesToPolygonData(coordinates) : null; const polygonData = [ { dataKey: this.settings.polygonKey, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 0aafc0889c..f316975bf3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import L, { Coords, TB, TileLayerOptions } from 'leaflet'; +import L, { TB } from 'leaflet'; import { guid } from '@core/utils'; import 'leaflet-providers'; import '@geoman-io/leaflet-geoman-free'; @@ -276,6 +276,81 @@ class GroupsControl extends SidebarPaneControl { } } +class ToolbarButton extends L.Control { + private readonly button: JQuery; + constructor(options: TB.ToolbarButtonOptions) { + super(options); + + this.button = $("
") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .attr('title', this.options.title) + .html('
'); + + this.button.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + this.options.click(e.originalEvent, this); + }); + } + + addToToolbar(toolbar: BottomToolbarControl): void { + this.button.appendTo(toolbar.container); + } +} + +class BottomToolbarControl extends L.Control { + + private readonly buttonContainer: JQuery; + + container: HTMLElement; + + constructor(options: TB.BottomToolbarControlOptions) { + super(options); + const controlContainer = $('.leaflet-control-container', options.mapElement); + const toolbar = $('
'); + toolbar.appendTo(controlContainer); + this.buttonContainer = $('
'); + this.buttonContainer.appendTo(toolbar); + this.container = this.buttonContainer[0]; + } + + addTo(map: L.Map): this { + return this; + } + + open(buttons: TB.ToolbarButtonOptions[]): void { + + buttons.forEach(buttonOption => { + const button = new ToolbarButton(buttonOption); + button.addToToolbar(this); + }); + + const closeButton = $("
") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .attr('title', this.options.closeTitle) + .html('
'); + + closeButton.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + this.close(); + }); + closeButton.appendTo(this.buttonContainer); + } + + close(): void { + this.buttonContainer.empty(); + if (this.options.onClose) { + this.options.onClose(); + } + } + +} + const sidebar = (options: TB.SidebarControlOptions): SidebarControl => { return new SidebarControl(options); } @@ -292,6 +367,10 @@ const groups = (options: TB.GroupsControlOptions): GroupsControl => { return new GroupsControl(options); } +const bottomToolbar = (options: TB.BottomToolbarControlOptions): BottomToolbarControl => { + return new BottomToolbarControl(options); +} + class ChinaProvider extends L.TileLayer { static chinaProviders: L.TB.TileLayer.ChinaProvidersData = { @@ -303,7 +382,7 @@ class ChinaProvider extends L.TileLayer { } }; - constructor(type: string, options?: TileLayerOptions) { + constructor(type: string, options?: L.TileLayerOptions) { options = options || {}; const parts = type.split('.'); @@ -316,7 +395,7 @@ class ChinaProvider extends L.TileLayer { super(url, options); } - getTileUrl(coords: Coords): string { + getTileUrl(coords: L.Coords): string { const data = { s: this._getSubdomain(coords), x: coords.x, @@ -338,7 +417,7 @@ class ChinaProvider extends L.TileLayer { } } -const chinaProvider = (type: string, options?: TileLayerOptions): ChinaProvider => { +const chinaProvider = (type: string, options?: L.TileLayerOptions): ChinaProvider => { return new ChinaProvider(type, options); } @@ -347,10 +426,13 @@ L.TB = L.TB || { SidebarPaneControl, LayersControl, GroupsControl, + ToolbarButton, + BottomToolbarControl, sidebar, sidebarPane, layers, groups, + bottomToolbar, TileLayer: { ChinaProvider }, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 238d91f03f..fadb85b201 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -17,6 +17,7 @@ //$map-element-hover-color: #307FE5; $map-element-hover-color: rgba(0,0,0,0.56); +$map-element-selected-color: #307FE5; .tb-map-layout { display: flex; @@ -79,6 +80,17 @@ $map-element-hover-color: rgba(0,0,0,0.56); } } } + .tb-map-bottom-toolbar { + left: 0; + right: 0; + display: flex; + flex-direction: row; + justify-content: center; + .leaflet-bar { + display: flex; + flex-direction: row; + } + } } .leaflet-control { .tb-control-button { @@ -100,6 +112,27 @@ $map-element-hover-color: rgba(0,0,0,0.56); &.tb-groups { mask-image: url('data:image/svg+xml,'); } + &.tb-remove { + mask-image: url('data:image/svg+xml,'); + } + &.tb-close { + background: #D12730; + mask-image: url('data:image/svg+xml,'); + } + } + } + } + .leaflet-map-pane:not(.leaflet-zoom-anim) { + .leaflet-marker-icon { + &.tb-hoverable:not(.tb-selected) { + svg { + transition: filter 0.2s; + } + } + } + img.leaflet-marker-icon, path { + &.tb-hoverable:not(.tb-selected) { + transition: filter 0.2s; } } } @@ -110,28 +143,35 @@ $map-element-hover-color: rgba(0,0,0,0.56); &.tb-draggable { cursor: move; } - &.tb-hoverable { - svg { - transition: filter 0.2s; - } + &.tb-hoverable:not(.tb-selected) { &:hover { svg { - filter: drop-shadow( 0 0 4px $map-element-hover-color); + //filter: drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); } } } + &.tb-selected { + svg { + filter: brightness(0.8); + //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + } + } } } img.leaflet-marker-icon, path { &.tb-draggable { cursor: move; } - &.tb-hoverable { - transition: filter 0.2s; + &.tb-hoverable:not(.tb-selected) { &:hover { - filter: drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); } } + &.tb-selected { + filter: brightness(0.8); + //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + } } .tb-cluster-marker-container { border: none; @@ -144,6 +184,11 @@ $map-element-hover-color: rgba(0,0,0,0.56); width: 40px; height: 40px; } + .tb-marker-label, .tb-polygon-label, .tb-circle-label { + border: none; + background: none; + box-shadow: none; + } } .tb-map-sidebar { .tb-layers, .tb-groups { @@ -267,3 +312,14 @@ $map-element-hover-color: rgba(0,0,0,0.56); } } } + +@keyframes tb-selected-animation { + 0% { + //filter: drop-shadow( 0 0 2px $map-element-selected-color); + filter: brightness(1); + } + 100% { + //filter: drop-shadow( 0 0 4px $map-element-selected-color) drop-shadow( 0 0 4px $map-element-selected-color); + filter: brightness(0.8); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 672682591c..dc36f64bd2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -32,7 +32,11 @@ import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; -import { MapDataLayerType, TbMapDataLayer, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + MapDataLayerType, + TbDataLayerItem, + TbMapDataLayer, +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; @@ -44,6 +48,9 @@ import { AttributeService } from '@core/http/attribute.service'; import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; + +type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; export abstract class TbMap { @@ -59,10 +66,14 @@ export abstract class TbMap { protected dataLayers: TbMapDataLayer[]; protected dsData: FormattedData[]; + protected selectedDataItem: TbDataLayerItem; + protected mapElement: HTMLElement; protected sidebar: L.TB.SidebarControl; + protected editToolbar: L.TB.BottomToolbarControl; + private readonly mapResize$: ResizeObserver; private readonly tooltipActions: { [name: string]: MapActionHandler }; @@ -70,7 +81,7 @@ export abstract class TbMap { private readonly polygonClickActions: { [name: string]: MapActionHandler }; private readonly circleClickActions: { [name: string]: MapActionHandler }; - private tooltipInstances: ITooltipsterInstance[] = []; + private tooltipInstances: TooltipInstancesData[] = []; protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, @@ -133,7 +144,7 @@ export abstract class TbMap { } this.setupDataLayers(); this.setupEditMode(); - this.createdControlButtonTooltip(); + this.createdControlButtonTooltip(this.mapElement, ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left'); } private setupDataLayers() { @@ -231,21 +242,36 @@ export abstract class TbMap { } private setupEditMode() { - const dragEnabled = this.dataLayers.some(dl => dl.isDragEnabled()); - if (dragEnabled) { - //this.map.pm.enableGlobalDragMode(); - } + this.editToolbar = L.TB.bottomToolbar({ + mapElement: $(this.mapElement), + closeTitle: this.ctx.translate.instant('action.cancel'), + onClose: () => { + this.deselectItem(); + } + }).addTo(this.map); + + this.map.on('click', () => { + this.deselectItem(); + }); } - private createdControlButtonTooltip() { + private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { import('tooltipster').then(() => { + let tooltipData = this.tooltipInstances.find(d => d.root === root); + if (!tooltipData) { + tooltipData = { + root, + instances: [] + } + this.tooltipInstances.push(tooltipData); + } if ($.tooltipster) { - this.tooltipInstances.forEach((instance) => { + tooltipData.instances.forEach((instance) => { instance.destroy(); }); - this.tooltipInstances = []; + tooltipData.instances = []; } - $(this.mapElement) + $(root) .find('a[role="button"]:not(.leaflet-pm-action)') .each((_index, element) => { let title: string; @@ -267,7 +293,7 @@ export abstract class TbMap { scroll: true, mouseleave: true }, - side: ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left', + side, distance: 2, trackOrigin: true, functionBefore: (_instance, helper) => { @@ -277,7 +303,14 @@ export abstract class TbMap { }, } ); - this.tooltipInstances.push(tooltip.tooltipster('instance')); + const instance = tooltip.tooltipster('instance'); + tooltipData.instances.push(instance); + instance.on('destroyed', () => { + const index = tooltipData.instances.indexOf(instance); + if (index > -1) { + tooltipData.instances.splice(index, 1); + } + }); }); }); } @@ -385,36 +418,64 @@ export abstract class TbMap { } } - public markerClick(marker: L.Layer, datasource: TbMapDatasource): void { + public markerClick(marker: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.markerClickActions).length) { - marker.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.markerClickActions) { - this.markerClickActions[action](event.originalEvent, datasource); + marker.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!marker.isEditing()) { + for (const action in this.markerClickActions) { + this.markerClickActions[action](event.originalEvent, datasource); + } } }); } } - public polygonClick(polygon: L.Layer, datasource: TbMapDatasource): void { + public polygonClick(polygon: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.polygonClickActions).length) { - polygon.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.polygonClickActions) { - this.polygonClickActions[action](event.originalEvent, datasource); + polygon.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!polygon.isEditing()) { + for (const action in this.polygonClickActions) { + this.polygonClickActions[action](event.originalEvent, datasource); + } } }); } } - public circleClick(circle: L.Layer, datasource: TbMapDatasource): void { + public circleClick(circle: TbDataLayerItem, datasource: TbMapDatasource): void { if (Object.keys(this.circleClickActions).length) { - circle.on('click', (event: L.LeafletMouseEvent) => { - for (const action in this.circleClickActions) { - this.circleClickActions[action](event.originalEvent, datasource); + circle.getLayer().on('click', (event: L.LeafletMouseEvent) => { + if (!circle.isEditing()) { + for (const action in this.circleClickActions) { + this.circleClickActions[action](event.originalEvent, datasource); + } } }); } } + public selectItem(item: TbDataLayerItem): void { + if (this.selectedDataItem) { + this.selectedDataItem.deselect(); + this.selectedDataItem = null; + this.editToolbar.close(); + } + this.selectedDataItem = item; + if (this.selectedDataItem) { + const buttons = this.selectedDataItem.select(); + this.editToolbar.open(buttons); + this.createdControlButtonTooltip(this.editToolbar.container, 'top'); + } + } + + public deselectItem(): void { + this.selectItem(null); + } + + public getSelectedDataItem(): TbDataLayerItem { + return this.selectedDataItem; + } + public saveItemData(datasource: TbMapDatasource, data: DataKeyValuePair[]): Observable { const attributeService = this.ctx.$injector.get(AttributeService); const attributes: AttributeData[] = []; @@ -466,8 +527,10 @@ export abstract class TbMap { if (this.map) { this.map.remove(); } - this.tooltipInstances.forEach((instance) => { - instance.destroy(); + this.tooltipInstances.forEach((data) => { + data.instances.forEach(instance => { + instance.destroy(); + }) }); } diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index b5d40d3264..ab108c5c73 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -15,7 +15,7 @@ /// import { FormattedData } from '@shared/models/widget.models'; -import L from 'leaflet'; +import L, { Control, ControlOptions } from 'leaflet'; import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; // redeclare module, maintains compatibility with @types/leaflet @@ -89,6 +89,30 @@ declare module 'leaflet' { constructor(options: GroupsControlOptions); } + interface ToolbarButtonOptions extends ControlOptions{ + title: string; + click: (e: MouseEvent, button: ToolbarButton) => void; + iconClass: string; + } + + class ToolbarButton extends Control{ + constructor(options: ToolbarButtonOptions); + addToToolbar(toolbar: BottomToolbarControl): void; + } + + interface BottomToolbarControlOptions extends ControlOptions { + mapElement: JQuery; + closeTitle: string; + onClose: () => void; + } + + class BottomToolbarControl extends Control { + constructor(options: BottomToolbarControlOptions); + open(buttons: ToolbarButtonOptions[]): void; + close(): void; + container: HTMLElement; + } + function sidebar(options: SidebarControlOptions): SidebarControl; function sidebarPane(options: O): SidebarPaneControl; @@ -97,6 +121,8 @@ declare module 'leaflet' { function groups(options: GroupsControlOptions): GroupsControl; + function bottomToolbar(options: BottomToolbarControlOptions): BottomToolbarControl; + namespace TileLayer { interface ChinaProvidersData { From cec50eac8029628fb5cf1f6f37c8edf0c2318089 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 28 Jan 2025 15:51:44 +0200 Subject: [PATCH 026/127] UI: Maps polygons edit mode. --- .../lib/maps/data-layer/circles-data-layer.ts | 23 +++ .../lib/maps/data-layer/map-data-layer.ts | 35 +++- .../lib/maps/data-layer/markers-data-layer.ts | 4 + .../maps/data-layer/polygons-data-layer.ts | 170 ++++++++++++++++++ .../widget/lib/maps/leaflet/leaflet-tb.ts | 57 +++++- .../home/components/widget/lib/maps/map.scss | 12 +- .../home/components/widget/lib/maps/map.ts | 35 ++-- .../assets/locale/locale.constant-en_US.json | 14 +- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 11 +- 9 files changed, 333 insertions(+), 28 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index c0dcb8e980..7f877dbb00 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -111,6 +111,29 @@ class TbCircleDataLayerItem extends TbDataLayerItem this.editing = true); + this.circle.on('pm:markerdragend', () => this.editing = false); + this.circle.on('pm:edit', (e) => this.saveCircleCoordinates()); + this.circle.pm.enable(); + } + return []; + } + + protected onDeselected(): void { + if (this.dataLayer.isEditEnabled()) { + this.circle.pm.disable(); + this.circle.off('pm:markerdragstart'); + this.circle.off('pm:markerdragend'); + this.circle.off('pm:edit'); + } + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.circle.remove-circle-for', {entityName: this.data.entityName}); + } + protected removeDataItem(): void { this.dataLayer.saveCircleCoordinates(this.data, null, null); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 9597464e9e..04672173f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -52,6 +52,7 @@ export abstract class TbDataLayerItem; protected selected = false; + protected removed = false; protected constructor(data: FormattedData, dsData: FormattedData[], @@ -143,11 +144,13 @@ export abstract class TbDataLayerItem { this.removeDataItem(); }, @@ -160,12 +163,19 @@ export abstract class TbDataLayerItem { + const map = this.dataLayer.getMap().getMap(); + if (!map.pm.globalCutModeEnabled()) { + this.disablePolygonRotateMode(); + this.disablePolygonEditMode(); + this.enablePolygonCutMode(button); + } else { + this.disablePolygonCutMode(button); + this.enablePolygonEditMode(); + } + } + }, + { + id: 'rotate', + title: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.rotate'), + iconClass: 'tb-rotate', + click: (e, button) => { + if (!this.polygon.pm.rotateEnabled()) { + this.disablePolygonCutMode(); + this.disablePolygonEditMode(); + this.enablePolygonRotateMode(button); + } else { + this.disablePolygonRotateMode(button); + this.enablePolygonEditMode(); + } + } + } + ); + } + return buttons; + } + + protected onDeselected(): void { + if (this.dataLayer.isEditEnabled()) { + this.disablePolygonEditMode(); + this.disablePolygonCutMode(); + this.disablePolygonRotateMode(); + } + } + + protected canDeselect(cancel = false): boolean { + if (!this.removed) { + const map = this.dataLayer.getMap().getMap(); + if (map.pm.globalCutModeEnabled()) { + if (cancel) { + this.disablePolygonCutMode(); + } + return false; + } else if (this.polygon.pm.rotateEnabled()) { + if (cancel) { + this.disablePolygonRotateMode(); + } + return false; + } + } + return true; + } + + protected removeDataItemTitle(): string { + return this.dataLayer.getCtx().translate.instant('widgets.maps.data-layer.polygon.remove-polygon-for', {entityName: this.data.entityName}); + } + protected removeDataItem(): void { this.dataLayer.savePolygonCoordinates(this.data, null); } @@ -129,6 +200,105 @@ class TbPolygonDataLayerItem extends TbDataLayerItem this.editing = true); + this.polygon.on('pm:markerdragend', () => this.editing = false); + this.polygon.on('pm:edit', (e) => this.savePolygonCoordinates()); + this.polygon.pm.enable(); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(false); + } + + private disablePolygonEditMode() { + this.polygon.pm.disable(); + this.polygon.off('pm:markerdragstart'); + this.polygon.off('pm:markerdragend'); + this.polygon.off('pm:edit'); + const map = this.dataLayer.getMap(); + map.getEditToolbar().getButton('remove')?.setDisabled(true); + } + + private enablePolygonCutMode(cutButton?: L.TB.ToolbarButton) { + this.polygonContainer.closePopup(); + this.editing = true; + this.polygon.options.bubblingMouseEvents = true; + this.polygon.once('pm:cut', (e) => { + if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); + // @ts-ignore + this.polygon = L.polygon(e.layer.getLatLngs(), { + ...this.polygonStyle, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: false + }); + this.polygon.addTo(this.polygonContainer); + } else { + // @ts-ignore + this.polygon.setLatLngs(e.layer.getLatLngs()); + } + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + this.polygonContainer.removeLayer(this.polygon); + // @ts-ignore + this.polygon._pmTempLayer = false; + this.polygon.addTo(this.polygonContainer); + this.updateSelectedState(); + cutButton?.setActive(false); + this.savePolygonCoordinates() + }); + const map = this.dataLayer.getMap().getMap(); + map.pm.setLang('en', { + tooltips: { + firstVertex: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.firstVertex-cut'), + continueLine: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.continueLine-cut'), + finishPoly: this.getDataLayer().getCtx().translate.instant('widgets.maps.data-layer.polygon.finishPoly-cut') + } + }, 'en'); + map.pm.enableGlobalCutMode({ + // @ts-ignore + layersToCut: [this.polygon] + }); + cutButton?.setActive(true); + map.once('pm:globalcutmodetoggled', (e) => { + if (!e.enabled) { + this.disablePolygonCutMode(cutButton); + this.enablePolygonEditMode(); + } + }); + } + + private disablePolygonCutMode(cutButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polygon.options.bubblingMouseEvents = false; + this.polygon.off('pm:cut'); + const map = this.dataLayer.getMap().getMap(); + map.pm.disableGlobalCutMode(); + cutButton?.setActive(false); + } + + private enablePolygonRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.polygonContainer.closePopup(); + this.editing = true; + this.polygon.on('pm:rotateend', (e) => { + this.savePolygonCoordinates(); + }); + this.polygon.pm.enableRotate(); + rotateButton?.setActive(true); + this.polygon.on('pm:rotatedisable', (e) => { + this.disablePolygonRotateMode(rotateButton); + this.enablePolygonEditMode(); + }); + } + + private disablePolygonRotateMode(rotateButton?: L.TB.ToolbarButton) { + this.editing = false; + this.polygon.pm.disableRotate(); + this.polygon.off('pm:rotateend'); + this.polygon.off('pm:rotatedisable'); + rotateButton?.setActive(false); + } + private savePolygonCoordinates() { let coordinates: TbPolygonCoordinates = this.polygon.getLatLngs(); if (coordinates.length === 1) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index f316975bf3..6c23323c86 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -277,10 +277,14 @@ class GroupsControl extends SidebarPaneControl { } class ToolbarButton extends L.Control { + private readonly id: string; private readonly button: JQuery; + private active = false; + private disabled = false; + constructor(options: TB.ToolbarButtonOptions) { super(options); - + this.id = options.id; this.button = $("
") .attr('class', 'tb-control-button') .attr('href', '#') @@ -295,14 +299,51 @@ class ToolbarButton extends L.Control { }); } + setActive(active: boolean): void { + if (this.active !== active) { + this.active = active; + if (this.active) { + L.DomUtil.addClass(this.button[0], 'active'); + } else { + L.DomUtil.removeClass(this.button[0], 'active'); + } + } + } + + isActive(): boolean { + return this.active; + } + + setDisabled(disabled: boolean): void { + if (this.disabled !== disabled) { + this.disabled = disabled; + if (this.disabled) { + L.DomUtil.addClass(this.button[0], 'leaflet-disabled'); + this.button[0].setAttribute('aria-disabled', 'true'); + } else { + L.DomUtil.removeClass(this.button[0], 'leaflet-disabled'); + this.button[0].setAttribute('aria-disabled', 'false'); + } + } + } + + isDisabled(): boolean { + return this.disabled; + } + addToToolbar(toolbar: BottomToolbarControl): void { this.button.appendTo(toolbar.container); } + + getId(): string { + return this.id; + } } class BottomToolbarControl extends L.Control { private readonly buttonContainer: JQuery; + private toolbarButtons: ToolbarButton[] = []; container: HTMLElement; @@ -320,10 +361,17 @@ class BottomToolbarControl extends L.Control { return this; } + getButton(id: string): ToolbarButton { + return this.toolbarButtons.find(b => b.getId() === id); + } + open(buttons: TB.ToolbarButtonOptions[]): void { + this.toolbarButtons.length = 0; + buttons.forEach(buttonOption => { const button = new ToolbarButton(buttonOption); + this.toolbarButtons.push(button); button.addToToolbar(this); }); @@ -343,9 +391,12 @@ class BottomToolbarControl extends L.Control { } close(): void { - this.buttonContainer.empty(); if (this.options.onClose) { - this.options.onClose(); + if (this.options.onClose()) { + this.buttonContainer.empty(); + } + } else { + this.buttonContainer.empty(); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index fadb85b201..2fc1baede6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -55,10 +55,14 @@ $map-element-selected-color: #307FE5; position: relative; background: transparent; &.leaflet-disabled { + pointer-events: none; color: rgba(0, 0, 0, 0.18); + > div { + background: rgba(0, 0, 0, 0.18); + } } &:not(.leaflet-disabled) { - &:hover { + &:hover, &.active { border-bottom: none; color: $tb-primary-color; // primary color &:before { @@ -115,6 +119,12 @@ $map-element-selected-color: #307FE5; &.tb-remove { mask-image: url('data:image/svg+xml,'); } + &.tb-cut { + mask-image: url('data:image/svg+xml,'); + } + &.tb-rotate { + mask-image: url('data:image/svg+xml,'); + } &.tb-close { background: #D12730; mask-image: url('data:image/svg+xml,'); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index dc36f64bd2..f391754674 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -246,7 +246,7 @@ export abstract class TbMap { mapElement: $(this.mapElement), closeTitle: this.ctx.translate.instant('action.cancel'), onClose: () => { - this.deselectItem(); + return this.deselectItem(true); } }).addTo(this.map); @@ -454,28 +454,39 @@ export abstract class TbMap { } } - public selectItem(item: TbDataLayerItem): void { + public selectItem(item: TbDataLayerItem, cancel = false): boolean { + let deselected = true; if (this.selectedDataItem) { - this.selectedDataItem.deselect(); - this.selectedDataItem = null; - this.editToolbar.close(); + deselected = this.selectedDataItem.deselect(cancel); + if (deselected) { + this.selectedDataItem = null; + this.editToolbar.close(); + } } - this.selectedDataItem = item; - if (this.selectedDataItem) { - const buttons = this.selectedDataItem.select(); - this.editToolbar.open(buttons); - this.createdControlButtonTooltip(this.editToolbar.container, 'top'); + if (deselected) { + this.selectedDataItem = item; + if (this.selectedDataItem) { + const buttons = this.selectedDataItem.select(); + this.editToolbar.open(buttons); + this.createdControlButtonTooltip(this.editToolbar.container, 'top'); + } } + this.ignoreUpdateBounds = !!this.selectedDataItem; + return deselected; } - public deselectItem(): void { - this.selectItem(null); + public deselectItem(cancel = false): boolean { + return this.selectItem(null, cancel); } public getSelectedDataItem(): TbDataLayerItem { return this.selectedDataItem; } + public getEditToolbar(): L.TB.BottomToolbarControl { + return this.editToolbar; + } + public saveItemData(datasource: TbMapDatasource, data: DataKeyValuePair[]): Observable { const attributeService = this.ctx.$injector.get(AttributeService); const attributes: AttributeData[] = []; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 94147c8b20..33d8797a2d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7716,7 +7716,8 @@ "use-cluster-marker-color-function": "Use cluster markers color function", "marker-color-function": "Marker color function" }, - "edit": "Edit marker" + "edit": "Edit marker", + "remove-marker-for": "Remove marker for '{{entityName}}'" }, "polygon": { "polygon-key": "Polygon key", @@ -7725,7 +7726,13 @@ "add-polygon": "Add polygon", "polygon-configuration": "Polygon configuration", "remove-polygon": "Remove polygon", - "edit": "Edit polygon" + "edit": "Edit polygon", + "remove-polygon-for": "Remove polygon for '{{entityName}}'", + "cut": "Cut polygon area", + "rotate": "Rotate polygon", + "firstVertex-cut": "Click to place first point", + "continueLine-cut": "Click to continue drawing", + "finishPoly-cut": "Click first marker to finish and save" }, "circle": { "circle-key": "Circle key", @@ -7734,7 +7741,8 @@ "add-circle": "Add circle", "circle-configuration": "Circle configuration", "remove-circle": "Remove circle", - "edit": "Edit circle" + "edit": "Edit circle", + "remove-circle-for": "Remove circle for '{{entityName}}'" } }, "select-entity": "Select entity", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index ab108c5c73..5ae83c1679 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -89,7 +89,8 @@ declare module 'leaflet' { constructor(options: GroupsControlOptions); } - interface ToolbarButtonOptions extends ControlOptions{ + interface ToolbarButtonOptions extends ControlOptions { + id: string; title: string; click: (e: MouseEvent, button: ToolbarButton) => void; iconClass: string; @@ -97,17 +98,21 @@ declare module 'leaflet' { class ToolbarButton extends Control{ constructor(options: ToolbarButtonOptions); - addToToolbar(toolbar: BottomToolbarControl): void; + setActive(active: boolean): void; + isActive(): boolean; + setDisabled(disabled: boolean): void; + isDisabled(): boolean; } interface BottomToolbarControlOptions extends ControlOptions { mapElement: JQuery; closeTitle: string; - onClose: () => void; + onClose: () => boolean; } class BottomToolbarControl extends Control { constructor(options: BottomToolbarControlOptions); + getButton(id: string): ToolbarButton | undefined; open(buttons: ToolbarButtonOptions[]): void; close(): void; container: HTMLElement; From f8751003b58b88f864dd52d2793f75d8826ba3b8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Tue, 28 Jan 2025 20:11:46 +0200 Subject: [PATCH 027/127] UI: Map - improve edit mode states. Add draw map items buttons. --- .../lib/maps/data-layer/map-data-layer.ts | 48 ++++++----- .../lib/maps/data-layer/markers-data-layer.ts | 6 +- .../maps/data-layer/polygons-data-layer.ts | 31 ++++--- .../widget/lib/maps/leaflet/leaflet-tb.ts | 44 ++++++++-- .../home/components/widget/lib/maps/map.scss | 77 +++++++++-------- .../home/components/widget/lib/maps/map.ts | 82 +++++++++++++++++-- .../assets/locale/locale.constant-en_US.json | 10 ++- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 7 ++ 8 files changed, 222 insertions(+), 83 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 04672173f4..4dfa17d9f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -52,7 +52,6 @@ export abstract class TbDataLayerItem; protected selected = false; - protected removed = false; protected constructor(data: FormattedData, dsData: FormattedData[], @@ -101,11 +100,6 @@ export abstract class TbDataLayerItem { - if (this.selected) { - this.dataLayer.getMap().deselectItem(); - } - }); } } @@ -163,9 +157,9 @@ export abstract class TbDataLayerItem[] = []; + public dataLayerLabelProcessor: DataLayerPatternProcessor; public dataLayerTooltipProcessor: DataLayerPatternProcessor; @@ -504,6 +499,12 @@ export abstract class TbMapDataLayer[]) { + this.unplacedItems.length = 0; const layerData = dsData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); - const rawItems = layerData.filter(d => this.isValidLayerData(d)); const toDelete = new Set(Array.from(this.layerItems.keys())); const updatedItems: TbDataLayerItem[] = []; - rawItems.forEach((data) => { - let layerItem = this.layerItems.get(data.entityId); - if (layerItem) { - layerItem.update(data, dsData); - updatedItems.push(layerItem); + layerData.forEach((data) => { + if (this.isValidLayerData(data)) { + let layerItem = this.layerItems.get(data.entityId); + if (layerItem) { + layerItem.update(data, dsData); + updatedItems.push(layerItem); + } else { + layerItem = this.createLayerItem(data, dsData); + this.layerItems.set(data.entityId, layerItem); + } + toDelete.delete(data.entityId); } else { - layerItem = this.createLayerItem(data, dsData); - this.layerItems.set(data.entityId, layerItem); + this.unplacedItems.push(data); } - toDelete.delete(data.entityId); }); toDelete.forEach((key) => { const item = this.layerItems.get(key); @@ -545,10 +550,15 @@ export abstract class TbMapDataLayer { return this.map; } + public hasUnplacedItems(): boolean { + return !!this.unplacedItems.length; + } + protected createDataLayerContainer(): L.FeatureGroup { return L.featureGroup([], {snapIgnore: !this.snappable}); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index fc635e1c7c..e3fee244cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -202,10 +202,10 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { @@ -171,19 +173,17 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { if (this.polygon instanceof L.Rectangle) { this.polygonContainer.removeLayer(this.polygon); @@ -271,6 +274,8 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { class ToolbarButton extends L.Control { private readonly id: string; - private readonly button: JQuery; + private readonly button: JQuery; private active = false; private disabled = false; @@ -331,13 +331,39 @@ class ToolbarButton extends L.Control { return this.disabled; } - addToToolbar(toolbar: BottomToolbarControl): void { - this.button.appendTo(toolbar.container); - } - getId(): string { return this.id; } + + getButtonElement(): JQuery { + return this.button; + } +} + +class ToolbarControl extends L.Control { + + private buttonContainer: JQuery; + + constructor(options: L.ControlOptions) { + super(options); + } + + toolbarButton(options: TB.ToolbarButtonOptions): ToolbarButton { + const button = new ToolbarButton(options); + button.getButtonElement().appendTo(this.buttonContainer); + return button; + } + + onAdd(map: L.Map): HTMLElement { + this.buttonContainer = $(" +
+
widgets.maps.data-layer.behavior
+
+
widgets.maps.data-layer.on-click
+ + +
+
{{ 'widgets.maps.data-layer.groups' | translate }}
Date: Fri, 31 Jan 2025 12:22:28 +0200 Subject: [PATCH 031/127] Process whole widget default config for images --- .../org/thingsboard/server/dao/resource/BaseImageService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 172b91a32c..a376c5785e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -385,6 +385,7 @@ public class BaseImageService extends BaseResourceService implements ImageServic JsonNode defaultConfig = widgetTypeDetails.getDefaultConfig(); if (defaultConfig != null) { updated |= convertToImageUrlsByMapping(tenantId, WIDGET_TYPE_BASE64_MAPPING, Collections.singletonMap("prefix", prefix), defaultConfig, imagesLinks); + updated |= convertToImageUrls(tenantId, prefix, defaultConfig, imagesLinks); widgetTypeDetails.setDefaultConfig(defaultConfig); } } From 66608d3e2b004339340ef47bbeca0d82c459c348 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 31 Jan 2025 15:03:15 +0200 Subject: [PATCH 032/127] UI: Update map widgets --- .../json/system/widget_types/image_map.json | 46 +------------------ .../data/json/system/widget_types/map.json | 46 +------------------ 2 files changed, 2 insertions(+), 90 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index 27af2bc724..b7e8644662 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -17,7 +17,7 @@ "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"layers\":[],\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"minZoomLevel\":16,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\",\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null}},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"layers\":[],\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null},\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1,\"patternFunction\":null},\"click\":{\"type\":\"doNothing\"},\"groups\":null,\"edit\":{\"enabledActions\":[],\"snappable\":false},\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"icon\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"size\":40,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var colors = ['#488bc7','#549c5d','#ed7546','#be2b29'];\\nvar temperature = data.temperature;\\nvar res = colors[0];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res = colors[index];\\n}\\nreturn res;\"},\"icon\":\"thermostat\"},\"markerImage\":{\"type\":\"function\",\"image\":\"/assets/markers/shape1.svg\",\"imageSize\":34,\"imageFunction\":\" \",\"images\":[]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" }, "resources": [ { @@ -41,50 +41,6 @@ "mediaType": "image/svg+xml", "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTEzNC41IiBoZWlnaHQ9Ijc2Mi43OCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI3LjA3MSAtMzA3LjkpIj4KICA8ZyBmaWxsPSJub25lIj4KICAgPHBhdGggZD0ibTkwNi4wMyA3MDYuMTMgMy40MjkyIDE3Ljc5Nm0tODgwLjg5IDQxLjEyMWMxNTAuNDQgNi44MzM0IDE0Ni4zOS0yNi4zMzQgMTY2LjQzLTI5LjMyIDM2LjE0NC01LjM4NDggMTE0LjI5LTYuNTI1NCAxNDguMzMtOC42MjM1IDQzLjM3OC0yLjY3MzggMTQxLjc2LTExLjIzMSAxODguODYtMTkuODM0IDM5LjgxMS03LjI3MjggMjIxLjM3LTAuODYyMzUgMzE5LjA3LTAuODYyMzUgNzAuODI3IDAgMTQ2LjkyLTEuNzI0NyAyMTguMTgtMS43MjQ3LTMxLjYyIDAgMTE3Ljg2LTIuNTg3MSA4Ni4yMzYtMi41ODcxbS0yNS4wOTEtNjguMTI2Yy01Mi44IDM0Ljc4NS02NS44OTUgNTEuNzQ5LTk1LjYzOSA4MS40OTMtMjQuOTMxIDI0LjkzMS0xNDAuNC0xOS4xMzktMTc4Ljk0IDM2LjY1LTEyLjI4MSAxNy43NzctNDcuMDAzIDQ2LjU0Ny02NS4xMDggNTkuMDcxLTIwLjEwNSAxMy45MDgtNTYuMDM3IDQ0Ljk1Ny02Ny43NjkgNzMuMDc4LTQuODAxNSAxMS41MDktMTMuMzggMzUuOTkzLTIzLjQ0OSA0Ni4wNjItMTAuNDk3IDEwLjQ5Ny0zOC4zNzcgNi4zODU3LTQ0LjAyMyAxNy42NDgtMTkuMDA1IDM3LjkwOC0yNS40NjUgMTAwLjkyLTY3LjYxOCAxMDIuMDVtMTkuMjgyLTYyNC4wMWMzNC42NTktMS44NzM4IDg0LjAyNyA3LjM5MTMgMTA5LjktNC4yODU0IDEzLjI4Mi01Ljk5NDEgNDEuNDA3LTIuNDYxNCA2Ni44MjktMi4zMjA1IDM1LjMyMiAwLjE5NTc4IDY0LjM4MiAwLjYzNDc3IDEwMS45MiA1LjAyMzIgMjUuMDMgMi45MjY1IDQ0LjY2MyAzNC4yODcgNTguNTI3IDUwLjY0NCAxNy4wOTkgMjAuMTczIDYyLjc2NC0xLjcxNDcgNjYuMzA2IDMyLjEzNCA1LjEwMjcgNDguNzY2LTYuMzI4NCA3OC42MzcgNi4xNDExIDk3LjM0MiAxOS45NjkgMjkuOTU0IDUwLjQ4NiAxNy44NTYgNDQuNjE5IDgzLjk3MW0tNDcyLjQ1LTM3OC43OWM0LjY0MzUgMjMuNzI5IDE1LjA2OSA3Mi43NzYgMTkuMDYxIDEzMC42NCAwLjg3MjA2IDEyLjY0IDUuNDQ3MiAyNC45OTMgNC4yMjIzIDQ1LjI3OC0yLjUxNzIgNDEuNjg4LTE1LjcxNyA0My42NzctMTUuMDkxIDYwLjM2NSAxLjQzMiAzOC4xODIgMzAuNjE0IDkzLjgzNyAzMC42MTQgMTM5LjcgMCAyNC4xODEtMi42Njk2IDExNS4zOSA3LjMzIDEzNS4zOSAwLjE1OTExIDAuMzE4MjEgMTAuMDY1IDM1Ljg4MyAxMC43NzkgNDkuMTU0IDAuOTQzNzggMTcuNTI1LTI0LjQ3OCAzOS40Ny0yOC4wMjcgNDYuNTY3LTUuNDc3NyAxMC45NTUtMzYuOTczIDEwLjg4Mi00MC4xIDI0LjE0Ni0zLjg2ODggMTYuNDE1LTMuODY2MyA0My43OTcgNC4wNDY1IDU5LjQ0MW05Ny4zMzctNjkxLjAxYy01LjAxMzMgMzUuNTE2LTQzLjY1OSAxMS4zMTctNTguNTM5IDIzLjc4MS0yMS4zMyAxNy44NjktNjIuNSAzMS40MzItNzAuMTI0IDM1LjM2Ny0zNS4wODggMTguMTA4LTExMC40Ny0xNS4xNDItMTI1LjYxIDQuMjY4NC0xNS45NTEgMjAuNDQ3LTAuMDczNSA2MS40NjYtOS4xNDY3IDg0LjE0OS02LjAzNTcgMTUuMDg5LTE4Ljg3NyAyMy4wMTctMjcuNDQgMzIuOTI4LTE5Ljc0OCAyMi44NTYtNjkuOTc0IDY5LjgyNC04NC43NTkgMTAwLTcuNDk3NCAxNS4zMDQtMy4yODQzIDQ0LjQyLTMuNDcwNSA2My4zNDMtMC4xMjc5MyAxMi45OTQtMC44MTAxNSAyMy4xMDQgMi40MDM0IDI4LjI3NiA0Ljk2MTYgNy45ODU4IDIzLjcyIDI4LjExMiAyNC4yMzkgNTAuNjExIDAuMjk0MTEgMTIuNzcxIDAuMDEzMyA3OC41OTEgMy4wNDg5IDg3LjY1NSAyLjMxMjYgNi45MDU1IDQuMjIgMjYuNTY1IDEwLjIxNCAzNi41ODcgMTEuMzU0IDE4Ljk4NCA0LjM4NzQgNDAuMTU3IDI3Ljg5NyA1My41MDggMTkuMDUgMTAuODE5IDQ2Ljg3OCAxMi4yMTkgODEuOTI2IDE0LjQ2MSAzMy43MDMgMi4xNTU5IDYxLjUxMi0xLjQzMDQgNzYuOTIxIDYuMTQxMSAxMS41ODUgNS42OTI3IDguNTgxNSAxNy45MzMgMTQuMjk1IDI5LjM2MSA1LjY0MDQgMTEuMjgxIDMxLjUwMyAxMS4xNTYgNDEuODA0IDQzLjQ1NSA3LjYwNTkgMjMuODQ3IDMuMDg1OSA0NC4xNTcgNi43MDc2IDY1Ljg4NyIgc3Ryb2tlPSIjMzY0ZTU5IiBzdHJva2Utd2lkdGg9IjMiLz4KICAgPHBhdGggZD0ibTQzLjI3OCA1MTcuOTVzMjMwLjg1LTMuNjM4IDI1MC4wMS0zLjY1ODdjNy40ODIyLThlLTMgOC42MTk1IDUuMTUxOSAxNC4wMjEgMTEuNDU5IDI0LjU5NiAyOC43MTkgOTMuOTEgMTEyLjk0IDkzLjkxIDExMi45NCIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogICA8cGF0aCBkPSJtMzUuOTYxIDU3Ny43czE2NS41Mi0xLjY4NDUgMjQ4Ljc4LTEuNjg0NWM0Ljk0NzUgMCA3LjcyOTktMi44ODMzIDEwLjUzOC01LjcyOTggOS42NjExLTkuNzk0MiAyNS42MzItMjguNTkgMjUuNjMyLTI4LjU5IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPC9nPgogIDxwYXRoIGQ9Im0zOC40IDY0MS43MyAzOTMuMzEtNC4yNjg0IiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0iIzMzNiIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxwYXRoIGQ9Im0zOS4wMDkgNzA0LjU0IDQ4NC4xNi02LjcwNzYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSIjMzM2IiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2Ij4KICAgPGcgc3Ryb2tlLXdpZHRoPSIxcHgiPgogICAgPHBhdGggZD0ibTMwMy45NiA2ODIuNTkgMTQ2LjggMS44MjkzYzEwLjUzNCAwLjEzMTI3IDE0LjM0NC0yLjYzNzQgMjUuNDg3LTYuMzcyOCAxMC40MTItMy40OTAzIDMxLjQyNC0yLjY5OSA0MS4zODUtMi43NzM4bDQwNS41Ni0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDI2LjIyIDMxNC44OWMyLjA2NzUgOS4wNTI3IDEuODQxOCA1MS43MjggNi41MDc5IDc0LjgzNSAxLjY3NDggOC4yOTM0IDguNjc1MSAxNC4wNjYgMTAuMDU1IDE0Ljg1OSA0LjkwMTUgMi44MTQ2IDEwLjgxNSA4LjE0OTggMTMuMDQ2IDE2LjA4OCA2Ljc1NzggMjQuMDQ2IDAuODc5NzIgNjguNDUyIDAuODc5NzIgMTEwLjY5IDAgNi4wOTc4IDEuNjYwMSAzMC4xNDctMi4xNTU5IDMzLjk2My0yLjU0MDggMi41NDA4LTAuMjgxNjMgMTIuOTkxLTMuNDM2OCAxNi4xNDRsLTkuODQ5NCA5Ljg0MzFjLTEwLjM2NyAxMC4zNi0xMS41OSA2LjUyNjEtMTcuNzM4IDE4LjgyMy0zLjU2NzcgNy4xMzU0IDUuNDAyNCAyMC42NzIgNy4zNTQzIDI0LjU3NiAxLjkzMjEgMy44NjQzLTEuODQyMiA0Ljc3NzctMS43OTI0IDcuNDQ2MyAwLjI1Mjg2IDEzLjU0NSAyLjI5NzUgMzczLjkzIDIuMjk3NSAzNzMuOTMiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzY1LjI0IDUxOS43OCA0LjExNiA1MDIuMTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMTE2LjUzIDUwNC4xOSAzLjg4MDYgMzEwLjk2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTMxNy42OCA1NzYuNDkgMTMwLjE5IDEuNTI0NGM0LjUxMDggMy4yNDE3IDIwLjM0NSA3Ljk2ODUgMjcuNzQ1IDQuMjY4NCAzLjE1NTUtMS41Nzc3IDkuNDE5LTUuMzg4MiAxNC4wMjUtMy45NjM2IDQuMjY3IDEuMzE5OCA2LjAxNjkgMy4xMTYzIDEwLjM2NiAzLjA0ODkgMTAuMzA0LTAuMTU5NzUgMjAuMjEyIDAuMzg3NDEgMzAuNDg5IDAuMzA0ODkgMTc3Ljg5LTEuNDI4MyAzNTYuNTktMi4xMzI1IDUzNC43Ny0zLjA0ODkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDc1LjMxIDU4Mi44OWMtMy40NDQyIDExLjM1MS0yLjEwMzQgMTIuNDM0IDMuNjU4NiAyMS4wMzcgMy43OTQ0IDUuNjY1NiA1MC44NjMgMTMuMDM4IDQxLjQ2NSAyNy4xMzUtMTAuNTM3IDE1LjgwNS0yMi44OTctNS40Nzc3LTMzLjg0My0xLjgyOTMtNS40NTI0IDEuODE3NC03LjM0OSA1LjQ1NjMtMy42NTg3IDkuMTQ2NiAyLjgwNjggMi44MDY4IDQuMDQ4IDEuODA0IDYuNTIwMyA1LjEwMDQiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjAxIDYzNi44NWM4LjMxOSAxMy4xMSAxOC44NDYgMTQuNjM1IDM1LjY3MiAxNC42MzUgMi45Mzg2IDAgNy44Ny0wLjkzMzcxIDEwLjY3MSAwIDExLjM1OSAzLjc4NjQgMjcuMTk0IDEwLjI3NiAzNi4yMDIgMjEuMTI5IDguMjggOS45NzY2IDEwLjI1MyAyMy44ODMgNy43MDIgMzcuMTA0LTYuMTY5OSAzMS45OC0xNi43MTQgNTYuOTg5LTE5LjA0NCA4Ni41NjktMS4zNDggMTcuMTE5IDQuNTA5NiAyMi41MzUgMTEuMDcxIDMzLjkyOSAxMC42NyAxOC41MjcgOC43MjQ1IDE0LjIgOC41NzE0IDM0LjI4Ni0wLjEzOTYzIDE4LjMxOSAwIDYwLjI2NCAwIDgwLjcxNCIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNTEgNjU4Ljk2Yy0xMC42ODEgMC45MDQ1NC03LjEwOC01LjYwMjYtMTAuODI0LTguMDc5Ni00Ljc4NDUtMy4xODk3LTEyLjIyNy0xLjI1MS0xNi43NjktNS43OTI5LTAuNjY2MTItMC42NjYxMi04LjgwOTctNC4xMDg4LTEwLjE3NC0yLjc0NC04LjM2NDYgOC4zNjQ2LTMuMDQ4OSAyMC41NTItMy4wNDg5IDMzLjUzOGwzLjAyMiAzMzkuNyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MTcuOTkgNjUxLjAzYy0wLjIyMTcxLTIuNzAxOCAxLjkwMzUtNS41NjIxIDMuMzUzOC03LjAxMjQgMS43OTk0LTEuNzk5NCA2LjkyMjkgMS4wMDQyIDguODQxOC0wLjkxNDY2IDAuMjg3NjUtMC4yODc2NiAwLjg0MzI5LTExLjE2NCAwLjIyODY2LTEzLjU2OC0yLjA2NDgtOC4wNzQyLTIuMDU4LTI4LjY1Ny0yLjA1OC0zOC43MjF2LTczLjE3MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MjguNjYgNjc1LjQyLTAuNDU3MzMtMzEuNTU2IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc2Ni4zMiA1NzkuNjQgMC40MzExOCAxMy43OThjMy4xMzY0IDQuNjY5MiAzLjAxODIgOS42MDA3IDMuMDE4MiAxNi4zODV2MTU3LjM4IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTExMjIuOSA3NjUuOTFjLTIwMi4zMSA0LjY5MDUtNDAzLjc0LTEuMTEzOC02MDUuOTUgMy4zNTM5LTEwLjg2NCAwLjI0MDAyLTMuMzYxNS04LjU4NjMtMjguNTM3LTguNTg2MyIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im04NjAuMDEgNzM3LjA3cy05Ny40NDggMC44NTgwNi0xNDcuNTcgMC44NTgwNmMtNS4yNjg2IDAtNC41MTU1LTguMzI5OS03LjMwMDktOC4zMjk5LTMuOTc0NCAwLTguNjI5MiAwLjAyMDEtMTAuNTA5IDAuMDM1OS0yLjMzNDggMC4wMTk3LTEuODEwOSA4LjM2Ni00LjE0NTggOC4zNjY5LTQ2LjE2OSAwLjAxODgtMTY3LjQxLTEuMzA4LTE3NS4wNS0xLjMwOC00LjQyOTYgMC04LjU3NjMtNi40Mzk3LTEzLjEzMi02LjQzOTdoLTE0LjM5NSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im02NzUuMDEgODMxLjE3LTAuNjA5NzgtNTIxLjc3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTc5OS40IDMxMy4wNiAxLjIxOTYgNDk1Ljg3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTczNi41OSAzMTIuNDUtMS4yMTk2IDcxNi40OSIgY29sb3I9IiMwMDAwMDAiLz4KICAgIDxwYXRoIGQ9Im01MzAuMDMgNjQzLjQ2IDM5Mi4zNy0zLjAxODIiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtODU5LjQ1IDMxNC45IDEuMjkzNSA1MDcuOTgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICAgPHBhdGggZD0ibTkyMS41NCAzMTAuNTkgMS43MjQ3IDUzMS43NSIgY29sb3I9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgIDxnIHN0cm9rZS13aWR0aD0iMXB4Ij4KICAgIDxwYXRoIGQ9Im03MzYuMjkgNDUzLjMxIDE4NS42OC0wLjMwNDg5IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTEwNjAuOCA1MTQuOTdzLTM2My4yOC01LjYyNjItNTQ0LjY1IDIuNTIxOGMtNC4xNzc4IDAuMTg3NjktMTIuNSAxLjA2NzEtMTIuNSAxLjA2NzEtMS41NzEgMC4xMzQxLTIuMDAwOS0yLjMyNS0yLjU5MTYtMy41MDYyLTAuMDk2Ny0wLjE5MzQzLTcuMDYwOC0xLjkzMzQtNy42MjIyLTEuMzcyLTIuODkzMSAyLjg5MzEtNy42MzE3IDQuMjQ4Ny0xMi4xOTYgNC4xMTZsLTExMi4wNS0zLjI1NzgiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtMzk5LjgyIDQ3OS42MSAxMS42NDIgNS42MDUzYzIuOTg0MSAxLjQzNjggNi41Mjg4LTAuNDc3MTIgOS45MTcxLTAuNDMxMThsMTI3LjIgMS43MjQ3IiBjb2xvcj0iIzAwMDAwMCIvPgogICAgPHBhdGggZD0ibTUxOS4yNSA1MTcuMTItMC40MzExOS0yMDguNjkiIGNvbG9yPSIjMDAwMDAwIi8+CiAgICA8cGF0aCBkPSJtNDMyLjkzIDM4OS43MWMxMS4wNDUgMCAzNS41MzMgMC42MTkyNyA0Mi41OC0xLjAwNCA4LjQwNTItMS45MzYyIDcuMDY2LTYuOTUzOCAxNC4xOTctNi45NTM4IDcuODA5NSAwIDYuNTQyOSA4LjA2MjQgMjAuMTQyIDguMDYyNCAxMy45OTEgMCA0NC45NzcgMC4zNzg4NiA2My45NCAwLjM3ODg2IDEyLjA4NCAwIDgyLjAwMyAwLjMwNDg5IDkzLjYwMSAwLjMwNDg5IDguNzYwNSAwIDEzLjE2LTIuMjg4MyAyMS4zNDItNy4wMTI0IDcuMTk1Mi00LjE1NDEgMi4wNTQ2LTkuNDkxNCAyMC40MjgtOC44NDE4IDIzLjE0NSAwLjgxODMzIDEyLjY0MyAxNC4wMjUgMzIuMzE4IDE0LjAyNWgxNTAuOTJjMTQuMzMyIDAtNC4xMTkxLTEzLjExIDI5LjI2OS0xMy40MTUiIGNvbG9yPSIjMDAwMDAwIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4Ij4KICAgPHRleHQgeD0iNTg4LjY3OTU3IiB5PSI3MzUuODA0NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU4OC42Nzk1NyIgeT0iNzM1LjgwNDYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TGluY29sbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI2ODYuMzk4NSIgeT0iNzY1LjYyODQyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+SGFycnk8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3MDkuODcxODMiIHk9Ii04MDIuMzc3MzgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjcwOS44NzE4MyIgeT0iLTgwMi4zNzczOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldvb2RsYXduPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTYyLjExOTI2IiB5PSItNzcxLjk2ODE0IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjIuMTE5MjYiIHk9Ii03NzEuOTY4MTQiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5FZGdlbW9vcjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5OC4zMDQ4NyIgeT0iLTczOC4zNjY0NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk4LjMwNDg3IiB5PSItNzM4LjM2NjQ2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+T2xpdmVyPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNTkyLjEyMjg2IiB5PSItNjc3LjIwMzk4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1OTIuMTIyODYiIHk9Ii02NzcuMjAzOTgiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5IaWxsc2lkZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU5Ny4zMjcwOSIgeT0iLTg2Mi42MTQwNyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTk3LjMyNzA5IiB5PSItODYyLjYxNDA3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Um9jazwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjU4Ny4zNzAxOCIgeT0iLTkyNi4xMzY2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1ODcuMzcwMTgiIHk9Ii05MjYuMTM2NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlYmI8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODcxLjE2MTAxIiB5PSI2MzcuNTc1MiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkNlbnRyYWw8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iODczLjgzMjI4IiB5PSI1NzcuMDMyNDciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijg3My44MzIyOCIgeT0iNTc3LjAzMjQ3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MTN0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI4NzUuOTY2NDkiIHk9IjUxMC4yNjE4MSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iODc1Ljk2NjQ5IiB5PSI1MTAuMjYxODEiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yMXN0PC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9Ijg4MS4zMTY1OSIgeT0iNDUwLjE5ODc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI4ODEuMzE2NTkiIHk9IjQ1MC4xOTg3NiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjI5dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNjE1Ljc5MjQ4IiB5PSIzODcuNzQ3MTYiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjYxNS43OTI0OCIgeT0iMzg3Ljc0NzE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Mzd0aDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI0ODQuNjkwMzciIHk9IjQ4MS42NTI4NiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDg0LjY5MDM3IiB5PSI0ODEuNjUyODYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij4yNXRoPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU2My4wNDY3NSIgeT0iNTEzLjM2MTMzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI1NjMuMDQ2NzUiIHk9IjUxMy4zNjEzMyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjIxc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTY1Ljk3MTUiIHk9IjU3Ny44OTQ4NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI0MzMuNTgwNzUiIHk9Ii00NjAuNzMzMTIiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjQzMy41ODA3NSIgeT0iLTQ2MC43MzMxMiIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPkFtaWRvbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjQwNS41MzA5OCIgeT0iLTUyMy41NDAxNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNDA1LjUzMDk4IiB5PSItNTIzLjU0MDE2IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QXJrYW5zYXM8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI3NDUuNDg0NjIiIHk9Ii0zNzIuNTg1OTQiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc0NS40ODQ2MiIgeT0iLTM3Mi41ODU5NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldlc3Q8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTYuNzI4MzMiIHk9Ii01MzEuMjU5MjgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5Ni43MjgzMyIgeT0iLTUzMS4yNTkyOCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPldhY288L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1OTUuNDM0ODEiIHk9Ii0xMjIuNTAyOTUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU5NS40MzQ4MSIgeT0iLTEyMi41MDI5NSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hemllPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDQ1KSIgeD0iNjk1Ljc3Mjk1IiB5PSIxNjIuMDY4NzciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY5NS43NzI5NSIgeT0iMTYyLjA2ODc3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+Wm9vPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjI0MC41ODk5NyIgeT0iNTc0LjQ0NTQzIiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIyNDAuNTg5OTciIHk9IjU3NC40NDU0MyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjEzdGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjA2LjAzMTc1IiB5PSI1MTEuNjM2NjMiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIwNi4wMzE3NSIgeT0iNTExLjYzNjYzIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+MjFzdDwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9IjYyMC40NDMxMiIgeT0iLTUwNi42ODIxOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNjIwLjQ0MzEyIiB5PSItNTA2LjY4MjE5IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TmltczwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzNzAuMjE2ODYiIHk9IjY5OC44NDAwOSIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iMzcwLjIxNjg2IiB5PSI2OTguODQwMDkiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NYXBsZTwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzODQuMDg0MiIgeT0iNjgwLjg1MTM4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+RG91Z2xhczwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCBkPSJtMzY3LjkxIDEwMTBoMjYzLjAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDxnIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCI+CiAgIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzM2LjI2NzQ2IiB5PSItNDMzLjEzNzc2IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI3MzYuMjY3NDYiIHk9Ii00MzMuMTM3NzYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5NZXJpZGlhbjwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB4PSI1NzIuODMyMTUiIHk9IjY0MC4yMDUyNiIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTcyLjgzMjE1IiB5PSI2NDAuMjA1MjYiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5DZW50cmFsPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjU3NS4wODk2NiIgeT0iNjcwLjkwMzUiIGZvbnQtc2l6ZT0iOS42NTg0cHgiIHN0eWxlPSJsaW5lLWhlaWdodDoxLjI1Ij5Eb3VnbGFzPC90c3Bhbj48L3RleHQ+CiAgIDx0ZXh0IHg9IjQ5OS40ODk2MiIgeT0iMTAwOC42MDY5IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSI0OTkuNDg5NjIiIHk9IjEwMDguNjA2OSIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPjQ3dGg8L3RzcGFuPjwvdGV4dD4KICAgPHRleHQgeD0iMjE2LjY0NTQzIiB5PSI3MjUuOTgyOTciIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjIxNi42NDU0MyIgeT0iNzI1Ljk4Mjk3IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+S2VsbG9nZzwvdHNwYW4+PC90ZXh0PgogICA8dGV4dCB0cmFuc2Zvcm09InJvdGF0ZSg5MCkiIHg9Ijc3NC44NzU2MSIgeT0iLTUwOC4xODk3MyIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNzc0Ljg3NTYxIiB5PSItNTA4LjE4OTczIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+TWNDbGVhbjwvdHNwYW4+PC90ZXh0PgogIDwvZz4KICA8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwIDI4Ny4zNikiIGQ9Im0zNjQuMTYgNjU4LjQzIDI5OS41MS0xLjAxMDJjNi40OTg3LTAuMDIxOSA2Ljk3NzIgOS4yNTQxIDE2LjU5NiA5LjM5MjUgMTIuMDU0IDAuMTczMzkgMjkuMTExLTAuNTM1NzIgNTQuMTE0LTAuMzAxMSIgY29sb3I9IiMwMDAwMDAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzMzNiIgc3Ryb2tlLXdpZHRoPSIxcHgiLz4KICA8dGV4dCB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZmlsbD0iIzAwMDAwMCIgZm9udC1mYW1pbHk9IlZlcmRhbmEiIGxldHRlci1zcGFjaW5nPSIwcHgiIHdvcmQtc3BhY2luZz0iMHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MCUiIHhtbDpzcGFjZT0icHJlc2VydmUiPjx0c3BhbiB4PSIzNzMuOTkzMDQiIHk9Ijk0NC4zNTc1NCIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPk1hY0FydGh1cjwvdHNwYW4+PC90ZXh0PgogIDx0ZXh0IHRyYW5zZm9ybT0icm90YXRlKDkwKSIgeD0iNzgwLjg0NjA3IiB5PSItNDkwLjI0NTk3IiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9Ijc4MC44NDYwNyIgeT0iLTQ5MC4yNDU5NyIgZm9udC1zaXplPSI5LjY1ODRweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjEuMjUiPlNlbmVjYTwvdHNwYW4+PC90ZXh0PgogIDxwYXRoIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgMjg3LjM2KSIgZD0ibTM2Ny43IDUzNy4yMSAxNDEuMjgtMS4wMTAyYzYuNDktMC4wNDY0IDEyLjc4MSA3LjIzNTQgMTkuMTkzIDcuMzIzNiA1NS45MjQgMC43Njg5IDE1OC42OS0wLjE3MzMzIDIzNi41MS0xLjAxMDIgNy44Mzk2LTAuMDg0MyAyMi42MzEtMTkuODU0IDMwLjMwNS0yMC40NTYgMjIuMjY2LTEuMzUxOCA0NS4xNzktMC41MDUwNyA2Ny42OC0wLjUwNTA3IDE2LjE0Ny0wLjYzMjQxIDMuNjEwMiAyMC43MDggMjYuNzY5IDIwLjcwOGwyNDMuNDUtMS4wMTAyIiBjb2xvcj0iIzAwMDAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzM2IiBzdHJva2Utd2lkdGg9IjFweCIvPgogIDx0ZXh0IHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmaWxsPSIjMDAwMDAwIiBmb250LWZhbWlseT0iVmVyZGFuYSIgbGV0dGVyLXNwYWNpbmc9IjBweCIgd29yZC1zcGFjaW5nPSIwcHgiIHN0eWxlPSJsaW5lLWhlaWdodDowJSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PHRzcGFuIHg9IjY4NS4yMDgxMyIgeT0iODI3LjUzMDgyIiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+UGF3bmVlPC90c3Bhbj48L3RleHQ+CiAgPHBhdGggdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAyODcuMzYpIiBkPSJtNTU0LjI5IDcyMS40My00LjI4NTctMTc4LjIxLTIuODU3MS00NDAuNzEtMC4zNTcxNC03OS4yODYiIGNvbG9yPSIjMDAwMDAwIiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzYiIHN0cm9rZS13aWR0aD0iMXB4Ii8+CiAgPHRleHQgdHJhbnNmb3JtPSJyb3RhdGUoOTApIiB4PSI1MjkuNjI1MzEiIHk9Ii01NTAuODQ3NzgiIGZpbGw9IiMwMDAwMDAiIGZvbnQtZmFtaWx5PSJWZXJkYW5hIiBsZXR0ZXItc3BhY2luZz0iMHB4IiB3b3JkLXNwYWNpbmc9IjBweCIgc3R5bGU9ImxpbmUtaGVpZ2h0OjAlIiB4bWw6c3BhY2U9InByZXNlcnZlIj48dHNwYW4geD0iNTI5LjYyNTMxIiB5PSItNTUwLjg0Nzc4IiBmb250LXNpemU9IjkuNjU4NHB4IiBzdHlsZT0ibGluZS1oZWlnaHQ6MS4yNSI+QnJvYWR3YXk8L3RzcGFuPjwvdGV4dD4KIDwvZz4KPC9zdmc+Cg==", "public": true - }, - { - "link": "/api/images/system/map_marker_image_0.png", - "title": "Map marker image 0", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_0.png", - "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", - "mediaType": "image/png", - "data": "iVBORw0KGgoAAAANSUhEUgAAAB4AAAB/CAYAAAD4mHJdAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAACWAAAAlgB7MGOJQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAwgSURBVGiB7Zt5cBT3lce/v18fc89oRoPEIRBCHIUxp2ywCAgIxLExvoidZIFNxXE2VXHirIO3aqtSseM43qpNeZfYKecox3bhpJykYgdjDkU2mBAB5vCamMNYAgQyURBCoxnNPd39O/aP7hGSEUR24L/uqqf+zfR77/Pe69/Rv6kWwcgPLRIJfZUAa7xez2xd90QBwDSNZKlkHJHAK+l09mUA7BP4vPpRUVExMVoRef+L998njxx9X57vPi/PnTsnO850yPaT7XLXrrflqjtWymhF+HA0Gp0wEp/kHymEQqG4ptJDGzf+um5RUxMSiV7Z3Lyt88L5nozgHJWj4pGmpqZav99PWve04onHHuswmViQzWb7ruZX+Udgv8/z3A+f/NGye1evxssvb+wo5PMfTZs6bfqcuXNHL7hlweh58+ZVAOTUpk2b0p9dvjyqqmrs/b8ejpUMc+unzjgUCsXjsYruE+2n1JY/NedM0zCi0VjA7/d7/f4AAgE//H4/vF4fOjvP9h5695C/oaEhcN/q1SyTzVdnMpnklXzTq4EplUsXfmaRCgC7du3cOn78+KfGj59Add3z1Md1vV7vqPa2D1sA4MYbZ6qUiqVX9X21i4TQcfX19QCA6urquN/vn0kAPRQKpYbTnzRpUhgAampqAEFrPjVYSql7fD4AgK5r2tV0AcDj8WkAoOk6JJGeTw2+nocLdsEu2AW7YBfsgl2wC3bBLtgFu2AX7IJdsAt2wS7YBbtgF+yCXbALdsEu2AW7YBfsgl2wC76mh/ppjIQgXVloPxVSBRV0rBe455P6+kTKBYF3tonxY/IWarry7DvI298Tgp0PR9RzACaN1NeIS100+EdvKXW3cMZvF8wCK10Sq2it2NAzakmukP/wmoP/KuId3BRUMg5uCfCSNVSKVn1rNto7Un8jLrUVqJ4Fi2eEQiEYBzOsy3SYL37TNQdzi8Q5FxkqJIQBsNLlYMGF/zqAJWBxSEogDAY+DJibYqTuRg4WFgO3OKhCYTExbKk5G/mbkSPP2DQhLA5IO/NhSz1MMP882BDgnAFQwdiVSs2vPVhYDIJLUMkBgw1favM6lJoZDDAYhKbAYsOX+rqAhcXAuQSIAKzhSy2vS8YmB7NYH4WCfM7kw5VaWtdpOO3bfWZJZVXgPxMX898bVsm6RhkTIseX29yyIErm/J5z5vwr6pvmsLYjBgeDwSpVJS/OmT1n1de+9qANZgLc4q9Dyj2qQhUhSSUAUCL7GBcchCymTEYBYNWqVXj30MGHT586PZEJ+WAul7ts8bjspd9QKDRNU2nz4z94YtI3H3oI+XwB//3j/9m77eRUUJ9/0eh4APGoDz6vCi4ksgUTmYyBC4k8RLGwtzF+EGu+tHqRqqrYtm0rXnzhhQ7G5cpsNnvyiuBIJFKnqvSd55772eilS5fhwIH9ye+/dPaEf1T9otW3T8GtiyYgGNBBymYEgLSbvakidu8/h01vnkYhcab1gcVs5tx5c6PHjh7DU0/9qFsINPb3939UZg28X11dXR0Qwtr9g8efqGtc+Bn89re/O7FhR9BXNaFm+n98uxHTZ1SDKQqKAihweZlITUVtXQwNs8fg+Bmzdk+bnmPdf/7bwsbGeO2ECaED+9/5XCxWuTGbzVpDwJpGNtx+28o77rr7bmzZsu3k7z+cMlHzeiPrvnoTwtVhFAVQHAZY4HBEoiAAeDXUjI/gyJGeQEd6TFj2tHYuXNgYy2azVe0fngiWDLNloHNFo4FZkXDsoTVr1+KD4x8U/3Ci1qP5PV7N74FeFUbClKDEriy57A5JANL5a68hnqoINL8OAPqbXbNp7clTxTVr1/oOHjr0MFXxq2Qy9wEFACnoY//6la9QAHj+9Q/eUL2RWkVXoWgqkhZBypRImkDKBFIWkLIk+h1JWdL+zrmeNCWSDFB0DYquQvWG637TcnozAKxbt45yTr8PAGowGBwVDAbvmT9/Pvbu3dddijV9WdUUUE0BUQm6kwaCYe+ljK/w8ruUdsYCBLlMEUQhoJoCygWM+LIvHTx4sGfevIbqYMD3BSFkJVUUrG5oaFABoPXwhd1UVUBVahtpKtoOnEV/gSHHgBwDso5c6XO6yNF24CNQTbV9qBRUUenuwz1/BoCZM2dplOJeSggWL1myFEII9IeXziIKBVUUW1QKo2Ci41Anei9kkWcY6Ex5R8qfc0wi0ZPF6QNnYeQNB2j7IQpFOtg0WwiBxoWNIBKLVQI6Z8rUqTh69FiWaFNmEIWgLFShoM5TZbIzgVxvFp6ID5rfA6JQgBAIxsGLJkrpAsycAcH4gN1gX0QPTW9vP5Grr58cJJTOpbqmjgWAnp6ei4QSEEJAKAGh1BbHCS2DLAFmMAgmICwObjDnyYMMAtJL9oN89vRc7KWUQtOUsSqhSggA8sWivSEh9qBxTiCEAGRwQARUVaB67Hf5pZAQlA0Ayrq2LTCogVyhlLURNEw55yYABP2+4ED3vHSClBKQ9jiFdHqvEBCMQzAOKYSt6/RqSGnbDPJRbgT93hAAcM4NyhjrBYDKylhswEEZJgYJFxDchnGTwSqasIomuMnsIDiH5GKIzUAQTsCVlZUxB9xLIUVbKpVEff3kiLTMfimEA7HP5bZgHMJ07mnJAiuaYEXT3jcZDMLkTgBD7exgBKRp9NfVTQwnk0kIKduoJGRH8/ZmhMNh4skc3DnEkDlAi4GbtjDDguVAmZM1M6yB68JyKsCGBqD373s7GAySnTt3gBDyFhWCvPHee/8HAJhTU5g0BMg4uMXBTT4AZSUTrGjBKpiwCnablQbDbZuyfTmAuRPMegA4euQopCRbaCaTOd2XSLzX3d2Nu+64bR7PnP3LJSCDMBm4YW9FWcmyQYMytsW+Zpfdsm1MdimAdMc7K29bMedCdzeSyeS76XT6jLNI4PGf/+w5aLqOu25IjOOWKcSg0jJjcLZ2ecsZD5TdybqsOxC0ZYpbJ58frek6nn/+eVBJHgecjXkqk2nu7Ozcdfz4cdx556rJN5C3m8v3jBt2xpdnazjysawNy5lUbKkrbmtZsWL5pGNHj6Or62+7k5lMy5CFNRQKTfN6tAMvvvhSRe3EOqx/4oXXLvia7qO6CsVZrey5154KB5YpKSG5tHs+5/ZsZnEIk6Ei1fLH73373i/09fXi0fWPpgyTLchkMqeGgAEgHA5/vjJWsf2PmzYr1dXV+K8fP7vjLxduWkY8ilpetQZPg+UJxh63lzqlNDi7gTa3fuPraz6bzxXw79/5FutP51am0+kdZdaQ/2kzDKNDUci51179w8pbP3er8sAD6+pnVCWy+/fs21LAqBnlMT50qJXFLq2a2L/5gaVy7N133j69u7sb67/7iFHIFf4tlU6/Ppg1kLGU8hYAywBMeOWV33gfXb9+1Q+ffDL+4Ne/AcYY/tS8PbV5++4Dhy+MopY2ZrLiidQDgDBSp5TS+Y7psS65ZOHsW26++eYosxje2PwGNm586eKzz/x027+sXWsBOAfgbULIQQAgUspaAA8BGAfnsamrq4u0tZ0Q333kkdGmZS3f8JNnlBXLV0AOilRKCS7sWYlxjlKxgHw+j5Y3W/C/Tz/NQ6Hgjp9seKZ31py5ajwe4wAtz9zdAH5OpJTPAqgEgL5USkpu4eLFHloqFXniYh9t3bunauuWrStisSi5//4vYnHTEkyZOhWqokBICcuy0N7ehr2trXjt1VeRzqTl3ffc81bjgsZELF4pQ6EAqa4eI6UEicfj5dhTKoCikynx6Bop5C14dJ2XcjmouipvvGFGoSJaWfr738/7tmzdjl/88pfIZjKwnH2SpmkIhSMYW1ODhvmNGFcztjhudFXR69Wgck58Hg+XEorH5ylDJYA8kVKOckpdB0ADIBOJhOzv70OhUFILuTzPZLNcSE6SfSlvJp0O5A1DN0qGDxLS4/OUAh6PGQqHC5XxeJEQgkgoRH1+L/wBP6LRuIjH4+Uf8gSAUwB+MbhzzQSwCMA0p/QUQADgNJ/PJ/v7+wnnnFiWkJZhKCYzKADoqiZUXeW67iGcSxKPx2QoFAo7AybnuE8COAZgHyHkxGXjeFAQEQCzANQCqAIQBeAH4AXgcex052w45TMcyQHIAOgBcBbAUUJI5uOM/wcaHmf3g9UM7QAAAABJRU5ErkJggg==", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_1.png", - "title": "Map marker image 1", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_1.png", - "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_2.png", - "title": "Map marker image 2", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_2.png", - "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_3.png", - "title": "Map marker image 3", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_3.png", - "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", - "mediaType": "image/png", - "data": "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", - "public": true } ], "scada": false, diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index 8fecb89f70..7ec5b35320 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -17,53 +17,9 @@ "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"minZoomLevel\":16,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\",\"imageSourceType\":null,\"imageUrl\":null,\"imageEntityAlias\":null,\"imageUrlAttribute\":null},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"imageSource\":null,\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"click\":{\"type\":\"doNothing\"},\"groups\":null,\"edit\":{\"enabledActions\":[],\"snappable\":false},\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"icon\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"size\":40,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var colors = ['#488bc7','#549c5d','#ed7546','#be2b29'];\\nvar temperature = data.temperature;\\nvar res = colors[0];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res = colors[index];\\n}\\nreturn res;\"},\"icon\":\"thermostat\"},\"markerImage\":{\"type\":\"function\",\"image\":\"/assets/markers/shape1.svg\",\"imageSize\":34,\"imageFunction\":\"\\n\",\"images\":[]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" }, "resources": [ - { - "link": "/api/images/system/map_marker_image_0.png", - "title": "Map marker image 0", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_0.png", - "publicResourceKey": "CdCrVxsjA4EAiFaXK4a7K2MZFMeEuGeD", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_1.png", - "title": "Map marker image 1", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_1.png", - "publicResourceKey": "DF3fuPXua9Vi3o3d9Nz2I1LXDTwEs2Tv", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_2.png", - "title": "Map marker image 2", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_2.png", - "publicResourceKey": "rz5SFAw2Sg5T2EyXNdwLycoDwf4QbMiZ", - "mediaType": "image/png", - "data": "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", - "public": true - }, - { - "link": "/api/images/system/map_marker_image_3.png", - "title": "Map marker image 3", - "type": "IMAGE", - "subType": "IMAGE", - "fileName": "map_marker_image_3.png", - "publicResourceKey": "KfPfTuvKCeAnmTcKcrvZQHfdU0TPArWY", - "mediaType": "image/png", - "data": "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", - "public": true - }, { "link": "/api/images/system/openstreet_map_system_widget_image.png", "title": "\"OpenStreet Map\" system widget image", From f290246aade5d0d4302179373dd74b0e2442e0d2 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 31 Jan 2025 16:54:33 +0200 Subject: [PATCH 033/127] UI: Maps - implement tooltip tag actions. --- .../json/system/widget_types/image_map.json | 2 +- .../data/json/system/widget_types/map.json | 2 +- .../lib/maps/data-layer/map-data-layer.ts | 22 ++- .../home/components/widget/lib/maps/map.ts | 36 ---- .../widget/lib/maps/models/map.models.ts | 3 +- ...idget-action-settings-panel.component.html | 6 +- .../widget-action-settings-panel.component.ts | 36 ++-- .../widget-action-settings.component.ts | 1 - .../action/widget-action.component.html | 17 ++ .../common/action/widget-action.component.ts | 39 +++- ...data-layer-pattern-settings.component.html | 4 + .../data-layer-pattern-settings.component.ts | 5 + .../map/map-data-layer-dialog.component.html | 4 +- .../map/map-data-layer-dialog.component.ts | 10 +- .../map-tooltip-tag-actions.component.html | 67 +++++++ .../map-tooltip-tag-actions.component.scss | 49 +++++ .../map/map-tooltip-tag-actions.component.ts | 176 ++++++++++++++++++ .../common/widget-settings-common.module.ts | 4 + ui-ngx/src/app/shared/models/widget.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 4 + 20 files changed, 407 insertions(+), 81 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index b7e8644662..0c10599757 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index 7ec5b35320..fb19914741 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 861c02a560..ebee9f8c59 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -60,7 +60,7 @@ export abstract class TbDataLayerItem; - private createTooltip(datasource: TbMapDatasource) { + private createTooltip() { this.tooltip = L.popup(); this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); this.layer.off('click'); @@ -279,22 +279,30 @@ export abstract class TbDataLayerItem { - this.bindTooltipActions(datasource); + this.bindTooltipActions(); (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { event.preventDefault(); }); }); } - private bindTooltipActions(datasource: TbMapDatasource) { + private bindTooltipActions() { const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); Array.from(actions).forEach( (element: HTMLElement) => { const actionName = element.getAttribute('data-action-name'); - this.dataLayer.getMap().tooltipElementClick(element, actionName, datasource); + if (this.settings.tooltip?.tagActions) { + const action = this.settings.tooltip.tagActions.find(action => action.name === actionName); + if (action) { + element.onclick = ($event) => + { + this.dataLayer.getMap().dataItemClick($event, action, this.data.$datasource); + return false; + }; + } + } }); } - } export enum MapDataLayerType { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 58f042f475..1e7165a85f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -18,7 +18,6 @@ import { additionalMapDataSourcesToDatasources, BaseMapSettings, DataKeyValuePair, - MapActionHandler, MapType, mergeMapDatasources, mergeUnplacedDataItemsArrays, @@ -99,8 +98,6 @@ export abstract class TbMap { private readonly mapResize$: ResizeObserver; - private readonly tooltipActions: { [name: string]: MapActionHandler }; - private tooltipInstances: TooltipInstancesData[] = []; private currentPopover: TbPopoverComponent; @@ -115,8 +112,6 @@ export abstract class TbMap { protected containerElement: HTMLElement) { this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); - this.tooltipActions = this.loadActions('tooltipAction'); - $(containerElement).empty(); $(containerElement).addClass('tb-map-layout'); const mapElement = $('
'); @@ -595,27 +590,6 @@ export abstract class TbMap { } } - private loadActions(name: string): { [name: string]: MapActionHandler } { - const descriptors = this.ctx.actionsApi.getActionDescriptors(name); - const actions: { [name: string]: MapActionHandler } = {}; - descriptors.forEach(descriptor => { - actions[descriptor.name] = ($event: Event, datasource: TbMapDatasource) => this.onCustomAction(descriptor, $event, datasource); - }); - return actions; - } - - private onCustomAction(descriptor: WidgetActionDescriptor, $event: Event, entityInfo: TbMapDatasource) { - if ($event) { - $event.preventDefault(); - $event.stopPropagation(); - } - const { entityId, entityName, entityLabel, entityType } = entityInfo; - this.ctx.actionsApi.handleWidgetAction($event, descriptor, { - entityType, - id: entityId - }, entityName, null, entityLabel); - } - private updateAddButtonsStates() { if (this.currentAddButton) { if (this.addMarkerButton && this.addMarkerButton !== this.currentAddButton) { @@ -705,16 +679,6 @@ export abstract class TbMap { }, entityName, null, entityLabel); } - public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { - if (element && this.tooltipActions[action]) { - element.onclick = ($event) => - { - this.tooltipActions[action]($event, datasource); - return false; - }; - } - } - public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { if (this.isPlacingItem) { return false; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 76b8566692..947d394c2d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -95,6 +95,7 @@ export interface DataLayerTooltipSettings extends DataLayerPatternSettings { autoclose: boolean; offsetX: number; offsetY: number; + tagActions?: WidgetAction[]; } export enum DataLayerEditAction { @@ -879,8 +880,6 @@ export type MapSetting = GeoMapSettings & ImageMapSettings; export const defaultMapSettings: MapSetting = defaultGeoMapSettings; -export type MapActionHandler = ($event: Event, datasource: TbMapDatasource) => void; - export interface MarkerImageInfo { url: string; size: number; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html index 3fffd3f424..7a7900214e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -21,7 +21,9 @@ + [widgetType]="widgetType" + [withName]="withName" + [actionNames]="actionNames">
@@ -36,7 +38,7 @@ type="button" (click)="applyWidgetAction()" [disabled]="widgetActionFormGroup.invalid || !widgetActionFormGroup.dirty"> - {{ 'action.apply' | translate }} + {{ applyTitle }}
") + .attr('class', 'leaflet-bar'); + return this.buttonContainer[0]; + } + + addTo(map: L.Map): this { + return super.addTo(map); + } + } class BottomToolbarControl extends L.Control { @@ -372,7 +398,7 @@ class BottomToolbarControl extends L.Control { buttons.forEach(buttonOption => { const button = new ToolbarButton(buttonOption); this.toolbarButtons.push(button); - button.addToToolbar(this); + button.getButtonElement().appendTo(this.container); }); const closeButton = $("") @@ -418,6 +444,10 @@ const groups = (options: TB.GroupsControlOptions): GroupsControl => { return new GroupsControl(options); } +const toolbar = (options: L.ControlOptions): ToolbarControl => { + return new ToolbarControl(options); +} + const bottomToolbar = (options: TB.BottomToolbarControlOptions): BottomToolbarControl => { return new BottomToolbarControl(options); } @@ -478,11 +508,13 @@ L.TB = L.TB || { LayersControl, GroupsControl, ToolbarButton, + ToolbarControl, BottomToolbarControl, sidebar, sidebarPane, layers, groups, + toolbar, bottomToolbar, TileLayer: { ChinaProvider diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 2fc1baede6..4dcb17668d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -15,10 +15,6 @@ */ @import '../../../../../../../scss/constants'; -//$map-element-hover-color: #307FE5; -$map-element-hover-color: rgba(0,0,0,0.56); -$map-element-selected-color: #307FE5; - .tb-map-layout { display: flex; width: 100%; @@ -36,19 +32,11 @@ $map-element-selected-color: #307FE5; flex: 1; &.leaflet-touch { .leaflet-bar { - border: none; + border: 1px solid rgba(0,0,0,0.38); border-radius: 15px; - box-shadow: 4px 4px 4px 0 rgba(0, 0, 0, 0.15); + box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.32); background: #fff; position: relative; - &:before { - content: ""; - position: absolute; - inset: 0; - border-radius: 15px; - background: $tb-primary-color; // primary color - opacity: 0.05; - } a { color: rgba(0, 0, 0, 0.54); border-bottom: none; @@ -111,10 +99,10 @@ $map-element-selected-color: #307FE5; mask-repeat: no-repeat; mask-position: center; &.tb-layers { - mask-image: url('data:image/svg+xml,'); + mask-image: url('data:image/svg+xml,'); } &.tb-groups { - mask-image: url('data:image/svg+xml,'); + mask-image: url('data:image/svg+xml,'); } &.tb-remove { mask-image: url('data:image/svg+xml,'); @@ -125,6 +113,18 @@ $map-element-selected-color: #307FE5; &.tb-rotate { mask-image: url('data:image/svg+xml,'); } + &.tb-place-marker { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-rectangle { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-polygon { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-circle { + mask-image: url('data:image/svg+xml,'); + } &.tb-close { background: #D12730; mask-image: url('data:image/svg+xml,'); @@ -156,15 +156,19 @@ $map-element-selected-color: #307FE5; &.tb-hoverable:not(.tb-selected) { &:hover { svg { - //filter: drop-shadow( 0 0 4px $map-element-hover-color); - filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(1.3) + drop-shadow( 0 0 4px rgba(0,0,0,0.56)) + drop-shadow( 0 0 4px rgba(255,255,255,0.56)); } } } &.tb-selected { svg { - filter: brightness(0.8); - //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + filter: brightness(1.3) + drop-shadow( 0 0 2px rgba(0,0,0,.6)) + drop-shadow( 0 0 4px rgba(255,255,255,.7)) + drop-shadow( 0 0 6px rgba(0,0,0,.8)) + drop-shadow( 0 0 8px rgba(255,255,255,.9)); } } } @@ -175,12 +179,28 @@ $map-element-selected-color: #307FE5; } &.tb-hoverable:not(.tb-selected) { &:hover { - filter: brightness(0.8) drop-shadow( 0 0 4px $map-element-hover-color); + filter: brightness(1.3) + drop-shadow( 0 0 4px rgba(0,0,0,0.56)) + drop-shadow( 0 0 4px rgba(255,255,255,0.56)); } } + } + img.leaflet-marker-icon { &.tb-selected { - filter: brightness(0.8); - //animation: tb-selected-animation 0.5s linear 0s infinite alternate; + filter: brightness(1.3) + drop-shadow( 0 0 2px rgba(0,0,0,.6)) + drop-shadow( 0 0 4px rgba(255,255,255,.7)) + drop-shadow( 0 0 6px rgba(0,0,0,.8)) + drop-shadow( 0 0 8px rgba(255,255,255,.9)); + } + } + path { + &.tb-selected:not(.tb-cut-mode) { + filter: brightness(1.3) + drop-shadow( 0 0 4px rgba(0,0,0,.4)) + drop-shadow( 0 0 4px rgba(255,255,255,.3)) + drop-shadow( 0 0 8px rgba(0,0,0,.6)) + drop-shadow( 0 0 8px rgba(255,255,255,.5)); } } .tb-cluster-marker-container { @@ -322,14 +342,3 @@ $map-element-selected-color: #307FE5; } } } - -@keyframes tb-selected-animation { - 0% { - //filter: drop-shadow( 0 0 2px $map-element-selected-color); - filter: brightness(1); - } - 100% { - //filter: drop-shadow( 0 0 4px $map-element-selected-color) drop-shadow( 0 0 4px $map-element-selected-color); - filter: brightness(0.8); - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index f391754674..346387ee6b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -23,7 +23,9 @@ import { mergeMapDatasources, parseCenterPosition, TbCircleData, - TbMapDatasource, TbPolygonCoordinates, TbPolygonRawCoordinates + TbMapDatasource, + TbPolygonCoordinates, + TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; @@ -74,6 +76,15 @@ export abstract class TbMap { protected editToolbar: L.TB.BottomToolbarControl; + protected addMarkerButton: L.TB.ToolbarButton; + protected addRectangleButton: L.TB.ToolbarButton; + protected addPolygonButton: L.TB.ToolbarButton; + protected addCircleButton: L.TB.ToolbarButton; + + protected addMarkerDataLayers: TbMapDataLayer[]; + protected addPolygonDataLayers: TbMapDataLayer[]; + protected addCircleDataLayers: TbMapDataLayer[]; + private readonly mapResize$: ResizeObserver; private readonly tooltipActions: { [name: string]: MapActionHandler }; @@ -253,6 +264,51 @@ export abstract class TbMap { this.map.on('click', () => { this.deselectItem(); }); + + const addSupportedDataLayers = this.dataLayers.filter(dl => dl.isAddEnabled()); + + if (addSupportedDataLayers.length) { + const drawToolbar = L.TB.toolbar({ + position: this.settings.controlsPosition + }).addTo(this.map); + this.addMarkerDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.marker); + if (this.addMarkerDataLayers.length) { + this.addMarkerButton = drawToolbar.toolbarButton({ + id: 'addMarker', + title: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker'), + iconClass: 'tb-place-marker', + click: (e, button) => {} + }); + this.addMarkerButton.setDisabled(true); + } + this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.polygon); + if (this.addPolygonDataLayers.length) { + this.addRectangleButton = drawToolbar.toolbarButton({ + id: 'addRectangle', + title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-rectangle'), + iconClass: 'tb-draw-rectangle', + click: (e, button) => {} + }); + this.addRectangleButton.setDisabled(true); + this.addPolygonButton = drawToolbar.toolbarButton({ + id: 'addPolygon', + title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-polygon'), + iconClass: 'tb-draw-polygon', + click: (e, button) => {} + }); + this.addPolygonButton.setDisabled(true); + } + this.addCircleDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.circle); + if (this.addCircleDataLayers.length) { + this.addCircleButton = drawToolbar.toolbarButton({ + id: 'addCircle', + title: this.ctx.translate.instant('widgets.maps.data-layer.circle.draw-circle'), + iconClass: 'tb-draw-circle', + click: (e, button) => {} + }); + this.addCircleButton.setDisabled(true); + } + } } private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { @@ -320,6 +376,7 @@ export abstract class TbMap { undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); this.dataLayers.forEach(dl => dl.updateData(this.dsData)); this.updateBounds(); + this.updateAddButtonsStates(); } private resize() { @@ -365,6 +422,21 @@ export abstract class TbMap { }, entityName, null, entityLabel); } + private updateAddButtonsStates() { + if (this.addMarkerButton) { + this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.hasUnplacedItems())); + } + if (this.addRectangleButton) { + this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + } + if (this.addPolygonButton) { + this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + } + if (this.addCircleButton) { + this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.hasUnplacedItems())); + } + } + protected abstract defaultSettings(): S; protected abstract createMap(): Observable; @@ -454,10 +526,10 @@ export abstract class TbMap { } } - public selectItem(item: TbDataLayerItem, cancel = false): boolean { + public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { let deselected = true; if (this.selectedDataItem) { - deselected = this.selectedDataItem.deselect(cancel); + deselected = this.selectedDataItem.deselect(cancel, force); if (deselected) { this.selectedDataItem = null; this.editToolbar.close(); @@ -475,8 +547,8 @@ export abstract class TbMap { return deselected; } - public deselectItem(cancel = false): boolean { - return this.selectItem(null, cancel); + public deselectItem(cancel = false, force = false): boolean { + return this.selectItem(null, cancel, force); } public getSelectedDataItem(): TbDataLayerItem { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 33d8797a2d..260999408a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7717,7 +7717,8 @@ "marker-color-function": "Marker color function" }, "edit": "Edit marker", - "remove-marker-for": "Remove marker for '{{entityName}}'" + "remove-marker-for": "Remove marker for '{{entityName}}'", + "place-marker": "Place marker" }, "polygon": { "polygon-key": "Polygon key", @@ -7732,7 +7733,9 @@ "rotate": "Rotate polygon", "firstVertex-cut": "Click to place first point", "continueLine-cut": "Click to continue drawing", - "finishPoly-cut": "Click first marker to finish and save" + "finishPoly-cut": "Click first marker to finish and save", + "draw-rectangle": "Draw rectangle", + "draw-polygon": "Draw polygon" }, "circle": { "circle-key": "Circle key", @@ -7742,7 +7745,8 @@ "circle-configuration": "Circle configuration", "remove-circle": "Remove circle", "edit": "Edit circle", - "remove-circle-for": "Remove circle for '{{entityName}}'" + "remove-circle-for": "Remove circle for '{{entityName}}'", + "draw-circle": "Draw circle" } }, "select-entity": "Select entity", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index 5ae83c1679..b75743c139 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -104,6 +104,11 @@ declare module 'leaflet' { isDisabled(): boolean; } + class ToolbarControl extends Control { + constructor(options: ControlOptions); + toolbarButton(options: ToolbarButtonOptions): ToolbarButton; + } + interface BottomToolbarControlOptions extends ControlOptions { mapElement: JQuery; closeTitle: string; @@ -126,6 +131,8 @@ declare module 'leaflet' { function groups(options: GroupsControlOptions): GroupsControl; + function toolbar(options: ControlOptions): ToolbarControl; + function bottomToolbar(options: BottomToolbarControlOptions): BottomToolbarControl; namespace TileLayer { From 0940e373bbbb04d787ae37cb59c196e33482799b Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 29 Jan 2025 21:06:11 +0200 Subject: [PATCH 028/127] UI: Map items add/draw mode. --- .../lib/maps/data-layer/circles-data-layer.ts | 8 +- .../lib/maps/data-layer/map-data-layer.ts | 45 +++- .../lib/maps/data-layer/markers-data-layer.ts | 6 +- .../maps/data-layer/polygons-data-layer.ts | 20 +- .../widget/lib/maps/leaflet/leaflet-tb.ts | 38 ++-- .../home/components/widget/lib/maps/map.scss | 117 +++++----- .../home/components/widget/lib/maps/map.ts | 200 ++++++++++++++++-- .../widget/lib/maps/models/map.models.ts | 11 + .../select-map-entity-panel.component.html | 44 ++++ .../select-map-entity-panel.component.scss | 39 ++++ .../select-map-entity-panel.component.ts | 69 ++++++ .../widget/widget-components.module.ts | 4 + .../components/widget/widget.component.ts | 2 + .../home/models/widget-component.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 7 +- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 3 +- 16 files changed, 531 insertions(+), 94 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 7f877dbb00..3f5368d95c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -45,7 +45,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem this.editing = true); this.circle.on('pm:markerdragend', () => this.editing = false); - this.circle.on('pm:edit', (e) => this.saveCircleCoordinates()); + this.circle.on('pm:edit', () => this.saveCircleCoordinates()); this.circle.pm.enable(); } return []; @@ -142,6 +142,10 @@ class TbCircleDataLayerItem extends TbDataLayerItem { @@ -177,12 +179,13 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]): void { @@ -365,7 +368,12 @@ export class DataLayerColorProcessor { } -export abstract class TbMapDataLayer, L extends L.Layer = L.Layer> implements L.TB.DataLayer { +export interface UnplacedMapDataItem { + entity: FormattedData; + dataLayer: TbMapDataLayer; +} + +export abstract class TbMapDataLayer = any, L extends L.Layer = L.Layer> implements L.TB.DataLayer { protected settings: S; @@ -386,13 +394,14 @@ export abstract class TbMapDataLayer[] = []; + private unplacedItems: UnplacedMapDataItem[] = []; public dataLayerLabelProcessor: DataLayerPatternProcessor; public dataLayerTooltipProcessor: DataLayerPatternProcessor; @@ -412,6 +421,7 @@ export abstract class TbMapDataLayer { @@ -559,6 +577,25 @@ export abstract class TbMapDataLayer { + this.polygon.on('pm:dragend', () => { this.savePolygonCoordinates(); this.editing = false; }); @@ -200,10 +200,14 @@ class TbPolygonDataLayerItem extends TbDataLayerItem this.editing = true); this.polygon.on('pm:markerdragend', () => this.editing = false); - this.polygon.on('pm:edit', (e) => this.savePolygonCoordinates()); + this.polygon.on('pm:edit', () => this.savePolygonCoordinates()); this.polygon.pm.enable(); const map = this.dataLayer.getMap(); map.getEditToolbar().getButton('remove')?.setDisabled(false); @@ -232,7 +236,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { + this.polygon.on('pm:rotateend', () => { this.savePolygonCoordinates(); }); this.polygon.pm.enableRotate(); rotateButton?.setActive(true); - this.polygon.on('pm:rotatedisable', (e) => { + this.polygon.on('pm:rotatedisable', () => { this.disablePolygonRotateMode(rotateButton); this.enablePolygonEditMode(); }); @@ -330,7 +334,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { constructor(options: TB.ToolbarButtonOptions) { super(options); this.id = options.id; + + const buttonText = this.options.showText ? this.options.title : null; this.button = $("") .attr('class', 'tb-control-button') .attr('href', '#') .attr('role', 'button') - .attr('title', this.options.title) - .html('
'); + .html('
' + (buttonText ? `
${buttonText}
` : '')); + if (this.options.showText) { + L.DomUtil.addClass(this.button[0], 'tb-control-text-button'); + } else { + this.button.attr('title', this.options.title); + } this.button.on('click', (e) => { e.stopPropagation(); @@ -391,7 +397,7 @@ class BottomToolbarControl extends L.Control { return this.toolbarButtons.find(b => b.getId() === id); } - open(buttons: TB.ToolbarButtonOptions[]): void { + open(buttons: TB.ToolbarButtonOptions[], showCloseButton = true): void { this.toolbarButtons.length = 0; @@ -401,19 +407,21 @@ class BottomToolbarControl extends L.Control { button.getButtonElement().appendTo(this.container); }); - const closeButton = $("
") - .attr('class', 'tb-control-button') - .attr('href', '#') - .attr('role', 'button') - .attr('title', this.options.closeTitle) - .html('
'); + if (showCloseButton) { + const closeButton = $("
") + .attr('class', 'tb-control-button') + .attr('href', '#') + .attr('role', 'button') + .attr('title', this.options.closeTitle) + .html('
'); - closeButton.on('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - this.close(); - }); - closeButton.appendTo(this.buttonContainer); + closeButton.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + this.close(); + }); + closeButton.appendTo(this.buttonContainer); + } } close(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 4dcb17668d..da96bcc3e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -16,6 +16,7 @@ @import '../../../../../../../scss/constants'; .tb-map-layout { + position: relative; display: flex; width: 100%; height: 100%; @@ -29,6 +30,7 @@ flex-direction: row; } .tb-map { + position: relative; flex: 1; &.leaflet-touch { .leaflet-bar { @@ -70,6 +72,73 @@ border-bottom-left-radius: 50%; border-bottom-right-radius: 50%; } + &.tb-control-button { + &.tb-control-text-button { + display: flex; + width: auto; + padding-right: 14px; + padding-left: 2px; + div.tb-control-text { + width: auto; + background: transparent; + font-family: Roboto; + font-size: 12px; + font-style: normal; + font-weight: 500; + } + } + &:not(.leaflet-disabled) { + &.active, &:hover { + &:before { + border-radius: 15px; + } + > div:not(.tb-control-text):not(.tb-close) { + background: $tb-primary-color; // primary color + } + } + > div { + background: rgba(0, 0, 0, 0.54); + } + } + > div { + width: 30px; + height: 30px; + line-height: 30px; + mask-repeat: no-repeat; + mask-position: center; + &.tb-layers { + mask-image: url('data:image/svg+xml,'); + } + &.tb-groups { + mask-image: url('data:image/svg+xml,'); + } + &.tb-remove { + mask-image: url('data:image/svg+xml,'); + } + &.tb-cut { + mask-image: url('data:image/svg+xml,'); + } + &.tb-rotate { + mask-image: url('data:image/svg+xml,'); + } + &.tb-place-marker { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-rectangle { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-polygon { + mask-image: url('data:image/svg+xml,'); + } + &.tb-draw-circle { + mask-image: url('data:image/svg+xml,'); + } + &.tb-close { + background: #D12730; + mask-image: url('data:image/svg+xml,'); + } + } + } } } .tb-map-bottom-toolbar { @@ -84,54 +153,6 @@ } } } - .leaflet-control { - .tb-control-button { - &.active, &:hover { - > div { - background: $tb-primary-color; // primary color - } - } - > div { - width: 30px; - height: 30px; - background: rgba(0, 0, 0, 0.54); - line-height: 30px; - mask-repeat: no-repeat; - mask-position: center; - &.tb-layers { - mask-image: url('data:image/svg+xml,'); - } - &.tb-groups { - mask-image: url('data:image/svg+xml,'); - } - &.tb-remove { - mask-image: url('data:image/svg+xml,'); - } - &.tb-cut { - mask-image: url('data:image/svg+xml,'); - } - &.tb-rotate { - mask-image: url('data:image/svg+xml,'); - } - &.tb-place-marker { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-rectangle { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-polygon { - mask-image: url('data:image/svg+xml,'); - } - &.tb-draw-circle { - mask-image: url('data:image/svg+xml,'); - } - &.tb-close { - background: #D12730; - mask-image: url('data:image/svg+xml,'); - } - } - } - } .leaflet-map-pane:not(.leaflet-zoom-anim) { .leaflet-marker-icon { &.tb-hoverable:not(.tb-selected) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 346387ee6b..a6b91d9008 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -20,6 +20,7 @@ import { DataKeyValuePair, MapActionHandler, MapType, + mergeUnplacedDataItemsArrays, mergeMapDatasources, parseCenterPosition, TbCircleData, @@ -32,12 +33,12 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, TbDataLayerItem, - TbMapDataLayer, + TbMapDataLayer, UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; @@ -51,6 +52,15 @@ import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@sh import { EntityId } from '@shared/models/id/entity-id'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import { TbPopoverService } from '@shared/components/popover.service'; +import { + SelectMapEntityPanelComponent +} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import tinycolor from 'tinycolor2'; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -61,6 +71,7 @@ export abstract class TbMap { protected defaultCenterPosition: [number, number]; protected ignoreUpdateBounds = false; + protected bounds: L.LatLngBounds; protected southWest = new L.LatLng(-L.Projection.SphericalMercator['MAX_LATITUDE'], -180); protected northEast = new L.LatLng(L.Projection.SphericalMercator['MAX_LATITUDE'], 180); @@ -94,6 +105,9 @@ export abstract class TbMap { private tooltipInstances: TooltipInstancesData[] = []; + private currentPopover: TbPopoverComponent; + private isAddingItem = false; + protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, protected containerElement: HTMLElement) { @@ -152,6 +166,9 @@ export abstract class TbMap { }); if (this.settings.useDefaultCenterPosition) { this.map.panTo(this.defaultCenterPosition); + this.bounds = this.map.getBounds(); + } else { + this.bounds = new L.LatLngBounds(null, null); } this.setupDataLayers(); this.setupEditMode(); @@ -265,6 +282,11 @@ export abstract class TbMap { this.deselectItem(); }); + if (this.dataLayers.some(dl => dl.isEditable())) { + this.map.pm.setGlobalOptions({ snappable: false } as L.PM.GlobalOptions); + this.map.pm.applyGlobalOptions(); + } + const addSupportedDataLayers = this.dataLayers.filter(dl => dl.isAddEnabled()); if (addSupportedDataLayers.length) { @@ -277,9 +299,25 @@ export abstract class TbMap { id: 'addMarker', title: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker'), iconClass: 'tb-place-marker', - click: (e, button) => {} + click: (e, button) => { + this.placeMarker(e, button); + } }); this.addMarkerButton.setDisabled(true); + createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), MarkerShape.markerShape1, tinycolor('rgba(255,255,255,0.75)')).subscribe( + ((iconUrl) => { + const icon = L.icon({ + iconUrl, + iconSize: [40, 40], + iconAnchor: [20, 40] + }); + this.map.pm.setGlobalOptions({ + markerStyle: { + icon + } + }); + }) + ); } this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.polygon); if (this.addPolygonDataLayers.length) { @@ -287,14 +325,18 @@ export abstract class TbMap { id: 'addRectangle', title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-rectangle'), iconClass: 'tb-draw-rectangle', - click: (e, button) => {} + click: (e, button) => { + this.drawRectangle(e, button); + } }); this.addRectangleButton.setDisabled(true); this.addPolygonButton = drawToolbar.toolbarButton({ id: 'addPolygon', title: this.ctx.translate.instant('widgets.maps.data-layer.polygon.draw-polygon'), iconClass: 'tb-draw-polygon', - click: (e, button) => {} + click: (e, button) => { + this.drawPolygon(e, button); + } }); this.addPolygonButton.setDisabled(true); } @@ -304,13 +346,138 @@ export abstract class TbMap { id: 'addCircle', title: this.ctx.translate.instant('widgets.maps.data-layer.circle.draw-circle'), iconClass: 'tb-draw-circle', - click: (e, button) => {} + click: (e, button) => { + this.drawCircle(e, button); + } }); this.addCircleButton.setDisabled(true); } } } + private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addMarkerDataLayers, + (entity) => { + this.map.pm.enableDraw('Marker'); + const hintText = this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint', {entityName: entity.entity.entityDisplayName}); + // @ts-ignore + this.map.pm.Draw.Marker._hintMarker.setTooltipContent(hintText); + }, + (entity, layer) => { + (entity.dataLayer as TbMarkersDataLayer).saveMarkerLocation(entity.entity, (layer as L.Marker).getLatLng()); + } + ); + } + + private drawRectangle(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addPolygonDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private drawPolygon(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addPolygonDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private drawCircle(e: MouseEvent, button: L.TB.ToolbarButton): void { + this.placeItem(e, button, this.addCircleDataLayers, + (entity) => { + + }, + (entity, layer) => { + } + ); + } + + private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbMapDataLayer[], + prepareDrawMode: (entity: UnplacedMapDataItem) => void, + saveLocation: (entity: UnplacedMapDataItem, layer: L.Layer) => void): void { + if (this.isAddingItem) { + return; + } + button.setActive(true); + this.deselectItem(false, true); + this.isAddingItem = true; + const items = mergeUnplacedDataItemsArrays(dataLayers.filter(dl => dl.isEnabled()).map(dl => dl.getUnplacedItems())).sort((entity1, entity2) => { + return entity1.entity.entityDisplayName.localeCompare(entity2.entity.entityDisplayName); + }); + this.selectEntityToPlace(e, items).subscribe((entity) => { + if (entity) { + + const finishAdd = () => { + this.map.off('pm:create'); + this.map.pm.disableDraw(); + this.dataLayers.forEach(dl => dl.enableEditMode()); + button.setActive(false); + this.isAddingItem = false; + this.editToolbar.close(); + }; + + this.map.once('pm:create', (e) => { + saveLocation(entity, e.layer); + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + finishAdd(); + }); + + this.dataLayers.forEach(dl => dl.disableEditMode()); + + prepareDrawMode(entity); + + this.editToolbar.open([ + { + id: 'cancel', + iconClass: 'tb-close', + title: this.ctx.translate.instant('action.cancel'), + showText: true, + click: finishAdd + } + ], false); + } else { + button.setActive(false); + this.isAddingItem = false; + } + }); + } + + private selectEntityToPlace(e: MouseEvent, entities: UnplacedMapDataItem[]): Observable { + if (entities.length === 1) { + return of(entities[0]); + } else { + if (e) { + e.stopPropagation(); + } + const trigger = (e.target || e.srcElement || e.currentTarget) as Element; + const popoverService = this.ctx.$injector.get(TbPopoverService); + const ctx: any = { + entities + }; + const popoverPosition = ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'rightTop' : 'leftTop'; + const selectMapEntityPanelPopover = popoverService.displayPopover(trigger, this.ctx.renderer, + this.ctx.widgetContentContainer, SelectMapEntityPanelComponent, popoverPosition, true, null, + ctx, + {}, + {}, {}, false); + this.currentPopover = selectMapEntityPanelPopover; + return selectMapEntityPanelPopover.tbComponentRef.instance.entitySelected.asObservable().pipe( + tap(() => { + this.currentPopover = null; + }) + ); + } + } + private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { import('tooltipster').then(() => { let tooltipData = this.tooltipInstances.find(d => d.root === root); @@ -382,6 +549,7 @@ export abstract class TbMap { private resize() { this.onResize(); this.map?.invalidateSize(); + this.currentPopover?.updatePosition(); } private updateBounds() { @@ -392,8 +560,9 @@ export abstract class TbMap { bounds = new L.LatLngBounds(null, null); dataLayersBounds.forEach(b => bounds.extend(b)); const mapBounds = this.map.getBounds(); - if (bounds.isValid() && this.settings.fitMapBounds && !mapBounds.contains(bounds)) { - if (!this.ignoreUpdateBounds) { + if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || !this.bounds.equals(bounds) && this.settings.fitMapBounds && !mapBounds.contains(bounds))) { + this.bounds = bounds; + if (!this.ignoreUpdateBounds && !this.isAddingItem) { this.fitBounds(bounds); } } @@ -424,16 +593,16 @@ export abstract class TbMap { private updateAddButtonsStates() { if (this.addMarkerButton) { - this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.hasUnplacedItems())); + this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addRectangleButton) { - this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addPolygonButton) { - this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.hasUnplacedItems())); + this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } if (this.addCircleButton) { - this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.hasUnplacedItems())); + this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } } @@ -480,6 +649,10 @@ export abstract class TbMap { return this.settings.mapType; } + public enabledDataLayersUpdated() { + this.updateAddButtonsStates(); + } + public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { if (element && this.tooltipActions[action]) { element.onclick = ($event) => @@ -527,6 +700,9 @@ export abstract class TbMap { } public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { + if (this.isAddingItem) { + return false; + } let deselected = true; if (this.selectedDataItem) { deselected = this.selectedDataItem.deselect(cancel, force); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 1f43e83c85..aa343e6520 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -25,6 +25,7 @@ import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; export enum MapType { geoMap = 'geoMap', @@ -1019,6 +1020,16 @@ const mergeMapDatasource = (target: TbMapDatasource, source: TbMapDatasource): T return target; } +export const mergeUnplacedDataItemsArrays = (dataItemsArrays: UnplacedMapDataItem[][]): UnplacedMapDataItem[] => { + const itemsMap = new Map(); + dataItemsArrays.forEach(dataItems => { + dataItems.forEach(dataItem => { + itemsMap.set(dataItem.entity.$datasource.entityId, dataItem); + }); + }); + return Array.from(itemsMap.values()); +} + const imageAspectMap: {[key: string]: ImageWithAspect} = {}; const imageLoader = (imageUrl: string): Observable => new Observable((observer: Observer) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html new file mode 100644 index 0000000000..afa4f23d97 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html @@ -0,0 +1,44 @@ + +
+ + {{ (selectEntityFormGroup.get('entity').value ? 'entity.entity' : 'widgets.maps.data-layer.select-entity') | translate }} + + + {{ entity.entity.entityDisplayName }} + + + widgets.maps.data-layer.select-entity-hint + +
+ + + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss new file mode 100644 index 0000000000..63ff621ecd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.scss @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2024 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import '../../../../../../../../scss/constants'; + +.tb-select-map-entity-panel { + width: 320px; + max-width: 90vw; + display: flex; + flex-direction: column; + gap: 16px; + @media #{$mat-xs} { + width: 90vw; + } + .mat-mdc-form-field-hint-wrapper { + padding: 0; + } + .tb-select-map-entity-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts new file mode 100644 index 0000000000..7b0ec943a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts @@ -0,0 +1,69 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +@Component({ + selector: 'tb-select-map-entity-panel', + templateUrl: './select-map-entity-panel.component.html', + providers: [], + styleUrls: ['./select-map-entity-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SelectMapEntityPanelComponent extends PageComponent implements OnInit { + + @Input() + entities: UnplacedMapDataItem[]; + + @Output() + entitySelected = new EventEmitter(); + + selectEntityFormGroup: UntypedFormGroup; + + selectedEntity: UnplacedMapDataItem = null; + + constructor(private fb: UntypedFormBuilder, + protected store: Store, + private popover: TbPopoverComponent) { + super(store); + } + + ngOnInit(): void { + this.selectEntityFormGroup = this.fb.group( + { + entity: ['', Validators.required] + } + ); + this.popover.tbDestroy.subscribe(() => { + this.entitySelected.emit(this.selectedEntity); + }); + } + + cancel() { + this.popover.hide(); + } + + selectEntity() { + this.selectedEntity = this.selectEntityFormGroup.value.entity; + this.popover.hide(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 1530561924..acd07ad0da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -89,6 +89,9 @@ import { EllipsisChipListDirective } from '@shared/directives/ellipsis-chip-list import { ScadaSymbolWidgetComponent } from '@home/components/widget/lib/scada/scada-symbol-widget.component'; import { TwoSegmentButtonWidgetComponent } from '@home/components/widget/lib/button/two-segment-button-widget.component'; import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.component'; +import { + SelectMapEntityPanelComponent +} from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; @NgModule({ declarations: [ @@ -143,6 +146,7 @@ import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget. UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, + SelectMapEntityPanelComponent, MapWidgetComponent ], imports: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index eaf6b6105e..5dc88a0bce 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -248,6 +248,8 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, this.widgetContext.isPreview = this.isPreview; this.widgetContext.isMobile = this.isMobile; this.widgetContext.toastTargetId = this.toastTargetId; + this.widgetContext.renderer = this.renderer; + this.widgetContext.widgetContentContainer = this.widgetContentContainer; this.widgetContext.subscriptionApi = { createSubscription: this.createSubscription.bind(this), diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index c599fbf16d..5380f1ea84 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -46,7 +46,15 @@ import { WidgetActionsApi, WidgetSubscriptionApi } from '@core/api/widget-api.models'; -import { ChangeDetectorRef, InjectionToken, Injector, NgZone, TemplateRef, Type } from '@angular/core'; +import { + ChangeDetectorRef, + InjectionToken, + Injector, + NgZone, Renderer2, + TemplateRef, + Type, + ViewContainerRef +} from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; @@ -215,6 +223,8 @@ export class WidgetContext { http: HttpClient; sanitizer: DomSanitizer; router: Router; + renderer: Renderer2; + widgetContentContainer: ViewContainerRef; private changeDetectorValue: ChangeDetectorRef; private containerChangeDetectorValue: ChangeDetectorRef; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 260999408a..9fa662c127 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7718,7 +7718,8 @@ }, "edit": "Edit marker", "remove-marker-for": "Remove marker for '{{entityName}}'", - "place-marker": "Place marker" + "place-marker": "Place marker", + "place-marker-hint": "Click to place '{{entityName}}' entity" }, "polygon": { "polygon-key": "Polygon key", @@ -7747,7 +7748,9 @@ "edit": "Edit circle", "remove-circle-for": "Remove circle for '{{entityName}}'", "draw-circle": "Draw circle" - } + }, + "select-entity": "Select entity", + "select-entity-hint": "Hint: after selection click at the map to set position" }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position", diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index b75743c139..e24c632a3b 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -94,6 +94,7 @@ declare module 'leaflet' { title: string; click: (e: MouseEvent, button: ToolbarButton) => void; iconClass: string; + showText?: boolean; } class ToolbarButton extends Control{ @@ -118,7 +119,7 @@ declare module 'leaflet' { class BottomToolbarControl extends Control { constructor(options: BottomToolbarControlOptions); getButton(id: string): ToolbarButton | undefined; - open(buttons: ToolbarButtonOptions[]): void; + open(buttons: ToolbarButtonOptions[], showCloseButton?: boolean): void; close(): void; container: HTMLElement; } From ab465bf651685355c91ee00b1c40b574580b6cdb Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 30 Jan 2025 13:17:01 +0200 Subject: [PATCH 029/127] UI: Map - implement map items draw/placement. --- .../lib/maps/data-layer/circles-data-layer.ts | 34 ++++- .../lib/maps/data-layer/map-data-layer.ts | 38 +++-- .../lib/maps/data-layer/markers-data-layer.ts | 29 +++- .../maps/data-layer/polygons-data-layer.ts | 80 ++++++++--- .../home/components/widget/lib/maps/map.scss | 15 +- .../home/components/widget/lib/maps/map.ts | 135 ++++++++++++------ .../assets/locale/locale.constant-en_US.json | 17 ++- 7 files changed, 253 insertions(+), 95 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 3f5368d95c..f7580b313d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -25,7 +25,12 @@ import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/s import { TbMap } from '@home/components/widget/lib/maps/map'; import { Observable } from 'rxjs'; import { isNotEmptyStr } from '@core/utils'; -import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + MapDataLayerType, + TbDataLayerItem, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { map } from 'rxjs/operators'; class TbCircleDataLayerItem extends TbDataLayerItem { @@ -134,8 +139,8 @@ class TbCircleDataLayerItem extends TbDataLayerItem { + return this.dataLayer.saveCircleCoordinates(this.data, null, null); } public isEditing() { @@ -149,7 +154,7 @@ class TbCircleDataLayerItem extends TbDataLayerItem) { @@ -178,6 +183,21 @@ export class TbCirclesDataLayer extends TbShapesDataLayer { + item.entity[this.settings.circleKey.label] = JSON.stringify(converted); + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a circle.'); + } + } + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { datasource.dataKeys.push(this.settings.circleKey); return datasource; @@ -204,7 +224,7 @@ export class TbCirclesDataLayer extends TbShapesDataLayer, center: L.LatLng, radius: number): void { + public saveCircleCoordinates(data: FormattedData, center: L.LatLng, radius: number): Observable { const converted = center ? this.map.coordinatesToCircleData(center, radius) : null; const circleData = [ { @@ -212,6 +232,8 @@ export class TbCirclesDataLayer extends TbShapesDataLayer converted) + ); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 00c4076d2d..393131e932 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -148,7 +148,9 @@ export abstract class TbDataLayerItem { - this.removeDataItem(); + this.removeDataItem().subscribe( + () => this.dataLayer.removeItem(this.data.entityId) + ); }, iconClass: 'tb-remove' }); @@ -246,7 +248,7 @@ export abstract class TbDataLayerItem; private createTooltip(datasource: TbMapDatasource) { this.tooltip = L.popup(); @@ -552,15 +554,31 @@ export abstract class TbMapDataLayer { - const item = this.layerItems.get(key); - item.remove(); - this.layerItems.delete(key); + this.removeItem(key); }); if (updatedItems.length) { this.layerItemsUpdated(updatedItems); } } + public removeItem(key: string): void { + const item = this.layerItems.get(key); + if (item) { + item.remove(); + this.layerItems.delete(key); + } + } + + protected createItemFromUnplaced(unplacedItem: UnplacedMapDataItem): void { + const index = this.unplacedItems.indexOf(unplacedItem); + if (index > -1) { + this.unplacedItems.splice(index, 1); + const layerItem = this.createLayerItem(unplacedItem.entity, this.map.getData()); + this.layerItems.set(unplacedItem.entity.entityId, layerItem); + this.map.enabledDataLayersUpdated(); + } + } + public invalidateCoordinates(): void { this.layerItems.forEach(item => item.invalidateCoordinates()); } @@ -612,9 +630,11 @@ export abstract class TbMapDataLayer): Partial; protected abstract doSetup(): Observable; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index db037b69d9..a466d1ec3f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -61,7 +61,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { MapDataLayerType, TbDataLayerItem, - TbMapDataLayer + TbMapDataLayer, UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; @@ -176,8 +176,8 @@ class TbMarkerDataLayerItem extends TbDataLayerItem { + return this.dataLayer.saveMarkerLocation(this.data, null); } public isEditing() { @@ -190,7 +190,7 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, dsData: FormattedData[]) { @@ -452,6 +452,21 @@ export class TbMarkersDataLayer extends TbMapDataLayer { + item.entity[this.settings.xKey.label] = converted.x; + item.entity[this.settings.yKey.label] = converted.y; + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a marker.'); + } + } + protected createDataLayerContainer(): FeatureGroup { if (this.settings.markerClustering?.enable) { return this.createMarkersClusterContainer(); @@ -645,7 +660,7 @@ export class TbMarkersDataLayer extends TbMapDataLayer, position: L.LatLng): void { + public saveMarkerLocation(data: FormattedData, position: L.LatLng): Observable<{x: number; y: number}> { const converted = this.map.latLngToLocationData(position); const locationData = [ { @@ -657,7 +672,9 @@ export class TbMarkersDataLayer extends TbMapDataLayer converted) + ); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index 5157b5922c..b6ca13d555 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -26,7 +26,12 @@ import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/s import { TbMap } from '@home/components/widget/lib/maps/map'; import { Observable } from 'rxjs'; import { isNotEmptyStr, isString } from '@core/utils'; -import { MapDataLayerType, TbDataLayerItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + MapDataLayerType, + TbDataLayerItem, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { map } from 'rxjs/operators'; class TbPolygonDataLayerItem extends TbDataLayerItem { @@ -184,6 +189,8 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { + return this.dataLayer.savePolygonCoordinates(this.data, null); } public isEditing() { @@ -206,7 +213,9 @@ class TbPolygonDataLayerItem extends TbDataLayerItem this.editing = true); - this.polygon.on('pm:markerdragend', () => this.editing = false); + this.polygon.on('pm:markerdragend', () => setTimeout(() => { + this.editing = false; + }) ); this.polygon.on('pm:edit', () => this.savePolygonCoordinates()); this.polygon.pm.enable(); const map = this.dataLayer.getMap(); @@ -230,18 +239,18 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { - if (this.polygon instanceof L.Rectangle) { - this.polygonContainer.removeLayer(this.polygon); - // @ts-ignore - this.polygon = L.polygon(e.layer.getLatLngs(), { - ...this.polygonStyle, - snapIgnore: !this.dataLayer.isSnappable(), - bubblingMouseEvents: !this.dataLayer.isEditMode() - }); - this.polygon.addTo(this.polygonContainer); - } else { - // @ts-ignore - this.polygon.setLatLngs(e.layer.getLatLngs()); + if (e.layer instanceof L.Polygon) { + if (this.polygon instanceof L.Rectangle) { + this.polygonContainer.removeLayer(this.polygon); + this.polygon = L.polygon(e.layer.getLatLngs(), { + ...this.polygonStyle, + snapIgnore: !this.dataLayer.isSnappable(), + bubblingMouseEvents: !this.dataLayer.isEditMode() + }); + this.polygon.addTo(this.polygonContainer); + } else { + this.polygon.setLatLngs(e.layer.getLatLngs()); + } } // @ts-ignore e.layer._pmTempLayer = true; @@ -257,15 +266,17 @@ class TbPolygonDataLayerItem extends TbDataLayerItem { if (!e.enabled) { @@ -320,7 +331,7 @@ class TbPolygonDataLayerItem extends TbDataLayerItem) { @@ -360,6 +371,29 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer { + item.entity[this.settings.polygonKey.label] = JSON.stringify(converted); + this.createItemFromUnplaced(item); + } + ); + } else { + console.warn('Unable to place item, layer is not a polygon.'); + } + } + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { datasource.dataKeys.push(this.settings.polygonKey); return datasource; @@ -390,7 +424,7 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer, coordinates: TbPolygonCoordinates): void { + public savePolygonCoordinates(data: FormattedData, coordinates: TbPolygonCoordinates): Observable { const converted = coordinates ? this.map.coordinatesToPolygonData(coordinates) : null; const polygonData = [ { @@ -398,6 +432,8 @@ export class TbPolygonsDataLayer extends TbShapesDataLayer converted) + ); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index da96bcc3e9..270732d25b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -81,7 +81,7 @@ div.tb-control-text { width: auto; background: transparent; - font-family: Roboto; + font-family: Roboto, "Helvetica Neue", sans-serif; font-size: 12px; font-style: normal; font-weight: 500; @@ -240,6 +240,19 @@ background: none; box-shadow: none; } + .tb-place-item-label { + border: none; + box-shadow: none; + border-radius: 4px; + background: rgba(0,0,0,0.56); + backdrop-filter: blur(4px); + padding: 4px 8px; + color: #fff; + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 12px; + font-style: normal; + font-weight: 400; + } } .tb-map-sidebar { .tb-layers, .tb-groups { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index a6b91d9008..16eb3ddd58 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -33,7 +33,7 @@ import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreAr import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; -import { map, switchMap, tap } from 'rxjs/operators'; +import { switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, @@ -106,7 +106,11 @@ export abstract class TbMap { private tooltipInstances: TooltipInstancesData[] = []; private currentPopover: TbPopoverComponent; - private isAddingItem = false; + private currentAddButton: L.TB.ToolbarButton; + + private get isPlacingItem(): boolean { + return !!this.currentAddButton; + } protected constructor(protected ctx: WidgetContext, protected inputSettings: DeepPartial, @@ -283,7 +287,7 @@ export abstract class TbMap { }); if (this.dataLayers.some(dl => dl.isEditable())) { - this.map.pm.setGlobalOptions({ snappable: false } as L.PM.GlobalOptions); + this.map.pm.setGlobalOptions({ snappable: false }); this.map.pm.applyGlobalOptions(); } @@ -358,13 +362,14 @@ export abstract class TbMap { private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addMarkerDataLayers, (entity) => { + this.map.pm.setLang('en', { + tooltips: { + placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint', {entityName: entity.entity.entityDisplayName}) + } + }, 'en'); this.map.pm.enableDraw('Marker'); - const hintText = this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint', {entityName: entity.entity.entityDisplayName}); // @ts-ignore - this.map.pm.Draw.Marker._hintMarker.setTooltipContent(hintText); - }, - (entity, layer) => { - (entity.dataLayer as TbMarkersDataLayer).saveMarkerLocation(entity.entity, (layer as L.Marker).getLatLng()); + L.DomUtil.addClass(this.map.pm.Draw.Marker._hintMarker.getTooltip()._container, 'tb-place-item-label'); } ); } @@ -372,9 +377,15 @@ export abstract class TbMap { private drawRectangle(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addPolygonDataLayers, (entity) => { - - }, - (entity, layer) => { + this.map.pm.setLang('en', { + tooltips: { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint', {entityName: entity.entity.entityDisplayName}), + finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint', {entityName: entity.entity.entityDisplayName}) + } + }, 'en'); + this.map.pm.enableDraw('Rectangle'); + // @ts-ignore + L.DomUtil.addClass(this.map.pm.Draw.Rectangle._hintMarker.getTooltip()._container, 'tb-place-item-label'); } ); } @@ -382,9 +393,16 @@ export abstract class TbMap { private drawPolygon(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addPolygonDataLayers, (entity) => { - - }, - (entity, layer) => { + this.map.pm.setLang('en', { + tooltips: { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint', {entityName: entity.entity.entityDisplayName}), + continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint', {entityName: entity.entity.entityDisplayName}), + finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint', {entityName: entity.entity.entityDisplayName}) + } + }, 'en'); + this.map.pm.enableDraw('Polygon'); + // @ts-ignore + L.DomUtil.addClass(this.map.pm.Draw.Polygon._hintMarker.getTooltip()._container, 'tb-place-item-label'); } ); } @@ -392,22 +410,25 @@ export abstract class TbMap { private drawCircle(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addCircleDataLayers, (entity) => { - - }, - (entity, layer) => { + this.map.pm.setLang('en', { + tooltips: { + startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint', {entityName: entity.entity.entityDisplayName}), + finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint', {entityName: entity.entity.entityDisplayName}), + } + }, 'en'); + this.map.pm.enableDraw('Circle'); + // @ts-ignore + L.DomUtil.addClass(this.map.pm.Draw.Circle._hintMarker.getTooltip()._container, 'tb-place-item-label'); } ); } private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbMapDataLayer[], - prepareDrawMode: (entity: UnplacedMapDataItem) => void, - saveLocation: (entity: UnplacedMapDataItem, layer: L.Layer) => void): void { - if (this.isAddingItem) { + prepareDrawMode: (entity: UnplacedMapDataItem) => void): void { + if (this.isPlacingItem) { return; } - button.setActive(true); - this.deselectItem(false, true); - this.isAddingItem = true; + this.updatePlaceItemState(button); const items = mergeUnplacedDataItemsArrays(dataLayers.filter(dl => dl.isEnabled()).map(dl => dl.getUnplacedItems())).sort((entity1, entity2) => { return entity1.entity.entityDisplayName.localeCompare(entity2.entity.entityDisplayName); }); @@ -418,23 +439,22 @@ export abstract class TbMap { this.map.off('pm:create'); this.map.pm.disableDraw(); this.dataLayers.forEach(dl => dl.enableEditMode()); - button.setActive(false); - this.isAddingItem = false; + this.updatePlaceItemState(); this.editToolbar.close(); }; this.map.once('pm:create', (e) => { - saveLocation(entity, e.layer); + entity.dataLayer.placeItem(entity, e.layer); // @ts-ignore e.layer._pmTempLayer = true; e.layer.remove(); finishAdd(); }); - this.dataLayers.forEach(dl => dl.disableEditMode()); - prepareDrawMode(entity); + this.dataLayers.forEach(dl => dl.disableEditMode()); + this.editToolbar.open([ { id: 'cancel', @@ -445,8 +465,7 @@ export abstract class TbMap { } ], false); } else { - button.setActive(false); - this.isAddingItem = false; + this.updatePlaceItemState(); } }); } @@ -478,6 +497,17 @@ export abstract class TbMap { } } + private updatePlaceItemState(addButton?: L.TB.ToolbarButton): void { + if (addButton) { + this.deselectItem(false, true); + addButton.setActive(true); + } else if (this.currentAddButton) { + this.currentAddButton.setActive(false); + } + this.currentAddButton = addButton; + this.updateAddButtonsStates(); + } + private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { import('tooltipster').then(() => { let tooltipData = this.tooltipInstances.find(d => d.root === root); @@ -562,7 +592,7 @@ export abstract class TbMap { const mapBounds = this.map.getBounds(); if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || !this.bounds.equals(bounds) && this.settings.fitMapBounds && !mapBounds.contains(bounds))) { this.bounds = bounds; - if (!this.ignoreUpdateBounds && !this.isAddingItem) { + if (!this.ignoreUpdateBounds && !this.isPlacingItem) { this.fitBounds(bounds); } } @@ -592,17 +622,32 @@ export abstract class TbMap { } private updateAddButtonsStates() { - if (this.addMarkerButton) { - this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); - } - if (this.addRectangleButton) { - this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); - } - if (this.addPolygonButton) { - this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); - } - if (this.addCircleButton) { - this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + if (this.currentAddButton) { + if (this.addMarkerButton && this.addMarkerButton !== this.currentAddButton) { + this.addMarkerButton.setDisabled(true); + } + if (this.addRectangleButton && this.addRectangleButton !== this.currentAddButton) { + this.addRectangleButton.setDisabled(true); + } + if (this.addPolygonButton && this.addPolygonButton !== this.currentAddButton) { + this.addPolygonButton.setDisabled(true); + } + if (this.addCircleButton && this.addCircleButton !== this.currentAddButton) { + this.addCircleButton.setDisabled(true); + } + } else { + if (this.addMarkerButton) { + this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + } + if (this.addRectangleButton) { + this.addRectangleButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + } + if (this.addPolygonButton) { + this.addPolygonButton.setDisabled(!this.addPolygonDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + } + if (this.addCircleButton) { + this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); + } } } @@ -700,7 +745,7 @@ export abstract class TbMap { } public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { - if (this.isAddingItem) { + if (this.isPlacingItem) { return false; } let deselected = true; @@ -727,10 +772,6 @@ export abstract class TbMap { return this.selectItem(null, cancel, force); } - public getSelectedDataItem(): TbDataLayerItem { - return this.selectedDataItem; - } - public getEditToolbar(): L.TB.BottomToolbarControl { return this.editToolbar; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9fa662c127..0a89ada264 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7732,11 +7732,16 @@ "remove-polygon-for": "Remove polygon for '{{entityName}}'", "cut": "Cut polygon area", "rotate": "Rotate polygon", - "firstVertex-cut": "Click to place first point", - "continueLine-cut": "Click to continue drawing", - "finishPoly-cut": "Click first marker to finish and save", "draw-rectangle": "Draw rectangle", - "draw-polygon": "Draw polygon" + "draw-polygon": "Draw polygon", + "polygon-place-first-point-cut-hint": "Click to place first point", + "continue-polygon-cut-hint": "Click to continue drawing", + "finish-polygon-cut-hint": "Click first marker to finish and save", + "polygon-place-first-point-hint": "Polygon for '{{entityName}}': click to place first point", + "continue-polygon-hint": "Polygon for '{{entityName}}': click to continue drawing", + "finish-polygon-hint": "Polygon for '{{entityName}}': click first marker to finish and save", + "rectangle-place-first-point-hint": "Rectangle for '{{entityName}}': click to place first point", + "finish-rectangle-hint": "Rectangle for '{{entityName}}': click to finish and save" }, "circle": { "circle-key": "Circle key", @@ -7747,7 +7752,9 @@ "remove-circle": "Remove circle", "edit": "Edit circle", "remove-circle-for": "Remove circle for '{{entityName}}'", - "draw-circle": "Draw circle" + "draw-circle": "Draw circle", + "place-circle-center-hint": "Circle for '{{entityName}}': click to place circle center", + "finish-circle-hint": "Circle for '{{entityName}}': click to finish and save circle" }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position" From 3c94c86098fb0ef1e4a66537c7b06cac58caba60 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 30 Jan 2025 16:48:15 +0200 Subject: [PATCH 030/127] UI: Map data layer click action --- .../json/system/widget_types/image_map.json | 4 +- .../data/json/system/widget_types/map.json | 4 +- ui-ngx/src/app/core/api/widget-api.models.ts | 2 +- .../lib/maps/data-layer/circles-data-layer.ts | 8 +-- .../lib/maps/data-layer/map-data-layer.ts | 11 ++-- .../lib/maps/data-layer/markers-data-layer.ts | 4 -- .../maps/data-layer/polygons-data-layer.ts | 4 -- .../home/components/widget/lib/maps/map.ts | 65 +++++-------------- .../widget/lib/maps/models/map.models.ts | 15 ++++- .../action-settings-button.component.html | 1 + .../map/map-data-layer-dialog.component.html | 12 ++++ .../map/map-data-layer-dialog.component.ts | 1 + .../assets/locale/locale.constant-en_US.json | 3 + 13 files changed, 63 insertions(+), 71 deletions(-) diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index 3330045721..27af2bc724 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -11,13 +11,13 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n markerClick: {\n name: 'widget-action.marker-click',\n multiple: false\n },\n polygonClick: {\n name: 'widget-action.polygon-click',\n multiple: false\n },\n circleClick: {\n name: 'widget-action.circle-click',\n multiple: false\n },\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"layers\":[],\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null},\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[{\"name\":\"testTag\",\"icon\":\"more_horiz\",\"useShowWidgetActionFunction\":null,\"showWidgetActionFunction\":\"return true;\",\"type\":\"custom\",\"customFunction\":\"console.log('It works!!!');\\n\\nconsole.log(entityName);\\n\\nconsole.log(additionalParams);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"f9b4925a-818c-15d2-6220-cf2f317bc7fe\"}]}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"image\",\"layers\":[],\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.2;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.3;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
Temperature: ${temperature} °C
See tooltip settings for details\",\"patternFunction\":null,\"trigger\":\"click\",\"autoclose\":true,\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.6;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 0.7;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1,\"positionFunction\":\"return {x: origXPos, y: origYPos};\",\"markerClustering\":{\"enable\":false,\"zoomOnClick\":true,\"maxZoom\":null,\"maxClusterRadius\":80,\"zoomAnimation\":true,\"showCoverageOnHover\":true,\"spiderfyOnMaxZoom\":false,\"chunkedLoad\":false,\"lazyLoad\":true,\"useClusterMarkerColorFunction\":false,\"clusterMarkerColorFunction\":null}}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"minZoomLevel\":16,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\",\"imageSource\":{\"sourceType\":\"image\",\"url\":\"tb-image;/api/images/system/image_map_system_widget_map_image.svg\",\"entityAliasId\":null,\"entityKey\":null}},\"title\":\"Image Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" }, "resources": [ { diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index e4aba10c61..8fecb89f70 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -11,13 +11,13 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n markerClick: {\n name: 'widget-action.marker-click',\n multiple: false\n },\n polygonClick: {\n name: 'widget-action.polygon-click',\n multiple: false\n },\n circleClick: {\n name: 'widget-action.circle-click',\n multiple: false\n },\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n tooltipAction: {\n name: 'widget-action.tooltip-tag-action',\n multiple: true\n }\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", "hasBasicMode": true, "basicModeDirective": "tb-map-basic-config", - "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"imageSourceType\":null,\"imageUrl\":null,\"imageEntityAlias\":null,\"imageUrlAttribute\":null,\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[{\"name\":\"testTag\",\"icon\":\"more_horiz\",\"useShowWidgetActionFunction\":null,\"showWidgetActionFunction\":\"return true;\",\"type\":\"custom\",\"customFunction\":\"console.log('It works!!!');\\n\\nconsole.log(entityName);\\n\\nconsole.log(additionalParams);\",\"openInSeparateDialog\":false,\"openInPopover\":false,\"id\":\"f9b4925a-818c-15d2-6220-cf2f317bc7fe\"}]}}" + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"markers\":[{\"dsType\":\"function\",\"dsLabel\":\"First point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8239425680406081,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\"},\"markerType\":\"shape\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"function\",\"color\":\"#307FE5\",\"colorFunction\":\"var temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix('blue', 'red', percent).toHexString();\\n}\\nreturn 'blue';\\n\"}},\"markerIcon\":{\"icon\":\"mdi:lightbulb-on\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"image\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xOTEuMzUgLTM1MS4xOCAxMDgzLjU4IDE3MzAuNDYiPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjZmU3NTY5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMzciIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTM1MS44MzMgMTM2MC43OGMtMzguNzY2LTE5MC4zLTEwNy4xMTYtMzQ4LjY2NS0xODkuOTAzLTQ5NS40NEMxMDAuNTIzIDc1Ni40NjkgMjkuMzg2IDY1NS45NzgtMzYuNDM0IDU1MC40MDRjLTIxLjk3Mi0zNS4yNDQtNDAuOTM0LTcyLjQ3Ny02Mi4wNDctMTA5LjA1NC00Mi4yMTYtNzMuMTM3LTc2LjQ0NC0xNTcuOTM1LTc0LjI2OS0yNjcuOTMyIDIuMTI1LTEwNy40NzMgMzMuMjA4LTE5My42ODUgNzguMDMtMjY0LjE3M0MtMjEtMjA2LjY5IDEwMi40ODEtMzAxLjc0NSAyNjguMTY0LTMyNi43MjRjMTM1LjQ2Ni0yMC40MjUgMjYyLjQ3NSAxNC4wODIgMzUyLjU0MyA2Ni43NDcgNzMuNiA0My4wMzggMTMwLjU5NiAxMDAuNTI4IDE3My45MiAxNjguMjggNDUuMjIgNzAuNzE2IDc2LjM2IDE1NC4yNiA3OC45NzEgMjYzLjIzMyAxLjMzNyA1NS44My03LjgwNSAxMDcuNTMyLTIwLjY4NCAxNTAuNDE3LTEzLjAzNCA0My40MS0zMy45OTYgNzkuNjk1LTUyLjY0NiAxMTguNDU1LTM2LjQwNiA3NS42NTktODIuMDQ5IDE0NC45ODEtMTI3Ljg1NSAyMTQuMzQ1LTEzNi40MzcgMjA2LjYwNi0yNjQuNDk2IDQxNy4zMS0zMjAuNTggNzA2LjAyOHoiLz48Y2lyY2xlIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBjeD0iMzUyLjg5MSIgY3k9IjIyNS43NzkiIHI9IjE4My4zMzIiLz48L3N2Zz4=\",\"imageSize\":34},\"markerOffsetX\":0.5,\"markerOffsetY\":1},{\"dsType\":\"function\",\"dsLabel\":\"Second point\",\"dsDeviceId\":null,\"dsEntityAliasId\":null,\"dsFilterId\":null,\"additionalDataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7826299113906372,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"label\":{\"show\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}\"},\"tooltip\":{\"show\":true,\"trigger\":\"click\",\"autoclose\":true,\"type\":\"pattern\",\"pattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See tooltip settings for details\",\"offsetX\":0,\"offsetY\":-1},\"groups\":null,\"xKey\":{\"name\":\"f(x)\",\"label\":\"latitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"yKey\":{\"name\":\"f(x)\",\"label\":\"longitude\",\"type\":\"function\",\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 500 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\",\"settings\":{},\"color\":\"#2196f3\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},\"markerType\":\"image\",\"markerShape\":{\"shape\":\"markerShape1\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerIcon\":{\"icon\":\"\",\"size\":34,\"color\":{\"type\":\"constant\",\"color\":\"#307FE5\"}},\"markerImage\":{\"type\":\"function\",\"image\":\"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzNCIgaGVpZ2h0PSIzNCIgdmlld0JveD0iMCAwIDM0IDM0IiBmaWxsPSJub25lIj4KICA8ZyBmaWx0ZXI9InVybCgjZmlsdGVyMF9iZl84ODE2XzI2Mzg4NykiPgogICAgPHBhdGggZD0iTTE5IDI0LjVDMTcuNDA3NSAyNy40MTI1IDE3IDMzIDE3IDMzQzE3IDMzIDI3LjA4NTggMzIuMTk1NSAzMC45OTkyIDI3LjQ5OThDMzQgMjMuODk5MiAzMS45OTkyIDE5IDI3Ljk5OTIgMTlDMjMuOTk5MyAxOSAyMS4xOTI5IDIwLjQ4OTQgMTkgMjQuNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMjQiLz4KICA8L2c+CiAgPG1hc2sgaWQ9InBhdGgtMi1pbnNpZGUtMV84ODE2XzI2Mzg4NyIgZmlsbD0id2hpdGUiPgogICAgPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yOCAxMS45QzI4LjAwMzcgNS4zMjc2MSAyNC4yOTAyIDAgMTcgMEM5LjcwOTgzIDAgNS45OTYzIDUuMzI3NjEgNiAxMS45QzYuMDA0NzMgMjAuMjkzNyAxNyAzNCAxNyAzNEMxNyAzNCAyNy45OTUzIDIwLjI5MzcgMjggMTEuOVpNMjEuMjUgMTAuNjI1QzIxLjI1IDEyLjk3MjIgMTkuMzQ3MiAxNC44NzUgMTcgMTQuODc1QzE0LjY1MjggMTQuODc1IDEyLjc1IDEyLjk3MjIgMTIuNzUgMTAuNjI1QzEyLjc1IDguMjc3NzkgMTQuNjUyOCA2LjM3NSAxNyA2LjM3NUMxOS4zNDcyIDYuMzc1IDIxLjI1IDguMjc3NzkgMjEuMjUgMTAuNjI1WiIvPgogIDwvbWFzaz4KICA8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTI4IDExLjlDMjguMDAzNyA1LjMyNzYxIDI0LjI5MDIgMCAxNyAwQzkuNzA5ODMgMCA1Ljk5NjMgNS4zMjc2MSA2IDExLjlDNi4wMDQ3MyAyMC4yOTM3IDE3IDM0IDE3IDM0QzE3IDM0IDI3Ljk5NTMgMjAuMjkzNyAyOCAxMS45Wk0yMS4yNSAxMC42MjVDMjEuMjUgMTIuOTcyMiAxOS4zNDcyIDE0Ljg3NSAxNyAxNC44NzVDMTQuNjUyOCAxNC44NzUgMTIuNzUgMTIuOTcyMiAxMi43NSAxMC42MjVDMTIuNzUgOC4yNzc3OSAxNC42NTI4IDYuMzc1IDE3IDYuMzc1QzE5LjM0NzIgNi4zNzUgMjEuMjUgOC4yNzc3OSAyMS4yNSAxMC42MjVaIiBmaWxsPSIjMzA3ZmU1Ii8+CiAgPHBhdGggZD0iTTI4IDExLjlMMjkuMDYyNSAxMS45MDA2TDI4IDExLjlaTTYgMTEuOUw3LjA2MjUgMTEuODk5NEw2IDExLjlaTTE3IDM0TDE2LjE3MTIgMzQuNjY0OUwxNyAzNS42OThMMTcuODI4OCAzNC42NjQ5TDE3IDM0Wk0xNyAxLjA2MjVDMjAuMzY0MSAxLjA2MjUgMjIuODA4NSAyLjI4MDA2IDI0LjQyNzMgNC4xNzUzOUMyNi4wNjUyIDYuMDkzMjMgMjYuOTM5MiA4LjgwMzMxIDI2LjkzNzUgMTEuODk5NEwyOS4wNjI1IDExLjkwMDZDMjkuMDY0NCA4LjQyNDMgMjguMDgzNSA1LjE4NDM4IDI2LjA0MzEgMi43OTUzMkMyMy45ODM1IDAuMzgzNzQyIDIwLjkyNjEgLTEuMDYyNSAxNyAtMS4wNjI1VjEuMDYyNVpNNy4wNjI1IDExLjg5OTRDNy4wNjA3NiA4LjgwMzMxIDcuOTM0NzcgNi4wOTMyMyA5LjU3Mjc0IDQuMTc1MzlDMTEuMTkxNSAyLjI4MDA2IDEzLjYzNTkgMS4wNjI1IDE3IDEuMDYyNVYtMS4wNjI1QzEzLjA3MzkgLTEuMDYyNSAxMC4wMTY1IDAuMzgzNzQxIDcuOTU2ODYgMi43OTUzMkM1LjkxNjQ1IDUuMTg0MzggNC45MzU1NSA4LjQyNDMgNC45Mzc1IDExLjkwMDZMNy4wNjI1IDExLjg5OTRaTTE3IDM0QzE3LjgyODggMzMuMzM1MSAxNy44Mjg4IDMzLjMzNTIgMTcuODI4OCAzMy4zMzUyQzE3LjgyODggMzMuMzM1MiAxNy44Mjg4IDMzLjMzNTEgMTcuODI4NyAzMy4zMzVDMTcuODI4NSAzMy4zMzQ4IDE3LjgyODEgMzMuMzM0MyAxNy44Mjc2IDMzLjMzMzZDMTcuODI2NSAzMy4zMzIzIDE3LjgyNDggMzMuMzMwMSAxNy44MjI0IDMzLjMyNzFDMTcuODE3NiAzMy4zMjEyIDE3LjgxMDMgMzMuMzEyIDE3LjgwMDQgMzMuMjk5NUMxNy43ODA3IDMzLjI3NDcgMTcuNzUwOSAzMy4yMzcxIDE3LjcxMTggMzMuMTg3NEMxNy42MzM1IDMzLjA4NzggMTcuNTE3OCAzMi45Mzk1IDE3LjM3IDMyLjc0NzJDMTcuMDc0MiAzMi4zNjI0IDE2LjY1MDMgMzEuODAxNyAxNi4xNDA5IDMxLjEwMTlDMTUuMTIxNCAyOS43MDE0IDEzLjc2MzQgMjcuNzQ5NSAxMi40MDcxIDI1LjU0MTVDMTEuMDQ5IDIzLjMzMDYgOS43MDM4OCAyMC44ODEzIDguNzAwOTEgMTguNDg0MUM3LjY5MTA5IDE2LjA3MDYgNy4wNjM1NyAxMy43OTIxIDcuMDYyNSAxMS44OTk0TDQuOTM3NSAxMS45MDA2QzQuOTM4OCAxNC4yMDQ4IDUuNjg3NDYgMTYuNzg3MyA2Ljc0MDU4IDE5LjMwNDNDNy44MDA1NSAyMS44Mzc4IDkuMjA1MTYgMjQuMzg4OSAxMC41OTY0IDI2LjY1MzhDMTEuOTg5NSAyOC45MjE2IDEzLjM4MDcgMzAuOTIwOSAxNC40MjI5IDMyLjM1MjZDMTQuOTQ0NCAzMy4wNjg5IDE1LjM3OTUgMzMuNjQ0NiAxNS42ODUxIDM0LjA0MjJDMTUuODM3OSAzNC4yNDEgMTUuOTU4NCAzNC4zOTU0IDE2LjA0MTIgMzQuNTAwN0MxNi4wODI2IDM0LjU1MzQgMTYuMTE0NiAzNC41OTM4IDE2LjEzNjUgMzQuNjIxM0MxNi4xNDc0IDM0LjYzNTEgMTYuMTU1OSAzNC42NDU2IDE2LjE2MTcgMzQuNjUyOUMxNi4xNjQ2IDM0LjY1NjYgMTYuMTY2OCAzNC42NTk0IDE2LjE2ODQgMzQuNjYxNEMxNi4xNjkyIDM0LjY2MjQgMTYuMTY5OSAzNC42NjMyIDE2LjE3MDMgMzQuNjYzN0MxNi4xNzA2IDM0LjY2NCAxNi4xNzA4IDM0LjY2NDMgMTYuMTcwOSAzNC42NjQ1QzE2LjE3MTEgMzQuNjY0NyAxNi4xNzEyIDM0LjY2NDkgMTcgMzRaTTI2LjkzNzUgMTEuODk5NEMyNi45MzY0IDEzLjc5MjEgMjYuMzA4OSAxNi4wNzA2IDI1LjI5OTEgMTguNDg0MUMyNC4yOTYxIDIwLjg4MTMgMjIuOTUxIDIzLjMzMDYgMjEuNTkyOSAyNS41NDE1QzIwLjIzNjYgMjcuNzQ5NSAxOC44Nzg2IDI5LjcwMTQgMTcuODU5MSAzMS4xMDE5QzE3LjM0OTcgMzEuODAxNyAxNi45MjU4IDMyLjM2MjQgMTYuNjMgMzIuNzQ3MkMxNi40ODIyIDMyLjkzOTUgMTYuMzY2NSAzMy4wODc4IDE2LjI4ODIgMzMuMTg3NEMxNi4yNDkxIDMzLjIzNzEgMTYuMjE5MyAzMy4yNzQ3IDE2LjE5OTYgMzMuMjk5NUMxNi4xODk3IDMzLjMxMiAxNi4xODI0IDMzLjMyMTIgMTYuMTc3NiAzMy4zMjcxQzE2LjE3NTIgMzMuMzMwMSAxNi4xNzM1IDMzLjMzMjMgMTYuMTcyNCAzMy4zMzM2QzE2LjE3MTkgMzMuMzM0MyAxNi4xNzE1IDMzLjMzNDggMTYuMTcxMyAzMy4zMzVDMTYuMTcxMiAzMy4zMzUxIDE2LjE3MTIgMzMuMzM1MiAxNi4xNzEyIDMzLjMzNTJDMTYuMTcxMiAzMy4zMzUyIDE2LjE3MTIgMzMuMzM1MSAxNyAzNEMxNy44Mjg4IDM0LjY2NDkgMTcuODI4OSAzNC42NjQ3IDE3LjgyOTEgMzQuNjY0NUMxNy44MjkyIDM0LjY2NDMgMTcuODI5NCAzNC42NjQgMTcuODI5NyAzNC42NjM3QzE3LjgzMDEgMzQuNjYzMiAxNy44MzA4IDM0LjY2MjQgMTcuODMxNiAzNC42NjE0QzE3LjgzMzIgMzQuNjU5NCAxNy44MzU0IDM0LjY1NjYgMTcuODM4MyAzNC42NTI5QzE3Ljg0NDEgMzQuNjQ1NiAxNy44NTI2IDM0LjYzNTEgMTcuODYzNSAzNC42MjEzQzE3Ljg4NTQgMzQuNTkzOCAxNy45MTc0IDM0LjU1MzQgMTcuOTU4OCAzNC41MDA3QzE4LjA0MTYgMzQuMzk1NCAxOC4xNjIxIDM0LjI0MSAxOC4zMTQ5IDM0LjA0MjJDMTguNjIwNSAzMy42NDQ2IDE5LjA1NTYgMzMuMDY4OSAxOS41NzcxIDMyLjM1MjZDMjAuNjE5MyAzMC45MjA5IDIyLjAxMDUgMjguOTIxNiAyMy40MDM2IDI2LjY1MzhDMjQuNzk0OCAyNC4zODg5IDI2LjE5OTUgMjEuODM3OCAyNy4yNTk0IDE5LjMwNDNDMjguMzEyNSAxNi43ODczIDI5LjA2MTIgMTQuMjA0OCAyOS4wNjI1IDExLjkwMDZMMjYuOTM3NSAxMS44OTk0Wk0xNyAxNS45Mzc1QzE5LjkzNCAxNS45Mzc1IDIyLjMxMjUgMTMuNTU5IDIyLjMxMjUgMTAuNjI1SDIwLjE4NzVDMjAuMTg3NSAxMi4zODU0IDE4Ljc2MDQgMTMuODEyNSAxNyAxMy44MTI1VjE1LjkzNzVaTTExLjY4NzUgIDEwLjYyNUMxMS42ODc1IDEzLjU1OSAxNC4wNjYgMTUuOTM3NSAxNyAxNS45Mzc1VjEzLjgxMjVDMTUuMjM5NiAxMy44MTI1IDEzLjgxMjUgMTIuMzg1NCAxMy44MTI1IDEwLjYyNUgxMS42ODc1Wk0xNyA1LjMxMjVDMTQuMDY2IDUuMzEyNSAxMS42ODc1IDcuNjkwOTkgMTEuNjg3NSAxMC42MjVIMTMuODEyNUMxMy44MTI1IDguODY0NTkgMTUuMjM5NiA3LjQzNzUgMTcgNy40Mzc1VjUuMzEyNVpNMjIuMzEyNSAxMC42MjVDMjIuMzEyNSA3LjY5MDk5IDE5LjkzNCA1LjMxMjUgMTcgNS4zMTI1VjcuNDM3NUMxOC43NjA0IDcuNDM3NSAyMC4xODc1IDguODY0NTkgMjAuMTg3NSAxMC42MjVIMjIuMzEyNVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuMzgiIG1hc2s9InVybCgjcGF0aC0yLWluc2lkZS0xXzg4MTZfMjYzODg3KSIvPgogIDxkZWZzPgogICAgPGZpbHRlciBpZD0iZmlsdGVyMF9iZl84ODE2XzI2Mzg4NyIgeD0iMTIuNzUiIHk9IjE0Ljc1IiB3aWR0aD0iMjMuOTQzNCIgaGVpZ2h0PSIyMi41IiBmaWx0ZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIGNvbG9yLWludGVycG9sYXRpb24tZmlsdGVycz0ic1JHQiI+CiAgICAgIDxmZUZsb29kIGZsb29kLW9wYWNpdHk9IjAiIHJlc3VsdD0iQmFja2dyb3VuZEltYWdlRml4Ii8+CiAgICAgIDxmZUdhdXNzaWFuQmx1ciBpbj0iQmFja2dyb3VuZEltYWdlRml4IiBzdGREZXZpYXRpb249IjIuMTI1Ii8+CiAgICAgIDxmZUNvbXBvc2l0ZSBpbjI9IlNvdXJjZUFscGhhIiBvcGVyYXRvcj0iaW4iIHJlc3VsdD0iZWZmZWN0MV9iYWNrZ3JvdW5kQmx1cl84ODE2XzI2Mzg4NyIvPgogICAgICA8ZmVCbGVuZCBtb2RlPSJub3JtYWwiIGluPSJTb3VyY2VHcmFwaGljIiBpbjI9ImVmZmVjdDFfYmFja2dyb3VuZEJsdXJfODgxNl8yNjM4ODciIHJlc3VsdD0ic2hhcGUiLz4KICAgICAgPGZlR2F1c3NpYW5CbHVyIHN0ZERldmlhdGlvbj0iMC41IiByZXN1bHQ9ImVmZmVjdDJfZm9yZWdyb3VuZEJsdXJfODgxNl8yNjM4ODciLz4KICAgIDwvZmlsdGVyPgogIDwvZGVmcz4KPC9zdmc+\",\"imageSize\":34,\"imageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data.temperature;\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.min(3, Math.floor(4 * percent));\\n res.url = images[index];\\n}\\nreturn res;\\n\",\"images\":[\"tb-image;/api/images/system/map_marker_image_0.png\",\"tb-image;/api/images/system/map_marker_image_1.png\",\"tb-image;/api/images/system/map_marker_image_2.png\",\"tb-image;/api/images/system/map_marker_image_3.png\"]},\"markerOffsetX\":0.5,\"markerOffsetY\":1}],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"minZoomLevel\":16,\"mapPageSize\":16384,\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\",\"imageSourceType\":null,\"imageUrl\":null,\"imageEntityAlias\":null,\"imageUrlAttribute\":null},\"title\":\"Map\",\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showTitleIcon\":false,\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"widgetCss\":\"\",\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\",\"titleFont\":null,\"titleColor\":null,\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\",\"titleIcon\":\"map\",\"iconColor\":\"#1F6BDD\",\"actions\":{\"tooltipAction\":[]}}" }, "resources": [ { diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index 82ac4e299c..10656b0c9b 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -93,7 +93,7 @@ export interface IWidgetUtils { export interface WidgetActionsApi { actionDescriptorsBySourceId: {[sourceId: string]: Array}; getActionDescriptors: (actionSourceId: string) => Array; - handleWidgetAction: ($event: Event, descriptor: WidgetActionDescriptor, + handleWidgetAction: ($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, additionalParams?: any, entityLabel?: string) => void; onWidgetAction: ($event: Event, action: WidgetAction) => void; elementClick: ($event: Event) => void; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index f7580b313d..c70ad98e8f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -16,7 +16,9 @@ import { CirclesDataLayerSettings, - defaultBaseCirclesDataLayerSettings, isJSON, TbCircleData, + defaultBaseCirclesDataLayerSettings, + isJSON, + TbCircleData, TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; import L from 'leaflet'; @@ -59,10 +61,6 @@ class TbCircleDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().circleClick(this, data.$datasource); - } - protected unbindLabel() { this.circle.unbindTooltip(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 393131e932..861c02a560 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -29,7 +29,7 @@ import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { FormattedData } from '@shared/models/widget.models'; +import { FormattedData, WidgetActionType } from '@shared/models/widget.models'; import { forkJoin, Observable, of } from 'rxjs'; import { createLabelFromPattern, @@ -64,7 +64,6 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]): void; - protected abstract addItemClass(clazz: string): void; protected abstract removeItemClass(clazz: string): void; @@ -103,6 +100,12 @@ export abstract class TbDataLayerItem { + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.data.$datasource); + }); + } } protected enableEdit(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index a466d1ec3f..fde9c97f1c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -91,10 +91,6 @@ class TbMarkerDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().markerClick(this, data.$datasource); - } - protected unbindLabel() { this.marker.unbindTooltip(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index b6ca13d555..b2dd4c94a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -64,10 +64,6 @@ class TbPolygonDataLayerItem extends TbDataLayerItem, _dsData: FormattedData[]): void { - this.dataLayer.getMap().polygonClick(this, data.$datasource); - } - protected unbindLabel() { this.polygonContainer.unbindTooltip(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 16eb3ddd58..58f042f475 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -20,8 +20,8 @@ import { DataKeyValuePair, MapActionHandler, MapType, - mergeUnplacedDataItemsArrays, mergeMapDatasources, + mergeUnplacedDataItemsArrays, parseCenterPosition, TbCircleData, TbMapDatasource, @@ -38,10 +38,11 @@ import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, TbDataLayerItem, - TbMapDataLayer, UnplacedMapDataItem, + TbMapDataLayer, + UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { FormattedData, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { FormattedData, WidgetAction, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; @@ -50,8 +51,6 @@ import { TbCirclesDataLayer } from '@home/components/widget/lib/maps/data-layer/ import { AttributeService } from '@core/http/attribute.service'; import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; -import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; -import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; import { TbPopoverService } from '@shared/components/popover.service'; import { SelectMapEntityPanelComponent @@ -61,6 +60,8 @@ import { createColorMarkerShapeURI, MarkerShape } from '@home/components/widget/ import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import tinycolor from 'tinycolor2'; +import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -99,9 +100,6 @@ export abstract class TbMap { private readonly mapResize$: ResizeObserver; private readonly tooltipActions: { [name: string]: MapActionHandler }; - private readonly markerClickActions: { [name: string]: MapActionHandler }; - private readonly polygonClickActions: { [name: string]: MapActionHandler }; - private readonly circleClickActions: { [name: string]: MapActionHandler }; private tooltipInstances: TooltipInstancesData[] = []; @@ -118,9 +116,6 @@ export abstract class TbMap { this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); this.tooltipActions = this.loadActions('tooltipAction'); - this.markerClickActions = this.loadActions('markerClick'); - this.polygonClickActions = this.loadActions('polygonClick'); - this.circleClickActions = this.loadActions('circleClick'); $(containerElement).empty(); $(containerElement).addClass('tb-map-layout'); @@ -698,6 +693,18 @@ export abstract class TbMap { this.updateAddButtonsStates(); } + public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } + const { entityId, entityName, entityLabel, entityType } = entityInfo; + this.ctx.actionsApi.handleWidgetAction($event, action, { + entityType, + id: entityId + }, entityName, null, entityLabel); + } + public tooltipElementClick(element: HTMLElement, action: string, datasource: TbMapDatasource): void { if (element && this.tooltipActions[action]) { element.onclick = ($event) => @@ -708,42 +715,6 @@ export abstract class TbMap { } } - public markerClick(marker: TbDataLayerItem, datasource: TbMapDatasource): void { - if (Object.keys(this.markerClickActions).length) { - marker.getLayer().on('click', (event: L.LeafletMouseEvent) => { - if (!marker.isEditing()) { - for (const action in this.markerClickActions) { - this.markerClickActions[action](event.originalEvent, datasource); - } - } - }); - } - } - - public polygonClick(polygon: TbDataLayerItem, datasource: TbMapDatasource): void { - if (Object.keys(this.polygonClickActions).length) { - polygon.getLayer().on('click', (event: L.LeafletMouseEvent) => { - if (!polygon.isEditing()) { - for (const action in this.polygonClickActions) { - this.polygonClickActions[action](event.originalEvent, datasource); - } - } - }); - } - } - - public circleClick(circle: TbDataLayerItem, datasource: TbMapDatasource): void { - if (Object.keys(this.circleClickActions).length) { - circle.getLayer().on('click', (event: L.LeafletMouseEvent) => { - if (!circle.isEditing()) { - for (const action in this.circleClickActions) { - this.circleClickActions[action](event.originalEvent, datasource); - } - } - }); - } - } - public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { if (this.isPlacingItem) { return false; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index aa343e6520..76b8566692 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -14,12 +14,19 @@ /// limitations under the License. /// -import { DataKey, Datasource, DatasourceType, FormattedData } from '@shared/models/widget.models'; +import { + DataKey, + Datasource, + DatasourceType, + FormattedData, + WidgetAction, + WidgetActionType +} from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; -import L, { LatLngExpression } from 'leaflet'; +import L from 'leaflet'; import { TbFunction } from '@shared/models/js-function.models'; import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -117,6 +124,7 @@ export interface MapDataLayerSettings extends MapDataSourceSettings { additionalDataKeys?: DataKey[]; label: DataLayerPatternSettings; tooltip: DataLayerTooltipSettings; + click: WidgetAction; groups?: string[]; edit: DataLayerEditSettings; } @@ -138,6 +146,9 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts index 8ed53fd95e..a4df5e9250 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts @@ -15,25 +15,12 @@ /// import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; -import { PageComponent } from '@shared/components/page.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; -import { Store } from '@ngrx/store'; -import { AppState } from '@core/core.state'; -import { merge } from 'rxjs'; -import { - DataToValueType, - GetValueAction, - getValueActions, - getValueActionTranslations, - GetValueSettings -} from '@shared/models/action-widget-settings.models'; -import { ValueType } from '@shared/models/constants'; -import { TargetDevice, WidgetAction, widgetType } from '@shared/models/widget.models'; -import { AttributeScope, DataKeyType, telemetryTypeTranslationsShort } from '@shared/models/telemetry/telemetry.models'; -import { IAliasController } from '@core/api/widget-api.models'; -import { WidgetService } from '@core/http/widget.service'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { WidgetAction, widgetType } from '@shared/models/widget.models'; import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'tb-widget-action-settings-panel', @@ -42,7 +29,7 @@ import { WidgetActionCallbacks } from '@home/components/widget/action/manage-wid styleUrls: ['./action-settings-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class WidgetActionSettingsPanelComponent extends PageComponent implements OnInit { +export class WidgetActionSettingsPanelComponent implements OnInit { @Input() widgetAction: WidgetAction; @@ -57,7 +44,14 @@ export class WidgetActionSettingsPanelComponent extends PageComponent implements callbacks: WidgetActionCallbacks; @Input() - popover: TbPopoverComponent; + @coerceBoolean() + withName = false; + + @Input() + actionNames: string[]; + + @Input() + applyTitle = this.translate.instant('action.apply'); @Output() widgetActionApplied = new EventEmitter(); @@ -65,8 +59,8 @@ export class WidgetActionSettingsPanelComponent extends PageComponent implements widgetActionFormGroup: UntypedFormGroup; constructor(private fb: UntypedFormBuilder, - protected store: Store) { - super(store); + private translate: TranslateService, + private popover: TbPopoverComponent) { } ngOnInit(): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts index 95ec0b1262..d860667d2a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts @@ -118,7 +118,6 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess ctx, {}, {}, {}, true); - widgetActionSettingsPanelPopover.tbComponentRef.instance.popover = widgetActionSettingsPanelPopover; widgetActionSettingsPanelPopover.tbComponentRef.instance.widgetActionApplied.subscribe((widgetAction) => { widgetActionSettingsPanelPopover.hide(); this.modelValue = widgetAction; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html index 362e8eac2e..643b36e32a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html @@ -16,6 +16,23 @@ -->
+
+
{{'widget-config.action-name' | translate}}*
+ + + + warning + + +
widget-config.action
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts index 6c515ad0a7..a7ad209b85 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts @@ -15,13 +15,13 @@ /// import { - ControlValueAccessor, + ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - Validator, + Validator, ValidatorFn, Validators } from '@angular/forms'; import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; @@ -46,6 +46,7 @@ import { CustomActionEditorCompleter, toCustomAction } from '@home/components/widget/lib/settings/common/action/custom-action.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; const stateDisplayTypes = ['normal', 'separateDialog', 'popover'] as const; type stateDisplayTypeTuple = typeof stateDisplayTypes; @@ -89,6 +90,13 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali @Input() callbacks: WidgetActionCallbacks; + @Input() + @coerceBoolean() + withName = false; + + @Input() + actionNames: string[]; + widgetActionTypes = widgetActionTypes; widgetActionTypeTranslations = widgetActionTypeTranslationMap; widgetActionType = WidgetActionType; @@ -147,6 +155,10 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali ngOnInit() { this.widgetActionFormGroup = this.fb.group({}); + if (this.withName) { + this.widgetActionFormGroup.addControl('name', + this.fb.control(null, [this.validateActionName(), Validators.required])); + } this.widgetActionFormGroup.addControl('type', this.fb.control(null, [Validators.required])); this.widgetActionFormGroup.get('type').valueChanges.pipe( @@ -162,6 +174,11 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali } writeValue(widgetAction?: WidgetAction): void { + if (this.withName) { + this.widgetActionFormGroup.patchValue({ + name: widgetAction?.name + }, {emitEvent: false}); + } this.widgetActionFormGroup.patchValue({ type: widgetAction?.type }, {emitEvent: false}); @@ -457,6 +474,24 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali return res; } + private validateActionName(): ValidatorFn { + return (c: FormControl) => { + const newName = c.value; + const valid = this.checkActionName(newName); + return !valid ? { + actionNameNotUnique: true + } : null; + }; + } + + private checkActionName(name: string): boolean { + let actionNameIsUnique = true; + if (this.actionNames?.length) { + actionNameIsUnique = !this.actionNames.includes(name); + } + return actionNameIsUnique; + } + private widgetActionUpdated() { const type: WidgetActionType = this.widgetActionFormGroup.get('type').value; let result: WidgetAction; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html index 892558b047..b7d723b991 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -79,6 +79,10 @@
+ + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index ee00d92043..dd57a7896b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -34,6 +34,7 @@ import { DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap } from '@home/components/widget/lib/maps/models/map.models'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ selector: 'tb-data-layer-pattern-settings', @@ -74,6 +75,9 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc @coerceBoolean() hasTooltipOffset = false; + @Input() + context: MapSettingsContext; + private modelValue: DataLayerPatternSettings | DataLayerTooltipSettings; private propagateChange = null; @@ -100,6 +104,7 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc this.patternSettingsFormGroup.addControl('offsetX', this.fb.control(null, [])); this.patternSettingsFormGroup.addControl('offsetY', this.fb.control(null, [])); } + this.patternSettingsFormGroup.addControl('tagActions', this.fb.control(null, [])); } this.patternSettingsFormGroup.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index aaf04f5550..1845b4a793 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -244,10 +244,12 @@ @@ -259,7 +261,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 5244e7e266..9751e87621 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -18,12 +18,13 @@ import { Component, DestroyRef, Inject, ViewEncapsulation } from '@angular/core' import { DialogComponent } from '@shared/components/dialog.component'; import { CirclesDataLayerSettings, - DataLayerEditAction, dataLayerEditActions, + DataLayerEditAction, + dataLayerEditActions, dataLayerEditActionTranslationMap, defaultBaseMapDataLayerSettings, MapDataLayerSettings, MapDataLayerType, - MapType, mapZoomActions, mapZoomActionTranslationMap, + MapType, MarkersDataLayerSettings, MarkerType, PolygonsDataLayerSettings, @@ -40,7 +41,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityType } from '@shared/models/entity-type.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; import { genNextLabelForDataKeys, mergeDeepIgnoreArray } from '@core/utils'; -import { MapProviders } from '@home/components/widget/lib/maps-legacy/map-models'; import { WidgetService } from '@core/http/widget.service'; export interface MapDataLayerDialogData { @@ -318,8 +318,4 @@ export class MapDataLayerDialogComponent extends DialogComponent +
+
widgets.maps.data-layer.tooltip-tag-actions
+
+ + +
+
+
+ + {{ action.name }} + +
+
+ + +
+
+
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss new file mode 100644 index 0000000000..bb18ee4012 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.scss @@ -0,0 +1,49 @@ +/** + * 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. + */ +.mat-mdc-chip.mat-mdc-standard-chip.tb-tag-action-chip { + overflow: hidden; + line-height: 20px; + height: 32px; + + &.mdc-evolution-chip--with-trailing-action { + .mdc-evolution-chip__action--primary { + padding-left: 4px; + padding-right: 12px; + } + } + + .tb-chip-labels { + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + padding: 2px 10px; + .tb-chip-label { + font-weight: normal; + font-size: 14px; + line-height: 20px; + &.tb-chip-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } + .mat-mdc-chip-remove.mat-mdc-icon-button { + color: inherit; + opacity: inherit; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts new file mode 100644 index 0000000000..fb3d690fe6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts @@ -0,0 +1,176 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + DestroyRef, + forwardRef, + Input, + OnInit, + Renderer2, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +import { + WidgetActionSettingsPanelComponent +} from '@home/components/widget/lib/settings/common/action/widget-action-settings-panel.component'; +import { MatButton } from '@angular/material/button'; +import { TranslateService } from '@ngx-translate/core'; +import { deepClone } from '@core/utils'; + +@Component({ + selector: 'tb-map-tooltip-tag-actions-panel', + templateUrl: './map-tooltip-tag-actions.component.html', + styleUrls: ['./map-tooltip-tag-actions.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapTooltipTagActionsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None +}) +export class MapTooltipTagActionsComponent implements ControlValueAccessor, OnInit { + + @Input() + disabled: boolean; + + @Input() + context: MapSettingsContext; + + actionsFormGroup: UntypedFormGroup; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private translate: TranslateService, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.actionsFormGroup = this.fb.group({ + actions: [null, []] + }); + this.actionsFormGroup.get('actions').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe( + (val) => this.propagateChange(val) + ); + } + + writeValue(actions?: WidgetAction[]): void { + this.actionsFormGroup.get('actions').patchValue(actions || [], {emitEvent: false}); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (this.disabled) { + this.actionsFormGroup.disable({emitEvent: false}); + } else { + this.actionsFormGroup.enable({emitEvent: false}); + } + } + + removeAction(index: number): void { + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value; + if (actions[index]) { + actions.splice(index, 1); + this.actionsFormGroup.get('actions').patchValue(actions); + } + } + + addAction($event: Event, matButton: MatButton): void { + if ($event) { + $event.stopPropagation(); + } + const action: WidgetAction = { + name: '', + type: WidgetActionType.doNothing + }; + const trigger = matButton._elementRef.nativeElement; + const actionNames = (this.actionsFormGroup.get('actions').value as WidgetAction[] || []).map(action => action.name); + this.openActionSettingsPopup(trigger, action, actionNames, true, (added) => { + if (added) { + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value || []; + actions.push(added); + this.actionsFormGroup.get('actions').patchValue(actions); + } + }); + } + + editAction($event: Event, matButton: MatButton, index: number): void { + if ($event) { + $event.stopPropagation(); + } + const actions: WidgetAction[] = this.actionsFormGroup.get('actions').value; + if (actions[index]) { + const action = deepClone(actions[index]); + const trigger = matButton._elementRef.nativeElement; + const actionNames = actions.filter((_action, current) => current !== index).map(action => action.name); + this.openActionSettingsPopup(trigger, action, actionNames, false, (updated) => { + if (updated) { + actions[index] = updated; + this.actionsFormGroup.get('actions').patchValue(actions); + } + }); + } + } + + private openActionSettingsPopup(trigger: Element, action: WidgetAction, actionNames: string[], isAdd: boolean, callback: (action?: WidgetAction) => void) { + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const title = this.translate.instant(isAdd ? 'widgets.maps.data-layer.add-tooltip-tag-action' : 'widgets.maps.data-layer.edit-tooltip-tag-action'); + const applyTitle = this.translate.instant(isAdd ? 'action.add' : 'action.apply'); + const ctx: any = { + widgetAction: action, + withName: true, + actionNames, + panelTitle: title, + applyTitle, + widgetType: widgetType.latest, + callbacks: this.context.callbacks + }; + const widgetActionSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, WidgetActionSettingsPanelComponent, + ['leftTopOnly', 'leftOnly', 'leftBottomOnly'], true, null, + ctx, + {}, + {}, {}, true); + widgetActionSettingsPanelPopover.tbComponentRef.instance.widgetActionApplied.subscribe((widgetAction) => { + widgetActionSettingsPanelPopover.hide(); + callback(widgetAction); + }); + } + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 8dd52cb1fe..351c203d3b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -233,6 +233,9 @@ import { import { ImageMapSourceSettingsComponent } from '@home/components/widget/lib/settings/common/map/image-map-source-settings.component'; +import { + MapTooltipTagActionsComponent +} from '@home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component'; @NgModule({ declarations: [ @@ -309,6 +312,7 @@ import { ImageMapSourceSettingsComponent, DataLayerColorSettingsComponent, DataLayerColorSettingsPanelComponent, + MapTooltipTagActionsComponent, DataLayerPatternSettingsComponent, MarkerShapeSettingsComponent, MarkerShapesComponent, diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 8b6558a11f..36d8966c6a 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -692,6 +692,7 @@ export interface CustomActionDescriptor { } export interface WidgetAction extends CustomActionDescriptor { + name?: string; type: WidgetActionType; targetDashboardId?: string; targetDashboardStateId?: string; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index b6085c2a40..bb1f184a45 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7667,6 +7667,10 @@ "tooltip-offset": "Tooltip offset", "tooltip-offset-horizontal": "Horizontal", "tooltip-offset-vertical": "Vertical", + "tooltip-tag-actions": "Tag actions", + "add-tooltip-tag-action": "Add tag action", + "edit-tooltip-tag-action": "Edit tag action", + "remove-tooltip-tag-action": "Remove tag action", "action-add": "Add", "action-edit": "Edit", "action-move": "Move", From 80e83e0ff9b4787a7525f45e244095de992d1c51 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 31 Jan 2025 19:32:18 +0200 Subject: [PATCH 034/127] UI: Maps top toolbar. --- .../widget/lib/maps/leaflet/leaflet-tb.ts | 76 ++++++++++++++++--- .../home/components/widget/lib/maps/map.scss | 2 +- .../home/components/widget/lib/maps/map.ts | 22 +++++- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 30 +++++++- 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 9418d3fcba..5994d25fca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -276,16 +276,49 @@ class GroupsControl extends SidebarPaneControl { } } -class ToolbarButton extends L.Control { +class TopToolbarButton { + private readonly button: JQuery; + private _onClick: (e: MouseEvent) => void; + + constructor(private readonly options: TB.TopToolbarButtonOptions) { + const iconElement = $('
'); + this.button = $("
") + .attr('class', 'tb-control-button tb-control-text-button') + .attr('href', '#') + .attr('role', 'button'); + this.button.append(iconElement); + this.button.append(`
${this.options.title}
`); + this.loadIcon(iconElement); + this.button.on('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + if (this._onClick) { + this._onClick(e.originalEvent); + } + }); + } + + onClick(onClick: (e: MouseEvent) => void): void { + this._onClick = onClick; + } + + private loadIcon(iconElement: JQuery) { + // this.options.icon + } + + getButtonElement(): JQuery { + return this.button; + } +} + +class ToolbarButton { private readonly id: string; private readonly button: JQuery; private active = false; private disabled = false; - constructor(options: TB.ToolbarButtonOptions) { - super(options); + constructor(private readonly options: TB.ToolbarButtonOptions) { this.id = options.id; - const buttonText = this.options.showText ? this.options.title : null; this.button = $("
") .attr('class', 'tb-control-button') @@ -346,6 +379,25 @@ class ToolbarButton extends L.Control { } } +class TopToolbarControl { + + private readonly toolbarElement: JQuery; + + constructor(private readonly options: TB.TopToolbarControlOptions) { + const controlContainer = $('.leaflet-control-container', options.mapElement); + this.toolbarElement = $('
'); + this.toolbarElement.appendTo(controlContainer); + } + + toolbarButton(options: TB.TopToolbarButtonOptions): TopToolbarButton { + const button = new TopToolbarButton(options); + const buttonContainer = $('
'); + button.getButtonElement().appendTo(buttonContainer); + buttonContainer.appendTo(this.toolbarElement); + return button; + } +} + class ToolbarControl extends L.Control { private buttonContainer: JQuery; @@ -372,15 +424,14 @@ class ToolbarControl extends L.Control { } -class BottomToolbarControl extends L.Control { +class BottomToolbarControl { private readonly buttonContainer: JQuery; private toolbarButtons: ToolbarButton[] = []; container: HTMLElement; - constructor(options: TB.BottomToolbarControlOptions) { - super(options); + constructor(private readonly options: TB.BottomToolbarControlOptions) { const controlContainer = $('.leaflet-control-container', options.mapElement); const toolbar = $('
'); toolbar.appendTo(controlContainer); @@ -389,10 +440,6 @@ class BottomToolbarControl extends L.Control { this.container = this.buttonContainer[0]; } - addTo(map: L.Map): this { - return this; - } - getButton(id: string): ToolbarButton { return this.toolbarButtons.find(b => b.getId() === id); } @@ -452,6 +499,10 @@ const groups = (options: TB.GroupsControlOptions): GroupsControl => { return new GroupsControl(options); } +const topToolbar = (options: TB.TopToolbarControlOptions): TopToolbarControl => { + return new TopToolbarControl(options); +} + const toolbar = (options: L.ControlOptions): ToolbarControl => { return new ToolbarControl(options); } @@ -515,6 +566,8 @@ L.TB = L.TB || { SidebarPaneControl, LayersControl, GroupsControl, + TopToolbarButton, + TopToolbarControl, ToolbarButton, ToolbarControl, BottomToolbarControl, @@ -522,6 +575,7 @@ L.TB = L.TB || { sidebarPane, layers, groups, + topToolbar, toolbar, bottomToolbar, TileLayer: { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 270732d25b..3cb3c2cb35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -141,7 +141,7 @@ } } } - .tb-map-bottom-toolbar { + .tb-map-bottom-toolbar, .tb-map-top-toolbar { left: 0; right: 0; display: flex; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 1e7165a85f..21518a699e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -85,6 +85,7 @@ export abstract class TbMap { protected sidebar: L.TB.SidebarControl; + protected customActionsToolbar: L.TB.TopToolbarControl; protected editToolbar: L.TB.BottomToolbarControl; protected addMarkerButton: L.TB.ToolbarButton; @@ -166,6 +167,7 @@ export abstract class TbMap { } this.setupDataLayers(); this.setupEditMode(); + this.setupCustomActions(); this.createdControlButtonTooltip(this.mapElement, ['topleft', 'bottomleft'].includes(this.settings.controlsPosition) ? 'right' : 'left'); } @@ -270,7 +272,7 @@ export abstract class TbMap { onClose: () => { return this.deselectItem(true); } - }).addTo(this.map); + }); this.map.on('click', () => { this.deselectItem(); @@ -349,6 +351,24 @@ export abstract class TbMap { } } + private setupCustomActions() { + this.customActionsToolbar = L.TB.topToolbar({ + mapElement: $(this.mapElement) + }); + + /*const customButton = this.customActionsToolbar.toolbarButton({ + title: 'Super button', + icon: 'add' + }); + this.customActionsToolbar.toolbarButton({ + title: 'Super button 2', + icon: 'add' + }); + customButton.onClick(e => { + console.log("Called!"); + });*/ + } + private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addMarkerDataLayers, (entity) => { diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index e24c632a3b..26084fabb5 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -89,7 +89,27 @@ declare module 'leaflet' { constructor(options: GroupsControlOptions); } - interface ToolbarButtonOptions extends ControlOptions { + interface TopToolbarButtonOptions { + icon: string; + iconColor?: string; + title: string; + } + + class TopToolbarButton { + constructor(options: TopToolbarButtonOptions); + onClick(onClick: (e: MouseEvent) => void): void; + } + + interface TopToolbarControlOptions { + mapElement: JQuery; + } + + class TopToolbarControl { + constructor(options: TopToolbarControlOptions); + toolbarButton(options: TopToolbarButtonOptions): TopToolbarButton; + } + + interface ToolbarButtonOptions { id: string; title: string; click: (e: MouseEvent, button: ToolbarButton) => void; @@ -97,7 +117,7 @@ declare module 'leaflet' { showText?: boolean; } - class ToolbarButton extends Control{ + class ToolbarButton { constructor(options: ToolbarButtonOptions); setActive(active: boolean): void; isActive(): boolean; @@ -110,13 +130,13 @@ declare module 'leaflet' { toolbarButton(options: ToolbarButtonOptions): ToolbarButton; } - interface BottomToolbarControlOptions extends ControlOptions { + interface BottomToolbarControlOptions { mapElement: JQuery; closeTitle: string; onClose: () => boolean; } - class BottomToolbarControl extends Control { + class BottomToolbarControl { constructor(options: BottomToolbarControlOptions); getButton(id: string): ToolbarButton | undefined; open(buttons: ToolbarButtonOptions[], showCloseButton?: boolean): void; @@ -132,6 +152,8 @@ declare module 'leaflet' { function groups(options: GroupsControlOptions): GroupsControl; + function topToolbar(options: TopToolbarControlOptions): TopToolbarControl; + function toolbar(options: ControlOptions): ToolbarControl; function bottomToolbar(options: BottomToolbarControlOptions): BottomToolbarControl; From 09ecfa8d602c6fad407934ca9a79002b10da8e42 Mon Sep 17 00:00:00 2001 From: deaflynx Date: Mon, 24 Feb 2025 15:41:43 +0200 Subject: [PATCH 035/127] UI: Mobile center page name add warnings 256 limit/only white spaces. --- .../default-mobile-page-panel.component.html | 16 ++++++++++++++++ .../default-mobile-page-panel.component.ts | 4 ++-- .../layout/mobile-page-item-row.component.html | 8 ++++++++ .../layout/mobile-page-item-row.component.ts | 2 +- 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html index 3ed1601a03..b6bd373eff 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html @@ -43,6 +43,22 @@ mobile.page-name + + warning + + + warning +
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts index f42cedd942..58c515afc4 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts @@ -17,7 +17,7 @@ import { Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { DefaultMobilePage, defaultMobilePageMap, hideDefaultMenuItems } from '@shared/models/mobile-app.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder } from '@angular/forms'; +import { FormBuilder, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ @@ -43,7 +43,7 @@ export class DefaultMobilePagePanelComponent implements OnInit { mobilePageFormGroup = this.fb.group({ visible: [true], icon: [''], - label: [''], + label: ['', [Validators.pattern(/\S/), Validators.maxLength(255)]], }); isCleanupEnabled = false; diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html index 6ece48086b..22cede48b6 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html @@ -50,6 +50,14 @@ class="tb-error"> warning + + warning + diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts index 7083d98860..eb83ae78d4 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts @@ -99,7 +99,7 @@ export class MobilePageItemRowComponent implements ControlValueAccessor, OnInit, mobilePageRowForm = this.fb.group({ visible: [true, []], icon: ['', []], - label: ['', [Validators.pattern(/\S/)]], + label: ['', [Validators.pattern(/\S/), Validators.maxLength(255)]], type: [MobilePageType.DEFAULT] }); From 7c14eaaaac8e8b4fa577ea2d2a69c81594fe17b7 Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Tue, 25 Feb 2025 13:49:41 +0200 Subject: [PATCH 036/127] PROD-5643: Pagination settings improvements --- ui-ngx/src/app/core/utils.ts | 13 ++++++++++ .../alarm/alarms-table-widget.component.ts | 22 +++++++++++++--- .../entity/entities-table-widget.component.ts | 24 +++++++++++++++--- .../lib/rpc/persistent-table.component.ts | 25 ++++++++++++++++--- ...larms-table-widget-settings.component.html | 22 ++++++++++++++-- .../alarms-table-widget-settings.component.ts | 20 +++++++++++++++ ...eries-table-widget-settings.component.html | 22 ++++++++++++++-- ...eseries-table-widget-settings.component.ts | 20 +++++++++++++++ ...stent-table-widget-settings.component.html | 18 ++++++++++++- ...sistent-table-widget-settings.component.ts | 21 ++++++++++++++++ ...ities-table-widget-settings.component.html | 22 ++++++++++++++-- ...ntities-table-widget-settings.component.ts | 20 +++++++++++++++ .../widget/lib/table-widget.models.ts | 2 ++ .../lib/timeseries-table-widget.component.ts | 22 +++++++++++++--- .../assets/locale/locale.constant-en_US.json | 2 ++ 15 files changed, 254 insertions(+), 21 deletions(-) diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index fed2de125f..d139fe58bb 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -31,6 +31,7 @@ import { isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models'; +import { UntypedFormGroup } from '@angular/forms'; const varsRegex = /\${([^}]*)}/g; @@ -926,3 +927,15 @@ export const unwrapModule = (module: any) : any => { return module; } }; + +export function buildPageStepSizeValues(formGroup: UntypedFormGroup, pageSteps: Array): void { + const pageStepCount = formGroup.get('pageStepCount')?.value; + const pageStepSize = formGroup.get('pageStepSize')?.value; + pageSteps.length = 0; + if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && + isDefinedAndNotNull(pageStepSize) && pageStepSize > 0) { + for (let i = 1; i <= pageStepCount; i++) { + pageSteps.push(pageStepSize * i); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 2b7bd7b2da..7455e08b15 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -185,7 +185,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, public enableStickyHeader = true; public enableStickyAction = false; public showCellActionsMenu = true; - public pageSizeOptions; + public pageSizeOptions = []; public pageLink: AlarmDataPageLink; public sortOrderProperty: string; public textSearchMode = false; @@ -213,7 +213,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, private allowClear = true; public allowAssign = true; - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = '-' + alarmFields.createdTime.value; private contentsInfo: {[key: string]: CellContentInfo} = {}; @@ -392,10 +392,26 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx'); const pageSize = this.settings.defaultPageSize; + let pageStepSize = this.settings.pageStepSize; + let pageStepCount = this.settings.pageStepCount; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepSize; + } + } else { + if (!this.defaultPageSize) { + this.defaultPageSize = 10; + } + pageStepSize = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepSize * i); + } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; const alarmFilter = this.entityService.resolveAlarmFilter(this.widgetConfig.alarmFilterConfig, false); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index e54153e360..e9e5c12f18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -42,7 +42,7 @@ import { import { IWidgetSubscription } from '@core/api/widget-api.models'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { deepClone, hashCode, isDefined, isNumber, isObject, isUndefined } from '@core/utils'; +import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNumber, isObject, isUndefined } from '@core/utils'; import cssjs from '@core/css/css'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @@ -139,7 +139,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni public enableStickyHeader = true; public enableStickyAction = true; public showCellActionsMenu = true; - public pageSizeOptions; + public pageSizeOptions = []; public pageLink: EntityDataPageLink; public sortOrderProperty: string; public textSearchMode = false; @@ -161,7 +161,7 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni private widgetResize$: ResizeObserver; private destroy$ = new Subject(); - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = 'entityName'; private contentsInfo: {[key: string]: CellContentInfo} = {}; @@ -311,10 +311,26 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx'); const pageSize = this.settings.defaultPageSize; + let pageStepSize = this.settings.pageStepSize; + let pageStepCount = this.settings.pageStepCount; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepSize; + } + } else { + if (!this.defaultPageSize) { + this.defaultPageSize = 10; + } + pageStepSize = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepSize * i); + } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; this.noDataDisplayMessageText = diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts index ba12957779..b795e927f6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -43,7 +43,7 @@ import { import cssjs from '@core/css/css'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { hashCode, isDefined, isNumber, parseHttpErrorMessage } from '@core/utils'; +import { hashCode, isDefined, isDefinedAndNotNull, isNumber, parseHttpErrorMessage } from '@core/utils'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { @@ -114,7 +114,7 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O private subscription: IWidgetSubscription; private enableFilterAction = true; private allowSendRequest = true; - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = '-createdTime'; private rpcStatusFilter: RpcStatus; private displayDetails = true; @@ -130,7 +130,7 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O public enableStickyHeader = true; public enableStickyAction = true; public pageLink: PageLink; - public pageSizeOptions; + public pageSizeOptions = []; public actionCellButtonAction: PersistentTableWidgetActionDescriptor[] = []; public displayedColumns: string[]; public hidePageSize = false; @@ -207,10 +207,27 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O this.displayedColumns = [...this.displayTableColumns]; const pageSize = this.settings.defaultPageSize; + let pageStepSize = this.settings.pageStepSize; + let pageStepCount = this.settings.pageStepCount; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepSize; + } + } else { + if (!this.defaultPageSize) { + this.defaultPageSize = 10; + } + pageStepSize = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepSize * i); + } + if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { this.defaultSortOrder = this.settings.defaultSortOrder; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html index b0ecdbeb74..fe4db349ca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html @@ -95,11 +95,29 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.default-page-size
+
widgets.table.page-step-size
- +
+
+
widgets.table.page-step-count
+ + + +
+ + widgets.table.default-page-size + + + {{ size }} + + +
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts index e3606b271a..1d6b8ab1c1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; @Component({ selector: 'tb-alarms-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent { alarmsTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -56,6 +58,8 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: true, displayPagination: true, defaultPageSize: 10, + pageStepSize: null, + pageStepCount: 3, defaultSortOrder: '-createdTime', useRowStyleFunction: false, rowStyleFunction: '' @@ -80,10 +84,20 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: [settings.displayActivity, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, + [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], + pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm, this.pageStepSizeValues); + } + + public onPaginationSettingsChange(): void { + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').reset(); + buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm, this.pageStepSizeValues); } protected validatorTriggers(): string[] { @@ -100,11 +114,17 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent } if (displayPagination) { this.alarmsTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').enable(); + this.alarmsTableWidgetSettingsForm.get('pageStepSize').enable(); } else { this.alarmsTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').disable(); + this.alarmsTableWidgetSettingsForm.get('pageStepSize').disable(); } this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); this.alarmsTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html index 70cc2b0224..540d3348d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html @@ -62,11 +62,29 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.default-page-size
+
widgets.table.page-step-size
- +
+
+
widgets.table.page-step-count
+ + + +
+ + widgets.table.default-page-size + + + {{ size }} + + +
widgets.table.table-tabs
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index e1f2975d92..fdc28f0f30 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; @Component({ selector: 'tb-timeseries-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsComponent { timeseriesTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -51,6 +53,8 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, useEntityLabel: false, defaultPageSize: 10, + pageStepSize: null, + pageStepCount: 3, hideEmptyLines: false, disableStickyHeader: false, useRowStyleFunction: false, @@ -77,11 +81,21 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: [settings.displayPagination, []], useEntityLabel: [settings.useEntityLabel, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, + [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], + pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], hideEmptyLines: [settings.hideEmptyLines, []], disableStickyHeader: [settings.disableStickyHeader, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm, this.pageStepSizeValues); + } + + public onPaginationSettingsChange(): void { + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').reset(); + buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm, this.pageStepSizeValues); } protected validatorTriggers(): string[] { @@ -98,11 +112,17 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon } if (displayPagination) { this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').enable(); + this.timeseriesTableWidgetSettingsForm.get('pageStepSize').enable(); } else { this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').disable(); + this.timeseriesTableWidgetSettingsForm.get('pageStepSize').disable(); } this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index 229cb21adc..46782364d8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -49,7 +49,23 @@ widgets.table.default-page-size - + + + {{ size }} + + + + +
+ + widgets.table.page-step-size + + + + widgets.table.page-step-count +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts index d4022f00e0..51cfa5652f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts @@ -27,6 +27,7 @@ import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { map, mergeMap, share, startWith } from 'rxjs/operators'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; +import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; interface DisplayColumn { name: string; @@ -57,6 +58,8 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon persistentTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; + filteredDisplayColumns: Observable>; columnSearchText = ''; @@ -94,6 +97,8 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, defaultPageSize: 10, + pageStepSize: null, + pageStepCount: 3, defaultSortOrder: '-createdTime', displayColumns: ['rpcId', 'messageType', 'status', 'method', 'createdTime', 'expirationTime'] @@ -110,9 +115,19 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayDetails: [settings.displayDetails, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, + [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], + pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], displayColumns: [settings.displayColumns, [Validators.required]] }); + buildPageStepSizeValues(this.persistentTableWidgetSettingsForm, this.pageStepSizeValues); + } + + public onPaginationSettingsChange(): void { + this.persistentTableWidgetSettingsForm.get('defaultPageSize').reset(); + buildPageStepSizeValues(this.persistentTableWidgetSettingsForm, this.pageStepSizeValues); } public validateSettings(): boolean { @@ -129,10 +144,16 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon const displayPagination: boolean = this.persistentTableWidgetSettingsForm.get('displayPagination').value; if (displayPagination) { this.persistentTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.persistentTableWidgetSettingsForm.get('pageStepCount').enable(); + this.persistentTableWidgetSettingsForm.get('pageStepSize').enable(); } else { this.persistentTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.persistentTableWidgetSettingsForm.get('pageStepCount').disable(); + this.persistentTableWidgetSettingsForm.get('pageStepSize').disable(); } this.persistentTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } private fetchColumns(searchText?: string): Observable> { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html index c3b5629a16..04c1f71c52 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html @@ -92,11 +92,29 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.default-page-size
+
widgets.table.page-step-size
- +
+
+
widgets.table.page-step-count
+ + + +
+ + widgets.table.default-page-size + + + {{ size }} + + +
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts index 892684cc7d..a2fc3ef9c7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts @@ -19,6 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; +import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; @Component({ selector: 'tb-entities-table-widget-settings', @@ -28,6 +29,7 @@ import { AppState } from '@core/core.state'; export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponent { entitiesTableWidgetSettingsForm: UntypedFormGroup; + pageStepSizeValues = []; constructor(protected store: Store, private fb: UntypedFormBuilder) { @@ -54,6 +56,8 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: true, displayPagination: true, defaultPageSize: 10, + pageStepSize: null, + pageStepCount: 3, defaultSortOrder: 'entityName', useRowStyleFunction: false, rowStyleFunction: '' @@ -76,10 +80,20 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: [settings.displayEntityType, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], + pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, + [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], + pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); + buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm, this.pageStepSizeValues); + } + + public onPaginationSettingsChange(): void { + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').reset(); + buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm, this.pageStepSizeValues); } protected validatorTriggers(): string[] { @@ -98,8 +112,12 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen } if (displayPagination) { this.entitiesTableWidgetSettingsForm.get('defaultPageSize').enable(); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').enable(); + this.entitiesTableWidgetSettingsForm.get('pageStepSize').enable(); } else { this.entitiesTableWidgetSettingsForm.get('defaultPageSize').disable(); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').disable(); + this.entitiesTableWidgetSettingsForm.get('pageStepSize').disable(); } if (displayEntityName) { this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').enable(); @@ -113,6 +131,8 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen } this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); this.entitiesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').updateValueAndValidity({emitEvent}); this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').updateValueAndValidity({emitEvent}); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 696570a04a..c4bdc91fa4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -47,6 +47,8 @@ export interface TableWidgetSettings { enableStickyHeader: boolean; displayPagination: boolean; defaultPageSize: number; + pageStepSize: number; + pageStepCount: number; useRowStyleFunction: boolean; rowStyleFunction?: TbFunction; reserveSpaceForHiddenAction?: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 7122bfc583..f9d02e27b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -173,7 +173,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI public enableStickyHeader = true; public enableStickyAction = true; public showCellActionsMenu = true; - public pageSizeOptions; + public pageSizeOptions = []; public textSearchMode = false; public hidePageSize = false; public sources: TimeseriesTableSource[]; @@ -192,7 +192,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI private latestData: Array; private datasources: Array; - private defaultPageSize = 10; + private defaultPageSize; private defaultSortOrder = '-0'; private hideEmptyLines = false; public showTimestamp = true; @@ -352,10 +352,26 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; + let pageStepSize = this.settings.pageStepSize; + let pageStepCount = this.settings.pageStepCount; if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - this.pageSizeOptions = [this.defaultPageSize, this.defaultPageSize * 2, this.defaultPageSize * 3]; + if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepSize; + } + } else { + if (!this.defaultPageSize) { + this.defaultPageSize = 10; + } + pageStepSize = this.defaultPageSize; + pageStepCount = 3; + } + + for (let i = 1; i <= pageStepCount; i++) { + this.pageSizeOptions.push(pageStepSize * i); + } this.noDataDisplayMessageText = noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widget.no-data-found', this.utils, this.translate); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 18801bda2c..d258b27d7f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -8111,6 +8111,8 @@ "display-timestamp": "Timestamp", "display-pagination": "Display pagination", "default-page-size": "Default page size", + "page-step-count": "Number of steps", + "page-step-size": "Page size increment", "use-entity-label-tab-name": "Use entity label in tab name", "hide-empty-lines": "Hide empty lines", "row-style": "Row style", From 400e74b00defd0f3854fafadfa98e0018735f51e Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Wed, 26 Feb 2025 16:28:46 +0200 Subject: [PATCH 037/127] Save attributes strategies: BE initial implementation --- .../rule_chains/edge_root_rule_chain.json | 5 +- .../device_profile/rule_chain_template.json | 5 +- .../tenant/rule_chains/root_rule_chain.json | 5 +- .../main/data/upgrade/basic/schema_update.sql | 30 +- .../DefaultTelemetrySubscriptionService.java | 12 +- ...faultTelemetrySubscriptionServiceTest.java | 96 ++- .../src/main/resources/root_rule_chain.json | 5 +- .../engine/api/AttributesSaveRequest.java | 17 +- .../engine/api/AttributesSaveRequestTest.java | 46 ++ .../engine/telemetry/TbMsgAttributesNode.java | 109 ++- .../TbMsgAttributesNodeConfiguration.java | 9 + .../engine/telemetry/TbMsgTimeseriesNode.java | 12 +- .../TbMsgTimeseriesNodeConfiguration.java | 62 +- .../AttributesProcessingSettings.java | 51 ++ .../settings/BaseProcessingSettings.java | 47 ++ .../TimeseriesProcessingSettings.java | 52 ++ .../TbMsgAttributesNodeConfigurationTest.java | 29 - .../telemetry/TbMsgAttributesNodeTest.java | 680 +++++++++++++++--- .../telemetry/TbMsgTimeseriesNodeTest.java | 20 +- 19 files changed, 1029 insertions(+), 263 deletions(-) create mode 100644 rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/AttributesProcessingSettings.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/BaseProcessingSettings.java create mode 100644 rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/TimeseriesProcessingSettings.java delete mode 100644 rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfigurationTest.java diff --git a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json index 6701b59e0e..9bb838963f 100644 --- a/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json +++ b/application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json @@ -50,8 +50,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json index 0f2473cde6..305dc04961 100644 --- a/application/src/main/data/json/tenant/device_profile/rule_chain_template.json +++ b/application/src/main/data/json/tenant/device_profile/rule_chain_template.json @@ -35,8 +35,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json index 8efda98c5b..a988c9d5eb 100644 --- a/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json +++ b/application/src/main/data/json/tenant/rule_chains/root_rule_chain.json @@ -34,8 +34,11 @@ }, "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Client Attributes", - "configurationVersion": 2, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2929635949..7a11f22202 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -62,4 +62,32 @@ $$; -- UPDATE SAVE TIME SERIES NODES END -ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; \ No newline at end of file +-- UPDATE SAVE ATTRIBUTES NODES START + +DO $$ + BEGIN + -- Check if the rule_node table exists + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = 'rule_node' + ) THEN + + UPDATE rule_node + SET configuration = ( + configuration::jsonb + || jsonb_build_object( + 'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE') + ) + )::text, + configuration_version = 3 + WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode' + AND configuration_version = 2; + + END IF; + END; +$$; + +-- UPDATE SAVE ATTRIBUTES NODES END + +ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 568d80787d..ace5247724 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -53,6 +53,7 @@ import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -165,9 +166,16 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void saveAttributesInternal(AttributesSaveRequest request) { log.trace("Executing saveInternal [{}]", request); - ListenableFuture> saveFuture = attrService.save(request.getTenantId(), request.getEntityId(), request.getScope(), request.getEntries()); + ListenableFuture> saveFuture; + if (request.getStrategy().saveAttributes()) { + saveFuture = attrService.save(request.getTenantId(), request.getEntityId(), request.getScope(), request.getEntries()); + } else { + saveFuture = Futures.immediateFuture(Collections.emptyList()); + } addMainCallback(saveFuture, request.getCallback()); - addWsCallback(saveFuture, success -> onAttributesUpdate(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getEntries(), request.isNotifyDevice())); + if (request.getStrategy().sendWsUpdate()) { + addWsCallback(saveFuture, success -> onAttributesUpdate(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getEntries(), request.isNotifyDevice())); + } } @Override diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index dbd61b638e..3fb5321499 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -29,11 +29,13 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; @@ -86,7 +88,7 @@ class DefaultTelemetrySubscriptionServiceTest { final long sampleTtl = 10_000L; - final List sampleTelemetry = List.of( + final List sampleTimeseries = List.of( new BasicTsKvEntry(100L, new DoubleDataEntry("temperature", 65.2)), new BasicTsKvEntry(100L, new DoubleDataEntry("humidity", 33.1)) ); @@ -147,9 +149,9 @@ class DefaultTelemetrySubscriptionServiceTest { lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi); - lenient().when(tsService.save(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(sampleTelemetry.size())); - lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(sampleTelemetry.size())); - lenient().when(tsService.saveLatest(tenantId, entityId, sampleTelemetry)).thenReturn(immediateFuture(listOfNNumbers(sampleTelemetry.size()))); + lenient().when(tsService.save(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(sampleTimeseries.size())); + lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(sampleTimeseries.size())); + lenient().when(tsService.saveLatest(tenantId, entityId, sampleTimeseries)).thenReturn(immediateFuture(listOfNNumbers(sampleTimeseries.size()))); // mock no entity views lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(Collections.emptyList())); @@ -171,7 +173,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(new TimeseriesSaveRequest.Strategy(true, false, false)) .callback(emptyCallback) @@ -181,7 +183,7 @@ class DefaultTelemetrySubscriptionServiceTest { telemetryService.saveTimeseries(request); // THEN - then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTelemetry.size()); + then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTimeseries.size()); } @Test @@ -191,7 +193,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) .callback(emptyCallback) @@ -214,7 +216,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(TimeseriesSaveRequest.Strategy.SAVE_ALL) .future(future) @@ -240,7 +242,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) .future(future) @@ -260,12 +262,12 @@ class DefaultTelemetrySubscriptionServiceTest { entityView.setTenantId(tenantId); entityView.setCustomerId(customerId); entityView.setEntityId(entityId); - entityView.setKeys(new TelemetryEntityView(sampleTelemetry.stream().map(KvEntry::getKey).toList(), new AttributesEntityView())); + entityView.setKeys(new TelemetryEntityView(sampleTimeseries.stream().map(KvEntry::getKey).toList(), new AttributesEntityView())); // mock that there is one entity view given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView))); // mock that save latest call for entity view is successful - given(tsService.saveLatest(tenantId, entityView.getId(), sampleTelemetry)).willReturn(immediateFuture(listOfNNumbers(sampleTelemetry.size()))); + given(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).willReturn(immediateFuture(listOfNNumbers(sampleTimeseries.size()))); // mock TPI for entity view given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi); @@ -273,7 +275,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(new TimeseriesSaveRequest.Strategy(false, true, false)) .callback(emptyCallback) @@ -284,12 +286,12 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN // should save latest to both the main entity and it's entity view - then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); - then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTelemetry); + then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries); + then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTimeseries); then(tsService).shouldHaveNoMoreInteractions(); // should send WS update only for entity view (WS update for the main entity is disabled in the save request) - then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTelemetry, TbCallback.EMPTY); + then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTimeseries, TbCallback.EMPTY); then(subscriptionManagerService).shouldHaveNoMoreInteractions(); } @@ -300,7 +302,7 @@ class DefaultTelemetrySubscriptionServiceTest { .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(new TimeseriesSaveRequest.Strategy(true, false, false)) .callback(emptyCallback) @@ -311,7 +313,7 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN // should save only time series for the main entity - then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl); then(tsService).shouldHaveNoMoreInteractions(); // should not send any WS updates @@ -319,14 +321,14 @@ class DefaultTelemetrySubscriptionServiceTest { } @ParameterizedTest - @MethodSource("booleanCombinations") - void shouldCallCorrectApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate) { + @MethodSource("allCombinationsOfThreeBooleans") + void shouldCallCorrectSaveTimeseriesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate) { // GIVEN var request = TimeseriesSaveRequest.builder() .tenantId(tenantId) .customerId(customerId) .entityId(entityId) - .entries(sampleTelemetry) + .entries(sampleTimeseries) .ttl(sampleTtl) .strategy(new TimeseriesSaveRequest.Strategy(saveTimeseries, saveLatest, sendWsUpdate)) .callback(emptyCallback) @@ -337,22 +339,22 @@ class DefaultTelemetrySubscriptionServiceTest { // THEN if (saveTimeseries && saveLatest) { - then(tsService).should().save(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().save(tenantId, entityId, sampleTimeseries, sampleTtl); } else if (saveLatest) { - then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); + then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries); } else if (saveTimeseries) { - then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); + then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl); } then(tsService).shouldHaveNoMoreInteractions(); if (sendWsUpdate) { - then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTelemetry, TbCallback.EMPTY); + then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTimeseries, TbCallback.EMPTY); } else { then(subscriptionManagerService).shouldHaveNoInteractions(); } } - private static Stream booleanCombinations() { + private static Stream allCombinationsOfThreeBooleans() { return Stream.of( Arguments.of(true, true, true), Arguments.of(true, true, false), @@ -365,7 +367,49 @@ class DefaultTelemetrySubscriptionServiceTest { ); } - // used to emulate sequence numbers returned by save latest API + @ParameterizedTest + @MethodSource("allCombinationsOfTwoBooleans") + void shouldCallCorrectSaveAttributesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveAttributes, boolean sendWsUpdate) { + // GIVEN + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(entityId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new DoubleDataEntry("temperature", 65.2)) + .notifyDevice(false) + .strategy(new AttributesSaveRequest.Strategy(saveAttributes, sendWsUpdate)) + .callback(emptyCallback) + .build(); + + lenient().when(attrService.save(tenantId, entityId, request.getScope(), request.getEntries())).thenReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + if (saveAttributes) { + then(attrService).should().save(tenantId, entityId, request.getScope(), request.getEntries()); + } else { + then(attrService).shouldHaveNoInteractions(); + } + + if (sendWsUpdate) { + then(subscriptionManagerService).should().onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), request.isNotifyDevice(), TbCallback.EMPTY); + } else { + then(subscriptionManagerService).shouldHaveNoInteractions(); + } + } + + static Stream allCombinationsOfTwoBooleans() { + return Stream.of( + Arguments.of(true, true), + Arguments.of(true, false), + Arguments.of(false, true), + Arguments.of(false, false) + ); + } + + // used to emulate sequence numbers returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); } diff --git a/monitoring/src/main/resources/root_rule_chain.json b/monitoring/src/main/resources/root_rule_chain.json index 46bdc72d9f..a1c12c8e9d 100644 --- a/monitoring/src/main/resources/root_rule_chain.json +++ b/monitoring/src/main/resources/root_rule_chain.json @@ -39,8 +39,11 @@ "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "name": "Save Attributes", "singletonMode": false, - "configurationVersion": 1, + "configurationVersion": 3, "configuration": { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, "scope": "CLIENT_SCOPE", "notifyDevice": false, "sendAttributesUpdatedNotification": false, diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java index c7d09d1525..5fb0ed3b92 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java @@ -40,8 +40,17 @@ public class AttributesSaveRequest { private final AttributeScope scope; private final List entries; private final boolean notifyDevice; + private final Strategy strategy; private final FutureCallback callback; + public record Strategy(boolean saveAttributes, boolean sendWsUpdate) { + + public static final Strategy PROCESS_ALL = new Strategy(true, true); + public static final Strategy WS_ONLY = new Strategy(false, true); + public static final Strategy SKIP_ALL = new Strategy(false, false); + + } + public static Builder builder() { return new Builder(); } @@ -53,6 +62,7 @@ public class AttributesSaveRequest { private AttributeScope scope; private List entries; private boolean notifyDevice = true; + private Strategy strategy = Strategy.PROCESS_ALL; private FutureCallback callback; Builder() {} @@ -100,6 +110,11 @@ public class AttributesSaveRequest { return this; } + public Builder strategy(Strategy strategy) { + this.strategy = strategy; + return this; + } + public Builder callback(FutureCallback callback) { this.callback = callback; return this; @@ -120,7 +135,7 @@ public class AttributesSaveRequest { } public AttributesSaveRequest build() { - return new AttributesSaveRequest(tenantId, entityId, scope, entries, notifyDevice, callback); + return new AttributesSaveRequest(tenantId, entityId, scope, entries, notifyDevice, strategy, callback); } } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java new file mode 100644 index 0000000000..1323130e7e --- /dev/null +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2025 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.api; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AttributesSaveRequestTest { + + @Test + void testDefaultSaveStrategyIsProcessAll() { + var request = AttributesSaveRequest.builder().build(); + + assertThat(request.getStrategy()).isEqualTo(AttributesSaveRequest.Strategy.PROCESS_ALL); + } + + @Test + void testProcessAllStrategy() { + assertThat(AttributesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new AttributesSaveRequest.Strategy(true, true)); + } + + @Test + void testWsOnlyStrategy() { + assertThat(AttributesSaveRequest.Strategy.WS_ONLY).isEqualTo(new AttributesSaveRequest.Strategy(false, true)); + } + + @Test + void testSkipAllStrategy() { + assertThat(AttributesSaveRequest.Strategy.SKIP_ALL).isEqualTo(new AttributesSaveRequest.Strategy(false, false)); + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index da6d82707a..053e743596 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -23,6 +23,7 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonParser; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.DonAsynchron; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -30,6 +31,7 @@ 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.rule.engine.telemetry.settings.AttributesProcessingSettings; import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.StringUtils; @@ -43,9 +45,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; @@ -55,13 +62,47 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_R type = ComponentType.ACTION, name = "save attributes", configClazz = TbMsgAttributesNodeConfiguration.class, - version = 2, - nodeDescription = "Saves attributes data", - nodeDetails = "Saves entity attributes based on configurable scope parameter. Expects messages with 'POST_ATTRIBUTES_REQUEST' message type. " + - "If upsert(update/insert) operation is completed successfully rule node will send the incoming message via Success chain, otherwise, Failure chain is used. " + - "Additionally if checkbox Send attributes updated notification is set to true, rule node will put the \"Attributes Updated\" " + - "event for SHARED_SCOPE and SERVER_SCOPE attributes updates to the corresponding rule engine queue." + - "Performance checkbox 'Save attributes only if the value changes' will skip attributes overwrites for values with no changes (avoid concurrent writes because this check is not transactional; will not update 'Last updated time' for skipped attributes).", + version = 3, + nodeDescription = """ + Saves attribute data with a configurable scope and according to configured processing strategies. + """, + nodeDetails = """ + Node performs two actions: +
    +
  • Attributes: save attribute data to a database.
  • +
  • WebSockets: notify WebSockets subscriptions about attribute data updates.
  • +
+ + For each action, three processing strategies are available: +
    +
  • On every message: perform the action for every message.
  • +
  • Deduplicate: perform the action only for the first message from a particular originator within a configurable interval.
  • +
  • Skip: never perform the action.
  • +
+ + Processing strategies are configured using processing settings, which support two modes: +
    +
  • Basic +
      +
    • On every message: applies the "On every message" strategy to all actions.
    • +
    • Deduplicate: applies the "Deduplicate" strategy (with a specified interval) to all actions.
    • +
    • WebSockets only: applies the "Skip" strategy to Attributes, and the "On every message" strategy to WebSockets.
    • +
    +
  • +
  • Advanced: configure each action’s strategy independently.
  • +
+ + Additionally: +
    +
  • If Save attributes only if the value changes is enabled, the rule node compares the received attribute value with the current stored value and skips the save operation if they match.
  • +
  • If Send attributes updated notification is enabled, the rule node will put the Attributes Updated event for SHARED_SCOPE and SERVER_SCOPE attribute updates to the queue named Main.
  • +
  • If Force notification to the device is enabled, then rule node will always notify device about SHARED_SCOPE attribute updates, regardless of the value of notifyDevice metadata property.
  • +
+ + This node expects messages of type POST_ATTRIBUTES_REQUEST. +

+ Output connections: Success, Failure. + """, configDirective = "tbActionNodeAttributesConfig", icon = "file_upload" ) @@ -73,9 +114,12 @@ public class TbMsgAttributesNode implements TbNode { private TbMsgAttributesNodeConfiguration config; + private AttributesProcessingSettings processingSettings; + @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class); + config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class); + processingSettings = config.getProcessingSettings(); } @Override @@ -90,11 +134,20 @@ public class TbMsgAttributesNode implements TbNode { ctx.tellSuccess(msg); return; } + + AttributesSaveRequest.Strategy strategy = determineSaveStrategy(msg.getMetaDataTs(), msg.getOriginator().getId()); + + // short-circuit + if (!strategy.saveAttributes() && !strategy.sendWsUpdate()) { + ctx.tellSuccess(msg); + return; + } + AttributeScope scope = getScope(msg.getMetaData().getValue(SCOPE)); boolean sendAttributesUpdateNotification = checkSendNotification(scope); if (!config.isUpdateAttributesOnlyOnValueChange()) { - saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification); + saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification, strategy); return; } @@ -104,13 +157,41 @@ public class TbMsgAttributesNode implements TbNode { DonAsynchron.withCallback(findFuture, currentAttributes -> { List attributesChanged = filterChangedAttr(currentAttributes, newAttributes); - saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification); + saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification, strategy); }, throwable -> ctx.tellFailure(msg, throwable), MoreExecutors.directExecutor()); } - void saveAttr(List attributes, TbContext ctx, TbMsg msg, AttributeScope scope, boolean sendAttributesUpdateNotification) { + private AttributesSaveRequest.Strategy determineSaveStrategy(long ts, UUID originatorUuid) { + if (processingSettings instanceof OnEveryMessage) { + return AttributesSaveRequest.Strategy.PROCESS_ALL; + } + if (processingSettings instanceof WebSocketsOnly) { + return AttributesSaveRequest.Strategy.WS_ONLY; + } + if (processingSettings instanceof Deduplicate deduplicate) { + boolean isFirstMsgInInterval = deduplicate.getProcessingStrategy().shouldProcess(ts, originatorUuid); + return isFirstMsgInInterval ? AttributesSaveRequest.Strategy.PROCESS_ALL : AttributesSaveRequest.Strategy.SKIP_ALL; + } + if (processingSettings instanceof Advanced advanced) { + return new AttributesSaveRequest.Strategy( + advanced.attributes().shouldProcess(ts, originatorUuid), + advanced.webSockets().shouldProcess(ts, originatorUuid) + ); + } + // should not happen + throw new IllegalArgumentException("Unknown processing settings type: " + processingSettings.getClass().getSimpleName()); + } + + private void saveAttr( + List attributes, + TbContext ctx, + TbMsg msg, + AttributeScope scope, + boolean sendAttributesUpdateNotification, + AttributesSaveRequest.Strategy strategy + ) { if (attributes.isEmpty()) { ctx.tellSuccess(msg); return; @@ -124,11 +205,12 @@ public class TbMsgAttributesNode implements TbNode { .scope(scope) .entries(attributes) .notifyDevice(config.isNotifyDevice() || checkNotifyDeviceMdValue(msg.getMetaData().getValue(NOTIFY_DEVICE_METADATA_KEY))) + .strategy(strategy) .callback(callback) .build()); } - List filterChangedAttr(List currentAttributes, List newAttributes) { + private List filterChangedAttr(List currentAttributes, List newAttributes) { if (currentAttributes == null || currentAttributes.isEmpty()) { return newAttributes; } @@ -178,6 +260,9 @@ public class TbMsgAttributesNode implements TbNode { hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, SEND_ATTRIBUTES_UPDATED_NOTIFICATION_KEY, hasChanges, false); // update updateAttributesOnlyOnValueChange. hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, UPDATE_ATTRIBUTES_ONLY_ON_VALUE_CHANGE_KEY, hasChanges, true); + case 2: + hasChanges = true; + ((ObjectNode) oldConfiguration).set("processingSettings", JacksonUtil.valueToTree(new OnEveryMessage())); break; default: break; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java index 161aa64d5f..2687125a0c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java @@ -15,13 +15,20 @@ */ package org.thingsboard.rule.engine.telemetry; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings; import org.thingsboard.server.common.data.DataConstants; +import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage; + @Data public class TbMsgAttributesNodeConfiguration implements NodeConfiguration { + @NotNull + private AttributesProcessingSettings processingSettings; + private String scope; private boolean notifyDevice; @@ -31,6 +38,7 @@ public class TbMsgAttributesNodeConfiguration implements NodeConfiguration { @@ -39,7 +28,7 @@ public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration newAttributes = new ArrayList<>(); + void verifyDefaultConfig() { + assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class); + assertThat(config.getScope()).isEqualTo("SERVER_SCOPE"); + assertThat(config.isNotifyDevice()).isFalse(); + assertThat(config.isSendAttributesUpdatedNotification()).isFalse(); + assertThat(config.isUpdateAttributesOnlyOnValueChange()).isTrue(); + } - List filtered = node.filterChangedAttr(Collections.emptyList(), newAttributes); - assertThat(filtered).isSameAs(newAttributes); + @Test + void givenProcessingSettingsAreNull_whenValidatingConstraints_thenThrowsException() { + // GIVEN + config.setProcessingSettings(null); + + // WHEN-THEN + assertThatThrownBy(() -> ConstraintValidator.validateFields(config)) + .isInstanceOf(DataValidationException.class) + .hasMessage("Validation error: processingSettings must not be null"); } @Test - void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnEmptyList() { - List currentAttributes = List.of( - new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), - new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), - new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)), - new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) - ); - List newAttributes = new ArrayList<>(currentAttributes); - newAttributes.add(newAttributes.get(0)); - newAttributes.remove(0); - assertThat(newAttributes).hasSize(currentAttributes.size()); - assertThat(currentAttributes).isNotEmpty(); - assertThat(newAttributes).containsExactlyInAnyOrderElementsOf(currentAttributes); - - List filtered = node.filterChangedAttr(currentAttributes, newAttributes); - assertThat(filtered).isEmpty(); //no changes + void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new OnEveryMessage()); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Deduplicate(10)); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + clearInvocations(telemetryServiceMock, ctxMock); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); } @Test - void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnExpectedList() { + void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new WebSocketsOnly()); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.WS_ONLY) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Advanced( + ProcessingStrategy.onEveryMessage(), + ProcessingStrategy.onEveryMessage() + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + var expectedSaveRequest = builder() + .tenantId(tenantId) + .entityId(msg.getOriginator()) + .scope(AttributeScope.valueOf(config.getScope())) + .entry(new DoubleDataEntry("temperature", 22.3)) + .notifyDevice(false) + .strategy(Strategy.PROCESS_ALL) + .build(); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest) + .usingRecursiveComparison() + .ignoringFields("callback", "entries.lastUpdateTs") + .isEqualTo(expectedSaveRequest) + )); + } + + @Test + void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(false); + config.setProcessingSettings(new Advanced( + ProcessingStrategy.deduplicate(1), + ProcessingStrategy.deduplicate(2) + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + long ts1 = 500L; + long ts2 = 1500L; + long ts3 = 2500L; + + // WHEN-THEN + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts1)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(Strategy.PROCESS_ALL) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts2)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, false)) + )); + + clearInvocations(telemetryServiceMock); + + node.onMsg(ctxMock, TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts3)))) + .build()); + then(telemetryServiceMock).should().saveAttributes(assertArg( + actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(Strategy.PROCESS_ALL) + )); + } + + @Test + public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException { + // GIVEN + config.setProcessingSettings(new Advanced( + ProcessingStrategy.skip(), + ProcessingStrategy.skip() + )); + + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString()) + .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false"))) + .build(); + + // WHEN-THEN + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); + then(ctxMock).should(times(1)).tellSuccess(msg); + + node.onMsg(ctxMock, msg); + then(telemetryServiceMock).should(never()).saveAttributes(any()); + then(ctxMock).should(times(2)).tellSuccess(msg); + } + + @Test + void givenVariousChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldCorrectlyFilterChangedAttributes() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(true); + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + List currentAttributes = List.of( - new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), - new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), - new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)), - new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) - ); - List newAttributes = List.of( - new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")), // value changed, reordered - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), //type changed - new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)), //value changed - new BaseAttributeKvEntry(1694000999L, new DoubleDataEntry("temp", -18.35)), - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("address", "Peremohy ave 1")) // reordered + new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")), + new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)), + new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L)), + new BaseAttributeKvEntry(123L, new DoubleDataEntry("temp", -18.35)), + new BaseAttributeKvEntry(123L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) ); - List expected = List.of( - new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), - new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)), - new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")) + given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes)); + + var data = JacksonUtil.newObjectNode() + .put("address", "Prospect Beresteiskyi 1") // no changes + .put("valid", "false") // type and value changed + .put("counter", 101L) // value changed + .put("temp", -18.35) // no changes + .put("json", "{\"warning\":\"out of paper\"}") // only type changed + .put("newKey", "newValue"); // new attribute + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(data.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + List expectedChangedAttributes = List.of( + new BaseAttributeKvEntry(456L, new StringDataEntry("valid", "false")), + new BaseAttributeKvEntry(456L, new LongDataEntry("counter", 101L)), + new BaseAttributeKvEntry(456L, new StringDataEntry("json", "{\"warning\":\"out of paper\"}")), + new BaseAttributeKvEntry(456L, new StringDataEntry("newKey", "newValue")) ); - List filtered = node.filterChangedAttr(currentAttributes, newAttributes); - assertThat(filtered).containsExactlyInAnyOrderElementsOf(expected); + then(telemetryServiceMock).should().saveAttributes(assertArg(request -> + assertThat(request.getEntries()) + .usingRecursiveComparison() + .ignoringCollectionOrder() + .ignoringFields("lastUpdateTs") + .isEqualTo(expectedChangedAttributes) + )); } - // Notify device backward-compatibility test arguments - private static Stream givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod() { - return Stream.of( - Arguments.of(null, true), - Arguments.of("null", false), - Arguments.of("true", true), - Arguments.of("false", false) + @Test + void givenNoChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldNotCallSaveAndJustTellSuccess() throws TbNodeException { + // GIVEN + config.setUpdateAttributesOnlyOnValueChange(true); + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + List currentAttributes = List.of( + new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")), + new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)), + new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L)) ); + given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes)); + + var data = JacksonUtil.newObjectNode() + .put("address", "Prospect Beresteiskyi 1") + .put("valid", true) + .put("counter", 100L); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) + .originator(deviceId) + .data(data.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN + then(telemetryServiceMock).shouldHaveNoInteractions(); + then(ctxMock).should().tellSuccess(msg); } // Notify device backward-compatibility test @ParameterizedTest @MethodSource - void givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod(String mdValue, boolean expectedArgumentValue) throws TbNodeException { - var ctxMock = mock(TbContext.class); - var telemetryServiceMock = mock(RuleEngineTelemetryService.class); - ObjectNode defaultConfig = (ObjectNode) JacksonUtil.valueToTree(new TbMsgAttributesNodeConfiguration().defaultConfiguration()); - defaultConfig.put("notifyDevice", false); - var tbNodeConfiguration = new TbNodeConfiguration(defaultConfig); - - assertThat(defaultConfig.has("notifyDevice")).as("pre condition has notifyDevice").isTrue(); - - when(ctxMock.getTenantId()).thenReturn(tenantId); - when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock); - willCallRealMethod().given(node).init(any(TbContext.class), any(TbNodeConfiguration.class)); - willCallRealMethod().given(node).saveAttr(any(), eq(ctxMock), any(TbMsg.class), any(AttributeScope.class), anyBoolean()); - - node.init(ctxMock, tbNodeConfiguration); - - TbMsgMetaData md = new TbMsgMetaData(); - if (mdValue != null) { - md.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue); - } - // dummy list with one ts kv to pass the empty list check. - var testTbMsg = TbMsg.newMsg() - .type(TbMsgType.POST_TELEMETRY_REQUEST) + void givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata(String mdValue, boolean expectedArgumentValue) throws TbNodeException { + // GIVEN + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + given(attributesServiceMock.find(tenantId, deviceId, AttributeScope.valueOf(config.getScope()), List.of("mode"))).willReturn( + immediateFuture(List.of(new BaseAttributeKvEntry(123L, new StringDataEntry("mode", "tilt")))) + ); + + var metadata = new TbMsgMetaData(); + metadata.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_ATTRIBUTES_REQUEST) .originator(deviceId) - .copyMetaData(md) - .data(TbMsg.EMPTY_STRING) + .data(JacksonUtil.newObjectNode().put("mode", "vibration").toString()) + .metaData(metadata) .build(); - List testAttrList = List.of(new BaseAttributeKvEntry(0L, new StringDataEntry("testKey", "testValue"))); - node.saveAttr(testAttrList, ctxMock, testTbMsg, AttributeScope.SHARED_SCOPE, false); + // WHEN + node.onMsg(ctxMock, msg); - verify(telemetryServiceMock, times(1)).saveAttributes(assertArg(request -> { - assertThat(request.getTenantId()).isEqualTo(tenantId); - assertThat(request.getEntityId()).isEqualTo(deviceId); - assertThat(request.getScope()).isEqualTo(AttributeScope.SHARED_SCOPE); - assertThat(request.getEntries()).isEqualTo(testAttrList); - assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue); - })); + // THEN + then(telemetryServiceMock).should().saveAttributes(assertArg(request -> assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue))); + } + + // Notify device backward-compatibility test arguments + static Stream givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata() { + return Stream.of( + Arguments.of(null, true), + Arguments.of("null", false), + Arguments.of("true", true), + Arguments.of("false", false) + ); } // Rule nodes upgrade - private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( // default config for version 0 Arguments.of(0, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\"}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": "false" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":false}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": false + } + """ + ), // default config for version 1 with upgrade from version 0 Arguments.of(0, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", - false, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // all flags are booleans Arguments.of(1, - "{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", - false, - "{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "scope": "SHARED_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "SHARED_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // no boolean flags set Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\"}", + """ + { + "scope": "CLIENT_SCOPE" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // all flags are boolean strings Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\",\"updateAttributesOnlyOnValueChange\":\"true\"}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": "false", + "updateAttributesOnlyOnValueChange": "true" + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // at least one flag is boolean string Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "false", + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), // notify device flag is null Arguments.of(1, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"null\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", + """ + { + "scope": "CLIENT_SCOPE", + "notifyDevice": "null", + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, + true, + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "CLIENT_SCOPE", + "notifyDevice": true, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ), + // default config for version 2 + Arguments.of(2, + """ + { + "scope": "SERVER_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """, true, - "{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}") + """ + { + "processingSettings": { + "type": "ON_EVERY_MESSAGE" + }, + "scope": "SERVER_SCOPE", + "notifyDevice": false, + "sendAttributesUpdatedNotification": false, + "updateAttributesOnlyOnValueChange": true + } + """ + ) ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java index de651e9841..c08808a4cb 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java @@ -73,6 +73,10 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Advanced; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage; +import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly; @ExtendWith(MockitoExtension.class) public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @@ -110,7 +114,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyDefaultConfig() { assertThat(config.getDefaultTTL()).isEqualTo(0L); - assertThat(config.getProcessingSettings()).isInstanceOf(TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage.class); + assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class); assertThat(config.isUseServerTs()).isFalse(); } @@ -223,7 +227,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { var timeseriesStrategy = ProcessingStrategy.onEveryMessage(); var latestStrategy = ProcessingStrategy.skip(); var webSockets = ProcessingStrategy.onEveryMessage(); - var processingSettings = new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced(timeseriesStrategy, latestStrategy, webSockets); + var processingSettings = new Advanced(timeseriesStrategy, latestStrategy, webSockets); config.setProcessingSettings(processingSettings); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -335,7 +339,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage()); + config.setProcessingSettings(new OnEveryMessage()); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -370,7 +374,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate(10)); + config.setProcessingSettings(new Deduplicate(10)); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -405,7 +409,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly()); + config.setProcessingSettings(new WebSocketsOnly()); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); @@ -440,7 +444,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage() @@ -479,7 +483,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( ProcessingStrategy.deduplicate(1), ProcessingStrategy.deduplicate(2), ProcessingStrategy.deduplicate(3) @@ -530,7 +534,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException { // GIVEN - config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( + config.setProcessingSettings(new Advanced( ProcessingStrategy.skip(), ProcessingStrategy.skip(), ProcessingStrategy.skip() From f0e5042fdd75c32f40e3dc3d243edd7b4922ff00 Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Wed, 26 Feb 2025 18:44:25 +0200 Subject: [PATCH 038/127] UI-redesign and additional corrections --- ui-ngx/src/app/core/utils.ts | 12 --- .../alarm/alarms-table-widget.component.ts | 21 +++--- .../entity/entities-table-widget.component.ts | 21 +++--- .../lib/rpc/persistent-table.component.ts | 21 +++--- ...larms-table-widget-settings.component.html | 62 ++++++++++----- .../alarms-table-widget-settings.component.ts | 49 ++++++------ ...eries-table-widget-settings.component.html | 62 ++++++++++----- ...eseries-table-widget-settings.component.ts | 48 ++++++------ ...stent-table-widget-settings.component.html | 75 +++++++++++++------ ...sistent-table-widget-settings.component.ts | 44 +++++------ ...ities-table-widget-settings.component.html | 62 ++++++++++----- ...ntities-table-widget-settings.component.ts | 59 +++++++-------- .../widget/lib/table-widget.models.ts | 16 +++- .../lib/timeseries-table-widget.component.ts | 21 +++--- .../assets/locale/locale.constant-en_US.json | 5 +- 15 files changed, 336 insertions(+), 242 deletions(-) diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index d139fe58bb..ac353e57b0 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -927,15 +927,3 @@ export const unwrapModule = (module: any) : any => { return module; } }; - -export function buildPageStepSizeValues(formGroup: UntypedFormGroup, pageSteps: Array): void { - const pageStepCount = formGroup.get('pageStepCount')?.value; - const pageStepSize = formGroup.get('pageStepSize')?.value; - pageSteps.length = 0; - if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && - isDefinedAndNotNull(pageStepSize) && pageStepSize > 0) { - for (let i = 1; i <= pageStepCount; i++) { - pageSteps.push(pageStepSize * i); - } - } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index 7455e08b15..6eb7195b0b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -392,25 +392,24 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepSize = this.settings.pageStepSize; + let pageStepIncrement = this.settings.pageStepIncrement; let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { - if (!this.defaultPageSize) { - this.defaultPageSize = pageStepSize; - } - } else { - if (!this.defaultPageSize) { - this.defaultPageSize = 10; - } - pageStepSize = this.defaultPageSize; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; pageStepCount = 3; } for (let i = 1; i <= pageStepCount; i++) { - this.pageSizeOptions.push(pageStepSize * i); + this.pageSizeOptions.push(pageStepIncrement * i); } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts index e9e5c12f18..e029451a28 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts @@ -311,25 +311,24 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepSize = this.settings.pageStepSize; + let pageStepIncrement = this.settings.pageStepIncrement; let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { - if (!this.defaultPageSize) { - this.defaultPageSize = pageStepSize; - } - } else { - if (!this.defaultPageSize) { - this.defaultPageSize = 10; - } - pageStepSize = this.defaultPageSize; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; pageStepCount = 3; } for (let i = 1; i <= pageStepCount; i++) { - this.pageSizeOptions.push(pageStepSize * i); + this.pageSizeOptions.push(pageStepIncrement * i); } this.pageLink.pageSize = this.displayPagination ? this.defaultPageSize : 1024; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts index b795e927f6..4fae519634 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -207,25 +207,24 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O this.displayedColumns = [...this.displayTableColumns]; const pageSize = this.settings.defaultPageSize; - let pageStepSize = this.settings.pageStepSize; + let pageStepIncrement = this.settings.pageStepIncrement; let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { - if (!this.defaultPageSize) { - this.defaultPageSize = pageStepSize; - } - } else { - if (!this.defaultPageSize) { - this.defaultPageSize = 10; - } - pageStepSize = this.defaultPageSize; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; pageStepCount = 3; } for (let i = 1; i <= pageStepCount; i++) { - this.pageSizeOptions.push(pageStepSize * i); + this.pageSizeOptions.push(pageStepIncrement * i); } if (this.settings.defaultSortOrder && this.settings.defaultSortOrder.length) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html index fe4db349ca..ab4bad3706 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html @@ -95,29 +95,53 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.page-step-size
- - - +
{{ 'widgets.table.page-step-settings' | translate }}
+
+
widgets.table.page-step-increment
+ + + + warning + + + +
widgets.table.page-step-count
+ + + + warning + + +
-
widgets.table.page-step-count
- - +
widgets.table.default-page-size
+ + + + {{ size }} + +
- - widgets.table.default-page-size - - - {{ size }} - - -
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts index 1d6b8ab1c1..7a7b97dc18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-alarms-table-widget-settings', @@ -58,7 +58,7 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: true, displayPagination: true, defaultPageSize: 10, - pageStepSize: null, + pageStepIncrement: null, pageStepCount: 3, defaultSortOrder: '-createdTime', useRowStyleFunction: false, @@ -84,47 +84,46 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent displayActivity: [settings.displayActivity, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], - pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, - [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], - pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); - buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm, this.pageStepSizeValues); - } - - public onPaginationSettingsChange(): void { - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').reset(); - buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm, this.pageStepSizeValues); + this.pageStepSizeValues = buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm.get('pageStepCount').value, + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination']; + return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', + 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.alarmsTableWidgetSettingsForm.get('pageStepCount').value, + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.alarmsTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.alarmsTableWidgetSettingsForm.get('displayPagination').value; if (useRowStyleFunction) { - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').enable(); - this.alarmsTableWidgetSettingsForm.get('pageStepCount').enable(); - this.alarmsTableWidgetSettingsForm.get('pageStepSize').enable(); + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').disable(); - this.alarmsTableWidgetSettingsForm.get('pageStepCount').disable(); - this.alarmsTableWidgetSettingsForm.get('pageStepSize').disable(); + this.alarmsTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.alarmsTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.alarmsTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.alarmsTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.alarmsTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); - this.alarmsTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); - this.alarmsTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html index 540d3348d4..befe55b1f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.html @@ -62,29 +62,53 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.page-step-size
- - - +
{{ 'widgets.table.page-step-settings' | translate }}
+
+
widgets.table.page-step-increment
+ + + + warning + + + +
widgets.table.page-step-count
+ + + + warning + + +
-
widgets.table.page-step-count
- - +
widgets.table.default-page-size
+ + + + {{ size }} + +
- - widgets.table.default-page-size - - - {{ size }} - - -
widgets.table.table-tabs
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index fdc28f0f30..1b9761814e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-timeseries-table-widget-settings', @@ -53,7 +53,7 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, useEntityLabel: false, defaultPageSize: 10, - pageStepSize: null, + pageStepIncrement: null, pageStepCount: 3, hideEmptyLines: false, disableStickyHeader: false, @@ -81,48 +81,46 @@ export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: [settings.displayPagination, []], useEntityLabel: [settings.useEntityLabel, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], - pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, - [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], - pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], hideEmptyLines: [settings.hideEmptyLines, []], disableStickyHeader: [settings.disableStickyHeader, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); - buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm, this.pageStepSizeValues); - } - - public onPaginationSettingsChange(): void { - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').reset(); - buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm, this.pageStepSizeValues); + this.pageStepSizeValues = buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm.get('pageStepCount').value, + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination']; + return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.timeseriesTableWidgetSettingsForm.get('pageStepCount').value, + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.timeseriesTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.timeseriesTableWidgetSettingsForm.get('displayPagination').value; if (useRowStyleFunction) { - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').enable(); - this.timeseriesTableWidgetSettingsForm.get('pageStepCount').enable(); - this.timeseriesTableWidgetSettingsForm.get('pageStepSize').enable(); + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').disable(); - this.timeseriesTableWidgetSettingsForm.get('pageStepCount').disable(); - this.timeseriesTableWidgetSettingsForm.get('pageStepSize').disable(); + this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.timeseriesTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.timeseriesTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.timeseriesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.timeseriesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); - this.timeseriesTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); - this.timeseriesTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index 46782364d8..e69ef101fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -43,31 +43,60 @@ {{ 'widgets.persistent-table.allow-delete-request' | translate }} -
- +
+
widgets.table.pagination
+ {{ 'widgets.table.display-pagination' | translate }} - - widgets.table.default-page-size - - - {{ size }} - - - -
-
- - widgets.table.page-step-size - - - - widgets.table.page-step-count - - -
+
+
{{ 'widgets.table.page-step-settings' | translate }}
+
+
widgets.table.page-step-increment
+ + + + warning + + + +
widgets.table.page-step-count
+ + + + warning + + +
+
+
+
widgets.table.default-page-size
+ + + + {{ size }} + + + +
+
widgets.table.default-sort-order diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts index 51cfa5652f..2c46642b8d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts @@ -27,7 +27,8 @@ import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { map, mergeMap, share, startWith } from 'rxjs/operators'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; -import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; +import { isDefinedAndNotNull } from '@core/utils'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; interface DisplayColumn { name: string; @@ -97,7 +98,7 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayPagination: true, defaultPageSize: 10, - pageStepSize: null, + pageStepIncrement: null, pageStepCount: 3, defaultSortOrder: '-createdTime', @@ -115,19 +116,15 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon displayDetails: [settings.displayDetails, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], - pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, - [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], - pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], displayColumns: [settings.displayColumns, [Validators.required]] }); - buildPageStepSizeValues(this.persistentTableWidgetSettingsForm, this.pageStepSizeValues); - } - - public onPaginationSettingsChange(): void { - this.persistentTableWidgetSettingsForm.get('defaultPageSize').reset(); - buildPageStepSizeValues(this.persistentTableWidgetSettingsForm, this.pageStepSizeValues); + this.pageStepSizeValues = buildPageStepSizeValues(this.persistentTableWidgetSettingsForm.get('pageStepCount').value, + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').value); } public validateSettings(): boolean { @@ -137,23 +134,26 @@ export class PersistentTableWidgetSettingsComponent extends WidgetSettingsCompon } protected validatorTriggers(): string[] { - return ['displayPagination']; + return ['displayPagination', 'pageStepCount', 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.persistentTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.persistentTableWidgetSettingsForm.get('pageStepCount').value, + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const displayPagination: boolean = this.persistentTableWidgetSettingsForm.get('displayPagination').value; if (displayPagination) { - this.persistentTableWidgetSettingsForm.get('defaultPageSize').enable(); - this.persistentTableWidgetSettingsForm.get('pageStepCount').enable(); - this.persistentTableWidgetSettingsForm.get('pageStepSize').enable(); + this.persistentTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.persistentTableWidgetSettingsForm.get('defaultPageSize').disable(); - this.persistentTableWidgetSettingsForm.get('pageStepCount').disable(); - this.persistentTableWidgetSettingsForm.get('pageStepSize').disable(); + this.persistentTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.persistentTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.persistentTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } - this.persistentTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); - this.persistentTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); - this.persistentTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); } private fetchColumns(searchText?: string): Observable> { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html index 04c1f71c52..ecd4b1d025 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html @@ -92,29 +92,53 @@ {{ 'widgets.table.display-pagination' | translate }}
-
widgets.table.page-step-size
- - - +
{{ 'widgets.table.page-step-settings' | translate }}
+
+
widgets.table.page-step-increment
+ + + + warning + + + +
widgets.table.page-step-count
+ + + + warning + + +
-
widgets.table.page-step-count
- - +
widgets.table.default-page-size
+ + + + {{ size }} + +
- - widgets.table.default-page-size - - - {{ size }} - - -
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts index a2fc3ef9c7..fe13d3d007 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { buildPageStepSizeValues, isDefinedAndNotNull } from '@core/utils'; +import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ selector: 'tb-entities-table-widget-settings', @@ -56,7 +56,7 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: true, displayPagination: true, defaultPageSize: 10, - pageStepSize: null, + pageStepIncrement: null, pageStepCount: 3, defaultSortOrder: 'entityName', useRowStyleFunction: false, @@ -80,61 +80,58 @@ export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponen displayEntityType: [settings.displayEntityType, []], displayPagination: [settings.displayPagination, []], defaultPageSize: [settings.defaultPageSize, [Validators.min(1)]], - pageStepCount: [isDefinedAndNotNull(settings.pageStepCount) ? settings.pageStepCount : 3, - [Validators.min(1), Validators.max(100), Validators.required, Validators.pattern(/^\d*$/)]], - pageStepSize: [isDefinedAndNotNull(settings.pageStepSize) ? settings.pageStepSize : settings.defaultPageSize, + pageStepCount: [settings.pageStepCount ?? 3, [Validators.min(1), Validators.max(100), + Validators.required, Validators.pattern(/^\d*$/)]], + pageStepIncrement: [settings.pageStepIncrement ?? settings.defaultPageSize, [Validators.min(1), Validators.required, Validators.pattern(/^\d*$/)]], defaultSortOrder: [settings.defaultSortOrder, []], useRowStyleFunction: [settings.useRowStyleFunction, []], rowStyleFunction: [settings.rowStyleFunction, [Validators.required]] }); - buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm, this.pageStepSizeValues); - } - - public onPaginationSettingsChange(): void { - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').reset(); - buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm, this.pageStepSizeValues); + this.pageStepSizeValues = buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm.get('pageStepCount').value, + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').value); } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination', 'displayEntityName', 'displayEntityLabel']; + return ['useRowStyleFunction', 'displayPagination', 'displayEntityName', 'displayEntityLabel', 'pageStepCount', + 'pageStepIncrement']; } - protected updateValidators(emitEvent: boolean) { + protected updateValidators(emitEvent: boolean, trigger: string) { + if (trigger === 'pageStepCount' || trigger === 'pageStepIncrement') { + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').reset(); + this.pageStepSizeValues = buildPageStepSizeValues(this.entitiesTableWidgetSettingsForm.get('pageStepCount').value, + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').value); + return; + } const useRowStyleFunction: boolean = this.entitiesTableWidgetSettingsForm.get('useRowStyleFunction').value; const displayPagination: boolean = this.entitiesTableWidgetSettingsForm.get('displayPagination').value; const displayEntityName: boolean = this.entitiesTableWidgetSettingsForm.get('displayEntityName').value; const displayEntityLabel: boolean = this.entitiesTableWidgetSettingsForm.get('displayEntityLabel').value; if (useRowStyleFunction) { - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').enable(); + this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').disable(); + this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').disable({emitEvent}); } if (displayPagination) { - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').enable(); - this.entitiesTableWidgetSettingsForm.get('pageStepCount').enable(); - this.entitiesTableWidgetSettingsForm.get('pageStepSize').enable(); + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').enable({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').enable({emitEvent: false}); + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').enable({emitEvent: false}); } else { - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').disable(); - this.entitiesTableWidgetSettingsForm.get('pageStepCount').disable(); - this.entitiesTableWidgetSettingsForm.get('pageStepSize').disable(); + this.entitiesTableWidgetSettingsForm.get('defaultPageSize').disable({emitEvent}); + this.entitiesTableWidgetSettingsForm.get('pageStepCount').disable({emitEvent: false}); + this.entitiesTableWidgetSettingsForm.get('pageStepIncrement').disable({emitEvent: false}); } if (displayEntityName) { - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').enable(); + this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').disable(); + this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').disable({emitEvent}); } if (displayEntityLabel) { - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').enable(); + this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').enable({emitEvent}); } else { - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').disable(); + this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').disable({emitEvent}); } - this.entitiesTableWidgetSettingsForm.get('rowStyleFunction').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('defaultPageSize').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('pageStepCount').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('pageStepSize').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('entityNameColumnTitle').updateValueAndValidity({emitEvent}); - this.entitiesTableWidgetSettingsForm.get('entityLabelColumnTitle').updateValueAndValidity({emitEvent}); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index c4bdc91fa4..90d4589e8a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -16,7 +16,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { DataKey, FormattedData, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models'; -import { getDescendantProp, isDefined, isNotEmptyStr } from '@core/utils'; +import { getDescendantProp, isDefined, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models'; import tinycolor from 'tinycolor2'; import { Direction } from '@shared/models/page/sort-order'; @@ -34,6 +34,7 @@ import { } from '@shared/models/js-function.models'; import { forkJoin, Observable, of, ReplaySubject } from 'rxjs'; import { catchError, map, share } from 'rxjs/operators'; +import { UntypedFormGroup } from '@angular/forms'; type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile'; @@ -47,7 +48,7 @@ export interface TableWidgetSettings { enableStickyHeader: boolean; displayPagination: boolean; defaultPageSize: number; - pageStepSize: number; + pageStepIncrement: number; pageStepCount: number; useRowStyleFunction: boolean; rowStyleFunction?: TbFunction; @@ -560,3 +561,14 @@ export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKey } return dataKey.label; } + +export function buildPageStepSizeValues(pageStepCount: number, pageStepIncrement: number): Array { + const pageSteps: Array = []; + if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 && + isDefinedAndNotNull(pageStepIncrement) && pageStepIncrement > 0) { + for (let i = 1; i <= pageStepCount; i++) { + pageSteps.push(pageStepIncrement * i); + } + } + return pageSteps; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index f9d02e27b5..539ba9229a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -352,25 +352,24 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx'); const pageSize = this.settings.defaultPageSize; - let pageStepSize = this.settings.pageStepSize; + let pageStepIncrement = this.settings.pageStepIncrement; let pageStepCount = this.settings.pageStepCount; + if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) { this.defaultPageSize = pageSize; } - if (isDefinedAndNotNull(pageStepSize) && isDefinedAndNotNull(pageStepCount)) { - if (!this.defaultPageSize) { - this.defaultPageSize = pageStepSize; - } - } else { - if (!this.defaultPageSize) { - this.defaultPageSize = 10; - } - pageStepSize = this.defaultPageSize; + + if (!this.defaultPageSize) { + this.defaultPageSize = pageStepIncrement ?? 10; + } + + if (!isDefinedAndNotNull(pageStepIncrement) || !isDefinedAndNotNull(pageStepCount)) { + pageStepIncrement = this.defaultPageSize; pageStepCount = 3; } for (let i = 1; i <= pageStepCount; i++) { - this.pageSizeOptions.push(pageStepSize * i); + this.pageSizeOptions.push(pageStepIncrement * i); } this.noDataDisplayMessageText = diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d258b27d7f..3aa6092476 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -8111,8 +8111,11 @@ "display-timestamp": "Timestamp", "display-pagination": "Display pagination", "default-page-size": "Default page size", + "page-step-settings": "Page step settings", "page-step-count": "Number of steps", - "page-step-size": "Page size increment", + "page-step-increment": "Step increment", + "page-step-count-format-message": "Should be an integer value, in the range from 1 to 100.", + "page-step-increment-format-message": "Should be an integer value, greater or equal to 1.", "use-entity-label-tab-name": "Use entity label in tab name", "hide-empty-lines": "Hide empty lines", "row-style": "Row style", From e32b58769c7503c6619171e80475d6e18a740bf1 Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Wed, 26 Feb 2025 18:51:24 +0200 Subject: [PATCH 039/127] Removed unused imports --- ui-ngx/src/app/core/utils.ts | 1 - .../control/persistent-table-widget-settings.component.ts | 1 - .../modules/home/components/widget/lib/table-widget.models.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index ac353e57b0..fed2de125f 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -31,7 +31,6 @@ import { isNotEmptyTbFunction, TbFunction } from '@shared/models/js-function.models'; -import { UntypedFormGroup } from '@angular/forms'; const varsRegex = /\${([^}]*)}/g; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts index 2c46642b8d..6714e3d912 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts @@ -27,7 +27,6 @@ import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; import { MatAutocomplete, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { map, mergeMap, share, startWith } from 'rxjs/operators'; import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; -import { isDefinedAndNotNull } from '@core/utils'; import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; interface DisplayColumn { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 90d4589e8a..fb791b8258 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -34,7 +34,6 @@ import { } from '@shared/models/js-function.models'; import { forkJoin, Observable, of, ReplaySubject } from 'rxjs'; import { catchError, map, share } from 'rxjs/operators'; -import { UntypedFormGroup } from '@angular/forms'; type ColumnVisibilityOptions = 'visible' | 'hidden' | 'hidden-mobile'; From 952a6d6e6d715d987f543f6d40a48dc254491e89 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 25 Feb 2025 17:52:19 +0200 Subject: [PATCH 040/127] UI: Add new map action - map action buttons --- .../json/system/widget_types/image_map.json | 2 +- .../data/json/system/widget_types/map.json | 2 +- ...anage-widget-actions-dialog.component.html | 1 + .../manage-widget-actions-dialog.component.ts | 3 +- .../action/manage-widget-actions.component.ts | 7 +- .../widget-action-dialog.component.html | 3 +- .../action/widget-action-dialog.component.ts | 2 + .../common/widget-actions-panel.component.ts | 3 +- .../widget/lib/maps/leaflet/leaflet-tb.ts | 109 ++++++- .../home/components/widget/lib/maps/map.scss | 53 +++- .../home/components/widget/lib/maps/map.ts | 289 ++++++++++++------ .../widget/lib/maps/models/map.models.ts | 25 +- ...idget-action-settings-panel.component.html | 3 +- .../widget-action-settings-panel.component.ts | 5 +- .../widget-action-settings.component.ts | 13 +- .../action/widget-action.component.html | 16 +- .../common/action/widget-action.component.ts | 42 ++- .../map/map-action-button-row.component.html | 47 +++ .../map/map-action-button-row.component.ts | 100 ++++++ ...map-action-buttons-settings.component.html | 63 ++++ .../map-action-buttons-settings.component.ts | 107 +++++++ .../common/map/map-settings.component.html | 2 + .../common/map/map-settings.component.ts | 3 +- .../common/widget-settings-common.module.ts | 8 + .../widget/widget-component.service.ts | 10 +- .../widget/widget-config.component.html | 1 + .../components/widget/widget.component.ts | 1 + ui-ngx/src/app/shared/models/widget.models.ts | 27 +- .../assets/locale/locale.constant-en_US.json | 46 ++- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 13 +- 30 files changed, 855 insertions(+), 151 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts diff --git a/application/src/main/data/json/system/widget_types/image_map.json b/application/src/main/data/json/system/widget_types/image_map.json index 0c10599757..2d66ca43da 100644 --- a/application/src/main/data/json/system/widget_types/image_map.json +++ b/application/src/main/data/json/system/widget_types/image_map.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", diff --git a/application/src/main/data/json/system/widget_types/map.json b/application/src/main/data/json/system/widget_types/map.json index fb19914741..379ae10b0a 100644 --- a/application/src/main/data/json/system/widget_types/map.json +++ b/application/src/main/data/json/system/widget_types/map.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": "", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true,\n additionalWidgetActionTypes: ['placeMapItem']\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "settingsDirective": "tb-map-widget-settings", diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html index 95163a1f49..d4c0d78fa3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html @@ -33,6 +33,7 @@ [callbacks]="data.callbacks" [widgetType] = "data.widgetType" [actionSources]="actionSources" + [additionalWidgetActionTypes]="data.additionalWidgetActionTypes" formControlName="actions">
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts index bead6147f1..7d2e269898 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { widgetType } from '@shared/models/widget.models'; +import { WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionCallbacks, WidgetActionsData @@ -32,6 +32,7 @@ export interface ManageWidgetActionsDialogData { actionsData: WidgetActionsData; callbacks: WidgetActionCallbacks; widgetType: widgetType; + additionalWidgetActionTypes?: WidgetActionType[]; } @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts index 2a83f97b80..902dc22d3d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts @@ -46,7 +46,7 @@ import { WidgetActionsDatasource } from '@home/components/widget/action/manage-widget-actions.component.models'; import { UtilsService } from '@core/services/utils.service'; -import { WidgetActionDescriptor, WidgetActionSource, widgetType } from '@shared/models/widget.models'; +import { WidgetActionDescriptor, WidgetActionSource, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionDialogComponent, WidgetActionDialogData @@ -77,6 +77,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni @Input() actionSources: {[actionSourceId: string]: WidgetActionSource}; + @Input() additionalWidgetActionTypes: WidgetActionType[]; + innerValue: WidgetActionsData; displayedColumns: string[]; @@ -236,7 +238,8 @@ export class ManageWidgetActionsComponent extends PageComponent implements OnIni callbacks: this.callbacks, actionsData, action: deepClone(action), - widgetType: this.widgetType + widgetType: this.widgetType, + additionalWidgetActionTypes: this.additionalWidgetActionTypes } }).afterClosed().subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index 621aebeae9..856a89a468 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -121,7 +121,8 @@ + [widgetType]="data.widgetType" + [additionalWidgetActionTypes]="data.additionalWidgetActionTypes">
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index 8f2398cab2..455aa31a9c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -43,6 +43,7 @@ import { CellClickColumnInfo, defaultWidgetAction, WidgetActionSource, + WidgetActionType, widgetType } from '@shared/models/widget.models'; import { takeUntil } from 'rxjs/operators'; @@ -58,6 +59,7 @@ export interface WidgetActionDialogData { actionsData: WidgetActionsData; action?: WidgetActionDescriptorInfo; widgetType: widgetType; + additionalWidgetActionTypes?: WidgetActionType[]; } @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index 37c5ab7f01..499c897a73 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -122,7 +122,8 @@ export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit widgetTitle: this.widgetConfigComponent.modelValue.widgetName, callbacks: this.widgetConfigComponent.widgetConfigCallbacks, actionsData, - widgetType: this.widgetConfigComponent.widgetType + widgetType: this.widgetConfigComponent.widgetType, + additionalWidgetActionTypes: this.widgetConfigComponent.modelValue.typeParameters.additionalWidgetActionTypes } }).afterClosed().subscribe( (res) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts index 5994d25fca..507985a999 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/leaflet/leaflet-tb.ts @@ -15,10 +15,14 @@ /// import L, { TB } from 'leaflet'; -import { guid } from '@core/utils'; +import { guid, isNotEmptyStr } from '@core/utils'; import 'leaflet-providers'; import '@geoman-io/leaflet-geoman-free'; import 'leaflet.markercluster'; +import { MatIconRegistry } from '@angular/material/icon'; +import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; +import { catchError, take } from 'rxjs/operators'; +import { of } from 'rxjs'; L.MarkerCluster = L.MarkerCluster.mergeOptions({ pmIgnore: true }); @@ -278,32 +282,105 @@ class GroupsControl extends SidebarPaneControl { class TopToolbarButton { private readonly button: JQuery; - private _onClick: (e: MouseEvent) => void; + private active = false; + private disabled = false; + private _onClick: (e: MouseEvent, button: TopToolbarButton) => void; - constructor(private readonly options: TB.TopToolbarButtonOptions) { + constructor(private readonly options: TB.TopToolbarButtonOptions, + private readonly iconRegistry: MatIconRegistry) { const iconElement = $('
'); + const setIcon = isNotEmptyStr(this.options.icon); + const setTitle = isNotEmptyStr(this.options.title); this.button = $("
") .attr('class', 'tb-control-button tb-control-text-button') .attr('href', '#') .attr('role', 'button'); - this.button.append(iconElement); - this.button.append(`
${this.options.title}
`); - this.loadIcon(iconElement); + if (setIcon) { + this.button.append(iconElement); + this.loadIcon(iconElement); + } + if (setTitle) { + this.button.append(`
${this.options.title}
`); + } + this.button.css('--tb-map-control-color', this.options.color); + this.button.css('--tb-map-control-active-color', this.options.color); + this.button.css('--tb-map-control-hover-background-color', this.options.color); + if (setIcon && !setTitle) { + this.button.css('padding', 0); + } else if (!setIcon && setTitle) { + this.button.css('padding-left', '14px'); + } this.button.on('click', (e) => { e.stopPropagation(); e.preventDefault(); if (this._onClick) { - this._onClick(e.originalEvent); + this._onClick(e.originalEvent, this); } }); } - onClick(onClick: (e: MouseEvent) => void): void { + onClick(onClick: (e: MouseEvent, button: TopToolbarButton) => void): void { this._onClick = onClick; } private loadIcon(iconElement: JQuery) { - // this.options.icon + iconElement.addClass(this.iconRegistry.getDefaultFontSetClass()); + + if (!isSvgIcon(this.options.icon)) { + iconElement.addClass('material-icon-font'); + iconElement.text(this.options.icon); + return; + } + + const [namespace, iconName] = splitIconName(this.options.icon); + this.iconRegistry + .getNamedSvgIcon(iconName, namespace) + .pipe( + take(1), + catchError((err: Error) => { + console.log(`Error retrieving icon ${namespace}:${iconName}! ${err.message}`) + return of(null); + }) + ) + .subscribe({ + next: (svg) => { + iconElement.append(svg); + svg.style.height = '24px'; + svg.style.width = '24px'; + } + }); + } + + setActive(active: boolean): void { + if (this.active !== active) { + this.active = active; + if (this.active) { + L.DomUtil.addClass(this.button[0], 'active'); + } else { + L.DomUtil.removeClass(this.button[0], 'active'); + } + } + } + + isActive(): boolean { + return this.active; + } + + setDisabled(disabled: boolean): void { + if (this.disabled !== disabled) { + this.disabled = disabled; + if (this.disabled) { + L.DomUtil.addClass(this.button[0], 'leaflet-disabled'); + this.button[0].setAttribute('aria-disabled', 'true'); + } else { + L.DomUtil.removeClass(this.button[0], 'leaflet-disabled'); + this.button[0].setAttribute('aria-disabled', 'false'); + } + } + } + + isDisabled(): boolean { + return this.disabled; } getButtonElement(): JQuery { @@ -382,20 +459,30 @@ class ToolbarButton { class TopToolbarControl { private readonly toolbarElement: JQuery; + private buttons: Array = []; constructor(private readonly options: TB.TopToolbarControlOptions) { - const controlContainer = $('.leaflet-control-container', options.mapElement); + const controlContainer = $('.leaflet-control-container', this.options.mapElement); this.toolbarElement = $('
'); this.toolbarElement.appendTo(controlContainer); } toolbarButton(options: TB.TopToolbarButtonOptions): TopToolbarButton { - const button = new TopToolbarButton(options); + const button = new TopToolbarButton(options, this.options.iconRegistry); const buttonContainer = $('
'); button.getButtonElement().appendTo(buttonContainer); buttonContainer.appendTo(this.toolbarElement); + this.buttons.push(button); return button; } + + setDisabled(disabled: boolean): void { + this.buttons.forEach(button => { + if (!button.isActive()) { + button.setDisabled(disabled); + } + }); + } } class ToolbarControl extends L.Control { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 3cb3c2cb35..c86933ccb7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -37,30 +37,34 @@ border: 1px solid rgba(0,0,0,0.38); border-radius: 15px; box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.32); - background: #fff; + background-color: #fff; position: relative; a { - color: rgba(0, 0, 0, 0.54); + color: var(--tb-map-control-color, rgba(0, 0, 0, 0.54)); border-bottom: none; position: relative; - background: transparent; + background-color: transparent; &.leaflet-disabled { pointer-events: none; - color: rgba(0, 0, 0, 0.18); - > div { - background: rgba(0, 0, 0, 0.18); + color: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18)); + > div:not(.material-icon-font):not(.tb-control-text) { + background-color: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18)); + svg { + fill: var(--tb-map-control-disable-color, rgba(0, 0, 0, 0.18)); + } } } &:not(.leaflet-disabled) { &:hover, &.active { border-bottom: none; - color: $tb-primary-color; // primary color + color: var(--tb-map-control-active-color, $tb-primary-color); // primary color &:before { content: ""; position: absolute; inset: 0; border-radius: 50%; - background-color: rgba(0, 105, 92, 0.10); + background-color: var(--tb-map-control-hover-background-color, rgba(0, 105, 92)); + opacity: 0.1; } } } @@ -93,11 +97,17 @@ border-radius: 15px; } > div:not(.tb-control-text):not(.tb-close) { - background: $tb-primary-color; // primary color + background-color: var(--tb-map-control-active-color, $tb-primary-color); + svg { + fill: var(--tb-map-control-active-color, $tb-primary-color); + } } } - > div { - background: rgba(0, 0, 0, 0.54); + > div:not(.material-icon-font):not(.tb-control-text) { + background-color: var(--tb-map-control-color, rgba(0, 0, 0, 0.54)); + svg { + fill: var(--tb-map-control-color, rgba(0, 0, 0, 0.54)); + } } } > div { @@ -141,7 +151,7 @@ } } } - .tb-map-bottom-toolbar, .tb-map-top-toolbar { + .tb-map-bottom-toolbar { left: 0; right: 0; display: flex; @@ -152,6 +162,25 @@ flex-direction: row; } } + + .tb-map-top-toolbar { + left: 52px; + right: 52px; + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + .leaflet-control { + margin-top: 0; + } + a.tb-control-button { + .tb-control-button-icon { + background-clip: text; + } + } + } } .leaflet-map-pane:not(.leaflet-zoom-anim) { .leaflet-marker-icon { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 21518a699e..6efece9502 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -17,6 +17,7 @@ import { additionalMapDataSourcesToDatasources, BaseMapSettings, + CustomActionData, DataKeyValuePair, MapType, mergeMapDatasources, @@ -41,7 +42,7 @@ import { UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { FormattedData, WidgetAction, WidgetActionDescriptor, widgetType } from '@shared/models/widget.models'; +import { FormattedData, MapItemType, WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; @@ -300,20 +301,7 @@ export abstract class TbMap { } }); this.addMarkerButton.setDisabled(true); - createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), MarkerShape.markerShape1, tinycolor('rgba(255,255,255,0.75)')).subscribe( - ((iconUrl) => { - const icon = L.icon({ - iconUrl, - iconSize: [40, 40], - iconAnchor: [20, 40] - }); - this.map.pm.setGlobalOptions({ - markerStyle: { - icon - } - }); - }) - ); + this.setPlaceMarkerStyle(); } this.addPolygonDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === MapDataLayerType.polygon); if (this.addPolygonDataLayers.length) { @@ -351,86 +339,32 @@ export abstract class TbMap { } } - private setupCustomActions() { - this.customActionsToolbar = L.TB.topToolbar({ - mapElement: $(this.mapElement) - }); - - /*const customButton = this.customActionsToolbar.toolbarButton({ - title: 'Super button', - icon: 'add' - }); - this.customActionsToolbar.toolbarButton({ - title: 'Super button 2', - icon: 'add' - }); - customButton.onClick(e => { - console.log("Called!"); - });*/ - } - private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { - this.placeItem(e, button, this.addMarkerDataLayers, - (entity) => { - this.map.pm.setLang('en', { - tooltips: { - placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint', {entityName: entity.entity.entityDisplayName}) - } - }, 'en'); - this.map.pm.enableDraw('Marker'); - // @ts-ignore - L.DomUtil.addClass(this.map.pm.Draw.Marker._hintMarker.getTooltip()._container, 'tb-place-item-label'); - } - ); + this.placeItem(e, button, this.addMarkerDataLayers, (entity) => this.prepareDrawMode('Marker', { + placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint-with-entity', {entityName: entity.entity.entityDisplayName}) + })); } private drawRectangle(e: MouseEvent, button: L.TB.ToolbarButton): void { - this.placeItem(e, button, this.addPolygonDataLayers, - (entity) => { - this.map.pm.setLang('en', { - tooltips: { - firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint', {entityName: entity.entity.entityDisplayName}), - finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint', {entityName: entity.entity.entityDisplayName}) - } - }, 'en'); - this.map.pm.enableDraw('Rectangle'); - // @ts-ignore - L.DomUtil.addClass(this.map.pm.Draw.Rectangle._hintMarker.getTooltip()._container, 'tb-place-item-label'); - } - ); + this.placeItem(e, button, this.addPolygonDataLayers, (entity) => this.prepareDrawMode('Rectangle', { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint-with-entity', {entityName: entity.entity.entityDisplayName}) + })); } private drawPolygon(e: MouseEvent, button: L.TB.ToolbarButton): void { - this.placeItem(e, button, this.addPolygonDataLayers, - (entity) => { - this.map.pm.setLang('en', { - tooltips: { - firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint', {entityName: entity.entity.entityDisplayName}), - continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint', {entityName: entity.entity.entityDisplayName}), - finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint', {entityName: entity.entity.entityDisplayName}) - } - }, 'en'); - this.map.pm.enableDraw('Polygon'); - // @ts-ignore - L.DomUtil.addClass(this.map.pm.Draw.Polygon._hintMarker.getTooltip()._container, 'tb-place-item-label'); - } - ); + this.placeItem(e, button, this.addPolygonDataLayers, (entity) => this.prepareDrawMode('Polygon', { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint-with-entity', {entityName: entity.entity.entityDisplayName}) + })); } private drawCircle(e: MouseEvent, button: L.TB.ToolbarButton): void { - this.placeItem(e, button, this.addCircleDataLayers, - (entity) => { - this.map.pm.setLang('en', { - tooltips: { - startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint', {entityName: entity.entity.entityDisplayName}), - finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint', {entityName: entity.entity.entityDisplayName}), - } - }, 'en'); - this.map.pm.enableDraw('Circle'); - // @ts-ignore - L.DomUtil.addClass(this.map.pm.Draw.Circle._hintMarker.getTooltip()._container, 'tb-place-item-label'); - } - ); + this.placeItem(e, button, this.addCircleDataLayers, (entity) => this.prepareDrawMode('Circle', { + startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint-with-entity', {entityName: entity.entity.entityDisplayName}), + })); } private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbMapDataLayer[], @@ -444,21 +378,12 @@ export abstract class TbMap { }); this.selectEntityToPlace(e, items).subscribe((entity) => { if (entity) { - - const finishAdd = () => { - this.map.off('pm:create'); - this.map.pm.disableDraw(); - this.dataLayers.forEach(dl => dl.enableEditMode()); - this.updatePlaceItemState(); - this.editToolbar.close(); - }; - this.map.once('pm:create', (e) => { entity.dataLayer.placeItem(entity, e.layer); // @ts-ignore e.layer._pmTempLayer = true; e.layer.remove(); - finishAdd(); + this.finishAdd(); }); prepareDrawMode(entity); @@ -471,7 +396,7 @@ export abstract class TbMap { iconClass: 'tb-close', title: this.ctx.translate.instant('action.cancel'), showText: true, - click: finishAdd + click: this.finishAdd } ], false); } else { @@ -507,6 +432,159 @@ export abstract class TbMap { } } + private setupCustomActions() { + if (!this.settings.mapActionButtons) { + return; + } + this.customActionsToolbar = L.TB.topToolbar({ + mapElement: $(this.mapElement), + iconRegistry: this.ctx.$injector.get(MatIconRegistry) + }); + + const mapActionButtons = this.settings.mapActionButtons; + + if (mapActionButtons.length) { + const customTranslate = this.ctx.$injector.get(CustomTranslatePipe); + + if (mapActionButtons.some(actionButton => actionButton.action.mapItemType === MapItemType.marker)) { + this.setPlaceMarkerStyle(); + } + + mapActionButtons.forEach(actionButton => { + const actionButtonConfig = { + icon: actionButton.icon, + color: actionButton.color, + title: customTranslate.transform(actionButton.label) + }; + const toolbarButton = this.customActionsToolbar.toolbarButton(actionButtonConfig); + if (actionButton.action.type !== WidgetActionType.placeMapItem) { + toolbarButton.onClick((e) => this.ctx.actionsApi.handleWidgetAction(e, actionButton.action)); + } else { + switch (actionButton.action.mapItemType) { + case MapItemType.marker: + toolbarButton.onClick((e, button) => this.createMarker(e, {button, action: actionButton.action})); + break; + case MapItemType.polygon: + toolbarButton.onClick((e, button) => this.createPolygon(e, {button, action: actionButton.action})); + break; + case MapItemType.rectangle: + toolbarButton.onClick((e, button) => this.createRectangle(e, {button, action: actionButton.action})); + break; + case MapItemType.circle: + toolbarButton.onClick((e, button) => this.createCircle(e, {button, action: actionButton.action})); + break; + } + } + }); + } + } + + private createMarker(e: MouseEvent, actionData: CustomActionData) { + this.createItem(e, actionData, () => this.prepareDrawMode('Marker', { + placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint') + })); + } + + private createRectangle(e: MouseEvent, actionData: CustomActionData): void { + this.createItem(e, actionData, () => this.prepareDrawMode('Rectangle', { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint'), + finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint') + })); + } + + private createPolygon(e: MouseEvent, actionData: CustomActionData): void { + this.createItem(e, actionData, () => this.prepareDrawMode('Polygon', { + firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint'), + continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint'), + finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint') + })); + } + + private createCircle(e: MouseEvent, actionData: CustomActionData): void { + this.createItem(e, actionData, () => this.prepareDrawMode('Circle', { + startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint'), + finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint') + })); + } + + private createItem(e: MouseEvent, actionData: CustomActionData, prepareDrawMode: () => void) { + if (this.isPlacingItem) { + return; + } + this.updatePlaceItemState(actionData.button); + + this.map.once('pm:create', (e) => { + this.ctx.actionsApi.handleWidgetAction(e as any, actionData.action, null, null, { + coordinates: convertLayerToCoordinates(actionData.action.mapItemType, e.layer), + layer: e.layer + }); + + // @ts-ignore + e.layer._pmTempLayer = true; + e.layer.remove(); + this.finishAdd(); + }); + + prepareDrawMode(); + + this.dataLayers.forEach(dl => dl.disableEditMode()); + + this.editToolbar.open([ + { + id: 'cancel', + iconClass: 'tb-close', + title: this.ctx.translate.instant('action.cancel'), + showText: true, + click: this.finishAdd + } + ], false); + + const convertLayerToCoordinates = (type: MapItemType, layer: L.Layer): {x: number; y: number} | TbPolygonRawCoordinates | TbCircleData => { + switch (type) { + case MapItemType.marker: + if (layer instanceof L.Marker) { + return this.latLngToLocationData(layer.getLatLng()); + } + return null; + case MapItemType.polygon: + if (layer instanceof L.Polygon) { + let coordinates: any = layer.getLatLngs(); + if (coordinates.length === 1) { + coordinates = coordinates[0]; + } + return this.coordinatesToPolygonData(coordinates); + } + return null; + case MapItemType.rectangle: + if (layer instanceof L.Rectangle) { + const bounds = layer.getBounds(); + return this.coordinatesToPolygonData([bounds.getNorthWest(), bounds.getSouthEast()]) + } + return null; + case MapItemType.circle: + if (layer instanceof L.Circle) { + return this.coordinatesToCircleData(layer.getLatLng(), layer.getRadius()); + } + return null; + } + } + } + + private finishAdd = () => { + this.map.off('pm:create'); + this.map.pm.disableDraw(); + this.dataLayers.forEach(dl => dl.enableEditMode()); + this.updatePlaceItemState(); + this.editToolbar.close(); + } + + private prepareDrawMode(shape: 'Marker' | 'Rectangle' | 'Polygon' | 'Circle', tooltipsTranslation: Record) { + this.map.pm.setLang('en', { tooltips: tooltipsTranslation }, 'en'); + this.map.pm.enableDraw(shape); + // @ts-ignore + L.DomUtil.addClass(this.map.pm.Draw[shape]._hintMarker.getTooltip()._container, 'tb-place-item-label'); + } + private updatePlaceItemState(addButton?: L.TB.ToolbarButton): void { if (addButton) { this.deselectItem(false, true); @@ -624,6 +702,7 @@ export abstract class TbMap { if (this.addCircleButton && this.addCircleButton !== this.currentAddButton) { this.addCircleButton.setDisabled(true); } + this.customActionsToolbar.setDisabled(true); } else { if (this.addMarkerButton) { this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); @@ -637,9 +716,27 @@ export abstract class TbMap { if (this.addCircleButton) { this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } + this.customActionsToolbar.setDisabled(false); } } + private setPlaceMarkerStyle() { + createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), MarkerShape.markerShape1, tinycolor('rgba(255,255,255,0.75)')).subscribe( + ((iconUrl) => { + const icon = L.icon({ + iconUrl, + iconSize: [40, 40], + iconAnchor: [20, 40] + }); + this.map.pm.setGlobalOptions({ + markerStyle: { + icon + } + }); + }) + ); + } + protected abstract defaultSettings(): S; protected abstract createMap(): Observable; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 947d394c2d..0c4b33c2bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -525,6 +525,13 @@ export const mapZoomActionTranslationMap = new Map( ] ); +export interface MapActionButtonSettings { + label?: string; + icon?: string; + color: string; + action: WidgetAction; +} + export interface BaseMapSettings { mapType: MapType; markers: MarkersDataLayerSettings[]; @@ -539,6 +546,7 @@ export interface BaseMapSettings { defaultZoomLevel: number; minZoomLevel: number; mapPageSize: number; + mapActionButtons: MapActionButtonSettings[]; } export const DEFAULT_MAP_PAGE_SIZE = 16384; @@ -557,9 +565,19 @@ export const defaultBaseMapSettings: BaseMapSettings = { defaultCenterPosition: '0,0', defaultZoomLevel: null, minZoomLevel: 16, - mapPageSize: DEFAULT_MAP_PAGE_SIZE + mapPageSize: DEFAULT_MAP_PAGE_SIZE, + mapActionButtons: [] }; +export const defaultMapActionButtonSettings: MapActionButtonSettings = { + label: '', + icon: 'add', + color: '#0000008a', + action: { + type: WidgetActionType.doNothing + } +} + export enum MapProvider { openstreet = 'openstreet', google = 'google', @@ -892,6 +910,11 @@ export interface MarkerIconInfo { size: [number, number]; } +export interface CustomActionData { + button: L.TB.TopToolbarButton; + action: WidgetAction; +} + export type MapStringFunction = (data: FormattedData, dsData: FormattedData[]) => string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html index 7a7900214e..09845cda2c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.html @@ -23,7 +23,8 @@ [callbacks]="callbacks" [widgetType]="widgetType" [withName]="withName" - [actionNames]="actionNames"> + [actionNames]="actionNames" + [additionalWidgetActionTypes]="additionalWidgetActionTypes">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts index a4df5e9250..efbb3fbafb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings-panel.component.ts @@ -17,7 +17,7 @@ import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; -import { WidgetAction, widgetType } from '@shared/models/widget.models'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { TranslateService } from '@ngx-translate/core'; @@ -53,6 +53,9 @@ export class WidgetActionSettingsPanelComponent implements OnInit { @Input() applyTitle = this.translate.instant('action.apply'); + @Input() + additionalWidgetActionTypes: WidgetActionType[]; + @Output() widgetActionApplied = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts index d860667d2a..b42262d593 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action-settings.component.ts @@ -29,7 +29,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { TranslateService } from '@ngx-translate/core'; -import { WidgetAction, widgetActionTypeTranslationMap, widgetType } from '@shared/models/widget.models'; +import { + WidgetAction, + WidgetActionType, + widgetActionTypeTranslationMap, + widgetType +} from '@shared/models/widget.models'; import { WidgetActionCallbacks } from '@home/components/widget/action/manage-widget-actions.component.models'; import { WidgetActionSettingsPanelComponent @@ -65,6 +70,9 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess @Input() disabled = false; + @Input() + additionalWidgetActionTypes: WidgetActionType[]; + modelValue: WidgetAction; displayValue: string; @@ -110,7 +118,8 @@ export class WidgetActionSettingsComponent implements OnInit, ControlValueAccess widgetAction: this.modelValue, panelTitle: this.panelTitle, widgetType: this.widgetType, - callbacks: this.callbacks + callbacks: this.callbacks, + additionalWidgetActionTypes: this.additionalWidgetActionTypes }; const widgetActionSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, this.viewContainerRef, WidgetActionSettingsPanelComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html index 643b36e32a..18dcaa9491 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html @@ -258,7 +258,21 @@ helpId="widget/action/custom_action_fn" > - + +
+
{{ 'widget-action.map-item-type' | translate }}
+ + + @for(type of mapItemTypes; track type) { + {{ mapItemTypeTranslationMap.get(type) | translate }} + } + + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts index a7ad209b85..f305b113cb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts @@ -15,17 +15,21 @@ /// import { - ControlValueAccessor, FormControl, + ControlValueAccessor, + FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, - Validator, ValidatorFn, + Validator, + ValidatorFn, Validators } from '@angular/forms'; import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { + MapItemType, + mapItemTypeTranslationMap, WidgetAction, WidgetActionType, widgetActionTypes, @@ -97,10 +101,25 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali @Input() actionNames: string[]; + @Input() + set additionalWidgetActionTypes(value: WidgetActionType[]) { + if (this.widgetActionFormGroup && !widgetActionTypes.includes(this.widgetActionFormGroup.get('type').value)) { + this.widgetActionFormGroup.get('type').setValue(WidgetActionType.doNothing); + } + if (value?.length) { + this.widgetActionTypes = widgetActionTypes.concat(value); + } else { + this.widgetActionTypes = widgetActionTypes; + } + } + widgetActionTypes = widgetActionTypes; widgetActionTypeTranslations = widgetActionTypeTranslationMap; widgetActionType = WidgetActionType; + mapItemTypes = Object.values(MapItemType) as MapItemType[]; + mapItemTypeTranslationMap = mapItemTypeTranslationMap; + allStateDisplayTypes = stateDisplayTypes; allPopoverPlacements = PopoverPlacements; @@ -171,6 +190,9 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali ).subscribe(() => { this.widgetActionUpdated(); }); + if (this.additionalWidgetActionTypes) { + this.widgetActionTypes = this.widgetActionTypes.concat(this.additionalWidgetActionTypes); + } } writeValue(widgetAction?: WidgetAction): void { @@ -307,6 +329,16 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali this.fb.control(action ? action.url : null, [Validators.required]) ); break; + case WidgetActionType.placeMapItem: + this.actionTypeFormGroup.addControl( + 'mapItemType', + this.fb.control(action?.mapItemType ?? MapItemType.marker, [Validators.required]) + ); + this.actionTypeFormGroup.addControl( + 'customAction', + this.fb.control(toCustomAction(action), [Validators.required]) + ); + break; } } this.actionTypeFormGroupSubscriptions.push( @@ -497,6 +529,12 @@ export class WidgetActionComponent implements ControlValueAccessor, OnInit, Vali let result: WidgetAction; if (type === WidgetActionType.customPretty) { result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.get('customAction').value}; + } else if (type === WidgetActionType.placeMapItem) { + result = { + ...this.widgetActionFormGroup.value, + ...this.actionTypeFormGroup.get('customAction').value, + mapItemType: this.actionTypeFormGroup.get('mapItemType').value + }; } else { result = {...this.widgetActionFormGroup.value, ...this.actionTypeFormGroup.value}; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html new file mode 100644 index 0000000000..70cc12dd27 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html @@ -0,0 +1,47 @@ + +
+ + + + warning + + + + + + +
+ +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts new file mode 100644 index 0000000000..a28e6fccb6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts @@ -0,0 +1,100 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, forwardRef, Output } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { MapActionButtonSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isEmptyStr } from '@core/utils'; + +@Component({ + selector: 'tb-map-action-button-row', + templateUrl: 'map-action-button-row.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }] +}) +export class MapActionButtonRowComponent implements ControlValueAccessor, Validator { + + @Output() + buttonRemoved = new EventEmitter(); + + mapActionButton = this.fb.group({ + label: [''], + icon: [''], + color: [''], + action: this.fb.control(null) + }, {validators: this.validateButtonConfig()}); + + additionalWidgetActionTypes = [WidgetActionType.placeMapItem]; + readonly widgetType = widgetType; + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder) { + this.mapActionButton.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => this.propagateChange(value)) + } + + registerOnChange(fn: any) { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { } + + setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.mapActionButton.disable({emitEvent: false}); + } else { + this.mapActionButton.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.mapActionButton.valid ? null : { + mapButtonAction: false + }; + } + + writeValue(value: MapActionButtonSettings) { + this.mapActionButton.patchValue(value, {emitEvent: false}); + } + + private validateButtonConfig() { + return (c: FormGroup) => { + return !c.value.icon && isEmptyStr(c.value.label) ? { + invalidButtonConfig: true + } : null; + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html new file mode 100644 index 0000000000..ddc4777134 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.html @@ -0,0 +1,63 @@ + +
+ + + {{ 'widgets.maps.map-action.map-action-buttons' | translate }} + + +
+
+
widgets.maps.map-action.label
+
widgets.maps.map-action.icon
+
widgets.maps.map-action.color
+
widgets.maps.map-action.action
+
+
+
+ @for (mapAction of mapActionButtonsForm.controls; track mapAction) { +
+ + +
+ +
+
+ } @empty { + {{ 'widgets.maps.map-action.no-action-buttons-configured' | translate }} + } +
+
+ +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts new file mode 100644 index 0000000000..8c304a7ca5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts @@ -0,0 +1,107 @@ +/// +/// Copyright © 2016-2024 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; +import { + defaultMapActionButtonSettings, + MapActionButtonSettings +} from '@home/components/widget/lib/maps/models/map.models'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Component({ + selector: 'tb-map-action-button-settings', + templateUrl: './map-action-buttons-settings.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }] +}) +export class MapActionButtonsSettingsComponent implements ControlValueAccessor, Validator { + + mapActionButtonsForm = this.fb.array([]); + + private propagateChange = (_val: any) => {}; + + constructor(private fb: FormBuilder) { + this.mapActionButtonsForm.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe(value => this.propagateChange(value)); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.mapActionButtonsForm.disable({emitEvent: false}); + } else { + this.mapActionButtonsForm.enable({emitEvent: false}); + } + } + + validate(): ValidationErrors | null { + return this.mapActionButtonsForm.valid ? null : { + mapActionButtons: false + }; + } + + writeValue(buttons: MapActionButtonSettings[] = []) { + if (buttons?.length === this.mapActionButtonsForm.length) { + this.mapActionButtonsForm.patchValue(buttons, {emitEvent: false}); + } else { + this.mapActionButtonsForm.clear({emitEvent: false}); + buttons.forEach( + button => this.mapActionButtonsForm.push(this.fb.control(button), {emitEvent: false}) + ); + } + } + + get dragEnabled(): boolean { + return this.mapActionButtonsForm.length > 1; + } + + buttonDrop(event: CdkDragDrop) { + const actionButton = this.mapActionButtonsForm.at(event.previousIndex); + this.mapActionButtonsForm.removeAt(event.previousIndex, {emitEvent: false}); + this.mapActionButtonsForm.insert(event.currentIndex, actionButton); + } + + addButton() { + this.mapActionButtonsForm.push(this.fb.control(defaultMapActionButtonSettings)); + } + + removeButton(index: number) { + this.mapActionButtonsForm.removeAt(index); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index d2bc0e33e4..0cf6b9d81f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -93,6 +93,8 @@
+ +
{{ 'widgets.maps.common.common-map-settings' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 3b5c57ecbe..32acc06c48 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -135,7 +135,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid useDefaultCenterPosition: [null, []], defaultCenterPosition: [null, []], defaultZoomLevel: [null, [Validators.min(0), Validators.max(20)]], - mapPageSize: [null, [Validators.min(1), Validators.required]] + mapPageSize: [null, [Validators.min(1), Validators.required]], + mapActionButtons: [null] }); this.mapSettingsFormGroup.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 351c203d3b..47680968a0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -236,6 +236,12 @@ import { import { MapTooltipTagActionsComponent } from '@home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component'; +import { + MapActionButtonsSettingsComponent +} from '@home/components/widget/lib/settings/common/map/map-action-buttons-settings.component'; +import { + MapActionButtonRowComponent +} from '@home/components/widget/lib/settings/common/map/map-action-button-row.component'; @NgModule({ declarations: [ @@ -313,6 +319,8 @@ import { DataLayerColorSettingsComponent, DataLayerColorSettingsPanelComponent, MapTooltipTagActionsComponent, + MapActionButtonsSettingsComponent, + MapActionButtonRowComponent, DataLayerPatternSettingsComponent, MarkerShapeSettingsComponent, MarkerShapesComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts index c16c61e264..31cae4717e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-component.service.ts @@ -40,11 +40,12 @@ import { migrateWidgetTypeToDynamicForms, Widget, widgetActionSources, + WidgetActionType, WidgetControllerDescriptor, WidgetType } from '@shared/models/widget.models'; import { catchError, map, mergeMap, switchMap, tap } from 'rxjs/operators'; -import { isFunction, isUndefined } from '@core/utils'; +import { isDefinedAndNotNull, isFunction, isUndefined } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; import { DynamicWidgetComponent } from '@home/components/widget/dynamic-widget.component'; import { WidgetComponentsModule } from '@home/components/widget/widget-components.module'; @@ -654,6 +655,13 @@ export class WidgetComponentService { if (isUndefined(result.typeParameters.targetDeviceOptional)) { result.typeParameters.targetDeviceOptional = false; } + if (isDefinedAndNotNull(result.typeParameters.additionalWidgetActionTypes)) { + if (Array.isArray(result.typeParameters.additionalWidgetActionTypes)) { + result.typeParameters.additionalWidgetActionTypes = result.typeParameters.additionalWidgetActionTypes.filter(type => WidgetActionType[type]); + } else { + result.typeParameters.additionalWidgetActionTypes = null; + } + } if (isFunction(widgetTypeInstance.actionSources)) { result.actionSources = widgetTypeInstance.actionSources(); } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 9e2b1bf1fb..7af678217e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -164,6 +164,7 @@ [callbacks]="widgetConfigCallbacks" [widgetType] = "modelValue.widgetType" [actionSources]="modelValue.actionSources" + [additionalWidgetActionTypes]="modelValue.typeParameters.additionalWidgetActionTypes" formControlName="actions">
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 5dc88a0bce..1850beb326 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -1149,6 +1149,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, ) } break; + case WidgetActionType.placeMapItem: case WidgetActionType.customPretty: const customPrettyFunction = descriptor.customFunction; const customHtml = descriptor.customHtml; diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 36d8966c6a..860693fbf8 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -192,6 +192,7 @@ export interface WidgetTypeParameters { dataKeySettingsFunction?: DataKeySettingsFunction; displayRpcMessageToast?: boolean; targetDeviceOptional?: boolean; + additionalWidgetActionTypes?: WidgetActionType[]; } export interface WidgetControllerDescriptor { @@ -575,7 +576,8 @@ export enum WidgetActionType { custom = 'custom', customPretty = 'customPretty', mobileAction = 'mobileAction', - openURL = 'openURL' + openURL = 'openURL', + placeMapItem = 'placeMapItem' } export enum WidgetMobileActionType { @@ -589,7 +591,15 @@ export enum WidgetMobileActionType { takeScreenshot = 'takeScreenshot' } -export const widgetActionTypes = Object.keys(WidgetActionType) as WidgetActionType[]; +export enum MapItemType { + marker = 'marker', + polygon = 'polygon', + rectangle = 'rectangle', + circle = 'circle' +} + +export const widgetActionTypes = Object.keys(WidgetActionType) + .filter(value => value !== WidgetActionType.placeMapItem) as WidgetActionType[]; export const widgetActionTypeTranslationMap = new Map( [ @@ -600,7 +610,8 @@ export const widgetActionTypeTranslationMap = new Map( [ WidgetActionType.custom, 'widget-action.custom' ], [ WidgetActionType.customPretty, 'widget-action.custom-pretty' ], [ WidgetActionType.mobileAction, 'widget-action.mobile-action' ], - [ WidgetActionType.openURL, 'widget-action.open-URL' ] + [ WidgetActionType.openURL, 'widget-action.open-URL' ], + [ WidgetActionType.placeMapItem, 'widget-action.place-map-item' ], ] ); @@ -617,6 +628,15 @@ export const widgetMobileActionTypeTranslationMap = new Map( + [ + [ MapItemType.marker, 'widget-action.map-item.marker' ], + [ MapItemType.polygon, 'widget-action.map-item.polygon' ], + [ MapItemType.rectangle, 'widget-action.map-item.rectangle' ], + [ MapItemType.circle, 'widget-action.map-item.circle' ], + ] +) + export interface MobileLaunchResult { launched: boolean; } @@ -714,6 +734,7 @@ export interface WidgetAction extends CustomActionDescriptor { stateEntityParamName?: string; mobileAction?: WidgetMobileActionDescriptor; url?: string; + mapItemType?: MapItemType; } export interface WidgetActionDescriptor extends WidgetAction { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ee9d2b7262..9c7eb49684 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -6403,7 +6403,15 @@ "take-screenshot": "Take screenshot" }, "custom-action-function": "Custom action function", - "custom-pretty-function": "Custom action (with HTML template) function" + "custom-pretty-function": "Custom action (with HTML template) function", + "map-item-type": "Map item type", + "map-item": { + "marker": "Marker", + "polygon": "Polygon", + "rectangle": "Rectangle", + "circle": "Circle" + }, + "place-map-item": "Place map item" }, "widgets-bundle": { "current": "Current bundle", @@ -7638,6 +7646,18 @@ "zoom-double-click": "Double click", "zoom-control-buttons": "Control buttons" }, + "map-action": { + "map-action-buttons": "Map action buttons", + "label": "Label", + "icon": "Icon", + "color": "Color", + "action": "Action", + "add-button": "Add button", + "no-action-buttons-configured": "No action buttons configured", + "remove-action-button": "Remove action button", + "map-action-button": "Map action button", + "button-requires": "Button requires label or icon" + }, "common": { "common-map-settings": "Common map settings", "fit-map-bounds": "Fit map bounds to cover all markers", @@ -7796,7 +7816,8 @@ "edit": "Edit marker", "remove-marker-for": "Remove marker for '{{entityName}}'", "place-marker": "Place marker", - "place-marker-hint": "Click to place '{{entityName}}' entity" + "place-marker-hint": "Click to place marker", + "place-marker-hint-with-entity": "Click to place '{{entityName}}' entity" }, "polygon": { "polygon-key": "Polygon key", @@ -7814,11 +7835,16 @@ "polygon-place-first-point-cut-hint": "Click to place first point", "continue-polygon-cut-hint": "Click to continue drawing", "finish-polygon-cut-hint": "Click first marker to finish and save", - "polygon-place-first-point-hint": "Polygon for '{{entityName}}': click to place first point", - "continue-polygon-hint": "Polygon for '{{entityName}}': click to continue drawing", - "finish-polygon-hint": "Polygon for '{{entityName}}': click first marker to finish and save", - "rectangle-place-first-point-hint": "Rectangle for '{{entityName}}': click to place first point", - "finish-rectangle-hint": "Rectangle for '{{entityName}}': click to finish and save" + "polygon-place-first-point-hint": "Polygon: click to place first point", + "polygon-place-first-point-hint-with-entity": "Polygon for '{{entityName}}': click to place first point", + "continue-polygon-hint": "Polygon: click to continue drawing", + "continue-polygon-hint-with-entity": "Polygon for '{{entityName}}': click to continue drawing", + "finish-polygon-hint": "Polygon: click first marker to finish drawing", + "finish-polygon-hint-with-entity": "Polygon for '{{entityName}}': click first marker to finish and save", + "rectangle-place-first-point-hint": "Rectangle: click to place first point", + "rectangle-place-first-point-hint-with-entity": "Rectangle for '{{entityName}}': click to place first point", + "finish-rectangle-hint": "Rectangle: click to finish drawing", + "finish-rectangle-hint-with-entity": "Rectangle for '{{entityName}}': click to finish and save" }, "circle": { "circle-key": "Circle key", @@ -7830,8 +7856,10 @@ "edit": "Edit circle", "remove-circle-for": "Remove circle for '{{entityName}}'", "draw-circle": "Draw circle", - "place-circle-center-hint": "Circle for '{{entityName}}': click to place circle center", - "finish-circle-hint": "Circle for '{{entityName}}': click to finish and save circle" + "place-circle-center-hint-with-entity": "Circle for '{{entityName}}': click to place circle center", + "place-circle-center-hint": "Circle: click to place circle center", + "finish-circle-hint-with-entity": "Circle for '{{entityName}}': click to finish and save circle", + "finish-circle-hint": "Circle: click to finish drawing" }, "select-entity": "Select entity", "select-entity-hint": "Hint: after selection click at the map to set position" diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index 26084fabb5..dee27287c2 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -17,6 +17,7 @@ import { FormattedData } from '@shared/models/widget.models'; import L, { Control, ControlOptions } from 'leaflet'; import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; +import { MatIconRegistry } from '@angular/material/icon'; // redeclare module, maintains compatibility with @types/leaflet declare module 'leaflet' { @@ -91,22 +92,28 @@ declare module 'leaflet' { interface TopToolbarButtonOptions { icon: string; - iconColor?: string; + color?: string; title: string; } class TopToolbarButton { - constructor(options: TopToolbarButtonOptions); - onClick(onClick: (e: MouseEvent) => void): void; + constructor(options: TopToolbarButtonOptions, iconRegistry: MatIconRegistry); + onClick(onClick: (e: MouseEvent, button: TopToolbarButton) => void): void; + setActive(active: boolean): void; + isActive(): boolean; + setDisabled(disabled: boolean): void; + isDisabled(): boolean; } interface TopToolbarControlOptions { mapElement: JQuery; + iconRegistry: MatIconRegistry; } class TopToolbarControl { constructor(options: TopToolbarControlOptions); toolbarButton(options: TopToolbarButtonOptions): TopToolbarButton; + setDisabled(disabled: boolean): void; } interface ToolbarButtonOptions { From 56fdaf173f0e8977366fc28a765f3ad4176398b4 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 26 Feb 2025 19:35:13 +0200 Subject: [PATCH 041/127] UI: Maps - Trips data layer. --- .../data/json/system/widget_bundles/maps.json | 1 + .../json/system/widget_types/trip_map.json | 47 ++ ui-ngx/src/app/core/utils.ts | 12 +- .../basic/map/map-basic-config.component.html | 3 + .../basic/map/map-basic-config.component.ts | 28 +- .../lib/maps/data-layer/map-data-layer.ts | 3 +- .../lib/maps/data-layer/trips-data-layer.ts | 416 ++++++++++++++++++ .../home/components/widget/lib/maps/map.scss | 9 +- .../home/components/widget/lib/maps/map.ts | 192 ++++++-- .../widget/lib/maps/models/map.models.ts | 102 ++++- .../panels/map-timeline-panel.component.html | 26 ++ .../panels/map-timeline-panel.component.scss | 19 + .../panels/map-timeline-panel.component.ts | 68 +++ .../common/map/map-settings.component.ts | 4 + .../widget/widget-components.module.ts | 2 + .../widget/widget-config.component.html | 6 + .../assets/locale/locale.constant-en_US.json | 4 + 17 files changed, 898 insertions(+), 44 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/trip_map.json create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts diff --git a/application/src/main/data/json/system/widget_bundles/maps.json b/application/src/main/data/json/system/widget_bundles/maps.json index 7060499f24..3e12e5a662 100644 --- a/application/src/main/data/json/system/widget_bundles/maps.json +++ b/application/src/main/data/json/system/widget_bundles/maps.json @@ -11,6 +11,7 @@ "widgetTypeFqns": [ "map", "image_map", + "trip_map", "maps_v2.openstreetmap", "maps_v2.google_maps", "maps_v2.image_map", diff --git a/application/src/main/data/json/system/widget_types/trip_map.json b/application/src/main/data/json/system/widget_types/trip_map.json new file mode 100644 index 0000000000..e64d491779 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/trip_map.json @@ -0,0 +1,47 @@ +{ + "fqn": "trip_map", + "name": "Trip Map", + "deprecated": false, + "image": "tb-image;/api/images/system/trip_animation_system_widget_image.png", + "description": "Displays the trip of the entity on the OpenStreetMap or other map providers. Allows to scroll and animate the movement of the entity. Highly customizable via custom markers, marker tooltips, and widget actions.", + "descriptor": { + "type": "timeseries", + "sizeX": 8.5, + "sizeY": 6, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.mapWidget.onInit();\n};\n\nself.typeParameters = function() {\n return {\n trip: true,\n hideDataTab: true,\n hideDataSettings: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n datasourcesOptional: true\n };\n}", + "settingsForm": [], + "dataKeySettingsForm": [], + "latestDataKeySettingsForm": [], + "settingsDirective": "tb-map-widget-settings", + "dataKeySettingsDirective": "", + "latestDataKeySettingsDirective": "", + "hasBasicMode": true, + "basicModeDirective": "tb-map-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"history\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":500}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"mapType\":\"geoMap\",\"layers\":[{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.Mapnik\"},{\"provider\":\"openstreet\",\"layerType\":\"OpenStreetMap.HOT\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldStreetMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldTopoMap\"},{\"provider\":\"openstreet\",\"layerType\":\"Esri.WorldImagery\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.Positron\"},{\"provider\":\"openstreet\",\"layerType\":\"CartoDB.DarkMatter\"}],\"trips\":[],\"markers\":[],\"polygons\":[],\"circles\":[],\"additionalDataSources\":[],\"controlsPosition\":\"topleft\",\"zoomActions\":[\"scroll\",\"doubleClick\",\"controlButtons\"],\"fitMapBounds\":true,\"useDefaultCenterPosition\":false,\"defaultCenterPosition\":\"0,0\",\"defaultZoomLevel\":null,\"minZoomLevel\":16,\"mapPageSize\":16384,\"tripTimeline\":{\"showTimelineControl\":true,\"timeStep\":1000,\"speedOptions\":[1,5,10,15,25],\"showTimestamp\":true,\"timestampFormat\":{\"format\":\"yyyy-MM-dd HH:mm:ss\",\"lastUpdateAgo\":false,\"custom\":false,\"auto\":false},\"snapToRealLocation\":false,\"locationSnapFilter\":\"return true;\"},\"background\":{\"type\":\"color\",\"color\":\"#fff\",\"overlay\":{\"enabled\":false,\"color\":\"rgba(255,255,255,0.72)\",\"blur\":3}},\"padding\":\"8px\"},\"title\":\"Trip Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":null,\"mobileHeight\":null,\"configMode\":\"basic\",\"actions\":{},\"showTitleIcon\":false,\"titleIcon\":\"assistant_navigation\",\"iconColor\":\"#1F6BDD\",\"useDashboardTimewindow\":false,\"displayTimewindow\":true,\"titleFont\":{\"size\":null,\"sizeUnit\":\"px\",\"family\":null,\"weight\":null,\"style\":null,\"lineHeight\":null},\"titleColor\":null,\"titleTooltip\":\"\",\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"units\":\"\",\"decimals\":null,\"noDataDisplayMessage\":\"\",\"timewindowStyle\":{\"showIcon\":false,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":true},\"margin\":\"0px\",\"borderRadius\":\"0px\",\"iconSize\":\"24px\"}" + }, + "resources": [ + { + "link": "/api/images/system/trip_animation_system_widget_image.png", + "title": "\"Trip Animation\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "trip_animation_system_widget_image.png", + "publicResourceKey": "3UKAE6mZvW6bnIhr7NAtYTG8FbIDFY1e", + "mediaType": "image/png", + "data": "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", + "public": true + } + ], + "scada": false, + "tags": [ + "mapping", + "gps", + "navigation", + "geolocation", + "satellite", + "directions" + ] +} \ No newline at end of file diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 41a7c588ed..83c253f64c 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -512,18 +512,20 @@ export function formattedDataFormDatasourceData el.datasource.entityName) +export function formattedDataArrayFromDatasourceData(input: DatasourceData[], + groupFunction: (el: DatasourceData) => any = + (el) => el.datasource.entityName + el.datasource.entityType): FormattedData[][] { + return _(input).groupBy(groupFunction) .values().value().map((entityArray, dsIndex) => { - const timeDataMap: {[time: number]: FormattedData} = {}; + const timeDataMap: {[time: number]: FormattedData} = {}; entityArray.filter(e => e.data.length).forEach(entity => { entity.data.forEach(tsData => { const time = tsData[0]; const value = tsData[1]; let data = timeDataMap[time]; if (!data) { - const datasource = entity.datasource; - data = formattedDataFromDatasource(datasource, dsIndex); + const datasource = entity.datasource as D; + data = formattedDataFromDatasource(datasource, dsIndex); data.time = time; timeDataMap[time] = data; } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html index b2803c4d50..dcefd50801 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -16,7 +16,10 @@ --> + + , protected widgetConfigComponent: WidgetConfigComponent, private $injector: Injector, @@ -46,6 +52,14 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { return this.mapWidgetConfigForm; } + protected setupConfig(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.trip)) { + this.trip = params.trip === true; + } + super.setupConfig(widgetConfig); + } + protected setupDefaults(configData: WidgetConfigComponentData) { const settings = configData.config.settings as MapWidgetSettings; if (settings?.markers?.length) { @@ -57,6 +71,11 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { if (settings?.circles?.length) { settings.circles = []; } + if (this.trip) { + if (settings?.trips?.length) { + settings.trips = []; + } + } } protected onConfigSet(configData: WidgetConfigComponentData) { @@ -85,10 +104,15 @@ export class MapBasicConfigComponent extends BasicWidgetConfigComponent { actions: [configData.config.actions || {}, []] }); + if (this.trip) { + this.mapWidgetConfigForm.addControl('timewindowConfig', this.fb.control(getTimewindowConfig(configData.config))) + } } protected prepareOutputConfig(config: any): WidgetConfigComponentData { - + if (this.trip) { + setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); + } this.widgetConfig.config.settings = config.mapSettings || {}; this.widgetConfig.config.showTitle = config.showTitle; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 2d29de3e79..8916931d2b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -306,12 +306,13 @@ export abstract class TbDataLayerItem; private pattern: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts new file mode 100644 index 0000000000..354fa4eb0f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -0,0 +1,416 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + DataLayerColorProcessor, + DataLayerPatternProcessor, + MapDataLayerType, TbDataLayerItem, + TbMapDataLayer, + UnplacedMapDataItem +} from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { + BaseMarkerShapeSettings, ClusterMarkerColorFunction, + DataLayerColorType, defaultBaseMarkersDataLayerSettings, defaultBaseTripsDataLayerSettings, + loadImageWithAspect, + MapStringFunction, MapType, + MarkerIconInfo, + MarkerIconSettings, + MarkerImageFunction, + MarkerImageInfo, + MarkerImageSettings, + MarkerImageType, + MarkerPositionFunction, MarkersDataLayerSettings, + MarkerShapeSettings, + MarkerType, + TbMapDatasource, + TripsDataLayerSettings +} from '@home/components/widget/lib/maps/models/map.models'; +import { forkJoin, Observable, of } from 'rxjs'; +import { FormattedData } from '@shared/models/widget.models'; +import { + createColorMarkerIconElement, + createColorMarkerShapeURI, + MarkerShape +} from '@home/components/widget/lib/maps/models/marker-shape.models'; +import tinycolor from 'tinycolor2'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import L from 'leaflet'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; +import { isDefined, isDefinedAndNotNull, parseTbFunction, safeExecuteTbFunction } from '@core/utils'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; + +abstract class TripMarkerIconProcessor { + + static fromSettings(dataLayer: TbTripsDataLayer, + settings: TripsDataLayerSettings): TripMarkerIconProcessor { + switch (settings.markerType) { + case MarkerType.shape: + return new ShapeTripMarkerIconProcessor(dataLayer, settings.markerShape); + case MarkerType.icon: + return new IconTripMarkerIconProcessor(dataLayer, settings.markerIcon); + case MarkerType.image: + return new ImageTripMarkerIconProcessor(dataLayer, settings.markerImage); + } + } + + protected constructor(protected dataLayer: TbTripsDataLayer, + protected settings: S) {} + + public abstract setup(): Observable; + + public abstract createMarkerIcon(data: FormattedData, + dsData: FormattedData[]): Observable; + +} + +abstract class BaseColorTripMarkerShapeProcessor extends TripMarkerIconProcessor { + + private markerColorFunction: CompiledTbFunction; + + protected constructor(protected dataLayer: TbTripsDataLayer, + protected settings: S) { + super(dataLayer, settings); + } + + public setup(): Observable { + const colorSettings = this.settings.color; + if (colorSettings.type === DataLayerColorType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, colorSettings.colorFunction, ['data', 'dsData']).pipe( + map((parsed) => { + this.markerColorFunction = parsed; + return null; + }) + ); + } else { + return of(null); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + const colorSettings = this.settings.color; + let color: tinycolor.Instance; + if (colorSettings.type === DataLayerColorType.function) { + const functionColor = safeExecuteTbFunction(this.markerColorFunction, [data, dsData]); + if (isDefinedAndNotNull(functionColor)) { + color = tinycolor(functionColor); + } else { + color = tinycolor(colorSettings.color); + } + } else { + color = tinycolor(colorSettings.color); + } + return this.createMarkerShape(color, data.rotationAngle, this.settings.size); + } + + protected abstract createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable; +} + +class ShapeTripMarkerIconProcessor extends BaseColorTripMarkerShapeProcessor { + + constructor(protected dataLayer: TbTripsDataLayer, + protected settings: MarkerShapeSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable { + return this.dataLayer.createColoredMarkerShape(this.settings.shape, color, rotationAngle, size); + } + +} + +class IconTripMarkerIconProcessor extends BaseColorTripMarkerShapeProcessor { + + constructor(protected dataLayer: TbTripsDataLayer, + protected settings: MarkerIconSettings) { + super(dataLayer, settings); + } + + protected createMarkerShape(color: tinycolor.Instance, rotationAngle: number, size: number): Observable { + return this.dataLayer.createColoredMarkerIcon(this.settings.icon, color, rotationAngle, size); + } + +} + +class ImageTripMarkerIconProcessor extends TripMarkerIconProcessor { + + private markerImageFunction: CompiledTbFunction; + + constructor(protected dataLayer: TbTripsDataLayer, + protected settings: MarkerImageSettings) { + super(dataLayer, settings); + } + + public setup(): Observable { + if (this.settings.type === MarkerImageType.function) { + return parseTbFunction(this.dataLayer.getCtx().http, this.settings.imageFunction, ['data', 'images', 'dsData']).pipe( + map((parsed) => { + this.markerImageFunction = parsed; + return null; + }) + ); + } else { + return of(null); + } + } + + public createMarkerIcon(data: FormattedData, dsData: FormattedData[]): Observable { + if (this.settings.type === MarkerImageType.function) { + const currentImage: MarkerImageInfo = safeExecuteTbFunction(this.markerImageFunction, [data, this.settings.images, dsData]); + return this.loadMarkerIconInfo(currentImage, data.rotationAngle); + } else { + const currentImage: MarkerImageInfo = { + url: this.settings.image, + size: this.settings.imageSize || 34 + }; + return this.loadMarkerIconInfo(currentImage, data.rotationAngle); + } + } + + private loadMarkerIconInfo(image: MarkerImageInfo, rotationAngle = 0): Observable { + if (image && image.url) { + return loadImageWithAspect(this.dataLayer.getCtx().$injector.get(ImagePipe), image.url).pipe( + switchMap((aspectImage) => { + if (aspectImage?.aspect) { + let width: number; + let height: number; + if (aspectImage.aspect > 1) { + width = image.size; + height = image.size / aspectImage.aspect; + } else { + width = image.size * aspectImage.aspect; + height = image.size; + } + let iconAnchor = image.markerOffset; + let popupAnchor = image.tooltipOffset; + if (!iconAnchor) { + iconAnchor = [width * this.dataLayer.markerOffset[0], height * this.dataLayer.markerOffset[1]]; + } + if (!popupAnchor) { + popupAnchor = [width * this.dataLayer.tooltipOffset[0], height * this.dataLayer.tooltipOffset[1]]; + } + const style = `background-image: url(${aspectImage.url}); transform: rotate(${rotationAngle}deg); height: ${height}px; width: ${width}px;`; + const icon = L.divIcon({ + html: `
`, + className: 'tb-marker-div-icon', + iconSize: [width, height], + iconAnchor, + popupAnchor + }); + const iconInfo: MarkerIconInfo = { + size: [width, height], + icon + }; + return of(iconInfo); + } else { + return this.dataLayer.createDefaultMarkerIcon(rotationAngle); + } + }), + catchError(() => this.dataLayer.createDefaultMarkerIcon(rotationAngle)) + ); + } else { + return this.dataLayer.createDefaultMarkerIcon(rotationAngle); + } + } +} + +export class TbTripsDataLayer extends TbMapDataLayer { + + public tripMarkerIconProcessor: TripMarkerIconProcessor; + + public markerOffset: L.LatLngTuple; + public tooltipOffset: L.LatLngTuple; + + public pathStrokeColorProcessor: DataLayerColorProcessor; + public pointColorProcessor: DataLayerColorProcessor; + public pointTooltipProcessor: DataLayerPatternProcessor; + + private positionFunction: CompiledTbFunction; + + private rawTripsData: FormattedData[][]; + private latestTripsData: FormattedData[]; + + constructor(protected map: TbMap, + inputSettings: TripsDataLayerSettings) { + super(map, inputSettings); + } + + public dataLayerType(): MapDataLayerType { + return MapDataLayerType.trip; + } + + public placeItem(item: UnplacedMapDataItem, layer: L.Layer): void { + throw new Error('Not implemented!'); + } + + protected setupDatasource(datasource: TbMapDatasource): TbMapDatasource { + datasource.dataKeys = [this.settings.xKey, this.settings.yKey]; + if (this.settings.additionalDataKeys?.length) { + const tsKeys = this.settings.additionalDataKeys.filter(key => key.type === DataKeyType.timeseries); + const latestKeys = this.settings.additionalDataKeys.filter(key => key.type !== DataKeyType.timeseries); + datasource.dataKeys.push(...tsKeys); + if (latestKeys.length) { + datasource.latestDataKeys = latestKeys; + } + } + return datasource; + } + + protected defaultBaseSettings(map: TbMap): Partial { + return defaultBaseTripsDataLayerSettings(map.type()); + } + + protected doSetup(): Observable { + this.markerOffset = [ + isDefined(this.settings.markerOffsetX) ? this.settings.markerOffsetX : 0.5, + isDefined(this.settings.markerOffsetY) ? this.settings.markerOffsetY : 1, + ]; + this.tooltipOffset = [ + isDefined(this.settings.tooltip?.offsetX) ? this.settings.tooltip?.offsetX : 0, + isDefined(this.settings.tooltip?.offsetY) ? this.settings.tooltip?.offsetY : -1, + ]; + this.tripMarkerIconProcessor = TripMarkerIconProcessor.fromSettings(this, this.settings); + const setup$: Observable[] = []; + if (this.map.type() === MapType.image) { + setup$.push( + parseTbFunction(this.getCtx().http, this.settings.positionFunction, ['origXPos', 'origYPos', 'data', 'dsData', 'aspect']).pipe( + map((parsed) => { + this.positionFunction = parsed; + return null; + }) + ) + ); + } + setup$.push(this.tripMarkerIconProcessor.setup()); + + if (this.settings.showPath) { + this.pathStrokeColorProcessor = new DataLayerColorProcessor(this, this.settings.pathStrokeColor); + setup$.push(this.pathStrokeColorProcessor.setup()); + } + + if (this.settings.showPoints) { + this.pointColorProcessor = new DataLayerColorProcessor(this, this.settings.pointColor); + setup$.push(this.pointColorProcessor.setup()); + if (this.settings.pointTooltip?.show) { + this.pointTooltipProcessor = new DataLayerPatternProcessor(this, this.settings.pointTooltip); + setup$.push(this.pointTooltipProcessor.setup()); + } + } + return forkJoin(setup$).pipe(map(() => null)); + } + + protected isValidLayerData(_layerData: FormattedData): boolean { + throw new Error('Not implemented!'); + } + + protected createLayerItem(_data: FormattedData, _dsData: FormattedData[]): TbDataLayerItem { + throw new Error('Not implemented!'); + } + + public updateData(_dsData: FormattedData[]) { + throw new Error('Not implemented!'); + } + + public prepareTripsData(tripsData: FormattedData[][], tripsLatestData: FormattedData[]): {minTime: number; maxTime: number} { + let minTime = Infinity; + let maxTime = -Infinity; + this.rawTripsData = + tripsData.filter(d => d[0].$datasource.mapDataIds.includes(this.mapDataId)).map( + item => this.clearIncorrectFirsLastDatapoint(item)).filter(arr => arr.length); + this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + this.rawTripsData.forEach((dataSource) => { + minTime = Math.min(dataSource[0].time, minTime); + maxTime = Math.max(dataSource[dataSource.length - 1].time, maxTime); + }); + + return {minTime, maxTime}; + } + + public updateTrips(minTime: number, maxTime: number) { + console.log(`Update trips: min(${minTime}), max(${maxTime})`); + } + + public updateTripsLatestData(tripsLatestData: FormattedData[]) { + this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + } + + private clearIncorrectFirsLastDatapoint(dataSource: FormattedData[]): FormattedData[] { + const firstHistoricalDataIndexCoordinate = dataSource.findIndex(this.findFirstHistoricalDataIndexCoordinate); + if (firstHistoricalDataIndexCoordinate === -1) { + return []; + } + let lastIndex = dataSource.length - 1; + for (lastIndex; lastIndex > 0; lastIndex--) { + if (this.findFirstHistoricalDataIndexCoordinate(dataSource[lastIndex])) { + lastIndex++; + break; + } + } + if (firstHistoricalDataIndexCoordinate > 0 || lastIndex < dataSource.length) { + return dataSource.slice(firstHistoricalDataIndexCoordinate, lastIndex); + } + return dataSource; + } + + private findFirstHistoricalDataIndexCoordinate = (item: FormattedData): boolean => { + return isDefined(item[this.settings.xKey.label]) && isDefined(item[this.settings.yKey.label]); + } + + public createDefaultMarkerIcon(rotationAngle = 0): Observable { + const color = this.settings.markerShape?.color?.color || '#307FE5'; + return this.createColoredMarkerShape(MarkerShape.markerShape1, tinycolor(color), rotationAngle); + } + + public createColoredMarkerShape(shape: MarkerShape, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable { + return createColorMarkerShapeURI(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), shape, color).pipe( + map((iconUrl) => { + const style = `background-image: url(${iconUrl}); transform: rotate(${rotationAngle}deg); height: ${size}px; width: ${size}px;`; + return { + size: [size, size], + icon: L.divIcon({ + html: `
`, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + + public createColoredMarkerIcon(icon: string, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable { + return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( + map((element) => { + element.style.transform = `rotate(${rotationAngle}deg)`; + return { + size: [size, size], + icon: L.divIcon({ + html: element.outerHTML, + className: 'tb-marker-div-icon', + iconSize: [size, size], + iconAnchor: [size * this.markerOffset[0], size * this.markerOffset[1]], + popupAnchor: [size * this.tooltipOffset[0], size * this.tooltipOffset[1]] + }) + }; + }) + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 1baeaffb87..ac8684e2bb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -15,7 +15,7 @@ */ @import '../../../../../../../scss/constants'; -.tb-map-layout { +.tb-map-container, .tb-map-layout { position: relative; display: flex; width: 100%; @@ -23,6 +23,13 @@ min-width: 0; min-height: 0; flex: 1; +} + +.tb-map-container { + flex-direction: column; +} + +.tb-map-layout { &.tb-sidebar-left { flex-direction: row-reverse; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index b3b2ebe343..968f911c1f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -28,7 +28,13 @@ import { TbPolygonRawCoordinates } from '@home/components/widget/lib/maps/models/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; -import { formattedDataFormDatasourceData, isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; +import { + formattedDataArrayFromDatasourceData, + formattedDataFormDatasourceData, + isDefined, + isDefinedAndNotNull, + mergeDeepIgnoreArray +} from '@core/utils'; import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; @@ -61,6 +67,9 @@ import { DomSanitizer } from '@angular/platform-browser'; import tinycolor from 'tinycolor2'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; +import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; +import { ComponentRef } from '@angular/core'; +import { TbTripsDataLayer } from '@home/components/widget/lib/maps/data-layer/trips-data-layer'; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -76,11 +85,13 @@ export abstract class TbMap { protected southWest = new L.LatLng(-L.Projection.SphericalMercator['MAX_LATITUDE'], -180); protected northEast = new L.LatLng(L.Projection.SphericalMercator['MAX_LATITUDE'], 180); - protected dataLayers: TbMapDataLayer[]; + protected dataLayers: TbMapDataLayer[]; + protected tripDataLayers: TbTripsDataLayer[]; protected dsData: FormattedData[]; protected selectedDataItem: TbDataLayerItem; + protected mapLayoutElement: HTMLElement; protected mapElement: HTMLElement; protected sidebar: L.TB.SidebarControl; @@ -93,6 +104,9 @@ export abstract class TbMap { protected addPolygonButton: L.TB.ToolbarButton; protected addCircleButton: L.TB.ToolbarButton; + protected timeLineComponentRef: ComponentRef; + protected timeLineComponent: MapTimelinePanelComponent; + protected addMarkerDataLayers: TbMapDataLayer[]; protected addPolygonDataLayers: TbMapDataLayer[]; protected addCircleDataLayers: TbMapDataLayer[]; @@ -114,9 +128,27 @@ export abstract class TbMap { this.settings = mergeDeepIgnoreArray({} as S, this.defaultSettings(), this.inputSettings as S); $(containerElement).empty(); - $(containerElement).addClass('tb-map-layout'); + $(containerElement).addClass('tb-map-container'); + const mapLayoutElement = $('
'); + this.mapLayoutElement = mapLayoutElement[0]; + $(containerElement).append(mapLayoutElement); + + if (this.settings.tripTimeline?.showTimelineControl) { + this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); + this.timeLineComponent = this.timeLineComponentRef.instance; + this.timeLineComponent.settings = this.settings.tripTimeline; + this.timeLineComponent.timeChanged.subscribe((time) => { + console.log(`Time updated: ${time}`); + }); + const parentElement = this.timeLineComponentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + containerElement.append(content); + } + const mapElement = $('
'); - $(containerElement).append(mapElement); + mapLayoutElement.append(mapElement); this.mapResize$ = new ResizeObserver(() => { this.resize(); @@ -173,6 +205,7 @@ export abstract class TbMap { private setupDataLayers() { this.dataLayers = []; + this.tripDataLayers = []; if (this.settings.markers) { this.dataLayers.push(...this.settings.markers.map(settings => new TbMarkersDataLayer(this, settings))); } @@ -182,10 +215,14 @@ export abstract class TbMap { if (this.settings.circles) { this.dataLayers.push(...this.settings.circles.map(settings => new TbCirclesDataLayer(this, settings))); } - if (this.dataLayers.length) { + if (this.settings.trips) { + this.tripDataLayers.push(...this.settings.trips.map(settings => new TbTripsDataLayer(this, settings))); + } + if (this.dataLayers.length || this.tripDataLayers.length) { const groupsMap = new Map(); const customTranslate = this.ctx.$injector.get(CustomTranslatePipe); - this.dataLayers.forEach(dl => { + const allDataLayers = [...this.dataLayers, ...this.tripDataLayers]; + allDataLayers.forEach(dl => { dl.getGroups().forEach(group => { let groupData = groupsMap.get(group); if (!groupData) { @@ -217,7 +254,7 @@ export abstract class TbMap { }); } - const setup = this.dataLayers.map(dl => dl.setup()); + const setup = allDataLayers.map(dl => dl.setup()); forkJoin(setup).subscribe( () => { let datasources: TbMapDatasource[]; @@ -231,35 +268,77 @@ export abstract class TbMap { } const additionalDatasources = additionalMapDataSourcesToDatasources(this.settings.additionalDataSources); datasources = mergeMapDatasources(datasources, additionalDatasources); - const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = { - datasources, - hasDataPageLink: true, - useDashboardTimewindow: false, - type: widgetType.latest, - callbacks: { - onDataUpdated: (subscription) => { - this.update(subscription); + if (datasources.length) { + const dataLayersSubscriptionOptions: WidgetSubscriptionOptions = { + datasources, + hasDataPageLink: true, + useDashboardTimewindow: false, + type: widgetType.latest, + callbacks: { + onDataUpdated: (subscription) => { + this.update(subscription); + } } - } - }; - this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe( - (dataLayersSubscription) => { - let pageSize = this.settings.mapPageSize; - if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) { - pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize); + }; + this.ctx.subscriptionApi.createSubscription(dataLayersSubscriptionOptions, false).subscribe( + (dataLayersSubscription) => { + let pageSize = this.settings.mapPageSize; + if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) { + pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize); + } + const pageLink: EntityDataPageLink = { + page: 0, + pageSize, + textSearch: null, + dynamic: true + }; + dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => { + // this.map.resetState(); + }); + dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null); } - const pageLink: EntityDataPageLink = { - page: 0, - pageSize, - textSearch: null, - dynamic: true - }; - dataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => { - // this.map.resetState(); - }); - dataLayersSubscription.subscribeAllForPaginatedData(pageLink, null); + ); + } + if (this.tripDataLayers.length) { + const tripDatasources = this.tripDataLayers.map(dl => dl.getDatasource()); + const tripDataLayersSubscriptionOptions: WidgetSubscriptionOptions = { + datasources: tripDatasources, + hasDataPageLink: true, + useDashboardTimewindow: isDefined(this.ctx.widgetConfig.useDashboardTimewindow) + ? this.ctx.widgetConfig.useDashboardTimewindow : true, + type: widgetType.timeseries, + callbacks: { + onDataUpdated: (subscription) => { + this.updateTrips(subscription); + }, + onLatestDataUpdated: (subscription) => { + this.updateTripsWithLatestData(subscription); + } + } + }; + if (!tripDataLayersSubscriptionOptions.useDashboardTimewindow) { + tripDataLayersSubscriptionOptions.timeWindowConfig = this.ctx.widgetConfig.timewindow; } - ); + + this.ctx.subscriptionApi.createSubscription(tripDataLayersSubscriptionOptions, false).subscribe( + (tripDataLayersSubscription) => { + let pageSize = this.settings.mapPageSize; + if (isDefinedAndNotNull(this.ctx.widgetConfig.pageSize)) { + pageSize = Math.max(pageSize, this.ctx.widgetConfig.pageSize); + } + const pageLink: EntityDataPageLink = { + page: 0, + pageSize, + textSearch: null, + dynamic: true + }; + tripDataLayersSubscription.paginatedDataSubscriptionUpdated.subscribe(() => { + // this.map.resetState(); + }); + tripDataLayersSubscription.subscribeAllForPaginatedData(pageLink, null); + } + ); + } } ); } @@ -582,10 +661,51 @@ export abstract class TbMap { this.dsData = formattedDataFormDatasourceData(subscription.data, undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); this.dataLayers.forEach(dl => dl.updateData(this.dsData)); + this.updateTripsAppearance(); + this.updateTripsTimeline(); this.updateBounds(); this.updateAddButtonsStates(); } + private updateTrips(subscription: IWidgetSubscription) { + const tripsData = formattedDataArrayFromDatasourceData(subscription.data, el => el.datasource.entityId + el.datasource.mapDataIds[0]); + const tripsLatestData = formattedDataFormDatasourceData(subscription.latestData, + undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); + + let minTime = Infinity; + let maxTime = -Infinity; + for (const tripsDataLayer of this.tripDataLayers) { + const minMax = tripsDataLayer.prepareTripsData(tripsData, tripsLatestData); + minTime = Math.min(minMax.minTime, minTime); + maxTime = Math.max(minMax.maxTime, maxTime); + } + this.tripDataLayers.forEach(dl => dl.updateTrips(minTime, maxTime)); + this.updateTripsTimeline(minTime, maxTime); + this.updateBounds(); + } + + private updateTripsWithLatestData(subscription: IWidgetSubscription) { + const tripsLatestData = formattedDataFormDatasourceData(subscription.latestData, + undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); + this.tripDataLayers.forEach(dl => dl.updateTripsLatestData(tripsLatestData)); + this.updateTripsAppearance(); + this.updateTripsTimeline(); + } + + private updateTripsAppearance() {} + + private updateTripsTimeline(minTime?: number, maxTime?: number) { + if (this.settings.tripTimeline?.showTimelineControl) { + if (isDefinedAndNotNull(minTime) && isDefinedAndNotNull(maxTime)) { + this.timeLineComponent.min = minTime; + this.timeLineComponent.max = maxTime; + } + if (this.settings.tripTimeline.snapToRealLocation) { + // Recalculate anchors only for enabled layers + } + } + } + private resize() { this.onResize(); this.map?.invalidateSize(); @@ -659,7 +779,7 @@ export abstract class TbMap { protected getSidebar(): L.TB.SidebarControl { if (!this.sidebar) { this.sidebar = L.TB.sidebar({ - container: $(this.containerElement), + container: $(this.mapLayoutElement), position: this.settings.controlsPosition, paneWidth: 220 }).addTo(this.map); @@ -685,6 +805,7 @@ export abstract class TbMap { public enabledDataLayersUpdated() { this.updateAddButtonsStates(); + this.updateTripsTimeline(); } public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) { @@ -787,6 +908,9 @@ export abstract class TbMap { instance.destroy(); }) }); + if (this.timeLineComponentRef) { + this.timeLineComponentRef.destroy(); + } } public abstract locationDataToLatLng(position: {x: number; y: number}): L.LatLng; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 31428c682c..69c38bfbf7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -33,6 +33,7 @@ import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; +import { DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models'; export enum MapType { geoMap = 'geoMap', @@ -368,6 +369,83 @@ export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial( + [ + [PathDecoratorSymbol.arrowHead, 'widgets.maps.data-layer.decorator-symbol-arrow-head'], + [PathDecoratorSymbol.dash, 'widgets.maps.data-layer.decorator-symbol-dash'] + ] +); + +export interface TripsDataLayerSettings extends MarkersDataLayerSettings { + rotateMarker: boolean; + offsetAngle: number; + showPath: boolean; + pathStrokeWeight?: number; + pathStrokeColor?: DataLayerColorSettings; + usePathDecorator?: boolean; + pathDecoratorSymbol?: PathDecoratorSymbol; + pathDecoratorSymbolSize?: number; + pathDecoratorSymbolColor?: string; + pathDecoratorOffset?: number; + pathEndDecoratorOffset?: number; + pathDecoratorRepeat?: number; + showPoints: boolean; + pointSize?: number; + pointColor?: DataLayerColorSettings; + pointTooltip?: DataLayerTooltipSettings; +} + +export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial => mergeDeep( + defaultBaseMarkersDataLayerSettings(mapType), + { + tooltip: { + pattern: mapType === MapType.geoMap ? + '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}' + : '${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
End Time: ${maxTime}
Start Time: ${minTime}', + }, + rotateMarker: true, + offsetAngle: 0, + markerOffsetX: 0.5, + markerOffsetY: 0.5, + showPath: true, + pathStrokeWeight: 2, + pathStrokeColor: { + type: DataLayerColorType.constant, + color: '#307FE5', + }, + usePathDecorator: false, + pathDecoratorSymbol: PathDecoratorSymbol.arrowHead, + pathDecoratorSymbolSize: 10, + pathDecoratorSymbolColor: '#307FE5', + pathDecoratorOffset: 20, + pathEndDecoratorOffset: 20, + pathDecoratorRepeat: 20, + showPoints: false, + pointSize: 10, + pointColor: { + type: DataLayerColorType.constant, + color: '#307FE5', + }, + pointTooltip: { + show: true, + trigger: DataLayerTooltipTrigger.click, + autoclose: true, + type: DataLayerPatternType.pattern, + pattern: mapType === MapType.geoMap ? + '${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}' + : '${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
End Time: ${maxTime}
Start Time: ${minTime}', + offsetX: 0, + offsetY: -1 + }, + } as TripsDataLayerSettings); + export interface ShapeDataLayerSettings extends MapDataLayerSettings { fillColor: DataLayerColorSettings; strokeColor: DataLayerColorSettings; @@ -525,8 +603,19 @@ export const mapZoomActionTranslationMap = new Map( ] ); +export interface TripTimelineSettings { + showTimelineControl: boolean; + timeStep: number; + speedOptions: number[]; + showTimestamp: boolean; + timestampFormat: DateFormatSettings; + snapToRealLocation: boolean; + locationSnapFilter: TbFunction; +} + export interface BaseMapSettings { mapType: MapType; + trips?: TripsDataLayerSettings[]; markers: MarkersDataLayerSettings[]; polygons: PolygonsDataLayerSettings[]; circles: CirclesDataLayerSettings[]; @@ -539,6 +628,7 @@ export interface BaseMapSettings { defaultZoomLevel: number; minZoomLevel: number; mapPageSize: number; + tripTimeline?: TripTimelineSettings; } export const DEFAULT_MAP_PAGE_SIZE = 16384; @@ -546,6 +636,7 @@ export const DEFAULT_ZOOM_LEVEL = 8; export const defaultBaseMapSettings: BaseMapSettings = { mapType: MapType.geoMap, + trips: [], markers: [], polygons: [], circles: [], @@ -557,7 +648,16 @@ export const defaultBaseMapSettings: BaseMapSettings = { defaultCenterPosition: '0,0', defaultZoomLevel: null, minZoomLevel: 16, - mapPageSize: DEFAULT_MAP_PAGE_SIZE + mapPageSize: DEFAULT_MAP_PAGE_SIZE, + tripTimeline: { + showTimelineControl: false, + timeStep: 1000, + speedOptions: [1,5,10,15,25], + showTimestamp: true, + timestampFormat: simpleDateFormat('yyyy-MM-dd HH:mm:ss'), + snapToRealLocation: false, + locationSnapFilter: 'return true;' + } }; export enum MapProvider { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html new file mode 100644 index 0000000000..543f07a3d7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html @@ -0,0 +1,26 @@ + +
+ + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss new file mode 100644 index 0000000000..9e38394deb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss @@ -0,0 +1,19 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-map-timeline-panel { + display: flex; + flex-direction: column; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts new file mode 100644 index 0000000000..33cac7c012 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + ElementRef, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models'; + +@Component({ + selector: 'tb-map-timeline-panel', + templateUrl: './map-timeline-panel.component.html', + styleUrls: ['./map-timeline-panel.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MapTimelinePanelComponent implements OnInit, OnDestroy { + + @Input() + settings: TripTimelineSettings; + + @Input() + disabled = false; + + @Input() + min = 0; + + @Input() + max = 10000; + + @Output() + timeChanged = new EventEmitter(); + + currentTime = 0; + + constructor(public element: ElementRef) { + } + + ngOnInit() { + } + + ngOnDestroy() { + } + + public onTimeChange() { + //this.updateValueText(); + this.timeChanged.next(this.currentTime); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index e92a39b863..710af3478b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -79,6 +79,10 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid MapType = MapType; + @Input() + @coerceBoolean() + trip = false; + @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 58a3144f9e..cdc829a96f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -93,6 +93,7 @@ import { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget. import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; +import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; @NgModule({ declarations: [ @@ -149,6 +150,7 @@ import { NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, SelectMapEntityPanelComponent, + MapTimelinePanelComponent, MapWidgetComponent ], imports: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 9943f1ec83..a3d924fe08 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -278,6 +278,12 @@
+ + + +
widget-config.data-settings
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index aece3d525f..dfe60066e0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7858,6 +7858,10 @@ "place-marker": "Place marker", "place-marker-hint": "Click to place '{{entityName}}' entity" }, + "path": { + "decorator-symbol-arrow-head": "Arrow", + "decorator-symbol-dash": "Dash" + }, "polygon": { "polygon-key": "Polygon key", "polygon-key-required": "Polygon key required", From 46f5ce56a46f773984dced1f0a00a19fe48d1ede Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Thu, 27 Feb 2025 11:52:40 +0100 Subject: [PATCH 042/127] removed serializers from kafka node config, used default StringSerializer --- .../org/thingsboard/rule/engine/kafka/TbKafkaNode.java | 5 +++-- .../rule/engine/kafka/TbKafkaNodeConfiguration.java | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java index 6008305570..8f6a6dc617 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNode.java @@ -26,6 +26,7 @@ import org.apache.kafka.common.config.SslConfigs; import org.apache.kafka.common.header.Headers; import org.apache.kafka.common.header.internals.RecordHeader; import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.serialization.StringSerializer; import org.springframework.util.ReflectionUtils; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -83,8 +84,8 @@ public class TbKafkaNode extends TbAbstractExternalNode { Properties properties = new Properties(); properties.put(ProducerConfig.CLIENT_ID_CONFIG, "producer-tb-kafka-node-" + ctx.getSelfId().getId().toString() + "-" + ctx.getServiceId()); properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, config.getBootstrapServers()); - properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, config.getValueSerializer()); - properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, config.getKeySerializer()); + properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); properties.put(ProducerConfig.ACKS_CONFIG, config.getAcks()); properties.put(ProducerConfig.RETRIES_CONFIG, config.getRetries()); properties.put(ProducerConfig.BATCH_SIZE_CONFIG, config.getBatchSize()); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java index 867dd2e54e..b3f1fe1473 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.rule.engine.kafka; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; import org.apache.kafka.common.serialization.StringSerializer; import org.thingsboard.rule.engine.api.NodeConfiguration; @@ -23,6 +25,7 @@ import java.util.Collections; import java.util.Map; @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class TbKafkaNodeConfiguration implements NodeConfiguration { private String topicPattern; @@ -33,8 +36,6 @@ public class TbKafkaNodeConfiguration implements NodeConfiguration otherProperties; private boolean addMetadataKeyValuesAsKafkaHeaders; @@ -50,8 +51,6 @@ public class TbKafkaNodeConfiguration implements NodeConfiguration Date: Thu, 27 Feb 2025 16:02:14 +0200 Subject: [PATCH 043/127] Flow animation for hp scada connector --- .../bottom-right-elbow-connector-hp.svg | 234 ++++++- .../scada_symbols/bottom-tee-connector-hp.svg | 466 +++++++++++++- .../scada_symbols/cross-connector-hp.svg | 582 +++++++++++++++++- .../scada_symbols/horizontal-connector-hp.svg | 200 ++++-- .../left-bottom-elbow-connector-hp.svg | 232 ++++++- .../scada_symbols/left-tee-connector-hp.svg | 466 +++++++++++++- .../left-top-elbow-connector-hp.svg | 234 ++++++- .../long-horizontal-connector-hp.svg | 203 ++++-- .../long-vertical-connector-hp.svg | 200 ++++-- .../scada_symbols/right-tee-connector-hp.svg | 466 +++++++++++++- .../top-right-elbow-connector-hp.svg | 234 ++++++- .../scada_symbols/top-tee-connector-hp.svg | 466 +++++++++++++- .../scada_symbols/vertical-connector-hp.svg | 200 ++++-- .../assets/locale/locale.constant-en_US.json | 16 +- 14 files changed, 3745 insertions(+), 454 deletions(-) diff --git a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg index 4e41fd77c6..37b3a199c6 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-right-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Bottom right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg index 33942432cb..2feb95e9ff 100644 --- a/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/bottom-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Bottom tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg index df708d47d3..ab6798b48b 100644 --- a/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/cross-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Cross connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\nconst bottomLine = \"M 100,200 V 103\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,480 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +500,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +513,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg index 33b6c0a222..74d048e884 100644 --- a/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/horizontal-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg index bfc97fe928..fd14834d12 100644 --- a/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-bottom-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left bottom elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg index eac3869a1a..af83a4abb3 100644 --- a/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H97\";\nconst topLine = \"M100 100L100 0\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg index 6dfbc9e265..5b6d30ab65 100644 --- a/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/left-top-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Left top elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg index 86bb07a520..3e65bd6b04 100644 --- a/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-horizontal-connector-hp.svg @@ -4,6 +4,7 @@ "description": "Long horizontal connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 2, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -86,6 +87,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -94,16 +172,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -114,12 +184,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -128,49 +197,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - - - + + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg index d7a0aa8aa7..b5c9730842 100644 --- a/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/long-vertical-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Long vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 2, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg index 17d8bf4e0c..62aecb065d 100644 --- a/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/right-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Right tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst topLine = \"M100 100L100 0\";\nconst rightLine = \"M103 100H200\";\nconst bottomLine = \"M 100,200 V 100\";\n\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\nprepareFlowAnimation('bottom', bottomLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "bottomFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.bottom-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg index f2d579c551..8ec4e3cc65 100644 --- a/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-right-elbow-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Top right elbow connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n animationDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `-${dashWidth + (dashGap || dashWidth)}` : `${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "line", @@ -10,23 +11,132 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "animationDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -37,12 +147,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -51,32 +160,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg index 004096aaa7..e4561a8347 100644 --- a/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/top-tee-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Top tee connector", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\n\nconst leftLine = \"M0 100H100\";\nconst topLine = \"M100 97L100 0\";\nconst rightLine = \"M100 100H200\";\n\nprepareFlowAnimation('left', leftLine);\nprepareFlowAnimation('top', topLine);\nprepareFlowAnimation('right', rightLine);\n\nfunction prepareFlowAnimation(prefix, line) {\n const flowAnimation = ctx.values[prefix + 'Flow'];\n const flowDirection = ctx.values[prefix + 'FlowDirection'];\n const flowAnimationSpeed = ctx.values[prefix + 'FlowAnimationSpeed'];\n\n const animation = ctx.tags[prefix + 'Line'][0];\n const offset = Date.now() % 1000;\n const duration = 1 / flowAnimationSpeed;\n \n const prevFlowAnimation = animation.remember('flowAnimation');\n const prevFlowDirection = animation.remember('flowDirection');\n const prevFlowDuration = animation.remember('flowDuration');\n \n if (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(animation, offset, flowDirection, duration, line);\n } else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n } else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n }\n}\n\nfunction animateFlow(group, offset, flowDirection, duration, line) {\n group.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n group.add(``);\n}", "tags": [ { "tag": "line", @@ -15,23 +16,364 @@ "actions": null } ], - "behavior": [], + "behavior": [ + { + "id": "leftFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "leftFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.left-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.fluid-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowDirection", + "name": "{i18n:scada.symbol.animation-direction}", + "hint": "{i18n:scada.symbol.animation-direction-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "topFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.top-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlow", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowDirection", + "name": "{i18n:scada.symbol.flow-direction}", + "hint": "{i18n:scada.symbol.flow-direction-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.forward}", + "falseLabel": "{i18n:scada.symbol.reverse}", + "stateLabel": "{i18n:scada.symbol.forward}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": true, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;", + "compareToValue": true + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "rightFlowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": "{i18n:scada.symbol.right-connector}", + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + } + ], "properties": [ { "id": "mainLine", "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -42,12 +384,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -56,32 +397,95 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "color", + "default": "#C8DFF7", + "disabled": false, + "visible": true + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg index 6ce9336fee..cfaf6793ea 100644 --- a/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg +++ b/application/src/main/data/json/system/scada_symbols/vertical-connector-hp.svg @@ -3,6 +3,7 @@ "description": "Vertical connector with an optional directional arrow to visually indicate flow.", "widgetSizeX": 1, "widgetSizeY": 1, + "stateRenderFunction": "const {\n flowAnimation,\n arrowDirection: flowDirection,\n flowAnimationSpeed\n} = ctx.values;\nconst {\n flowAnimationWidth: lineWidth,\n flowAnimationColor: lineColor,\n flowStyleDash: dashWidth,\n flowStyleGap: dashGap,\n flowDashCap: dashCap\n} = ctx.properties;\nconst line = ctx.tags.line[0].attr('d');\nconst animation = ctx.tags.animationGroup[0];\nconst offset = Date.now() % 1000;\nconst duration = 1 / flowAnimationSpeed;\n\nconst prevFlowAnimation = animation.remember('flowAnimation');\nconst prevFlowDirection = animation.remember('flowDirection');\nconst prevFlowDuration = animation.remember('flowDuration');\n\nif (flowAnimation && flowAnimation !== prevFlowAnimation) {\n animation.remember('flowAnimation', flowAnimation);\n animation.remember('flowDuration', duration);\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && flowDirection !== prevFlowDirection) {\n animation.remember('flowDirection', flowDirection);\n animateFlow(offset, flowDirection);\n} else if (flowAnimation && duration !== prevFlowDuration) {\n animation.remember('flowDuration', duration);\n animation.findOne('animate').attr('dur', `${duration}s`) ;\n} else if (!flowAnimation && prevFlowAnimation) {\n animation.remember('flowAnimation', null);\n animation.clear();\n}\n\nfunction animateFlow(offset, flowDirection) {\n animation.clear();\n const dashArray = `${dashWidth}${dashGap ? ` ${dashGap}` : ''}`;\n const value = flowDirection ? `${dashWidth + (dashGap || dashWidth)}` : `-${dashWidth + (dashGap || dashWidth)}`;\n\n animation.add(``);\n}\n", "tags": [ { "tag": "arrow", @@ -85,6 +86,83 @@ }, "defaultSetValueSettings": null, "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimation", + "name": "{i18n:scada.symbol.flow-animation}", + "hint": "{i18n:scada.symbol.flow-animation-hint}", + "group": null, + "type": "value", + "valueType": "BOOLEAN", + "trueLabel": "{i18n:scada.symbol.present}", + "falseLabel": "{i18n:scada.symbol.absent}", + "stateLabel": "{i18n:scada.symbol.flow-present}", + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": false, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "key": "state", + "scope": null + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "compareToValue": true, + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null + }, + { + "id": "flowAnimationSpeed", + "name": "{i18n:scada.symbol.flow-animation-speed}", + "hint": "{i18n:scada.symbol.flow-animation-speed-hint}", + "group": null, + "type": "value", + "valueType": "DOUBLE", + "trueLabel": null, + "falseLabel": null, + "stateLabel": null, + "defaultGetValueSettings": { + "action": "DO_NOTHING", + "defaultValue": 1, + "executeRpc": { + "method": "getState", + "requestTimeout": 5000, + "requestPersistent": false, + "persistentPollingInterval": 1000 + }, + "getAttribute": { + "scope": null, + "key": "state" + }, + "getTimeSeries": { + "key": "state" + }, + "getAlarmStatus": { + "severityList": null, + "typeList": null + }, + "dataToValue": { + "type": "NONE", + "dataToValueFunction": "/* Should return boolean value */\nreturn data;" + } + }, + "defaultSetValueSettings": null, + "defaultWidgetActionSettings": null } ], "properties": [ @@ -93,16 +171,8 @@ "name": "{i18n:scada.symbol.main-line}", "type": "switch", "default": true, - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { "id": "mainLineSize", @@ -113,12 +183,11 @@ "subLabel": "Main", "divider": true, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "secondaryLineSize", @@ -127,48 +196,93 @@ "default": 2, "required": true, "subLabel": "Secondary", - "divider": null, "fieldSuffix": "px", - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", "min": 0, "max": 99, - "step": 1 + "step": 1, + "disabled": false, + "visible": true }, { "id": "lineColor", "name": "{i18n:scada.symbol.line-color}", "type": "color", "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "disabled": false, + "visible": true }, { - "id": "arrowColor", - "name": "{i18n:scada.symbol.arrow-color}", + "id": "flowAnimationWidth", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 4, + "subLabel": "Width", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowAnimationColor", + "name": "{i18n:scada.symbol.flow}", + "group": "{i18n:scada.symbol.animation}", "type": "color", - "default": "#1A1A1A", - "required": null, - "subLabel": null, - "divider": null, - "fieldSuffix": null, - "disableOnProperty": null, - "rowClass": "", - "fieldClass": "", - "min": null, - "max": null, - "step": null + "default": "#C8DFF7" + }, + { + "id": "flowStyleDash", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "required": true, + "subLabel": "{i18n:scada.symbol.dash}", + "divider": true, + "fieldSuffix": "px", + "min": 0, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowStyleGap", + "name": "{i18n:scada.symbol.flow-style}", + "group": "{i18n:scada.symbol.animation}", + "type": "number", + "default": 10, + "subLabel": "{i18n:scada.symbol.gap}", + "fieldSuffix": "px", + "min": 1, + "step": 1, + "disabled": false, + "visible": true + }, + { + "id": "flowDashCap", + "name": "{i18n:scada.symbol.flow-dash-cap}", + "group": "{i18n:scada.symbol.animation}", + "type": "select", + "default": "butt", + "items": [ + { + "value": "butt", + "label": "{i18n:scada.symbol.dash-cap-butt}" + }, + { + "value": "round", + "label": "{i18n:scada.symbol.dash-cap-round}" + }, + { + "value": "square", + "label": "{i18n:scada.symbol.dash-cap-square}" + } + ], + "disabled": false, + "visible": true } ] }]]> - + \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 18801bda2c..72c2cf80ae 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3260,6 +3260,8 @@ "left-bottom-connector": "Left bottom connector", "top-left-connector": "Top left connector", "top-right-connector": "Top right connector", + "top-connector": "Top connector", + "bottom-connector": "Bottom connector", "running-color": "Running color", "stopped-color": "Stopped color", "stopped": "Stopped", @@ -3332,8 +3334,20 @@ "arrow-presence": "Arrow presence", "arrow-presence-hint": "Indicates whether arrow is present in connector.", "arrow-present": "Arrow present", - "arrow-direction": "Arrow direction", + "arrow-direction": "Arrow/Animation direction", "arrow-direction-hint": "Indicates flow direction.", + "animation-direction": "Flow animation direction", + "animation-direction-hint": "Indicates animation flow direction.", + "flow-animation": "Flow animation", + "flow-animation-hint": "Indicates whether animation is present in connector.", + "flow": "Flow", + "flow-style": "Flow style", + "flow-dash-cap": "Flow dash cap", + "dash-cap-butt": "Butt", + "dash-cap-round": "Round", + "dash-cap-square": "Square", + "dash": "Dash", + "gap": "Gap", "main-line": "Main line", "line": "Line", "line-color": "Line color", From f11ea0ba3d09718c124aef77874809f338647eff Mon Sep 17 00:00:00 2001 From: Tarnavskiy Date: Thu, 27 Feb 2025 16:41:21 +0200 Subject: [PATCH 044/127] Code formatting --- .../settings/alarm/alarms-table-widget-settings.component.ts | 3 +-- .../control/persistent-table-widget-settings.component.html | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts index 7a7b97dc18..51452fb9b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts @@ -97,8 +97,7 @@ export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent } protected validatorTriggers(): string[] { - return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', - 'pageStepIncrement']; + return ['useRowStyleFunction', 'displayPagination', 'pageStepCount', 'pageStepIncrement']; } protected updateValidators(emitEvent: boolean, trigger: string) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index e69ef101fb..addf275f4f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -45,7 +45,7 @@
widgets.table.pagination
- + {{ 'widgets.table.display-pagination' | translate }}
From 73a654d7840c2290877a3eaf7f765f4d3efb45f6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 27 Feb 2025 18:43:19 +0200 Subject: [PATCH 045/127] UI: Trip map settings. --- .../lib/maps/data-layer/trips-data-layer.ts | 4 +- .../widget/lib/maps/models/map.models.ts | 35 +++- .../lib/maps/models/marker-shape.models.ts | 71 +++++++- .../panels/map-timeline-panel.component.html | 11 ++ .../panels/map-timeline-panel.component.scss | 8 + .../panels/map-timeline-panel.component.ts | 36 +++- ...data-layer-pattern-settings.component.html | 2 +- .../data-layer-pattern-settings.component.ts | 3 + .../map/map-action-button-row.component.html | 2 +- .../map/map-action-button-row.component.ts | 2 +- ...map-action-buttons-settings.component.html | 2 +- .../map-action-buttons-settings.component.ts | 2 +- .../map/map-data-layer-dialog.component.html | 172 +++++++++++++++--- .../map/map-data-layer-dialog.component.ts | 124 +++++++++++-- .../map/map-data-layer-row.component.html | 12 +- .../map/map-data-layer-row.component.ts | 30 ++- .../common/map/map-data-layers.component.html | 4 +- .../common/map/map-data-layers.component.ts | 4 + .../common/map/map-settings.component.html | 9 + .../map/map-settings.component.models.ts | 4 +- .../common/map/map-settings.component.ts | 12 +- .../map/marker-shape-settings.component.ts | 10 +- .../common/map/marker-shapes.component.ts | 13 +- .../map/trip-timeline-settings.component.html | 81 +++++++++ .../map/trip-timeline-settings.component.ts | 172 ++++++++++++++++++ .../common/widget-settings-common.module.ts | 4 + .../map/map-widget-settings.component.html | 1 + .../map/map-widget-settings.component.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 31 +++- ui-ngx/src/assets/markers/tripShape1.svg | 4 + ui-ngx/src/assets/markers/tripShape10.svg | 10 + ui-ngx/src/assets/markers/tripShape2.svg | 15 ++ ui-ngx/src/assets/markers/tripShape3.svg | 9 + ui-ngx/src/assets/markers/tripShape4.svg | 9 + ui-ngx/src/assets/markers/tripShape5.svg | 10 + ui-ngx/src/assets/markers/tripShape6.svg | 10 + ui-ngx/src/assets/markers/tripShape7.svg | 11 ++ ui-ngx/src/assets/markers/tripShape8.svg | 9 + ui-ngx/src/assets/markers/tripShape9.svg | 10 + 39 files changed, 882 insertions(+), 88 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts create mode 100644 ui-ngx/src/assets/markers/tripShape1.svg create mode 100644 ui-ngx/src/assets/markers/tripShape10.svg create mode 100644 ui-ngx/src/assets/markers/tripShape2.svg create mode 100644 ui-ngx/src/assets/markers/tripShape3.svg create mode 100644 ui-ngx/src/assets/markers/tripShape4.svg create mode 100644 ui-ngx/src/assets/markers/tripShape5.svg create mode 100644 ui-ngx/src/assets/markers/tripShape6.svg create mode 100644 ui-ngx/src/assets/markers/tripShape7.svg create mode 100644 ui-ngx/src/assets/markers/tripShape8.svg create mode 100644 ui-ngx/src/assets/markers/tripShape9.svg diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 354fa4eb0f..63824365ca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -331,7 +331,7 @@ export class TbTripsDataLayer extends TbMapDataLayer d[0].$datasource.mapDataIds.includes(this.mapDataId)).map( + tripsData.filter(d => !!d.length && d[0].$datasource.mapDataIds.includes(this.mapDataId)).map( item => this.clearIncorrectFirsLastDatapoint(item)).filter(arr => arr.length); this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); this.rawTripsData.forEach((dataSource) => { @@ -396,7 +396,7 @@ export class TbTripsDataLayer extends TbMapDataLayer { - return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color).pipe( + return createColorMarkerIconElement(this.getCtx().$injector.get(MatIconRegistry), this.getCtx().$injector.get(DomSanitizer), icon, color, true).pipe( map((element) => { element.style.transform = `rotate(${rotationAngle}deg)`; return { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 2361440828..f93eab6dbc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -157,7 +157,7 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial { if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { @@ -307,13 +307,13 @@ const defaultMarkerYPosFunction = 'var value = prevValue || 0.3;\n' + '}\n' + 'return value;'; -export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => mergeDeep({ +const defaultMarkersDataSourceSettings = (mapType: MapType, timeSeries = false, functionsOnly = false): Partial => ({ dsType: functionsOnly ? DatasourceType.function : DatasourceType.entity, dsLabel: functionsOnly ? 'First point' : '', xKey: { name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'latitude' : 'xPos'), label: MapType.geoMap === mapType ? 'latitude' : 'xPos', - type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + type: functionsOnly ? DataKeyType.function : (timeSeries ? DataKeyType.timeseries : DataKeyType.attribute), funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLatitudeFunction : defaultMarkerXPosFunction) : undefined, settings: {}, color: materialColors[0].value @@ -321,12 +321,16 @@ export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly yKey: { name: functionsOnly ? 'f(x)' : (MapType.geoMap === mapType ? 'longitude' : 'yPos'), label: MapType.geoMap === mapType ? 'longitude' : 'yPos', - type: functionsOnly ? DataKeyType.function : DataKeyType.attribute, + type: functionsOnly ? DataKeyType.function : (timeSeries ? DataKeyType.timeseries : DataKeyType.attribute), funcBody: functionsOnly ? (MapType.geoMap === mapType ? defaultMarkerLongitudeFunction : defaultMarkerYPosFunction) : undefined, settings: {}, color: materialColors[0].value } -} as MarkersDataLayerSettings, defaultBaseMarkersDataLayerSettings(mapType) as MarkersDataLayerSettings); +}); + +export const defaultMarkersDataLayerSettings = (mapType: MapType, functionsOnly = false): MarkersDataLayerSettings => mergeDeep( + defaultMarkersDataSourceSettings(mapType, false, functionsOnly) as MarkersDataLayerSettings, + defaultBaseMarkersDataLayerSettings(mapType) as MarkersDataLayerSettings); export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial => mergeDeep({ markerType: MarkerType.shape, @@ -378,8 +382,8 @@ export const pathDecoratorSymbols = Object.keys(PathDecoratorSymbol) as PathDeco export const pathDecoratorSymbolTranslationMap = new Map( [ - [PathDecoratorSymbol.arrowHead, 'widgets.maps.data-layer.decorator-symbol-arrow-head'], - [PathDecoratorSymbol.dash, 'widgets.maps.data-layer.decorator-symbol-dash'] + [PathDecoratorSymbol.arrowHead, 'widgets.maps.data-layer.path.decorator-symbol-arrow-head'], + [PathDecoratorSymbol.dash, 'widgets.maps.data-layer.path.decorator-symbol-dash'] ] ); @@ -402,6 +406,10 @@ export interface TripsDataLayerSettings extends MarkersDataLayerSettings { pointTooltip?: DataLayerTooltipSettings; } +export const defaultTripsDataLayerSettings = (mapType: MapType, functionsOnly = false): TripsDataLayerSettings => mergeDeep( + defaultMarkersDataSourceSettings(mapType, true, functionsOnly) as TripsDataLayerSettings, + defaultBaseTripsDataLayerSettings(mapType) as TripsDataLayerSettings); + export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial => mergeDeep( defaultBaseMarkersDataLayerSettings(mapType), { @@ -412,6 +420,15 @@ export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial { switch (dataLayerType) { + case 'trips': + return defaultTripsDataLayerSettings(mapType, functionsOnly); case 'markers': return defaultMarkersDataLayerSettings(mapType, functionsOnly); case 'polygons': @@ -523,6 +542,8 @@ export const defaultMapDataLayerSettings = (mapType: MapType, dataLayerType: Map export const defaultBaseMapDataLayerSettings = (mapType: MapType, dataLayerType: MapDataLayerType): T => { switch (dataLayerType) { + case 'trips': + return defaultBaseTripsDataLayerSettings(mapType) as T; case 'markers': return defaultBaseMarkersDataLayerSettings(mapType) as T; case 'polygons': diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts index 7abf0e19cd..1948ff6fe7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts @@ -32,7 +32,17 @@ export enum MarkerShape { markerShape7 = 'markerShape7', markerShape8 = 'markerShape8', markerShape9 = 'markerShape9', - markerShape10 = 'markerShape10' + markerShape10 = 'markerShape10', + tripMarkerShape1 = 'tripMarkerShape1', + tripMarkerShape2 = 'tripMarkerShape2', + tripMarkerShape3 = 'tripMarkerShape3', + tripMarkerShape4 = 'tripMarkerShape4', + tripMarkerShape5 = 'tripMarkerShape5', + tripMarkerShape6 = 'tripMarkerShape6', + tripMarkerShape7 = 'tripMarkerShape7', + tripMarkerShape8 = 'tripMarkerShape8', + tripMarkerShape9 = 'tripMarkerShape9', + tripMarkerShape10 = 'tripMarkerShape10' } export const markerShapeMap = new Map( @@ -46,21 +56,63 @@ export const markerShapeMap = new Map( [MarkerShape.markerShape7, '/assets/markers/shape7.svg'], [MarkerShape.markerShape8, '/assets/markers/shape8.svg'], [MarkerShape.markerShape9, '/assets/markers/shape9.svg'], - [MarkerShape.markerShape10, '/assets/markers/shape10.svg'] + [MarkerShape.markerShape10, '/assets/markers/shape10.svg'], + [MarkerShape.tripMarkerShape1, '/assets/markers/tripShape1.svg'], + [MarkerShape.tripMarkerShape2, '/assets/markers/tripShape2.svg'], + [MarkerShape.tripMarkerShape3, '/assets/markers/tripShape3.svg'], + [MarkerShape.tripMarkerShape4, '/assets/markers/tripShape4.svg'], + [MarkerShape.tripMarkerShape5, '/assets/markers/tripShape5.svg'], + [MarkerShape.tripMarkerShape6, '/assets/markers/tripShape6.svg'], + [MarkerShape.tripMarkerShape7, '/assets/markers/tripShape7.svg'], + [MarkerShape.tripMarkerShape8, '/assets/markers/tripShape8.svg'], + [MarkerShape.tripMarkerShape9, '/assets/markers/tripShape9.svg'], + [MarkerShape.tripMarkerShape10, '/assets/markers/tripShape10.svg'] ] ); +export const markerShapes = [ + MarkerShape.markerShape1, + MarkerShape.markerShape2, + MarkerShape.markerShape3, + MarkerShape.markerShape4, + MarkerShape.markerShape5, + MarkerShape.markerShape6, + MarkerShape.markerShape7, + MarkerShape.markerShape8, + MarkerShape.markerShape9, + MarkerShape.markerShape10 +]; + +export const tripMarkerShapes = [ + MarkerShape.tripMarkerShape1, + MarkerShape.tripMarkerShape2, + MarkerShape.tripMarkerShape3, + MarkerShape.tripMarkerShape4, + MarkerShape.tripMarkerShape5, + MarkerShape.tripMarkerShape6, + MarkerShape.tripMarkerShape7, + MarkerShape.tripMarkerShape8, + MarkerShape.tripMarkerShape9, + MarkerShape.tripMarkerShape10 +]; + const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { const markerAssetUrl = markerShapeMap.get(shape); const safeUrl = domSanitizer.bypassSecurityTrustResourceUrl(markerAssetUrl); return iconRegistry.getSvgIconFromUrl(safeUrl).pipe( map((svgElement) => { const colorElements = Array.from(svgElement.getElementsByClassName('marker-color')); + if (svgElement.classList.contains('marker-color')) { + colorElements.push(svgElement); + } colorElements.forEach(el => { el.setAttribute('fill', '#'+color.toHex()); el.setAttribute('fill-opacity', `${color.getAlpha()}`); }); const strokeElements = Array.from(svgElement.getElementsByClassName('marker-stroke')); + if (svgElement.classList.contains('marker-stroke')) { + strokeElements.push(svgElement); + } strokeElements.forEach(el => { el.setAttribute('stroke', '#'+color.toHex()); el.setAttribute('stroke-opacity', `${color.getAlpha()}`); @@ -80,10 +132,10 @@ export const createColorMarkerShapeURI = (iconRegistry: MatIconRegistry, domSani ); } -const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: number, color: tinycolor.Instance): Observable => { +const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: number, color: tinycolor.Instance, trip = false): Observable => { const isSvg = isSvgIcon(icon); const iconAlpha = color.getAlpha(); - const iconColor = tinycolor.mix(color.clone().setAlpha(1), tinycolor('rgba(0,0,0,0.38)')); + const iconColor = trip ? color : tinycolor.mix(color.clone().setAlpha(1), tinycolor('rgba(0,0,0,0.38)')); if (isSvg) { const [namespace, iconName] = splitIconName(icon); return iconRegistry @@ -122,14 +174,19 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu } } -export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, icon: string, color: tinycolor.Instance): Observable => { - return createColorMarkerShape(iconRegistry, domSanitizer, MarkerShape.markerShape6, color).pipe( +const markerIconShape = MarkerShape.markerShape6; +const tripMarkerIconShape = MarkerShape.tripMarkerShape2; + +export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, icon: string, color: tinycolor.Instance, + trip = false): Observable => { + return createColorMarkerShape(iconRegistry, domSanitizer, trip ? tripMarkerIconShape : markerIconShape, color).pipe( switchMap((svgElement) => { - return createIconElement(iconRegistry, icon, 12, color).pipe( + return createIconElement(iconRegistry, icon, trip ? 24 : 12, trip ? tinycolor('#fff') : color, trip).pipe( map((iconElement) => { let elements = svgElement.getElementsByClassName('marker-icon-container'); if (iconElement && elements.length) { const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.clear(); iconContainer.add(iconElement); const box = iconElement.bbox(); iconElement.translate(-box.cx, -box.cy); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html index 543f07a3d7..57bf909613 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html @@ -23,4 +23,15 @@ [step]="settings.timeStep"> +
+
+
+
+
2
+
+ + {{speedValue}}x + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss index 9e38394deb..7795e05523 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss @@ -14,6 +14,14 @@ * limitations under the License. */ .tb-map-timeline-panel { + padding-top: 4px; + padding-left: 16px; + padding-right: 16px; display: flex; flex-direction: column; + .tb-timeline-controls { + display: flex; + flex-direction: row; + align-items: center; + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index 33cac7c012..1cb76aceea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -15,16 +15,18 @@ /// import { + ChangeDetectorRef, Component, ElementRef, - EventEmitter, - Input, + EventEmitter, Injector, + Input, OnChanges, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { DateFormatProcessor } from '@shared/models/widget-settings.models'; @Component({ selector: 'tb-map-timeline-panel', @@ -32,7 +34,7 @@ import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/ma styleUrls: ['./map-timeline-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class MapTimelinePanelComponent implements OnInit, OnDestroy { +export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy { @Input() settings: TripTimelineSettings; @@ -51,18 +53,42 @@ export class MapTimelinePanelComponent implements OnInit, OnDestroy { currentTime = 0; - constructor(public element: ElementRef) { + timestampFormat: DateFormatProcessor; + + speed: number; + + constructor(public element: ElementRef, + private cd: ChangeDetectorRef, + private injector: Injector) { } ngOnInit() { + if (this.settings.showTimestamp) { + this.timestampFormat = DateFormatProcessor.fromSettings(this.injector, this.settings.timestampFormat); + this.timestampFormat.update(this.currentTime); + } + this.speed = this.settings.speedOptions[0]; + } + + ngOnChanges() { + this.currentTime = this.min === Infinity ? 0 : this.min; + if (this.settings.showTimestamp) { + this.timestampFormat.update(this.currentTime); + this.cd.markForCheck(); + } } ngOnDestroy() { } public onTimeChange() { - //this.updateValueText(); + if (this.settings.showTimestamp) { + this.timestampFormat.update(this.currentTime); + this.cd.markForCheck(); + } this.timeChanged.next(this.currentTime); } + public speedUpdated() {} + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html index 71dbecab18..4286bf1c22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -23,7 +23,7 @@
- {{ (patternType === 'label' ? 'widgets.maps.data-layer.label' : 'widgets.maps.data-layer.tooltip') | translate }} + {{ patternTitle ? patternTitle : ((patternType === 'label' ? 'widgets.maps.data-layer.label' : 'widgets.maps.data-layer.tooltip') | translate) }} {{ 'widgets.maps.data-layer.pattern-type-pattern' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index 9a8835fa7b..e13e7eec1c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -71,6 +71,9 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc @Input() patternType: 'label' | 'tooltip' = 'label'; + @Input() + patternTitle: string; + @Input() @coerceBoolean() hasTooltipOffset = false; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html index 70cc12dd27..8bd57487b0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html @@ -1,6 +1,6 @@ + + +
+ + + + + {{ 'widgets.maps.timeline.control-panel' | translate }} + + + + +
+
widgets.maps.timeline.time-step
+ + + ms + +
+
+
widgets.maps.timeline.speed-options
+ + + + {{ speed }} + + + +
+
+ + {{ 'widgets.maps.timeline.timestamp' | translate }} + + +
+
+ + + + + {{ 'widgets.maps.timeline.snap-to-real-location' | translate }} + + + + + + + + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts new file mode 100644 index 0000000000..fd9afb8740 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts @@ -0,0 +1,172 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { merge } from 'rxjs'; +import { WidgetService } from '@core/http/widget.service'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + DataLayerPatternSettings, + DataLayerPatternType, + DataLayerTooltipSettings, + dataLayerTooltipTriggers, + dataLayerTooltipTriggerTranslationMap, + pathDecoratorSymbols, pathDecoratorSymbolTranslationMap, + TripTimelineSettings +} from '@home/components/widget/lib/maps/models/map.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; + +@Component({ + selector: 'tb-trip-timeline-settings', + templateUrl: './trip-timeline-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + } + ] +}) +export class TripTimelineSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + settingsExpanded = false; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + @Input() + disabled: boolean; + + private modelValue: TripTimelineSettings; + + private propagateChange = null; + + public tripTimelineSettingsFormGroup: UntypedFormGroup; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + ngOnInit(): void { + + this.tripTimelineSettingsFormGroup = this.fb.group({ + showTimelineControl: [null], + timeStep: [null, [Validators.required, Validators.min(1)]], + speedOptions: [null, [Validators.required]], + showTimestamp: [null], + timestampFormat: [null, [Validators.required]], + snapToRealLocation: [null], + locationSnapFilter: [null, [Validators.required]] + }); + + this.tripTimelineSettingsFormGroup.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + merge(this.tripTimelineSettingsFormGroup.get('showTimelineControl').valueChanges, + this.tripTimelineSettingsFormGroup.get('showTimestamp').valueChanges, + this.tripTimelineSettingsFormGroup.get('snapToRealLocation').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.tripTimelineSettingsFormGroup.disable({emitEvent: false}); + } else { + this.tripTimelineSettingsFormGroup.enable({emitEvent: false}); + this.updateValidators(); + } + } + + writeValue(value: TripTimelineSettings): void { + this.modelValue = value; + this.tripTimelineSettingsFormGroup.patchValue( + value, {emitEvent: false} + ); + this.updateValidators(); + this.settingsExpanded = this.tripTimelineSettingsFormGroup.get('showTimelineControl').value; + this.tripTimelineSettingsFormGroup.get('showTimelineControl').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe((show) => { + this.settingsExpanded = show; + }); + } + + public validate(c: UntypedFormControl) { + const valid = this.tripTimelineSettingsFormGroup.valid; + return valid ? null : { + tripTimelineSettings: { + valid: false, + }, + }; + } + + private updateValidators() { + const showTimelineControl: boolean = this.tripTimelineSettingsFormGroup.get('showTimelineControl').value; + const showTimestamp: boolean = this.tripTimelineSettingsFormGroup.get('showTimestamp').value; + const snapToRealLocation: boolean = this.tripTimelineSettingsFormGroup.get('snapToRealLocation').value; + if (showTimelineControl) { + this.tripTimelineSettingsFormGroup.enable({emitEvent: false}); + if (!showTimestamp) { + this.tripTimelineSettingsFormGroup.get('timestampFormat').disable({emitEvent: false}); + } + if (!snapToRealLocation) { + this.tripTimelineSettingsFormGroup.get('locationSnapFilter').disable({emitEvent: false}); + } + } else { + this.tripTimelineSettingsFormGroup.disable({emitEvent: false}); + this.tripTimelineSettingsFormGroup.get('showTimelineControl').enable({emitEvent: false}); + } + } + + private updateModel() { + this.modelValue = this.tripTimelineSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + protected readonly pathDecoratorSymbols = pathDecoratorSymbols; + protected readonly pathDecoratorSymbolTranslationMap = pathDecoratorSymbolTranslationMap; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 536623e86b..ef7dca9a11 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -242,6 +242,9 @@ import { import { MapActionButtonRowComponent } from '@home/components/widget/lib/settings/common/map/map-action-button-row.component'; +import { + TripTimelineSettingsComponent +} from '@home/components/widget/lib/settings/common/map/trip-timeline-settings.component'; @NgModule({ declarations: [ @@ -332,6 +335,7 @@ import { MapDataLayersComponent, MapDataSourceRowComponent, MapDataSourcesComponent, + TripTimelineSettingsComponent, MapSettingsComponent, EntityAliasSelectComponent, FilterSelectComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html index 870c1f6deb..f58e1d65ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.html @@ -17,6 +17,7 @@ --> , private fb: UntypedFormBuilder) { super(store); @@ -40,6 +43,13 @@ export class MapWidgetSettingsComponent extends WidgetSettingsComponent { return this.mapWidgetSettingsForm; } + protected onWidgetConfigSet(widgetConfig: WidgetConfigComponentData) { + const params = widgetConfig.typeParameters as any; + if (isDefinedAndNotNull(params.trip)) { + this.trip = params.trip === true; + } + } + protected defaultSettings(): WidgetSettings { return mergeDeepIgnoreArray({} as MapWidgetSettings, mapWidgetDefaultSettings); } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index cc6acce2b2..39156e001b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7706,6 +7706,14 @@ "zoom-double-click": "Double click", "zoom-control-buttons": "Control buttons" }, + "timeline": { + "control-panel": "Timeline control panel", + "time-step": "Time step", + "speed-options": "Speed options", + "timestamp": "Timestamp", + "snap-to-real-location": "Snap to real location", + "location-snap-filter-function": "Location snap filter function" + }, "map-action": { "map-action-buttons": "Map action buttons", "label": "Label", @@ -7779,6 +7787,7 @@ }, "overlays": { "overlays": "Overlays", + "trips": "Trips", "markers": "Markers", "polygons": "Polygons", "circles": "Circles" @@ -7827,7 +7836,14 @@ "action-remove": "Remove", "edit-instruments": "Instruments", "enable-snapping": "Enable snapping to other vertices for precision drawing", + "trip": { + "no-trips": "No trips configured", + "add-trip": "Add trip", + "trip-configuration": "Trip configuration", + "remove-trip": "Remove trip" + }, "marker": { + "marker": "Marker", "latitude-key": "Latitude key", "longitude-key": "Longitude key", "x-pos-key": "X position key", @@ -7857,6 +7873,8 @@ "marker-offset": "Marker offset", "offset-horizontal": "Horizontal", "offset-vertical": "Vertical", + "rotate-marker": "Rotate marker", + "offset-angle": "Offset angle", "position-conversion": "Position conversion", "position-conversion-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each", "clustering": { @@ -7880,8 +7898,19 @@ "place-marker-hint-with-entity": "Click to place '{{entityName}}' entity" }, "path": { + "path": "Path", + "path-decorator": "Path decorator", + "decorator-symbol": "Decorator symbol", "decorator-symbol-arrow-head": "Arrow", - "decorator-symbol-dash": "Dash" + "decorator-symbol-dash": "Dash", + "decorator-arrangement": "Decorator arrangement", + "decorator-offset": "Start", + "decorator-end-offset": "End", + "decorator-repeat": "Repeat" + }, + "points": { + "points": "Points", + "point-tooltip": "Point tooltip" }, "polygon": { "polygon-key": "Polygon key", diff --git a/ui-ngx/src/assets/markers/tripShape1.svg b/ui-ngx/src/assets/markers/tripShape1.svg new file mode 100644 index 0000000000..da98975b52 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-ngx/src/assets/markers/tripShape10.svg b/ui-ngx/src/assets/markers/tripShape10.svg new file mode 100644 index 0000000000..507d3725de --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape10.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape2.svg b/ui-ngx/src/assets/markers/tripShape2.svg new file mode 100644 index 0000000000..0dfc4e22a1 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape3.svg b/ui-ngx/src/assets/markers/tripShape3.svg new file mode 100644 index 0000000000..e49d699ad2 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape4.svg b/ui-ngx/src/assets/markers/tripShape4.svg new file mode 100644 index 0000000000..f4b48da113 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape4.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape5.svg b/ui-ngx/src/assets/markers/tripShape5.svg new file mode 100644 index 0000000000..a88d26035d --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape5.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape6.svg b/ui-ngx/src/assets/markers/tripShape6.svg new file mode 100644 index 0000000000..23105baba5 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape6.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape7.svg b/ui-ngx/src/assets/markers/tripShape7.svg new file mode 100644 index 0000000000..342c9db364 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape7.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape8.svg b/ui-ngx/src/assets/markers/tripShape8.svg new file mode 100644 index 0000000000..f8edbe4dc0 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape8.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/tripShape9.svg b/ui-ngx/src/assets/markers/tripShape9.svg new file mode 100644 index 0000000000..9494a976f0 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripShape9.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From aef85444b64420750ba6b1561d030272b91e8f16 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 28 Feb 2025 10:03:23 +0200 Subject: [PATCH 046/127] UI: Remove key and value serializer form kafka node --- .../rule-node/external/kafka-config.component.html | 14 -------------- .../rule-node/external/kafka-config.component.ts | 2 -- .../src/assets/locale/locale.constant-en_US.json | 4 ---- 3 files changed, 20 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index 3aa743247f..1132c9982c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html @@ -73,20 +73,6 @@ - - rule-node-config.key-serializer - - - {{ 'rule-node-config.key-serializer-required' | translate }} - - - - rule-node-config.value-serializer - - - {{ 'rule-node-config.value-serializer-required' | translate }} - - Date: Fri, 28 Feb 2025 11:39:43 +0200 Subject: [PATCH 047/127] PROD-5684: Setting to disable sorting in Entity-group tables and Table-widgets --- .../widget/lib/alarm/alarms-table-widget.component.html | 2 +- .../widget/lib/alarm/alarms-table-widget.component.ts | 5 +---- .../widget/lib/entity/entities-table-widget.component.ts | 3 ++- .../settings/alarm/alarms-table-key-settings.component.html | 5 +++++ .../settings/alarm/alarms-table-key-settings.component.ts | 4 +++- .../cards/timeseries-table-key-settings.component.html | 5 +++++ .../cards/timeseries-table-key-settings.component.ts | 4 +++- .../timeseries-table-latest-key-settings.component.html | 5 +++++ .../cards/timeseries-table-latest-key-settings.component.ts | 4 +++- .../entity/entities-table-key-settings.component.html | 5 +++++ .../settings/entity/entities-table-key-settings.component.ts | 4 +++- .../home/components/widget/lib/table-widget.models.ts | 3 ++- .../widget/lib/timeseries-table-widget.component.ts | 4 ++-- ui-ngx/src/assets/locale/locale.constant-en_US.json | 3 ++- 14 files changed, 42 insertions(+), 14 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html index 19a0a95abf..acd4fc43be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.html @@ -78,7 +78,7 @@ - + {{ column.title }}
+
+ + {{ 'widgets.table.disable-sorting' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts index 8984e3889f..cccc826ff4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts @@ -47,7 +47,8 @@ export class AlarmsTableKeySettingsComponent extends WidgetSettingsComponent { useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -61,6 +62,7 @@ export class AlarmsTableKeySettingsComponent extends WidgetSettingsComponent { cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html index 1dcd4f1f3f..1905888bb6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.html @@ -47,6 +47,11 @@
+
+ + {{ 'widgets.table.disable-sorting' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts index 1bc28baf53..ff314d9d2d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-key-settings.component.ts @@ -45,7 +45,8 @@ export class TimeseriesTableKeySettingsComponent extends WidgetSettingsComponent useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -57,6 +58,7 @@ export class TimeseriesTableKeySettingsComponent extends WidgetSettingsComponent cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html index 54b2a370e6..684258bea7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.html @@ -56,6 +56,11 @@
+
+ + {{ 'widgets.table.disable-sorting' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts index a0ee7e7c70..2e2f414f2c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-latest-key-settings.component.ts @@ -46,7 +46,8 @@ export class TimeseriesTableLatestKeySettingsComponent extends WidgetSettingsCom useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -60,6 +61,7 @@ export class TimeseriesTableLatestKeySettingsComponent extends WidgetSettingsCom cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html index 76aa5fd2f4..14383b9608 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.html @@ -59,6 +59,11 @@
+
+ + {{ 'widgets.table.disable-sorting' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts index 8572a9e348..89393ade34 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts @@ -47,7 +47,8 @@ export class EntitiesTableKeySettingsComponent extends WidgetSettingsComponent { useCellContentFunction: false, cellContentFunction: '', defaultColumnVisibility: 'visible', - columnSelectionToDisplay: 'enabled' + columnSelectionToDisplay: 'enabled', + disableSorting: false }; } @@ -61,6 +62,7 @@ export class EntitiesTableKeySettingsComponent extends WidgetSettingsComponent { cellContentFunction: [settings.cellContentFunction, [Validators.required]], defaultColumnVisibility: [settings.defaultColumnVisibility, []], columnSelectionToDisplay: [settings.columnSelectionToDisplay, []], + disableSorting: [settings.disableSorting, []] }); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 47ae0b8cc8..15a94f5ecc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -61,6 +61,7 @@ export interface TableWidgetDataKeySettings { cellContentFunction?: TbFunction; defaultColumnVisibility?: ColumnVisibilityOptions; columnSelectionToDisplay?: ColumnSelectionOptions; + disableSorting?: boolean; } export type ShowCellButtonActionFunction = (ctx: WidgetContext, data: EntityData | AlarmDataInfo | FormattedData) => boolean; @@ -145,7 +146,7 @@ export function entityDataSortOrderFromString(strSortOrder: string, columns: Ent if (!column) { column = findColumnByName(property, columns); } - if (column && column.entityKey) { + if (column && column.entityKey && column.sortable) { return {key: column.entityKey, direction}; } return null; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index 0fda3b9afe..623aed9c34 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -518,8 +518,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI const latestDataKeys = datasource.latestDataKeys; let header: TimeseriesHeader[] = []; dataKeys.forEach((dataKey, index) => { - const sortable = !dataKey.usePostProcessing; const keySettings: TableWidgetDataKeySettings = dataKey.settings; + const sortable = !keySettings.disableSorting && !dataKey.usePostProcessing; const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); @@ -544,8 +544,8 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI if (latestDataKeys) { latestDataKeys.forEach((dataKey, latestIndex) => { const index = dataKeys.length + latestIndex; - const sortable = !dataKey.usePostProcessing; const keySettings: TimeseriesWidgetLatestDataKeySettings = dataKey.settings; + const sortable = !keySettings.disableSorting && !dataKey.usePostProcessing; const styleInfo = getCellStyleInfo(this.ctx, keySettings, 'value, rowData, ctx'); const contentFunctionInfo = getCellContentFunctionInfo(this.ctx, keySettings, 'value, rowData, ctx'); const columnDefaultVisibility = getColumnDefaultVisibility(keySettings, this.ctx); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 18801bda2c..62a9b6883f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -8164,7 +8164,8 @@ "timeseries-column-error": "At least one time series column should be specified", "alarm-column-error": "At least one alarm column should be specified", "table-tabs": "Table tabs", - "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode" + "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode", + "disable-sorting": "Disable sorting" }, "latest-chart": { "total": "Total", From f42e0e49b91f9587609e77f80ce0613286627627 Mon Sep 17 00:00:00 2001 From: Artem Dzhereleiko Date: Fri, 28 Feb 2025 15:16:13 +0200 Subject: [PATCH 048/127] UI: Processing settings for save attribute node --- ...dvanced-persistence-setting.component.html | 12 ++- .../advanced-persistence-setting.component.ts | 52 +++++++++-- .../action/attributes-config.component.html | 41 +++++++++ .../action/attributes-config.component.ts | 91 ++++++++++++++++++- .../action/attributes-config.model.ts | 59 ++++++++++++ .../action/timeseries-config.component.html | 1 + .../assets/locale/locale.constant-en_US.json | 16 ++++ 7 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html index 094f6dbc2a..f951a9f826 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.html @@ -18,18 +18,22 @@
- - + - diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.ts index 01314ec4f3..4609a7d010 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-persistence-setting.component.ts @@ -19,12 +19,15 @@ import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, + UntypedFormGroup, ValidationErrors, Validator } from '@angular/forms'; -import { Component, forwardRef } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/timeseries-config.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { AttributeAdvancedProcessingStrategy } from '@home/components/rule-node/action/attributes-config.model'; @Component({ selector: 'tb-advanced-persistence-settings', @@ -39,19 +42,48 @@ import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/ti multi: true }] }) -export class AdvancedPersistenceSettingComponent implements ControlValueAccessor, Validator { +export class AdvancedPersistenceSettingComponent implements OnInit, ControlValueAccessor, Validator { - persistenceForm = this.fb.group({ - timeseries: [null], - latest: [null], - webSockets: [null] - }); + @Input() + @coerceBoolean() + timeseries = false; + + @Input() + @coerceBoolean() + attribute = false; + + @Input() + @coerceBoolean() + latest = false; + + @Input() + @coerceBoolean() + webSockets = false; + + persistenceForm: UntypedFormGroup; private propagateChange: (value: any) => void = () => {}; - constructor(private fb: FormBuilder) { + constructor(private fb: FormBuilder, + private destroyRef: DestroyRef) { + } + + ngOnInit() { + this.persistenceForm = this.fb.group({}); + if (this.timeseries) { + this.persistenceForm.addControl('timeseries', this.fb.control(null, [])); + } + if (this.attribute) { + this.persistenceForm.addControl('attribute', this.fb.control(null, [])); + } + if (this.attribute) { + this.persistenceForm.addControl('latest', this.fb.control(null, [])); + } + if (this.attribute) { + this.persistenceForm.addControl('webSockets', this.fb.control(null, [])); + } this.persistenceForm.valueChanges.pipe( - takeUntilDestroyed() + takeUntilDestroyed(this.destroyRef) ).subscribe(value => this.propagateChange(value)); } @@ -76,7 +108,7 @@ export class AdvancedPersistenceSettingComponent implements ControlValueAccessor }; } - writeValue(value: AdvancedProcessingStrategy) { + writeValue(value: AdvancedProcessingStrategy | AttributeAdvancedProcessingStrategy) { this.persistenceForm.patchValue(value, {emitEvent: false}); } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index 03ea4fccdd..69feec1732 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html @@ -16,6 +16,47 @@ -->
+
+
+
+ rule-node-config.save-attribute.processing-settings +
+ + {{ 'rule-node-config.basic-mode' | translate}} + {{ 'rule-node-config.advanced-mode' | translate }} + +
+ @if(!attributesConfigForm.get('processingSettings.isAdvanced').value) { + + rule-node-config.save-attribute.strategy + + @for (strategy of persistenceStrategies; track strategy) { + {{ PersistenceTypeTranslationMap.get(strategy) | translate }} + } + + + + @if(attributesConfigForm.get('processingSettings.type').value === PersistenceType.DEDUPLICATE) { + + + } + } @else { + + } +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts index 5a404c8de4..7257565608 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts @@ -15,10 +15,22 @@ /// import { Component } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { FormGroup, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; import { AttributeScope, telemetryTypeTranslations } from '@app/shared/models/telemetry/telemetry.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + maxDeduplicateTimeSecs, + ProcessingSettings, + ProcessingSettingsForm, + ProcessingType, + ProcessingTypeTranslationMap +} from '@home/components/rule-node/action/timeseries-config.models'; +import { + AttributeNodeConfiguration, + AttributeNodeConfigurationForm, + defaultAttributeAdvancedPersistenceStrategy +} from '@home/components/rule-node/action/attributes-config.model'; @Component({ selector: 'tb-action-node-attributes-config', @@ -31,6 +43,12 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent { attributeScopes = Object.keys(AttributeScope); telemetryTypeTranslationsMap = telemetryTypeTranslations; + PersistenceType = ProcessingType; + persistenceStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.WEBSOCKETS_ONLY]; + PersistenceTypeTranslationMap = ProcessingTypeTranslationMap; + + maxDeduplicateTime = maxDeduplicateTimeSecs; + attributesConfigForm: UntypedFormGroup; constructor(private fb: UntypedFormBuilder) { @@ -41,8 +59,64 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent { return this.attributesConfigForm; } + protected validatorTriggers(): string[] { + return ['processingSettings.isAdvanced', 'processingSettings.type']; + } + + protected prepareInputConfig(config: AttributeNodeConfiguration): AttributeNodeConfigurationForm { + let processingSettings: ProcessingSettingsForm; + if (config?.processingSettings) { + const isAdvanced = config?.processingSettings?.type === ProcessingType.ADVANCED; + processingSettings = { + type: isAdvanced ? ProcessingType.ON_EVERY_MESSAGE : config.processingSettings.type, + isAdvanced: isAdvanced, + deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs ?? 60, + advanced: isAdvanced ? config.processingSettings : defaultAttributeAdvancedPersistenceStrategy + } + } else { + processingSettings = { + type: ProcessingType.ON_EVERY_MESSAGE, + isAdvanced: false, + deduplicationIntervalSecs: 60, + advanced: defaultAttributeAdvancedPersistenceStrategy + }; + } + return { + ...config, + processingSettings: processingSettings + } + } + + protected prepareOutputConfig(config: AttributeNodeConfigurationForm): AttributeNodeConfiguration { + let processingSettings: ProcessingSettings; + if (config.processingSettings.isAdvanced) { + processingSettings = { + ...config.processingSettings.advanced, + type: ProcessingType.ADVANCED + }; + } else { + processingSettings = { + type: config.processingSettings.type, + deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs + }; + } + return { + ...config, + processingSettings + }; + } + protected onConfigurationSet(configuration: RuleNodeConfiguration) { this.attributesConfigForm = this.fb.group({ + processingSettings: this.fb.group({ + isAdvanced: [configuration?.processingSettings?.isAdvanced ?? false], + type: [configuration?.processingSettings?.type ?? ProcessingType.ON_EVERY_MESSAGE], + deduplicationIntervalSecs: [ + {value: configuration?.processingSettings?.deduplicationIntervalSecs ?? 60, disabled: true}, + [Validators.required, Validators.max(maxDeduplicateTimeSecs)] + ], + advanced: [{value: null, disabled: true}] + }), scope: [configuration ? configuration.scope : null, [Validators.required]], notifyDevice: [configuration ? configuration.notifyDevice : true, []], sendAttributesUpdatedNotification: [configuration ? configuration.sendAttributesUpdatedNotification : false, []], @@ -62,4 +136,19 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent { }); } + protected updateValidators(emitEvent: boolean, _trigger?: string) { + const processingForm = this.attributesConfigForm.get('processingSettings') as FormGroup; + const isAdvanced: boolean = processingForm.get('isAdvanced').value; + const type: ProcessingType = processingForm.get('type').value; + if (!isAdvanced && type === ProcessingType.DEDUPLICATE) { + processingForm.get('deduplicationIntervalSecs').enable({emitEvent}); + } else { + processingForm.get('deduplicationIntervalSecs').disable({emitEvent}); + } + if (isAdvanced) { + processingForm.get('advanced').enable({emitEvent}); + } else { + processingForm.get('advanced').disable({emitEvent}); + } + } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts new file mode 100644 index 0000000000..fb53a1ceaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts @@ -0,0 +1,59 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { DAY, SECOND } from '@shared/models/time/time.models'; +import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; +import { BasicProcessingSettings, ProcessingType } from '@home/components/rule-node/action/timeseries-config.models'; + +export interface AttributeNodeConfiguration { + processingSettings: AttributeProcessingSettings; + scope: AttributeScope; + notifyDevice: boolean; + sendAttributesUpdatedNotification: boolean; + updateAttributesOnlyOnValueChange: boolean; +} + +export interface AttributeNodeConfigurationForm extends Omit { + processingSettings: AttributeProcessingSettingsForm +} + +export type AttributeProcessingSettings = BasicProcessingSettings & Partial & Partial; + +export type AttributeProcessingSettingsForm = Omit & { + isAdvanced: boolean; + advanced?: Partial; + type: ProcessingType; +}; + +export interface AttributeDeduplicateProcessingStrategy extends BasicProcessingSettings{ + deduplicationIntervalSecs: number; +} + +export interface AttributeAdvancedProcessingStrategy extends BasicProcessingSettings{ + attribute: AttributeAdvancedProcessingConfig; + webSockets: AttributeAdvancedProcessingConfig; +} + +export type AttributeAdvancedProcessingConfig = WithOptional; + +export const defaultAdvancedProcessingConfig: AttributeAdvancedProcessingConfig = { + type: ProcessingType.ON_EVERY_MESSAGE +} + +export const defaultAttributeAdvancedPersistenceStrategy: Omit = { + attribute: defaultAdvancedProcessingConfig, + webSockets: defaultAdvancedProcessingConfig, +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html index 6e3e9fb6d1..250e66541b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html @@ -53,6 +53,7 @@ }
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 18801bda2c..291cae525f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5155,6 +5155,22 @@ "latest": "Latest values", "web-sockets": "WebSockets" }, + "save-attribute": { + "processing-settings": "Processing settings", + "processing-settings-hint": "Define how incoming messages are processed. In Basic mode, select a preconfigured processing strategy or enable only WebSocket updates. Advanced mode allows you to select individual processing strategies for each action.", + "advanced-settings-hint": "Be cautious when configuring processing strategies. Certain combinations can lead to unexpected behavior.", + "strategy": "Strategy", + "deduplication-interval": "Deduplication interval", + "deduplication-interval-required": "Deduplication interval is required", + "deduplication-interval-min-max-range": "Deduplication interval should be at least 1 second and at most 1 day", + "strategy-type": { + "every-message": "On every message", + "skip": "Skip", + "deduplicate": "Deduplicate", + "web-sockets-only": "WebSockets only" + }, + "attribute": "Attribute" + }, "key-val": { "key": "Key", "value": "Value", From dba18323e2029b9c4802959da1095865d2ef9858 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Fri, 28 Feb 2025 17:32:54 +0200 Subject: [PATCH 049/127] Fixed transport messages maximum number hints --- .../default-tenant-profile-configuration.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 64d5039914..aded3e2f78 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -155,10 +155,10 @@ formControlName="maxTransportMessages" type="number"> - {{ 'tenant-profile.max-transport-messages-range' | translate}} + {{ 'tenant-profile.max-transport-messages-required' | translate}} - {{ 'tenant-profile.max-transport-messages-required' | translate}} + {{ 'tenant-profile.max-transport-messages-range' | translate}} From cd3a7c4c4ea1e1ff863a7006d24a6184285535d9 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 28 Feb 2025 19:31:22 +0200 Subject: [PATCH 050/127] UI: Trip Map - Implement timeline control. Implement trip item. --- .../lib/maps/data-layer/data-layer-utils.ts | 96 +++++ .../lib/maps/data-layer/map-data-layer.ts | 67 +--- .../lib/maps/data-layer/trips-data-layer.ts | 347 +++++++++++++++++- .../home/components/widget/lib/maps/map.ts | 106 +++++- .../widget/lib/maps/models/map.models.ts | 52 ++- .../panels/map-timeline-panel.component.html | 74 +++- .../panels/map-timeline-panel.component.scss | 29 +- .../panels/map-timeline-panel.component.ts | 174 ++++++++- .../assets/locale/locale.constant-en_US.json | 3 +- 9 files changed, 819 insertions(+), 129 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts new file mode 100644 index 0000000000..f5872844c9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts @@ -0,0 +1,96 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + DataLayerTooltipSettings, + DataLayerTooltipTrigger, processTooltipTemplate, + TbMapDatasource +} from '@home/components/widget/lib/maps/models/map.models'; +import { TbMap } from '@home/components/widget/lib/maps/map'; +import { FormattedData } from '@shared/models/widget.models'; +import L from 'leaflet'; +import { DataLayerPatternProcessor } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; + +export const createTooltip = (map: TbMap, + layer: L.Layer, + settings: DataLayerTooltipSettings, + data: FormattedData, + canOpen: () => boolean): L.Popup => { + const tooltip = L.popup(); + layer.bindPopup(tooltip, {autoClose: settings.autoclose, closeOnClick: false}); + layer.off('click'); + if (settings.trigger === DataLayerTooltipTrigger.click) { + layer.on('click', () => { + if (tooltip.isOpen()) { + layer.closePopup(); + } else if (canOpen()) { + layer.openPopup(); + } + }); + } else if (settings.trigger === DataLayerTooltipTrigger.hover) { + layer.on('mouseover', () => { + if (canOpen()) { + layer.openPopup(); + } + }); + layer.on('mousemove', (e) => { + tooltip.setLatLng(e.latlng); + }); + layer.on('mouseout', () => { + layer.closePopup(); + }); + } + layer.on('popupopen', () => { + bindTooltipActions(map, tooltip, settings, data); + (layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { + event.preventDefault(); + }); + }); + return tooltip; +} + +export const updateTooltip = (map: TbMap, + tooltip: L.Popup, + settings: DataLayerTooltipSettings, + processor: DataLayerPatternProcessor, + data: FormattedData, + dsData: FormattedData[]): void => { + let tooltipTemplate = processor.processPattern(data, dsData); + tooltipTemplate = processTooltipTemplate(tooltipTemplate); + tooltip.setContent(tooltipTemplate); + if (tooltip.isOpen() && tooltip.getElement()) { + bindTooltipActions(map, tooltip, settings, data); + } +} + +const bindTooltipActions = (map: TbMap, tooltip: L.Popup, settings: DataLayerTooltipSettings, data: FormattedData): void => { + const actions = tooltip.getElement().getElementsByClassName('tb-custom-action'); + Array.from(actions).forEach( + (element: HTMLElement) => { + const actionName = element.getAttribute('data-action-name'); + if (settings?.tagActions) { + const action = settings.tagActions.find(action => action.name === actionName); + if (action) { + element.onclick = ($event) => + { + map.dataItemClick($event, action, data.$datasource); + return false; + }; + } + } + } + ); +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 8916931d2b..1c4e58a320 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -44,6 +44,7 @@ import { CompiledTbFunction } from '@shared/models/js-function.models'; import { map } from 'rxjs/operators'; import { WidgetContext } from '@home/models/widget-component.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; +import { createTooltip, updateTooltip } from './data-layer-utils'; export abstract class TbDataLayerItem = TbMapDataLayer, L extends L.Layer = L.Layer> { @@ -60,8 +61,12 @@ export abstract class TbDataLayerItem { + return !this.isEditing(); + }); + updateTooltip(this.dataLayer.getMap(), this.tooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); } this.bindEvents(); try { @@ -220,12 +225,8 @@ export abstract class TbDataLayerItem, dsData: FormattedData[]) { if (this.settings.tooltip.show) { - let tooltipTemplate = this.dataLayer.dataLayerTooltipProcessor.processPattern(data, dsData); - tooltipTemplate = processTooltipTemplate(tooltipTemplate); - this.tooltip.setContent(tooltipTemplate); - if (this.tooltip.isOpen() && this.tooltip.getElement()) { - this.bindTooltipActions(); - } + updateTooltip(this.dataLayer.getMap(), this.tooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); } } @@ -253,56 +254,6 @@ export abstract class TbDataLayerItem; - private createTooltip() { - this.tooltip = L.popup(); - this.layer.bindPopup(this.tooltip, {autoClose: this.settings.tooltip.autoclose, closeOnClick: false}); - this.layer.off('click'); - if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.click) { - this.layer.on('click', () => { - if (this.tooltip.isOpen()) { - this.layer.closePopup(); - } else if (!this.isEditing()) { - this.layer.openPopup(); - } - }); - } else if (this.settings.tooltip.trigger === DataLayerTooltipTrigger.hover) { - this.layer.on('mouseover', () => { - if (!this.isEditing()) { - this.layer.openPopup(); - } - }); - this.layer.on('mousemove', (e) => { - this.tooltip.setLatLng(e.latlng); - }); - this.layer.on('mouseout', () => { - this.layer.closePopup(); - }); - } - this.layer.on('popupopen', () => { - this.bindTooltipActions(); - (this.layer as any)._popup._closeButton.addEventListener('click', (event: Event) => { - event.preventDefault(); - }); - }); - } - - private bindTooltipActions() { - const actions = this.tooltip.getElement().getElementsByClassName('tb-custom-action'); - Array.from(actions).forEach( - (element: HTMLElement) => { - const actionName = element.getAttribute('data-action-name'); - if (this.settings.tooltip?.tagActions) { - const action = this.settings.tooltip.tagActions.find(action => action.name === actionName); - if (action) { - element.onclick = ($event) => - { - this.dataLayer.getMap().dataItemClick($event, action, this.data.$datasource); - return false; - }; - } - } - }); - } } export enum MapDataLayerType { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 63824365ca..a7b0d74d4d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -22,17 +22,25 @@ import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { - BaseMarkerShapeSettings, ClusterMarkerColorFunction, - DataLayerColorType, defaultBaseMarkersDataLayerSettings, defaultBaseTripsDataLayerSettings, + BaseMarkerShapeSettings, + calculateInterpolationRatio, calculateLastPoints, + ClusterMarkerColorFunction, + DataLayerColorType, + defaultBaseMarkersDataLayerSettings, + defaultBaseTripsDataLayerSettings, + findRotationAngle, + interpolateLineSegment, isValidLatLng, loadImageWithAspect, - MapStringFunction, MapType, + MapStringFunction, + MapType, MarkerIconInfo, MarkerIconSettings, MarkerImageFunction, MarkerImageInfo, MarkerImageSettings, MarkerImageType, - MarkerPositionFunction, MarkersDataLayerSettings, + MarkerPositionFunction, + MarkersDataLayerSettings, MarkerShapeSettings, MarkerType, TbMapDatasource, @@ -49,12 +57,244 @@ import tinycolor from 'tinycolor2'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { catchError, map, switchMap } from 'rxjs/operators'; -import L from 'leaflet'; +import L, { PolylineDecorator } from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; -import { isDefined, isDefinedAndNotNull, parseTbFunction, safeExecuteTbFunction } from '@core/utils'; +import { + deepClone, + isDefined, + isDefinedAndNotNull, + isEmptyStr, + isUndefined, + parseTbFunction, + safeExecuteTbFunction +} from '@core/utils'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import moment from 'moment/moment'; +import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; +import { createTooltip, updateTooltip } from '@home/components/widget/lib/maps/data-layer/data-layer-utils'; + +type TripRouteData = {[time: number]: FormattedData}; + +class TbTripDataItem { + + private tripRouteData: TripRouteData; + + private layer: L.FeatureGroup; + + private marker: L.Marker; + private markerTooltip: L.Popup; + private labelOffset: L.PointTuple; + + private polyline: L.Polyline; + private polylineDecorator: PolylineDecorator; + private points: L.FeatureGroup; + + private currentTime: number; + private currentPositionData: FormattedData; + + constructor(private rawRouteData: FormattedData[], + private latestData: FormattedData, + private settings: TripsDataLayerSettings, + private dataLayer: TbTripsDataLayer) { + this.tripRouteData = this.prepareTripRouteData(); + this.create(); + } + + private create() { + this.updateCurrentPosition(); + this.layer = L.featureGroup(); + let pointData = this.currentPositionData; + if (this.latestData) { + pointData = {...pointData, ...this.latestData}; + } + this.createMarker(pointData); + try { + this.dataLayer.getDataLayerContainer().addLayer(this.layer); + } catch (e) { + console.warn(e); + } + } + + public update(rawRouteData: FormattedData[]) { + this.rawRouteData = rawRouteData; + this.tripRouteData = this.prepareTripRouteData(); + this.updateCurrentPosition(true); + let pointData = this.currentPositionData; + if (this.latestData) { + pointData = {...pointData, ...this.latestData}; + } + this.updateMarker(pointData); + } + + public updateLatestData(latestData: FormattedData) { + this.latestData = latestData; + this.updateAppearance(); + } + + public updateAppearance() { + let data = this.currentPositionData; + if (this.latestData) { + data = {...data, ...this.latestData}; + } + const dsData = this.dataLayer.getMap().getData(); + if (this.settings.tooltip.show) { + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + } + this.updateMarkerIcon(data, dsData); + } + + public updateCurrentTime() { + this.updateCurrentPosition(); + let pointData = this.currentPositionData; + if (this.latestData) { + pointData = {...pointData, ...this.latestData}; + } + this.updateMarker(pointData); + } + + public remove() { + this.dataLayer.getDataLayerContainer().removeLayer(this.layer); + this.layer.off(); + } + + public getLayer(): L.Layer { + return this.layer; + } + + private createMarker(data: FormattedData) { + const dsData = this.dataLayer.getMap().getData(); + const location = this.dataLayer.extractLocation(data, dsData); + this.marker = L.marker(location, { + tbMarkerData: data, + snapIgnore: true + }); + this.marker.addTo(this.layer); + this.updateMarkerIcon(data, dsData); + if (this.settings.tooltip?.show) { + this.markerTooltip = createTooltip(this.dataLayer.getMap(), + this.marker, this.settings.tooltip, data, () => true); + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + } + } + + private updateMarker(data: FormattedData) { + const dsData = this.dataLayer.getMap().getData(); + this.marker.options.tbMarkerData = data; + this.updateMarkerLocation(data, dsData); + if (this.settings.tooltip.show) { + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + } + this.updateMarkerIcon(data, dsData); + } + + private updateMarkerLocation(data: FormattedData, dsData: FormattedData[]) { + const location = this.dataLayer.extractLocation(data, dsData); + if (!this.marker.getLatLng().equals(location)) { + this.marker.setLatLng(location); + } + } + + private updateMarkerIcon(data: FormattedData, dsData: FormattedData[]) { + this.dataLayer.tripMarkerIconProcessor.createMarkerIcon(data, dsData).subscribe( + (iconInfo) => { + const options = deepClone(iconInfo.icon.options); + this.marker.setIcon(iconInfo.icon); + const anchor = options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateMarkerLabel(data, dsData); + } + ); + } + + private updateMarkerLabel(data: FormattedData, dsData: FormattedData[]) { + if (this.settings.label.show) { + this.marker.unbindTooltip(); + const label = this.dataLayer.dataLayerLabelProcessor.processPattern(data, dsData); + const labelColor = this.dataLayer.getCtx().widgetConfig.color; + const content: L.Content = `
${label}
`; + this.marker.bindTooltip(content, { className: 'tb-marker-label', permanent: true, direction: 'top', offset: this.labelOffset }); + } + } + + private prepareTripRouteData(): TripRouteData { + const result: TripRouteData = {}; + const minTime = this.dataLayer.getMap().getMinTime(); + const maxTime = this.dataLayer.getMap().getMaxTime(); + const timeStep = this.dataLayer.getMap().getTimeStep(); + const timeline = this.dataLayer.getMap().hasTimeline(); + for (const data of this.rawRouteData) { + const currentTime = data.time; + const normalizeTime = timeline ? (minTime + + Math.ceil((currentTime - minTime) / timeStep) * timeStep) : currentTime; + result[normalizeTime] = { + ...data, + minTime: minTime !== Infinity ? moment(minTime).format('YYYY-MM-DD HH:mm:ss') : '', + maxTime: maxTime !== -Infinity ? moment(maxTime).format('YYYY-MM-DD HH:mm:ss') : '', + rotationAngle: this.settings.rotateMarker ? this.settings.offsetAngle : 0 + }; + } + if (timeline) { + const xKey = this.settings.xKey.label; + const yKey = this.settings.yKey.label; + const timeStamp = Object.keys(result); + for (let i = 0; i < timeStamp.length - 1; i++) { + if (isUndefined(result[timeStamp[i + 1]][xKey]) || isUndefined(result[timeStamp[i + 1]][yKey])) { + for (let j = i + 2; j < timeStamp.length - 1; j++) { + if (isDefined(result[timeStamp[j]][xKey]) || isDefined(result[timeStamp[j]][yKey])) { + const ratio = calculateInterpolationRatio(Number(timeStamp[i]), Number(timeStamp[j]), Number(timeStamp[i + 1])); + result[timeStamp[i + 1]] = { + ...interpolateLineSegment(result[timeStamp[i]], result[timeStamp[j]], xKey, yKey, ratio), + ...result[timeStamp[i + 1]], + }; + break; + } + } + } + if (this.settings.rotateMarker) { + result[timeStamp[i]].rotationAngle += findRotationAngle(result[timeStamp[i]], result[timeStamp[i + 1]], xKey, yKey); + } + } + } + return result; + } + + private updateCurrentPosition(force = false) { + if (this.currentTime !== this.dataLayer.getMap().getCurrentTime() || force) { + this.currentTime = this.dataLayer.getMap().getCurrentTime(); + let currentPosition = this.tripRouteData[this.currentTime]; + if (!currentPosition) { + const timePoints = Object.keys(this.tripRouteData).map(item => parseInt(item, 10)); + for (let i = 1; i < timePoints.length; i++) { + if (timePoints[i - 1] < this.currentTime && timePoints[i] > this.currentTime) { + const beforePosition = this.tripRouteData[timePoints[i - 1]]; + const afterPosition = this.tripRouteData[timePoints[i]]; + const ratio = calculateInterpolationRatio(timePoints[i - 1], timePoints[i], this.currentTime); + currentPosition = { + ...beforePosition, + time: this.currentTime, + ...interpolateLineSegment(beforePosition, afterPosition, this.settings.xKey.label, this.settings.yKey.label, ratio) + }; + break; + } + } + } + if (!currentPosition) { + currentPosition = calculateLastPoints(this.tripRouteData, this.currentTime); + } + this.currentPositionData = currentPosition; + } + } + +} abstract class TripMarkerIconProcessor { @@ -205,7 +445,7 @@ class ImageTripMarkerIconProcessor extends TripMarkerIconProcessor
`, className: 'tb-marker-div-icon', @@ -241,6 +481,8 @@ export class TbTripsDataLayer extends TbMapDataLayer(); + private positionFunction: CompiledTbFunction; private rawTripsData: FormattedData[][]; @@ -338,16 +580,66 @@ export class TbTripsDataLayer extends TbMapDataLayer { + const entityId = rawTripData[0].entityId; + let tripItem = this.tripItems.get(entityId); + if (tripItem) { + tripItem.update(rawTripData); + } else { + const latestData = this.latestTripsData.find(d => d.entityId === entityId); + tripItem = new TbTripDataItem(rawTripData, latestData, this.settings, this); + this.tripItems.set(entityId, tripItem); + } + toDelete.delete(entityId); + }); + toDelete.forEach((key) => { + this.removeItem(key); + }); + } + + public removeItem(key: string): void { + const item = this.tripItems.get(key); + if (item) { + item.remove(); + this.tripItems.delete(key); + } } public updateTripsLatestData(tripsLatestData: FormattedData[]) { this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); + console.log(`Update trips latest data`); + this.tripItems.forEach((item, entityId) => { + const latestData = this.latestTripsData.find(d => d.entityId === entityId); + item.updateLatestData(latestData); + }); + } + + public updateCurrentTime() { + const currentTime = this.map.getCurrentTime(); + console.log(`Update trips current time: current(${currentTime})`); + this.tripItems.forEach(item => { + item.updateCurrentTime(); + }); + } + + public updateAppearance() { + console.log(`Update trips appearance`); + this.tripItems.forEach(item => { + item.updateAppearance(); + }); + } + + public calculateAnchors(): number[] { + return []; } private clearIncorrectFirsLastDatapoint(dataSource: FormattedData[]): FormattedData[] { @@ -413,4 +705,39 @@ export class TbTripsDataLayer extends TbMapDataLayer): {x: number; y: number} { + if (data) { + const xKeyVal = data[this.settings.xKey.label]; + const yKeyVal = data[this.settings.yKey.label]; + switch (this.mapType()) { + case MapType.geoMap: + if (!isValidLatLng(xKeyVal, yKeyVal)) { + return null; + } + break; + case MapType.image: + if (!isDefinedAndNotNull(xKeyVal) || isEmptyStr(xKeyVal) || isNaN(xKeyVal) || !isDefinedAndNotNull(yKeyVal) || isEmptyStr(yKeyVal) || isNaN(yKeyVal)) { + return null; + } + break; + } + return {x: xKeyVal, y: yKeyVal}; + } else { + return null; + } + } + + public extractLocation(data: FormattedData, dsData: FormattedData[]): L.LatLng { + let locationData = this.extractLocationData(data); + if (locationData) { + if (this.map.type() === MapType.image && this.positionFunction) { + const imageMap = this.map as TbImageMap; + locationData = this.positionFunction.execute(locationData.x, locationData.y, data, dsData, imageMap.getAspect()) || {x: 0, y: 0}; + } + return this.map.locationDataToLatLng(locationData); + } else { + return null; + } + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 7e884c473d..11af4338e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -33,7 +33,7 @@ import { formattedDataArrayFromDatasourceData, formattedDataFormDatasourceData, isDefined, - isDefinedAndNotNull, + isDefinedAndNotNull, isUndefined, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; @@ -90,6 +90,12 @@ export abstract class TbMap { protected tripDataLayers: TbTripsDataLayer[]; protected dsData: FormattedData[]; + protected timeline = false; + protected minTime: number; + protected maxTime: number; + protected timeStep: number; + protected currentTime: number; + protected selectedDataItem: TbDataLayerItem; protected mapLayoutElement: HTMLElement; @@ -108,9 +114,9 @@ export abstract class TbMap { protected timeLineComponentRef: ComponentRef; protected timeLineComponent: MapTimelinePanelComponent; - protected addMarkerDataLayers: TbMapDataLayer[]; - protected addPolygonDataLayers: TbMapDataLayer[]; - protected addCircleDataLayers: TbMapDataLayer[]; + protected addMarkerDataLayers: TbMapDataLayer[]; + protected addPolygonDataLayers: TbMapDataLayer[]; + protected addCircleDataLayers: TbMapDataLayer[]; private readonly mapResize$: ResizeObserver; @@ -135,11 +141,14 @@ export abstract class TbMap { $(containerElement).append(mapLayoutElement); if (this.settings.tripTimeline?.showTimelineControl) { + this.timeline = true; + this.timeStep = this.settings.tripTimeline.timeStep; this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); this.timeLineComponent = this.timeLineComponentRef.instance; this.timeLineComponent.settings = this.settings.tripTimeline; this.timeLineComponent.timeChanged.subscribe((time) => { - console.log(`Time updated: ${time}`); + this.currentTime = time; + this.updateTripsTime(); }); const parentElement = this.timeLineComponentRef.instance.element.nativeElement; const content = parentElement.firstChild; @@ -740,7 +749,7 @@ export abstract class TbMap { undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); this.dataLayers.forEach(dl => dl.updateData(this.dsData)); this.updateTripsAppearance(); - this.updateTripsTimeline(); + this.updateTripsAnchors(); this.updateBounds(); this.updateAddButtonsStates(); } @@ -757,8 +766,23 @@ export abstract class TbMap { minTime = Math.min(minMax.minTime, minTime); maxTime = Math.max(minMax.maxTime, maxTime); } - this.tripDataLayers.forEach(dl => dl.updateTrips(minTime, maxTime)); - this.updateTripsTimeline(minTime, maxTime); + const prevMinTime = this.minTime; + const prevMaxTime = this.maxTime; + this.minTime = minTime; + this.maxTime = maxTime; + if (this.timeline) { + this.timeLineComponent.min = this.minTime; + this.timeLineComponent.max = this.maxTime; + const currentTime = this.calculateCurrentTime(prevMinTime, prevMaxTime); + if (currentTime !== this.currentTime) { + this.currentTime = currentTime; + this.timeLineComponent.currentTime = currentTime; + } + } else { + this.currentTime = this.maxTime; + } + this.tripDataLayers.forEach(dl => dl.updateTrips()); + this.updateTripsAnchors(); this.updateBounds(); } @@ -766,22 +790,43 @@ export abstract class TbMap { const tripsLatestData = formattedDataFormDatasourceData(subscription.latestData, undefined, undefined, el => el.datasource.entityId + el.datasource.mapDataIds[0]); this.tripDataLayers.forEach(dl => dl.updateTripsLatestData(tripsLatestData)); - this.updateTripsAppearance(); - this.updateTripsTimeline(); + this.updateTripsAnchors(); } - private updateTripsAppearance() {} + private updateTripsAppearance() { + this.tripDataLayers.forEach(dl => dl.updateAppearance()); + } + private updateTripsTime() { + this.tripDataLayers.forEach(dl => dl.updateCurrentTime()); + } - private updateTripsTimeline(minTime?: number, maxTime?: number) { - if (this.settings.tripTimeline?.showTimelineControl) { - if (isDefinedAndNotNull(minTime) && isDefinedAndNotNull(maxTime)) { - this.timeLineComponent.min = minTime; - this.timeLineComponent.max = maxTime; - } + private updateTripsAnchors() { + if (this.timeline) { if (this.settings.tripTimeline.snapToRealLocation) { // Recalculate anchors only for enabled layers + let anchors: number[] = []; + const enableTrips = this.tripDataLayers.filter(dl => dl.isEnabled()); + for (const tripsDataLayer of enableTrips) { + const tripsAnchors = tripsDataLayer.calculateAnchors(); + anchors = [...new Set([...anchors, ...tripsAnchors])]; + } + anchors.sort((a, b) => a - b); + this.timeLineComponent.anchors = anchors; + } + } + } + + private calculateCurrentTime(minTime: number, maxTime: number): number { + if (minTime !== this.minTime || maxTime !== this.maxTime) { + if (this.minTime >= this.currentTime || isUndefined(this.currentTime)) { + return this.minTime; + } else if (this.maxTime <= this.currentTime) { + return this.maxTime; + } else { + return this.minTime + Math.ceil((this.currentTime - this.minTime) / this.settings.tripTimeline.timeStep) * this.settings.tripTimeline.timeStep; } } + return this.currentTime; } private resize() { @@ -793,6 +838,9 @@ export abstract class TbMap { private updateBounds() { const enabledDataLayers = this.dataLayers.filter(dl => dl.isEnabled()); const dataLayersBounds = enabledDataLayers.map(dl => dl.getBounds()).filter(b => b.isValid()); + const enabledTripsDataLayers = this.tripDataLayers.filter(dl => dl.isEnabled()); + const tripsDataLayersBounds = enabledTripsDataLayers.map(dl => dl.getBounds()).filter(b => b.isValid()); + dataLayersBounds.push(...tripsDataLayersBounds); let bounds: L.LatLngBounds; if (dataLayersBounds.length) { bounds = new L.LatLngBounds(null, null); @@ -902,7 +950,7 @@ export abstract class TbMap { public enabledDataLayersUpdated() { this.updateAddButtonsStates(); - this.updateTripsTimeline(); + this.updateTripsAnchors(); } public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) { @@ -993,6 +1041,28 @@ export abstract class TbMap { } } + // Timeline methods + + public hasTimeline(): boolean { + return this.timeline; + } + + public getMinTime(): number { + return this.minTime; + } + + public getMaxTime(): number { + return this.maxTime; + } + + public getTimeStep(): number { + return this.timeStep; + } + + public getCurrentTime(): number { + return this.currentTime; + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index f93eab6dbc..79fb8a7c04 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -23,7 +23,7 @@ import { WidgetActionType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, mergeDeep } from '@core/utils'; +import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; import L from 'leaflet'; @@ -414,6 +414,7 @@ export const defaultBaseTripsDataLayerSettings = (mapType: MapType): Partial${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}' : '${entityName}

X Pos: ${xPos:2}
Y Pos: ${yPos:2}
End Time: ${maxTime}
Start Time: ${minTime}', @@ -1274,7 +1275,7 @@ export const processTooltipTemplate = (template: string): string => { return template; } -export function calculateNewPointCoordinate(coordinate: number, imageSize: number): number { +export const calculateNewPointCoordinate = (coordinate: number, imageSize: number): number => { let pointCoordinate = coordinate / imageSize; if (pointCoordinate < 0) { pointCoordinate = 0; @@ -1284,7 +1285,7 @@ export function calculateNewPointCoordinate(coordinate: number, imageSize: numbe return pointCoordinate; } -export function checkLngLat(point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng { +export const checkLngLat = (point: L.LatLng, southWest: L.LatLng, northEast: L.LatLng, offset = 0): L.LatLng => { const maxLngMap = northEast.lng - offset; const minLngMap = southWest.lng + offset; const maxLatMap = northEast.lat - offset; @@ -1301,3 +1302,48 @@ export function checkLngLat(point: L.LatLng, southWest: L.LatLng, northEast: L.L } return point; } + +export type TripRouteData = {[time: number]: FormattedData}; + +export const calculateInterpolationRatio = (firsMoment: number, secondMoment: number, intermediateMoment: number): number => { + return (intermediateMoment - firsMoment) / (secondMoment - firsMoment); +} + +export const interpolateLineSegment = ( + pointA: FormattedData, + pointB: FormattedData, + xKey: string, + yKey: string, + ratio: number +): { [key: string]: number } => { + return { + [xKey]: (pointA[xKey] + (pointB[xKey] - pointA[xKey]) * ratio), + [yKey]: (pointA[yKey] + (pointB[yKey] - pointA[yKey]) * ratio) + }; +} + +export const findRotationAngle = (startPoint: FormattedData, endPoint: FormattedData, xKey: string, yKey: string): number => { + if (isUndefined(startPoint) || isUndefined(endPoint)) { + return 0; + } + let angle = -Math.atan2(endPoint[xKey] - startPoint[xKey], endPoint[yKey] - startPoint[yKey]); + angle = angle * 180 / Math.PI; + return parseInt(angle.toFixed(2), 10); +} + +export const calculateLastPoints = (routeData: TripRouteData, time: number): FormattedData => { + const timeArr = Object.keys(routeData); + let index = timeArr.findIndex((dtime) => { + return Number(dtime) >= time; + }); + + if (index !== -1) { + if (Number(timeArr[index]) !== time && index !== 0) { + index--; + } + } else { + index = timeArr.length - 1; + } + + return routeData[timeArr[index]]; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html index 57bf909613..7a725e48eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html @@ -16,22 +16,62 @@ -->
- - - -
-
-
+ @if(hasData) { + + + +
+
+
+
+
+ + + @if(playing) { + + } @else { + + } + + +
+
+ + {{speed}}x + {{speedValue}}x + +
-
2
-
- - {{speedValue}}x - -
-
+ } @else { +
widgets.maps.timeline.no-trips-data-available
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss index 7795e05523..3a022f65f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss @@ -14,14 +14,37 @@ * limitations under the License. */ .tb-map-timeline-panel { - padding-top: 4px; - padding-left: 16px; - padding-right: 16px; + height: 100px; + padding: 4px 16px 8px; display: flex; flex-direction: column; .tb-timeline-controls { display: flex; flex-direction: row; align-items: center; + .tb-map-timeline-timestamp { + font-size: 14px; + font-weight: 400; + color: rgba(0,0,0,0.76); + } + .tb-timeline-buttons { + .mat-mdc-mini-fab.mat-primary { + --mdc-fab-small-container-color: rgba(0,0,0,0); + --mat-fab-small-foreground-color: rgba(0,0,0,0.54); + --mdc-fab-small-container-elevation-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.15); + &:before { + opacity: 0.1; + } + } + .tb-timeline-button-32 { + width: 32px; + height: 32px; + } + } + } + .tb-map-timeline-no-data { + font-size: 18px; + font-weight: 400; + color: rgba(0,0,0,0.76); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index 1cb76aceea..7b2b428c5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -16,17 +16,20 @@ import { ChangeDetectorRef, - Component, + Component, DestroyRef, ElementRef, - EventEmitter, Injector, - Input, OnChanges, - OnDestroy, + EventEmitter, + Injector, + Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models'; import { DateFormatProcessor } from '@shared/models/widget-settings.models'; +import { interval, Observable, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-map-timeline-panel', @@ -34,7 +37,7 @@ import { DateFormatProcessor } from '@shared/models/widget-settings.models'; styleUrls: ['./map-timeline-panel.component.scss'], encapsulation: ViewEncapsulation.None }) -export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy { +export class MapTimelinePanelComponent implements OnInit { @Input() settings: TripTimelineSettings; @@ -43,22 +46,70 @@ export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy { disabled = false; @Input() - min = 0; + set min(value: number) { + if (this.minValue !== value) { + this.minValue = value; + this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep); + this.cd.markForCheck(); + } + } + + get min(): number { + return this.minValue; + } + + @Input() + set max(value: number) { + if (this.maxValue !== value) { + this.maxValue = value; + this.maxTimeIndex = Math.ceil((this.maxValue - this.minValue) / this.settings.timeStep); + this.cd.markForCheck(); + } + } + + get max(): number { + return this.maxValue; + } @Input() - max = 10000; + set currentTime(time: number) { + if (this.currentTimeValue !== time) { + this.currentTimeValue = time; + this.updateTimestampDisplayValue(); + this.cd.markForCheck(); + } + } + + get currentTime(): number { + return this.currentTimeValue; + } + + get hasData(): boolean { + return !!this.currentTimeValue && this.currentTimeValue !== Infinity; + } + + @Input() + anchors: number[] = []; @Output() timeChanged = new EventEmitter(); - currentTime = 0; - timestampFormat: DateFormatProcessor; + minTimeIndex = 0; + maxTimeIndex = 0; + index = 0; + playing = false; + interval: Subscription; speed: number; + private minValue: number; + private maxValue: number; + private currentTimeValue: number = null; + constructor(public element: ElementRef, private cd: ChangeDetectorRef, + private destroyRef: DestroyRef, private injector: Injector) { } @@ -70,25 +121,110 @@ export class MapTimelinePanelComponent implements OnInit, OnChanges, OnDestroy { this.speed = this.settings.speedOptions[0]; } - ngOnChanges() { - this.currentTime = this.min === Infinity ? 0 : this.min; - if (this.settings.showTimestamp) { - this.timestampFormat.update(this.currentTime); - this.cd.markForCheck(); + public onIndexChange(index: number) { + this.index = index; + this.updateCurrentTime(); + } + + public play() { + this.playing = true; + if (!this.interval) { + this.interval = interval(1000 / this.speed) + .pipe( + takeUntilDestroyed(this.destroyRef), + filter(() => this.playing) + ).subscribe( + { + next: () => { + if (this.index < this.maxTimeIndex) { + this.index++; + this.updateCurrentTime(); + } else { + this.playing = false; + this.cd.markForCheck(); + this.interval.unsubscribe(); + this.interval = null; + } + }, + error: (err) => { + console.error(err); + } + } + ); } } - ngOnDestroy() { + public pause() { + this.playing = false; + this.updateCurrentTime(); } - public onTimeChange() { - if (this.settings.showTimestamp) { + public fastRewind() { + this.index = this.minTimeIndex; + this.pause(); + } + + public fastForward() { + this.index = this.maxTimeIndex; + this.pause(); + } + + public moveNext() { + if (this.index < this.maxTimeIndex) { + if (this.settings.snapToRealLocation) { + const anchorIndex = this.findIndex(this.currentTime, this.anchors) + 1; + this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep); + } else { + this.index++; + } + } + this.pause(); + } + + public movePrev() { + if (this.index > this.minTimeIndex) { + if (this.settings.snapToRealLocation) { + const anchorIndex = this.findIndex(this.currentTime, this.anchors) - 1; + this.index = Math.floor((this.anchors[anchorIndex] - this.minValue) / this.settings.timeStep); + } else { + this.index--; + } + } + this.pause(); + } + + public speedUpdated() { + if (this.interval) { + this.interval.unsubscribe(); + this.interval = null; + } + if (this.playing) { + this.play(); + } + } + + private updateCurrentTime() { + const newTime = this.minValue + this.index * this.settings.timeStep; + if (this.currentTime !== newTime) { + this.currentTime = newTime; + this.timeChanged.emit(this.currentTime); + this.updateTimestampDisplayValue(); + } + } + + private updateTimestampDisplayValue() { + if (this.settings.showTimestamp && this.hasData) { this.timestampFormat.update(this.currentTime); this.cd.markForCheck(); } - this.timeChanged.next(this.currentTime); } - public speedUpdated() {} + private findIndex(value: number, array: number[]): number { + let i = 0; + while (array[i] < value) { + i++; + } + return i; + } } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 39156e001b..c0876889f3 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7712,7 +7712,8 @@ "speed-options": "Speed options", "timestamp": "Timestamp", "snap-to-real-location": "Snap to real location", - "location-snap-filter-function": "Location snap filter function" + "location-snap-filter-function": "Location snap filter function", + "no-trips-data-available": "No trips data available" }, "map-action": { "map-action-buttons": "Map action buttons", From 8ebf5125af9d17ea5bf7373a5b6a1fa600d9e1a6 Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Sun, 2 Mar 2025 22:27:42 +0200 Subject: [PATCH 051/127] UI: Fix process header widget action - place map item --- .../widget/lib/maps/map-widget.component.ts | 2 +- .../home/components/widget/lib/maps/map.ts | 74 ++++++++-------- .../widget/lib/maps/models/map.models.ts | 5 +- .../components/widget/widget.component.ts | 87 +++++++++++-------- 4 files changed, 93 insertions(+), 75 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index a462a01356..a020bb1527 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -64,7 +64,7 @@ export class MapWidgetComponent implements OnInit, OnDestroy { overlayStyle: ComponentStyle = {}; padding: string; - private map: TbMap; + map: TbMap; constructor(public widgetComponent: WidgetComponent, private imagePipe: ImagePipe, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 11af4338e9..121277e7da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -33,7 +33,8 @@ import { formattedDataArrayFromDatasourceData, formattedDataFormDatasourceData, isDefined, - isDefinedAndNotNull, isUndefined, + isDefinedAndNotNull, + isUndefined, mergeDeepIgnoreArray } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; @@ -48,7 +49,7 @@ import { UnplacedMapDataItem, } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { IWidgetSubscription, WidgetSubscriptionOptions } from '@core/api/widget-api.models'; -import { FormattedData, MapItemType, WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; +import { FormattedData, MapItemType, WidgetAction, widgetType } from '@shared/models/widget.models'; import { EntityDataPageLink } from '@shared/models/query/query.models'; import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbMarkersDataLayer } from '@home/components/widget/lib/maps/data-layer/markers-data-layer'; @@ -545,66 +546,67 @@ export abstract class TbMap { title: customTranslate.transform(actionButton.label) }; const toolbarButton = this.customActionsToolbar.toolbarButton(actionButtonConfig); - if (actionButton.action.type !== WidgetActionType.placeMapItem) { - toolbarButton.onClick((e) => this.ctx.actionsApi.handleWidgetAction(e, actionButton.action)); - } else { - switch (actionButton.action.mapItemType) { - case MapItemType.marker: - toolbarButton.onClick((e, button) => this.createMarker(e, {button, action: actionButton.action})); - break; - case MapItemType.polygon: - toolbarButton.onClick((e, button) => this.createPolygon(e, {button, action: actionButton.action})); - break; - case MapItemType.rectangle: - toolbarButton.onClick((e, button) => this.createRectangle(e, {button, action: actionButton.action})); - break; - case MapItemType.circle: - toolbarButton.onClick((e, button) => this.createCircle(e, {button, action: actionButton.action})); - break; - } - } + toolbarButton.onClick((e, button) => this.ctx.actionsApi.handleWidgetAction(e, actionButton.action, null, null, {button})); }); } } - private createMarker(e: MouseEvent, actionData: CustomActionData) { - this.createItem(e, actionData, () => this.prepareDrawMode('Marker', { + public placeMapItem(actionData: CustomActionData): void { + switch (actionData.action.mapItemType) { + case MapItemType.marker: + this.createMarker(actionData); + break; + case MapItemType.polygon: + this.createPolygon(actionData); + break; + case MapItemType.rectangle: + this.createRectangle(actionData); + break; + case MapItemType.circle: + this.createCircle(actionData); + break; + } + } + + private createMarker(actionData: CustomActionData) { + this.createItem(actionData, () => this.prepareDrawMode('Marker', { placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint') })); } - private createRectangle(e: MouseEvent, actionData: CustomActionData): void { - this.createItem(e, actionData, () => this.prepareDrawMode('Rectangle', { + private createRectangle(actionData: CustomActionData): void { + this.createItem(actionData, () => this.prepareDrawMode('Rectangle', { firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.rectangle-place-first-point-hint'), finishRect: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-rectangle-hint') })); } - private createPolygon(e: MouseEvent, actionData: CustomActionData): void { - this.createItem(e, actionData, () => this.prepareDrawMode('Polygon', { + private createPolygon(actionData: CustomActionData): void { + this.createItem(actionData, () => this.prepareDrawMode('Polygon', { firstVertex: this.ctx.translate.instant('widgets.maps.data-layer.polygon.polygon-place-first-point-hint'), continueLine: this.ctx.translate.instant('widgets.maps.data-layer.polygon.continue-polygon-hint'), finishPoly: this.ctx.translate.instant('widgets.maps.data-layer.polygon.finish-polygon-hint') })); } - private createCircle(e: MouseEvent, actionData: CustomActionData): void { - this.createItem(e, actionData, () => this.prepareDrawMode('Circle', { + private createCircle(actionData: CustomActionData): void { + this.createItem(actionData, () => this.prepareDrawMode('Circle', { startCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.place-circle-center-hint'), finishCircle: this.ctx.translate.instant('widgets.maps.data-layer.circle.finish-circle-hint') })); } - private createItem(e: MouseEvent, actionData: CustomActionData, prepareDrawMode: () => void) { + private createItem(actionData: CustomActionData, prepareDrawMode: () => void) { if (this.isPlacingItem) { return; } - this.updatePlaceItemState(actionData.button); + this.updatePlaceItemState(actionData.button, true); this.map.once('pm:create', (e) => { - this.ctx.actionsApi.handleWidgetAction(e as any, actionData.action, null, null, { + actionData.afterPlaceItemCallback(e as any, actionData.action, null, null, { coordinates: convertLayerToCoordinates(actionData.action.mapItemType, e.layer), - layer: e.layer + layer: e.layer, + button: actionData.button }); // @ts-ignore @@ -673,7 +675,7 @@ export abstract class TbMap { L.DomUtil.addClass(this.map.pm.Draw[shape]._hintMarker.getTooltip()._container, 'tb-place-item-label'); } - private updatePlaceItemState(addButton?: L.TB.ToolbarButton): void { + private updatePlaceItemState(addButton?: L.TB.ToolbarButton, disabled = false): void { if (addButton) { this.deselectItem(false, true); addButton.setActive(true); @@ -681,7 +683,7 @@ export abstract class TbMap { this.currentAddButton.setActive(false); } this.currentAddButton = addButton; - this.updateAddButtonsStates(); + this.updateAddButtonsStates(disabled); } private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { @@ -856,8 +858,8 @@ export abstract class TbMap { } } - private updateAddButtonsStates() { - if (this.currentAddButton) { + private updateAddButtonsStates(disabled = false) { + if (this.currentAddButton || disabled) { if (this.addMarkerButton && this.addMarkerButton !== this.currentAddButton) { this.addMarkerButton.setDisabled(true); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 79fb8a7c04..9863253ff8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -34,6 +34,7 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models'; +import { EntityId } from '@shared/models/id/entity-id'; export enum MapType { geoMap = 'geoMap', @@ -1033,8 +1034,10 @@ export interface MarkerIconInfo { } export interface CustomActionData { - button: L.TB.TopToolbarButton; + button?: L.TB.TopToolbarButton; action: WidgetAction; + afterPlaceItemCallback: ($event: Event, descriptor: WidgetAction, entityId?: EntityId, entityName?: string, + additionalParams?: any, entityLabel?: string) => void } export type MapStringFunction = (data: FormattedData, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index 8a9b6e194c..2ac0a6a0e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -125,6 +125,7 @@ import { IModulesMap } from '@modules/common/modules-map.models'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { CompiledTbFunction, compileTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; import { HttpClient } from '@angular/common/http'; +import type { MapWidgetComponent } from '@home/components/widget/lib/maps/map-widget.component'; @Component({ selector: 'tb-widget', @@ -1150,44 +1151,17 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, } break; case WidgetActionType.placeMapItem: - case WidgetActionType.customPretty: - const customPrettyFunction = descriptor.customFunction; - const customHtml = descriptor.customHtml; - const customCss = descriptor.customCss; - const customResources = descriptor.customResources; - const actionNamespace = `custom-action-pretty-${guid()}`; - let htmlTemplate = ''; - if (isDefined(customHtml) && customHtml.length > 0) { - htmlTemplate = customHtml; + const mapWidget: MapWidgetComponent = this.widgetContext.$scope.mapWidget + if (mapWidget) { + mapWidget.map.placeMapItem({ + action: descriptor, + afterPlaceItemCallback: this.executeCustomPrettyAction.bind(this), + button: additionalParams?.button + }); } - this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ - next: () => { - if (isNotEmptyTbFunction(customPrettyFunction)) { - compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId', - 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe( - { - next: (compiled) => { - try { - if (!additionalParams) { - additionalParams = {}; - } - this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports); - compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel); - } catch (e) { - console.error(e); - } - }, - error: (err) => { - console.error(err); - } - } - ) - } - }, - error: (errorMessages: string[]) => { - this.processResourcesLoadErrors(errorMessages); - } - }); + break; + case WidgetActionType.customPretty: + this.executeCustomPrettyAction($event, descriptor, entityId, entityName, additionalParams, entityLabel); break; case WidgetActionType.mobileAction: const mobileAction = descriptor.mobileAction; @@ -1559,6 +1533,45 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, this.handleWidgetAction($event, action, entityId, entityName, null, entityLabel); } + private executeCustomPrettyAction($event: Event, descriptor: WidgetAction, entityId?: EntityId, + entityName?: string, additionalParams?: any, entityLabel?: string) { + const customPrettyFunction = descriptor.customFunction; + const customHtml = descriptor.customHtml; + const customCss = descriptor.customCss; + const customResources = descriptor.customResources; + const actionNamespace = `custom-action-pretty-${guid()}`; + let htmlTemplate = ''; + if (isDefined(customHtml) && customHtml.length > 0) { + htmlTemplate = customHtml; + } + this.loadCustomActionResources(actionNamespace, customCss, customResources, descriptor).subscribe({ + next: () => { + if (isNotEmptyTbFunction(customPrettyFunction)) { + compileTbFunction(this.http, customPrettyFunction, '$event', 'widgetContext', 'entityId', + 'entityName', 'htmlTemplate', 'additionalParams', 'entityLabel').subscribe({ + next: (compiled) => { + try { + if (!additionalParams) { + additionalParams = {}; + } + this.widgetContext.customDialog.setAdditionalImports(descriptor.customImports); + compiled.execute($event, this.widgetContext, entityId, entityName, htmlTemplate, additionalParams, entityLabel); + } catch (e) { + console.error(e); + } + }, + error: (err) => { + console.error(err); + } + }); + } + }, + error: (errorMessages: string[]) => { + this.processResourcesLoadErrors(errorMessages); + } + }); + } + private loadCustomActionResources(actionNamespace: string, customCss: string, customResources: Array, actionDescriptor: WidgetAction): Observable { const resourceTasks: Observable[] = []; From bd43b7ae6183907667b6f3e42a97f0d93bbca837 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 3 Mar 2025 11:04:10 +0200 Subject: [PATCH 052/127] added expression function description --- .../en_US/calculated-field/expression_fn.md | 188 +++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md index f8173dc528..eb06cda0cb 100644 --- a/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md +++ b/ui-ngx/src/assets/help/en_US/calculated-field/expression_fn.md @@ -1 +1,187 @@ - +#### Calculated field TBEL script function + +The **calculate()** function is user-defined and receives arguments configured by the user to generate new telemetry data. +It allows you to perform custom calculations using [TBEL{:target="_blank"}](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/tbel/). + +##### Function signature + +```javascript + function calculate(arg1, arg2, ...): object | object[] +``` + +The function automatically receives user-defined arguments **(arg1, arg2, ...)** and should return a JSON object. + +##### Example: Air Density Calculation + +```javascript +function calculate(altitude, temperature) { + var avgTemperature = temperature.mean(); // Get average temperature + var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin + + // Estimate air pressure based on altitude + var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude.value), 5.25588); + + // Air density formula + var airDensity = pressure / (287.05 * temperatureK); + + return { + "airDensity": airDensity + }; +} +``` + +##### Function arguments + +The function receives user-configured arguments, which can be of two types: + +* single value arguments - represent the latest telemetry data or attribute. + ```json + { + "altitude": { + "ts": 1740644636669, + "value": 1034 + } + } + ``` + + Use dot notation (`.`) to access argument properties. + + ```javascript + var altitudeTimestamp = altitude.ts; + var altitudeValue = altitude.value; + ``` + +* time series rolling arguments - contain historical data within a defined time window. + ```json + { + "temperature": { + "timeWindow": { + "startTs": 1740643762896, + "endTs": 1740644662896, + "limit": 5000 + }, + "values": [ + { "ts": 1740644355935, "value": 72.32 }, + { "ts": 1740644365935, "value": 72.86 }, + { "ts": 1740644375935, "value": 73.58 }, + { "ts": 1740644385935, "value": "NaN" } + ] + } + } + ``` + + Use dot notation (`.`) to access argument properties. + + ```javascript + var startOfInterval = temperature.timeWindow.startTs; + var firstTimestamp = temperature.values[0].ts; + var firstValue = temperature.values[0].value; + ``` + +**Built-in methods for rolling arguments** + +Time series rolling arguments provide built-in functions for calculations. +These functions accept an optional 'ignoreNaN' boolean parameter, which controls how NaN values are handled. +Each method has two function signatures: + +* **Without parameters:** `method()` → called **without parameters** and defaults to `ignoreNaN = true`, meaning NaN values are ignored. +* **With an explicit parameter:** `method(boolean ignoreNaN)` → called with a boolean `ignoreNaN` parameter: + * `true` → ignores NaN values (default behavior). + * `false` → includes NaN values in calculations. + +| Method | Default Behavior (`ignoreNaN = true`) | Alternative (`ignoreNaN = false`) | +|-----------|--------------------------------------------------|---------------------------------------------| +| `max()` | Returns the highest value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `min()` | Returns the lowest value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `mean()` | Computes the average value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `std()` | Calculates the standard deviation, ignoring NaN. | Returns NaN if any NaN values exist. | +| `median()` | Returns the median value, ignoring NaN values. | Returns NaN if any NaN values exist. | +| `count()` | Counts only valid (non-NaN) values. | Counts all values, including NaN. | +| `last()` | Returns the most recent non-NaN value. | Returns the last value, even if it is NaN. | +| `first()` | Returns the oldest non-NaN value. | Returns the first value, even if it is NaN. | +| `sum()` | Computes the total sum, ignoring NaN values. | Returns NaN if any NaN values exist. | + +##### The following calculations are executed over the provided above arguments: + +**Example usage: default (`ignoreNaN = true`)** + +```javascript +var avgTemp = temperature.mean(); +var tempMax = temperature.max(); +var valueCount = temperature.count(); +``` + +**Output:** + +```json +{ + "avgTemp": 72.92, + "tempMax": 73.58, + "valueCount": 3 +} +``` + +**Example usage: explicit (`ignoreNaN = false`)** + +```javascript +var avgTemp = temperature.mean(false); // Returns NaN if any NaN values exist +var tempMax = temperature.max(false); // Returns NaN if any NaN values exist +var valueCount = temperature.count(false); // Counts all values, including NaN +``` + +**Output:** + +```json +{ + "avgTemp": "NaN", + "tempMax": "NaN", + "valueCount": 4 +} +``` + +##### Function return format: + +The script should return a JSON object formatted according to the [ThingsBoard Telemetry Upload API](${siteBaseUrl}/docs${docPlatformPrefix}/user-guide/telemetry/#time-series-data-upload-api/). +The return value must match one of the supported telemetry upload formats. +**Example Formats**: + +Single key-value format: + +```json +{ + "airDensity": 1.06 +} +``` + +Key-value format with a timestamp: + +```json +{ + "ts": 1740644636669, + "values": { + "airDensity": 1.06 + } +} +``` + +Array of telemetry entries: + +```json +[ + { + "ts": 1740644636669, + "values": { + "airDensity": 1.06 + } + }, + { + "ts": 1740644662896, + "values": [ + { "ts": 1740644355935, "value": 72.32 }, + { "ts": 1740644365935, "value": 72.86 }, + { "ts": 1740644375935, "value": 73.58 }, + { "ts": 1740644385935, "value": "NaN" } + ] + } +] +``` From ed6262ba9f4d48aeae1c82c0c9fad0a9fd1f9482 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 3 Mar 2025 12:04:51 +0200 Subject: [PATCH 053/127] Save attributes strategies: fix UI bugs --- .../action/advanced-processing-setting.component.html | 4 ++-- .../action/advanced-processing-setting.component.ts | 10 +++++----- .../rule-node/action/attributes-config.component.html | 2 +- .../rule-node/action/attributes-config.model.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 02d8f7be8f..02965fe535 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -25,8 +25,8 @@ formControlName="timeseries" title="{{ 'rule-node-config.save-time-series.time-series' | translate }}" > - }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts index 441fe9d816..1f24b69116 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts @@ -42,7 +42,7 @@ export interface AttributeDeduplicateProcessingStrategy extends BasicProcessingS } export interface AttributeAdvancedProcessingStrategy extends BasicProcessingSettings { - attribute: AttributeAdvancedProcessingConfig; + attributes: AttributeAdvancedProcessingConfig; webSockets: AttributeAdvancedProcessingConfig; calculatedFields: AttributeAdvancedProcessingConfig; } @@ -54,7 +54,7 @@ export const defaultAdvancedProcessingConfig: AttributeAdvancedProcessingConfig } export const defaultAttributeAdvancedProcessingStrategy: Omit = { - attribute: defaultAdvancedProcessingConfig, + attributes: defaultAdvancedProcessingConfig, webSockets: defaultAdvancedProcessingConfig, calculatedFields: defaultAdvancedProcessingConfig, } From 2b7deda3d16bad64ea9754ed64e29a3d179a9c58 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 3 Mar 2025 12:26:20 +0200 Subject: [PATCH 054/127] Save attributes strategies: add advanced mode warning text; minor title correction --- ...advanced-processing-setting.component.html | 4 +-- .../rulenode/save_attributes_node_advanced.md | 32 +++++++++++++++++++ .../assets/locale/locale.constant-en_US.json | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 02965fe535..edce8b12a9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -18,7 +18,7 @@
Date: Mon, 3 Mar 2025 12:50:34 +0200 Subject: [PATCH 055/127] UI: Fix device profile extra scroll in lwm2m model table. --- .../lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss index 910c6625db..d86864d1f3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.scss @@ -15,7 +15,7 @@ */ :host{ .tb-panel { - min-height: 42px; + min-height: 48px; .tb-panel-title { font-weight: 500; From da4e231ea3857f25d78cce47cc34f5c13a572d50 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 3 Mar 2025 14:47:51 +0200 Subject: [PATCH 056/127] Save attributes strategies: ensure shared attributes subscription update is sent even if WS updates are disabled --- .../DefaultSubscriptionManagerService.java | 4 - .../DefaultTelemetrySubscriptionService.java | 17 ++ ...faultTelemetrySubscriptionServiceTest.java | 205 ++++++++++++++++++ .../thingsboard/common/util/DonAsynchron.java | 6 + 4 files changed, 228 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java index 23adacfc2e..d5a5318798 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java @@ -217,10 +217,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene if (entityId.getEntityType() == EntityType.DEVICE) { if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope)) { updateDeviceInactivityTimeout(tenantId, entityId, attributes); - } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { - clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onUpdate(tenantId, - new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, new ArrayList<>(attributes)) - , null); } } callback.onSuccess(); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index d60d1621b7..3af2c04643 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -35,9 +35,11 @@ import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.CustomerId; +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.AttributeKvEntry; @@ -45,6 +47,7 @@ import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; import org.thingsboard.server.common.msg.queue.TbCallback; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -52,6 +55,7 @@ import org.thingsboard.server.dao.util.KvUtils; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; +import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.ArrayList; @@ -197,6 +201,15 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } }, t -> request.getCallback().onFailure(t)); + if (strategy.saveAttributes() + && entityId.getEntityType() == EntityType.DEVICE + && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(request.getScope().name()) + && request.isNotifyDevice()) { + addMainCallback(resultFuture, success -> clusterService.pushMsgToCore( + DeviceAttributesEventNotificationMsg.onUpdate(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getEntries()), null + )); + } + if (strategy.sendWsUpdate()) { addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), request.isNotifyDevice())); } @@ -342,6 +355,10 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer addMainCallback(saveFuture, result -> callback.onSuccess(null), callback::onFailure); } + private void addMainCallback(ListenableFuture saveFuture, Consumer onSuccess) { + DonAsynchron.withCallback(saveFuture, onSuccess, null, tsCallBackExecutor); + } + private void addMainCallback(ListenableFuture saveFuture, Consumer onSuccess, Consumer onFailure) { DonAsynchron.withCallback(saveFuture, onSuccess, onFailure, tsCallBackExecutor); } diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index c96b96b2e0..8bdcbc1349 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -24,6 +24,7 @@ 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -35,15 +36,21 @@ import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.objects.AttributesEntityView; @@ -51,6 +58,7 @@ import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; @@ -74,13 +82,17 @@ import java.util.concurrent.ExecutorService; import java.util.stream.LongStream; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFailedFuture; import static com.google.common.util.concurrent.Futures.immediateFuture; 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.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class DefaultTelemetrySubscriptionServiceTest { @@ -428,6 +440,199 @@ class DefaultTelemetrySubscriptionServiceTest { ); } + @Test + void shouldThrowErrorWhenTryingToSaveAttributesForApiUsageState() { + // GIVEN + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(new ApiUsageStateId(UUID.randomUUID())) + .scope(AttributeScope.SHARED_SCOPE) + .entry(new DoubleDataEntry("temperature", 65.2)) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + // WHEN + assertThatThrownBy(() -> telemetryService.saveAttributes(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Can't update API Usage State!"); + + // THEN + then(attrService).shouldHaveNoInteractions(); + } + + @Test + void shouldSendAttributesUpdateNotificationWhenDeviceSharedAttributesAreSavedAndNotifyDeviceIsTrue() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + var expectedAttributesUpdateMsg = DeviceAttributesEventNotificationMsg.onUpdate(tenantId, deviceId, "SHARED_SCOPE", entries); + + then(clusterService).should().pushMsgToCore(eq(expectedAttributesUpdateMsg), isNull()); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesUpdateNotificationWhenEntityIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, nonDeviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = "SHARED_SCOPE", + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesUpdateNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(notSharedScope) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenNotifyDeviceIsFalse() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(false) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveWasSkipped() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(false, false, false)) + .build(); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)), + new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test")) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .entries(entries) + .notifyDevice(true) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFailedFuture(new RuntimeException("failed to save"))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + // used to emulate versions returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); diff --git a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java index 008158246e..0f1a56cb17 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DonAsynchron.java @@ -36,6 +36,9 @@ public class DonAsynchron { FutureCallback callback = new FutureCallback() { @Override public void onSuccess(T result) { + if (onSuccess == null) { + return; + } try { onSuccess.accept(result); } catch (Throwable th) { @@ -45,6 +48,9 @@ public class DonAsynchron { @Override public void onFailure(Throwable t) { + if (onFailure == null) { + return; + } onFailure.accept(t); } }; From f318fa0ebdd4b0a86a9751bfc32edcc94905d231 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 3 Mar 2025 17:23:01 +0200 Subject: [PATCH 057/127] Save attributes strategies: handle attributes deleted subscription notification in DefaultTelemetrySubscriptionService --- .../main/data/upgrade/basic/schema_update.sql | 106 +++++------- .../queue/DefaultTbCoreConsumerService.java | 2 +- .../DefaultSubscriptionManagerService.java | 14 +- .../SubscriptionManagerService.java | 4 +- .../subscription/TbSubscriptionUtils.java | 3 +- .../DefaultTelemetrySubscriptionService.java | 36 +++-- ...faultTelemetrySubscriptionServiceTest.java | 152 +++++++++++++++++- common/proto/src/main/proto/queue.proto | 3 +- .../engine/api/AttributesDeleteRequest.java | 10 +- .../api/AttributesDeleteRequestTest.java | 39 +++++ 10 files changed, 268 insertions(+), 101 deletions(-) create mode 100644 rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 215ac5248d..04dce447c5 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -16,78 +16,52 @@ -- UPDATE SAVE TIME SERIES NODES START -DO $$ - BEGIN - -- Check if the rule_node table exists - IF EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_name = 'rule_node' - ) THEN +UPDATE rule_node +SET configuration = ( + (configuration::jsonb - 'skipLatestPersistence') + || jsonb_build_object( + 'processingSettings', jsonb_build_object( + 'type', 'ADVANCED', + 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), + 'latest', jsonb_build_object('type', 'SKIP'), + 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), + 'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE') + ) + ) + )::text, + configuration_version = 1 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' + AND configuration_version = 0 + AND configuration::jsonb ->> 'skipLatestPersistence' = 'true'; - UPDATE rule_node - SET configuration = ( - (configuration::jsonb - 'skipLatestPersistence') - || jsonb_build_object( - 'processingSettings', jsonb_build_object( - 'type', 'ADVANCED', - 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), - 'latest', jsonb_build_object('type', 'SKIP'), - 'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), - 'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE') - ) - ) - )::text, - configuration_version = 1 - WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' - AND configuration_version = 0 - AND configuration::jsonb ->> 'skipLatestPersistence' = 'true'; - - UPDATE rule_node - SET configuration = ( - (configuration::jsonb - 'skipLatestPersistence') - || jsonb_build_object( - 'processingSettings', jsonb_build_object( - 'type', 'ON_EVERY_MESSAGE' - ) - ) - )::text, - configuration_version = 1 - WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' - AND configuration_version = 0 - AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL); - - END IF; - END; -$$; +UPDATE rule_node +SET configuration = ( + (configuration::jsonb - 'skipLatestPersistence') + || jsonb_build_object( + 'processingSettings', jsonb_build_object( + 'type', 'ON_EVERY_MESSAGE' + ) + ) + )::text, + configuration_version = 1 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode' + AND configuration_version = 0 + AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL); -- UPDATE SAVE TIME SERIES NODES END -- UPDATE SAVE ATTRIBUTES NODES START -DO $$ - BEGIN - -- Check if the rule_node table exists - IF EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_name = 'rule_node' - ) THEN - - UPDATE rule_node - SET configuration = ( - configuration::jsonb - || jsonb_build_object( - 'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE') - ) - )::text, - configuration_version = 3 - WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode' - AND configuration_version = 2; - - END IF; - END; -$$; +UPDATE rule_node +SET configuration = ( + configuration::jsonb + || jsonb_build_object( + 'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE') + ) + )::text, + configuration_version = 3 +WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode' + AND configuration_version = 2; -- UPDATE SAVE ATTRIBUTES NODES END diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 7219179b2c..0152e34d50 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -545,7 +545,7 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService attributes, TbCallback callback) { - onAttributesUpdate(tenantId, entityId, scope, attributes, true, callback); - } - - @Override - public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback) { getEntityUpdatesInfo(entityId).attributesUpdateTs = System.currentTimeMillis(); processAttributesUpdate(entityId, scope, attributes); if (entityId.getEntityType() == EntityType.DEVICE) { @@ -223,16 +214,13 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene } @Override - public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback callback) { + public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, TbCallback callback) { processAttributesUpdate(entityId, scope, keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); if (entityId.getEntityType() == EntityType.DEVICE) { if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) || TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) { deleteDeviceInactivityTimeout(tenantId, entityId, keys); - } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { - clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, - new DeviceId(entityId.getId()), scope, keys), null); } } callback.onSuccess(); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java index d199f18b75..3fd58a1243 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java @@ -39,9 +39,7 @@ public interface SubscriptionManagerService extends ApplicationListener attributes, TbCallback callback); - void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice, TbCallback callback); - - void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback empty); + void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, TbCallback empty); void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java index 68c0f52f71..1d5e85cc22 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java @@ -209,7 +209,7 @@ public class TbSubscriptionUtils { return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); } - public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { + public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List keys) { TbAttributeDeleteProto.Builder builder = TbAttributeDeleteProto.newBuilder(); builder.setEntityType(entityId.getEntityType().name()); builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); @@ -218,7 +218,6 @@ public class TbSubscriptionUtils { builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); builder.setScope(scope); builder.addAllKeys(keys); - builder.setNotifyDevice(notifyDevice); SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); msgBuilder.setAttrDelete(builder); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 3af2c04643..252f696c72 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -211,7 +211,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } if (strategy.sendWsUpdate()) { - addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), request.isNotifyDevice())); + addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries())); } } @@ -223,11 +223,25 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Override public void deleteAttributesInternal(AttributesDeleteRequest request) { - ListenableFuture> deleteFuture = attrService.removeAll(request.getTenantId(), request.getEntityId(), request.getScope(), request.getKeys()); - DonAsynchron.withCallback(deleteFuture, result -> { - calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()); - }, safeCallback(request.getCallback()), tsCallBackExecutor); - addWsCallback(deleteFuture, success -> onAttributesDelete(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getKeys(), request.isNotifyDevice())); + TenantId tenantId = request.getTenantId(); + EntityId entityId = request.getEntityId(); + + ListenableFuture> deleteFuture = attrService.removeAll(tenantId, entityId, request.getScope(), request.getKeys()); + + addMainCallback(deleteFuture, + result -> calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()), + t -> request.getCallback().onFailure(t) + ); + + if (entityId.getEntityType() == EntityType.DEVICE + && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(request.getScope().name()) + && request.isNotifyDevice()) { + addMainCallback(deleteFuture, success -> clusterService.pushMsgToCore( + DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getKeys()), null + )); + } + + addWsCallback(deleteFuture, success -> onAttributesDelete(tenantId, entityId, request.getScope().name(), request.getKeys())); } @Override @@ -312,16 +326,16 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } } - private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, boolean notifyDevice) { + private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes) { forwardToSubscriptionManagerService(tenantId, entityId, - subscriptionManagerService -> subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice, TbCallback.EMPTY), + subscriptionManagerService -> subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, TbCallback.EMPTY), () -> TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes)); } - private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice) { + private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys) { forwardToSubscriptionManagerService(tenantId, entityId, - subscriptionManagerService -> subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, notifyDevice, TbCallback.EMPTY), - () -> TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys, notifyDevice)); + subscriptionManagerService -> subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, TbCallback.EMPTY), + () -> TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys)); } private void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List ts) { diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index 8bdcbc1349..44b7f146a5 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -29,6 +29,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; @@ -421,7 +422,7 @@ class DefaultTelemetrySubscriptionServiceTest { } if (sendWsUpdate) { - then(subscriptionManagerService).should().onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), request.isNotifyDevice(), TbCallback.EMPTY); + then(subscriptionManagerService).should().onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), TbCallback.EMPTY); } else { then(subscriptionManagerService).shouldHaveNoInteractions(); } @@ -633,6 +634,155 @@ class DefaultTelemetrySubscriptionServiceTest { then(clusterService).should(never()).pushMsgToCore(any(), any()); } + /* --- Delete attributes API --- */ + + @Test + void shouldThrowErrorWhenTryingToDeleteAttributesForApiUsageState() { + // GIVEN + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(new ApiUsageStateId(UUID.randomUUID())) + .scope(AttributeScope.SHARED_SCOPE) + .keys(List.of("attributeKeyToDelete1", "attributeKeyToDelete2")) + .notifyDevice(true) + .build(); + + // WHEN + assertThatThrownBy(() -> telemetryService.deleteAttributes(request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("Can't update API Usage State!"); + + // THEN + then(attrService).shouldHaveNoInteractions(); + } + + @Test + void shouldSendAttributesDeletedNotificationWhenDeviceSharedAttributesAreDeletedAndNotifyDeviceIsTrue() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + var expectedAttributesDeletedMsg = DeviceAttributesEventNotificationMsg.onDelete(tenantId, deviceId, "SHARED_SCOPE", List.of("attributeKeyToDelete1", "attributeKeyToDelete2")); + + then(clusterService).should().pushMsgToCore(eq(expectedAttributesDeletedMsg), isNull()); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesDeletedNotificationWhenEntityIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = "SHARED_SCOPE", + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotSendAttributesDeletedNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(notSharedScope) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesDeletedNotificationWhenNotifyDeviceIsFalse() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(false) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys)); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + + @Test + void shouldNotSendAttributesDeletedNotificationWhenAttributesDeleteFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SHARED_SCOPE) + .keys(keys) + .notifyDevice(true) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFailedFuture(new RuntimeException("failed to delete"))); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(clusterService).should(never()).pushMsgToCore(any(), any()); + } + // used to emulate versions returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 77dab2cb57..3007d48584 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1079,7 +1079,8 @@ message TbAttributeDeleteProto { int64 tenantIdLSB = 5; string scope = 6; repeated string keys = 7; - bool notifyDevice = 8; + // not used anymore since device notification are now handled in DefaultTelemetrySubscriptionService instead of DefaultSubscriptionManagerService + bool notifyDevice = 8 [deprecated = true]; } message TbTimeSeriesDeleteProto { diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java index 2ab6923899..374fcc45f6 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java @@ -21,6 +21,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.ToString; +import org.thingsboard.common.util.NoOpFutureCallback; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -30,6 +31,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import java.util.List; import java.util.UUID; +import static java.util.Objects.requireNonNullElse; + @Getter @ToString @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -61,8 +64,7 @@ public class AttributesDeleteRequest implements CalculatedFieldSystemAwareReques private TbMsgType tbMsgType; private FutureCallback callback; - Builder() { - } + Builder() {} public Builder tenantId(TenantId tenantId) { this.tenantId = tenantId; @@ -134,7 +136,9 @@ public class AttributesDeleteRequest implements CalculatedFieldSystemAwareReques } public AttributesDeleteRequest build() { - return new AttributesDeleteRequest(tenantId, entityId, scope, keys, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, callback); + return new AttributesDeleteRequest( + tenantId, entityId, scope, keys, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance()) + ); } } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java new file mode 100644 index 0000000000..9b4a825a66 --- /dev/null +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 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.api; + +import org.junit.jupiter.api.Test; +import org.thingsboard.common.util.NoOpFutureCallback; + +import static org.assertj.core.api.Assertions.assertThat; + +class AttributesDeleteRequestTest { + + @Test + void testDefaultCallbackIsNoOp() { + var request = AttributesDeleteRequest.builder().build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + + @Test + void testNullCallbackIsNoOp() { + var request = AttributesDeleteRequest.builder().callback(null).build(); + + assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance()); + } + +} From 9d9fd93c89f7b23b976fd869b994f1aaa66c4617 Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Mon, 3 Mar 2025 17:23:42 +0200 Subject: [PATCH 058/127] Save attributes strategies: improve proto deprecation note --- common/proto/src/main/proto/queue.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 3007d48584..969b47f847 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1079,7 +1079,7 @@ message TbAttributeDeleteProto { int64 tenantIdLSB = 5; string scope = 6; repeated string keys = 7; - // not used anymore since device notification are now handled in DefaultTelemetrySubscriptionService instead of DefaultSubscriptionManagerService + // Deprecated since 4.0, not used anymore since device notification are now handled in DefaultTelemetrySubscriptionService instead of DefaultSubscriptionManagerService bool notifyDevice = 8 [deprecated = true]; } From 749a0b5c612339afdb501fbadd202fa12482ff23 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 3 Mar 2025 18:10:23 +0200 Subject: [PATCH 059/127] UI: Trip data layer implementation. --- .../lib/maps/data-layer/data-layer-utils.ts | 2 +- .../lib/maps/data-layer/map-data-layer.ts | 2 +- .../lib/maps/data-layer/trips-data-layer.ts | 229 ++++++++++++++---- .../home/components/widget/lib/maps/map.ts | 33 ++- .../widget/lib/maps/models/map.models.ts | 20 +- .../panels/map-timeline-panel.component.ts | 7 +- .../map/map-data-layer-dialog.component.html | 1 + .../map/map-data-layer-dialog.component.ts | 2 +- 8 files changed, 233 insertions(+), 63 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts index f5872844c9..6e4ec1e485 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts @@ -86,7 +86,7 @@ const bindTooltipActions = (map: TbMap, tooltip: L.Popup, settings: DataLay if (action) { element.onclick = ($event) => { - map.dataItemClick($event, action, data.$datasource); + map.dataItemClick($event, action, data); return false; }; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index 1c4e58a320..e3f326b729 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -108,7 +108,7 @@ export abstract class TbDataLayerItem { - this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.data.$datasource); + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.data); }); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index a7b0d74d4d..45aa31def5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -17,19 +17,20 @@ import { DataLayerColorProcessor, DataLayerPatternProcessor, - MapDataLayerType, TbDataLayerItem, + MapDataLayerType, + TbDataLayerItem, TbMapDataLayer, UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/map-data-layer'; import { BaseMarkerShapeSettings, - calculateInterpolationRatio, calculateLastPoints, - ClusterMarkerColorFunction, + calculateInterpolationRatio, + calculateLastPoints, DataLayerColorType, - defaultBaseMarkersDataLayerSettings, defaultBaseTripsDataLayerSettings, findRotationAngle, - interpolateLineSegment, isValidLatLng, + interpolateLineSegment, + isValidLatLng, loadImageWithAspect, MapStringFunction, MapType, @@ -40,14 +41,13 @@ import { MarkerImageSettings, MarkerImageType, MarkerPositionFunction, - MarkersDataLayerSettings, MarkerShapeSettings, MarkerType, TbMapDatasource, TripsDataLayerSettings } from '@home/components/widget/lib/maps/models/map.models'; import { forkJoin, Observable, of } from 'rxjs'; -import { FormattedData } from '@shared/models/widget.models'; +import { FormattedData, WidgetActionType } from '@shared/models/widget.models'; import { createColorMarkerIconElement, createColorMarkerShapeURI, @@ -57,7 +57,7 @@ import tinycolor from 'tinycolor2'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { catchError, map, switchMap } from 'rxjs/operators'; -import L, { PolylineDecorator } from 'leaflet'; +import L from 'leaflet'; import { CompiledTbFunction } from '@shared/models/js-function.models'; import { deepClone, @@ -74,6 +74,7 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import moment from 'moment/moment'; import { TbImageMap } from '@home/components/widget/lib/maps/image-map'; import { createTooltip, updateTooltip } from '@home/components/widget/lib/maps/data-layer/data-layer-utils'; +import _ from 'lodash'; type TripRouteData = {[time: number]: FormattedData}; @@ -88,11 +89,12 @@ class TbTripDataItem { private labelOffset: L.PointTuple; private polyline: L.Polyline; - private polylineDecorator: PolylineDecorator; + private polylineDecorator: L.PolylineDecorator; private points: L.FeatureGroup; private currentTime: number; private currentPositionData: FormattedData; + private pointData: FormattedData; constructor(private rawRouteData: FormattedData[], private latestData: FormattedData, @@ -105,11 +107,13 @@ class TbTripDataItem { private create() { this.updateCurrentPosition(); this.layer = L.featureGroup(); - let pointData = this.currentPositionData; + this.pointData = this.currentPositionData; if (this.latestData) { - pointData = {...pointData, ...this.latestData}; + this.pointData = {...this.pointData, ...this.latestData}; } - this.createMarker(pointData); + this.createPath(); + this.updatePoints(); + this.createMarker(); try { this.dataLayer.getDataLayerContainer().addLayer(this.layer); } catch (e) { @@ -121,38 +125,50 @@ class TbTripDataItem { this.rawRouteData = rawRouteData; this.tripRouteData = this.prepareTripRouteData(); this.updateCurrentPosition(true); - let pointData = this.currentPositionData; + this.pointData = this.currentPositionData; if (this.latestData) { - pointData = {...pointData, ...this.latestData}; + this.pointData = {...this.pointData, ...this.latestData}; } - this.updateMarker(pointData); + this.updatePath(); + this.updatePoints(); + this.updateMarker(); } public updateLatestData(latestData: FormattedData) { this.latestData = latestData; + this.pointData = this.currentPositionData; + if (this.latestData) { + this.pointData = {...this.pointData, ...this.latestData}; + } this.updateAppearance(); } public updateAppearance() { - let data = this.currentPositionData; - if (this.latestData) { - data = {...data, ...this.latestData}; - } + this.updatePathAppearance(); const dsData = this.dataLayer.getMap().getData(); if (this.settings.tooltip.show) { updateTooltip(this.dataLayer.getMap(), this.markerTooltip, - this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); } - this.updateMarkerIcon(data, dsData); + this.updatePoints(); + this.updateMarkerIcon(this.pointData, dsData); } public updateCurrentTime() { this.updateCurrentPosition(); - let pointData = this.currentPositionData; + this.pointData = this.currentPositionData; if (this.latestData) { - pointData = {...pointData, ...this.latestData}; + this.pointData = {...this.pointData, ...this.latestData}; } - this.updateMarker(pointData); + this.updatePath(); + this.updateMarker(); + } + + public invalidateCoordinates(): void { + this.tripRouteData = this.prepareTripRouteData(); + this.updatePath(); + this.updatePoints(); + this.updateMarker(); } public remove() { @@ -164,32 +180,117 @@ class TbTripDataItem { return this.layer; } - private createMarker(data: FormattedData) { + public calculateAnchors(): number[] { + const entries = Object.entries(this.tripRouteData); const dsData = this.dataLayer.getMap().getData(); - const location = this.dataLayer.extractLocation(data, dsData); + return entries.filter(data => this.dataLayer.getMap().getLocationSnapFilterFunction().execute(data[1], dsData)) + .map(data => parseInt(data[0], 10)); + } + + private createMarker() { + const dsData = this.dataLayer.getMap().getData(); + const location = this.dataLayer.extractLocation(this.pointData, dsData); this.marker = L.marker(location, { - tbMarkerData: data, + tbMarkerData: this.pointData, snapIgnore: true }); this.marker.addTo(this.layer); - this.updateMarkerIcon(data, dsData); + this.updateMarkerIcon(this.pointData, dsData); if (this.settings.tooltip?.show) { this.markerTooltip = createTooltip(this.dataLayer.getMap(), - this.marker, this.settings.tooltip, data, () => true); + this.marker, this.settings.tooltip, this.pointData, () => true); updateTooltip(this.dataLayer.getMap(), this.markerTooltip, - this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + const clickAction = this.settings.click; + if (clickAction && clickAction.type !== WidgetActionType.doNothing) { + this.marker.on('click', (event) => { + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.pointData); + }); } } - private updateMarker(data: FormattedData) { + private updateMarker() { const dsData = this.dataLayer.getMap().getData(); - this.marker.options.tbMarkerData = data; - this.updateMarkerLocation(data, dsData); + this.marker.options.tbMarkerData = this.pointData; + this.updateMarkerLocation(this.pointData, dsData); if (this.settings.tooltip.show) { updateTooltip(this.dataLayer.getMap(), this.markerTooltip, - this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, data, dsData); + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + this.updateMarkerIcon(this.pointData, dsData); + } + + private createPath() { + if (this.settings.showPath) { + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const locations = formattedRouteData.map(data => this.dataLayer.extractLocation(data, dsData)); + const pathStyle = this.dataLayer.getPathStyle(this.pointData, dsData); + this.polyline = L.polyline(locations, pathStyle); + this.polyline.addTo(this.layer); + if (this.settings.usePathDecorator) { + this.polylineDecorator = new L.PolylineDecorator(this.polyline, this.dataLayer.getPathDecoratorStyle(pathStyle.color)); + this.polylineDecorator.addTo(this.layer); + } + } + } + + private updatePath() { + if (this.settings.showPath) { + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const locations = formattedRouteData.map(data => this.dataLayer.extractLocation(data, dsData)); + this.polyline.setLatLngs(locations); + if (this.settings.usePathDecorator) { + this.polylineDecorator.setPaths(this.polyline); + } + this.updatePathAppearance(); + } + } + + private updatePoints() { + if (this.settings.showPoints) { + if (!this.points) { + this.points = L.featureGroup(); + this.points.addTo(this.layer); + } + this.points.clearLayers(); + const formattedRouteData = _.values(this.tripRouteData); + const dsData = this.dataLayer.getMap().getData(); + const pointsList = formattedRouteData.filter(data => !!this.dataLayer.extractLocationData(data)); + for (const pData of pointsList) { + let pointData = pData; + if (this.latestData) { + pointData = {...pointData, ...this.latestData}; + } + const pointColor = this.dataLayer.pointColorProcessor.processColor(pointData, dsData); + const point = L.circleMarker(this.dataLayer.extractLocation(pointData, dsData), { + stroke: false, + fillOpacity: 1, + fillColor: pointColor, + radius: this.settings.pointSize + }); + if (this.settings.pointTooltip?.show) { + const pointTooltip = createTooltip(this.dataLayer.getMap(), + point, this.settings.pointTooltip, pointData, () => true); + updateTooltip(this.dataLayer.getMap(), pointTooltip, + this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + } + this.points.addLayer(point); + } + } + } + + private updatePathAppearance() { + if (this.settings.showPath) { + const dsData = this.dataLayer.getMap().getData(); + const pathStyle = this.dataLayer.getPathStyle(this.pointData, dsData); + this.polyline.setStyle(pathStyle); + if (this.settings.usePathDecorator) { + this.polylineDecorator.setPatterns(this.dataLayer.getPathDecoratorStyle(pathStyle.color).patterns) + } } - this.updateMarkerIcon(data, dsData); } private updateMarkerLocation(data: FormattedData, dsData: FormattedData[]) { @@ -231,6 +332,7 @@ class TbTripDataItem { const maxTime = this.dataLayer.getMap().getMaxTime(); const timeStep = this.dataLayer.getMap().getTimeStep(); const timeline = this.dataLayer.getMap().hasTimeline(); + const dsData = this.dataLayer.getMap().getData(); for (const data of this.rawRouteData) { const currentTime = data.time; const normalizeTime = timeline ? (minTime @@ -260,7 +362,9 @@ class TbTripDataItem { } } if (this.settings.rotateMarker) { - result[timeStamp[i]].rotationAngle += findRotationAngle(result[timeStamp[i]], result[timeStamp[i + 1]], xKey, yKey); + const startPoint = this.dataLayer.extractLocation(result[timeStamp[i]], dsData); + const endPoint = this.dataLayer.extractLocation(result[timeStamp[i + 1]], dsData); + result[timeStamp[i]].rotationAngle += findRotationAngle(startPoint, endPoint); } } } @@ -584,10 +688,6 @@ export class TbTripsDataLayer extends TbMapDataLayer { const entityId = rawTripData[0].entityId; @@ -614,9 +714,12 @@ export class TbTripsDataLayer extends TbMapDataLayer item.invalidateCoordinates()); + } + public updateTripsLatestData(tripsLatestData: FormattedData[]) { this.latestTripsData = tripsLatestData.filter(d => d.$datasource.mapDataIds.includes(this.mapDataId)); - console.log(`Update trips latest data`); this.tripItems.forEach((item, entityId) => { const latestData = this.latestTripsData.find(d => d.entityId === entityId); item.updateLatestData(latestData); @@ -624,22 +727,24 @@ export class TbTripsDataLayer extends TbMapDataLayer { item.updateCurrentTime(); }); } public updateAppearance() { - console.log(`Update trips appearance`); this.tripItems.forEach(item => { item.updateAppearance(); }); } public calculateAnchors(): number[] { - return []; + let anchors: number[] = []; + this.tripItems.forEach(item => { + const tripAnchors = item.calculateAnchors(); + anchors = [...new Set([...anchors, ...tripAnchors])]; + }); + return anchors; } private clearIncorrectFirsLastDatapoint(dataSource: FormattedData[]): FormattedData[] { @@ -705,7 +810,7 @@ export class TbTripsDataLayer extends TbMapDataLayer): {x: number; y: number} { + public extractLocationData(data: FormattedData): {x: number; y: number} { if (data) { const xKeyVal = data[this.settings.xKey.label]; const yKeyVal = data[this.settings.yKey.label]; @@ -740,4 +845,36 @@ export class TbTripsDataLayer extends TbMapDataLayer, dsData: FormattedData[]): L.PolylineOptions { + const pathStroke = this.pathStrokeColorProcessor.processColor(data, dsData); + return { + interactive: false, + color: pathStroke, + opacity: 1, + weight: this.settings.pathStrokeWeight, + pmIgnore: true + }; + } + + public getPathDecoratorStyle(pathStroke: string): L.PolylineDecoratorOptions { + const symbolConstructor = L.Symbol[this.settings.pathDecoratorSymbol]; + return { + patterns: [ + { + offset: this.settings.pathDecoratorOffset, + endOffset: this.settings.pathEndDecoratorOffset, + repeat: this.settings.pathDecoratorRepeat, + symbol: symbolConstructor({ + pixelSize: this.settings.pathDecoratorSymbolSize, + polygon: false, + pathOptions: { + color: this.settings.pathDecoratorSymbolColor ? this.settings.pathDecoratorSymbolColor : pathStroke, + stroke: true + } + }) + } + ] + }; + } + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 11af4338e9..6c7d384c81 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -18,7 +18,7 @@ import { additionalMapDataSourcesToDatasources, BaseMapSettings, CustomActionData, - DataKeyValuePair, + DataKeyValuePair, MapBooleanFunction, MapStringFunction, MapType, mergeMapDatasources, mergeUnplacedDataItemsArrays, @@ -34,12 +34,12 @@ import { formattedDataFormDatasourceData, isDefined, isDefinedAndNotNull, isUndefined, - mergeDeepIgnoreArray + mergeDeepIgnoreArray, parseTbFunction } from '@core/utils'; import { DeepPartial } from '@shared/models/common'; import L from 'leaflet'; import { forkJoin, Observable, of } from 'rxjs'; -import { switchMap, tap } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import '@home/components/widget/lib/maps/leaflet/leaflet-tb'; import { MapDataLayerType, @@ -71,6 +71,7 @@ import TooltipPositioningSide = JQueryTooltipster.TooltipPositioningSide; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; import { ComponentRef } from '@angular/core'; import { TbTripsDataLayer } from '@home/components/widget/lib/maps/data-layer/trips-data-layer'; +import { CompiledTbFunction } from '@shared/models/js-function.models'; type TooltipInstancesData = {root: HTMLElement, instances: ITooltipsterInstance[]}; @@ -88,7 +89,7 @@ export abstract class TbMap { protected dataLayers: TbMapDataLayer[]; protected tripDataLayers: TbTripsDataLayer[]; - protected dsData: FormattedData[]; + protected dsData: FormattedData[] = []; protected timeline = false; protected minTime: number; @@ -113,6 +114,7 @@ export abstract class TbMap { protected timeLineComponentRef: ComponentRef; protected timeLineComponent: MapTimelinePanelComponent; + protected locationSnapFilterFunction: CompiledTbFunction; protected addMarkerDataLayers: TbMapDataLayer[]; protected addPolygonDataLayers: TbMapDataLayer[]; @@ -183,7 +185,16 @@ export abstract class TbMap { if (this.map.zoomControl) { this.map.zoomControl.setPosition(this.settings.controlsPosition); } - return this.doSetupControls(); + const setup = [this.doSetupControls()]; + if (this.timeline && this.settings.tripTimeline.snapToRealLocation) { + setup.push(parseTbFunction(this.getCtx().http, this.settings.tripTimeline.locationSnapFilter, ['data', 'dsData']).pipe( + map((parsed) => { + this.locationSnapFilterFunction = parsed; + return null; + }) + )); + } + return forkJoin(setup); } private initMap() { @@ -263,7 +274,6 @@ export abstract class TbMap { this.updateBounds(); }); } - const setup = allDataLayers.map(dl => dl.setup()); forkJoin(setup).subscribe( () => { @@ -919,6 +929,7 @@ export abstract class TbMap { protected invalidateDataLayersCoordinates(): void { this.dataLayers.forEach(dl => dl.invalidateCoordinates()); + this.tripDataLayers.forEach(dl => dl.invalidateCoordinates()); } protected getSidebar(): L.TB.SidebarControl { @@ -953,16 +964,16 @@ export abstract class TbMap { this.updateTripsAnchors(); } - public dataItemClick($event: Event, action: WidgetAction, entityInfo: TbMapDatasource) { + public dataItemClick($event: Event, action: WidgetAction, data: FormattedData) { if ($event) { $event.preventDefault(); $event.stopPropagation(); } - const { entityId, entityName, entityLabel, entityType } = entityInfo; + const { entityId, entityName, entityLabel, entityType } = data.$datasource; this.ctx.actionsApi.handleWidgetAction($event, action, { entityType, id: entityId - }, entityName, null, entityLabel); + }, entityName, data, entityLabel); } public selectItem(item: TbDataLayerItem, cancel = false, force = false): boolean { @@ -1063,6 +1074,10 @@ export abstract class TbMap { return this.currentTime; } + public getLocationSnapFilterFunction(): CompiledTbFunction { + return this.locationSnapFilterFunction; + } + public destroy() { if (this.mapResize$) { this.mapResize$.disconnect(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index 79fb8a7c04..826b47fc91 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -23,7 +23,16 @@ import { WidgetActionType } from '@shared/models/widget.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; -import { guid, hashCode, isDefinedAndNotNull, isNotEmptyStr, isString, isUndefined, mergeDeep } from '@core/utils'; +import { + guid, + hashCode, + isDefinedAndNotNull, + isNotEmptyStr, + isString, + isUndefined, + isUndefinedOrNull, + mergeDeep +} from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; import L from 'leaflet'; @@ -1040,6 +1049,9 @@ export interface CustomActionData { export type MapStringFunction = (data: FormattedData, dsData: FormattedData[]) => string; +export type MapBooleanFunction = (data: FormattedData, + dsData: FormattedData[]) => boolean; + export type MarkerImageFunction = (data: FormattedData, markerImages: string[], dsData: FormattedData[]) => MarkerImageInfo; @@ -1322,11 +1334,11 @@ export const interpolateLineSegment = ( }; } -export const findRotationAngle = (startPoint: FormattedData, endPoint: FormattedData, xKey: string, yKey: string): number => { - if (isUndefined(startPoint) || isUndefined(endPoint)) { +export const findRotationAngle = (startPoint: L.LatLng, endPoint: L.LatLng): number => { + if (isUndefinedOrNull(startPoint) || isUndefinedOrNull(endPoint)) { return 0; } - let angle = -Math.atan2(endPoint[xKey] - startPoint[xKey], endPoint[yKey] - startPoint[yKey]); + let angle = -Math.atan2(endPoint.lat - startPoint.lat, endPoint.lng - startPoint.lng); angle = angle * 180 / Math.PI; return parseInt(angle.toFixed(2), 10); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index 7b2b428c5c..7d165c5a24 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -27,7 +27,7 @@ import { } from '@angular/core'; import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models'; import { DateFormatProcessor } from '@shared/models/widget-settings.models'; -import { interval, Observable, Subscription } from 'rxjs'; +import { interval, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -75,6 +75,11 @@ export class MapTimelinePanelComponent implements OnInit { set currentTime(time: number) { if (this.currentTimeValue !== time) { this.currentTimeValue = time; + if (this.hasData) { + this.index = Math.ceil((this.currentTimeValue - this.minValue) / this.settings.timeStep); + } else { + this.index = 0; + } this.updateTimestampDisplayValue(); this.cd.markForCheck(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index ffd2166aab..07e0fa43e9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -285,6 +285,7 @@ px diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 7d1d4a4b50..8e57e891c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -155,7 +155,7 @@ export class MapDataLayerDialogComponent extends DialogComponent Date: Tue, 4 Mar 2025 11:44:41 +0200 Subject: [PATCH 060/127] Save attributes strategies: preserve backwards compatibility when deprecating 'notifyDevice' --- .../queue/DefaultTbCoreConsumerService.java | 17 +++++++++++++---- .../DefaultSubscriptionManagerService.java | 11 +++++++++++ .../SubscriptionManagerService.java | 9 +++++++++ common/proto/src/main/proto/queue.proto | 7 +++++-- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 0152e34d50..4f0522e62a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -542,10 +542,19 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService keys, TbCallback callback) { + onAttributesDelete(tenantId, entityId, scope, keys, false, callback); + } + + @Override + public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback callback) { processAttributesUpdate(entityId, scope, keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); if (entityId.getEntityType() == EntityType.DEVICE) { if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) || TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) { deleteDeviceInactivityTimeout(tenantId, entityId, keys); + } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { + clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, + new DeviceId(entityId.getId()), scope, keys), null); } } callback.onSuccess(); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java index 3fd58a1243..57d4fda5f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java @@ -41,6 +41,15 @@ public interface SubscriptionManagerService extends ApplicationListener keys, TbCallback empty); + /** + * This method is retained solely for backwards compatibility, specifically to handle + * legacy proto messages that include the notifyDevice field. + * + * @deprecated as of 4.0, this method will be removed in future releases. + */ + @Deprecated(forRemoval = true, since = "4.0") + void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback empty); + void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List keys, TbCallback callback); void onAlarmUpdate(TenantId tenantId, EntityId entityId, AlarmInfo alarm, TbCallback callback); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 969b47f847..76477d7801 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -1079,8 +1079,11 @@ message TbAttributeDeleteProto { int64 tenantIdLSB = 5; string scope = 6; repeated string keys = 7; - // Deprecated since 4.0, not used anymore since device notification are now handled in DefaultTelemetrySubscriptionService instead of DefaultSubscriptionManagerService - bool notifyDevice = 8 [deprecated = true]; + // DEPRECATED. FOR REMOVAL + // Since 4.0, this field is no longer used. + // Device notifications are now handled directly by DefaultTelemetrySubscriptionService, + // eliminating the need to pass this parameter through the queue and proto to DefaultSubscriptionManagerService. + optional bool notifyDevice = 8 [deprecated = true]; } message TbTimeSeriesDeleteProto { From 49e627a8a66edeb398572465408ae9db7abec0bc Mon Sep 17 00:00:00 2001 From: mpetrov Date: Tue, 4 Mar 2025 13:07:52 +0200 Subject: [PATCH 061/127] Fixed highlight bug --- .../scada-symbol/scada-symbol-editor.models.ts | 5 ++--- ui-ngx/src/app/shared/models/ace/ace.models.ts | 2 ++ .../app/shared/models/calculated-field.models.ts | 14 +++++++++++++- 3 files changed, 17 insertions(+), 4 deletions(-) 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 13b55d4f67..cc5a9baf4f 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 @@ -35,7 +35,8 @@ import { AceHighlightRule, AceHighlightRules, dotOperatorHighlightRule, - endGroupHighlightRule + endGroupHighlightRule, + identifierRe } from '@shared/models/ace/ace.models'; import { HelpLinks, ValueType } from '@shared/models/constants'; import { formPropertyCompletions } from '@shared/models/dynamic-form.models'; @@ -924,8 +925,6 @@ export class ScadaSymbolElement { } -const identifierRe = /[a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*/; - const scadaSymbolCtxObjectHighlightRule: AceHighlightRule = { token: 'tb.scada-symbol-ctx', regex: /\bctx\b/, diff --git a/ui-ngx/src/app/shared/models/ace/ace.models.ts b/ui-ngx/src/app/shared/models/ace/ace.models.ts index 5287886b84..9533f80d51 100644 --- a/ui-ngx/src/app/shared/models/ace/ace.models.ts +++ b/ui-ngx/src/app/shared/models/ace/ace.models.ts @@ -376,5 +376,7 @@ export const endGroupHighlightRule: AceHighlightRule = { next: 'no_regex' }; +export const identifierRe = /[a-zA-Z$_\u00a1-\uffff][a-zA-Z\d$_\u00a1-\uffff]*/; + diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index f6d7aaaa1e..fef90613a8 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -32,7 +32,8 @@ import { AceHighlightRule, AceHighlightRules, dotOperatorHighlightRule, - endGroupHighlightRule + endGroupHighlightRule, + identifierRe } from '@shared/models/ace/ace.models'; export interface CalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { @@ -480,6 +481,17 @@ export const getCalculatedFieldArgumentsHighlights = ( ? 'calculatedFieldRollingArgumentValue' : 'calculatedFieldSingleArgumentValue' })), + no_regex: [ + { + token: 'tb.identifier', + regex: identifierRe, + }, + { + token: 'tb.paren.lparen', + regex: '\\(', + next: 'start' + } + ], ...calculatedFieldSingleArgumentValueHighlightRules, ...calculatedFieldRollingArgumentValueHighlightRules, ...calculatedFieldTimeWindowArgumentValueHighlightRules From e62b062d5b0ff24ddc70d57101743303fbe0188e Mon Sep 17 00:00:00 2001 From: mpetrov Date: Tue, 4 Mar 2025 14:57:46 +0200 Subject: [PATCH 062/127] Added copy argument name button --- ...lated-field-arguments-table.component.html | 19 ++++++++++++++----- ...lated-field-arguments-table.component.scss | 18 ++++++++++++++++++ .../components/event/event-table-config.ts | 4 ++-- .../assets/locale/locale.constant-en_US.json | 1 + 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html index 6bab01f976..8a362f7eda 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html @@ -20,18 +20,27 @@ - +
{{ 'common.name' | translate }}
- -
{{ argument.argumentName }}
+ +
+
{{ argument.argumentName }}
+ +
- + {{ 'entity.entity-type' | translate }} - +
@if (argument.refEntityId?.entityType === ArgumentEntityType.Tenant) { {{ 'calculated-fields.argument-current-tenant' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss index 877a749afa..ae8fd25170 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.scss @@ -28,6 +28,17 @@ .key-text { font-size: 13px; } + + .copy-argument-name { + visibility: hidden; + transition: visibility 0.1s; + } + + .argument-name-cell:hover { + .copy-argument-name { + visibility: visible; + } + } } .max-args-warning { @@ -61,4 +72,11 @@ padding: 0 28px 0 0; } } + + .copy-argument-name { + .mat-icon { + font-size: 16px; + padding: 4px; + } + } } diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 6c334d2a5e..56a17cc7ae 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -369,7 +369,7 @@ export class EventTableConfig extends EntityTableConfig { (entity) => entity.body.entityId, { name: this.translate.instant('event.copy-entity-id'), - icon: 'content_paste', + icon: 'content_copy', style: { padding: '4px', 'font-size': '16px', @@ -389,7 +389,7 @@ export class EventTableConfig extends EntityTableConfig { false, { name: this.translate.instant('event.copy-message-id'), - icon: 'content_paste', + icon: 'content_copy', style: { padding: '4px', 'font-size': '16px', diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 02c604d8ae..96d8bf205f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1031,6 +1031,7 @@ "argument-type": "Argument type", "see-debug-events": "See debug events", "attribute": "Attribute", + "copy-argument-name": "Copy argument name", "timeseries-key": "Time series key", "device-name": "Device name", "latest-telemetry": "Latest telemetry", From 6d401e74899789da489dd275daf809fe32201096 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Tue, 4 Mar 2025 15:15:14 +0200 Subject: [PATCH 063/127] Fixed delete argument on sorting --- .../calculated-field-arguments-table.component.html | 2 +- .../calculated-field-arguments-table.component.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html index 8a362f7eda..162bd3aa1e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html @@ -107,7 +107,7 @@
@if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { - - - {{ (outputFormGroup.get('type').value === OutputType.Timeseries - ? 'calculated-fields.timeseries-key' - : 'calculated-fields.attribute-key') - | translate }} - - - @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { - - @if (outputFormGroup.get('name').hasError('required')) { - {{ 'common.hint.key-required' | translate }} - } @else if (outputFormGroup.get('name').hasError('pattern')) { - {{ 'common.hint.key-pattern' | translate }} - } @else if (outputFormGroup.get('name').hasError('maxlength')) { - {{ 'common.hint.key-max-length' | translate }} - } - - } - +
+ + + {{ (outputFormGroup.get('type').value === OutputType.Timeseries + ? 'calculated-fields.timeseries-key' + : 'calculated-fields.attribute-key') + | translate }} + + + @if (outputFormGroup.get('name').errors && outputFormGroup.get('name').touched) { + + @if (outputFormGroup.get('name').hasError('required')) { + {{ 'common.hint.key-required' | translate }} + } @else if (outputFormGroup.get('name').hasError('pattern')) { + {{ 'common.hint.key-pattern' | translate }} + } @else if (outputFormGroup.get('name').hasError('maxlength')) { + {{ 'common.hint.key-max-length' | translate }} + } + + } + + + {{ 'calculated-fields.decimals-by-default' | translate }} + + @if (outputFormGroup.get('decimalsByDefault').errors && outputFormGroup.get('decimalsByDefault').touched) { + {{ 'calculated-fields.hint.decimals-range' | translate }} + } + +
}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 757231364b..127c1c8c1c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -33,7 +33,7 @@ import { OutputType, OutputTypeTranslations } from '@shared/models/calculated-field.models'; -import { oneSpaceInsideRegex } from '@shared/models/regex.constants'; +import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { AttributeScope } from '@shared/models/telemetry/telemetry.models'; import { EntityType } from '@shared/models/entity-type.models'; import { map, startWith, switchMap } from 'rxjs/operators'; @@ -61,7 +61,8 @@ export class CalculatedFieldDialogComponent extends DialogComponent Date: Wed, 5 Mar 2025 12:36:57 +0200 Subject: [PATCH 075/127] UI: Maps - introduce button to toggle drag-drop mode. --- .../maps/data-layer/latest-map-data-layer.ts | 26 ++++- .../components/widget/lib/maps/map-layer.ts | 5 +- .../home/components/widget/lib/maps/map.scss | 3 + .../home/components/widget/lib/maps/map.ts | 101 ++++++++++++++---- .../widget/lib/maps/models/map.models.ts | 2 + .../common/map/map-settings.component.html | 5 + .../common/map/map-settings.component.ts | 37 ++++++- .../assets/locale/locale.constant-en_US.json | 4 +- 8 files changed, 158 insertions(+), 25 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts index fe8639f7a0..7c77056894 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts @@ -114,6 +114,18 @@ export abstract class TbLatestDataLayerItem, dsData: FormattedData[]): void { this.data = data; this.doUpdate(data, dsData); @@ -151,7 +163,7 @@ export abstract class TbLatestDataLayerItem item.editModeUpdated()); } + private updateItemsDragMode() { + this.layerItems.forEach(item => item.dragModeUpdated()); + } + public abstract placeItem(item: UnplacedMapDataItem, layer: L.Layer): void; protected abstract isValidLayerData(layerData: FormattedData): boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index a139eeb4c0..c6f8f446a6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -161,7 +161,7 @@ class TbGoogleMapLayer extends TbMapLayer { } private loadGoogle(): Observable { - const apiKey = this.settings.apiKey; + const apiKey = this.settings.apiKey || defaultGoogleMapLayerSettings.apiKey; if (TbGoogleMapLayer.loadedApiKeysGlobal[apiKey]) { return of(true); } else { @@ -213,7 +213,8 @@ class TbHereMapLayer extends TbMapLayer { } protected createLayer(): Observable { - const layer = L.tileLayer.provider(this.settings.layerType, {useV3: true, apiKey: this.settings.apiKey} as any); + const apiKey = this.settings.apiKey || defaultHereMapLayerSettings.apiKey; + const layer = L.tileLayer.provider(this.settings.layerType, {useV3: true, apiKey} as any); return of(layer); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 9c764cbb0e..fc60862be4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -138,6 +138,9 @@ &.tb-rotate { mask-image: url('data:image/svg+xml,'); } + &.tb-drag-mode { + mask-image: url('data:image/svg+xml,'); + } &.tb-place-marker { mask-image: url('data:image/svg+xml,'); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index f279a9fb68..6225ff878d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -109,6 +109,7 @@ export abstract class TbMap { protected customActionsToolbar: L.TB.TopToolbarControl; protected editToolbar: L.TB.BottomToolbarControl; + protected dragModeButton: L.TB.ToolbarButton; protected addMarkerButton: L.TB.ToolbarButton; protected addRectangleButton: L.TB.ToolbarButton; protected addPolygonButton: L.TB.ToolbarButton; @@ -127,10 +128,12 @@ export abstract class TbMap { private tooltipInstances: TooltipInstancesData[] = []; private currentPopover: TbPopoverComponent; - private currentAddButton: L.TB.ToolbarButton; + private currentEditButton: L.TB.ToolbarButton; + + private dragMode = true; private get isPlacingItem(): boolean { - return !!this.currentAddButton; + return !!this.currentEditButton; } protected constructor(protected ctx: WidgetContext, @@ -188,6 +191,7 @@ export abstract class TbMap { if (this.map.zoomControl) { this.map.zoomControl.setPosition(this.settings.controlsPosition); } + this.dragMode = !this.settings.dragModeButton; const setup = [this.doSetupControls()]; if (this.timeline && this.settings.tripTimeline.snapToRealLocation) { setup.push(parseTbFunction(this.getCtx().http, this.settings.tripTimeline.locationSnapFilter, ['data', 'dsData']).pipe( @@ -393,12 +397,24 @@ export abstract class TbMap { this.map.pm.applyGlobalOptions(); } + const dragSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isDragEnabled()); + const showDragModeButton = this.settings.dragModeButton && dragSupportedDataLayers.length; const addSupportedDataLayers = this.latestDataLayers.filter(dl => dl.isAddEnabled()); - if (addSupportedDataLayers.length) { + if (showDragModeButton || addSupportedDataLayers.length) { const drawToolbar = L.TB.toolbar({ position: this.settings.controlsPosition }).addTo(this.map); + if (showDragModeButton) { + this.dragModeButton = drawToolbar.toolbarButton({ + id: 'dragMode', + title: this.ctx.translate.instant('widgets.maps.data-layer.drag-drop-mode'), + iconClass: 'tb-drag-mode', + click: (e, button) => { + this.toggleDragMode(e, button); + } + }); + } this.addMarkerDataLayers = addSupportedDataLayers.filter(dl => dl.dataLayerType() === 'markers'); if (this.addMarkerDataLayers.length) { this.addMarkerButton = drawToolbar.toolbarButton({ @@ -448,6 +464,32 @@ export abstract class TbMap { } } + private toggleDragMode(e: MouseEvent, button: L.TB.ToolbarButton): void { + if (this.dragMode) { + this.disableDragMode(); + } else { + this.dragMode = true; + this.latestDataLayers.forEach(dl => dl.dragModeUpdated()); + this.updatePlaceItemState(button); + this.editToolbar.open([ + { + id: 'cancel', + iconClass: 'tb-close', + title: this.ctx.translate.instant('action.cancel'), + showText: true, + click: this.disableDragMode + } + ], false); + } + } + + private disableDragMode = () => { + this.dragMode = false; + this.latestDataLayers.forEach(dl => dl.dragModeUpdated()); + this.updatePlaceItemState(); + this.editToolbar.close(); + } + private placeMarker(e: MouseEvent, button: L.TB.ToolbarButton): void { this.placeItem(e, button, this.addMarkerDataLayers, (entity) => this.prepareDrawMode('Marker', { placeMarker: this.ctx.translate.instant('widgets.maps.data-layer.marker.place-marker-hint-with-entity', {entityName: entity.entity.entityDisplayName}) @@ -479,6 +521,7 @@ export abstract class TbMap { private placeItem(e: MouseEvent, button: L.TB.ToolbarButton, dataLayers: TbLatestMapDataLayer[], prepareDrawMode: (entity: UnplacedMapDataItem) => void): void { if (this.isPlacingItem) { + this.finishAdd(); return; } this.updatePlaceItemState(button); @@ -692,6 +735,10 @@ export abstract class TbMap { } private finishAdd = () => { + if (this.currentPopover) { + this.currentPopover.hide(); + this.currentPopover = null; + } this.map.off('pm:create'); this.map.pm.disableDraw(); this.latestDataLayers.forEach(dl => dl.enableEditMode()); @@ -706,15 +753,15 @@ export abstract class TbMap { L.DomUtil.addClass(this.map.pm.Draw[shape]._hintMarker.getTooltip()._container, 'tb-place-item-label'); } - private updatePlaceItemState(addButton?: L.TB.ToolbarButton, disabled = false): void { - if (addButton) { + private updatePlaceItemState(editButton?: L.TB.ToolbarButton, disabled = false): void { + if (editButton) { this.deselectItem(false, true); - addButton.setActive(true); - } else if (this.currentAddButton) { - this.currentAddButton.setActive(false); + editButton.setActive(true); + } else if (this.currentEditButton) { + this.currentEditButton.setActive(false); } - this.currentAddButton = addButton; - this.updateAddButtonsStates(disabled); + this.currentEditButton = editButton; + this.updateEditButtonsStates(disabled); } private createdControlButtonTooltip(root: HTMLElement, side: TooltipPositioningSide) { @@ -784,7 +831,7 @@ export abstract class TbMap { this.updateTripsAppearance(); this.updateTripsAnchors(); this.updateBounds(); - this.updateAddButtonsStates(); + this.updateEditButtonsStates(); } private updateTrips(subscription: IWidgetSubscription) { @@ -886,22 +933,28 @@ export abstract class TbMap { } } - private updateAddButtonsStates(disabled = false) { - if (this.currentAddButton || disabled) { - if (this.addMarkerButton && this.addMarkerButton !== this.currentAddButton) { + private updateEditButtonsStates(disabled = false) { + if (this.currentEditButton || disabled) { + if (this.dragModeButton && this.dragModeButton !== this.currentEditButton) { + this.dragModeButton.setDisabled(true); + } + if (this.addMarkerButton && this.addMarkerButton !== this.currentEditButton) { this.addMarkerButton.setDisabled(true); } - if (this.addRectangleButton && this.addRectangleButton !== this.currentAddButton) { + if (this.addRectangleButton && this.addRectangleButton !== this.currentEditButton) { this.addRectangleButton.setDisabled(true); } - if (this.addPolygonButton && this.addPolygonButton !== this.currentAddButton) { + if (this.addPolygonButton && this.addPolygonButton !== this.currentEditButton) { this.addPolygonButton.setDisabled(true); } - if (this.addCircleButton && this.addCircleButton !== this.currentAddButton) { + if (this.addCircleButton && this.addCircleButton !== this.currentEditButton) { this.addCircleButton.setDisabled(true); } - this.customActionsToolbar.setDisabled(true); + this.customActionsToolbar?.setDisabled(true); } else { + if (this.dragModeButton) { + this.dragModeButton.setDisabled(false); + } if (this.addMarkerButton) { this.addMarkerButton.setDisabled(!this.addMarkerDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } @@ -914,7 +967,7 @@ export abstract class TbMap { if (this.addCircleButton) { this.addCircleButton.setDisabled(!this.addCircleDataLayers.some(dl => dl.isEnabled() && dl.hasUnplacedItems())); } - this.customActionsToolbar.setDisabled(false); + this.customActionsToolbar?.setDisabled(false); } } @@ -979,7 +1032,7 @@ export abstract class TbMap { } public enabledDataLayersUpdated() { - this.updateAddButtonsStates(); + this.updateEditButtonsStates(); this.updateTripsAnchors(); } @@ -1027,6 +1080,14 @@ export abstract class TbMap { return this.editToolbar; } + public useDragModeButton(): boolean { + return this.settings.dragModeButton; + } + + public dragModeEnabled(): boolean { + return this.dragMode; + } + public saveItemData(datasource: TbMapDatasource, data: DataKeyValuePair[]): Observable { const attributeService = this.ctx.$injector.get(AttributeService); const attributes: AttributeData[] = []; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts index d28e04b4fc..0f0e64f5b7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts @@ -660,6 +660,7 @@ export interface BaseMapSettings { additionalDataSources: AdditionalMapDataSourceSettings[]; controlsPosition: MapControlsPosition; zoomActions: MapZoomAction[]; + dragModeButton: boolean; fitMapBounds: boolean; useDefaultCenterPosition: boolean; defaultCenterPosition?: string; @@ -682,6 +683,7 @@ export const defaultBaseMapSettings: BaseMapSettings = { additionalDataSources: [], controlsPosition: MapControlsPosition.topleft, zoomActions: [MapZoomAction.scroll, MapZoomAction.doubleClick, MapZoomAction.controlButtons], + dragModeButton: false, fitMapBounds: true, useDefaultCenterPosition: false, defaultCenterPosition: '0,0', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index 0ee29c54c5..97c686732a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -99,6 +99,11 @@ +
+ + {{ 'widgets.maps.control.switch-to-drag-mode-using-button' | translate }} + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 74536f8fd5..6f1650b19f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -26,10 +26,13 @@ import { Validators } from '@angular/forms'; import { + DataLayerEditAction, defaultImageMapSourceSettings, - ImageMapSourceSettings, imageMapSourceSettingsValidator, + ImageMapSourceSettings, + imageMapSourceSettingsValidator, mapControlPositions, mapControlsPositionTranslationMap, + MapDataLayerSettings, MapDataLayerType, MapSetting, MapType, @@ -109,6 +112,8 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid dataLayerMode: MapDataLayerType = 'markers'; + showDragButtonModeButtonSettings = false; + constructor(private fb: UntypedFormBuilder, private dialog: MatDialog, private destroyRef: DestroyRef) { @@ -139,6 +144,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid additionalDataSources: [null, []], controlsPosition: [null, []], zoomActions: [null, []], + dragModeButton: [null, []], fitMapBounds: [null, []], useDefaultCenterPosition: [null, []], defaultCenterPosition: [null, []], @@ -167,6 +173,14 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid ).subscribe((mapType: MapType) => { this.mapTypeChanged(mapType); }); + merge(this.mapSettingsFormGroup.get('markers').valueChanges, + this.mapSettingsFormGroup.get('polygons').valueChanges, + this.mapSettingsFormGroup.get('circles').valueChanges + ).pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateDragButtonModeSettings(); + }); } registerOnChange(fn: any): void { @@ -192,6 +206,7 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid value, {emitEvent: false} ); this.updateValidators(); + this.updateDragButtonModeSettings(); } public validate(_c: UntypedFormControl) { @@ -237,6 +252,26 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid } } + private updateDragButtonModeSettings() { + const markers: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('markers').value; + const circles: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('circles').value; + let dragModeButtonSettingsEnabled = markers.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + if (!dragModeButtonSettingsEnabled) { + const polygons: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('polygons').value; + dragModeButtonSettingsEnabled = polygons.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + } + if (!dragModeButtonSettingsEnabled) { + const circles: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('circles').value; + dragModeButtonSettingsEnabled = circles.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); + } + this.showDragButtonModeButtonSettings = dragModeButtonSettingsEnabled; + if (dragModeButtonSettingsEnabled) { + this.mapSettingsFormGroup.get('dragModeButton').enable({emitEvent: false}); + } else { + this.mapSettingsFormGroup.get('dragModeButton').disable({emitEvent: false}); + } + } + private updateModel() { this.modelValue = this.mapSettingsFormGroup.getRawValue(); this.propagateChange(this.modelValue); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 6eaa7cd206..4c336831fa 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7704,7 +7704,8 @@ "zoom-actions": "Zoom actions", "zoom-scroll": "Scroll", "zoom-double-click": "Double click", - "zoom-control-buttons": "Control buttons" + "zoom-control-buttons": "Control buttons", + "switch-to-drag-mode-using-button": "Switch to drag mode using button" }, "timeline": { "control-panel": "Timeline control panel", @@ -7839,6 +7840,7 @@ "action-remove": "Remove", "edit-instruments": "Instruments", "enable-snapping": "Enable snapping to other vertices for precision drawing", + "drag-drop-mode": "Drag-drop mode", "trip": { "no-trips": "No trips configured", "add-trip": "Add trip", From d53f359bca5a34260e5b00cd116dd46cebe48a60 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Wed, 5 Mar 2025 13:40:05 +0200 Subject: [PATCH 076/127] Allowed underscore in argument name --- ui-ngx/src/app/shared/models/regex.constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/shared/models/regex.constants.ts b/ui-ngx/src/app/shared/models/regex.constants.ts index f78d492d96..b8b1be4e83 100644 --- a/ui-ngx/src/app/shared/models/regex.constants.ts +++ b/ui-ngx/src/app/shared/models/regex.constants.ts @@ -16,6 +16,6 @@ export const oneSpaceInsideRegex = /^\s*\S+(?:\s\S+)*\s*$/; -export const charsWithNumRegex = /^[a-zA-Z]+[a-zA-Z0-9]*$/; +export const charsWithNumRegex = /^[a-zA-Z_]+[a-zA-Z0-9_]*$/; export const digitsRegex = /^\d*$/; From a991719585a5a7388a71f6a43ebcd9ea9afabec9 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Wed, 5 Mar 2025 14:02:57 +0200 Subject: [PATCH 077/127] Fixed highlight on type change bug --- .../calculated-field-dialog.component.html | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 1feb76035d..a136d3ea8b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -76,30 +76,28 @@
{{ 'calculated-fields.expression' | translate }}
- @if (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE) { - - - @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { - - @if (configFormGroup.get('expressionSIMPLE').hasError('required')) { - {{ 'calculated-fields.hint.expression-required' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { - {{ 'calculated-fields.hint.expression-invalid' | translate }} - } @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { - {{ 'calculated-fields.hint.expression-max-length' | translate }} - } - - } @else { - {{ 'calculated-fields.hint.expression' | translate }} - } - - } @else { + + + @if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) { + + @if (configFormGroup.get('expressionSIMPLE').hasError('required')) { + {{ 'calculated-fields.hint.expression-required' | translate }} + } @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) { + {{ 'calculated-fields.hint.expression-invalid' | translate }} + } @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) { + {{ 'calculated-fields.hint.expression-max-length' | translate }} + } + + } @else { + {{ 'calculated-fields.hint.expression' | translate }} + } + +
- } +
{{ 'calculated-fields.output' | translate }}
From 547bb7e169cd1dd699013dbac0842a9abbec3fde Mon Sep 17 00:00:00 2001 From: mpetrov Date: Wed, 5 Mar 2025 16:18:06 +0200 Subject: [PATCH 078/127] refactoring --- .../calculated-fields/calculated-fields-table-config.ts | 4 ++-- .../components/dialog/calculated-field-dialog.component.html | 1 + ui-ngx/src/app/shared/models/calculated-field.models.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 400e9bad1c..cd9f0373db 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -120,11 +120,11 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig { const expressionLabel = this.getExpressionLabel(entity); - return expressionLabel.length < 50 ? expressionLabel : `${expressionLabel.substring(0, 49)}…`; + return expressionLabel.length < 45 ? expressionLabel : `${expressionLabel.substring(0, 44)}…`; } expressionColumn.cellTooltipFunction = entity => { const expressionLabel = this.getExpressionLabel(entity); - return expressionLabel.length < 50 ? null : expressionLabel + return expressionLabel.length < 45 ? null : expressionLabel }; this.columns.push(new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px')); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index a136d3ea8b..471c722145 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -98,6 +98,7 @@ formControlName="expressionSCRIPT" functionName="calculate" [functionArgs]="functionArgs$ | async" + [disableUndefinedCheck]="true" [scriptLanguage]="ScriptLanguage.TBEL" [highlightRules]="argumentsHighlightRules$ | async" [editorCompleter]="argumentsEditorCompleter$ | async" diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index d7c6754b9a..7083ce7ae0 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -608,5 +608,5 @@ const calculatedFieldTimeWindowArgumentValueHighlightRules: AceHighlightRules = export const calculatedFieldDefaultScript = 'return {\n' + ' // Convert Fahrenheit to Celsius\n' + - ' "temperatureC": (temperature - 32) / 1.8\n' + + ' "temperatureCelsius": (temperature.value - 32) / 1.8\n' + '};' From fdad68997ae67ed1ec91ab4c09491f0055c82e4f Mon Sep 17 00:00:00 2001 From: mpetrov Date: Wed, 5 Mar 2025 16:19:20 +0200 Subject: [PATCH 079/127] refactoring --- .../components/dialog/calculated-field-dialog.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 127c1c8c1c..0c3e7464cd 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -128,7 +128,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent Date: Wed, 5 Mar 2025 18:42:25 +0200 Subject: [PATCH 080/127] UI: Improve map widget import/export. Add help assets for new maps. --- .../app/core/services/item-buffer.service.ts | 154 ++++++------------ .../lib/maps/data-layer/circles-data-layer.ts | 2 +- .../lib/maps/data-layer/data-layer-utils.ts | 2 +- .../maps/data-layer/latest-map-data-layer.ts | 2 +- .../lib/maps/data-layer/map-data-layer.ts | 7 +- .../lib/maps/data-layer/markers-data-layer.ts | 4 +- .../maps/data-layer/polygons-data-layer.ts | 2 +- .../lib/maps/data-layer/shapes-data-layer.ts | 2 +- .../lib/maps/data-layer/trips-data-layer.ts | 2 +- .../components/widget/lib/maps/geo-map.ts | 2 +- .../components/widget/lib/maps/image-map.ts | 2 +- .../components/widget/lib/maps/map-layer.ts | 2 +- .../widget/lib/maps/map-widget.component.ts | 5 +- .../widget/lib/maps/map-widget.models.ts | 2 +- .../home/components/widget/lib/maps/map.ts | 6 +- .../panels/map-timeline-panel.component.ts | 2 +- ...-layer-color-settings-panel.component.html | 2 +- ...ta-layer-color-settings-panel.component.ts | 5 +- .../data-layer-color-settings.component.ts | 6 +- ...data-layer-pattern-settings.component.html | 2 +- .../data-layer-pattern-settings.component.ts | 5 +- .../image-map-source-settings.component.ts | 4 +- .../map/map-action-button-row.component.ts | 2 +- .../map-action-buttons-settings.component.ts | 2 +- .../map/map-data-layer-dialog.component.html | 11 +- .../map/map-data-layer-dialog.component.ts | 32 +++- .../map/map-data-layer-row.component.ts | 2 +- .../common/map/map-data-layers.component.ts | 2 +- .../map/map-data-source-row.component.ts | 2 +- .../common/map/map-data-sources.component.ts | 2 +- .../common/map/map-layer-row.component.ts | 2 +- .../map/map-layer-settings-panel.component.ts | 2 +- .../common/map/map-layers.component.ts | 2 +- .../common/map/map-settings.component.ts | 3 +- .../marker-clustering-settings.component.ts | 2 +- .../marker-image-settings-panel.component.ts | 2 +- .../map/marker-image-settings.component.ts | 2 +- .../map/marker-shape-settings.component.html | 2 +- .../map/marker-shape-settings.component.ts | 4 +- .../common/map/marker-shapes.component.ts | 2 +- .../map/trip-timeline-settings.component.ts | 13 +- .../map/legacy/circle-settings.component.html | 8 +- .../marker-clustering-settings.component.html | 2 +- .../legacy/markers-settings.component.html | 10 +- .../legacy/polygon-settings.component.html | 8 +- ...p-animation-common-settings.component.html | 2 +- ...p-animation-marker-settings.component.html | 4 +- ...rip-animation-path-settings.component.html | 2 +- ...ip-animation-point-settings.component.html | 4 +- .../map/map-widget-settings.component.ts | 2 +- .../import-export/import-export.service.ts | 12 +- ui-ngx/src/app/shared/models/alias.models.ts | 39 +++++ .../app/shared/models/query/query.models.ts | 40 +++++ .../models/widget/maps/map-export.models.ts | 151 +++++++++++++++++ .../models/widget/maps}/map.models.ts | 2 +- .../widget/maps}/marker-shape.models.ts | 2 +- .../models/widget/widget-export.models.ts | 35 ++++ .../lib/map-legacy/clustering_color_fn.md | 65 ++++++++ .../en_US/widget/lib/map-legacy/color_fn.md | 42 +++++ .../en_US/widget/lib/map-legacy/label_fn.md | 40 +++++ .../widget/lib/map-legacy/map_fn_args.md | 10 ++ .../widget/lib/map-legacy/marker_image_fn.md | 62 +++++++ .../widget/lib/map-legacy/path_color_fn.md | 42 +++++ .../lib/map-legacy/path_point_color_fn.md | 43 +++++ .../{map => map-legacy}/polygon_color_fn.md | 2 +- .../lib/map-legacy/polygon_tooltip_fn.md | 40 +++++ .../widget/lib/map-legacy/position_fn.md | 65 ++++++++ .../en_US/widget/lib/map-legacy/tooltip_fn.md | 40 +++++ .../lib/map-legacy/trip_point_as_anchor_fn.md | 34 ++++ .../widget/lib/map/circle_fill_color_fn.md | 24 +++ .../en_US/widget/lib/map/circle_label_fn.md | 22 +++ .../widget/lib/map/circle_stroke_color_fn.md | 24 +++ .../en_US/widget/lib/map/circle_tooltip_fn.md | 22 +++ .../widget/lib/map/clustering_color_fn.md | 6 +- .../help/en_US/widget/lib/map/color_fn.md | 22 +-- .../en_US/widget/lib/map/color_fn_examples.md | 19 +++ .../help/en_US/widget/lib/map/label_fn.md | 22 +-- .../en_US/widget/lib/map/label_fn_examples.md | 19 +++ .../help/en_US/widget/lib/map/map_fn_args.md | 12 +- .../en_US/widget/lib/map/marker_image_fn.md | 18 +- .../widget/lib/map/marker_image_fn_args.md | 10 ++ .../en_US/widget/lib/map/path_color_fn.md | 24 +-- .../widget/lib/map/path_point_color_fn.md | 25 +-- .../widget/lib/map/path_point_tooltip_fn.md | 22 +++ .../widget/lib/map/polygon_fill_color_fn.md | 24 +++ .../en_US/widget/lib/map/polygon_label_fn.md | 22 +++ .../widget/lib/map/polygon_stroke_color_fn.md | 24 +++ .../widget/lib/map/polygon_tooltip_fn.md | 24 +-- .../help/en_US/widget/lib/map/position_fn.md | 6 +- .../help/en_US/widget/lib/map/tooltip_fn.md | 24 +-- .../widget/lib/map/tooltip_fn_examples.md | 19 +++ .../widget/lib/map/trip_point_as_anchor_fn.md | 4 +- ui-ngx/src/typings/leaflet-extend-tb.d.ts | 4 +- 93 files changed, 1214 insertions(+), 349 deletions(-) create mode 100644 ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts rename ui-ngx/src/app/{modules/home/components/widget/lib/maps/models => shared/models/widget/maps}/map.models.ts (99%) rename ui-ngx/src/app/{modules/home/components/widget/lib/maps/models => shared/models/widget/maps}/marker-shape.models.ts (98%) create mode 100644 ui-ngx/src/app/shared/models/widget/widget-export.models.ts create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md rename ui-ngx/src/assets/help/en_US/widget/lib/{map => map-legacy}/polygon_color_fn.md (94%) create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md create mode 100644 ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 8806b15867..cef9bbe7a3 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { BreakpointId, Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models'; -import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo } from '@shared/models/alias.models'; +import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; import { Datasource, DatasourceType, @@ -34,7 +34,8 @@ import { map } from 'rxjs/operators'; import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainImport } from '@shared/models/rule-chain.models'; -import { Filter, FilterInfo, Filters, FiltersInfo } from '@shared/models/query/query.models'; +import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models'; +import { getWidgetExportDefinition } from '@shared/models/widget/widget-export.models'; const WIDGET_ITEM = 'widget_item'; const WIDGET_REFERENCE = 'widget_reference'; @@ -47,6 +48,7 @@ export interface WidgetItem { filtersInfo: FiltersInfo; originalSize: WidgetSize; originalColumns: number; + widgetExportInfo?: any; } export interface WidgetReference { @@ -139,12 +141,18 @@ export class ItemBufferService { } } } + let widgetExportInfo: any; + const exportDefinition = getWidgetExportDefinition(widget); + if (exportDefinition) { + widgetExportInfo = exportDefinition.prepareExportInfo(dashboard, widget); + } return { widget, aliasesInfo, filtersInfo, originalSize, - originalColumns + originalColumns, + widgetExportInfo }; } @@ -189,6 +197,7 @@ export class ItemBufferService { const filtersInfo = widgetItem.filtersInfo; const originalColumns = widgetItem.originalColumns; const originalSize = widgetItem.originalSize; + const widgetExportInfo = widgetItem.widgetExportInfo; let targetRow = -1; let targetColumn = -1; if (position) { @@ -199,7 +208,7 @@ export class ItemBufferService { return this.addWidgetToDashboard(targetDashboard, targetState, targetLayout, widget, aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, - originalColumns, originalSize, targetRow, targetColumn, breakpoint).pipe( + originalColumns, originalSize, targetRow, targetColumn, breakpoint, widgetExportInfo).pipe( map(() => widget) ); } else { @@ -248,7 +257,8 @@ export class ItemBufferService { originalSize: WidgetSize, row: number, column: number, - breakpoint = 'default'): Observable { + breakpoint = 'default', + widgetExportInfo?: any): Observable { let theDashboard: Dashboard; if (dashboard) { theDashboard = dashboard; @@ -258,26 +268,39 @@ export class ItemBufferService { theDashboard = this.dashboardUtils.validateAndUpdateDashboard(theDashboard); let callAliasUpdateFunction = false; let callFilterUpdateFunction = false; + let newEntityAliases: EntityAliases; + let newFilters: Filters; + const exportDefinition = getWidgetExportDefinition(widget); + if (exportDefinition && widgetExportInfo || aliasesInfo) { + newEntityAliases = deepClone(dashboard.configuration.entityAliases); + } + if (exportDefinition && widgetExportInfo || filtersInfo) { + newFilters = deepClone(dashboard.configuration.filters); + } if (aliasesInfo) { - const newEntityAliases = this.updateAliases(theDashboard, widget, aliasesInfo); - const aliasesUpdated = !isEqual(newEntityAliases, theDashboard.configuration.entityAliases); - if (aliasesUpdated) { - theDashboard.configuration.entityAliases = newEntityAliases; - if (onAliasesUpdateFunction) { - callAliasUpdateFunction = true; - } - } + this.updateAliases(widget, newEntityAliases, aliasesInfo); } if (filtersInfo) { - const newFilters = this.updateFilters(theDashboard, widget, filtersInfo); - const filtersUpdated = !isEqual(newFilters, theDashboard.configuration.filters); - if (filtersUpdated) { - theDashboard.configuration.filters = newFilters; - if (onFiltersUpdateFunction) { - callFilterUpdateFunction = true; - } + this.updateFilters(widget, newFilters, filtersInfo); + } + if (exportDefinition && widgetExportInfo) { + exportDefinition.updateFromExportInfo(widget, newEntityAliases, newFilters, widgetExportInfo); + } + const aliasesUpdated = newEntityAliases && !isEqual(newEntityAliases, theDashboard.configuration.entityAliases); + if (aliasesUpdated) { + theDashboard.configuration.entityAliases = newEntityAliases; + if (onAliasesUpdateFunction) { + callAliasUpdateFunction = true; } } + const filtersUpdated = newFilters && !isEqual(newFilters, theDashboard.configuration.filters); + if (filtersUpdated) { + theDashboard.configuration.filters = newFilters; + if (onFiltersUpdateFunction) { + callFilterUpdateFunction = true; + } + } + this.dashboardUtils.addWidgetToLayout(theDashboard, targetState, targetLayout, widget, originalColumns, originalSize, row, column, breakpoint); if (callAliasUpdateFunction) { @@ -430,14 +453,13 @@ export class ItemBufferService { }; } - private updateAliases(dashboard: Dashboard, widget: Widget, aliasesInfo: AliasesInfo): EntityAliases { - const entityAliases = deepClone(dashboard.configuration.entityAliases); + private updateAliases(widget: Widget, entityAliases: EntityAliases, aliasesInfo: AliasesInfo): void { let aliasInfo: EntityAliasInfo; let newAliasId: string; for (const datasourceIndexStr of Object.keys(aliasesInfo.datasourceAliases)) { const datasourceIndex = Number(datasourceIndexStr); aliasInfo = aliasesInfo.datasourceAliases[datasourceIndex]; - newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); + newAliasId = getEntityAliasId(entityAliases, aliasInfo); if (widget.type === widgetType.alarm) { widget.config.alarmSource.entityAliasId = newAliasId; } else { @@ -446,7 +468,7 @@ export class ItemBufferService { } if (aliasesInfo.targetDeviceAlias) { aliasInfo = aliasesInfo.targetDeviceAlias; - newAliasId = this.getEntityAliasId(entityAliases, aliasInfo); + newAliasId = getEntityAliasId(entityAliases, aliasInfo); if (widget.config.targetDevice?.type !== TargetDeviceType.entity) { widget.config.targetDevice = { type: TargetDeviceType.entity @@ -454,101 +476,21 @@ export class ItemBufferService { } widget.config.targetDevice.entityAliasId = newAliasId; } - return entityAliases; } - private updateFilters(dashboard: Dashboard, widget: Widget, filtersInfo: FiltersInfo): Filters { - const filters = deepClone(dashboard.configuration.filters); + private updateFilters(widget: Widget, filters: Filters, filtersInfo: FiltersInfo): void { let filterInfo: FilterInfo; let newFilterId: string; for (const datasourceIndexStr of Object.keys(filtersInfo.datasourceFilters)) { const datasourceIndex = Number(datasourceIndexStr); filterInfo = filtersInfo.datasourceFilters[datasourceIndex]; - newFilterId = this.getFilterId(filters, filterInfo); + newFilterId = getFilterId(filters, filterInfo); if (widget.type === widgetType.alarm) { widget.config.alarmSource.filterId = newFilterId; } else { widget.config.datasources[datasourceIndex].filterId = newFilterId; } } - return filters; - } - - private isEntityAliasEqual(alias1: EntityAliasInfo, alias2: EntityAliasInfo): boolean { - return isEqual(alias1.filter, alias2.filter); - } - - private getEntityAliasId(entityAliases: EntityAliases, aliasInfo: EntityAliasInfo): string { - let newAliasId: string; - for (const aliasId of Object.keys(entityAliases)) { - if (this.isEntityAliasEqual(entityAliases[aliasId], aliasInfo)) { - newAliasId = aliasId; - break; - } - } - if (!newAliasId) { - const newAliasName = this.createEntityAliasName(entityAliases, aliasInfo.alias); - newAliasId = this.utils.guid(); - entityAliases[newAliasId] = {id: newAliasId, alias: newAliasName, filter: aliasInfo.filter}; - } - return newAliasId; - } - - private createEntityAliasName(entityAliases: EntityAliases, alias: string): string { - let c = 0; - let newAlias = alias; - let unique = false; - while (!unique) { - unique = true; - for (const entAliasId of Object.keys(entityAliases)) { - const entAlias = entityAliases[entAliasId]; - if (newAlias === entAlias.alias) { - c++; - newAlias = alias + c; - unique = false; - } - } - } - return newAlias; - } - - private isFilterEqual(filter1: FilterInfo, filter2: FilterInfo): boolean { - return isEqual(filter1.keyFilters, filter2.keyFilters); - } - - private getFilterId(filters: Filters, filterInfo: FilterInfo): string { - let newFilterId: string; - for (const filterId of Object.keys(filters)) { - if (this.isFilterEqual(filters[filterId], filterInfo)) { - newFilterId = filterId; - break; - } - } - if (!newFilterId) { - const newFilterName = this.createFilterName(filters, filterInfo.filter); - newFilterId = this.utils.guid(); - filters[newFilterId] = {id: newFilterId, filter: newFilterName, - keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; - } - return newFilterId; - } - - private createFilterName(filters: Filters, filter: string): string { - let c = 0; - let newFilter = filter; - let unique = false; - while (!unique) { - unique = true; - for (const entFilterId of Object.keys(filters)) { - const entFilter = filters[entFilterId]; - if (newFilter === entFilter.filter) { - c++; - newFilter = filter + c; - unique = false; - } - } - } - return newFilter; } private storeSet(key: string, elem: any) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index 140e4f770b..62b36b59c6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -20,7 +20,7 @@ import { isJSON, MapDataLayerType, TbCircleData, TbMapDatasource -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import L from 'leaflet'; import { FormattedData } from '@shared/models/widget.models'; import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts index 6e4ec1e485..d5329586be 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/data-layer-utils.ts @@ -18,7 +18,7 @@ import { DataLayerTooltipSettings, DataLayerTooltipTrigger, processTooltipTemplate, TbMapDatasource -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { FormattedData } from '@shared/models/widget.models'; import L from 'leaflet'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts index 7c77056894..3a537ba8e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/latest-map-data-layer.ts @@ -18,7 +18,7 @@ import { DataLayerEditAction, MapDataLayerSettings, TbMapDatasource -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { FormattedData, WidgetActionType } from '@shared/models/widget.models'; import { Observable } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts index ce3f9862de..3ae61642ce 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/map-data-layer.ts @@ -20,7 +20,7 @@ import { MapDataLayerSettings, MapDataLayerType, mapDataSourceSettingsToDatasource, MapStringFunction, MapType, TbMapDatasource -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { createLabelFromPattern, guid, isDefined, @@ -82,6 +82,7 @@ export class DataLayerColorProcessor { private settings: DataLayerColorSettings) {} public setup(): Observable { + this.color = this.settings.color; if (this.settings.type === DataLayerColorType.function) { return parseTbFunction(this.dataLayer.getCtx().http, this.settings.colorFunction, ['data', 'dsData']).pipe( map((parsed) => { @@ -90,7 +91,6 @@ export class DataLayerColorProcessor { }) ); } else { - this.color = this.settings.color; return of(null) } } @@ -99,6 +99,9 @@ export class DataLayerColorProcessor { let color: string; if (this.settings.type === DataLayerColorType.function) { color = safeExecuteTbFunction(this.colorFunction, [data, dsData]); + if (!color) { + color = this.color; + } } else { color = this.color; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index 5e9517b7fc..106c33a530 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -34,7 +34,7 @@ import { MarkerShapeSettings, MarkerType, TbMapDatasource -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import L, { FeatureGroup } from 'leaflet'; import { FormattedData } from '@shared/models/widget.models'; import { forkJoin, Observable, of } from 'rxjs'; @@ -55,7 +55,7 @@ import { createColorMarkerIconElement, createColorMarkerShapeURI, MarkerShape -} from '@home/components/widget/lib/maps/models/marker-shape.models'; +} from '@shared/models/widget/maps/marker-shape.models'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index 5d4f9a6ae3..d2178169dc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -19,7 +19,7 @@ import { isCutPolygon, isJSON, MapDataLayerType, PolygonsDataLayerSettings, TbMapDatasource, TbPolyData, TbPolygonCoordinates, TbPolygonRawCoordinates -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import L from 'leaflet'; import { FormattedData } from '@shared/models/widget.models'; import { TbShapesDataLayer } from '@home/components/widget/lib/maps/data-layer/shapes-data-layer'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts index 39c703b00a..ec6c5aba80 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/shapes-data-layer.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { ShapeDataLayerSettings, TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; +import { ShapeDataLayerSettings, TbMapDatasource } from '@shared/models/widget/maps/map.models'; import L from 'leaflet'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { forkJoin, Observable } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 27d726f15e..368ca42be8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -23,7 +23,7 @@ import { MapDataLayerType, TbMapDatasource, TripsDataLayerSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { forkJoin, Observable } from 'rxjs'; import { FormattedData, WidgetActionType } from '@shared/models/widget.models'; import { map } from 'rxjs/operators'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts index 72ddacf44e..fd03990d26 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/geo-map.ts @@ -25,7 +25,7 @@ import { TbPolygonCoordinates, TbPolygonRawCoordinate, TbPolygonRawCoordinates -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { forkJoin, Observable, of } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts index a063bf786b..219f5d63ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -24,7 +24,7 @@ import { loadImageWithAspect, MapZoomAction, TbCircleData, TbPolygonCoordinate, TbPolygonCoordinates, TbPolygonRawCoordinate, TbPolygonRawCoordinates -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { Observable, of, ReplaySubject, switchMap } from 'rxjs'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index c6f8f446a6..304100828b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -28,7 +28,7 @@ import { MapProvider, OpenStreetMapLayerSettings, TencentMapLayerSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { DeepPartial } from '@shared/models/common'; import { mergeDeep } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index a462a01356..013a6c88a9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -15,14 +15,12 @@ /// import { - AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, OnDestroy, OnInit, - Renderer2, TemplateRef, ViewChild, ViewEncapsulation @@ -36,7 +34,7 @@ import { WidgetContext } from '@home/models/widget-component.models'; import { Observable } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; import { TbMap } from '@home/components/widget/lib/maps/map'; -import { MapSetting } from '@home/components/widget/lib/maps/models/map.models'; +import { MapSetting } from '@shared/models/widget/maps/map.models'; import { WidgetComponent } from '@home/components/widget/widget.component'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; @@ -69,7 +67,6 @@ export class MapWidgetComponent implements OnInit, OnDestroy { constructor(public widgetComponent: WidgetComponent, private imagePipe: ImagePipe, private sanitizer: DomSanitizer, - private renderer: Renderer2, private cd: ChangeDetectorRef) { } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts index 19f1db5838..d25e56bbf5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { defaultMapSettings, MapSetting, MapType } from '@home/components/widget/lib/maps/models/map.models'; +import { defaultMapSettings, MapSetting, MapType } from '@shared/models/widget/maps/map.models'; import { BackgroundSettings, BackgroundType } from '@shared/models/widget-settings.models'; import { mergeDeep } from '@core/utils'; import { WidgetContext } from '@home/models/widget-component.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 6225ff878d..64b02d83bd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -27,7 +27,7 @@ import { TbMapDatasource, TbPolygonCoordinates, TbPolygonRawCoordinates -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { WidgetContext } from '@home/models/widget-component.models'; import { formattedDataArrayFromDatasourceData, @@ -63,7 +63,7 @@ import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { createPlaceItemIcon } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { createPlaceItemIcon } from '@shared/models/widget/maps/marker-shape.models'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; @@ -464,7 +464,7 @@ export abstract class TbMap { } } - private toggleDragMode(e: MouseEvent, button: L.TB.ToolbarButton): void { + private toggleDragMode(_e: MouseEvent, button: L.TB.ToolbarButton): void { if (this.dragMode) { this.disableDragMode(); } else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index 7d165c5a24..a6504ab96e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -25,7 +25,7 @@ import { Output, ViewEncapsulation } from '@angular/core'; -import { TripTimelineSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { TripTimelineSettings } from '@shared/models/widget/maps/map.models'; import { DateFormatProcessor } from '@shared/models/widget-settings.models'; import { interval, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html index 4991d03011..e512e06a32 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.html @@ -63,7 +63,7 @@ [functionArgs]="['data', 'dsData']" [globalVariables]="functionScopeVariables" functionTitle="{{ 'widgets.maps.data-layer.color-function' | translate }}" - helpId="widget/lib/map/color_fn"> + [helpId]="helpId">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts index 17fcaa310a..2ce10c25ed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@shared/models/widget/maps/map.models'; @Component({ selector: 'tb-data-layer-color-settings-panel', @@ -36,6 +36,9 @@ export class DataLayerColorSettingsPanelComponent extends PageComponent implemen @Input() colorSettings: DataLayerColorSettings; + @Input() + helpId = 'widget/lib/map/color_fn'; + @Input() popover: TbPopoverComponent; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts index 4df7290f9d..75cfb9c7bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -19,7 +19,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ComponentStyle } from '@shared/models/widget-settings.models'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { DataLayerColorSettings, DataLayerColorType } from '@home/components/widget/lib/maps/models/map.models'; +import { DataLayerColorSettings, DataLayerColorType } from '@shared/models/widget/maps/map.models'; import { DataLayerColorSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component'; @@ -41,6 +41,9 @@ export class DataLayerColorSettingsComponent implements ControlValueAccessor { @Input() disabled: boolean; + @Input() + helpId = 'widget/lib/map/color_fn'; + DataLayerColorType = DataLayerColorType; modelValue: DataLayerColorSettings; @@ -82,6 +85,7 @@ export class DataLayerColorSettingsComponent implements ControlValueAccessor { } else { const ctx: any = { colorSettings: this.modelValue, + helpId: this.helpId }; const colorSettingsPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, this.viewContainerRef, DataLayerColorSettingsPanelComponent, 'left', true, null, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html index 4286bf1c22..7912c1aeb5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -46,7 +46,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData']" functionTitle="{{ (patternType === 'label' ? 'widgets.maps.data-layer.label-function' : 'widgets.maps.data-layer.tooltip-function') | translate }}" - helpId="{{ patternType === 'label' ? 'widget/lib/map/label_fn' : 'widget/lib/map/tooltip_fn' }}"> + [helpId]="helpId">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index e13e7eec1c..22fc96bcbc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -32,7 +32,7 @@ import { DataLayerPatternSettings, DataLayerPatternType, DataLayerTooltipSettings, dataLayerTooltipTriggers, dataLayerTooltipTriggerTranslationMap -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @@ -71,6 +71,9 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc @Input() patternType: 'label' | 'tooltip' = 'label'; + @Input() + helpId = 'widget/lib/map/label_fn'; + @Input() patternTitle: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts index 7867d41363..5b43386664 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALIDATORS, @@ -26,7 +26,7 @@ import { Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ImageMapSourceSettings, ImageSourceType } from '@home/components/widget/lib/maps/models/map.models'; +import { ImageMapSourceSettings, ImageSourceType } from '@shared/models/widget/maps/map.models'; import { DataKey, DatasourceType, widgetType } from '@shared/models/widget.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts index 9f045d3db5..466c6a39d6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.ts @@ -24,7 +24,7 @@ import { ValidationErrors, Validator } from '@angular/forms'; -import { MapActionButtonSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { MapActionButtonSettings } from '@shared/models/widget/maps/map.models'; import { WidgetAction, WidgetActionType, widgetType } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isEmptyStr } from '@core/utils'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts index cfee949885..0260676e03 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts @@ -26,7 +26,7 @@ import { import { defaultMapActionButtonSettings, MapActionButtonSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 07e0fa43e9..ecabc191e2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -256,7 +256,7 @@ px - +
@@ -334,13 +334,14 @@ px - +
@@ -350,7 +351,7 @@
widgets.maps.data-layer.fill-color
- +
widgets.maps.data-layer.stroke
@@ -359,18 +360,20 @@ px - +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 8e57e891c8..6771bdb226 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -29,7 +29,7 @@ import { MarkerType, pathDecoratorSymbols, pathDecoratorSymbolTranslationMap, PolygonsDataLayerSettings, ShapeDataLayerSettings, TripsDataLayerSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; @@ -94,6 +94,36 @@ export class MapDataLayerDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: MapDataLayerDialogData, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts index 835aed171e..ae47852f33 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-row.component.ts @@ -42,7 +42,7 @@ import { MarkersDataLayerSettings, PolygonsDataLayerSettings, TripsDataLayerSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts index 17e4baa22e..747ef21681 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -35,7 +35,7 @@ import { mapDataLayerValid, mapDataLayerValidator, MapType -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { MapSettingsComponent } from '@home/components/widget/lib/settings/common/map/map-settings.component'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts index d41d6b4df5..a163e8322f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.ts @@ -33,7 +33,7 @@ import { Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { AdditionalMapDataSourceSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { AdditionalMapDataSourceSettings } from '@shared/models/widget/maps/map.models'; import { DataKey, DatasourceType, datasourceTypeTranslationMap, widgetType } from '@shared/models/widget.models'; import { EntityType } from '@shared/models/entity-type.models'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts index 828d11ab8f..681a135fed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts @@ -33,7 +33,7 @@ import { additionalMapDataSourceValid, additionalMapDataSourceValidator, defaultAdditionalMapDataSourceSettings -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts index a4ade55caf..7112a0c3c2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.ts @@ -53,7 +53,7 @@ import { openStreetMapLayerTranslationMap, tencentLayerTranslationMap, tencentLayerTypes -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { deepClone } from '@core/utils'; import { MapLayerSettingsPanelComponent diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts index 60c2c66863..f27ecf2eca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-settings-panel.component.ts @@ -33,7 +33,7 @@ import { openStreetMapLayerTranslationMap, tencentLayerTranslationMap, tencentLayerTypes -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { TranslateService } from '@ngx-translate/core'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts index 41dcb46cc6..60e9f981fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts @@ -35,7 +35,7 @@ import { mapLayerValid, mapLayerValidator, MapProvider -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; @Component({ selector: 'tb-map-layers', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 6f1650b19f..2abaab17f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -38,7 +38,7 @@ import { MapType, mapZoomActions, mapZoomActionTranslationMap -} from '@home/components/widget/lib/maps/models/map.models'; +} from '@shared/models/widget/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { merge, Observable } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; @@ -254,7 +254,6 @@ export class MapSettingsComponent implements OnInit, ControlValueAccessor, Valid private updateDragButtonModeSettings() { const markers: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('markers').value; - const circles: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('circles').value; let dragModeButtonSettingsEnabled = markers.some(d => d.edit && d.edit.enabledActions && d.edit.enabledActions.includes(DataLayerEditAction.move)); if (!dragModeButtonSettingsEnabled) { const polygons: MapDataLayerSettings[] = this.mapSettingsFormGroup.get('polygons').value; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts index 00db2a1fa8..8240b5d6dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts @@ -27,7 +27,7 @@ import { } from '@angular/forms'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MarkerClusteringSettings } from '@home/components/widget/lib/maps/models/map.models'; +import { MarkerClusteringSettings } from '@shared/models/widget/maps/map.models'; import { merge } from 'rxjs'; @Component({ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts index d8e0a2b083..72edca5e81 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts @@ -22,7 +22,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/models/map.models'; +import { MarkerImageSettings, MarkerImageType } from '@shared/models/widget/maps/map.models'; @Component({ selector: 'tb-marker-image-settings-panel', diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts index f7323ca467..21600392df 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts @@ -18,7 +18,7 @@ import { ChangeDetectorRef, Component, forwardRef, Input, Renderer2, ViewContain import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { MarkerImageSettings, MarkerImageType } from '@home/components/widget/lib/maps/models/map.models'; +import { MarkerImageSettings, MarkerImageType } from '@shared/models/widget/maps/map.models'; import { MarkerImageSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html index 59931cce66..0a78698f0f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html @@ -30,5 +30,5 @@
- + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts index 729817681e..98651a397d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts @@ -33,7 +33,7 @@ import { } from '@angular/forms'; import { MatButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; -import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@home/components/widget/lib/maps/models/map.models'; +import { MarkerIconSettings, MarkerShapeSettings, MarkerType } from '@shared/models/widget/maps/map.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Observable } from 'rxjs'; import { DomSanitizer, SafeHtml, SafeUrl } from '@angular/platform-browser'; @@ -41,7 +41,7 @@ import { MatIconRegistry } from '@angular/material/icon'; import { createColorMarkerIconElement, createColorMarkerShapeURI -} from '@home/components/widget/lib/maps/models/marker-shape.models'; +} from '@shared/models/widget/maps/marker-shape.models'; import tinycolor from 'tinycolor2'; import { map, share } from 'rxjs/operators'; import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts index 797e9f6215..0e4d9abb97 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts @@ -23,7 +23,7 @@ import { createColorMarkerShapeURI, MarkerShape, markerShapes, tripMarkerShapes -} from '@home/components/widget/lib/maps/models/marker-shape.models'; +} from '@shared/models/widget/maps/marker-shape.models'; import { Observable } from 'rxjs'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { MatIconRegistry } from '@angular/material/icon'; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts index fd9afb8740..5afeefcbe6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.ts @@ -29,16 +29,8 @@ import { merge } from 'rxjs'; import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { - DataLayerPatternSettings, - DataLayerPatternType, - DataLayerTooltipSettings, - dataLayerTooltipTriggers, - dataLayerTooltipTriggerTranslationMap, - pathDecoratorSymbols, pathDecoratorSymbolTranslationMap, TripTimelineSettings -} from '@home/components/widget/lib/maps/models/map.models'; -import { coerceBoolean } from '@shared/decorators/coercion'; -import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; +} from '@shared/models/widget/maps/map.models'; @Component({ selector: 'tb-trip-timeline-settings', @@ -166,7 +158,4 @@ export class TripTimelineSettingsComponent implements OnInit, ControlValueAccess this.modelValue = this.tripTimelineSettingsFormGroup.getRawValue(); this.propagateChange(this.modelValue); } - - protected readonly pathDecoratorSymbols = pathDecoratorSymbols; - protected readonly pathDecoratorSymbolTranslationMap = pathDecoratorSymbolTranslationMap; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html index 49605f4ced..54f9bf4788 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html @@ -66,7 +66,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -110,7 +110,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-tooltip-function' | translate }}" - helpId="widget/lib/map/polygon_tooltip_fn"> + helpId="widget/lib/map-legacy/polygon_tooltip_fn"> @@ -146,7 +146,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-fill-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> @@ -186,7 +186,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.circle-stroke-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html index e3201cb4c5..21b4f19e8d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.html @@ -82,7 +82,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'childCount']" functionTitle="{{ 'widgets.maps.marker-color-function' | translate }}" - helpId="widget/lib/map/clustering_color_fn"> + helpId="widget/lib/map-legacy/clustering_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html index 99b4f0fa29..4111a22987 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html @@ -35,7 +35,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['origXPos', 'origYPos', 'data', 'dsData', 'dsIndex', 'aspect']" functionTitle="{{ 'widgets.maps.position-function' | translate }}" - helpId="widget/lib/map/position_fn"> + helpId="widget/lib/map-legacy/position_fn"> {{ 'widgets.maps.draggable-marker' | translate }} @@ -68,7 +68,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -112,7 +112,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.tooltip-function' | translate }}" - helpId="widget/lib/map/tooltip_fn"> + helpId="widget/lib/map-legacy/tooltip_fn">
@@ -151,7 +151,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.color-function' | translate }}" - helpId="widget/lib/map/color_fn"> + helpId="widget/lib/map-legacy/color_fn"> @@ -185,7 +185,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'images', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.marker-image-function' | translate }}" - helpId="widget/lib/map/marker_image_fn"> + helpId="widget/lib/map-legacy/marker_image_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -110,7 +110,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-tooltip-function' | translate }}" - helpId="widget/lib/map/polygon_tooltip_fn"> + helpId="widget/lib/map-legacy/polygon_tooltip_fn"> @@ -146,7 +146,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> @@ -186,7 +186,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.polygon-stroke-color-function' | translate }}" - helpId="widget/lib/map/polygon_color_fn"> + helpId="widget/lib/map-legacy/polygon_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html index 1ac03e042a..59393ef944 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.html @@ -85,7 +85,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.tooltip-function' | translate }}" - helpId="widget/lib/map/tooltip_fn"> + helpId="widget/lib/map-legacy/tooltip_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html index b869d12fb8..dfb2dd8ce5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.html @@ -50,7 +50,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.label-function' | translate }}" - helpId="widget/lib/map/label_fn"> + helpId="widget/lib/map-legacy/label_fn"> @@ -84,7 +84,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'images', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.marker-image-function' | translate }}" - helpId="widget/lib/map/marker_image_fn"> + helpId="widget/lib/map-legacy/marker_image_fn"> + helpId="widget/lib/map-legacy/path_color_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html index ea7b2d77ca..d98a88f6f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.html @@ -59,7 +59,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.point-color-function' | translate }}" - helpId="widget/lib/map/path_point_color_fn"> + helpId="widget/lib/map-legacy/path_point_color_fn"> @@ -80,7 +80,7 @@ [globalVariables]="functionScopeVariables" [functionArgs]="['data', 'dsData', 'dsIndex']" functionTitle="{{ 'widgets.maps.point-as-anchor-function' | translate }}" - helpId="widget/lib/map/trip_point_as_anchor_fn"> + helpId="widget/lib/map-legacy/trip_point_as_anchor_fn"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts index aea4ef376c..221f820d68 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts @@ -19,7 +19,7 @@ import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.m import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { isDefinedAndNotNull, mergeDeep, mergeDeepIgnoreArray } from '@core/utils'; +import { isDefinedAndNotNull, mergeDeepIgnoreArray } from '@core/utils'; import { mapWidgetDefaultSettings, MapWidgetSettings } from '@home/components/widget/lib/maps/map-widget.models'; import { WidgetConfigComponentData } from '@home/models/widget-component.models'; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 5212c15fd4..23a8007278 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -307,23 +307,23 @@ export class ImportExportService { } } return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } )); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } ) ); } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } else { return this.addImportedWidget(dashboard, targetState, targetLayoutFunction, widget, - aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize); + aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, originalColumns, originalSize, widgetItem.widgetExportInfo); } } }), @@ -1033,11 +1033,11 @@ export class ImportExportService { filtersInfo: FiltersInfo, onAliasesUpdateFunction: () => void, onFiltersUpdateFunction: () => void, - originalColumns: number, originalSize: WidgetSize): Observable { + originalColumns: number, originalSize: WidgetSize, widgetExportInfo: any): Observable { return targetLayoutFunction().pipe( mergeMap((targetLayout) => this.itembuffer.addWidgetToDashboard(dashboard, targetState, targetLayout, widget, aliasesInfo, filtersInfo, onAliasesUpdateFunction, onFiltersUpdateFunction, - originalColumns, originalSize, -1, -1).pipe( + originalColumns, originalSize, -1, -1, 'default', widgetExportInfo).pipe( map(() => ({widget, layoutId: targetLayout} as ImportWidgetResult)) ) )); diff --git a/ui-ngx/src/app/shared/models/alias.models.ts b/ui-ngx/src/app/shared/models/alias.models.ts index 4005e451af..bc325a0692 100644 --- a/ui-ngx/src/app/shared/models/alias.models.ts +++ b/ui-ngx/src/app/shared/models/alias.models.ts @@ -18,6 +18,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { EntityId } from '@shared/models/id/entity-id'; import { EntitySearchDirection, RelationEntityTypeFilter } from '@shared/models/relation.models'; import { EntityFilter } from '@shared/models/query/query.models'; +import { guid, isEqual } from '@core/utils'; export enum AliasFilterType { singleEntity = 'singleEntity', @@ -210,3 +211,41 @@ export interface EntityAliasFilterResult { entityFilter: EntityFilter; entityParamName?: string; } + +export const getEntityAliasId = (entityAliases: EntityAliases, aliasInfo: EntityAliasInfo): string => { + let newAliasId: string; + for (const aliasId of Object.keys(entityAliases)) { + if (isEntityAliasEqual(entityAliases[aliasId], aliasInfo)) { + newAliasId = aliasId; + break; + } + } + if (!newAliasId) { + const newAliasName = createEntityAliasName(entityAliases, aliasInfo.alias); + newAliasId = guid(); + entityAliases[newAliasId] = {id: newAliasId, alias: newAliasName, filter: aliasInfo.filter}; + } + return newAliasId; +} + +const isEntityAliasEqual = (alias1: EntityAliasInfo, alias2: EntityAliasInfo): boolean => { + return isEqual(alias1.filter, alias2.filter); +} + +const createEntityAliasName = (entityAliases: EntityAliases, alias: string): string => { + let c = 0; + let newAlias = alias; + let unique = false; + while (!unique) { + unique = true; + for (const entAliasId of Object.keys(entityAliases)) { + const entAlias = entityAliases[entAliasId]; + if (newAlias === entAlias.alias) { + c++; + newAlias = alias + c; + unique = false; + } + } + } + return newAlias; +} diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 98c0cce970..65128fe1eb 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -23,6 +23,7 @@ import { EntityType } from '@shared/models/entity-type.models'; import { DataKey, Datasource, DatasourceType } from '@shared/models/widget.models'; import { PageData } from '@shared/models/page/page-data'; import { + guid, isArraysEqualIgnoreUndefined, isDefined, isDefinedAndNotNull, @@ -921,3 +922,42 @@ export function updateDatasourceFromEntityInfo(datasource: Datasource, entity: E } } } + +export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => { + let newFilterId: string; + for (const filterId of Object.keys(filters)) { + if (isFilterEqual(filters[filterId], filterInfo)) { + newFilterId = filterId; + break; + } + } + if (!newFilterId) { + const newFilterName = createFilterName(filters, filterInfo.filter); + newFilterId = guid(); + filters[newFilterId] = {id: newFilterId, filter: newFilterName, + keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; + } + return newFilterId; +} + +const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { + return isEqual(filter1.keyFilters, filter2.keyFilters); +} + +const createFilterName = (filters: Filters, filter: string): string => { + let c = 0; + let newFilter = filter; + let unique = false; + while (!unique) { + unique = true; + for (const entFilterId of Object.keys(filters)) { + const entFilter = filters[entFilterId]; + if (newFilter === entFilter.filter) { + c++; + newFilter = filter + c; + unique = false; + } + } + } + return newFilter; +} diff --git a/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts new file mode 100644 index 0000000000..f7750db731 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/maps/map-export.models.ts @@ -0,0 +1,151 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; +import { FilterInfo, Filters, getFilterId } from '@shared/models/query/query.models'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { DatasourceType, Widget } from '@shared/models/widget.models'; +import { BaseMapSettings, MapDataSourceSettings, MapType } from '@shared/models/widget/maps/map.models'; +import { WidgetExportDefinition } from '@shared/models/widget/widget-export.models'; + +interface ExportDataSourceInfo { + aliases: {[dataLayerIndex: number]: EntityAliasInfo}; + filters: {[dataLayerIndex: number]: FilterInfo}; +} + +interface MapDatasourcesInfo { + trips?: ExportDataSourceInfo; + markers?: ExportDataSourceInfo; + polygons?: ExportDataSourceInfo; + circles?: ExportDataSourceInfo; + additionalDataSources?: ExportDataSourceInfo; +} + +export const MapExportDefinition: WidgetExportDefinition = { + testWidget(widget: Widget): boolean { + if (widget?.config?.settings) { + const settings = widget.config.settings; + if (settings.mapType && [MapType.image, MapType.geoMap].includes(settings.mapType)) { + if (settings.trips && Array.isArray(settings.trips)) { + return true; + } + if (settings.markers && Array.isArray(settings.markers)) { + return true; + } + if (settings.polygons && Array.isArray(settings.polygons)) { + return true; + } + if (settings.circles && Array.isArray(settings.circles)) { + return true; + } + } + } + return false; + }, + prepareExportInfo(dashboard: Dashboard, widget: Widget): MapDatasourcesInfo { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + const info: MapDatasourcesInfo = {}; + if (settings.trips?.length) { + info.trips = prepareExportDataSourcesInfo(dashboard, settings.trips); + } + if (settings.markers?.length) { + info.markers = prepareExportDataSourcesInfo(dashboard, settings.markers); + } + if (settings.polygons?.length) { + info.polygons = prepareExportDataSourcesInfo(dashboard, settings.polygons); + } + if (settings.circles?.length) { + info.circles = prepareExportDataSourcesInfo(dashboard, settings.circles); + } + if (settings.additionalDataSources?.length) { + info.additionalDataSources = prepareExportDataSourcesInfo(dashboard, settings.additionalDataSources); + } + return info; + }, + updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: MapDatasourcesInfo): void { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + if (info?.trips) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.trips, info.trips); + } + if (info?.markers) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.markers, info.markers); + } + if (info?.polygons) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.polygons, info.polygons); + } + if (info?.circles) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.circles, info.circles); + } + if (info?.additionalDataSources) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.additionalDataSources, info.additionalDataSources); + } + } +}; + +const updateMapDatasourceFromExportInfo = (entityAliases: EntityAliases, + filters: Filters, settings: MapDataSourceSettings[], info: ExportDataSourceInfo): void => { + if (info.aliases) { + for (const dsIndexStr of Object.keys(info.aliases)) { + const dsIndex = Number(dsIndexStr); + if (settings[dsIndex] && settings[dsIndex].dsType === DatasourceType.entity) { + const aliasInfo = info.aliases[dsIndex]; + settings[dsIndex].dsEntityAliasId = getEntityAliasId(entityAliases, aliasInfo); + } + } + } + if (info.filters) { + for (const dsIndexStr of Object.keys(info.filters)) { + const dsIndex = Number(dsIndexStr); + if (settings[dsIndex] && settings[dsIndex].dsType === DatasourceType.entity) { + const filterInfo = info.filters[dsIndex]; + settings[dsIndex].dsFilterId = getFilterId(filters, filterInfo); + } + } + } +} + +const prepareExportDataSourcesInfo = (dashboard: Dashboard, settings: MapDataSourceSettings[]): ExportDataSourceInfo => { + const info: ExportDataSourceInfo = { + aliases: {}, + filters: {} + }; + settings.forEach((dsSettings, index) => { + prepareExportDataSourceInfo(dashboard, info, dsSettings, index); + }); + return info; +} + +const prepareExportDataSourceInfo = (dashboard: Dashboard, info: ExportDataSourceInfo, settings: MapDataSourceSettings, index: number): void => { + if (settings.dsType === DatasourceType.entity) { + const entityAlias = dashboard.configuration.entityAliases[settings.dsEntityAliasId]; + if (entityAlias) { + info.aliases[index] = { + alias: entityAlias.alias, + filter: entityAlias.filter + }; + } + if (settings.dsFilterId && dashboard.configuration.filters) { + const filter = dashboard.configuration.filters[settings.dsFilterId]; + if (filter) { + info.filters[index] = { + filter: filter.filter, + keyFilters: filter.keyFilters, + editable: filter.editable + }; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts similarity index 99% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts rename to ui-ngx/src/app/shared/models/widget/maps/map.models.ts index 0f0e64f5b7..8b44534c68 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -39,7 +39,7 @@ import { TbFunction } from '@shared/models/js-function.models'; import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; -import { MarkerShape } from '@home/components/widget/lib/maps/models/marker-shape.models'; +import { MarkerShape } from '@shared/models/widget/maps/marker-shape.models'; import { DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models'; export enum MapType { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts similarity index 98% rename from ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts rename to ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts index fa7007b886..7a4c25d879 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/models/marker-shape.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts @@ -205,7 +205,7 @@ export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domS let placeItemIconURI$: Observable; -export const createPlaceItemIcon= (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer): Observable => { +export const createPlaceItemIcon = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer): Observable => { if (placeItemIconURI$) { return placeItemIconURI$; } diff --git a/ui-ngx/src/app/shared/models/widget/widget-export.models.ts b/ui-ngx/src/app/shared/models/widget/widget-export.models.ts new file mode 100644 index 0000000000..5b47010285 --- /dev/null +++ b/ui-ngx/src/app/shared/models/widget/widget-export.models.ts @@ -0,0 +1,35 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Widget } from '@shared/models/widget.models'; +import { Dashboard } from '@shared/models/dashboard.models'; +import { EntityAliases } from '@shared/models/alias.models'; +import { Filters } from '@shared/models/query/query.models'; +import { MapExportDefinition } from '@shared/models/widget/maps/map-export.models'; + +export interface WidgetExportDefinition { + testWidget(widget: Widget): boolean; + prepareExportInfo(dashboard: Dashboard, widget: Widget): T; + updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: T): void; +} + +const widgetExportDefinitions: WidgetExportDefinition[] = [ + MapExportDefinition +]; + +export const getWidgetExportDefinition = (widget: Widget): WidgetExportDefinition => { + return widgetExportDefinitions.find(def => def.testWidget(widget)); +} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md new file mode 100644 index 0000000000..ba58fa3cd3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/clustering_color_fn.md @@ -0,0 +1,65 @@ +#### Clustering marker function + +
+
+ +*function (data, childCount): string* + +A JavaScript function used to compute clustering marker color. + +**Parameters:** + +
    +
  • data: FormattedData[] + - the array of total markers contained within each cluster.
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • childCount: number - the total number of markers contained within that cluster +
  • +
+ +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
+ +##### Examples + +
    +
  • +Calculate color depending on temperature telemetry value: +
  • + + +```javascript +let customColor; +for (let markerData of data) { + if (markerData.temperature > 40) { + customColor = 'red' + } +} +return customColor ? customColor : 'green'; +{:copy-code} +``` + +
  • +Calculate color depending on childCount: +
  • + +```javascript +if (childCount < 10) { + return 'green'; +} else if (childCount < 100) { + return 'yellow'; +} else { + return 'red'; +} +{:copy-code} +``` + +
+
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md new file mode 100644 index 0000000000..2f461e28a6 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/color_fn.md @@ -0,0 +1,42 @@ +#### Marker color function + +
+
+ +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the marker. + +**Parameters:** + +
    + {% include widget/lib/map-legacy/map_fn_args %} +
+ +**Returns:** + +Should return string value presenting color of the marker. + +In case no data is returned, color value from **Color** settings field will be used. + +
+ +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md new file mode 100644 index 0000000000..adfdcc287c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/label_fn.md @@ -0,0 +1,40 @@ +#### Marker label function + +
+
+ +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code of the marker label. + +**Parameters:** + +
    + {% include widget/lib/map-legacy/map_fn_args %} +
+ +**Returns:** + +Should return string value presenting text or HTML of the marker label. + +
+ +##### Examples + +* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}, ${energy:2} kWt'; + } else if (deviceType == "thermometer") { + return '${entityName}, ${temperature:2} °C'; + } +} +return data.entityName; +{:copy-code} +``` + +
+
diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md new file mode 100644 index 0000000000..7a4f24f0ac --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/map_fn_args.md @@ -0,0 +1,10 @@ +
  • data: FormattedData - A FormattedData object associated with marker or data point of the route.
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • dsData: FormattedData[] - All available data associated with markers or routes data points as array of FormattedData objects
    + resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
    + and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
  • +
  • dsIndex number - index of the current marker data or route data point in dsData array.
    + Note: The data argument is equivalent to dsData[dsIndex] expression. +
  • diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md new file mode 100644 index 0000000000..2ef0f4d645 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/marker_image_fn.md @@ -0,0 +1,62 @@ +#### Marker image function + +
    +
    + +*function (data, images, dsData, dsIndex): {url: string, size: number}* + +A JavaScript function used to compute marker image. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return marker image data having the following structure: + +```typescript +{ + url: string, + size: number +} +``` + +- *url* - marker image url; +- *size* - marker image size; + +In case no data is returned, default marker image will be used. + +
    + +##### Examples + +
      +
    • +Calculate image url depending on temperature telemetry value for thermometer device type.
      +Let's assume 4 images are defined in Marker images section. Each image corresponds to particular temperature level: +
    • +
    + +```javascript +var type = data['Type']; +if (type == 'thermometer') { + var res = { + url: images[0], + size: 40 + } + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120; + var index = Math.min(3, Math.floor(4 * percent)); + res.url = images[index]; + } + return res; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md new file mode 100644 index 0000000000..01d3d09ea7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_color_fn.md @@ -0,0 +1,42 @@ +#### Path color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path. + +In case no data is returned, color value from **Path color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md new file mode 100644 index 0000000000..83a4af5bc4 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/path_point_color_fn.md @@ -0,0 +1,43 @@ +#### Path point color function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute color of the trip path point. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting color of the trip path point. + +In case no data is returned, color value from **Point color** settings field will be used. + +
    + +##### Examples + +* Calculate color depending on `temperature` telemetry value for `colorpin` device type: + +```javascript +var type = data['Type']; +if (type == 'colorpin') { + var temperature = data['temperature']; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
    +
    + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md similarity index 94% rename from ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md rename to ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md index b4cc0c5223..45edf0d2a5 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_color_fn.md @@ -10,7 +10,7 @@ A JavaScript function used to compute color of the polygon. **Parameters:**
      - {% include widget/lib/map/map_fn_args %} + {% include widget/lib/map-legacy/map_fn_args %}
    **Returns:** diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md new file mode 100644 index 0000000000..b583e93228 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/polygon_tooltip_fn.md @@ -0,0 +1,40 @@ +#### Polygon tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the polygon tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `energy meter` or `thermostat` device types: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermostat") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md new file mode 100644 index 0000000000..fa33714fbd --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/position_fn.md @@ -0,0 +1,65 @@ +#### Position conversion function + +
    +
    + +*function (origXPos, origYPos, data, dsData, dsIndex, aspect): {x: number, y: number}* + +A JavaScript function used to convert original relative x, y coordinates of the marker. + +**Parameters:** + +
      +
    • origXPos: number - original relative x coordinate as double from 0 to 1.
    • +
    • origYPos: number - original relative y coordinate as double from 0 to 1.
    • + {% include widget/lib/map-legacy/map_fn_args %} +
    • aspect: number - image map aspect ratio.
    • +
    + +**Returns:** + +Should return position data having the following structure: + +```typescript +{ + x: number, + y: number +} +``` + +- *x* - new relative x coordinate as double from 0 to 1; +- *y* - new relative y coordinate as double from 0 to 1; + +
    + +##### Examples + +* Scale the coordinates to half the original: + +```javascript +return {x: origXPos / 2, y: origYPos / 2}; +{:copy-code} +``` + +* Detect markers with same positions and place them with minimum overlap: + +```javascript +var xPos = data.xPos; +var yPos = data.yPos; +var locationGroup = dsData.filter((item) => item.xPos === xPos && item.yPos === yPos); +if (locationGroup.length > 1) { + const count = locationGroup.length; + const index = locationGroup.indexOf(data); + const radius = 0.035; + const angle = (360 / count) * index - 45; + const x = xPos + radius * Math.sin(angle*Math.PI/180) / aspect; + const y = yPos + radius * Math.cos(angle*Math.PI/180); + return {x: x, y: y}; +} else { + return {x: xPos, y: yPos}; +} +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md new file mode 100644 index 0000000000..8b290c2215 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/tooltip_fn.md @@ -0,0 +1,40 @@ +#### Marker tooltip function + +
    +
    + +*function (data, dsData, dsIndex): string* + +A JavaScript function used to compute text or HTML code to be displayed in the marker, point or polygon tooltip. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +##### Examples + +* Display details with corresponding telemetry data for `thermostat` device type: + +```javascript +var deviceType = data['Type']; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermometer") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md new file mode 100644 index 0000000000..eb11261f3d --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map-legacy/trip_point_as_anchor_fn.md @@ -0,0 +1,34 @@ +#### Point as anchor function + +
    +
    + +*function (data, dsData, dsIndex): boolean* + +A JavaScript function evaluating whether to use trip point as time anchor used in time selector. + +**Parameters:** + +
      + {% include widget/lib/map-legacy/map_fn_args %} +
    + +**Returns:** + +`true` if the point should be decided as anchor, `false` otherwise. + +In case no data is returned, the point is not used as anchor. + +
    + +##### Examples + +* Make anchors with 5 seconds step interval: + +```javascript +return data.time % 5000 < 1000; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md new file mode 100644 index 0000000000..9a7a383876 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_fill_color_fn.md @@ -0,0 +1,24 @@ +#### Circle fill color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute fill color of the circle. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting fill color of the circle. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md new file mode 100644 index 0000000000..1b73148876 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_label_fn.md @@ -0,0 +1,22 @@ +#### Circle label function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code of the circle label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the circle label. + +
    + +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md new file mode 100644 index 0000000000..31d0a0f82a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_stroke_color_fn.md @@ -0,0 +1,24 @@ +#### Circle stroke color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute stroke color of the circle. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting stroke color of the circle. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md new file mode 100644 index 0000000000..1ea037f084 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/circle_tooltip_fn.md @@ -0,0 +1,22 @@ +#### Circle tooltip function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code to be displayed in the circle tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md index ba58fa3cd3..33d4df2f18 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/clustering_color_fn.md @@ -10,9 +10,9 @@ A JavaScript function used to compute clustering marker color. **Parameters:**
      -
    • data: FormattedData[] +
    • data: FormattedData[] - the array of total markers contained within each cluster.
      - Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration.
    • childCount: number - the total number of markers contained within that cluster
    • @@ -22,7 +22,7 @@ A JavaScript function used to compute clustering marker color. Should return string value presenting color of the marker. -In case no data is returned, color value from **Color** settings field will be used. +In case no data is returned, default colors will be used depending on number of markers within that cluster.
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md index ede068d68f..fdecf9596c 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn.md @@ -3,7 +3,7 @@

      -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the marker. @@ -21,22 +21,4 @@ In case no data is returned, color value from **Color** settings field will be u
      -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
      -
      +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md new file mode 100644 index 0000000000..8c3631bde7 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/color_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Calculate color depending on `temperature` telemetry value for `thermostat` device type: + +```javascript +var type = data.Type; +if (type == 'thermostat') { + var temperature = data.temperature; + if (typeof temperature !== undefined) { + var percent = (temperature + 60)/120 * 100; + return tinycolor.mix('blue', 'red', percent).toHexString(); + } + return 'blue'; +} +{:copy-code} +``` + +
      +
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md index eb3e6f981e..0028d77b82 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn.md @@ -3,7 +3,7 @@

      -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute text or HTML code of the marker label. @@ -19,22 +19,4 @@ Should return string value presenting text or HTML of the marker label.
      -##### Examples - -* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}, ${energy:2} kWt'; - } else if (deviceType == "thermometer") { - return '${entityName}, ${temperature:2} °C'; - } -} -return data.entityName; -{:copy-code} -``` - -
      -
      +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md new file mode 100644 index 0000000000..2e2ecfabec --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/label_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Display styled label with corresponding latest telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data.Type; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}, ${energy:2} kWt'; + } else if (deviceType == "thermometer") { + return '${entityName}, ${temperature:2} °C'; + } +} +return data.entityName; +{:copy-code} +``` + +
      +
      diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md index 7a4f24f0ac..a627e1867f 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/map_fn_args.md @@ -1,10 +1,8 @@ -
    • data: FormattedData - A FormattedData object associated with marker or data point of the route.
      - Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in widget datasource configuration. +
    • data: FormattedData object associated with data layer (markers/polygons/circles) or data point of the route (trips data layer).
      + Represents basic entity properties (ex. entityId, entityName)
      and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration.
    • -
    • dsData: FormattedData[] - All available data associated with markers or routes data points as array of FormattedData objects
      +
    • dsData: FormattedData[] - All available data associated with data layers including additional datasources as array of FormattedData objects
      resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
      - and provides access to other entity attributes/timeseries declared in widget datasource configuration. -
    • -
    • dsIndex number - index of the current marker data or route data point in dsData array.
      - Note: The data argument is equivalent to dsData[dsIndex] expression. + and provides access to other entity attributes/timeseries declared in datasources of data layers configuration including additional datasources of the map configuration.
    • + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md index 565de0147a..ed8b82d4cc 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn.md @@ -3,14 +3,14 @@

      -*function (data, images, dsData, dsIndex): {url: string, size: number}* +*function (data, images, dsData): {url: string, size: number}* A JavaScript function used to compute marker image. **Parameters:**
        - {% include widget/lib/map/map_fn_args %} + {% include widget/lib/map/marker_image_fn_args %}
      **Returns:** @@ -18,14 +18,18 @@ A JavaScript function used to compute marker image. Should return marker image data having the following structure: ```typescript -{ - url: string, - size: number +{ + url: string; + size: number; + markerOffset?: [number, number]; + tooltipOffset?: [number, number]; } ``` - *url* - marker image url; - *size* - marker image size; +- *markerOffset* - optional array of two numbers presenting relative horizontal and vertical offset of the marker image; +- *tooltipOffset* - optional array of two numbers presenting relative horizontal and vertical offset of the marker image tooltip; In case no data is returned, default marker image will be used. @@ -41,13 +45,13 @@ Let's assume 4 images are defined in Marker images section. Each image co
    ```javascript -var type = data['Type']; +var type = data.Type; if (type == 'thermometer') { var res = { url: images[0], size: 40 } - var temperature = data['temperature']; + var temperature = data.temperature; if (typeof temperature !== undefined) { var percent = (temperature + 60)/120; var index = Math.min(3, Math.floor(4 * percent)); diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md new file mode 100644 index 0000000000..e86bd3106f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/marker_image_fn_args.md @@ -0,0 +1,10 @@ +
  • data: FormattedData object associated with data layer (markers/polygons/circles) or data point of the route (trips data layer).
    + Represents basic entity properties (ex. entityId, entityName)
    and provides access to other entity attributes/timeseries declared in datasource of the data layer configuration. +
  • +
  • images: string[] - array of image urls configured in the Marker images section. +
  • +
  • dsData: FormattedData[] - All available data associated with data layers including additional datasources as array of FormattedData objects
    + resolved from configured datasources. Each object represents basic entity properties (ex. entityId, entityName)
    + and provides access to other entity attributes/timeseries declared in datasources of data layers configuration including additional datasources of the map configuration. +
  • + diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md index c4df0a99a9..7f90b9c8f7 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_color_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the trip path. @@ -17,26 +17,8 @@ A JavaScript function used to compute color of the trip path. Should return string value presenting color of the trip path. -In case no data is returned, color value from **Path color** settings field will be used. +In case no data is returned, color value from **Color** settings field will be used.
    -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md index de092704c3..be95f136ad 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_color_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute color of the trip path point. @@ -17,27 +17,8 @@ A JavaScript function used to compute color of the trip path point. Should return string value presenting color of the trip path point. -In case no data is returned, color value from **Point color** settings field will be used. +In case no data is returned, color value from **Color** settings field will be used.
    -##### Examples - -* Calculate color depending on `temperature` telemetry value for `colorpin` device type: - -```javascript -var type = data['Type']; -if (type == 'colorpin') { - var temperature = data['temperature']; - if (typeof temperature !== undefined) { - var percent = (temperature + 60)/120 * 100; - return tinycolor.mix('blue', 'red', percent).toHexString(); - } - return 'blue'; -} -{:copy-code} -``` - -
    -
    - +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md new file mode 100644 index 0000000000..4ce996626a --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/path_point_tooltip_fn.md @@ -0,0 +1,22 @@ +#### Path point tooltip function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code to be displayed in the trip path point tooltip. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML for the tooltip. + +
    + +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md new file mode 100644 index 0000000000..ef1ca8c54c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_fill_color_fn.md @@ -0,0 +1,24 @@ +#### Polygon fill color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute fill color of the polygon. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting fill color of the polygon. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md new file mode 100644 index 0000000000..00bc0ee50b --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_label_fn.md @@ -0,0 +1,22 @@ +#### Polygon label function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute text or HTML code of the polygon label. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting text or HTML of the polygon label. + +
    + +{% include widget/lib/map/label_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md new file mode 100644 index 0000000000..4419ab991c --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_stroke_color_fn.md @@ -0,0 +1,24 @@ +#### Polygon stroke color function + +
    +
    + +*function (data, dsData): string* + +A JavaScript function used to compute stroke color of the polygon. + +**Parameters:** + +
      + {% include widget/lib/map/map_fn_args %} +
    + +**Returns:** + +Should return string value presenting stroke color of the polygon. + +In case no data is returned, color value from **Color** settings field will be used. + +
    + +{% include widget/lib/map/color_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md index 86de41c185..9c3a5c8e5e 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/polygon_tooltip_fn.md @@ -3,7 +3,7 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* A JavaScript function used to compute text or HTML code to be displayed in the polygon tooltip. @@ -15,26 +15,8 @@ A JavaScript function used to compute text or HTML code to be displayed in the p **Returns:** -Should return string value presenting text or HTML for the polygon tooltip. +Should return string value presenting text or HTML for the tooltip.
    -##### Examples - -* Display details with corresponding telemetry data for `energy meter` or `thermostat` device types: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}
    Energy: ${energy:2} kWt
    '; - } else if (deviceType == "thermostat") { - return '${entityName}
    Temperature: ${temperature:2} °C
    '; - } -} -return data.entityName; -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md index 5687e4fe84..f85c691cdd 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/position_fn.md @@ -3,7 +3,7 @@

    -*function (origXPos, origYPos, data, dsData, dsIndex, aspect): {x: number, y: number}* +*function (origXPos, origYPos, data, dsData, aspect): {x: number, y: number}* A JavaScript function used to convert original relative x, y coordinates of the marker. @@ -22,8 +22,8 @@ Should return position data having the following structure: ```typescript { - x: number, - y: number + x: number; + y: number; } ``` diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md index d4d97d99b6..61fb00e7fd 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn.md @@ -3,9 +3,9 @@

    -*function (data, dsData, dsIndex): string* +*function (data, dsData): string* -A JavaScript function used to compute text or HTML code to be displayed in the marker, point or polygon tooltip. +A JavaScript function used to compute text or HTML code to be displayed in the marker tooltip. **Parameters:** @@ -19,22 +19,4 @@ Should return string value presenting text or HTML for the tooltip.
    -##### Examples - -* Display details with corresponding telemetry data for `thermostat` device type: - -```javascript -var deviceType = data['Type']; -if (typeof deviceType !== undefined) { - if (deviceType == "energy meter") { - return '${entityName}
    Energy: ${energy:2} kWt
    '; - } else if (deviceType == "thermometer") { - return '${entityName}
    Temperature: ${temperature:2} °C
    '; - } -} -return data.entityName; -{:copy-code} -``` - -
    -
    +{% include widget/lib/map/tooltip_fn_examples %} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md new file mode 100644 index 0000000000..34f8e06e0f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/tooltip_fn_examples.md @@ -0,0 +1,19 @@ +##### Examples + +* Display details with corresponding telemetry data for `energy meter` or `thermometer` device types: + +```javascript +var deviceType = data.Type; +if (typeof deviceType !== undefined) { + if (deviceType == "energy meter") { + return '${entityName}
    Energy: ${energy:2} kWt
    '; + } else if (deviceType == "thermometer") { + return '${entityName}
    Temperature: ${temperature:2} °C
    '; + } +} +return data.entityName; +{:copy-code} +``` + +
    +
    diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md index e938532b78..167fb0a123 100644 --- a/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md +++ b/ui-ngx/src/assets/help/en_US/widget/lib/map/trip_point_as_anchor_fn.md @@ -1,9 +1,9 @@ -#### Point as anchor function +#### Location snap filter function

    -*function (data, dsData, dsIndex): boolean* +*function (data, dsData): boolean* A JavaScript function evaluating whether to use trip point as time anchor used in time selector. diff --git a/ui-ngx/src/typings/leaflet-extend-tb.d.ts b/ui-ngx/src/typings/leaflet-extend-tb.d.ts index de63139bd7..3ba1317436 100644 --- a/ui-ngx/src/typings/leaflet-extend-tb.d.ts +++ b/ui-ngx/src/typings/leaflet-extend-tb.d.ts @@ -15,8 +15,8 @@ /// import { FormattedData } from '@shared/models/widget.models'; -import L, { Control, ControlOptions } from 'leaflet'; -import { TbMapDatasource } from '@home/components/widget/lib/maps/models/map.models'; +import L from 'leaflet'; +import { TbMapDatasource } from '@shared/models/widget/maps/map.models'; import { MatIconRegistry } from '@angular/material/icon'; // redeclare module, maintains compatibility with @types/leaflet From 31cf30382c1ed4a9be3fced636e0a5681d160b30 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Thu, 6 Mar 2025 12:21:57 +0200 Subject: [PATCH 081/127] Handeled deepClone of Observable --- ui-ngx/src/app/core/utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 3096054a67..ca9c17dbb5 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -15,7 +15,7 @@ /// import _ from 'lodash'; -import { from, Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { from, isObservable, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { catchError, finalize, share } from 'rxjs/operators'; import { Datasource, DatasourceData, FormattedData, ReplaceInfo } from '@app/shared/models/widget.models'; import { EntityId } from '@shared/models/id/entity-id'; @@ -331,6 +331,10 @@ export function deepClone(target: T, ignoreFields?: string[]): T { if (target === null) { return target; } + // Observables can't be cloned using the spread operator, because they have non-enumerable methods (like .pipe). + if (isObservable(target)) { + return target; + } if (target instanceof Date) { return new Date(target.getTime()) as any; } From 894f6db5184e39e2350b89c3dadd7dabdd06f57e Mon Sep 17 00:00:00 2001 From: kalytka Date: Thu, 6 Mar 2025 13:46:59 +0200 Subject: [PATCH 082/127] Fix not closing pop-up after click insight dashboard zone --- .../modules/home/components/dashboard/dashboard.component.ts | 2 +- .../home/components/widget/widget-container.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index d25c6b77cf..cc013fe34d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -395,7 +395,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo onDashboardMouseDown($event: MouseEvent) { if (this.callbacks && this.callbacks.onDashboardMouseDown) { - if ($event) { + if ($event && this.isEdit) { $event.stopPropagation(); } this.callbacks.onDashboardMouseDown($event); diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts index 80afff855f..ba59570ff5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts @@ -204,7 +204,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O } onMouseDown(event: MouseEvent) { - if (event) { + if (event && this.isEdit) { event.stopPropagation(); } this.widgetComponentAction.emit({ From 14de7726c2f17773bcb2a9c53719ee9c70f27fb8 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 6 Mar 2025 17:31:00 +0200 Subject: [PATCH 083/127] UI: Fix widget edit tooltip. Map - Improve marker icon appearance. --- ui-ngx/patches/tooltipster+4.2.8.patch | 223 ++++++++++++++++++ .../lib/maps/data-layer/markers-data-layer.ts | 10 +- .../home/components/widget/lib/maps/map.scss | 8 + .../map/map-data-layer-dialog.component.html | 74 +++--- .../map/marker-icon-shapes.component.html | 60 +++++ .../map/marker-icon-shapes.component.scss | 47 ++++ .../map/marker-icon-shapes.component.ts | 127 ++++++++++ .../map/marker-shape-settings.component.ts | 31 ++- .../common/widget-settings-common.module.ts | 4 + .../widget/widget-container.component.ts | 7 +- .../components/material-icons.component.html | 2 +- .../components/material-icons.component.ts | 6 +- .../shared/models/widget/maps/map.models.ts | 10 +- .../models/widget/maps/marker-shape.models.ts | 66 ++++-- .../assets/locale/locale.constant-en_US.json | 2 + ui-ngx/src/assets/markers/iconContainer1.svg | 22 ++ ui-ngx/src/assets/markers/shape1.svg | 4 +- ui-ngx/src/assets/markers/shape2.svg | 4 +- ui-ngx/src/assets/markers/shape6.svg | 7 +- .../src/assets/markers/tripIconContainer1.svg | 12 + 20 files changed, 648 insertions(+), 78 deletions(-) create mode 100644 ui-ngx/patches/tooltipster+4.2.8.patch create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts create mode 100644 ui-ngx/src/assets/markers/iconContainer1.svg create mode 100644 ui-ngx/src/assets/markers/tripIconContainer1.svg diff --git a/ui-ngx/patches/tooltipster+4.2.8.patch b/ui-ngx/patches/tooltipster+4.2.8.patch new file mode 100644 index 0000000000..d328300720 --- /dev/null +++ b/ui-ngx/patches/tooltipster+4.2.8.patch @@ -0,0 +1,223 @@ +diff --git a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js +index 8d94dc7..8123bac 100644 +--- a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js ++++ b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.js +@@ -1,32 +1,34 @@ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["tooltipster"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("tooltipster")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +diff --git a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js +index df77068..84bdfdd 100644 +--- a/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js ++++ b/node_modules/tooltipster/dist/js/plugins/tooltipster/SVG/tooltipster-SVG.min.js +@@ -1 +1 @@ +-!function(a,b){"function"==typeof define&&define.amd?define(["tooltipster"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("tooltipster")):b(jQuery)}(this,function(a){!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){var b="tooltipster.SVG";return a.tooltipster._plugin({name:b,core:{__init:function(){a.tooltipster._on("init",function(c){var d=a.tooltipster._env.window;d.SVGElement&&c.origin instanceof d.SVGElement&&c.instance._plug(b)})}},instance:{__init:function(b){var c=this;if(c.__hadTitleTag=!1,c.__instance=b,!c.__instance._$origin.hasClass("tooltipstered")){var d=c.__instance._$origin.attr("class")||"";-1==d.indexOf("tooltipstered")&&c.__instance._$origin.attr("class",d+" tooltipstered")}if(null===c.__instance.content()){var e=c.__instance._$origin.find(">title");if(e[0]){var f=e.text();c.__hadTitleTag=!0,c.__instance._$origin.data("tooltipster-initialTitle",f),c.__instance.content(f),e.remove()}}c.__instance._on("geometry."+c.namespace,function(b){var c=a.tooltipster._env.window;if(c.SVG.svgjs){c.SVG.parser||c.SVG.prepare();var d=c.SVG.adopt(b.origin);if(d&&d.screenBBox){var e=d.screenBBox();b.edit({height:e.height,left:e.x,top:e.y,width:e.width})}}})._on("destroy."+c.namespace,function(){c.__destroy()})},__destroy:function(){var b=this;if(!b.__instance._$origin.hasClass("tooltipstered")){var c=b.__instance._$origin.attr("class").replace("tooltipstered","");b.__instance._$origin.attr("class",c)}b.__instance._off("."+b.namespace),b.__hadTitleTag&&b.__instance.one("destroyed",function(){var c=b.__instance._$origin.attr("title");c&&(a(document.createElementNS("http://www.w3.org/2000/svg","title")).text(c).appendTo(b.__instance._$origin),b.__instance._$origin.removeAttr("title"))})}}}),a})}); +\ No newline at end of file ++!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["tooltipster"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("tooltipster")):b(a.jQuery)}(this,function(a){!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){var b="tooltipster.SVG";return a.tooltipster._plugin({name:b,core:{__init:function(){a.tooltipster._on("init",function(c){var d=a.tooltipster._env.window;d.SVGElement&&c.origin instanceof d.SVGElement&&c.instance._plug(b)})}},instance:{__init:function(b){var c=this;if(c.__hadTitleTag=!1,c.__instance=b,!c.__instance._$origin.hasClass("tooltipstered")){var d=c.__instance._$origin.attr("class")||"";d.indexOf("tooltipstered")==-1&&c.__instance._$origin.attr("class",d+" tooltipstered")}if(null===c.__instance.content()){var e=c.__instance._$origin.find(">title");if(e[0]){var f=e.text();c.__hadTitleTag=!0,c.__instance._$origin.data("tooltipster-initialTitle",f),c.__instance.content(f),e.remove()}}c.__instance._on("geometry."+c.namespace,function(b){var c=a.tooltipster._env.window;if(c.SVG.svgjs){c.SVG.parser||c.SVG.prepare();var d=c.SVG.adopt(b.origin);if(d&&d.screenBBox){var e=d.screenBBox();b.edit({height:e.height,left:e.x,top:e.y,width:e.width})}}})._on("destroy."+c.namespace,function(){c.__destroy()})},__destroy:function(){var b=this;if(!b.__instance._$origin.hasClass("tooltipstered")){var c=b.__instance._$origin.attr("class").replace("tooltipstered","");b.__instance._$origin.attr("class",c)}b.__instance._off("."+b.namespace),b.__hadTitleTag&&b.__instance.one("destroyed",function(){var c=b.__instance._$origin.attr("title");c&&(a(document.createElementNS("http://www.w3.org/2000/svg","title")).text(c).appendTo(b.__instance._$origin),b.__instance._$origin.removeAttr("title"))})}}}),a})}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/dist/js/tooltipster.bundle.js b/node_modules/tooltipster/dist/js/tooltipster.bundle.js +index ed97bdc..2b3381f 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.bundle.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.bundle.js +@@ -5,18 +5,19 @@ + * MIT license + */ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +@@ -1260,8 +1261,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1412,7 +1419,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { +@@ -3341,6 +3348,7 @@ function transitionSupport() { + // we'll return jQuery for plugins not to have to declare it as a dependency, + // but it's done by a build task since it should be included only once at the + // end when we concatenate the main file with a plugin ++ + // sideTip is Tooltipster's default plugin. + // This file will be UMDified by a build task. + +diff --git a/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js b/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js +index bbbd6ad..af0ffc0 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.bundle.min.js +@@ -1,2 +1,2 @@ +-/*! tooltipster v4.2.8 */!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){return void 0===c[a]||b[a]!==c[a]?(d=!1,!1):void 0}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){return b.name.substring(b.name.length-d.length-1)=="."+d?(e=b,!1):void 0}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),w>C&&(w=C),A="odd"):(C>z&&(z=C,1==B&&(x=z)),x>C&&(x=C),A="even")}k.origin.size.height=z-x,k.origin.size.width=y-w,k.origin.windowOffset.left+=w,k.origin.windowOffset.top+=x}}var D=function(a){k.origin.size.height=a.height,k.origin.windowOffset.left=a.left,k.origin.windowOffset.top=a.top,k.origin.size.width=a.width};for(b._trigger({type:"geometry",edit:D,geometry:{height:k.origin.size.height,left:k.origin.windowOffset.left,top:k.origin.windowOffset.top,width:k.origin.size.width}}),k.origin.windowOffset.right=k.origin.windowOffset.left+k.origin.size.width,k.origin.windowOffset.bottom=k.origin.windowOffset.top+k.origin.size.height,k.origin.offset.left=k.origin.windowOffset.left+k.window.scroll.left,k.origin.offset.top=k.origin.windowOffset.top+k.window.scroll.top,k.origin.offset.bottom=k.origin.offset.top+k.origin.size.height,k.origin.offset.right=k.origin.offset.left+k.origin.size.width,k.available.document={bottom:{height:k.document.size.height-k.origin.offset.bottom,width:k.document.size.width},left:{height:k.document.size.height,width:k.origin.offset.left},right:{height:k.document.size.height,width:k.document.size.width-k.origin.offset.right},top:{height:k.origin.offset.top,width:k.document.size.width}},k.available.window={bottom:{height:Math.max(k.window.size.height-Math.max(k.origin.windowOffset.bottom,0),0),width:k.window.size.width},left:{height:k.window.size.height,width:Math.max(k.origin.windowOffset.left,0)},right:{height:k.window.size.height,width:Math.max(k.window.size.width-Math.max(k.origin.windowOffset.right,0),0)},top:{height:Math.max(k.origin.windowOffset.top,0),width:k.window.size.width}};"html"!=j[0].tagName.toLowerCase();){if("fixed"==j.css("position")){k.origin.fixedLineage=!0;break}j=j.parent()}return k},__optionsFormat:function(){return"number"==typeof this.__options.animationDuration&&(this.__options.animationDuration=[this.__options.animationDuration,this.__options.animationDuration]),"number"==typeof this.__options.delay&&(this.__options.delay=[this.__options.delay,this.__options.delay]),"number"==typeof this.__options.delayTouch&&(this.__options.delayTouch=[this.__options.delayTouch,this.__options.delayTouch]),"string"==typeof this.__options.theme&&(this.__options.theme=[this.__options.theme]),null===this.__options.parent?this.__options.parent=a(h.window.document.body):"string"==typeof this.__options.parent&&(this.__options.parent=a(this.__options.parent)),"hover"==this.__options.trigger?(this.__options.triggerOpen={mouseenter:!0,touchstart:!0},this.__options.triggerClose={mouseleave:!0,originClick:!0,touchleave:!0}):"click"==this.__options.trigger&&(this.__options.triggerOpen={click:!0,tap:!0},this.__options.triggerClose={click:!0,tap:!0}),this._trigger("options"),this},__prepareGC:function(){var b=this;return b.__options.selfDestruction?b.__garbageCollector=setInterval(function(){var c=(new Date).getTime();b.__touchEvents=a.grep(b.__touchEvents,function(a,b){return c-a.time>6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,c){var d=a(c),g=d.css("overflow-x"),h=d.css("overflow-y");if("visible"!=g||"visible"!=h){var i=c.getBoundingClientRect();if("visible"!=g&&(e.origin.windowOffset.lefti.right))return f=!0,!1;if("visible"!=h&&(e.origin.windowOffset.topi.bottom))return f=!0,!1}return"fixed"==d.css("position")?!1:void 0}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);return g!==f||"instance"===b[0]?(d=g,!1):void 0}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();-1!=j.indexOf("msie")?h.IE=parseInt(j.split("msie")[1]):-1!==j.toLowerCase().indexOf("trident")&&-1!==j.indexOf(" rv:11")?h.IE=11:-1!=j.toLowerCase().indexOf("edge/")&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]));var k="tooltipster.sideTip";return a.tooltipster._plugin({name:k,instance:{__defaults:function(){return{arrow:!0,distance:6,functionPosition:null,maxWidth:null,minIntersection:16,minWidth:0,position:null,side:"top",viewportAware:!0}},__init:function(a){var b=this;b.__instance=a,b.__namespace="tooltipster-sideTip-"+Math.round(1e6*Math.random()),b.__previousState="closed",b.__options,b.__optionsFormat(),b.__instance._on("state."+b.__namespace,function(a){"closed"==a.state?b.__close():"appearing"==a.state&&"closed"==b.__previousState&&b.__create(),b.__previousState=a.state}),b.__instance._on("options."+b.__namespace,function(){b.__optionsFormat()}),b.__instance._on("reposition."+b.__namespace,function(a){b.__reposition(a.event,a.helper)})},__close:function(){this.__instance.content()instanceof a&&this.__instance.content().detach(),this.__instance._$tooltip.remove(),this.__instance._$tooltip=null},__create:function(){var b=a('
    ');this.__options.arrow||b.find(".tooltipster-box").css("margin",0).end().find(".tooltipster-arrow").hide(),this.__options.minWidth&&b.css("min-width",this.__options.minWidth+"px"),this.__options.maxWidth&&b.css("max-width",this.__options.maxWidth+"px"), +-this.__instance._$tooltip=b,this.__instance._trigger("created")},__destroy:function(){this.__instance._off("."+self.__namespace)},__optionsFormat:function(){var b=this;if(b.__options=b.__instance._optionsExtract(k,b.__defaults()),b.__options.position&&(b.__options.side=b.__options.position),"object"!=typeof b.__options.distance&&(b.__options.distance=[b.__options.distance]),b.__options.distance.length<4&&(void 0===b.__options.distance[1]&&(b.__options.distance[1]=b.__options.distance[0]),void 0===b.__options.distance[2]&&(b.__options.distance[2]=b.__options.distance[0]),void 0===b.__options.distance[3]&&(b.__options.distance[3]=b.__options.distance[1])),b.__options.distance={top:b.__options.distance[0],right:b.__options.distance[1],bottom:b.__options.distance[2],left:b.__options.distance[3]},"string"==typeof b.__options.side){var c={top:"bottom",right:"left",bottom:"top",left:"right"};b.__options.side=[b.__options.side,c[b.__options.side]],"left"==b.__options.side[0]||"right"==b.__options.side[0]?b.__options.side.push("top","bottom"):b.__options.side.push("right","left")}6===a.tooltipster._env.IE&&b.__options.arrow!==!0&&(b.__options.arrow=!1)},__reposition:function(b,c){var d,e=this,f=e.__targetFind(c),g=[];e.__instance._$tooltip.detach();var h=e.__instance._$tooltip.clone(),i=a.tooltipster._getRuler(h),j=!1,k=e.__instance.option("animation");switch(k&&h.removeClass("tooltipster-"+k),a.each(["window","document"],function(d,k){var l=null;if(e.__instance._trigger({container:k,helper:c,satisfied:j,takeTest:function(a){l=a},results:g,type:"positionTest"}),1==l||0!=l&&0==j&&("window"!=k||e.__options.viewportAware))for(var d=0;d=h.outerSize.width&&c.geo.available[k][n].height>=h.outerSize.height?h.fits=!0:h.fits=!1:h.fits=p.fits,"window"==k&&(h.fits?"top"==n||"bottom"==n?h.whole=c.geo.origin.windowOffset.right>=e.__options.minIntersection&&c.geo.window.size.width-c.geo.origin.windowOffset.left>=e.__options.minIntersection:h.whole=c.geo.origin.windowOffset.bottom>=e.__options.minIntersection&&c.geo.window.size.height-c.geo.origin.windowOffset.top>=e.__options.minIntersection:h.whole=!1),g.push(h),h.whole)j=!0;else if("natural"==h.mode&&(h.fits||h.size.width<=c.geo.available[k][n].width))return!1}})}}),e.__instance._trigger({edit:function(a){g=a},event:b,helper:c,results:g,type:"positionTested"}),g.sort(function(a,b){if(a.whole&&!b.whole)return-1;if(!a.whole&&b.whole)return 1;if(a.whole&&b.whole){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return d>c?-1:c>d?1:"natural"==a.mode?-1:1}if(a.fits&&!b.fits)return-1;if(!a.fits&&b.fits)return 1;if(a.fits&&b.fits){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return d>c?-1:c>d?1:"natural"==a.mode?-1:1}return"document"==a.container&&"bottom"==a.side&&"natural"==a.mode?-1:1}),d=g[0],d.coord={},d.side){case"left":case"right":d.coord.top=Math.floor(d.target-d.size.height/2);break;case"bottom":case"top":d.coord.left=Math.floor(d.target-d.size.width/2)}switch(d.side){case"left":d.coord.left=c.geo.origin.windowOffset.left-d.outerSize.width;break;case"right":d.coord.left=c.geo.origin.windowOffset.right+d.distance.horizontal;break;case"top":d.coord.top=c.geo.origin.windowOffset.top-d.outerSize.height;break;case"bottom":d.coord.top=c.geo.origin.windowOffset.bottom+d.distance.vertical}"window"==d.container?"top"==d.side||"bottom"==d.side?d.coord.left<0?c.geo.origin.windowOffset.right-this.__options.minIntersection>=0?d.coord.left=0:d.coord.left=c.geo.origin.windowOffset.right-this.__options.minIntersection-1:d.coord.left>c.geo.window.size.width-d.size.width&&(c.geo.origin.windowOffset.left+this.__options.minIntersection<=c.geo.window.size.width?d.coord.left=c.geo.window.size.width-d.size.width:d.coord.left=c.geo.origin.windowOffset.left+this.__options.minIntersection+1-d.size.width):d.coord.top<0?c.geo.origin.windowOffset.bottom-this.__options.minIntersection>=0?d.coord.top=0:d.coord.top=c.geo.origin.windowOffset.bottom-this.__options.minIntersection-1:d.coord.top>c.geo.window.size.height-d.size.height&&(c.geo.origin.windowOffset.top+this.__options.minIntersection<=c.geo.window.size.height?d.coord.top=c.geo.window.size.height-d.size.height:d.coord.top=c.geo.origin.windowOffset.top+this.__options.minIntersection+1-d.size.height):(d.coord.left>c.geo.window.size.width-d.size.width&&(d.coord.left=c.geo.window.size.width-d.size.width),d.coord.left<0&&(d.coord.left=0)),e.__sideChange(h,d.side),c.tooltipClone=h[0],c.tooltipParent=e.__instance.option("parent").parent[0],c.mode=d.mode,c.whole=d.whole,c.origin=e.__instance._$origin[0],c.tooltip=e.__instance._$tooltip[0],delete d.container,delete d.fits,delete d.mode,delete d.outerSize,delete d.whole,d.distance=d.distance.horizontal||d.distance.vertical;var l=a.extend(!0,{},d);if(e.__instance._trigger({edit:function(a){d=a},event:b,helper:c,position:l,type:"position"}),e.__options.functionPosition){var m=e.__options.functionPosition.call(e,e.__instance,c,l);m&&(d=m)}i.destroy();var n,o;"top"==d.side||"bottom"==d.side?(n={prop:"left",val:d.target-d.coord.left},o=d.size.width-this.__options.minIntersection):(n={prop:"top",val:d.target-d.coord.top},o=d.size.height-this.__options.minIntersection),n.valo&&(n.val=o);var p;p=c.geo.origin.fixedLineage?c.geo.origin.windowOffset:{left:c.geo.origin.windowOffset.left+c.geo.window.scroll.left,top:c.geo.origin.windowOffset.top+c.geo.window.scroll.top},d.coord={left:p.left+(d.coord.left-c.geo.origin.windowOffset.left),top:p.top+(d.coord.top-c.geo.origin.windowOffset.top)},e.__sideChange(e.__instance._$tooltip,d.side),c.geo.origin.fixedLineage?e.__instance._$tooltip.css("position","fixed"):e.__instance._$tooltip.css("position",""),e.__instance._$tooltip.css({left:d.coord.left,top:d.coord.top,height:d.size.height,width:d.size.width}).find(".tooltipster-arrow").css({left:"",top:""}).css(n.prop,n.val),e.__instance._$tooltip.appendTo(e.__instance.option("parent")),e.__instance._trigger({type:"repositioned",event:b,position:d})},__sideChange:function(a,b){a.removeClass("tooltipster-bottom").removeClass("tooltipster-left").removeClass("tooltipster-right").removeClass("tooltipster-top").addClass("tooltipster-"+b)},__targetFind:function(a){var b={},c=this.__instance._$origin[0].getClientRects();if(c.length>1){var d=this.__instance._$origin.css("opacity");1==d&&(this.__instance._$origin.css("opacity",.99),c=this.__instance._$origin[0].getClientRects(),this.__instance._$origin.css("opacity",1))}if(c.length<2)b.top=Math.floor(a.geo.origin.windowOffset.left+a.geo.origin.size.width/2),b.bottom=b.top,b.left=Math.floor(a.geo.origin.windowOffset.top+a.geo.origin.size.height/2),b.right=b.left;else{var e=c[0];b.top=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil(c.length/2)-1]:c[0],b.right=Math.floor(e.top+(e.bottom-e.top)/2),e=c[c.length-1],b.bottom=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil((c.length+1)/2)-1]:c[c.length-1],b.left=Math.floor(e.top+(e.bottom-e.top)/2)}return b}}}),a}); +\ No newline at end of file ++/*! tooltipster v4.2.8 */!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){if(void 0===c[a]||b[a]!==c[a])return d=!1,!1}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){if(b.name.substring(b.name.length-d.length-1)=="."+d)return e=b,!1}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),Cz&&(z=C,1==B&&(x=z)),C6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,d){var g=a(d),h=g.css("overflow-x"),i=g.css("overflow-y");if("visible"!=h||"visible"!=i){var j=d.getBoundingClientRect();if("visible"!=h&&(e.origin.windowOffset.leftj.right))return f=!0,!1;if("visible"!=i&&(e.origin.windowOffset.topj.bottom)&&(f=!c.__options.checkOverflowY||c.__options.checkOverflowY(e,j)))return!1}if("fixed"==g.css("position"))return!1}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave&&!a.__options.ignoreCloseOnScroll?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);if(g!==f||"instance"===b[0])return d=g,!1}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();j.indexOf("msie")!=-1?h.IE=parseInt(j.split("msie")[1]):j.toLowerCase().indexOf("trident")!==-1&&j.indexOf(" rv:11")!==-1?h.IE=11:j.toLowerCase().indexOf("edge/")!=-1&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]));var k="tooltipster.sideTip";return a.tooltipster._plugin({name:k,instance:{__defaults:function(){return{arrow:!0,distance:6,functionPosition:null,maxWidth:null,minIntersection:16,minWidth:0,position:null,side:"top",viewportAware:!0}},__init:function(a){var b=this;b.__instance=a,b.__namespace="tooltipster-sideTip-"+Math.round(1e6*Math.random()),b.__previousState="closed",b.__options,b.__optionsFormat(),b.__instance._on("state."+b.__namespace,function(a){"closed"==a.state?b.__close():"appearing"==a.state&&"closed"==b.__previousState&&b.__create(),b.__previousState=a.state}),b.__instance._on("options."+b.__namespace,function(){b.__optionsFormat()}),b.__instance._on("reposition."+b.__namespace,function(a){b.__reposition(a.event,a.helper)})},__close:function(){this.__instance.content()instanceof a&&this.__instance.content().detach(),this.__instance._$tooltip.remove(),this.__instance._$tooltip=null},__create:function(){var b=a('
    ');this.__options.arrow||b.find(".tooltipster-box").css("margin",0).end().find(".tooltipster-arrow").hide(), ++this.__options.minWidth&&b.css("min-width",this.__options.minWidth+"px"),this.__options.maxWidth&&b.css("max-width",this.__options.maxWidth+"px"),this.__instance._$tooltip=b,this.__instance._trigger("created")},__destroy:function(){this.__instance._off("."+self.__namespace)},__optionsFormat:function(){var b=this;if(b.__options=b.__instance._optionsExtract(k,b.__defaults()),b.__options.position&&(b.__options.side=b.__options.position),"object"!=typeof b.__options.distance&&(b.__options.distance=[b.__options.distance]),b.__options.distance.length<4&&(void 0===b.__options.distance[1]&&(b.__options.distance[1]=b.__options.distance[0]),void 0===b.__options.distance[2]&&(b.__options.distance[2]=b.__options.distance[0]),void 0===b.__options.distance[3]&&(b.__options.distance[3]=b.__options.distance[1])),b.__options.distance={top:b.__options.distance[0],right:b.__options.distance[1],bottom:b.__options.distance[2],left:b.__options.distance[3]},"string"==typeof b.__options.side){var c={top:"bottom",right:"left",bottom:"top",left:"right"};b.__options.side=[b.__options.side,c[b.__options.side]],"left"==b.__options.side[0]||"right"==b.__options.side[0]?b.__options.side.push("top","bottom"):b.__options.side.push("right","left")}6===a.tooltipster._env.IE&&b.__options.arrow!==!0&&(b.__options.arrow=!1)},__reposition:function(b,c){var d,e=this,f=e.__targetFind(c),g=[];e.__instance._$tooltip.detach();var h=e.__instance._$tooltip.clone(),i=a.tooltipster._getRuler(h),j=!1,k=e.__instance.option("animation");switch(k&&h.removeClass("tooltipster-"+k),a.each(["window","document"],function(d,k){var l=null;if(e.__instance._trigger({container:k,helper:c,satisfied:j,takeTest:function(a){l=a},results:g,type:"positionTest"}),1==l||0!=l&&0==j&&("window"!=k||e.__options.viewportAware))for(var d=0;d=h.outerSize.width&&c.geo.available[k][n].height>=h.outerSize.height?h.fits=!0:h.fits=!1:h.fits=p.fits,"window"==k&&(h.fits?"top"==n||"bottom"==n?h.whole=c.geo.origin.windowOffset.right>=e.__options.minIntersection&&c.geo.window.size.width-c.geo.origin.windowOffset.left>=e.__options.minIntersection:h.whole=c.geo.origin.windowOffset.bottom>=e.__options.minIntersection&&c.geo.window.size.height-c.geo.origin.windowOffset.top>=e.__options.minIntersection:h.whole=!1),g.push(h),h.whole)j=!0;else if("natural"==h.mode&&(h.fits||h.size.width<=c.geo.available[k][n].width))return!1}})}}),e.__instance._trigger({edit:function(a){g=a},event:b,helper:c,results:g,type:"positionTested"}),g.sort(function(a,b){if(a.whole&&!b.whole)return-1;if(!a.whole&&b.whole)return 1;if(a.whole&&b.whole){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return cd?1:"natural"==a.mode?-1:1}if(a.fits&&!b.fits)return-1;if(!a.fits&&b.fits)return 1;if(a.fits&&b.fits){var c=e.__options.side.indexOf(a.side),d=e.__options.side.indexOf(b.side);return cd?1:"natural"==a.mode?-1:1}return"document"==a.container&&"bottom"==a.side&&"natural"==a.mode?-1:1}),d=g[0],d.coord={},d.side){case"left":case"right":d.coord.top=Math.floor(d.target-d.size.height/2);break;case"bottom":case"top":d.coord.left=Math.floor(d.target-d.size.width/2)}switch(d.side){case"left":d.coord.left=c.geo.origin.windowOffset.left-d.outerSize.width;break;case"right":d.coord.left=c.geo.origin.windowOffset.right+d.distance.horizontal;break;case"top":d.coord.top=c.geo.origin.windowOffset.top-d.outerSize.height;break;case"bottom":d.coord.top=c.geo.origin.windowOffset.bottom+d.distance.vertical}"window"==d.container?"top"==d.side||"bottom"==d.side?d.coord.left<0?c.geo.origin.windowOffset.right-this.__options.minIntersection>=0?d.coord.left=0:d.coord.left=c.geo.origin.windowOffset.right-this.__options.minIntersection-1:d.coord.left>c.geo.window.size.width-d.size.width&&(c.geo.origin.windowOffset.left+this.__options.minIntersection<=c.geo.window.size.width?d.coord.left=c.geo.window.size.width-d.size.width:d.coord.left=c.geo.origin.windowOffset.left+this.__options.minIntersection+1-d.size.width):d.coord.top<0?c.geo.origin.windowOffset.bottom-this.__options.minIntersection>=0?d.coord.top=0:d.coord.top=c.geo.origin.windowOffset.bottom-this.__options.minIntersection-1:d.coord.top>c.geo.window.size.height-d.size.height&&(c.geo.origin.windowOffset.top+this.__options.minIntersection<=c.geo.window.size.height?d.coord.top=c.geo.window.size.height-d.size.height:d.coord.top=c.geo.origin.windowOffset.top+this.__options.minIntersection+1-d.size.height):(d.coord.left>c.geo.window.size.width-d.size.width&&(d.coord.left=c.geo.window.size.width-d.size.width),d.coord.left<0&&(d.coord.left=0)),e.__sideChange(h,d.side),c.tooltipClone=h[0],c.tooltipParent=e.__instance.option("parent").parent[0],c.mode=d.mode,c.whole=d.whole,c.origin=e.__instance._$origin[0],c.tooltip=e.__instance._$tooltip[0],delete d.container,delete d.fits,delete d.mode,delete d.outerSize,delete d.whole,d.distance=d.distance.horizontal||d.distance.vertical;var l=a.extend(!0,{},d);if(e.__instance._trigger({edit:function(a){d=a},event:b,helper:c,position:l,type:"position"}),e.__options.functionPosition){var m=e.__options.functionPosition.call(e,e.__instance,c,l);m&&(d=m)}i.destroy();var n,o;"top"==d.side||"bottom"==d.side?(n={prop:"left",val:d.target-d.coord.left},o=d.size.width-this.__options.minIntersection):(n={prop:"top",val:d.target-d.coord.top},o=d.size.height-this.__options.minIntersection),n.valo&&(n.val=o);var p;p=c.geo.origin.fixedLineage?c.geo.origin.windowOffset:{left:c.geo.origin.windowOffset.left+c.geo.window.scroll.left,top:c.geo.origin.windowOffset.top+c.geo.window.scroll.top},d.coord={left:p.left+(d.coord.left-c.geo.origin.windowOffset.left),top:p.top+(d.coord.top-c.geo.origin.windowOffset.top)},e.__sideChange(e.__instance._$tooltip,d.side),c.geo.origin.fixedLineage?e.__instance._$tooltip.css("position","fixed"):e.__instance._$tooltip.css("position",""),e.__instance._$tooltip.css({left:d.coord.left,top:d.coord.top,height:d.size.height,width:d.size.width}).find(".tooltipster-arrow").css({left:"",top:""}).css(n.prop,n.val),e.__instance._$tooltip.appendTo(e.__instance.option("parent")),e.__instance._trigger({type:"repositioned",event:b,position:d})},__sideChange:function(a,b){a.removeClass("tooltipster-bottom").removeClass("tooltipster-left").removeClass("tooltipster-right").removeClass("tooltipster-top").addClass("tooltipster-"+b)},__targetFind:function(a){var b={},c=this.__instance._$origin[0].getClientRects();if(c.length>1){var d=this.__instance._$origin.css("opacity");1==d&&(this.__instance._$origin.css("opacity",.99),c=this.__instance._$origin[0].getClientRects(),this.__instance._$origin.css("opacity",1))}if(c.length<2)b.top=Math.floor(a.geo.origin.windowOffset.left+a.geo.origin.size.width/2),b.bottom=b.top,b.left=Math.floor(a.geo.origin.windowOffset.top+a.geo.origin.size.height/2),b.right=b.left;else{var e=c[0];b.top=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil(c.length/2)-1]:c[0],b.right=Math.floor(e.top+(e.bottom-e.top)/2),e=c[c.length-1],b.bottom=Math.floor(e.left+(e.right-e.left)/2),e=c.length>2?c[Math.ceil((c.length+1)/2)-1]:c[c.length-1],b.left=Math.floor(e.top+(e.bottom-e.top)/2)}return b}}}),a}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/dist/js/tooltipster.main.js b/node_modules/tooltipster/dist/js/tooltipster.main.js +index 34c11d8..44efcd5 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.main.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.main.js +@@ -5,18 +5,19 @@ + * MIT license + */ + (function (root, factory) { ++ if (root === undefined && window !== undefined) root = window; + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define(["jquery"], function (a0) { + return (factory(a0)); + }); +- } else if (typeof exports === 'object') { ++ } else if (typeof module === 'object' && module.exports) { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(require("jquery")); + } else { +- factory(jQuery); ++ factory(root["jQuery"]); + } + }(this, function ($) { + +@@ -1260,8 +1261,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1412,7 +1419,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { +@@ -3340,6 +3347,7 @@ function transitionSupport() { + + // we'll return jQuery for plugins not to have to declare it as a dependency, + // but it's done by a build task since it should be included only once at the +-// end when we concatenate the main file with a pluginreturn $; ++// end when we concatenate the main file with a plugin ++return $; + + })); +diff --git a/node_modules/tooltipster/dist/js/tooltipster.main.min.js b/node_modules/tooltipster/dist/js/tooltipster.main.min.js +index 1221ae9..b2b3ac9 100644 +--- a/node_modules/tooltipster/dist/js/tooltipster.main.min.js ++++ b/node_modules/tooltipster/dist/js/tooltipster.main.min.js +@@ -1 +1 @@ +-/*! tooltipster v4.2.8 */!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof exports?module.exports=b(require("jquery")):b(jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){return void 0===c[a]||b[a]!==c[a]?(d=!1,!1):void 0}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){return b.name.substring(b.name.length-d.length-1)=="."+d?(e=b,!1):void 0}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),w>C&&(w=C),A="odd"):(C>z&&(z=C,1==B&&(x=z)),x>C&&(x=C),A="even")}k.origin.size.height=z-x,k.origin.size.width=y-w,k.origin.windowOffset.left+=w,k.origin.windowOffset.top+=x}}var D=function(a){k.origin.size.height=a.height,k.origin.windowOffset.left=a.left,k.origin.windowOffset.top=a.top,k.origin.size.width=a.width};for(b._trigger({type:"geometry",edit:D,geometry:{height:k.origin.size.height,left:k.origin.windowOffset.left,top:k.origin.windowOffset.top,width:k.origin.size.width}}),k.origin.windowOffset.right=k.origin.windowOffset.left+k.origin.size.width,k.origin.windowOffset.bottom=k.origin.windowOffset.top+k.origin.size.height,k.origin.offset.left=k.origin.windowOffset.left+k.window.scroll.left,k.origin.offset.top=k.origin.windowOffset.top+k.window.scroll.top,k.origin.offset.bottom=k.origin.offset.top+k.origin.size.height,k.origin.offset.right=k.origin.offset.left+k.origin.size.width,k.available.document={bottom:{height:k.document.size.height-k.origin.offset.bottom,width:k.document.size.width},left:{height:k.document.size.height,width:k.origin.offset.left},right:{height:k.document.size.height,width:k.document.size.width-k.origin.offset.right},top:{height:k.origin.offset.top,width:k.document.size.width}},k.available.window={bottom:{height:Math.max(k.window.size.height-Math.max(k.origin.windowOffset.bottom,0),0),width:k.window.size.width},left:{height:k.window.size.height,width:Math.max(k.origin.windowOffset.left,0)},right:{height:k.window.size.height,width:Math.max(k.window.size.width-Math.max(k.origin.windowOffset.right,0),0)},top:{height:Math.max(k.origin.windowOffset.top,0),width:k.window.size.width}};"html"!=j[0].tagName.toLowerCase();){if("fixed"==j.css("position")){k.origin.fixedLineage=!0;break}j=j.parent()}return k},__optionsFormat:function(){return"number"==typeof this.__options.animationDuration&&(this.__options.animationDuration=[this.__options.animationDuration,this.__options.animationDuration]),"number"==typeof this.__options.delay&&(this.__options.delay=[this.__options.delay,this.__options.delay]),"number"==typeof this.__options.delayTouch&&(this.__options.delayTouch=[this.__options.delayTouch,this.__options.delayTouch]),"string"==typeof this.__options.theme&&(this.__options.theme=[this.__options.theme]),null===this.__options.parent?this.__options.parent=a(h.window.document.body):"string"==typeof this.__options.parent&&(this.__options.parent=a(this.__options.parent)),"hover"==this.__options.trigger?(this.__options.triggerOpen={mouseenter:!0,touchstart:!0},this.__options.triggerClose={mouseleave:!0,originClick:!0,touchleave:!0}):"click"==this.__options.trigger&&(this.__options.triggerOpen={click:!0,tap:!0},this.__options.triggerClose={click:!0,tap:!0}),this._trigger("options"),this},__prepareGC:function(){var b=this;return b.__options.selfDestruction?b.__garbageCollector=setInterval(function(){var c=(new Date).getTime();b.__touchEvents=a.grep(b.__touchEvents,function(a,b){return c-a.time>6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,c){var d=a(c),g=d.css("overflow-x"),h=d.css("overflow-y");if("visible"!=g||"visible"!=h){var i=c.getBoundingClientRect();if("visible"!=g&&(e.origin.windowOffset.lefti.right))return f=!0,!1;if("visible"!=h&&(e.origin.windowOffset.topi.bottom))return f=!0,!1}return"fixed"==d.css("position")?!1:void 0}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);return g!==f||"instance"===b[0]?(d=g,!1):void 0}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();-1!=j.indexOf("msie")?h.IE=parseInt(j.split("msie")[1]):-1!==j.toLowerCase().indexOf("trident")&&-1!==j.indexOf(" rv:11")?h.IE=11:-1!=j.toLowerCase().indexOf("edge/")&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1]))}); +\ No newline at end of file ++/*! tooltipster v4.2.8 */!function(a,b){void 0===a&&void 0!==window&&(a=window),"function"==typeof define&&define.amd?define(["jquery"],function(a){return b(a)}):"object"==typeof module&&module.exports?module.exports=b(require("jquery")):b(a.jQuery)}(this,function(a){function b(a){this.$container,this.constraints=null,this.__$tooltip,this.__init(a)}function c(b,c){var d=!0;return a.each(b,function(a,e){if(void 0===c[a]||b[a]!==c[a])return d=!1,!1}),d}function d(b){var c=b.attr("id"),d=c?h.window.document.getElementById(c):null;return d?d===b[0]:a.contains(h.window.document.body,b[0])}function e(){if(!g)return!1;var a=g.document.body||g.document.documentElement,b=a.style,c="transition",d=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof b[c])return!0;c=c.charAt(0).toUpperCase()+c.substr(1);for(var e=0;e0?e=c.__plugins[d]:a.each(c.__plugins,function(a,b){if(b.name.substring(b.name.length-d.length-1)=="."+d)return e=b,!1}),e}if(b.name.indexOf(".")<0)throw new Error("Plugins must be namespaced");return c.__plugins[b.name]=b,b.core&&c.__bridge(b.core,c,b.name),this},_trigger:function(){var a=Array.prototype.slice.apply(arguments);return"string"==typeof a[0]&&(a[0]={type:a[0]}),this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,a),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,a),this},instances:function(b){var c=[],d=b||".tooltipstered";return a(d).each(function(){var b=a(this),d=b.data("tooltipster-ns");d&&a.each(d,function(a,d){c.push(b.data(d))})}),c},instancesLatest:function(){return this.__instancesLatestArr},off:function(){return this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},origins:function(b){var c=b?b+" ":"";return a(c+".tooltipstered").toArray()},setDefaults:function(b){return a.extend(f,b),this},triggerHandler:function(){return this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.tooltipster=new i,a.Tooltipster=function(b,c){this.__callbacks={close:[],open:[]},this.__closingTime,this.__Content,this.__contentBcr,this.__destroyed=!1,this.__$emitterPrivate=a({}),this.__$emitterPublic=a({}),this.__enabled=!0,this.__garbageCollector,this.__Geometry,this.__lastPosition,this.__namespace="tooltipster-"+Math.round(1e6*Math.random()),this.__options,this.__$originParents,this.__pointerIsOverOrigin=!1,this.__previousThemes=[],this.__state="closed",this.__timeouts={close:[],open:null},this.__touchEvents=[],this.__tracker=null,this._$origin,this._$tooltip,this.__init(b,c)},a.Tooltipster.prototype={__init:function(b,c){var d=this;if(d._$origin=a(b),d.__options=a.extend(!0,{},f,c),d.__optionsFormat(),!h.IE||h.IE>=d.__options.IEmin){var e=null;if(void 0===d._$origin.data("tooltipster-initialTitle")&&(e=d._$origin.attr("title"),void 0===e&&(e=null),d._$origin.data("tooltipster-initialTitle",e)),null!==d.__options.content)d.__contentSet(d.__options.content);else{var g,i=d._$origin.attr("data-tooltip-content");i&&(g=a(i)),g&&g[0]?d.__contentSet(g.first()):d.__contentSet(e)}d._$origin.removeAttr("title").addClass("tooltipstered"),d.__prepareOrigin(),d.__prepareGC(),a.each(d.__options.plugins,function(a,b){d._plug(b)}),h.hasTouchCapability&&a(h.window.document.body).on("touchmove."+d.__namespace+"-triggerOpen",function(a){d._touchRecordEvent(a)}),d._on("created",function(){d.__prepareTooltip()})._on("repositioned",function(a){d.__lastPosition=a.position})}else d.__options.disabled=!0},__contentInsert:function(){var a=this,b=a._$tooltip.find(".tooltipster-content"),c=a.__Content,d=function(a){c=a};return a._trigger({type:"format",content:a.__Content,format:d}),a.__options.functionFormat&&(c=a.__options.functionFormat.call(a,a,{origin:a._$origin[0]},a.__Content)),"string"!=typeof c||a.__options.contentAsHTML?b.empty().append(c):b.text(c),a},__contentSet:function(b){return b instanceof a&&this.__options.contentCloning&&(b=b.clone(!0)),this.__Content=b,this._trigger({type:"updated",content:b}),this},__destroyError:function(){throw new Error("This tooltip has been destroyed and cannot execute your method call.")},__geometry:function(){var b=this,c=b._$origin,d=b._$origin.is("area");if(d){var e=b._$origin.parent().attr("name");c=a('img[usemap="#'+e+'"]')}var f=c[0].getBoundingClientRect(),g=a(h.window.document),i=a(h.window),j=c,k={available:{document:null,window:null},document:{size:{height:g.height(),width:g.width()}},window:{scroll:{left:h.window.scrollX||h.window.document.documentElement.scrollLeft,top:h.window.scrollY||h.window.document.documentElement.scrollTop},size:{height:i.height(),width:i.width()}},origin:{fixedLineage:!1,offset:{},size:{height:f.bottom-f.top,width:f.right-f.left},usemapImage:d?c[0]:null,windowOffset:{bottom:f.bottom,left:f.left,right:f.right,top:f.top}}};if(d){var l=b._$origin.attr("shape"),m=b._$origin.attr("coords");if(m&&(m=m.split(","),a.map(m,function(a,b){m[b]=parseInt(a)})),"default"!=l)switch(l){case"circle":var n=m[0],o=m[1],p=m[2],q=o-p,r=n-p;k.origin.size.height=2*p,k.origin.size.width=k.origin.size.height,k.origin.windowOffset.left+=r,k.origin.windowOffset.top+=q;break;case"rect":var s=m[0],t=m[1],u=m[2],v=m[3];k.origin.size.height=v-t,k.origin.size.width=u-s,k.origin.windowOffset.left+=s,k.origin.windowOffset.top+=t;break;case"poly":for(var w=0,x=0,y=0,z=0,A="even",B=0;By&&(y=C,0===B&&(w=y)),Cz&&(z=C,1==B&&(x=z)),C6e4}),d(b._$origin)||b.close(function(){b.destroy()})},2e4):clearInterval(b.__garbageCollector),b},__prepareOrigin:function(){var a=this;if(a._$origin.off("."+a.__namespace+"-triggerOpen"),h.hasTouchCapability&&a._$origin.on("touchstart."+a.__namespace+"-triggerOpen touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen",function(b){a._touchRecordEvent(b)}),a.__options.triggerOpen.click||a.__options.triggerOpen.tap&&h.hasTouchCapability){var b="";a.__options.triggerOpen.click&&(b+="click."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.tap&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&a._open(b)})}if(a.__options.triggerOpen.mouseenter||a.__options.triggerOpen.touchstart&&h.hasTouchCapability){var b="";a.__options.triggerOpen.mouseenter&&(b+="mouseenter."+a.__namespace+"-triggerOpen "),a.__options.triggerOpen.touchstart&&h.hasTouchCapability&&(b+="touchstart."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){!a._touchIsTouchEvent(b)&&a._touchIsEmulatedEvent(b)||(a.__pointerIsOverOrigin=!0,a._openShortly(b))})}if(a.__options.triggerClose.mouseleave||a.__options.triggerClose.touchleave&&h.hasTouchCapability){var b="";a.__options.triggerClose.mouseleave&&(b+="mouseleave."+a.__namespace+"-triggerOpen "),a.__options.triggerClose.touchleave&&h.hasTouchCapability&&(b+="touchend."+a.__namespace+"-triggerOpen touchcancel."+a.__namespace+"-triggerOpen"),a._$origin.on(b,function(b){a._touchIsMeaningfulEvent(b)&&(a.__pointerIsOverOrigin=!1)})}return a},__prepareTooltip:function(){var b=this,c=b.__options.interactive?"auto":"";return b._$tooltip.attr("id",b.__namespace).css({"pointer-events":c,zIndex:b.__options.zIndex}),a.each(b.__previousThemes,function(a,c){b._$tooltip.removeClass(c)}),a.each(b.__options.theme,function(a,c){b._$tooltip.addClass(c)}),b.__previousThemes=a.merge([],b.__options.theme),b},__scrollHandler:function(b){var c=this;if(c.__options.triggerClose.scroll)c._close(b);else if(d(c._$origin)&&d(c._$tooltip)){var e=null;if(b.target===h.window.document)c.__Geometry.origin.fixedLineage||c.__options.repositionOnScroll&&c.reposition(b);else{e=c.__geometry();var f=!1;if("fixed"!=c._$origin.css("position")&&c.__$originParents.each(function(b,d){var g=a(d),h=g.css("overflow-x"),i=g.css("overflow-y");if("visible"!=h||"visible"!=i){var j=d.getBoundingClientRect();if("visible"!=h&&(e.origin.windowOffset.leftj.right))return f=!0,!1;if("visible"!=i&&(e.origin.windowOffset.topj.bottom)&&(f=!c.__options.checkOverflowY||c.__options.checkOverflowY(e,j)))return!1}if("fixed"==g.css("position"))return!1}),f)c._$tooltip.css("visibility","hidden");else if(c._$tooltip.css("visibility","visible"),c.__options.repositionOnScroll)c.reposition(b);else{var g=e.origin.offset.left-c.__Geometry.origin.offset.left,i=e.origin.offset.top-c.__Geometry.origin.offset.top;c._$tooltip.css({left:c.__lastPosition.coord.left+g,top:c.__lastPosition.coord.top+i})}}c._trigger({type:"scroll",event:b,geo:e})}return c},__stateSet:function(a){return this.__state=a,this._trigger({type:"state",state:a}),this},__timeoutsClear:function(){return clearTimeout(this.__timeouts.open),this.__timeouts.open=null,a.each(this.__timeouts.close,function(a,b){clearTimeout(b)}),this.__timeouts.close=[],this},__trackerStart:function(){var a=this,b=a._$tooltip.find(".tooltipster-content");return a.__options.trackTooltip&&(a.__contentBcr=b[0].getBoundingClientRect()),a.__tracker=setInterval(function(){if(d(a._$origin)&&d(a._$tooltip)){if(a.__options.trackOrigin){var e=a.__geometry(),f=!1;c(e.origin.size,a.__Geometry.origin.size)&&(a.__Geometry.origin.fixedLineage?c(e.origin.windowOffset,a.__Geometry.origin.windowOffset)&&(f=!0):c(e.origin.offset,a.__Geometry.origin.offset)&&(f=!0)),f||(a.__options.triggerClose.mouseleave&&!a.__options.ignoreCloseOnScroll?a._close():a.reposition())}if(a.__options.trackTooltip){var g=b[0].getBoundingClientRect();g.height===a.__contentBcr.height&&g.width===a.__contentBcr.width||(a.reposition(),a.__contentBcr=g)}}else a._close()},a.__options.trackerInterval),a},_close:function(b,c,d){var e=this,f=!0;if(e._trigger({type:"close",event:b,stop:function(){f=!1}}),f||d){c&&e.__callbacks.close.push(c),e.__callbacks.open=[],e.__timeoutsClear();var g=function(){a.each(e.__callbacks.close,function(a,c){c.call(e,e,{event:b,origin:e._$origin[0]})}),e.__callbacks.close=[]};if("closed"!=e.__state){var i=!0,j=new Date,k=j.getTime(),l=k+e.__options.animationDuration[1];if("disappearing"==e.__state&&l>e.__closingTime&&e.__options.animationDuration[1]>0&&(i=!1),i){e.__closingTime=l,"disappearing"!=e.__state&&e.__stateSet("disappearing");var m=function(){clearInterval(e.__tracker),e._trigger({type:"closing",event:b}),e._$tooltip.off("."+e.__namespace+"-triggerClose").removeClass("tooltipster-dying"),a(h.window).off("."+e.__namespace+"-triggerClose"),e.__$originParents.each(function(b,c){a(c).off("scroll."+e.__namespace+"-triggerClose")}),e.__$originParents=null,a(h.window.document.body).off("."+e.__namespace+"-triggerClose"),e._$origin.off("."+e.__namespace+"-triggerClose"),e._off("dismissable"),e.__stateSet("closed"),e._trigger({type:"after",event:b}),e.__options.functionAfter&&e.__options.functionAfter.call(e,e,{event:b,origin:e._$origin[0]}),g()};h.hasTransitions?(e._$tooltip.css({"-moz-animation-duration":e.__options.animationDuration[1]+"ms","-ms-animation-duration":e.__options.animationDuration[1]+"ms","-o-animation-duration":e.__options.animationDuration[1]+"ms","-webkit-animation-duration":e.__options.animationDuration[1]+"ms","animation-duration":e.__options.animationDuration[1]+"ms","transition-duration":e.__options.animationDuration[1]+"ms"}),e._$tooltip.clearQueue().removeClass("tooltipster-show").addClass("tooltipster-dying"),e.__options.animationDuration[1]>0&&e._$tooltip.delay(e.__options.animationDuration[1]),e._$tooltip.queue(m)):e._$tooltip.stop().fadeOut(e.__options.animationDuration[1],m)}}else g()}return e},_off:function(){return this.__$emitterPrivate.off.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_on:function(){return this.__$emitterPrivate.on.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_one:function(){return this.__$emitterPrivate.one.apply(this.__$emitterPrivate,Array.prototype.slice.apply(arguments)),this},_open:function(b,c){var e=this;if(!e.__destroying&&d(e._$origin)&&e.__enabled){var f=!0;if("closed"==e.__state&&(e._trigger({type:"before",event:b,stop:function(){f=!1}}),f&&e.__options.functionBefore&&(f=e.__options.functionBefore.call(e,e,{event:b,origin:e._$origin[0]}))),f!==!1&&null!==e.__Content){c&&e.__callbacks.open.push(c),e.__callbacks.close=[],e.__timeoutsClear();var g,i=function(){"stable"!=e.__state&&e.__stateSet("stable"),a.each(e.__callbacks.open,function(a,b){b.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}),e.__callbacks.open=[]};if("closed"!==e.__state)g=0,"disappearing"===e.__state?(e.__stateSet("appearing"),h.hasTransitions?(e._$tooltip.clearQueue().removeClass("tooltipster-dying").addClass("tooltipster-show"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i)):e._$tooltip.stop().fadeIn(i)):"stable"==e.__state&&i();else{if(e.__stateSet("appearing"),g=e.__options.animationDuration[0],e.__contentInsert(),e.reposition(b,!0),h.hasTransitions?(e._$tooltip.addClass("tooltipster-"+e.__options.animation).addClass("tooltipster-initial").css({"-moz-animation-duration":e.__options.animationDuration[0]+"ms","-ms-animation-duration":e.__options.animationDuration[0]+"ms","-o-animation-duration":e.__options.animationDuration[0]+"ms","-webkit-animation-duration":e.__options.animationDuration[0]+"ms","animation-duration":e.__options.animationDuration[0]+"ms","transition-duration":e.__options.animationDuration[0]+"ms"}),setTimeout(function(){"closed"!=e.__state&&(e._$tooltip.addClass("tooltipster-show").removeClass("tooltipster-initial"),e.__options.animationDuration[0]>0&&e._$tooltip.delay(e.__options.animationDuration[0]),e._$tooltip.queue(i))},0)):e._$tooltip.css("display","none").fadeIn(e.__options.animationDuration[0],i),e.__trackerStart(),a(h.window).on("resize."+e.__namespace+"-triggerClose",function(b){var c=a(document.activeElement);(c.is("input")||c.is("textarea"))&&a.contains(e._$tooltip[0],c[0])||e.reposition(b)}).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)}),e.__$originParents=e._$origin.parents(),e.__$originParents.each(function(b,c){a(c).on("scroll."+e.__namespace+"-triggerClose",function(a){e.__scrollHandler(a)})}),e.__options.triggerClose.mouseleave||e.__options.triggerClose.touchleave&&h.hasTouchCapability){e._on("dismissable",function(a){a.dismissable?a.delay?(m=setTimeout(function(){e._close(a.event)},a.delay),e.__timeouts.close.push(m)):e._close(a):clearTimeout(m)});var j=e._$origin,k="",l="",m=null;e.__options.interactive&&(j=j.add(e._$tooltip)),e.__options.triggerClose.mouseleave&&(k+="mouseenter."+e.__namespace+"-triggerClose ",l+="mouseleave."+e.__namespace+"-triggerClose "),e.__options.triggerClose.touchleave&&h.hasTouchCapability&&(k+="touchstart."+e.__namespace+"-triggerClose",l+="touchend."+e.__namespace+"-triggerClose touchcancel."+e.__namespace+"-triggerClose"),j.on(l,function(a){if(e._touchIsTouchEvent(a)||!e._touchIsEmulatedEvent(a)){var b="mouseleave"==a.type?e.__options.delay:e.__options.delayTouch;e._trigger({delay:b[1],dismissable:!0,event:a,type:"dismissable"})}}).on(k,function(a){!e._touchIsTouchEvent(a)&&e._touchIsEmulatedEvent(a)||e._trigger({dismissable:!1,event:a,type:"dismissable"})})}e.__options.triggerClose.originClick&&e._$origin.on("click."+e.__namespace+"-triggerClose",function(a){e._touchIsTouchEvent(a)||e._touchIsEmulatedEvent(a)||e._close(a)}),(e.__options.triggerClose.click||e.__options.triggerClose.tap&&h.hasTouchCapability)&&setTimeout(function(){if("closed"!=e.__state){var b="",c=a(h.window.document.body);e.__options.triggerClose.click&&(b+="click."+e.__namespace+"-triggerClose "),e.__options.triggerClose.tap&&h.hasTouchCapability&&(b+="touchend."+e.__namespace+"-triggerClose"),c.on(b,function(b){e._touchIsMeaningfulEvent(b)&&(e._touchRecordEvent(b),e.__options.interactive&&a.contains(e._$tooltip[0],b.target)||e._close(b))}),e.__options.triggerClose.tap&&h.hasTouchCapability&&c.on("touchstart."+e.__namespace+"-triggerClose",function(a){e._touchRecordEvent(a)})}},0),e._trigger("ready"),e.__options.functionReady&&e.__options.functionReady.call(e,e,{origin:e._$origin[0],tooltip:e._$tooltip[0]})}if(e.__options.timer>0){var m=setTimeout(function(){e._close()},e.__options.timer+g);e.__timeouts.close.push(m)}}}return e},_openShortly:function(a){var b=this,c=!0;if("stable"!=b.__state&&"appearing"!=b.__state&&!b.__timeouts.open&&(b._trigger({type:"start",event:a,stop:function(){c=!1}}),c)){var d=0==a.type.indexOf("touch")?b.__options.delayTouch:b.__options.delay;d[0]?b.__timeouts.open=setTimeout(function(){b.__timeouts.open=null,b.__pointerIsOverOrigin&&b._touchIsMeaningfulEvent(a)?(b._trigger("startend"),b._open(a)):b._trigger("startcancel")},d[0]):(b._trigger("startend"),b._open(a))}return b},_optionsExtract:function(b,c){var d=this,e=a.extend(!0,{},c),f=d.__options[b];return f||(f={},a.each(c,function(a,b){var c=d.__options[a];void 0!==c&&(f[a]=c)})),a.each(e,function(b,c){void 0!==f[b]&&("object"!=typeof c||c instanceof Array||null==c||"object"!=typeof f[b]||f[b]instanceof Array||null==f[b]?e[b]=f[b]:a.extend(e[b],f[b]))}),e},_plug:function(b){var c=a.tooltipster._plugin(b);if(!c)throw new Error('The "'+b+'" plugin is not defined');return c.instance&&a.tooltipster.__bridge(c.instance,this,c.name),this},_touchIsEmulatedEvent:function(a){for(var b=!1,c=(new Date).getTime(),d=this.__touchEvents.length-1;d>=0;d--){var e=this.__touchEvents[d];if(!(c-e.time<500))break;e.target===a.target&&(b=!0)}return b},_touchIsMeaningfulEvent:function(a){return this._touchIsTouchEvent(a)&&!this._touchSwiped(a.target)||!this._touchIsTouchEvent(a)&&!this._touchIsEmulatedEvent(a)},_touchIsTouchEvent:function(a){return 0==a.type.indexOf("touch")},_touchRecordEvent:function(a){return this._touchIsTouchEvent(a)&&(a.time=(new Date).getTime(),this.__touchEvents.push(a)),this},_touchSwiped:function(a){for(var b=!1,c=this.__touchEvents.length-1;c>=0;c--){var d=this.__touchEvents[c];if("touchmove"==d.type){b=!0;break}if("touchstart"==d.type&&a===d.target)break}return b},_trigger:function(){var b=Array.prototype.slice.apply(arguments);return"string"==typeof b[0]&&(b[0]={type:b[0]}),b[0].instance=this,b[0].origin=this._$origin?this._$origin[0]:null,b[0].tooltip=this._$tooltip?this._$tooltip[0]:null,this.__$emitterPrivate.trigger.apply(this.__$emitterPrivate,b),a.tooltipster._trigger.apply(a.tooltipster,b),this.__$emitterPublic.trigger.apply(this.__$emitterPublic,b),this},_unplug:function(b){var c=this;if(c[b]){var d=a.tooltipster._plugin(b);d.instance&&a.each(d.instance,function(a,d){c[a]&&c[a].bridged===c[b]&&delete c[a]}),c[b].__destroy&&c[b].__destroy(),delete c[b]}return c},close:function(a){return this.__destroyed?this.__destroyError():this._close(null,a),this},content:function(a){var b=this;if(void 0===a)return b.__Content;if(b.__destroyed)b.__destroyError();else if(b.__contentSet(a),null!==b.__Content){if("closed"!==b.__state&&(b.__contentInsert(),b.reposition(),b.__options.updateAnimation))if(h.hasTransitions){var c=b.__options.updateAnimation;b._$tooltip.addClass("tooltipster-update-"+c),setTimeout(function(){"closed"!=b.__state&&b._$tooltip.removeClass("tooltipster-update-"+c)},1e3)}else b._$tooltip.fadeTo(200,.5,function(){"closed"!=b.__state&&b._$tooltip.fadeTo(200,1)})}else b._close();return b},destroy:function(){var b=this;if(b.__destroyed)b.__destroyError();else{"closed"!=b.__state?b.option("animationDuration",0)._close(null,null,!0):b.__timeoutsClear(),b._trigger("destroy"),b.__destroyed=!0,b._$origin.removeData(b.__namespace).off("."+b.__namespace+"-triggerOpen"),a(h.window.document.body).off("."+b.__namespace+"-triggerOpen");var c=b._$origin.data("tooltipster-ns");if(c)if(1===c.length){var d=null;"previous"==b.__options.restoration?d=b._$origin.data("tooltipster-initialTitle"):"current"==b.__options.restoration&&(d="string"==typeof b.__Content?b.__Content:a("
    ").append(b.__Content).html()),d&&b._$origin.attr("title",d),b._$origin.removeClass("tooltipstered"),b._$origin.removeData("tooltipster-ns").removeData("tooltipster-initialTitle")}else c=a.grep(c,function(a,c){return a!==b.__namespace}),b._$origin.data("tooltipster-ns",c);b._trigger("destroyed"),b._off(),b.off(),b.__Content=null,b.__$emitterPrivate=null,b.__$emitterPublic=null,b.__options.parent=null,b._$origin=null,b._$tooltip=null,a.tooltipster.__instancesLatestArr=a.grep(a.tooltipster.__instancesLatestArr,function(a,c){return b!==a}),clearInterval(b.__garbageCollector)}return b},disable:function(){return this.__destroyed?(this.__destroyError(),this):(this._close(),this.__enabled=!1,this)},elementOrigin:function(){return this.__destroyed?void this.__destroyError():this._$origin[0]},elementTooltip:function(){return this._$tooltip?this._$tooltip[0]:null},enable:function(){return this.__enabled=!0,this},hide:function(a){return this.close(a)},instance:function(){return this},off:function(){return this.__destroyed||this.__$emitterPublic.off.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},on:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.on.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},one:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.one.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this},open:function(a){return this.__destroyed?this.__destroyError():this._open(null,a),this},option:function(b,c){return void 0===c?this.__options[b]:(this.__destroyed?this.__destroyError():(this.__options[b]=c,this.__optionsFormat(),a.inArray(b,["trigger","triggerClose","triggerOpen"])>=0&&this.__prepareOrigin(),"selfDestruction"===b&&this.__prepareGC()),this)},reposition:function(a,b){var c=this;return c.__destroyed?c.__destroyError():"closed"!=c.__state&&d(c._$origin)&&(b||d(c._$tooltip))&&(b||c._$tooltip.detach(),c.__Geometry=c.__geometry(),c._trigger({type:"reposition",event:a,helper:{geo:c.__Geometry}})),c},show:function(a){return this.open(a)},status:function(){return{destroyed:this.__destroyed,enabled:this.__enabled,open:"closed"!==this.__state,state:this.__state}},triggerHandler:function(){return this.__destroyed?this.__destroyError():this.__$emitterPublic.triggerHandler.apply(this.__$emitterPublic,Array.prototype.slice.apply(arguments)),this}},a.fn.tooltipster=function(){var b=Array.prototype.slice.apply(arguments),c="You are using a single HTML element as content for several tooltips. You probably want to set the contentCloning option to TRUE.";if(0===this.length)return this;if("string"==typeof b[0]){var d="#*$~&";return this.each(function(){var e=a(this).data("tooltipster-ns"),f=e?a(this).data(e[0]):null;if(!f)throw new Error("You called Tooltipster's \""+b[0]+'" method on an uninitialized element');if("function"!=typeof f[b[0]])throw new Error('Unknown method "'+b[0]+'"');this.length>1&&"content"==b[0]&&(b[1]instanceof a||"object"==typeof b[1]&&null!=b[1]&&b[1].tagName)&&!f.__options.contentCloning&&f.__options.debug&&console.log(c);var g=f[b[0]](b[1],b[2]);if(g!==f||"instance"===b[0])return d=g,!1}),"#*$~&"!==d?d:this}a.tooltipster.__instancesLatestArr=[];var e=b[0]&&void 0!==b[0].multiple,g=e&&b[0].multiple||!e&&f.multiple,h=b[0]&&void 0!==b[0].content,i=h&&b[0].content||!h&&f.content,j=b[0]&&void 0!==b[0].contentCloning,k=j&&b[0].contentCloning||!j&&f.contentCloning,l=b[0]&&void 0!==b[0].debug,m=l&&b[0].debug||!l&&f.debug;return this.length>1&&(i instanceof a||"object"==typeof i&&null!=i&&i.tagName)&&!k&&m&&console.log(c),this.each(function(){var c=!1,d=a(this),e=d.data("tooltipster-ns"),f=null;e?g?c=!0:m&&(console.log("Tooltipster: one or more tooltips are already attached to the element below. Ignoring."),console.log(this)):c=!0,c&&(f=new a.Tooltipster(this,b[0]),e||(e=[]),e.push(f.__namespace),d.data("tooltipster-ns",e),d.data(f.__namespace,f),f.__options.functionInit&&f.__options.functionInit.call(f,f,{origin:this}),f._trigger("init")),a.tooltipster.__instancesLatestArr.push(f)}),this},b.prototype={__init:function(b){this.__$tooltip=b,this.__$tooltip.css({left:0,overflow:"hidden",position:"absolute",top:0}).find(".tooltipster-content").css("overflow","auto"),this.$container=a('
    ').append(this.__$tooltip).appendTo(h.window.document.body)},__forceRedraw:function(){var a=this.__$tooltip.parent();this.__$tooltip.detach(),this.__$tooltip.appendTo(a)},constrain:function(a,b){return this.constraints={width:a,height:b},this.__$tooltip.css({display:"block",height:"",overflow:"auto",width:a}),this},destroy:function(){this.__$tooltip.detach().find(".tooltipster-content").css({display:"",overflow:""}),this.$container.remove()},free:function(){return this.constraints=null,this.__$tooltip.css({display:"",height:"",overflow:"visible",width:""}),this},measure:function(){this.__forceRedraw();var a=this.__$tooltip[0].getBoundingClientRect(),b={size:{height:a.height||a.bottom-a.top,width:a.width||a.right-a.left}};if(this.constraints){var c=this.__$tooltip.find(".tooltipster-content"),d=this.__$tooltip.outerHeight(),e=c[0].getBoundingClientRect(),f={height:d<=this.constraints.height,width:a.width<=this.constraints.width&&e.width>=c[0].scrollWidth-1};b.fits=f.height&&f.width}return h.IE&&h.IE<=11&&b.size.width!==h.window.document.documentElement.clientWidth&&(b.size.width=Math.ceil(b.size.width)+1),b}};var j=navigator.userAgent.toLowerCase();return j.indexOf("msie")!=-1?h.IE=parseInt(j.split("msie")[1]):j.toLowerCase().indexOf("trident")!==-1&&j.indexOf(" rv:11")!==-1?h.IE=11:j.toLowerCase().indexOf("edge/")!=-1&&(h.IE=parseInt(j.toLowerCase().split("edge/")[1])),a}); +\ No newline at end of file +diff --git a/node_modules/tooltipster/src/js/tooltipster.js b/node_modules/tooltipster/src/js/tooltipster.js +index bcf5abd..d22ed9e 100644 +--- a/node_modules/tooltipster/src/js/tooltipster.js ++++ b/node_modules/tooltipster/src/js/tooltipster.js +@@ -1238,8 +1238,14 @@ $.Tooltipster.prototype = { + if ( geo.origin.windowOffset.top < bcr.top + || geo.origin.windowOffset.bottom > bcr.bottom + ) { +- overflows = true; +- return false; ++ if (self.__options.checkOverflowY) { ++ overflows = self.__options.checkOverflowY(geo, bcr); ++ } else { ++ overflows = true; ++ } ++ if (overflows) { ++ return false; ++ } + } + } + } +@@ -1390,7 +1396,7 @@ $.Tooltipster.prototype = { + + // close the tooltip when using the mouseleave close trigger + // (see https://github.com/iamceege/tooltipster/pull/253) +- if (self.__options.triggerClose.mouseleave) { ++ if (self.__options.triggerClose.mouseleave && !self.__options.ignoreCloseOnScroll) { + self._close(); + } + else { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts index 106c33a530..f835c0578b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/markers-data-layer.ts @@ -53,7 +53,7 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { TbMap } from '@home/components/widget/lib/maps/map'; import { createColorMarkerIconElement, - createColorMarkerShapeURI, + createColorMarkerShapeURI, MarkerIconContainer, MarkerShape } from '@shared/models/widget/maps/marker-shape.models'; import { MatIconRegistry } from '@angular/material/icon'; @@ -168,8 +168,10 @@ export class MarkerDataProcessor { - return createColorMarkerIconElement(this.dataLayer.getCtx().$injector.get(MatIconRegistry), this.dataLayer.getCtx().$injector.get(DomSanitizer), icon, color, this.trip).pipe( + public createColoredMarkerIcon(iconContainer: MarkerIconContainer, + icon: string, color: tinycolor.Instance, rotationAngle = 0, size = 34): Observable { + return createColorMarkerIconElement(this.dataLayer.getCtx().$injector.get(MatIconRegistry), this.dataLayer.getCtx().$injector.get(DomSanitizer), + iconContainer, icon, color, this.trip).pipe( map((element) => { if (rotationAngle !== 0) { element.style.transform = `rotate(${rotationAngle}deg)`; @@ -289,7 +291,7 @@ class IconMarkerIconProcessor extends BaseColorMarkerShapeProcessor { - return this.dataProcessor.createColoredMarkerIcon(this.settings.icon, color, rotationAngle, size); + return this.dataProcessor.createColoredMarkerIcon(this.settings.iconContainer, this.settings.icon, color, rotationAngle, size); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index fc60862be4..592610f8b2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -15,6 +15,14 @@ */ @import '../../../../../../../scss/constants'; +div.tb-widget .tb-widget-content.tb-no-interaction { + .tb-map { + .leaflet-interactive, .leaflet-control { + pointer-events: none; + } + } +} + .tb-map-container, .tb-map-layout { position: relative; display: flex; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index ecabc191e2..7cf5ba9a7f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -224,6 +224,10 @@
    + @if (dataLayerType === 'trips') { + + + }
    widgets.maps.data-layer.marker.position-conversion
    @@ -244,7 +248,7 @@ - {{ 'widgets.maps.data-layer.path.path' | translate }} +
    {{ 'widgets.maps.data-layer.path.path' | translate }}
    @@ -322,7 +326,7 @@ - {{ 'widgets.maps.data-layer.points.points' | translate }} +
    {{ 'widgets.maps.data-layer.points.points' | translate }}
    @@ -364,32 +368,13 @@
    - - - - - -
    -
    widgets.maps.data-layer.behavior
    -
    -
    widgets.maps.data-layer.on-click
    - - -
    + @if (dataLayerType !== 'trips') { + + }
    + @if (dataLayerType !== 'trips') { + + }
    {{ 'widgets.maps.data-layer.groups' | translate }}
    + + + + + + + + + + + + +
    +
    widgets.maps.data-layer.behavior
    +
    +
    widgets.maps.data-layer.on-click
    + + +
    +
    +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html new file mode 100644 index 0000000000..a7e62694df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html @@ -0,0 +1,60 @@ + +
    +
    widgets.maps.data-layer.marker.marker-icon
    + +
    widgets.maps.data-layer.marker.marker-appearance
    +
    + + @if (containerInfo.iconContainer === iconContainer) { + + } @else { + + } + +
    +
    + + + +
    +
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss new file mode 100644 index 0000000000..9022db881d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.scss @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-marker-icon-shapes-panel { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + .tb-marker-icon-shapes-title { + font-size: 16px; + font-weight: 500; + line-height: 24px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + } + button.mat-mdc-button-base.tb-select-shape-button { + width: 42px; + min-width: 42px; + height: 42px; + padding: 4px; + div.tb-marker-shape { + width: 34px; + height: 34px; + object-fit: contain; + } + } + .tb-marker-icon-shapes-panel-buttons { + height: 40px; + display: flex; + flex-direction: row; + gap: 16px; + justify-content: flex-end; + align-items: flex-end; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts new file mode 100644 index 0000000000..b4cfa7ae00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts @@ -0,0 +1,127 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + createColorMarkerIconElement, + MarkerIconContainer, markerIconContainers, + tripMarkerIconContainers +} from '@shared/models/widget/maps/marker-shape.models'; +import { Observable } from 'rxjs'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { MatIconRegistry } from '@angular/material/icon'; +import tinycolor from 'tinycolor2'; +import { map, share } from 'rxjs/operators'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +export interface MarkerIconInfo { + iconContainer?: MarkerIconContainer; + icon: string; +} + +interface MarkerIconContainerInfo { + iconContainer: MarkerIconContainer; + html$: Observable; +} + +@Component({ + selector: 'tb-marker-icon-shapes', + templateUrl: './marker-icon-shapes.component.html', + providers: [], + styleUrls: ['./marker-icon-shapes.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class MarkerIconShapesComponent extends PageComponent implements OnInit { + + @Input() + icon: string; + + @Input() + iconContainer: MarkerIconContainer; + + @Input() + color: string; + + @Input() + @coerceBoolean() + trip = false; + + @Input() + popover: TbPopoverComponent; + + @Output() + markerIconSelected = new EventEmitter(); + + dirty = false; + + iconContainers: MarkerIconContainerInfo[]; + + constructor(protected store: Store, + private iconRegistry: MatIconRegistry, + private domSanitizer: DomSanitizer) { + super(store); + } + + ngOnInit(): void { + this.updateIconContainers(); + } + + cancel() { + this.popover?.hide(); + } + + selectIcon(icon: string) { + if (this.icon !== icon) { + this.icon = icon; + this.dirty = true; + this.updateIconContainers(); + } + } + + selectIconContainer(iconContainer: MarkerIconContainer) { + if (this.iconContainer !== iconContainer) { + this.iconContainer = iconContainer; + this.dirty = true; + } + } + + apply() { + const iconInfo: MarkerIconInfo = { + iconContainer: this.iconContainer, + icon: this.icon + }; + this.markerIconSelected.emit(iconInfo); + } + + private updateIconContainers() { + const containersList = [...(this.trip ? tripMarkerIconContainers : markerIconContainers),null]; + this.iconContainers = containersList.map((iconContainer) => { + return { + iconContainer, + html$: createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, iconContainer, this.icon, tinycolor(this.color), this.trip).pipe( + map((element) => { + return this.domSanitizer.bypassSecurityTrustHtml(element.outerHTML); + }), + share() + ) + }; + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts index 98651a397d..ef6a85ac8c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts @@ -45,8 +45,10 @@ import { import tinycolor from 'tinycolor2'; import { map, share } from 'rxjs/operators'; import { MarkerShapesComponent } from '@home/components/widget/lib/settings/common/map/marker-shapes.component'; -import { MaterialIconsComponent } from '@shared/components/material-icons.component'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { + MarkerIconShapesComponent +} from '@home/components/widget/lib/settings/common/map/marker-icon-shapes.component'; @Component({ selector: 'tb-marker-shape-settings', @@ -89,7 +91,6 @@ export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnIni private iconRegistry: MatIconRegistry, private domSanitizer: DomSanitizer, private renderer: Renderer2, - private cd: ChangeDetectorRef, private viewContainerRef: ViewContainerRef) {} ngOnInit(): void { @@ -101,6 +102,7 @@ export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnIni this.shapeSettingsFormGroup.addControl('shape', this.fb.control(null, [Validators.required])); } if (this.markerType === MarkerType.icon) { + this.shapeSettingsFormGroup.addControl('iconContainer', this.fb.control(null, [])); this.shapeSettingsFormGroup.addControl('icon', this.fb.control(null, [Validators.required])); } this.shapeSettingsFormGroup.valueChanges.pipe( @@ -162,20 +164,26 @@ export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnIni }); } else if (this.markerType === MarkerType.icon) { const ctx: any = { - selectedIcon: (this.modelValue as MarkerIconSettings).icon, - iconClearButton: false + iconContainer: (this.modelValue as MarkerIconSettings).iconContainer, + icon: (this.modelValue as MarkerIconSettings).icon, + color: this.modelValue.color.color, + trip: this.trip }; - const materialIconsPopover = this.popoverService.displayPopover(trigger, this.renderer, - this.viewContainerRef, MaterialIconsComponent, 'left', true, null, + const markerIconShapesPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MarkerIconShapesComponent, 'left', true, null, ctx, {}, {}, {}, true); - materialIconsPopover.tbComponentRef.instance.popover = materialIconsPopover; - materialIconsPopover.tbComponentRef.instance.iconSelected.subscribe((icon) => { - materialIconsPopover.hide(); + markerIconShapesPopover.tbComponentRef.instance.popover = markerIconShapesPopover; + markerIconShapesPopover.tbComponentRef.instance.markerIconSelected.subscribe((iconInfo) => { + markerIconShapesPopover.hide(); + this.shapeSettingsFormGroup.get('iconContainer').patchValue( + iconInfo.iconContainer, {emitEvent: false} + ); this.shapeSettingsFormGroup.get('icon').patchValue( - icon + iconInfo.icon, {emitEvent: false} ); + this.updateModel(); }); } } @@ -198,8 +206,9 @@ export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnIni share() ); } else if (this.markerType === MarkerType.icon) { + const iconContainer = (this.modelValue as MarkerIconSettings).iconContainer; const icon = (this.modelValue as MarkerIconSettings).icon; - this.iconPreview$ = createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, icon, tinycolor(color), this.trip).pipe( + this.iconPreview$ = createColorMarkerIconElement(this.iconRegistry, this.domSanitizer, iconContainer, icon, tinycolor(color), this.trip).pipe( map((element) => { return this.domSanitizer.bypassSecurityTrustHtml(element.outerHTML); }), diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index ef7dca9a11..045ce9a801 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -245,6 +245,9 @@ import { import { TripTimelineSettingsComponent } from '@home/components/widget/lib/settings/common/map/trip-timeline-settings.component'; +import { + MarkerIconShapesComponent +} from '@home/components/widget/lib/settings/common/map/marker-icon-shapes.component'; @NgModule({ declarations: [ @@ -327,6 +330,7 @@ import { DataLayerPatternSettingsComponent, MarkerShapeSettingsComponent, MarkerShapesComponent, + MarkerIconShapesComponent, MarkerImageSettingsComponent, MarkerImageSettingsPanelComponent, MarkerClusteringSettingsComponent, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts index 80afff855f..47a0936b90 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts @@ -45,6 +45,7 @@ import { UtilsService } from '@core/services/utils.service'; import { from } from 'rxjs'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; +import ITooltipsterGeoHelper = JQueryTooltipster.ITooltipsterGeoHelper; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; export enum WidgetComponentActionType { @@ -124,7 +125,6 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O widgetComponentAction: EventEmitter = new EventEmitter(); hovered = false; - isReferenceWidget = false; get widgetEditActionsEnabled(): boolean { return (this.isEditActionEnabled || this.isRemoveActionEnabled || this.isExportActionEnabled) && !this.widget?.isFullscreen; @@ -295,6 +295,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O theme: ['tb-widget-edit-actions-tooltip'], interactive: true, trigger: 'custom', + ignoreCloseOnScroll: true, triggerOpen: { mouseenter: true }, @@ -305,6 +306,9 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O trackOrigin: true, trackerInterval: 25, content: '', + checkOverflowY: (geo: ITooltipsterGeoHelper, bcr: DOMRect) => { + return geo.origin.windowOffset.top < bcr.top || geo.origin.windowOffset.bottom < bcr.bottom; + }, functionPosition: (instance, helper, position) => { const clientRect = helper.origin.getBoundingClientRect(); const container = parent.getBoundingClientRect(); @@ -314,6 +318,7 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O return position; }, functionReady: (_instance, helper) => { + this.editWidgetActionsTooltip.__scrollHandler({}); const tooltipEl = $(helper.tooltip); tooltipEl.on('mouseenter', () => { this.hovered = true; diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index 1df91fc9be..d4d9fe93a6 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -16,7 +16,7 @@ -->
    -
    icon.icons
    +
    icon.icons
    search diff --git a/ui-ngx/src/app/shared/components/material-icons.component.ts b/ui-ngx/src/app/shared/components/material-icons.component.ts index 22360affa7..ece1a99108 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -58,7 +58,11 @@ export class MaterialIconsComponent extends PageComponent implements OnInit { iconClearButton = false; @Input() - popover: TbPopoverComponent; + @coerceBoolean() + showTitle = true; + + @Input() + popover: TbPopoverComponent; @Output() iconSelected = new EventEmitter(); diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index 8b44534c68..b7755c00d5 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -28,18 +28,19 @@ import { hashCode, isDefinedAndNotNull, isNotEmptyStr, + isNumber, isString, isUndefinedOrNull, mergeDeep } from '@core/utils'; import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; import { materialColors } from '@shared/models/material.models'; -import L from 'leaflet'; +import type L from 'leaflet'; import { TbFunction } from '@shared/models/js-function.models'; import { Observable, Observer, of, switchMap } from 'rxjs'; import { map } from 'rxjs/operators'; import { ImagePipe } from '@shared/pipe/image.pipe'; -import { MarkerShape } from '@shared/models/widget/maps/marker-shape.models'; +import { MarkerIconContainer, MarkerShape } from '@shared/models/widget/maps/marker-shape.models'; import { DateFormatSettings, simpleDateFormat } from '@shared/models/widget-settings.models'; export enum MapType { @@ -262,6 +263,7 @@ export interface MarkerShapeSettings extends BaseMarkerShapeSettings { } export interface MarkerIconSettings extends BaseMarkerShapeSettings { + iconContainer?: MarkerIconContainer; icon: string; } export interface MarkerClusteringSettings { @@ -352,6 +354,7 @@ export const defaultBaseMarkersDataLayerSettings = (mapType: MapType): Partial isValidLatitude(latitude) && isValidLongitude(longitude); export const isCutPolygon = (data: TbPolygonCoordinates | TbPolygonRawCoordinates): boolean => { - return data.length > 1 && Array.isArray(data[0]) && (Array.isArray(data[0][0]) || data[0][0] instanceof L.LatLng); + return data.length > 1 && Array.isArray(data[0]) && (Array.isArray(data[0][0]) || (isNumber((data[0][0] as any).lat) && isNumber((data[0][0] as any).lng)) ); } export const parseCenterPosition = (position: string | [number, number]): [number, number] => { diff --git a/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts index 7a4c25d879..76a8af733f 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/marker-shape.models.ts @@ -20,7 +20,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { Observable, of, shareReplay, switchMap } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; -import { Element, G, Text } from '@svgdotjs/svg.js'; +import { Element, G, SVG, Text } from '@svgdotjs/svg.js'; export enum MarkerShape { markerShape1 = 'markerShape1', @@ -45,6 +45,11 @@ export enum MarkerShape { tripMarkerShape10 = 'tripMarkerShape10' } +export enum MarkerIconContainer { + iconContainer1 = 'iconContainer1', + tripIconContainer1 = 'tripIconContainer1' +} + export const markerShapeMap = new Map( [ [MarkerShape.markerShape1, '/assets/markers/shape1.svg'], @@ -70,6 +75,13 @@ export const markerShapeMap = new Map( ] ); +export const markerIconContainerMap = new Map( + [ + [MarkerIconContainer.iconContainer1, '/assets/markers/iconContainer1.svg'], + [MarkerIconContainer.tripIconContainer1, '/assets/markers/tripIconContainer1.svg'] + ] +); + export const markerShapes = [ MarkerShape.markerShape1, MarkerShape.markerShape2, @@ -96,9 +108,11 @@ export const tripMarkerShapes = [ MarkerShape.tripMarkerShape10 ]; -const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { - const markerAssetUrl = markerShapeMap.get(shape); - const safeUrl = domSanitizer.bypassSecurityTrustResourceUrl(markerAssetUrl); +export const markerIconContainers = [ MarkerIconContainer.iconContainer1 ]; +export const tripMarkerIconContainers = [ MarkerIconContainer.tripIconContainer1 ]; + +const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, assetUrl: string, color: tinycolor.Instance): Observable => { + const safeUrl = domSanitizer.bypassSecurityTrustResourceUrl(assetUrl); return iconRegistry.getSvgIconFromUrl(safeUrl).pipe( map((svgElement) => { const colorElements = Array.from(svgElement.getElementsByClassName('marker-color')); @@ -124,7 +138,8 @@ const createColorMarkerShape = (iconRegistry: MatIconRegistry, domSanitizer: Dom export const createColorMarkerShapeURI = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, shape: MarkerShape, color: tinycolor.Instance): Observable => { - return createColorMarkerShape(iconRegistry, domSanitizer, shape, color).pipe( + const assetUrl = markerShapeMap.get(shape); + return createColorMarkerShape(iconRegistry, domSanitizer, assetUrl, color).pipe( map((svgElement) => { const svg = svgElement.outerHTML; return 'data:image/svg+xml;base64,' + btoa(svg); @@ -174,29 +189,38 @@ const createIconElement = (iconRegistry: MatIconRegistry, icon: string, size: nu } } -const markerIconShape = MarkerShape.markerShape6; -const tripMarkerIconShape = MarkerShape.tripMarkerShape2; - -export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, icon: string, color: tinycolor.Instance, +export const createColorMarkerIconElement = (iconRegistry: MatIconRegistry, domSanitizer: DomSanitizer, + iconContainer: MarkerIconContainer, icon: string, color: tinycolor.Instance, trip = false): Observable => { - return createColorMarkerShape(iconRegistry, domSanitizer, trip ? tripMarkerIconShape : markerIconShape, color).pipe( + const markerShape$: Observable = iconContainer ? + createColorMarkerShape(iconRegistry, domSanitizer, markerIconContainerMap.get(iconContainer), color) : of(null); + return markerShape$.pipe( switchMap((svgElement) => { - return createIconElement(iconRegistry, icon, trip ? 24 : 12, trip ? tinycolor('#fff') : color, trip).pipe( + const iconSize = svgElement ? (trip ? 24 : 12) : 24; + const iconColor = svgElement ? (trip ? tinycolor('#fff') : color) : color; + return createIconElement(iconRegistry, icon, iconSize, iconColor, trip || !svgElement).pipe( map((iconElement) => { - let elements = svgElement.getElementsByClassName('marker-icon-container'); - if (iconElement && elements.length) { - const iconContainer = new G(elements[0] as SVGGElement); - iconContainer.clear(); + if (svgElement) { + const elements = svgElement.getElementsByClassName('marker-icon-container'); + if (iconElement && elements.length) { + const iconContainer = new G(elements[0] as SVGGElement); + iconContainer.clear(); + iconContainer.add(iconElement); + const box = iconElement.bbox(); + iconElement.translate(-box.cx, -box.cy); + } + return svgElement; + } else { + const svg = SVG(); + svg.viewbox(0,0,iconSize,iconSize); + const iconContainer = new G(); + iconContainer.translate(iconSize/2,iconSize/2); iconContainer.add(iconElement); const box = iconElement.bbox(); iconElement.translate(-box.cx, -box.cy); + svg.add(iconContainer); + return svg.node; } - elements = svgElement.getElementsByClassName('marker-icon-background'); - if (elements.length) { - (elements[0] as SVGGElement).style.display = ''; - (elements[0] as SVGGElement).setAttribute('fill-opacity', `${color.getAlpha()}`); - } - return svgElement; }) ); }) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 4c336831fa..48272ada9e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -7869,6 +7869,8 @@ "icon": "Icon", "image": "Image", "marker-shapes": "Marker shapes", + "marker-icon": "Marker icon", + "marker-appearance": "Marker appearance", "marker-image": "Marker image", "marker-image-type-image": "Image", "marker-image-type-function": "Function", diff --git a/ui-ngx/src/assets/markers/iconContainer1.svg b/ui-ngx/src/assets/markers/iconContainer1.svg new file mode 100644 index 0000000000..bf92e4d90f --- /dev/null +++ b/ui-ngx/src/assets/markers/iconContainer1.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/markers/shape1.svg b/ui-ngx/src/assets/markers/shape1.svg index fafd5638e7..0a29bb7dbe 100644 --- a/ui-ngx/src/assets/markers/shape1.svg +++ b/ui-ngx/src/assets/markers/shape1.svg @@ -1,11 +1,11 @@ - + - + diff --git a/ui-ngx/src/assets/markers/shape2.svg b/ui-ngx/src/assets/markers/shape2.svg index 6777c1810e..1c59215800 100644 --- a/ui-ngx/src/assets/markers/shape2.svg +++ b/ui-ngx/src/assets/markers/shape2.svg @@ -1,8 +1,8 @@ - + - + diff --git a/ui-ngx/src/assets/markers/shape6.svg b/ui-ngx/src/assets/markers/shape6.svg index f4de6d140d..19002cec71 100644 --- a/ui-ngx/src/assets/markers/shape6.svg +++ b/ui-ngx/src/assets/markers/shape6.svg @@ -1,15 +1,12 @@ - + - + - - - diff --git a/ui-ngx/src/assets/markers/tripIconContainer1.svg b/ui-ngx/src/assets/markers/tripIconContainer1.svg new file mode 100644 index 0000000000..df33957809 --- /dev/null +++ b/ui-ngx/src/assets/markers/tripIconContainer1.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + From ac03bf27a38a92fd59087189717d8240514c6f99 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Thu, 6 Mar 2025 19:08:39 +0200 Subject: [PATCH 084/127] UI: Map - add show marker option for trips. --- .../lib/maps/data-layer/trips-data-layer.ts | 84 ++++++----- .../home/components/widget/lib/maps/map.ts | 38 ++--- .../map/map-data-layer-dialog.component.html | 119 +++++++++------- .../map/map-data-layer-dialog.component.ts | 131 +++++++++++------- .../shared/models/widget/maps/map.models.ts | 2 + 5 files changed, 217 insertions(+), 157 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 368ca42be8..1bc1e8a395 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -149,37 +149,41 @@ class TbTripDataItem extends TbDataLayerItem true); - updateTooltip(this.dataLayer.getMap(), this.markerTooltip, - this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); - } - const clickAction = this.settings.click; - if (clickAction && clickAction.type !== WidgetActionType.doNothing) { - this.marker.on('click', (event) => { - this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.pointData); + if (this.settings.showMarker) { + const dsData = this.dataLayer.getMap().getData(); + const location = this.dataLayer.dataProcessor.extractLocation(this.pointData, dsData); + this.marker = L.marker(location, { + tbMarkerData: this.pointData, + snapIgnore: true }); + this.marker.addTo(this.layer); + this.updateMarkerIcon(this.pointData, dsData); + if (this.settings.tooltip?.show) { + this.markerTooltip = createTooltip(this.dataLayer.getMap(), + this.marker, this.settings.tooltip, this.pointData, () => true); + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + const clickAction = this.settings.click; + if (clickAction && clickAction.type !== WidgetActionType.doNothing) { + this.marker.on('click', (event) => { + this.dataLayer.getMap().dataItemClick(event.originalEvent, clickAction, this.pointData); + }); + } } } private updateMarker() { - const dsData = this.dataLayer.getMap().getData(); - this.marker.options.tbMarkerData = this.pointData; - this.updateMarkerLocation(this.pointData, dsData); - if (this.settings.tooltip.show) { - updateTooltip(this.dataLayer.getMap(), this.markerTooltip, - this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + if (this.settings.showMarker) { + const dsData = this.dataLayer.getMap().getData(); + this.marker.options.tbMarkerData = this.pointData; + this.updateMarkerLocation(this.pointData, dsData); + if (this.settings.tooltip.show) { + updateTooltip(this.dataLayer.getMap(), this.markerTooltip, + this.settings.tooltip, this.dataLayer.dataLayerTooltipProcessor, this.pointData, dsData); + } + this.updateMarkerIcon(this.pointData, dsData); } - this.updateMarkerIcon(this.pointData, dsData); } private createPath() { @@ -262,19 +266,21 @@ class TbTripDataItem extends TbDataLayerItem, dsData: FormattedData[]) { - this.dataLayer.dataProcessor.createMarkerIcon(data, dsData, data.rotationAngle).subscribe( - (iconInfo) => { - const options = deepClone(iconInfo.icon.options); - this.marker.setIcon(iconInfo.icon); - const anchor = options.iconAnchor; - if (anchor && Array.isArray(anchor)) { - this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; - } else { - this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + if (this.settings.showMarker) { + this.dataLayer.dataProcessor.createMarkerIcon(data, dsData, data.rotationAngle).subscribe( + (iconInfo) => { + const options = deepClone(iconInfo.icon.options); + this.marker.setIcon(iconInfo.icon); + const anchor = options.iconAnchor; + if (anchor && Array.isArray(anchor)) { + this.labelOffset = [iconInfo.size[0] / 2 - anchor[0], 10 - anchor[1]]; + } else { + this.labelOffset = [0, -iconInfo.size[1] * this.dataLayer.markerOffset[1] + 10]; + } + this.updateMarkerLabel(data, dsData); } - this.updateMarkerLabel(data, dsData); - } - ); + ); + } } private updateMarkerLabel(data: FormattedData, dsData: FormattedData[]) { @@ -384,6 +390,10 @@ export class TbTripsDataLayer extends TbMapDataLayer[][], tripsLatestData: FormattedData[]): {minTime: number; maxTime: number} { let minTime = Infinity; let maxTime = -Infinity; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 64b02d83bd..7fc4cf5e54 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -148,23 +148,6 @@ export abstract class TbMap { this.mapLayoutElement = mapLayoutElement[0]; $(containerElement).append(mapLayoutElement); - if (this.settings.tripTimeline?.showTimelineControl) { - this.timeline = true; - this.timeStep = this.settings.tripTimeline.timeStep; - this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); - this.timeLineComponent = this.timeLineComponentRef.instance; - this.timeLineComponent.settings = this.settings.tripTimeline; - this.timeLineComponent.timeChanged.subscribe((time) => { - this.currentTime = time; - this.updateTripsTime(); - }); - const parentElement = this.timeLineComponentRef.instance.element.nativeElement; - const content = parentElement.firstChild; - parentElement.removeChild(content); - parentElement.style.display = 'none'; - containerElement.append(content); - } - const mapElement = $('
    '); mapLayoutElement.append(mapElement); @@ -380,6 +363,27 @@ export abstract class TbMap { } private setupEditMode() { + + const tripsWithMarkers = this.tripDataLayers.filter(dl => dl.showMarker()); + const showTimeline = this.settings.tripTimeline?.showTimelineControl && tripsWithMarkers.length; + + if (showTimeline) { + this.timeline = true; + this.timeStep = this.settings.tripTimeline.timeStep; + this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); + this.timeLineComponent = this.timeLineComponentRef.instance; + this.timeLineComponent.settings = this.settings.tripTimeline; + this.timeLineComponent.timeChanged.subscribe((time) => { + this.currentTime = time; + this.updateTripsTime(); + }); + const parentElement = this.timeLineComponentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + this.containerElement.append(content); + } + this.editToolbar = L.TB.bottomToolbar({ mapElement: $(this.mapElement), closeTitle: this.ctx.translate.instant('action.cancel'), diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 7cf5ba9a7f..8e634e178b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -176,58 +176,73 @@
    {{ 'widget-config.appearance' | translate }}
    -
    -
    -
    {{ 'widgets.maps.data-layer.marker.marker' | translate }}
    - - {{ 'widgets.maps.data-layer.marker.marker-type-shape' | translate }} - {{ 'widgets.maps.data-layer.marker.marker-type-icon' | translate }} - {{ 'widgets.maps.data-layer.marker.marker-type-image' | translate }} - -
    -
    -
    widgets.maps.data-layer.marker.shape
    - -
    -
    -
    widgets.maps.data-layer.marker.icon
    - -
    -
    -
    widgets.maps.data-layer.marker.image
    - -
    -
    -
    widgets.maps.data-layer.marker.marker-offset
    -
    -
    widgets.maps.data-layer.marker.offset-horizontal
    - - - -
    widgets.maps.data-layer.marker.offset-vertical
    - - - -
    -
    -
    - - {{ 'widgets.maps.data-layer.marker.rotate-marker' | translate }} - -
    -
    widgets.maps.data-layer.marker.offset-angle
    - - -
    deg
    -
    -
    -
    - @if (dataLayerType === 'trips') { - - - } +
    + + + +
    + @if (dataLayerType === 'markers') { +
    {{ 'widgets.maps.data-layer.marker.marker' | translate }}
    + } @else { + +
    {{ 'widgets.maps.data-layer.marker.marker' | translate }}
    +
    + } + + {{ 'widgets.maps.data-layer.marker.marker-type-shape' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-icon' | translate }} + {{ 'widgets.maps.data-layer.marker.marker-type-image' | translate }} + +
    +
    +
    + +
    +
    widgets.maps.data-layer.marker.shape
    + +
    +
    +
    widgets.maps.data-layer.marker.icon
    + +
    +
    +
    widgets.maps.data-layer.marker.image
    + +
    +
    +
    widgets.maps.data-layer.marker.marker-offset
    +
    +
    widgets.maps.data-layer.marker.offset-horizontal
    + + + +
    widgets.maps.data-layer.marker.offset-vertical
    + + + +
    +
    +
    + + {{ 'widgets.maps.data-layer.marker.rotate-marker' | translate }} + +
    +
    widgets.maps.data-layer.marker.offset-angle
    + + +
    deg
    +
    +
    +
    + @if (dataLayerType === 'trips') { + + + } +
    +
    widgets.maps.data-layer.marker.position-conversion
    diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index 6771bdb226..b52e7b9013 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -168,6 +168,7 @@ export class MapDataLayerDialogComponent extends DialogComponent => mergeDeep( defaultBaseMarkersDataLayerSettings(mapType), { + showMarker: true, tooltip: { offsetY: -0.5, pattern: mapType === MapType.geoMap ? From e4a7046e24a30dab5f8b382d2f06561c51ff54bb Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 7 Mar 2025 10:04:35 +0200 Subject: [PATCH 085/127] Save time series strategies: fix broken tests for device state service --- .../server/service/state/DefaultDeviceStateService.java | 8 +++----- .../service/state/DefaultDeviceStateServiceTest.java | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index a57debf012..cc476d377d 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -661,16 +661,14 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService activityTimeseries = Futures.getDone(timeseriesActivityDataFuture); Optional inactivityTimeoutAttribute = Futures.getDone(inactivityTimeoutAttributeFuture); - List result; if (inactivityTimeoutAttribute.isPresent()) { - result = new ArrayList<>(activityTimeseries.size() + 1); + List result = new ArrayList<>(activityTimeseries.size() + 1); result.addAll(activityTimeseries); - inactivityTimeoutAttribute.ifPresent(result::add); + result.add(inactivityTimeoutAttribute.get()); + return result; } else { return activityTimeseries; } - - return result; }, deviceStateCallbackExecutor); future = Futures.transform(fullActivityDataFuture, extractDeviceStateData(device), MoreExecutors.directExecutor()); diff --git a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java index f0276d2b62..26e913eacc 100644 --- a/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java @@ -69,7 +69,7 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyCollection; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -1074,7 +1074,7 @@ public class DefaultDeviceStateServiceTest { final long defaultTimeout = 1000; initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); - given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) @@ -1156,7 +1156,7 @@ public class DefaultDeviceStateServiceTest { final long defaultTimeout = 1000; initStateService(defaultTimeout); given(deviceService.findDeviceById(any(TenantId.class), any(DeviceId.class))).willReturn(new Device(deviceId)); - given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyList())).willReturn(Futures.immediateFuture(Collections.emptyList())); + given(attributesService.find(any(TenantId.class), any(EntityId.class), any(AttributeScope.class), anyCollection())).willReturn(Futures.immediateFuture(Collections.emptyList())); long currentTime = System.currentTimeMillis(); DeviceState deviceState = DeviceState.builder() From d7f61f07892810a438d2490fdcebb02dcc6a1e3a Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 7 Mar 2025 10:07:59 +0200 Subject: [PATCH 086/127] added validation and fixed timeouts --- .../CalculatedFieldEntityMessageProcessor.java | 12 +++++++++--- .../CalculatedFieldManagerMessageProcessor.java | 4 ++-- .../server/controller/TenantProfileController.java | 10 ++++++++-- .../cf/DefaultCalculatedFieldProcessingService.java | 3 ++- .../server/common/data/TenantProfile.java | 2 ++ .../profile/DefaultTenantProfileConfiguration.java | 7 +++++++ .../data/tenant/profile/TenantProfileData.java | 2 ++ 7 files changed, 32 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 61f46b7901..9ab019097b 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -134,14 +134,20 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM public void process(CalculatedFieldEntityDeleteMsg msg) { log.info("[{}] Processing CF entity delete msg.", msg.getEntityId()); if (this.entityId.equals(msg.getEntityId())) { - MultipleTbCallback multipleTbCallback = new MultipleTbCallback(states.size(), msg.getCallback()); - states.forEach((cfId, state) -> cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback)); - ctx.stop(ctx.getSelf()); + if (states.isEmpty()) { + msg.getCallback().onSuccess(); + } else { + MultipleTbCallback multipleTbCallback = new MultipleTbCallback(states.size(), msg.getCallback()); + states.forEach((cfId, state) -> cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), multipleTbCallback)); + ctx.stop(ctx.getSelf()); + } } else { var cfId = new CalculatedFieldId(msg.getEntityId().getId()); var state = states.remove(cfId); if (state != null) { cfStateService.removeState(new CalculatedFieldEntityCtxId(tenantId, cfId, entityId), msg.getCallback()); + } else { + msg.getCallback().onSuccess(); } } } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index e7c963a4a7..1418821081 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -199,7 +199,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (fieldsCount > 0) { MultipleTbCallback multiCallback = new MultipleTbCallback(fieldsCount, callback); var entityId = msg.getEntityId(); - oldProfileCfs.forEach(ctx -> deleteCfForEntity(entityId, ctx.getCfId(), callback)); + oldProfileCfs.forEach(ctx -> deleteCfForEntity(entityId, ctx.getCfId(), multiCallback)); newProfileCfs.forEach(ctx -> initCfForEntity(entityId, ctx, true, multiCallback)); } else { callback.onSuccess(); @@ -306,7 +306,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (!entityIds.isEmpty()) { //TODO: no need to do this if we cache all created actors and know which one belong to us; var multiCallback = new MultipleTbCallback(entityIds.size(), callback); - entityIds.forEach(id -> deleteCfForEntity(entityId, cfId, multiCallback)); + entityIds.forEach(id -> deleteCfForEntity(id, cfId, multiCallback)); } else { callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index ff4de0ce0c..1a3bdce1f6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -18,6 +18,7 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -160,7 +161,12 @@ public class TenantProfileController extends BaseController { " \"rpcTtlDays\": 0,\n" + " \"queueStatsTtlDays\": 0,\n" + " \"ruleEngineExceptionsTtlDays\": 0,\n" + - " \"warnThreshold\": 0\n" + + " \"warnThreshold\": 0,\n" + + " \"maxCalculatedFieldsPerEntity\": 5,\n" + + " \"maxArgumentsPerCF\": 10,\n" + + " \"maxDataPointsPerRollingArg\": 1000,\n" + + " \"maxStateSizeInKBytes\": 32,\n" + + " \"maxSingleValueArgumentSizeInKBytes\": 2" + " }\n" + " },\n" + " \"default\": false\n" + @@ -172,7 +178,7 @@ public class TenantProfileController extends BaseController { @RequestMapping(value = "/tenantProfile", method = RequestMethod.POST) @ResponseBody public TenantProfile saveTenantProfile(@Parameter(description = "A JSON value representing the tenant profile.") - @RequestBody TenantProfile tenantProfile) throws ThingsboardException { + @Valid @RequestBody TenantProfile tenantProfile) throws ThingsboardException { TenantProfile oldProfile; if (tenantProfile.getId() == null) { accessControlService.checkPermission(getCurrentUser(), Resource.TENANT_PROFILE, Operation.CREATE); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 4976045b84..27bc0120c4 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -273,7 +273,8 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow(); long startTs = currentTime - timeWindow; long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); - int limit = argument.getLimit() == 0 ? (int) maxDataPoints : argument.getLimit(); + int argumentLimit = argument.getLimit(); + int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit(); ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE); ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java b/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java index b7c7584931..5c7f0bb53d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TenantProfile.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; @@ -58,6 +59,7 @@ public class TenantProfile extends BaseData implements HasName @Schema(description = "If enabled, will push all messages related to this tenant and processed by the rule engine into separate queue. " + "Useful for complex microservices deployments, to isolate processing of the data for specific tenants", example = "false") private boolean isolatedTbRuleEngine; + @Valid @Schema(description = "Complex JSON object that contains profile settings: queue configs, max devices, max assets, rate limits, etc.") private transient TenantProfileData profileData; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 9c86785a5e..0568218d9c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -135,10 +136,16 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private double warnThreshold; + @Schema(example = "5") private long maxCalculatedFieldsPerEntity = 5; + @Schema(example = "10") private long maxArgumentsPerCF = 10; + @Min(value = 0, message = "must be at least 0") + @Schema(example = "1000") private long maxDataPointsPerRollingArg = 1000; + @Schema(example = "32") private long maxStateSizeInKBytes = 32; + @Schema(example = "2") private long maxSingleValueArgumentSizeInKBytes = 2; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java index b1f6c27fd8..44ca79cabb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/TenantProfileData.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; import lombok.Data; import java.io.Serializable; @@ -27,6 +28,7 @@ public class TenantProfileData implements Serializable { private static final long serialVersionUID = -3642550257035920976L; + @Valid @Schema(description = "Complex JSON object that contains profile settings: max devices, max assets, rate limits, etc.") private TenantProfileConfiguration configuration; From 0fa32587e3525a90acab04ebcf5d0af6f74c27f2 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 7 Mar 2025 10:38:48 +0200 Subject: [PATCH 087/127] added audit log mask --- application/src/main/resources/thingsboard.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index b01a0cf55e..01d7e69ba7 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -858,6 +858,7 @@ audit-log: "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation + "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" From c78509c7c40298bc301cbd015e4e0cd1a1478ca1 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 7 Mar 2025 11:31:13 +0200 Subject: [PATCH 088/127] UI: Maps - performance improvements and minor fixes. --- .../lib/maps/data-layer/trips-data-layer.ts | 73 ++++++++++++++----- .../home/components/widget/lib/maps/map.ts | 46 ++++++------ .../data-layer-pattern-settings.component.ts | 6 +- .../map/map-data-layer-dialog.component.html | 9 ++- .../map/map-data-layer-dialog.component.ts | 1 + 5 files changed, 91 insertions(+), 44 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts index 1bc1e8a395..72400b0f38 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/trips-data-layer.ts @@ -44,6 +44,11 @@ import { MarkerDataProcessor } from '@home/components/widget/lib/maps/data-layer type TripRouteData = {[time: number]: FormattedData}; +interface PointItem { + point: L.CircleMarker; + tooltip?: L.Popup; +} + class TbTripDataItem extends TbDataLayerItem { private tripRouteData: TripRouteData; @@ -54,7 +59,8 @@ class TbTripDataItem extends TbDataLayerItem(); private currentTime: number; private currentPositionData: FormattedData; @@ -94,7 +100,7 @@ class TbTripDataItem extends TbDataLayerItem { + pointItem.point.off(); + }); this.dataLayer.getDataLayerContainer().removeLayer(this.layer); this.layer.off(); } @@ -216,34 +228,61 @@ class TbTripDataItem extends TbDataLayerItem !!this.dataLayer.dataProcessor.extractLocationData(data)); - for (const pData of pointsList) { - let pointData = pData; + const pointsData = formattedRouteData.map(data => ({ + location: this.dataLayer.dataProcessor.extractLocation(data, dsData), + data + })).filter(pData => !!pData.location); + const toDelete = new Set(Array.from(this.points.keys())); + for (const pData of pointsData) { + let pointData = pData.data; if (this.latestData) { pointData = {...pointData, ...this.latestData}; } + const pointLocation = pData.location; const pointColor = this.dataLayer.pointColorProcessor.processColor(pointData, dsData); - const point = L.circleMarker(this.dataLayer.dataProcessor.extractLocation(pointData, dsData), { + const pointStyle = { stroke: false, fillOpacity: 1, fillColor: pointColor, radius: this.settings.pointSize - }); - if (this.settings.pointTooltip?.show) { - const pointTooltip = createTooltip(this.dataLayer.getMap(), - point, this.settings.pointTooltip, pointData, () => true); - updateTooltip(this.dataLayer.getMap(), pointTooltip, - this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + }; + const pointKey = `${pointLocation.lat}_${pointLocation.lng}`; + let pointItem = this.points.get(pointKey); + if (pointItem) { + pointItem.point.setStyle(pointStyle); + if (this.settings.pointTooltip?.show) { + updateTooltip(this.dataLayer.getMap(), pointItem.tooltip, + this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + } + } else { + pointItem = { + point: L.circleMarker(pointLocation, pointStyle) + }; + pointItem.point.addTo(this.pointsContainer); + if (this.settings.pointTooltip?.show) { + pointItem.tooltip = createTooltip(this.dataLayer.getMap(), + pointItem.point, this.settings.pointTooltip, pointData, () => true); + updateTooltip(this.dataLayer.getMap(), pointItem.tooltip, + this.settings.pointTooltip, this.dataLayer.pointTooltipProcessor, pointData, dsData); + } + this.points.set(pointKey, pointItem); } - this.points.addLayer(point); + toDelete.delete(pointKey); } + toDelete.forEach(pointKey => { + const pointItem = this.points.get(pointKey); + if (pointItem) { + this.pointsContainer.removeLayer(pointItem.point); + pointItem.point.off(); + this.points.delete(pointKey); + } + }); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts index 7fc4cf5e54..c88d8072e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.ts @@ -175,6 +175,25 @@ export abstract class TbMap { this.map.zoomControl.setPosition(this.settings.controlsPosition); } this.dragMode = !this.settings.dragModeButton; + const tripsWithMarkers = this.settings.trips?.length ? this.settings.trips.filter(trip => trip.showMarker) : []; + const showTimeline = this.settings.tripTimeline?.showTimelineControl && tripsWithMarkers.length; + + if (showTimeline) { + this.timeline = true; + this.timeStep = this.settings.tripTimeline.timeStep; + this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); + this.timeLineComponent = this.timeLineComponentRef.instance; + this.timeLineComponent.settings = this.settings.tripTimeline; + this.timeLineComponent.timeChanged.subscribe((time) => { + this.currentTime = time; + this.updateTripsTime(); + }); + const parentElement = this.timeLineComponentRef.instance.element.nativeElement; + const content = parentElement.firstChild; + parentElement.removeChild(content); + parentElement.style.display = 'none'; + this.containerElement.append(content); + } const setup = [this.doSetupControls()]; if (this.timeline && this.settings.tripTimeline.snapToRealLocation) { setup.push(parseTbFunction(this.getCtx().http, this.settings.tripTimeline.locationSnapFilter, ['data', 'dsData']).pipe( @@ -269,7 +288,7 @@ export abstract class TbMap { buttonTitle: this.ctx.translate.instant('widgets.maps.data-layer.groups'), }).addTo(this.map); this.map.on('layergroupchange', () => { - this.updateBounds(); + this.updateBounds(true); }); } const setup = this.dataLayers.map(dl => dl.setup()); @@ -322,6 +341,7 @@ export abstract class TbMap { const tripDataLayersSubscriptionOptions: WidgetSubscriptionOptions = { datasources: tripDatasources, hasDataPageLink: true, + ignoreDataUpdateOnIntervalTick: true, useDashboardTimewindow: isDefined(this.ctx.widgetConfig.useDashboardTimewindow) ? this.ctx.widgetConfig.useDashboardTimewindow : true, type: widgetType.timeseries, @@ -364,26 +384,6 @@ export abstract class TbMap { private setupEditMode() { - const tripsWithMarkers = this.tripDataLayers.filter(dl => dl.showMarker()); - const showTimeline = this.settings.tripTimeline?.showTimelineControl && tripsWithMarkers.length; - - if (showTimeline) { - this.timeline = true; - this.timeStep = this.settings.tripTimeline.timeStep; - this.timeLineComponentRef = this.ctx.widgetContentContainer.createComponent(MapTimelinePanelComponent); - this.timeLineComponent = this.timeLineComponentRef.instance; - this.timeLineComponent.settings = this.settings.tripTimeline; - this.timeLineComponent.timeChanged.subscribe((time) => { - this.currentTime = time; - this.updateTripsTime(); - }); - const parentElement = this.timeLineComponentRef.instance.element.nativeElement; - const content = parentElement.firstChild; - parentElement.removeChild(content); - parentElement.style.display = 'none'; - this.containerElement.append(content); - } - this.editToolbar = L.TB.bottomToolbar({ mapElement: $(this.mapElement), closeTitle: this.ctx.translate.instant('action.cancel'), @@ -919,7 +919,7 @@ export abstract class TbMap { this.currentPopover?.updatePosition(); } - private updateBounds() { + private updateBounds(force = false) { const enabledDataLayers = this.dataLayers.filter(dl => dl.isEnabled()); const dataLayersBounds = enabledDataLayers.map(dl => dl.getBounds()).filter(b => b.isValid()); let bounds: L.LatLngBounds; @@ -927,7 +927,7 @@ export abstract class TbMap { bounds = new L.LatLngBounds(null, null); dataLayersBounds.forEach(b => bounds.extend(b)); const mapBounds = this.map.getBounds(); - if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || !this.bounds.equals(bounds) && this.settings.fitMapBounds && !mapBounds.contains(bounds))) { + if (bounds.isValid() && (!this.bounds || !this.bounds.isValid() || (!this.bounds.equals(bounds) || force) && this.settings.fitMapBounds && !mapBounds.contains(bounds))) { this.bounds = bounds; if (!this.ignoreUpdateBounds && !this.isPlacingItem) { this.fitBounds(bounds); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index 22fc96bcbc..681b93da4a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -81,6 +81,10 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc @coerceBoolean() hasTooltipOffset = false; + @Input() + @coerceBoolean() + expand = true; + @Input() context: MapSettingsContext; @@ -149,7 +153,7 @@ export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAc value, {emitEvent: false} ); this.updateValidators(); - this.settingsExpanded = this.patternSettingsFormGroup.get('show').value; + this.settingsExpanded = this.patternSettingsFormGroup.get('show').value && this.expand; this.patternSettingsFormGroup.get('show').valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe((show) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 8e634e178b..012fbf6625 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -238,7 +238,7 @@
    @if (dataLayerType === 'trips') { - + } @@ -360,6 +360,7 @@ patternType="tooltip" patternTitle="{{ 'widgets.maps.data-layer.points.point-tooltip' | translate }}" [context]="context" + [expand]="false" helpId="widget/lib/map/path_point_tooltip_fn" formControlName="pointTooltip"> @@ -384,7 +385,7 @@
    @if (dataLayerType !== 'trips') { - + }
    @if (dataLayerType !== 'trips') { @@ -436,18 +437,20 @@ - + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts index b52e7b9013..64d75eb78f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.ts @@ -428,6 +428,7 @@ export class MapDataLayerDialogComponent extends DialogComponent { if (updatedDataKey) { this.dataLayerFormGroup.get(keyType).patchValue(updatedDataKey); + this.dataLayerFormGroup.markAsDirty(); } } ); From ee3d405ed87aae8c229cda5834be512ae6d32d7d Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Fri, 7 Mar 2025 11:44:49 +0200 Subject: [PATCH 089/127] Adding ctx as first argument in CF --- .../controller/CalculatedFieldController.java | 28 +++++++++++---- .../cf/ctx/state/CalculatedFieldCtx.java | 5 ++- .../ctx/state/ScriptCalculatedFieldState.java | 25 ++++++++++--- .../state/ScriptCalculatedFieldStateTest.java | 2 +- .../DefaultTenantProfileConfiguration.java | 2 +- .../api/tbel/DefaultTbelInvokeService.java | 1 + .../thingsboard/script/api/tbel/TbUtils.java | 2 ++ .../script/api/tbel/TbelCfCtx.java | 36 +++++++++++++++++++ .../script/api/tbel/TbelCfObject.java | 2 ++ .../script/api/tbel/TbelCfSingleValueArg.java | 2 -- 10 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index fe85cf3f87..f899d0f480 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -34,6 +34,8 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; @@ -216,11 +218,14 @@ public class CalculatedFieldController extends BaseController { @RequestBody JsonNode inputParams) { String expression = inputParams.get("expression").asText(); Map arguments = Objects.requireNonNullElse( - JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference>() { + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() { }), Collections.emptyMap() ); - ArrayList argNames = new ArrayList<>(arguments.keySet()); + + ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(arguments.keySet()); String output = ""; String errorText = ""; @@ -234,12 +239,20 @@ public class CalculatedFieldController extends BaseController { getTenantId(), tbelInvokeService, expression, - argNames.toArray(String[]::new) + ctxAndArgNames.toArray(String[]::new) ); - Object[] args = argNames.stream() - .map(arguments::get) - .toArray(); + + Object[] args = new Object[ctxAndArgNames.size()]; + args[0] = new TbelCfCtx(arguments); + for (int i = 1; i < ctxAndArgNames.size(); i++) { + var arg = arguments.get(ctxAndArgNames.get(i)); + if (arg instanceof TbelCfSingleValueArg svArg) { + args[i] = svArg.getValue(); + } else { + args[i] = arg; + } + } JsonNode json = calculatedFieldScriptEngine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); output = JacksonUtil.toString(json); @@ -260,7 +273,8 @@ public class CalculatedFieldController extends BaseController { EntityType entityType = referencedEntityId.getEntityType(); switch (entityType) { case TENANT, CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + default -> + throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 399e0d9f84..e5a3d0e05e 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -136,11 +136,14 @@ public class CalculatedFieldCtx { throw new IllegalArgumentException("TBEL script engine is disabled!"); } + List ctxAndArgNames = new ArrayList<>(argNames.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(argNames); return new CalculatedFieldTbelScriptEngine( tenantId, tbelInvokeService, expression, - argNames.toArray(String[]::new) + ctxAndArgNames.toArray(String[]::new) ); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java index 00d9ccbe5a..bf00f1b0b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java @@ -23,11 +23,17 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.service.cf.CalculatedFieldResult; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Data @Slf4j @@ -49,11 +55,20 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState { @Override public ListenableFuture performCalculation(CalculatedFieldCtx ctx) { - Object[] args = ctx.getArgNames().stream() - .map(this::toTbelArgument) - .toArray(); - - ListenableFuture resultFuture = ctx.getCalculatedFieldScriptEngine().executeJsonAsync(args); + Map arguments = new LinkedHashMap<>(); + List args = new ArrayList<>(ctx.getArgNames().size() + 1); + args.add(new Object()); // first element is a ctx, but we will set it later; + for (String argName : ctx.getArgNames()) { + var arg = toTbelArgument(argName); + arguments.put(argName, arg); + if (arg instanceof TbelCfSingleValueArg svArg) { + args.add(svArg.getValue()); + } else { + args.add(arg); + } + } + args.set(0, new TbelCfCtx(arguments)); + ListenableFuture resultFuture = ctx.getCalculatedFieldScriptEngine().executeJsonAsync(args.toArray()); Output output = ctx.getOutput(); return Futures.transform(resultFuture, result -> new CalculatedFieldResult(output.getType(), output.getScope(), result), diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java index dee32e7d1f..320093e8f4 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java @@ -191,7 +191,7 @@ public class ScriptCalculatedFieldStateTest { config.setArguments(Map.of("deviceTemperature", argument1, "assetHumidity", argument2)); - config.setExpression("return {\"maxDeviceTemperature\": deviceTemperature.max(), \"assetHumidity\": assetHumidity.value}"); + config.setExpression("return {\"maxDeviceTemperature\": deviceTemperature.max(), \"assetHumidity\": assetHumidity}"); Output output = new Output(); output.setType(OutputType.ATTRIBUTES); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 0568218d9c..06a0489037 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -140,7 +140,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxCalculatedFieldsPerEntity = 5; @Schema(example = "10") private long maxArgumentsPerCF = 10; - @Min(value = 0, message = "must be at least 0") + @Min(value = 1, message = "must be at least 1") @Schema(example = "1000") private long maxDataPointsPerRollingArg = 1000; @Schema(example = "32") diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java index 38fed7a2aa..3680e1bfb2 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/DefaultTbelInvokeService.java @@ -139,6 +139,7 @@ public class DefaultTbelInvokeService extends AbstractScriptInvokeService implem parserConfig.registerDataType("TbelCfTsRollingData", TbelCfTsRollingData.class, TbelCfTsRollingData::memorySize); parserConfig.registerDataType("TbTimeWindow", TbTimeWindow.class, TbTimeWindow::memorySize); parserConfig.registerDataType("TbelCfTsDoubleVal", TbelCfTsMultiDoubleVal.class, TbelCfTsMultiDoubleVal::memorySize); + parserConfig.registerDataType("TbelCfCtx", TbelCfCtx.class, TbelCfCtx::memorySize); TbUtils.register(parserConfig); executor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "tbel-executor")); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index 20a753da14..ca3a8db02a 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -18,6 +18,8 @@ package org.thingsboard.script.api.tbel; import com.google.common.primitives.Bytes; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ArrayUtils; +import org.mvel2.ConversionHandler; +import org.mvel2.DataConversion; import org.mvel2.ExecutionContext; import org.mvel2.ParserConfiguration; import org.mvel2.execution.ExecutionArrayList; diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java new file mode 100644 index 0000000000..ce42e2cf3b --- /dev/null +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 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.script.api.tbel; + +import lombok.Getter; + +import java.util.Collections; +import java.util.Map; + +public class TbelCfCtx implements TbelCfObject { + + @Getter + private final Map args; + + public TbelCfCtx(Map args) { + this.args = Collections.unmodifiableMap(args); + } + + @Override + public long memorySize() { + return OBJ_SIZE; + } +} diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java index 3af6198a22..cf575bd9d0 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfObject.java @@ -17,6 +17,8 @@ package org.thingsboard.script.api.tbel; public interface TbelCfObject { + long OBJ_SIZE = 32L; // Approximate calculation; + long memorySize(); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java index 193b6ea1ae..84227a8d80 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfSingleValueArg.java @@ -22,8 +22,6 @@ import lombok.Data; @Data public class TbelCfSingleValueArg implements TbelCfArg { - public static final long OBJ_SIZE = 32L; // Approximate calculation; - private final long ts; private final Object value; From 4495a2fa4b25f4b65c11ca08ecd9997924244f6d Mon Sep 17 00:00:00 2001 From: Dmytro Skarzhynets Date: Fri, 7 Mar 2025 11:53:25 +0200 Subject: [PATCH 090/127] Save attributes strategies: ensure device state service is notified when inactivity timeout is updated --- .../DefaultSubscriptionManagerService.java | 61 +--- .../DefaultTelemetrySubscriptionService.java | 83 ++++- ...faultTelemetrySubscriptionServiceTest.java | 299 +++++++++++++++++- 3 files changed, 374 insertions(+), 69 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java index 3e2c873030..1fafb562c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java @@ -48,8 +48,6 @@ import org.thingsboard.server.queue.discovery.event.OtherServiceShutdownEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.state.DefaultDeviceStateService; -import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; @@ -75,7 +73,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene private final TbServiceInfoProvider serviceInfoProvider; private final TbQueueProducerProvider producerProvider; private final TbLocalSubscriptionService localSubscriptionService; - private final DeviceStateService deviceStateService; private final TbClusterService clusterService; private final SubscriptionSchedulerComponent scheduler; @@ -170,7 +167,7 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene callback.onSuccess(); } - public void onTimeSeriesUpdate(EntityId entityId, List update) { + private void onTimeSeriesUpdate(EntityId entityId, List update) { getEntityUpdatesInfo(entityId).timeSeriesUpdateTs = System.currentTimeMillis(); TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); if (subInfo != null) { @@ -202,11 +199,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List attributes, TbCallback callback) { getEntityUpdatesInfo(entityId).attributesUpdateTs = System.currentTimeMillis(); processAttributesUpdate(entityId, scope, attributes); - if (entityId.getEntityType() == EntityType.DEVICE) { - if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope)) { - updateDeviceInactivityTimeout(tenantId, entityId, attributes); - } - } callback.onSuccess(); } @@ -219,19 +211,13 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List keys, boolean notifyDevice, TbCallback callback) { processAttributesUpdate(entityId, scope, keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); - if (entityId.getEntityType() == EntityType.DEVICE) { - if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) - || TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) { - deleteDeviceInactivityTimeout(tenantId, entityId, keys); - } else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { - clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, - new DeviceId(entityId.getId()), scope, keys), null); - } + if (entityId.getEntityType() == EntityType.DEVICE && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) { + clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), scope, keys), null); } callback.onSuccess(); } - public void processAttributesUpdate(EntityId entityId, String scope, List update) { + private void processAttributesUpdate(EntityId entityId, String scope, List update) { TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); if (subInfo != null) { log.trace("[{}] Handling attributes update: {}", entityId, update); @@ -259,22 +245,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene } } - private void updateDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List kvEntries) { - for (KvEntry kvEntry : kvEntries) { - if (kvEntry.getKey().equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { - deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), getLongValue(kvEntry)); - } - } - } - - private void deleteDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List keys) { - for (String key : keys) { - if (key.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) { - deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), 0); - } - } - } - @Override public void onAlarmUpdate(TenantId tenantId, EntityId entityId, AlarmInfo alarm, TbCallback callback) { onAlarmSubUpdate(tenantId, entityId, alarm, false, callback); @@ -344,29 +314,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene } } - private static long getLongValue(KvEntry kve) { - switch (kve.getDataType()) { - case LONG: - return kve.getLongValue().orElse(0L); - case DOUBLE: - return kve.getDoubleValue().orElse(0.0).longValue(); - case STRING: - try { - return Long.parseLong(kve.getStrValue().orElse("0")); - } catch (NumberFormatException e) { - return 0L; - } - case JSON: - try { - return Long.parseLong(kve.getJsonValue().orElse("0")); - } catch (NumberFormatException e) { - return 0L; - } - default: - return 0L; - } - } - private static List getSubList(List ts, Set keys) { List update = null; for (T entry : ts) { diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 0201bb4942..2302446b6b 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -31,10 +31,12 @@ import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; @@ -43,6 +45,7 @@ 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.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; @@ -55,12 +58,11 @@ import org.thingsboard.server.dao.util.KvUtils; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; -import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; +import org.thingsboard.server.service.state.DefaultDeviceStateService; import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -70,6 +72,11 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; +import static java.util.Comparator.comparing; +import static java.util.Comparator.comparingLong; +import static java.util.Comparator.naturalOrder; +import static java.util.Comparator.nullsFirst; + /** * Created by ashvayka on 27.03.18. */ @@ -83,6 +90,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer private final TbApiUsageReportClient apiUsageClient; private final TbApiUsageStateService apiUsageStateService; private final CalculatedFieldQueueService calculatedFieldQueueService; + private final DeviceStateManager deviceStateManager; private ExecutorService tsCallBackExecutor; @@ -94,13 +102,15 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer @Lazy TbEntityViewService tbEntityViewService, TbApiUsageReportClient apiUsageClient, TbApiUsageStateService apiUsageStateService, - CalculatedFieldQueueService calculatedFieldQueueService) { + CalculatedFieldQueueService calculatedFieldQueueService, + DeviceStateManager deviceStateManager) { this.attrService = attrService; this.tsService = tsService; this.tbEntityViewService = tbEntityViewService; this.apiUsageClient = apiUsageClient; this.apiUsageStateService = apiUsageStateService; this.calculatedFieldQueueService = calculatedFieldQueueService; + this.deviceStateManager = deviceStateManager; } @PostConstruct @@ -201,20 +211,51 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer } }, t -> request.getCallback().onFailure(t)); - if (strategy.saveAttributes() - && entityId.getEntityType() == EntityType.DEVICE - && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(request.getScope().name()) - && request.isNotifyDevice()) { + if (shouldSendSharedAttributesUpdatedNotification(request)) { addMainCallback(resultFuture, success -> clusterService.pushMsgToCore( DeviceAttributesEventNotificationMsg.onUpdate(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getEntries()), null )); } + if (shouldCheckForInactivityTimeoutUpdates(request)) { + findNewInactivityTimeout(request.getEntries()).ifPresent(newInactivityTimeout -> + addMainCallback(resultFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate( + tenantId, new DeviceId(entityId.getId()), newInactivityTimeout, TbCallback.EMPTY) + ) + ); + } + if (strategy.sendWsUpdate()) { addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries())); } } + private static boolean shouldSendSharedAttributesUpdatedNotification(AttributesSaveRequest request) { + return request.getStrategy().saveAttributes() && shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice()); + } + + private static boolean shouldCheckForInactivityTimeoutUpdates(AttributesSaveRequest request) { + return request.getStrategy().saveAttributes() + && request.getEntityId().getEntityType() == EntityType.DEVICE + && request.getScope() == AttributeScope.SERVER_SCOPE; + } + + private static Optional findNewInactivityTimeout(List entries) { + return entries.stream() + .filter(entry -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, entry.getKey())) + // Select the entry with the highest version, or if the versions are equal, the one with the most recent update timestamp + .max(comparing(AttributeKvEntry::getVersion, nullsFirst(naturalOrder())).thenComparingLong(AttributeKvEntry::getLastUpdateTs)) + .map(DefaultTelemetrySubscriptionService::parseAsLong); + } + + private static long parseAsLong(KvEntry kve) { + try { + return Long.parseLong(kve.getValueAsString()); + } catch (NumberFormatException e) { + return 0L; + } + } + @Override public void deleteAttributes(AttributesDeleteRequest request) { checkInternalEntity(request.getEntityId()); @@ -233,17 +274,37 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer t -> request.getCallback().onFailure(t) ); - if (entityId.getEntityType() == EntityType.DEVICE - && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(request.getScope().name()) - && request.isNotifyDevice()) { + if (shouldSendSharedAttributesDeletedNotification(request)) { addMainCallback(deleteFuture, success -> clusterService.pushMsgToCore( DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getKeys()), null )); } + if (inactivityTimeoutDeleted(request)) { + addMainCallback(deleteFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate( + tenantId, new DeviceId(entityId.getId()), 0L, TbCallback.EMPTY) + ); + } + addWsCallback(deleteFuture, success -> onAttributesDelete(tenantId, entityId, request.getScope().name(), request.getKeys())); } + private static boolean shouldSendSharedAttributesDeletedNotification(AttributesDeleteRequest request) { + return shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice()); + } + + private static boolean shouldSendSharedAttributesNotification(EntityId entityId, AttributeScope scope, boolean notifyDevice) { + return entityId.getEntityType() == EntityType.DEVICE + && scope == AttributeScope.SHARED_SCOPE + && notifyDevice; + } + + private static boolean inactivityTimeoutDeleted(AttributesDeleteRequest request) { + return request.getEntityId().getEntityType() == EntityType.DEVICE + && request.getScope() == AttributeScope.SERVER_SCOPE + && request.getKeys().stream().anyMatch(key -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, key)); + } + @Override public void deleteTimeseries(TimeseriesDeleteRequest request) { checkInternalEntity(request.getEntityId()); @@ -293,7 +354,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer if (entries != null) { Optional tsKvEntry = entries.stream() .filter(entry -> entry.getTs() > startTs && entry.getTs() <= endTs) - .max(Comparator.comparingLong(TsKvEntry::getTs)); + .max(comparingLong(TsKvEntry::getTs)); tsKvEntry.ifPresent(entityViewLatest::add); } } diff --git a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java index e96566c116..64845a12cd 100644 --- a/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java @@ -31,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.ApiUsageRecordKey; @@ -51,6 +52,7 @@ import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TsKvEntry; @@ -137,12 +139,14 @@ class DefaultTelemetrySubscriptionServiceTest { TbApiUsageStateService apiUsageStateService; @Mock CalculatedFieldQueueService calculatedFieldQueueService; + @Mock + DeviceStateManager deviceStateManager; DefaultTelemetrySubscriptionService telemetryService; @BeforeEach void setup() { - telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService, calculatedFieldQueueService); + telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService, calculatedFieldQueueService, deviceStateManager); ReflectionTestUtils.setField(telemetryService, "clusterService", clusterService); ReflectionTestUtils.setField(telemetryService, "partitionService", partitionService); ReflectionTestUtils.setField(telemetryService, "subscriptionManagerService", Optional.of(subscriptionManagerService)); @@ -658,6 +662,184 @@ class DefaultTelemetrySubscriptionServiceTest { then(clusterService).should(never()).pushMsgToCore(any(), any()); } + @Test + void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasUpdated() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 5000L, TbCallback.EMPTY); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutSaveWasSkipped() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(false, true, true)) + .build(); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButEntityTypeIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, nonDeviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = {"SERVER_SCOPE"}, + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButAttributeScopeIsNotServer(AttributeScope nonServerScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(nonServerScope) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenUpdatedAttributesDoNotContainInactivityTimeout() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("notInactivityTimeout", 5000L)); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(inactivityTimeout) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldUseInactivityTimeoutEntryWithTheGreatestVersion() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 0L), 0L, null), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 3L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 2L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 1L, 3L) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entries(entries) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY); + } + + @Test + void shouldUseInactivityTimeoutEntryWithTheGreatestLastUpdateTsWhenVersionsAreTheSame() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + List entries = List.of( + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 1L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 1L), + new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 3L, 1L) + ); + + var request = AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .entries(entries) + .strategy(new AttributesSaveRequest.Strategy(true, false, false)) + .build(); + + given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size()))); + + // WHEN + telemetryService.saveAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY); + } + /* --- Delete attributes API --- */ @Test @@ -807,6 +989,121 @@ class DefaultTelemetrySubscriptionServiceTest { then(clusterService).should(never()).pushMsgToCore(any(), any()); } + @Test + void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasDeleted() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 0L, TbCallback.EMPTY); + } + + @ParameterizedTest + @EnumSource( + value = EntityType.class, + names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButEntityTypeIsNotDevice(EntityType entityType) { + // GIVEN + var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(nonDeviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @ParameterizedTest + @EnumSource( + value = AttributeScope.class, + names = {"SERVER_SCOPE"}, + mode = EnumSource.Mode.EXCLUDE + ) + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButAttributeScopeIsNotServer(AttributeScope nonServerScope) { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(nonServerScope) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasNotDeleted() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys())); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + + @Test + void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutDeleteFailed() { + // GIVEN + var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088"); + + var request = AttributesDeleteRequest.builder() + .tenantId(tenantId) + .entityId(deviceId) + .scope(AttributeScope.SERVER_SCOPE) + .keys(List.of("inactivityTimeout", "someOtherDeletedAttribute")) + .build(); + + given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFailedFuture(new RuntimeException("failed to delete"))); + + // WHEN + telemetryService.deleteAttributes(request); + + // THEN + then(deviceStateManager).shouldHaveNoInteractions(); + } + // used to emulate versions returned by save APIs private static List listOfNNumbers(int N) { return LongStream.range(0, N).boxed().toList(); From 7ca43d386c8aa26ec4f40660df547af3c42092cc Mon Sep 17 00:00:00 2001 From: ViacheslavKlimov Date: Fri, 7 Mar 2025 12:11:57 +0200 Subject: [PATCH 091/127] Add 3.9.1 to SUPPORTED_VERSIONS_FOR_UPGRADE --- .../service/install/DefaultDatabaseSchemaSettingsService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index 0f4cf30fac..a0012a6fb3 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("3.9.0"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("3.9.0", "3.9.1"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; From a76149d60da8d8d0c40c72ac7dc6fae513842d81 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Fri, 7 Mar 2025 12:27:58 +0200 Subject: [PATCH 092/127] Changed Calculated fields highlights and autocompletes --- .../calculated-field-dialog.component.scss | 14 ++- .../calculated-field-dialog.component.ts | 2 +- ...lculated-field-argument-panel.component.ts | 2 +- .../shared/models/calculated-field.models.ts | 95 +++++++++++++++---- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss index 3bb2759274..8bc422eed1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -36,14 +36,20 @@ .tb-js-func { .ace_tb { &.ace_calculated-field { - &-key { + &-ctx { color: #C52F00; } - &-ts, &-time-window, &-values, &-value, &-func { + &-args { + color: #185F2A; + } + &-key { + color: #c24c1a; + } + &-time-window, &-values, &-func, &-value, &-ts { color: #7214D0; } - &-start-ts, &-end-ts, &-limit { - color: #185F2A; + &-start-ts, &-end-ts { + color: #2CAA00; } } } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 0c3e7464cd..052b660c3c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -70,7 +70,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent Object.keys(argumentsObj)) + map(argumentsObj => ['ctx', ...Object.keys(argumentsObj)]) ); argumentsEditorCompleter$ = this.configFormGroup.get('arguments').valueChanges diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts index 7e41f60968..1058f70b49 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts @@ -64,7 +64,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit { readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); argumentFormGroup = this.fb.group({ - argumentName: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + argumentName: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(/^(?!ctx$).+$/), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], refEntityId: this.fb.group({ entityType: [ArgumentEntityType.Current], id: [''] diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 7083ce7ae0..d20a9d8924 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -235,10 +235,10 @@ export type CalculatedFieldArgumentEventValue = CalculatedF export type CalculatedFieldEventArguments = Record>; -export const CalculatedFieldLatestTelemetryArgumentAutocomplete = { +export const CalculatedFieldCtxLatestTelemetryArgumentAutocomplete = { meta: 'object', type: '{ ts: number; value: any; }', - description: 'Calculated field latest telemetry value argument.', + description: 'Calculated field context latest telemetry value argument.', children: { ts: { meta: 'number', @@ -253,10 +253,10 @@ export const CalculatedFieldLatestTelemetryArgumentAutocomplete = { }, }; -export const CalculatedFieldAttributeValueArgumentAutocomplete = { +export const CalculatedFieldCtxAttributeValueArgumentAutocomplete = { meta: 'object', type: '{ ts: number; value: any; }', - description: 'Calculated field attribute value argument.', + description: 'Calculated field context attribute value argument.', children: { ts: { meta: 'number', @@ -270,6 +270,19 @@ export const CalculatedFieldAttributeValueArgumentAutocomplete = { } }, }; + +export const CalculatedFieldLatestTelemetryArgumentAutocomplete = { + meta: 'any', + type: 'any', + description: 'Calculated field latest telemetry argument value.', +}; + +export const CalculatedFieldAttributeValueArgumentAutocomplete = { + meta: 'any', + type: 'any', + description: 'Calculated field attribute argument value.', +}; + export const CalculatedFieldRollingValueArgumentFunctionsAutocomplete = { max: { meta: 'function', @@ -513,35 +526,84 @@ export const getCalculatedFieldArgumentsEditorCompleter = (argumentsObj: Record< switch (argumentsObj[key].refEntityKey.type) { case ArgumentType.Attribute: acc[key] = CalculatedFieldAttributeValueArgumentAutocomplete; + acc.ctx.children.args.children[key] = CalculatedFieldCtxAttributeValueArgumentAutocomplete; break; case ArgumentType.LatestTelemetry: - acc[key] = CalculatedFieldLatestTelemetryArgumentAutocomplete; + acc[key] = { ...CalculatedFieldLatestTelemetryArgumentAutocomplete, children: {} }; + acc.ctx.children.args.children[key] = CalculatedFieldCtxLatestTelemetryArgumentAutocomplete; break; case ArgumentType.Rolling: acc[key] = CalculatedFieldRollingValueArgumentAutocomplete; + acc.ctx.children.args.children[key] = CalculatedFieldRollingValueArgumentAutocomplete; break; } return acc; - }, {})); + }, { + ctx: { + meta: 'object', + type: '{ args: { [key: string]: object } }', + description: 'Calculated field context.', + children: { + args: { + meta: 'object', + type: '{ [key: string]: object }', + description: 'Calculated field context arguments.', + children: {} + } + } + } + })); } export const getCalculatedFieldArgumentsHighlights = ( argumentsObj: Record ): AceHighlightRules => { + const calculatedFieldArgumentsKeys = Object.keys(argumentsObj).map(key => ({ + token: 'tb.calculated-field-key', + regex: `\\b${key}\\b`, + next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling + ? 'calculatedFieldRollingArgumentValue' + : 'start' + })); + const calculatedFieldCtxArgumentsHighlightRules = { + calculatedFieldCtxArgs: [ + dotOperatorHighlightRule, + ...calculatedFieldArgumentsKeys.map(argumentRule => argumentRule.next === 'start' ? {...argumentRule, next: 'calculatedFieldSingleArgumentValue' } : argumentRule), + endGroupHighlightRule + ] + }; + return { - start: Object.keys(argumentsObj).map(key => ({ - token: 'tb.calculated-field-key', - regex: `\\b${key}\\b`, - next: argumentsObj[key].refEntityKey.type === ArgumentType.Rolling - ? 'calculatedFieldRollingArgumentValue' - : 'calculatedFieldSingleArgumentValue' - })), + start: [ + calculatedFieldArgumentsContextHighlightRules, + ...calculatedFieldArgumentsKeys, + ], + ...calculatedFieldArgumentsContextValueHighlightRules, + ...calculatedFieldCtxArgumentsHighlightRules, ...calculatedFieldSingleArgumentValueHighlightRules, ...calculatedFieldRollingArgumentValueHighlightRules, ...calculatedFieldTimeWindowArgumentValueHighlightRules }; }; +const calculatedFieldArgumentsContextHighlightRules: AceHighlightRule = { + token: 'tb.calculated-field-ctx', + regex: /ctx/, + next: 'calculatedFieldCtxValue' +} + +const calculatedFieldArgumentsContextValueHighlightRules: AceHighlightRules = { + calculatedFieldCtxValue: [ + dotOperatorHighlightRule, + { + token: 'tb.calculated-field-args', + regex: /args/, + next: 'calculatedFieldCtxArgs' + }, + endGroupHighlightRule + ] +} + const calculatedFieldSingleArgumentValueHighlightRules: AceHighlightRules = { calculatedFieldSingleArgumentValue: [ dotOperatorHighlightRule, @@ -597,16 +659,11 @@ const calculatedFieldTimeWindowArgumentValueHighlightRules: AceHighlightRules = regex: /endTs/, next: 'no_regex' }, - { - token: 'tb.calculated-field-limit', - regex: /limit/, - next: 'no_regex' - }, endGroupHighlightRule ] } export const calculatedFieldDefaultScript = 'return {\n' + ' // Convert Fahrenheit to Celsius\n' + - ' "temperatureCelsius": (temperature.value - 32) / 1.8\n' + + ' "temperatureCelsius": (temperature - 32) / 1.8\n' + '};' From dc648e0d8ca5b14d4cffda5c8d6f1c6c883c7baf Mon Sep 17 00:00:00 2001 From: mpetrov Date: Fri, 7 Mar 2025 12:31:24 +0200 Subject: [PATCH 093/127] refactoring --- ui-ngx/src/app/shared/models/calculated-field.models.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index d20a9d8924..c898342c68 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -225,7 +225,7 @@ export interface CalculatedFieldLatestTelemetryArgumentValue extends CalculatedFieldArgumentValueBase { - timeWindow: { startTs: number; endTs: number; limit: number }; + timeWindow: { startTs: number; endTs: number; }; values: CalculatedFieldSingleArgumentValue[]; } @@ -529,7 +529,7 @@ export const getCalculatedFieldArgumentsEditorCompleter = (argumentsObj: Record< acc.ctx.children.args.children[key] = CalculatedFieldCtxAttributeValueArgumentAutocomplete; break; case ArgumentType.LatestTelemetry: - acc[key] = { ...CalculatedFieldLatestTelemetryArgumentAutocomplete, children: {} }; + acc[key] = CalculatedFieldLatestTelemetryArgumentAutocomplete; acc.ctx.children.args.children[key] = CalculatedFieldCtxLatestTelemetryArgumentAutocomplete; break; case ArgumentType.Rolling: From 7e49cd9ef06f436d3324119253b3f13243584d14 Mon Sep 17 00:00:00 2001 From: mpetrov Date: Fri, 7 Mar 2025 12:49:50 +0200 Subject: [PATCH 094/127] Fixed cf test script dialog styles --- ...ed-field-script-test-dialog.component.html | 2 +- ...ed-field-script-test-dialog.component.scss | 107 ++++++++++-------- ...ated-field-script-test-dialog.component.ts | 4 +- 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html index cda99f6c09..6859ff6dab 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
    +

    {{ 'calculated-fields.test-script-function' | translate }} ({{ 'api-usage.tbel' | translate }})