ednl-liftstatus-web-components
Version:
The EDNL LiftStatus web components
608 lines (607 loc) • 20.1 kB
JavaScript
import { Host, h, getAssetPath, } from "@stencil/core";
import * as d3 from "d3";
import dayjs from "dayjs";
import { get, keys, values, each, indexOf, isArray, includes, isObject, last, isNil, sortBy, filter, isNumber, isEmpty, debounce, } from "lodash-es";
import { getStore } from "../../store";
import { sensorInfo } from "../../utils/sensorInfo";
export class LsSwimlane {
constructor() {
this.DEBOUNCEDELAY = 350;
// Using a debounce here to save resources
this.handleResize = debounce(() => {
// Redraw the chart
this.drawChart();
this.updateTimeline();
}, this.DEBOUNCEDELAY,
// Enables the first trigger i.e. for when the screen gets maximized or media queries are triggered
{ leading: true });
/**
* Loading indicator
*/
this.chartLoading = true;
this.maxWidth = 0;
this.swimLineWidth = 0;
this.sensorLabelsWidth = 0;
this.dateRange = { from: null, to: null };
this.storedAndOrderedSensorData = {};
/**
* Create fixed colors for sensor ID's
* @param value the value of the sensor
* @param sensorType the type of sensor
*/
this.sensorValueColorCode = (value, sensorType) => {
let result = "#ffffff";
const switchValue = `${value}`;
switch (switchValue) {
case "+":
result = "#FEF08A";
break;
case "-":
result = "#E9D5FF";
break;
case "U":
result = "#FEF08A";
break;
case "D":
result = "#E9D5FF";
break;
case "O":
result = "#BBF7D0";
break;
case "C":
result = "#FECACA";
break;
case "X":
result = "#E4E4E7";
break;
}
const number = parseInt(value, 10);
if (!isNaN(number) && isNumber(number)) {
if ((!sensorType || sensorType !== "514") && isNumber(value)) {
result = "#C7D2FE";
}
else if (number % 2 === 0) {
result = "#CFFAFE";
}
else {
result = "#A5F3FC";
}
}
return result;
};
/**
* Settings object with some values
*/
this.swimLaneSettings = {
minHeight: 200,
laneHeight: 25,
timer: null,
subscriptions: [],
swimlanes: {},
labels: [],
scale: {
units: "minute",
value: 1,
},
};
this.clearChart = () => {
// clear out the html of the chart so that we have a clear chart
d3.select(this.canvas).html("");
};
/**
* Create initial chart layout
*/
this.drawChart = () => {
// lets first remove the current chart before we draw a new one to prevent overlapping
this.clearChart();
// Calculate and apply chart width
this.maxWidth = this.canvas.clientWidth <= 0 ? 1 : this.canvas.clientWidth;
this.sensorLabelsWidth =
this.canvas.clientWidth < 800
? this.canvas.clientWidth * 0.3
: this.canvas.clientWidth * 0.25;
this.swimLineWidth =
this.canvas.clientWidth < 800
? this.canvas.clientWidth * 0.7
: this.canvas.clientWidth * 0.75;
// Chart
const chart = d3
.select(this.canvas)
.append("svg")
.attr("width", this.maxWidth)
.attr("height", this.swimLaneSettings.minHeight)
.attr("class", "chart");
// Swimlanes
const swimlanes = chart
.append("g")
.attr("width", this.swimLineWidth)
.attr("transform", `translate(${Math.floor(this.sensorLabelsWidth)},0)`)
.attr("class", "swimlanes");
// Sensor
const sensorLabels = chart
.append("g")
.attr("width", this.sensorLabelsWidth)
.attr("class", "sensors-labels");
// Sensor background
sensorLabels
.append("rect")
.attr("width", this.sensorLabelsWidth)
.attr("height", "100%");
// Main group
const main = chart
.append("g")
.attr("width", this.swimLineWidth)
.attr("transform", `translate(${Math.floor(this.sensorLabelsWidth)},0)`)
.attr("class", "main");
// Horizontal and vertical scales
const endDate = dayjs().toDate();
const startDate = dayjs()
.subtract(this.swimLaneSettings.scale.value, this.swimLaneSettings.scale.units)
.toDate();
const horizontalScale = d3
.scaleTime()
.domain([startDate, endDate])
.range([0, this.swimLineWidth]);
this.xAxisScale = horizontalScale;
this.dateRange.from = startDate;
this.dateRange.to = endDate;
// Add the x-axis
const horizontalAxis = d3
.axisBottom(horizontalScale)
.ticks(5, "s")
.tickFormat(d3.timeFormat("%H:%M:%S"))
.tickSize(6);
// Append the labels to the x axis
main
.append("g")
.attr("transform", `translate(0,${this.sensorIdsArray.length * this.swimLaneSettings.laneHeight})`)
.attr("class", "main axis time")
.call(horizontalAxis)
.selectAll("text")
.attr("dx", 5)
.attr("dy", 12);
this.xAxis = horizontalAxis;
swimlanes.append("g").attr("class", "paths");
// Add swimlane for each sensor
each(this.sensorIdsArray, (sensorId) => {
this.addSwimlane(sensorId);
});
};
/**
* Add swimlane to chart
*/
this.addSwimlane = (sensorId) => {
if (this.xAxis !== undefined) {
// Only add swimlanes for sensors that we support
if (indexOf(this.sensorIdsArray, sensorId) > -1) {
// The height of the swimlanes is based on the number of existing swimlanes
const totalHeight = this.swimLaneSettings.laneHeight *
(keys(this.swimLaneSettings.swimlanes).length + 1) +
23;
const indexHeight = this.swimLaneSettings.laneHeight *
this.sensorIdsArray.indexOf(sensorId);
// Put the ticks (date and time) at the bottom
const chart = d3.select(this.canvas);
// Increase the height of the canvas tot the total number of lanes + 23px for the bottom axis
chart.select("svg").attr("height", totalHeight);
// Add swimlane group
const swimlaneGroup = chart
.select(".swimlanes")
.append("g")
.attr("width", this.swimLineWidth)
.attr("height", this.swimLaneSettings.laneHeight)
.attr("class", "swimlane")
.attr("transform", `translate(0,${indexHeight})`);
// Add label group
const labelGroup = chart
.select(".sensors-labels")
.append("g")
.attr("width", this.sensorLabelsWidth)
.attr("height", this.swimLaneSettings.laneHeight)
.attr("class", "sensor")
.attr("transform", `translate(0,${indexHeight})`);
// Add text label
labelGroup
.append("text")
.text(sensorInfo.getLabel(sensorId))
.attr("transform", `translate(0,${this.swimLaneSettings.laneHeight - 9})`)
.attr("font-size", "10")
.attr("class", "sensor-title");
// Add lines
swimlaneGroup
.append("line")
.attr("x1", 0)
.attr("y1", this.swimLaneSettings.laneHeight)
.attr("x2", this.swimLineWidth)
.attr("y2", this.swimLaneSettings.laneHeight)
.attr("stroke", "lightgray");
labelGroup
.append("line")
.attr("x1", 0)
.attr("y1", this.swimLaneSettings.laneHeight)
.attr("x2", this.sensorLabelsWidth)
.attr("y2", this.swimLaneSettings.laneHeight)
.attr("stroke", "lightgray");
// Add the swimlane
this.swimLaneSettings.swimlanes[sensorId] = swimlaneGroup;
this.swimLaneSettings.labels.push({
element: labelGroup,
index: indexOf(this.sensorIdsArray, sensorId),
});
}
}
};
/**
* Add data to state
*/
this.addData = (sensorId, value, timestamp) => {
if (isNil(sensorId))
return;
if (!this.sensorIdsArray.includes(sensorId))
return;
let sensorDataCollection;
if (!isArray(this.storedAndOrderedSensorData[`${sensorId}`])) {
sensorDataCollection = [];
}
else {
sensorDataCollection = this.storedAndOrderedSensorData[`${sensorId}`];
}
const lastValue = last(sensorDataCollection);
// Previous entry found with other value than current
if (!isNil(lastValue) && lastValue.value !== value) {
// Remove previous
sensorDataCollection.pop();
// Add previous back with end time
sensorDataCollection.push({
value: lastValue.value,
start: lastValue.start,
color: "#eee",
end: timestamp, // Start time of next is end time of previous
});
}
// Add new entry
sensorDataCollection.push({
value,
start: timestamp,
color: "#eee", // Color will be overwritten
});
// Update all entries
this.storedAndOrderedSensorData[`${sensorId}`] = sortBy(sensorDataCollection, "start");
};
this.updateTimeline = () => {
const now = dayjs();
const results = [];
const theLimit = now
.clone()
.subtract(this.swimLaneSettings.scale.value, this.swimLaneSettings.scale.units);
// Remove 'old' items first
// These are the items that would otherwise overflow on left side
each(this.storedAndOrderedSensorData, (data, sensorId) => {
const intSensorId = parseInt(sensorId, 10);
if (indexOf(this.sensorIdsArray, intSensorId) > -1) {
this.storedAndOrderedSensorData[`${sensorId}`] = filter(data, (sensorUpdate) => {
const end = get(sensorUpdate, "end", +new Date());
return theLimit.isBefore(dayjs(end));
});
}
});
each(this.storedAndOrderedSensorData, (data, sensorId) => {
const intSensorId = parseInt(sensorId, 10);
if (indexOf(this.sensorIdsArray, intSensorId) > -1) {
const swimlaneIndex = indexOf(this.sensorIdsArray, intSensorId);
each(data, (sensorUpdate) => {
if (isObject(sensorUpdate)) {
const start = get(sensorUpdate, "start");
let value = get(sensorUpdate, "value", "?");
const end = get(sensorUpdate, "end", +new Date());
// The values of sensor 501, 514 and 515 are zero based, but we need to show them one based
if (includes([501, 514, 515], intSensorId) &&
!isNaN(parseInt(value, 10))) {
value = parseInt(value, 10) + 1;
}
results.push({
start: new Date(start),
end: new Date(end),
index: swimlaneIndex,
color: this.sensorValueColorCode(value, sensorId),
value: value,
});
}
});
}
});
const chart = d3.select(this.canvas);
// Remove all references
const swimlaneGroup = chart.select(".swimlanes");
swimlaneGroup.selectAll(".mainItemGroup").remove();
// Add the rectangles
const rectangles = swimlaneGroup.selectAll("rect").data(results).enter();
rectangles
.append("g")
.attr("height", this.swimLaneSettings.laneHeight)
.attr("class", "mainItemGroup")
.attr("transform", (d) => {
let theBegin = new Date(d.start);
if (theLimit.isAfter(theBegin)) {
theBegin = theLimit.toDate();
}
return `translate( ${this.xAxisScale(theBegin)},${this.swimLaneSettings.laneHeight * d.index + 2})`;
})
.append("rect")
.attr("width", (d) => {
const theEnd = dayjs(d.end);
let theBegin = new Date(d.start);
if (theLimit.isAfter(theBegin)) {
theBegin = theLimit.toDate();
}
if (theLimit.isAfter(theEnd)) {
return 0;
}
else {
let result = this.xAxisScale(theEnd) - this.xAxisScale(theBegin);
// We need a minimum size for the rectangle
//
if (result < 1) {
result = 5;
}
return result;
}
})
.attr("height", () => 20)
.style("fill", (d) => d.color)
.select(function () {
return this.parentNode;
})
.append("text")
.text((d) => {
let value = d.value;
switch (value) {
case "D":
value = "↓";
break;
case "U":
value = "↑";
break;
case "I":
case "O":
case "C":
case "X":
case "-":
case "+":
value = "";
break;
}
return value;
})
.attr("text-anchor", "middle")
.attr("font-size", "9")
.attr("font-weight", "bold")
.attr("class", "sensor-value")
.attr("transform", (d) => {
const theEnd = dayjs(d.end);
let theBegin = new Date(d.start);
if (theLimit.isAfter(theBegin)) {
theBegin = theLimit.toDate();
}
if (theLimit.isAfter(theEnd)) {
return "translate(0,0)";
}
else {
const width = this.xAxisScale(theEnd) - this.xAxisScale(theBegin);
return `translate(${width / 2},13)`;
}
})
.filter((d) => d.value === "O" ||
d.value === "C" ||
d.value === "X" ||
d.value === "-" ||
d.value === "+")
.select(function () {
return this.parentNode;
})
.append("image")
.attr("href", (d) => {
let source = getAssetPath(`./assets/elevator.png`);
switch (d.value) {
case "O":
source = getAssetPath(`./assets/elevator-open.png`);
break;
case "C":
source = getAssetPath(`./assets/elevator-closed.png`);
break;
case "X":
source = getAssetPath(`./assets/elevator-locked.png`);
break;
case "-":
source = getAssetPath(`./assets/elevator-closing.png`);
break;
case "+":
source = getAssetPath(`./assets/elevator-opening.png`);
break;
}
return source;
})
.attr("x", (d) => {
const theEnd = dayjs(d.end);
let theBegin = new Date(d.start);
if (theLimit.isAfter(theBegin)) {
theBegin = theLimit.toDate();
}
if (theLimit.isAfter(theEnd)) {
return 0;
}
else {
const width = this.xAxisScale(theEnd) - this.xAxisScale(theBegin);
return Math.floor(width / 2 - 6);
}
})
.attr("y", 3)
.attr("width", 12)
.attr("height", 12);
this.xAxisScale.domain([theLimit.toDate(), now]);
// update the axis the D3.js way
chart.select(".main.axis.time").call(this.xAxis);
};
/**
* This method handles every incoming event from any sensor
*/
this.handleSensorUpdate = (data, timestamp) => {
if (!isNil(data)) {
const sensorId = parseInt(keys(data)[0], 10);
const value = values(data)[0];
const ts = timestamp || +new Date();
this.addData(sensorId, value, ts);
}
};
this.processHistoricalData = (sensorValues) => {
if (!isNil(values)) {
for (const value of sensorValues) {
const { rows, sensor } = value;
if (!isEmpty(rows)) {
rows.forEach((row) => {
const update = {};
update[sensor.id] = row.val;
this.handleSensorUpdate(update, +new Date(row.ts));
});
}
}
}
};
this.processLiveData = (sensorData) => {
const dataValues = sensorData.values;
const timestamps = sensorData.updated;
for (let i = 0; i < keys(dataValues).length; i++) {
const formattedDataValues = {};
formattedDataValues[keys(dataValues)[i]] = values(dataValues)[i];
const timestamp = timestamps[keys(dataValues)[i]];
this.handleSensorUpdate(formattedDataValues, timestamp);
}
};
/**
* Start (and stop) the animation interval
*/
this.timelineTimer = () => {
clearInterval(this.interval);
this.interval = setInterval(() => {
this.updateTimeline();
}, 500);
};
this.sensorIds = "500,501,502,503,514,515";
this.idKey = undefined;
this.sensorIdsArray = this.sensorIds.split(",").map(Number);
}
// Listen for resize events and recalculate
resizeWindow() {
this.handleResize();
}
async componentWillLoad() {
this.store = await getStore(this.idKey);
// Remove back door sensor if installation does not have one
if (!this.store.state.hasBackDoor) {
this.sensorIdsArray = this.sensorIdsArray.filter((value) => {
return value !== 503;
});
}
this.chartLoading = false;
}
componentDidLoad() {
this.drawChart();
// Fill chart with historical data
this.processHistoricalData(this.store.state.historicalSensorData);
// Start the timer to update the swim lane at regular intervals
this.timelineTimer();
// Subscribe to future sensor value updates
this.store.onChange("currentSensorData", (sensorData) => {
this.processLiveData(sensorData);
});
// Remove the loading indicator
this.chartLoading = false;
}
render() {
let errorMessage = "";
if (!isEmpty(this.store.state.error.type)) {
if (this.store.state.error.type === "Token") {
errorMessage = this.store.state.error.message;
}
else {
errorMessage = "Geen verbinding";
}
clearInterval(this.interval);
this.interval = null;
}
return (h(Host, null, h("div", { ref: (el) => {
this.canvas = el;
} }, h("div", { class: "chart" })), this.chartLoading && errorMessage === "" ? (h("div", { class: "loading" }, "Laden...")) : (errorMessage !== "" && h("div", { class: "error" }, errorMessage)), h("slot", null)));
}
disconnectedCallback() {
window.clearInterval(this.interval);
this.interval = null;
}
static get is() { return "ls-swimlane"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["ls-swimlane.css"]
};
}
static get styleUrls() {
return {
"$": ["ls-swimlane.css"]
};
}
static get assetsDirs() { return ["assets"]; }
static get properties() {
return {
"sensorIds": {
"type": "string",
"mutable": false,
"complexType": {
"original": "\"500,501,502,503,514,515\"",
"resolved": "\"500,501,502,503,514,515\"",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the IDs of the sensors that are to be displayed."
},
"attribute": "sensor-ids",
"reflect": false,
"defaultValue": "\"500,501,502,503,514,515\""
},
"idKey": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "The unique key that is used to identify store data."
},
"attribute": "id-key",
"reflect": false
}
};
}
static get states() {
return {
"sensorIdsArray": {}
};
}
static get listeners() {
return [{
"name": "resize",
"method": "resizeWindow",
"target": "window",
"capture": false,
"passive": true
}];
}
}
//# sourceMappingURL=ls-swimlane.js.map