61 changed files with 2507 additions and 84 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,309 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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.transport.coap.client; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import lombok.Getter; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.eclipse.californium.core.CoapHandler; |
|||
import org.eclipse.californium.core.CoapObserveRelation; |
|||
import org.eclipse.californium.core.CoapResponse; |
|||
import org.eclipse.californium.core.coap.CoAP; |
|||
import org.eclipse.californium.core.coap.MediaTypeRegistry; |
|||
import org.junit.After; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.query.EntityKey; |
|||
import org.thingsboard.server.common.data.query.EntityKeyType; |
|||
import org.thingsboard.server.common.data.query.SingleEntityFilter; |
|||
import org.thingsboard.server.common.msg.session.FeatureType; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.transport.coap.AbstractCoapIntegrationTest; |
|||
import org.thingsboard.server.transport.coap.CoapTestCallback; |
|||
import org.thingsboard.server.transport.coap.CoapTestClient; |
|||
import org.thingsboard.server.transport.coap.CoapTestConfigProperties; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Arrays; |
|||
import java.util.HashSet; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.stream.Collectors; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.awaitility.Awaitility.await; |
|||
import static org.junit.Assert.assertArrayEquals; |
|||
import static org.junit.Assert.assertEquals; |
|||
import static org.junit.Assert.assertNotNull; |
|||
import static org.junit.Assert.assertTrue; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
import static org.thingsboard.server.common.data.query.EntityKeyType.CLIENT_ATTRIBUTE; |
|||
import static org.thingsboard.server.common.data.query.EntityKeyType.SHARED_ATTRIBUTE; |
|||
|
|||
@Slf4j |
|||
@DaoSqlTest |
|||
public class CoapClientIntegrationTest extends AbstractCoapIntegrationTest { |
|||
|
|||
private static final String PAYLOAD_VALUES_STR = "{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4," + |
|||
" \"key5\": {\"someNumber\": 42, \"someArray\": [1,2,3], \"someNestedObject\": {\"key\": \"value\"}}}"; |
|||
private static final List<String> EXPECTED_KEYS = Arrays.asList("key1", "key2", "key3", "key4", "key5"); |
|||
private static final String DEVICE_RESPONSE = "{\"value1\":\"A\",\"value2\":\"B\"}"; |
|||
|
|||
|
|||
@Before |
|||
public void beforeTest() throws Exception { |
|||
CoapTestConfigProperties configProperties = CoapTestConfigProperties.builder() |
|||
.deviceName("Test Post Attributes device") |
|||
.build(); |
|||
processBeforeTest(configProperties); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
processAfterTest(); |
|||
} |
|||
|
|||
@Test |
|||
public void testConfirmableRequests() throws Exception { |
|||
boolean confirmable = true; |
|||
processAttributesTest(confirmable); |
|||
processTwoWayRpcTest(confirmable); |
|||
processTestRequestAttributesValuesFromTheServer(confirmable); |
|||
} |
|||
|
|||
@Test |
|||
public void testNonConfirmableRequests() throws Exception { |
|||
boolean confirmable = false; |
|||
processAttributesTest(confirmable); |
|||
processTwoWayRpcTest(confirmable); |
|||
processTestRequestAttributesValuesFromTheServer(confirmable); |
|||
} |
|||
|
|||
protected void processAttributesTest(boolean confirmable) throws Exception { |
|||
client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable); |
|||
CoapResponse coapResponse = client.postMethod(PAYLOAD_VALUES_STR.getBytes()); |
|||
assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode()); |
|||
assertEquals("CoAP response type is wrong!", client.getType(), coapResponse.advanced().getType()); |
|||
|
|||
DeviceId deviceId = savedDevice.getId(); |
|||
List<String> actualKeys = getActualKeysList(deviceId); |
|||
assertNotNull(actualKeys); |
|||
|
|||
Set<String> actualKeySet = new HashSet<>(actualKeys); |
|||
Set<String> expectedKeySet = new HashSet<>(EXPECTED_KEYS); |
|||
assertEquals(expectedKeySet, actualKeySet); |
|||
|
|||
String attributesValuesUrl = "/api/plugins/telemetry/DEVICE/" + deviceId + "/values/attributes/CLIENT_SCOPE?keys=" + String.join(",", actualKeySet); |
|||
; |
|||
List<Map<String, Object>> values = doGetAsyncTyped(attributesValuesUrl, new TypeReference<>() { |
|||
}); |
|||
assertAttributesValues(values, actualKeySet); |
|||
String deleteAttributesUrl = "/api/plugins/telemetry/DEVICE/" + deviceId + "/CLIENT_SCOPE?keys=" + String.join(",", actualKeySet); |
|||
doDelete(deleteAttributesUrl); |
|||
} |
|||
|
|||
protected void processTwoWayRpcTest(boolean confirmable) throws Exception { |
|||
client = createClientForFeatureWithConfirmableParameter(FeatureType.RPC, confirmable); |
|||
CoapTestCallback callbackCoap = new TestCoapCallbackForRPC(client); |
|||
|
|||
CoapObserveRelation observeRelation = client.getObserveRelation(callbackCoap, confirmable); |
|||
String awaitAlias = "await Two Way Rpc (client.getObserveRelation)"; |
|||
await(awaitAlias) |
|||
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) |
|||
.until(() -> CoAP.ResponseCode.VALID.equals(callbackCoap.getResponseCode()) && |
|||
callbackCoap.getObserve() != null && |
|||
0 == callbackCoap.getObserve()); |
|||
validateCurrentStateNotification(callbackCoap); |
|||
|
|||
String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"26\",\"value\": 1}}"; |
|||
String deviceId = savedDevice.getId().getId().toString(); |
|||
int expectedObserveCountAfterGpioRequest1 = callbackCoap.getObserve() + 1; |
|||
String actualResult = doPostAsync("/api/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk()); |
|||
awaitAlias = "await Two Way Rpc (setGpio(method, params, value) first"; |
|||
await(awaitAlias) |
|||
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) |
|||
.until(() -> CoAP.ResponseCode.CONTENT.equals(callbackCoap.getResponseCode()) && |
|||
callbackCoap.getObserve() != null && |
|||
expectedObserveCountAfterGpioRequest1 == callbackCoap.getObserve()); |
|||
validateTwoWayStateChangedNotification(callbackCoap, actualResult); |
|||
|
|||
int expectedObserveCountAfterGpioRequest2 = callbackCoap.getObserve() + 1; |
|||
actualResult = doPostAsync("/api/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk()); |
|||
awaitAlias = "await Two Way Rpc (setGpio(method, params, value) second"; |
|||
await(awaitAlias) |
|||
.atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) |
|||
.until(() -> CoAP.ResponseCode.CONTENT.equals(callbackCoap.getResponseCode()) && |
|||
callbackCoap.getObserve() != null && |
|||
expectedObserveCountAfterGpioRequest2 == callbackCoap.getObserve()); |
|||
|
|||
validateTwoWayStateChangedNotification(callbackCoap, actualResult); |
|||
|
|||
observeRelation.proactiveCancel(); |
|||
assertTrue(observeRelation.isCanceled()); |
|||
} |
|||
|
|||
protected void processTestRequestAttributesValuesFromTheServer(boolean confirmable) throws Exception { |
|||
client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable); |
|||
SingleEntityFilter dtf = new SingleEntityFilter(); |
|||
dtf.setSingleEntity(savedDevice.getId()); |
|||
List<EntityKey> csKeys = getEntityKeys(CLIENT_ATTRIBUTE); |
|||
List<EntityKey> shKeys = getEntityKeys(SHARED_ATTRIBUTE); |
|||
List<EntityKey> keys = new ArrayList<>(); |
|||
keys.addAll(csKeys); |
|||
keys.addAll(shKeys); |
|||
getWsClient().subscribeLatestUpdate(keys, dtf); |
|||
getWsClient().registerWaitForUpdate(2); |
|||
|
|||
doPostAsync("/api/plugins/telemetry/DEVICE/" + savedDevice.getId().getId() + "/attributes/SHARED_SCOPE", |
|||
PAYLOAD_VALUES_STR, String.class, status().isOk()); |
|||
|
|||
CoapResponse coapResponse = client.postMethod(PAYLOAD_VALUES_STR); |
|||
assertEquals(CoAP.ResponseCode.CREATED, coapResponse.getCode()); |
|||
|
|||
String update = getWsClient().waitForUpdate(); |
|||
assertThat(update).as("ws update received").isNotBlank(); |
|||
|
|||
String keysParam = String.join(",", EXPECTED_KEYS); |
|||
String featureTokenUrl = CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.ATTRIBUTES) + "?clientKeys=" + keysParam + "&sharedKeys=" + keysParam; |
|||
client.setURI(featureTokenUrl); |
|||
CoapResponse response = client.getMethod(); |
|||
assertEquals("CoAP response type is wrong!", client.getType(), response.advanced().getType()); |
|||
} |
|||
|
|||
@SuppressWarnings({"unchecked", "rawtypes"}) |
|||
protected void assertAttributesValues(List<Map<String, Object>> deviceValues, Set<String> keySet) { |
|||
for (Map<String, Object> map : deviceValues) { |
|||
String key = (String) map.get("key"); |
|||
Object value = map.get("value"); |
|||
assertTrue(keySet.contains(key)); |
|||
switch (key) { |
|||
case "key1": |
|||
assertEquals("value1", value); |
|||
break; |
|||
case "key2": |
|||
assertEquals(true, value); |
|||
break; |
|||
case "key3": |
|||
assertEquals(3.0, value); |
|||
break; |
|||
case "key4": |
|||
assertEquals(4, value); |
|||
break; |
|||
case "key5": |
|||
assertNotNull(value); |
|||
assertEquals(3, ((LinkedHashMap) value).size()); |
|||
assertEquals(42, ((LinkedHashMap) value).get("someNumber")); |
|||
assertEquals(Arrays.asList(1, 2, 3), ((LinkedHashMap) value).get("someArray")); |
|||
LinkedHashMap<String, String> someNestedObject = (LinkedHashMap) ((LinkedHashMap) value).get("someNestedObject"); |
|||
assertEquals("value", someNestedObject.get("key")); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private List<String> getActualKeysList(DeviceId deviceId) throws Exception { |
|||
long start = System.currentTimeMillis(); |
|||
long end = System.currentTimeMillis() + 5000; |
|||
|
|||
List<String> actualKeys = null; |
|||
while (start <= end) { |
|||
actualKeys = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + deviceId + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() { |
|||
}); |
|||
if (actualKeys.size() == EXPECTED_KEYS.size()) { |
|||
break; |
|||
} |
|||
Thread.sleep(100); |
|||
start += 100; |
|||
} |
|||
return actualKeys; |
|||
} |
|||
|
|||
private void validateCurrentStateNotification(CoapTestCallback callback) { |
|||
assertArrayEquals(EMPTY_PAYLOAD, callback.getPayloadBytes()); |
|||
} |
|||
|
|||
private void validateTwoWayStateChangedNotification(CoapTestCallback callback, String actualResult) { |
|||
assertEquals(DEVICE_RESPONSE, actualResult); |
|||
assertNotNull(callback.getPayloadBytes()); |
|||
} |
|||
|
|||
protected class TestCoapCallbackForRPC extends CoapTestCallback { |
|||
|
|||
private final CoapTestClient client; |
|||
|
|||
@Getter |
|||
private boolean wasSuccessful = false; |
|||
|
|||
TestCoapCallbackForRPC(CoapTestClient client) { |
|||
this.client = client; |
|||
} |
|||
|
|||
@Override |
|||
public void onLoad(CoapResponse response) { |
|||
payloadBytes = response.getPayload(); |
|||
responseCode = response.getCode(); |
|||
observe = response.getOptions().getObserve(); |
|||
wasSuccessful = client.getType().equals(response.advanced().getType()); |
|||
if (observe != null) { |
|||
if (observe > 0) { |
|||
processOnLoadResponse(response, client); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void onError() { |
|||
log.warn("Command Response Ack Error, No connect"); |
|||
} |
|||
} |
|||
|
|||
protected void processOnLoadResponse(CoapResponse response, CoapTestClient client) { |
|||
JsonNode responseJson = JacksonUtil.fromBytes(response.getPayload()); |
|||
int requestId = responseJson.get("id").asInt(); |
|||
client.setURI(CoapTestClient.getFeatureTokenUrl(accessToken, FeatureType.RPC, requestId)); |
|||
client.postMethod(new CoapHandler() { |
|||
@Override |
|||
public void onLoad(CoapResponse response) { |
|||
log.warn("RPC {} command response ack: {}", requestId, response.getCode()); |
|||
} |
|||
|
|||
@Override |
|||
public void onError() { |
|||
log.warn("RPC {} command response ack error, no connect", requestId); |
|||
} |
|||
}, DEVICE_RESPONSE, MediaTypeRegistry.APPLICATION_JSON); |
|||
} |
|||
|
|||
private CoapTestClient createClientForFeatureWithConfirmableParameter(FeatureType featureType, boolean confirmable) { |
|||
CoapTestClient coapTestClient = new CoapTestClient(accessToken, featureType); |
|||
if (confirmable) { |
|||
coapTestClient.useCONs(); |
|||
} else { |
|||
coapTestClient.useNONs(); |
|||
} |
|||
return coapTestClient; |
|||
} |
|||
|
|||
private List<EntityKey> getEntityKeys(EntityKeyType scope) { |
|||
return CoapClientIntegrationTest.EXPECTED_KEYS.stream().map(key -> new EntityKey(scope, key)).collect(Collectors.toList()); |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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.common.transport.limits; |
|||
|
|||
import com.github.benmanes.caffeine.cache.Cache; |
|||
import com.github.benmanes.caffeine.cache.Caffeine; |
|||
import com.github.benmanes.caffeine.cache.Expiry; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.jetbrains.annotations.NotNull; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.queue.util.TbTransportComponent; |
|||
|
|||
import java.util.concurrent.ThreadLocalRandom; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
@Service |
|||
@TbTransportComponent |
|||
@Slf4j |
|||
public class DefaultEntityLimitsCache implements EntityLimitsCache { |
|||
|
|||
private static final int DEVIATION = 10; |
|||
private final Cache<EntityLimitKey, Boolean> cache; |
|||
|
|||
public DefaultEntityLimitsCache(@Value("${cache.entityLimits.timeToLiveInMinutes:5}") int ttl, |
|||
@Value("${cache.entityLimits.maxSize:100000}") int maxSize) { |
|||
// We use the 'random' expiration time to avoid peak loads.
|
|||
long mainPart = (TimeUnit.MINUTES.toNanos(ttl) / 100) * (100 - DEVIATION); |
|||
long randomPart = (TimeUnit.MINUTES.toNanos(ttl) / 100) * DEVIATION; |
|||
cache = Caffeine.newBuilder() |
|||
.expireAfter(new Expiry<EntityLimitKey, Boolean>() { |
|||
@Override |
|||
public long expireAfterCreate(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime) { |
|||
return mainPart + (long) (randomPart * ThreadLocalRandom.current().nextDouble()); |
|||
} |
|||
|
|||
@Override |
|||
public long expireAfterUpdate(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime, long currentDuration) { |
|||
return currentDuration; |
|||
} |
|||
|
|||
@Override |
|||
public long expireAfterRead(@NotNull EntityLimitKey key, @NotNull Boolean value, long currentTime, long currentDuration) { |
|||
return currentDuration; |
|||
} |
|||
}) |
|||
.maximumSize(maxSize) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public boolean get(EntityLimitKey key) { |
|||
var result = cache.getIfPresent(key); |
|||
return result != null ? result : false; |
|||
} |
|||
|
|||
@Override |
|||
public void put(EntityLimitKey key, boolean value) { |
|||
cache.put(key, value); |
|||
} |
|||
} |
|||
@ -0,0 +1,27 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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.common.transport.limits; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
|
|||
@Data |
|||
public class EntityLimitKey { |
|||
|
|||
private final TenantId tenantId; |
|||
private final String deviceName; |
|||
|
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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.common.transport.limits; |
|||
|
|||
public interface EntityLimitsCache { |
|||
|
|||
boolean get(EntityLimitKey key); |
|||
|
|||
void put(EntityLimitKey key, boolean value); |
|||
|
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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.dao.exception; |
|||
|
|||
import lombok.Getter; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
|
|||
public class EntitiesLimitException extends DataValidationException { |
|||
private static final long serialVersionUID = -9211462514373279196L; |
|||
|
|||
@Getter |
|||
private final TenantId tenantId; |
|||
@Getter |
|||
private final EntityType entityType; |
|||
|
|||
public EntitiesLimitException(TenantId tenantId, EntityType entityType) { |
|||
super(entityType.getNormalName() + "s limit reached"); |
|||
this.tenantId = tenantId; |
|||
this.entityType = entityType; |
|||
} |
|||
} |
|||
@ -0,0 +1,222 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="rangeChartWidgetConfigForm"> |
|||
<tb-timewindow-config-panel formControlName="timewindowConfig"> |
|||
</tb-timewindow-config-panel> |
|||
<tb-datasources |
|||
[configMode]="basicMode" |
|||
hideDatasourceLabel |
|||
hideDataKeyColor |
|||
hideDataKeyUnits |
|||
hideDataKeyDecimals |
|||
formControlName="datasources"> |
|||
</tb-datasources> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.appearance</div> |
|||
<div class="tb-form-row column-xs"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showTitle"> |
|||
{{ 'widget-config.title' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field class="flex" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="title" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-font-settings formControlName="titleFont" |
|||
clearButton |
|||
[previewText]="rangeChartWidgetConfigForm.get('title').value" |
|||
[initialPreviewStyle]="widgetConfig.config.titleStyle"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="titleColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row column-xs"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="showIcon"> |
|||
{{ 'widgets.value-chart-card.icon' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<mat-form-field appearance="outline" class="flex number" subscriptSizing="dynamic"> |
|||
<input matInput type="number" min="0" formControlName="iconSize" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
<tb-css-unit-select fxFlex formControlName="iconSizeUnit"></tb-css-unit-select> |
|||
<tb-material-icon-select asBoxInput |
|||
iconClearButton |
|||
[color]="rangeChartWidgetConfigForm.get('iconColor').value" |
|||
formControlName="icon"> |
|||
</tb-material-icon-select> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="iconColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widgets.range-chart.chart</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="dataZoom"> |
|||
{{ 'widgets.range-chart.data-zoom' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.range-colors' | translate }}</div> |
|||
<tb-color-range-settings formControlName="rangeColors"> |
|||
</tb-color-range-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.out-of-range-color' | translate }}</div> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="outOfRangeColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="fillArea"> |
|||
{{ 'widgets.range-chart.fill-area' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div translate>widget-config.units-short</div> |
|||
<tb-unit-input |
|||
formControlName="units"> |
|||
</tb-unit-input> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div translate>widget-config.decimals-short</div> |
|||
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="decimals" type="number" min="0" max="15" step="1" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-panel tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetConfigForm.get('showLegend').value" [disabled]="!rangeChartWidgetConfigForm.get('showLegend').value"> |
|||
<mat-expansion-panel-header fxLayout="row wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showLegend" (click)="$event.stopPropagation()" |
|||
fxLayoutAlign="center"> |
|||
{{ 'widget-config.legend' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'legend.position' | translate }}</div> |
|||
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="legendPosition"> |
|||
<mat-option *ngFor="let pos of legendPositions" [value]="pos"> |
|||
{{ legendPositionTranslationMap.get(pos) | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.legend-label' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-font-settings formControlName="legendLabelFont" |
|||
previewText="20 - 30"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="legendLabelColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<div class="tb-form-panel tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetConfigForm.get('showTooltip').value" [disabled]="!rangeChartWidgetConfigForm.get('showTooltip').value"> |
|||
<mat-expansion-panel-header fxLayout="row wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showTooltip" (click)="$event.stopPropagation()" |
|||
fxLayoutAlign="center"> |
|||
{{ 'widgets.range-chart.tooltip' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-value' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-font-settings formControlName="tooltipValueFont" |
|||
[previewText]="tooltipValuePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipValueColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row column-xs"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="tooltipShowDate"> |
|||
{{ 'widgets.range-chart.tooltip-date' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-date-format-select fxFlex excludeLastUpdateAgo formControlName="tooltipDateFormat"></tb-date-format-select> |
|||
<tb-font-settings formControlName="tooltipDateFont" |
|||
[previewText]="tooltipDatePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipDateColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-background-color' | translate }}</div> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipBackgroundColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-background-blur' | translate }}</div> |
|||
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="tooltipBackgroundBlur" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<div matSuffix>px</div> |
|||
</mat-form-field> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widget-config.card-appearance</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.background.background' | translate }}</div> |
|||
<tb-background-settings formControlName="background"> |
|||
</tb-background-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between column-lt-md"> |
|||
<div translate>widget-config.show-card-buttons</div> |
|||
<mat-chip-listbox multiple formControlName="cardButtons"> |
|||
<mat-chip-option value="fullscreen">{{ 'fullscreen.fullscreen' | translate }}</mat-chip-option> |
|||
</mat-chip-listbox> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widget-config.card-border-radius' | translate }}</div> |
|||
<mat-form-field appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="borderRadius" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
</mat-form-field> |
|||
</div> |
|||
</div> |
|||
<tb-widget-actions-panel |
|||
formControlName="actions"> |
|||
</tb-widget-actions-panel> |
|||
</ng-container> |
|||
@ -0,0 +1,269 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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, Injector } from '@angular/core'; |
|||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { DataKey, legendPositions, legendPositionTranslationMap, WidgetConfig, } from '@shared/models/widget.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { |
|||
getTimewindowConfig, |
|||
setTimewindowConfig |
|||
} from '@home/components/widget/config/timewindow-config-panel.component'; |
|||
import { formatValue, isUndefined } from '@core/utils'; |
|||
import { |
|||
cssSizeToStrSize, |
|||
DateFormatProcessor, |
|||
DateFormatSettings, |
|||
resolveCssSize |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { |
|||
rangeChartDefaultSettings, |
|||
RangeChartWidgetSettings |
|||
} from '@home/components/widget/lib/chart/range-chart-widget.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-range-chart-basic-config', |
|||
templateUrl: './range-chart-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'] |
|||
}) |
|||
export class RangeChartBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
legendPositions = legendPositions; |
|||
|
|||
legendPositionTranslationMap = legendPositionTranslationMap; |
|||
|
|||
rangeChartWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); |
|||
|
|||
tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private $injector: Injector, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.rangeChartWidgetConfigForm; |
|||
} |
|||
|
|||
protected defaultDataKeys(configData: WidgetConfigComponentData): DataKey[] { |
|||
return [{ name: 'temperature', label: 'Temperature', type: DataKeyType.timeseries }]; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: RangeChartWidgetSettings = {...rangeChartDefaultSettings, ...(configData.config.settings || {})}; |
|||
const iconSize = resolveCssSize(configData.config.iconSize); |
|||
this.rangeChartWidgetConfigForm = this.fb.group({ |
|||
timewindowConfig: [getTimewindowConfig(configData.config), []], |
|||
datasources: [configData.config.datasources, []], |
|||
|
|||
showTitle: [configData.config.showTitle, []], |
|||
title: [configData.config.title, []], |
|||
titleFont: [configData.config.titleFont, []], |
|||
titleColor: [configData.config.titleColor, []], |
|||
|
|||
showIcon: [configData.config.showTitleIcon, []], |
|||
iconSize: [iconSize[0], [Validators.min(0)]], |
|||
iconSizeUnit: [iconSize[1], []], |
|||
icon: [configData.config.titleIcon, []], |
|||
iconColor: [configData.config.iconColor, []], |
|||
|
|||
dataZoom: [settings.dataZoom, []], |
|||
rangeColors: [settings.rangeColors, []], |
|||
outOfRangeColor: [settings.outOfRangeColor, []], |
|||
fillArea: [settings.fillArea, []], |
|||
units: [configData.config.units, []], |
|||
decimals: [configData.config.decimals, []], |
|||
|
|||
showLegend: [settings.showLegend, []], |
|||
legendPosition: [settings.legendPosition, []], |
|||
legendLabelFont: [settings.legendLabelFont, []], |
|||
legendLabelColor: [settings.legendLabelColor, []], |
|||
|
|||
showTooltip: [settings.showTooltip, []], |
|||
tooltipValueFont: [settings.tooltipValueFont, []], |
|||
tooltipValueColor: [settings.tooltipValueColor, []], |
|||
tooltipShowDate: [settings.tooltipShowDate, []], |
|||
tooltipDateFormat: [settings.tooltipDateFormat, []], |
|||
tooltipDateFont: [settings.tooltipDateFont, []], |
|||
tooltipDateColor: [settings.tooltipDateColor, []], |
|||
tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], |
|||
tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], |
|||
|
|||
background: [settings.background, []], |
|||
|
|||
cardButtons: [this.getCardButtons(configData.config), []], |
|||
borderRadius: [configData.config.borderRadius, []], |
|||
|
|||
actions: [configData.config.actions || {}, []] |
|||
}); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); |
|||
this.widgetConfig.config.datasources = config.datasources; |
|||
|
|||
this.widgetConfig.config.showTitle = config.showTitle; |
|||
this.widgetConfig.config.title = config.title; |
|||
this.widgetConfig.config.titleFont = config.titleFont; |
|||
this.widgetConfig.config.titleColor = config.titleColor; |
|||
|
|||
this.widgetConfig.config.showTitleIcon = config.showIcon; |
|||
this.widgetConfig.config.iconSize = cssSizeToStrSize(config.iconSize, config.iconSizeUnit); |
|||
this.widgetConfig.config.titleIcon = config.icon; |
|||
this.widgetConfig.config.iconColor = config.iconColor; |
|||
|
|||
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
|||
|
|||
this.widgetConfig.config.settings.dataZoom = config.dataZoom; |
|||
this.widgetConfig.config.settings.rangeColors = config.rangeColors; |
|||
this.widgetConfig.config.settings.outOfRangeColor = config.outOfRangeColor; |
|||
this.widgetConfig.config.settings.fillArea = config.fillArea; |
|||
this.widgetConfig.config.units = config.units; |
|||
this.widgetConfig.config.decimals = config.decimals; |
|||
|
|||
this.widgetConfig.config.settings.showLegend = config.showLegend; |
|||
this.widgetConfig.config.settings.legendPosition = config.legendPosition; |
|||
this.widgetConfig.config.settings.legendLabelFont = config.legendLabelFont; |
|||
this.widgetConfig.config.settings.legendLabelColor = config.legendLabelColor; |
|||
|
|||
this.widgetConfig.config.settings.showTooltip = config.showTooltip; |
|||
this.widgetConfig.config.settings.tooltipValueFont = config.tooltipValueFont; |
|||
this.widgetConfig.config.settings.tooltipValueColor = config.tooltipValueColor; |
|||
this.widgetConfig.config.settings.tooltipShowDate = config.tooltipShowDate; |
|||
this.widgetConfig.config.settings.tooltipDateFormat = config.tooltipDateFormat; |
|||
this.widgetConfig.config.settings.tooltipDateFont = config.tooltipDateFont; |
|||
this.widgetConfig.config.settings.tooltipDateColor = config.tooltipDateColor; |
|||
this.widgetConfig.config.settings.tooltipBackgroundColor = config.tooltipBackgroundColor; |
|||
this.widgetConfig.config.settings.tooltipBackgroundBlur = config.tooltipBackgroundBlur; |
|||
|
|||
this.widgetConfig.config.settings.background = config.background; |
|||
|
|||
this.setCardButtons(config.cardButtons, this.widgetConfig.config); |
|||
this.widgetConfig.config.borderRadius = config.borderRadius; |
|||
|
|||
this.widgetConfig.config.actions = config.actions; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showTitle', 'showIcon', 'showLegend', 'showTooltip', 'tooltipShowDate']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean, trigger?: string) { |
|||
const showTitle: boolean = this.rangeChartWidgetConfigForm.get('showTitle').value; |
|||
const showIcon: boolean = this.rangeChartWidgetConfigForm.get('showIcon').value; |
|||
const showLegend: boolean = this.rangeChartWidgetConfigForm.get('showLegend').value; |
|||
const showTooltip: boolean = this.rangeChartWidgetConfigForm.get('showTooltip').value; |
|||
const tooltipShowDate: boolean = this.rangeChartWidgetConfigForm.get('tooltipShowDate').value; |
|||
|
|||
if (showTitle) { |
|||
this.rangeChartWidgetConfigForm.get('title').enable(); |
|||
this.rangeChartWidgetConfigForm.get('titleFont').enable(); |
|||
this.rangeChartWidgetConfigForm.get('titleColor').enable(); |
|||
this.rangeChartWidgetConfigForm.get('showIcon').enable({emitEvent: false}); |
|||
if (showIcon) { |
|||
this.rangeChartWidgetConfigForm.get('iconSize').enable(); |
|||
this.rangeChartWidgetConfigForm.get('iconSizeUnit').enable(); |
|||
this.rangeChartWidgetConfigForm.get('icon').enable(); |
|||
this.rangeChartWidgetConfigForm.get('iconColor').enable(); |
|||
} else { |
|||
this.rangeChartWidgetConfigForm.get('iconSize').disable(); |
|||
this.rangeChartWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.rangeChartWidgetConfigForm.get('icon').disable(); |
|||
this.rangeChartWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
} else { |
|||
this.rangeChartWidgetConfigForm.get('title').disable(); |
|||
this.rangeChartWidgetConfigForm.get('titleFont').disable(); |
|||
this.rangeChartWidgetConfigForm.get('titleColor').disable(); |
|||
this.rangeChartWidgetConfigForm.get('showIcon').disable({emitEvent: false}); |
|||
this.rangeChartWidgetConfigForm.get('iconSize').disable(); |
|||
this.rangeChartWidgetConfigForm.get('iconSizeUnit').disable(); |
|||
this.rangeChartWidgetConfigForm.get('icon').disable(); |
|||
this.rangeChartWidgetConfigForm.get('iconColor').disable(); |
|||
} |
|||
|
|||
if (showLegend) { |
|||
this.rangeChartWidgetConfigForm.get('legendPosition').enable(); |
|||
this.rangeChartWidgetConfigForm.get('legendLabelFont').enable(); |
|||
this.rangeChartWidgetConfigForm.get('legendLabelColor').enable(); |
|||
} else { |
|||
this.rangeChartWidgetConfigForm.get('legendPosition').disable(); |
|||
this.rangeChartWidgetConfigForm.get('legendLabelFont').disable(); |
|||
this.rangeChartWidgetConfigForm.get('legendLabelColor').disable(); |
|||
} |
|||
|
|||
if (showTooltip) { |
|||
this.rangeChartWidgetConfigForm.get('tooltipValueFont').enable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipValueColor').enable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipShowDate').enable({emitEvent: false}); |
|||
this.rangeChartWidgetConfigForm.get('tooltipBackgroundColor').enable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipBackgroundBlur').enable(); |
|||
if (tooltipShowDate) { |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').enable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFont').enable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateColor').enable(); |
|||
} else { |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFont').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateColor').disable(); |
|||
} |
|||
} else { |
|||
this.rangeChartWidgetConfigForm.get('tooltipValueFont').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipValueColor').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipShowDate').disable({emitEvent: false}); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFormat').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateFont').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipDateColor').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipBackgroundColor').disable(); |
|||
this.rangeChartWidgetConfigForm.get('tooltipBackgroundBlur').disable(); |
|||
} |
|||
} |
|||
|
|||
private getCardButtons(config: WidgetConfig): string[] { |
|||
const buttons: string[] = []; |
|||
if (isUndefined(config.enableFullscreen) || config.enableFullscreen) { |
|||
buttons.push('fullscreen'); |
|||
} |
|||
return buttons; |
|||
} |
|||
|
|||
private setCardButtons(buttons: string[], config: WidgetConfig) { |
|||
config.enableFullscreen = buttons.includes('fullscreen'); |
|||
} |
|||
|
|||
private _tooltipValuePreviewFn(): string { |
|||
const units: string = this.rangeChartWidgetConfigForm.get('units').value; |
|||
const decimals: number = this.rangeChartWidgetConfigForm.get('decimals').value; |
|||
return formatValue(22, decimals, units, false); |
|||
} |
|||
|
|||
private _tooltipDatePreviewFn(): string { |
|||
const dateFormat: DateFormatSettings = this.rangeChartWidgetConfigForm.get('tooltipDateFormat').value; |
|||
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); |
|||
processor.update(Date.now()); |
|||
return processor.formatted; |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="tb-range-chart-panel" [style]="backgroundStyle"> |
|||
<div class="tb-range-chart-overlay" [style]="overlayStyle"></div> |
|||
<ng-container *ngTemplateOutlet="widgetTitlePanel"></ng-container> |
|||
<div class="tb-range-chart-content" [class]="legendClass"> |
|||
<div #chartShape class="tb-range-chart-shape"> |
|||
</div> |
|||
<div *ngIf="showLegend" class="tb-range-chart-legend"> |
|||
<div class="tb-range-chart-legend-item" *ngFor="let rangeItem of visibleRangeItems" |
|||
(click)="toggleRangeItem(rangeItem)"> |
|||
<div class="tb-range-chart-legend-item-label"> |
|||
<div class="tb-range-chart-legend-item-label-circle" [style]="{background: rangeItem.enabled ? rangeItem.color : null}"></div> |
|||
<div [style]="rangeItem.enabled ? legendLabelStyle : disabledLegendLabelStyle">{{ rangeItem.label }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,105 @@ |
|||
/** |
|||
* Copyright © 2016-2023 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-range-chart-panel { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: relative; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
padding: 20px 24px 24px 24px; |
|||
> div:not(.tb-range-chart-overlay) { |
|||
z-index: 1; |
|||
} |
|||
.tb-range-chart-overlay { |
|||
position: absolute; |
|||
top: 12px; |
|||
left: 12px; |
|||
bottom: 12px; |
|||
right: 12px; |
|||
} |
|||
div.tb-widget-title { |
|||
padding: 0; |
|||
} |
|||
.tb-range-chart-content { |
|||
flex: 1; |
|||
min-width: 0; |
|||
min-height: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
&.legend-top { |
|||
flex-direction: column-reverse; |
|||
} |
|||
&.legend-right { |
|||
flex-direction: row; |
|||
} |
|||
&.legend-left { |
|||
flex-direction: row-reverse; |
|||
} |
|||
.tb-range-chart-shape { |
|||
flex: 1; |
|||
min-width: 0; |
|||
min-height: 0; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
.tb-range-chart-legend { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
align-self: stretch; |
|||
flex-wrap: wrap; |
|||
column-gap: 24px; |
|||
row-gap: 8px; |
|||
.tb-range-chart-legend-item { |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
align-items: flex-start; |
|||
user-select: none; |
|||
cursor: pointer; |
|||
.tb-range-chart-legend-item-label { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 4px; |
|||
color: #ccc; |
|||
.tb-range-chart-legend-item-label-circle { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
background-color: #ccc; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
&.legend-right, &.legend-left { |
|||
gap: 24px; |
|||
.tb-range-chart-legend { |
|||
flex-direction: column-reverse; |
|||
justify-content: flex-end; |
|||
align-items: stretch; |
|||
.tb-range-chart-legend-item { |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,527 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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 { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { |
|||
backgroundStyle, |
|||
ColorRange, |
|||
ComponentStyle, |
|||
DateFormatProcessor, filterIncludingColorRanges, |
|||
getDataKey, |
|||
overlayStyle, |
|||
sortedColorRange, |
|||
textStyle |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { ResizeObserver } from '@juggle/resize-observer'; |
|||
import * as echarts from 'echarts/core'; |
|||
import { formatValue, isDefinedAndNotNull, isNumber } from '@core/utils'; |
|||
import { |
|||
DataZoomComponent, |
|||
DataZoomComponentOption, |
|||
GridComponent, |
|||
GridComponentOption, |
|||
MarkLineComponent, |
|||
MarkLineComponentOption, |
|||
TooltipComponent, |
|||
TooltipComponentOption, |
|||
VisualMapComponent, |
|||
VisualMapComponentOption |
|||
} from 'echarts/components'; |
|||
import { LineChart, LineSeriesOption, } from 'echarts/charts'; |
|||
import { CanvasRenderer } from 'echarts/renderers'; |
|||
import { rangeChartDefaultSettings, RangeChartWidgetSettings } from './range-chart-widget.models'; |
|||
import { DataSet } from '@shared/models/widget.models'; |
|||
|
|||
echarts.use([ |
|||
TooltipComponent, |
|||
GridComponent, |
|||
VisualMapComponent, |
|||
DataZoomComponent, |
|||
MarkLineComponent, |
|||
LineChart, |
|||
CanvasRenderer |
|||
]); |
|||
|
|||
type EChartsOption = echarts.ComposeOption< |
|||
| TooltipComponentOption |
|||
| GridComponentOption |
|||
| VisualMapComponentOption |
|||
| DataZoomComponentOption |
|||
| MarkLineComponentOption |
|||
| LineSeriesOption |
|||
>; |
|||
|
|||
type ECharts = echarts.ECharts; |
|||
|
|||
interface VisualPiece { |
|||
lt?: number; |
|||
gt?: number; |
|||
lte?: number; |
|||
gte?: number; |
|||
value?: number; |
|||
color?: string; |
|||
} |
|||
|
|||
interface RangeItem { |
|||
index: number; |
|||
from?: number; |
|||
to?: number; |
|||
piece: VisualPiece; |
|||
color: string; |
|||
label: string; |
|||
visible: boolean; |
|||
enabled: boolean; |
|||
} |
|||
|
|||
const rangeItemLabel = (from?: number, to?: number): string => { |
|||
if (isNumber(from) && isNumber(to)) { |
|||
if (from === to) { |
|||
return `${from}`; |
|||
} else { |
|||
return `${from} - ${to}`; |
|||
} |
|||
} else if (isNumber(from)) { |
|||
return `≥ ${from}`; |
|||
} else if (isNumber(to)) { |
|||
return `< ${to}`; |
|||
} else { |
|||
return null; |
|||
} |
|||
}; |
|||
|
|||
const toVisualPiece = (color: string, from?: number, to?: number): VisualPiece => { |
|||
const piece: VisualPiece = { |
|||
color |
|||
}; |
|||
if (isNumber(from) && isNumber(to)) { |
|||
if (from === to) { |
|||
piece.value = from; |
|||
} else { |
|||
piece.gte = from; |
|||
piece.lt = to; |
|||
} |
|||
} else if (isNumber(from)) { |
|||
piece.gte = from; |
|||
} else if (isNumber(to)) { |
|||
piece.lt = to; |
|||
} |
|||
return piece; |
|||
}; |
|||
|
|||
const toRangeItems = (colorRanges: Array<ColorRange>): RangeItem[] => { |
|||
const rangeItems: RangeItem[] = []; |
|||
let counter = 0; |
|||
const ranges = sortedColorRange(filterIncludingColorRanges(colorRanges)).filter(r => isNumber(r.from) || isNumber(r.to)); |
|||
for (let i = 0; i < ranges.length; i++) { |
|||
const range = ranges[i]; |
|||
let from = range.from; |
|||
const to = range.to; |
|||
if (i > 0) { |
|||
const prevRange = ranges[i - 1]; |
|||
if (isNumber(prevRange.to) && isNumber(from) && from < prevRange.to) { |
|||
from = prevRange.to; |
|||
} |
|||
} |
|||
rangeItems.push( |
|||
{ |
|||
index: counter++, |
|||
color: range.color, |
|||
enabled: true, |
|||
visible: true, |
|||
from, |
|||
to, |
|||
label: rangeItemLabel(from, to), |
|||
piece: toVisualPiece(range.color, from, to) |
|||
} |
|||
); |
|||
if (!isNumber(from) || !isNumber(to)) { |
|||
const value = !isNumber(from) ? to : from; |
|||
rangeItems.push( |
|||
{ |
|||
index: counter++, |
|||
color: 'transparent', |
|||
enabled: true, |
|||
visible: false, |
|||
label: '', |
|||
piece: { gt: value - 0.000000001, lt: value + 0.000000001, color: 'transparent'} |
|||
} |
|||
); |
|||
} |
|||
} |
|||
return rangeItems; |
|||
}; |
|||
|
|||
const toNamedData = (data: DataSet): {name: string; value: [number, any]}[] => { |
|||
if (!data?.length) { |
|||
return []; |
|||
} else { |
|||
return data.map(d => ({ |
|||
name: d[0] + '', |
|||
value: d |
|||
})); |
|||
} |
|||
}; |
|||
|
|||
const getMarkPoints = (ranges: Array<RangeItem>): number[] => { |
|||
const points = new Set<number>(); |
|||
for (const range of ranges) { |
|||
if (range.visible) { |
|||
if (isNumber(range.from)) { |
|||
points.add(range.from); |
|||
} |
|||
if (isNumber(range.to)) { |
|||
points.add(range.to); |
|||
} |
|||
} |
|||
} |
|||
return Array.from(points).sort(); |
|||
}; |
|||
|
|||
@Component({ |
|||
selector: 'tb-range-chart-widget', |
|||
templateUrl: './range-chart-widget.component.html', |
|||
styleUrls: ['./range-chart-widget.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewInit { |
|||
|
|||
@ViewChild('chartShape', {static: false}) |
|||
chartShape: ElementRef<HTMLElement>; |
|||
|
|||
settings: RangeChartWidgetSettings; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
@Input() |
|||
widgetTitlePanel: TemplateRef<any>; |
|||
|
|||
showLegend: boolean; |
|||
legendClass: string; |
|||
|
|||
backgroundStyle: ComponentStyle = {}; |
|||
overlayStyle: ComponentStyle = {}; |
|||
|
|||
legendLabelStyle: ComponentStyle; |
|||
disabledLegendLabelStyle: ComponentStyle; |
|||
visibleRangeItems: RangeItem[]; |
|||
|
|||
private rangeItems: RangeItem[]; |
|||
|
|||
private shapeResize$: ResizeObserver; |
|||
|
|||
private decimals = 0; |
|||
private units = ''; |
|||
|
|||
private drawChartPending = false; |
|||
private rangeChart: ECharts; |
|||
private rangeChartOptions: EChartsOption; |
|||
private selectedRanges: {[key: number]: boolean} = {}; |
|||
|
|||
private tooltipDateFormat: DateFormatProcessor; |
|||
|
|||
constructor(private renderer: Renderer2, |
|||
private cd: ChangeDetectorRef) { |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.rangeChartWidget = this; |
|||
this.settings = {...rangeChartDefaultSettings, ...this.ctx.settings}; |
|||
|
|||
this.decimals = this.ctx.decimals; |
|||
this.units = this.ctx.units; |
|||
const dataKey = getDataKey(this.ctx.datasources); |
|||
if (isDefinedAndNotNull(dataKey?.decimals)) { |
|||
this.decimals = dataKey.decimals; |
|||
} |
|||
if (dataKey?.units) { |
|||
this.units = dataKey.units; |
|||
} |
|||
|
|||
|
|||
this.backgroundStyle = backgroundStyle(this.settings.background); |
|||
this.overlayStyle = overlayStyle(this.settings.background.overlay); |
|||
|
|||
this.rangeItems = toRangeItems(this.settings.rangeColors); |
|||
this.visibleRangeItems = this.rangeItems.filter(item => item.visible); |
|||
for (const range of this.rangeItems) { |
|||
this.selectedRanges[range.index] = true; |
|||
} |
|||
|
|||
this.showLegend = this.settings.showLegend && !!this.rangeItems.length; |
|||
|
|||
if (this.showLegend) { |
|||
this.legendClass = `legend-${this.settings.legendPosition}`; |
|||
this.legendLabelStyle = textStyle(this.settings.legendLabelFont); |
|||
this.disabledLegendLabelStyle = textStyle(this.settings.legendLabelFont); |
|||
this.legendLabelStyle.color = this.settings.legendLabelColor; |
|||
} |
|||
|
|||
if (this.settings.showTooltip && this.settings.tooltipShowDate) { |
|||
this.tooltipDateFormat = DateFormatProcessor.fromSettings(this.ctx.$injector, this.settings.tooltipDateFormat); |
|||
} |
|||
} |
|||
|
|||
ngAfterViewInit() { |
|||
if (this.drawChartPending) { |
|||
this.drawChart(); |
|||
} |
|||
} |
|||
|
|||
ngOnDestroy() { |
|||
if (this.shapeResize$) { |
|||
this.shapeResize$.disconnect(); |
|||
} |
|||
if (this.rangeChart) { |
|||
this.rangeChart.dispose(); |
|||
} |
|||
} |
|||
|
|||
public onInit() { |
|||
const borderRadius = this.ctx.$widgetElement.css('borderRadius'); |
|||
this.overlayStyle = {...this.overlayStyle, ...{borderRadius}}; |
|||
if (this.chartShape) { |
|||
this.drawChart(); |
|||
} else { |
|||
this.drawChartPending = true; |
|||
} |
|||
this.cd.detectChanges(); |
|||
} |
|||
|
|||
public onDataUpdated() { |
|||
if (this.rangeChart) { |
|||
this.rangeChart.setOption({ |
|||
xAxis: { |
|||
min: this.ctx.defaultSubscription.timeWindow.minTime, |
|||
max: this.ctx.defaultSubscription.timeWindow.maxTime |
|||
}, |
|||
series: [ |
|||
{data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : []} |
|||
], |
|||
visualMap: { |
|||
selected: this.selectedRanges |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
public toggleRangeItem(item: RangeItem) { |
|||
item.enabled = !item.enabled; |
|||
this.selectedRanges[item.index] = item.enabled; |
|||
this.rangeChart.dispatchAction({ |
|||
type: 'selectDataRange', |
|||
selected: this.selectedRanges |
|||
}); |
|||
} |
|||
|
|||
private drawChart() { |
|||
const dataKey = getDataKey(this.ctx.datasources); |
|||
this.rangeChart = echarts.init(this.chartShape.nativeElement, null, { |
|||
renderer: 'canvas', |
|||
}); |
|||
this.rangeChartOptions = { |
|||
tooltip: { |
|||
trigger: 'none' |
|||
}, |
|||
grid: { |
|||
containLabel: true, |
|||
top: '30', |
|||
left: 0, |
|||
right: 0, |
|||
bottom: this.settings.dataZoom ? 60 : 0 |
|||
}, |
|||
xAxis: { |
|||
type: 'time', |
|||
axisTick: { |
|||
show: true |
|||
}, |
|||
axisLabel: { |
|||
hideOverlap: true, |
|||
fontSize: 10 |
|||
}, |
|||
axisLine: { |
|||
onZero: false |
|||
}, |
|||
min: this.ctx.defaultSubscription.timeWindow.minTime, |
|||
max: this.ctx.defaultSubscription.timeWindow.maxTime |
|||
}, |
|||
yAxis: { |
|||
type: 'value', |
|||
axisLabel: { |
|||
formatter: value => formatValue(value, this.decimals, this.units, false) |
|||
} |
|||
}, |
|||
series: [{ |
|||
type: 'line', |
|||
name: dataKey?.label, |
|||
smooth: false, |
|||
showSymbol: false, |
|||
animation: true, |
|||
areaStyle: this.settings.fillArea ? {} : undefined, |
|||
data: this.ctx.data?.length ? toNamedData(this.ctx.data[0].data) : [], |
|||
markLine: this.rangeItems.length ? { |
|||
animation: true, |
|||
symbol: ['circle', 'arrow'], |
|||
symbolSize: [5, 7], |
|||
lineStyle: { |
|||
width: 1, |
|||
type: [3, 3], |
|||
color: '#37383b' |
|||
}, |
|||
label: { |
|||
position: 'insideEndTop', |
|||
color: '#37383b', |
|||
backgroundColor: 'rgba(255,255,255,0.56)', |
|||
padding: [4, 5], |
|||
borderRadius: 4, |
|||
formatter: params => formatValue(params.value, this.decimals, this.units, false) |
|||
}, |
|||
emphasis: { |
|||
disabled: true |
|||
}, |
|||
data: getMarkPoints(this.rangeItems).map(point => ({ yAxis: point })) |
|||
} : undefined |
|||
}], |
|||
dataZoom: [ |
|||
{ |
|||
type: 'inside', |
|||
disabled: !this.settings.dataZoom |
|||
}, |
|||
{ |
|||
type: 'slider', |
|||
show: this.settings.dataZoom, |
|||
showDetail: false, |
|||
right: 10 |
|||
} |
|||
], |
|||
visualMap: { |
|||
show: false, |
|||
type: 'piecewise', |
|||
selected: this.selectedRanges, |
|||
pieces: this.rangeItems.map(item => item.piece), |
|||
outOfRange: { |
|||
color: this.settings.outOfRangeColor |
|||
}, |
|||
inRange: !this.rangeItems.length ? { |
|||
color: this.settings.outOfRangeColor |
|||
} : undefined |
|||
} |
|||
}; |
|||
|
|||
if (this.settings.showTooltip) { |
|||
this.rangeChartOptions.tooltip = { |
|||
trigger: 'axis', |
|||
formatter: (params) => { |
|||
if (!params.length || !params[0]) { |
|||
return null; |
|||
} |
|||
const seriesParams = params[0]; |
|||
const value = formatValue(seriesParams.value[1], this.decimals, this.units, false); |
|||
const tooltipElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.setStyle(tooltipElement, 'display', 'flex'); |
|||
this.renderer.setStyle(tooltipElement, 'flex-direction', 'column'); |
|||
this.renderer.setStyle(tooltipElement, 'align-items', 'flex-start'); |
|||
this.renderer.setStyle(tooltipElement, 'gap', '4px'); |
|||
if (this.settings.tooltipShowDate) { |
|||
const dateElement: HTMLElement = this.renderer.createElement('div'); |
|||
const ts = seriesParams.value[0]; |
|||
this.tooltipDateFormat.update(ts); |
|||
this.renderer.appendChild(dateElement, this.renderer.createText(this.tooltipDateFormat.formatted)); |
|||
this.renderer.setStyle(dateElement, 'font-family', this.settings.tooltipDateFont.family); |
|||
this.renderer.setStyle(dateElement, 'font-size', this.settings.tooltipDateFont.size + this.settings.tooltipDateFont.sizeUnit); |
|||
this.renderer.setStyle(dateElement, 'font-style', this.settings.tooltipDateFont.style); |
|||
this.renderer.setStyle(dateElement, 'font-weight', this.settings.tooltipDateFont.weight); |
|||
this.renderer.setStyle(dateElement, 'line-height', this.settings.tooltipDateFont.lineHeight); |
|||
this.renderer.setStyle(dateElement, 'color', this.settings.tooltipDateColor); |
|||
this.renderer.appendChild(tooltipElement, dateElement); |
|||
} |
|||
const labelValueElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.setStyle(labelValueElement, 'display', 'flex'); |
|||
this.renderer.setStyle(labelValueElement, 'flex-direction', 'row'); |
|||
this.renderer.setStyle(labelValueElement, 'align-items', 'center'); |
|||
this.renderer.setStyle(labelValueElement, 'align-self', 'stretch'); |
|||
this.renderer.setStyle(labelValueElement, 'gap', '12px'); |
|||
this.renderer.appendChild(tooltipElement, labelValueElement); |
|||
const labelElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.setStyle(labelElement, 'display', 'flex'); |
|||
this.renderer.setStyle(labelElement, 'align-items', 'center'); |
|||
this.renderer.setStyle(labelElement, 'gap', '8px'); |
|||
this.renderer.appendChild(labelValueElement, labelElement); |
|||
const circleElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.setStyle(circleElement, 'width', '8px'); |
|||
this.renderer.setStyle(circleElement, 'height', '8px'); |
|||
this.renderer.setStyle(circleElement, 'border-radius', '50%'); |
|||
this.renderer.setStyle(circleElement, 'background', seriesParams.color); |
|||
this.renderer.appendChild(labelElement, circleElement); |
|||
const labelTextElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.appendChild(labelTextElement, this.renderer.createText(seriesParams.seriesName)); |
|||
this.renderer.setStyle(labelTextElement, 'font-family', 'Roboto'); |
|||
this.renderer.setStyle(labelTextElement, 'font-size', '12px'); |
|||
this.renderer.setStyle(labelTextElement, 'font-style', 'normal'); |
|||
this.renderer.setStyle(labelTextElement, 'font-weight', '400'); |
|||
this.renderer.setStyle(labelTextElement, 'line-height', '16px'); |
|||
this.renderer.setStyle(labelTextElement, 'letter-spacing', '0.4px'); |
|||
this.renderer.setStyle(labelTextElement, 'color', 'rgba(0, 0, 0, 0.76)'); |
|||
this.renderer.appendChild(labelElement, labelTextElement); |
|||
const valueElement: HTMLElement = this.renderer.createElement('div'); |
|||
this.renderer.appendChild(valueElement, this.renderer.createText(value)); |
|||
this.renderer.setStyle(valueElement, 'font-family', this.settings.tooltipValueFont.family); |
|||
this.renderer.setStyle(valueElement, 'font-size', this.settings.tooltipValueFont.size + this.settings.tooltipValueFont.sizeUnit); |
|||
this.renderer.setStyle(valueElement, 'font-style', this.settings.tooltipValueFont.style); |
|||
this.renderer.setStyle(valueElement, 'font-weight', this.settings.tooltipValueFont.weight); |
|||
this.renderer.setStyle(valueElement, 'line-height', this.settings.tooltipValueFont.lineHeight); |
|||
this.renderer.setStyle(valueElement, 'color', this.settings.tooltipValueColor); |
|||
this.renderer.appendChild(labelValueElement, valueElement); |
|||
return tooltipElement; |
|||
}, |
|||
padding: [8, 12], |
|||
backgroundColor: this.settings.tooltipBackgroundColor, |
|||
extraCssText: `line-height: 1; backdrop-filter: blur(${this.settings.tooltipBackgroundBlur}px);` |
|||
}; |
|||
} |
|||
|
|||
this.rangeChart.setOption(this.rangeChartOptions); |
|||
|
|||
this.shapeResize$ = new ResizeObserver(() => { |
|||
this.onResize(); |
|||
}); |
|||
this.shapeResize$.observe(this.chartShape.nativeElement); |
|||
this.onResize(); |
|||
} |
|||
|
|||
private onResize() { |
|||
const width = this.rangeChart.getWidth(); |
|||
const height = this.rangeChart.getHeight(); |
|||
const shapeWidth = this.chartShape.nativeElement.offsetWidth; |
|||
const shapeHeight = this.chartShape.nativeElement.offsetHeight; |
|||
if (width !== shapeWidth || height !== shapeHeight) { |
|||
this.rangeChart.resize(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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 { |
|||
BackgroundSettings, |
|||
BackgroundType, |
|||
ColorRange, |
|||
DateFormatSettings, |
|||
Font, simpleDateFormat |
|||
} from '@shared/models/widget-settings.models'; |
|||
import { LegendPosition } from '@shared/models/widget.models'; |
|||
|
|||
export interface RangeChartWidgetSettings { |
|||
dataZoom: boolean; |
|||
rangeColors: Array<ColorRange>; |
|||
outOfRangeColor: string; |
|||
fillArea: boolean; |
|||
showLegend: boolean; |
|||
legendPosition: LegendPosition; |
|||
legendLabelFont: Font; |
|||
legendLabelColor: string; |
|||
showTooltip: boolean; |
|||
tooltipValueFont: Font; |
|||
tooltipValueColor: string; |
|||
tooltipShowDate: boolean; |
|||
tooltipDateFormat: DateFormatSettings; |
|||
tooltipDateFont: Font; |
|||
tooltipDateColor: string; |
|||
tooltipBackgroundColor: string; |
|||
tooltipBackgroundBlur: number; |
|||
background: BackgroundSettings; |
|||
} |
|||
|
|||
export const rangeChartDefaultSettings: RangeChartWidgetSettings = { |
|||
dataZoom: true, |
|||
rangeColors: [ |
|||
{to: -20, color: '#234CC7'}, |
|||
{from: -20, to: 0, color: '#305AD7'}, |
|||
{from: 0, to: 10, color: '#7191EF'}, |
|||
{from: 10, to: 20, color: '#FFA600'}, |
|||
{from: 20, to: 30, color: '#F36900'}, |
|||
{from: 30, to: 40, color: '#F04022'}, |
|||
{from: 40, color: '#D81838'} |
|||
], |
|||
outOfRangeColor: '#ccc', |
|||
fillArea: true, |
|||
showLegend: true, |
|||
legendPosition: LegendPosition.top, |
|||
legendLabelFont: { |
|||
family: 'Roboto', |
|||
size: 12, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '16px' |
|||
}, |
|||
legendLabelColor: 'rgba(0, 0, 0, 0.76)', |
|||
showTooltip: true, |
|||
tooltipValueFont: { |
|||
family: 'Roboto', |
|||
size: 12, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '500', |
|||
lineHeight: '16px' |
|||
}, |
|||
tooltipValueColor: 'rgba(0, 0, 0, 0.76)', |
|||
tooltipShowDate: true, |
|||
tooltipDateFormat: simpleDateFormat('dd MMM yyyy HH:mm'), |
|||
tooltipDateFont: { |
|||
family: 'Roboto', |
|||
size: 11, |
|||
sizeUnit: 'px', |
|||
style: 'normal', |
|||
weight: '400', |
|||
lineHeight: '16px' |
|||
}, |
|||
tooltipDateColor: 'rgba(0, 0, 0, 0.76)', |
|||
tooltipBackgroundColor: 'rgba(255, 255, 255, 0.76)', |
|||
tooltipBackgroundBlur: 4, |
|||
background: { |
|||
type: BackgroundType.color, |
|||
color: '#fff', |
|||
overlay: { |
|||
enabled: false, |
|||
color: 'rgba(255,255,255,0.72)', |
|||
blur: 3 |
|||
} |
|||
} |
|||
}; |
|||
@ -0,0 +1,140 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2023 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="rangeChartWidgetSettingsForm"> |
|||
<div class="tb-form-panel"> |
|||
<div class="tb-form-panel-title" translate>widgets.range-chart.range-chart-card-style</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="dataZoom"> |
|||
{{ 'widgets.range-chart.data-zoom' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.range-colors' | translate }}</div> |
|||
<tb-color-range-settings formControlName="rangeColors"> |
|||
</tb-color-range-settings> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.out-of-range-color' | translate }}</div> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="outOfRangeColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="fillArea"> |
|||
{{ 'widgets.range-chart.fill-area' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
<div class="tb-form-panel tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetSettingsForm.get('showLegend').value" [disabled]="!rangeChartWidgetSettingsForm.get('showLegend').value"> |
|||
<mat-expansion-panel-header fxLayout="row wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showLegend" (click)="$event.stopPropagation()" |
|||
fxLayoutAlign="center"> |
|||
{{ 'widget-config.legend' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'legend.position' | translate }}</div> |
|||
<mat-form-field class="medium-width" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="legendPosition"> |
|||
<mat-option *ngFor="let pos of legendPositions" [value]="pos"> |
|||
{{ legendPositionTranslationMap.get(pos) | translate }} |
|||
</mat-option> |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.legend-label' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-font-settings formControlName="legendLabelFont" |
|||
previewText="20 - 30"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="legendLabelColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<div class="tb-form-panel tb-slide-toggle"> |
|||
<mat-expansion-panel class="tb-settings" [expanded]="rangeChartWidgetSettingsForm.get('showTooltip').value" [disabled]="!rangeChartWidgetSettingsForm.get('showTooltip').value"> |
|||
<mat-expansion-panel-header fxLayout="row wrap"> |
|||
<mat-panel-title> |
|||
<mat-slide-toggle class="mat-slide" formControlName="showTooltip" (click)="$event.stopPropagation()" |
|||
fxLayoutAlign="center"> |
|||
{{ 'widgets.range-chart.tooltip' | translate }} |
|||
</mat-slide-toggle> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-value' | translate }}</div> |
|||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-font-settings formControlName="tooltipValueFont" |
|||
[previewText]="tooltipValuePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipValueColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row column-xs"> |
|||
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="tooltipShowDate"> |
|||
{{ 'widgets.range-chart.tooltip-date' | translate }} |
|||
</mat-slide-toggle> |
|||
<div fxFlex.gt-xs fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px"> |
|||
<tb-date-format-select fxFlex excludeLastUpdateAgo formControlName="tooltipDateFormat"></tb-date-format-select> |
|||
<tb-font-settings formControlName="tooltipDateFont" |
|||
[previewText]="tooltipDatePreviewFn"> |
|||
</tb-font-settings> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipDateColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-background-color' | translate }}</div> |
|||
<tb-color-input asBoxInput |
|||
colorClearButton |
|||
formControlName="tooltipBackgroundColor"> |
|||
</tb-color-input> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.range-chart.tooltip-background-blur' | translate }}</div> |
|||
<mat-form-field appearance="outline" class="number" subscriptSizing="dynamic"> |
|||
<input matInput formControlName="tooltipBackgroundBlur" type="number" min="0" step="1" placeholder="{{ 'widget-config.set' | translate }}"> |
|||
<div matSuffix>px</div> |
|||
</mat-form-field> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<div class="tb-form-row space-between"> |
|||
<div>{{ 'widgets.background.background' | translate }}</div> |
|||
<tb-background-settings formControlName="background"> |
|||
</tb-background-settings> |
|||
</div> |
|||
</div> |
|||
</ng-container> |
|||
@ -0,0 +1,147 @@ |
|||
///
|
|||
/// Copyright © 2016-2023 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, Injector } from '@angular/core'; |
|||
import { |
|||
legendPositions, |
|||
legendPositionTranslationMap, |
|||
WidgetSettings, |
|||
WidgetSettingsComponent |
|||
} from '@shared/models/widget.models'; |
|||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { formatValue } from '@core/utils'; |
|||
import { rangeChartDefaultSettings } from '@home/components/widget/lib/chart/range-chart-widget.models'; |
|||
import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-range-chart-widget-settings', |
|||
templateUrl: './range-chart-widget-settings.component.html', |
|||
styleUrls: [] |
|||
}) |
|||
export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent { |
|||
|
|||
legendPositions = legendPositions; |
|||
|
|||
legendPositionTranslationMap = legendPositionTranslationMap; |
|||
|
|||
rangeChartWidgetSettingsForm: UntypedFormGroup; |
|||
|
|||
tooltipValuePreviewFn = this._tooltipValuePreviewFn.bind(this); |
|||
|
|||
tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
private $injector: Injector, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store); |
|||
} |
|||
|
|||
protected settingsForm(): UntypedFormGroup { |
|||
return this.rangeChartWidgetSettingsForm; |
|||
} |
|||
|
|||
protected defaultSettings(): WidgetSettings { |
|||
return {...rangeChartDefaultSettings}; |
|||
} |
|||
|
|||
protected onSettingsSet(settings: WidgetSettings) { |
|||
this.rangeChartWidgetSettingsForm = this.fb.group({ |
|||
dataZoom: [settings.dataZoom, []], |
|||
rangeColors: [settings.rangeColors, []], |
|||
outOfRangeColor: [settings.outOfRangeColor, []], |
|||
fillArea: [settings.fillArea, []], |
|||
|
|||
showLegend: [settings.showLegend, []], |
|||
legendPosition: [settings.legendPosition, []], |
|||
legendLabelFont: [settings.legendLabelFont, []], |
|||
legendLabelColor: [settings.legendLabelColor, []], |
|||
|
|||
showTooltip: [settings.showTooltip, []], |
|||
tooltipValueFont: [settings.tooltipValueFont, []], |
|||
tooltipValueColor: [settings.tooltipValueColor, []], |
|||
tooltipShowDate: [settings.tooltipShowDate, []], |
|||
tooltipDateFormat: [settings.tooltipDateFormat, []], |
|||
tooltipDateFont: [settings.tooltipDateFont, []], |
|||
tooltipDateColor: [settings.tooltipDateColor, []], |
|||
tooltipBackgroundColor: [settings.tooltipBackgroundColor, []], |
|||
tooltipBackgroundBlur: [settings.tooltipBackgroundBlur, []], |
|||
|
|||
background: [settings.background, []] |
|||
}); |
|||
} |
|||
|
|||
protected validatorTriggers(): string[] { |
|||
return ['showLegend', 'showTooltip', 'tooltipShowDate']; |
|||
} |
|||
|
|||
protected updateValidators(emitEvent: boolean) { |
|||
const showLegend: boolean = this.rangeChartWidgetSettingsForm.get('showLegend').value; |
|||
const showTooltip: boolean = this.rangeChartWidgetSettingsForm.get('showTooltip').value; |
|||
const tooltipShowDate: boolean = this.rangeChartWidgetSettingsForm.get('tooltipShowDate').value; |
|||
|
|||
if (showLegend) { |
|||
this.rangeChartWidgetSettingsForm.get('legendPosition').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('legendLabelFont').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('legendLabelColor').enable(); |
|||
} else { |
|||
this.rangeChartWidgetSettingsForm.get('legendPosition').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('legendLabelFont').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('legendLabelColor').disable(); |
|||
} |
|||
|
|||
if (showTooltip) { |
|||
this.rangeChartWidgetSettingsForm.get('tooltipValueFont').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipValueColor').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipShowDate').enable({emitEvent: false}); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').enable(); |
|||
if (tooltipShowDate) { |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').enable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').enable(); |
|||
} else { |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable(); |
|||
} |
|||
} else { |
|||
this.rangeChartWidgetSettingsForm.get('tooltipValueFont').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipValueColor').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipShowDate').disable({emitEvent: false}); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateFont').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipDateColor').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundColor').disable(); |
|||
this.rangeChartWidgetSettingsForm.get('tooltipBackgroundBlur').disable(); |
|||
} |
|||
} |
|||
|
|||
private _tooltipValuePreviewFn(): string { |
|||
const units: string = this.widgetConfig.config.units; |
|||
const decimals: number = this.widgetConfig.config.decimals; |
|||
return formatValue(22, decimals, units, false); |
|||
} |
|||
|
|||
private _tooltipDatePreviewFn(): string { |
|||
const dateFormat: DateFormatSettings = this.rangeChartWidgetSettingsForm.get('tooltipDateFormat').value; |
|||
const processor = DateFormatProcessor.fromSettings(this.$injector, dateFormat); |
|||
processor.update(Date.now()); |
|||
return processor.formatted; |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue