ednl-liftstatus-web-components
Version:
The EDNL LiftStatus web components
444 lines (443 loc) • 17.6 kB
JavaScript
import { Host, h, } from "@stencil/core";
import * as d3 from "d3";
import dayjs from "dayjs";
import { cloneDeep, debounce, filter, first, get, isEmpty, isObject, keys, last, sortBy, values, } from "lodash-es";
import { getStore } from "../../store";
export class LsRecentStops {
constructor() {
// Set the constants
this.DAYJSFORMAT = "DD-MM-YYYY HH:mm:ss";
this.DEBOUNCEDELAY = 350;
this.MARGIN = { BOTTOM: 20, LEFT: 20, RIGHT: 20, TOP: 20 };
// key 501 is current (open doors)
// key 515 is current (regardless of door state)
// key 514 is last passed
// These are the only statuses we want to show
this.SENSOR_IDS = [501, 514, 515];
this.focusCircleId = "focusCircle";
this.focusLineXId = "focusLineX";
this.focusLineYId = "focusLineY";
this.focusTextContainerId = "focusTextContainer";
this.focusTextId = "focusText";
// Width and height will be set as soon as the component is loaded
this.height = 0;
this.width = 0;
// Using a debounce here to save resources
this.handleResize = debounce(() => {
// Redraw the chart
this.drawChart();
this.updateDataRange();
}, this.DEBOUNCEDELAY,
// Enables the first trigger i.e. for when the screen gets maximized or media queries are triggered
{ leading: true });
this.currentXScale = undefined;
this.currentYScale = undefined;
this.graph = undefined;
this.timePoints = [];
this.maxFloors = 0;
// Stores the error and loading states
this.errorState = false;
this.handleSensorUpdate = (data, timestamp) => {
if (isEmpty(data))
return;
for (let i = 0; i < keys(data).length; i++) {
const key = parseInt(keys(data)[i], 10);
const value = parseInt(values(data)[i], 10);
if (key === 591) {
this.maxFloors = (value || 0) + 1;
}
if (!Number.isNaN(value) && this.SENSOR_IDS.includes(key)) {
const momentTimestamp = dayjs(timestamp[key]);
const newTimePoint = {
name: momentTimestamp.format(this.DAYJSFORMAT),
x: momentTimestamp.toDate(),
y: value + 1,
id: `${key}-${timestamp[key]}`,
sensor: key,
fake: false,
};
const updatedTimePoints = [...this.timePoints, newTimePoint];
this.timePoints = updatedTimePoints;
}
}
};
this.clearChart = () => {
// Clear out the html of the chart so that we have a clear chart
d3.select(this.canvas).html("");
};
this.drawChart = async () => {
// lets first remove the current chart before we draw a new one to prevent overlapping
this.clearChart();
// (Re)calculate width and height
const screenWidth = document.documentElement.clientWidth;
this.width =
screenWidth >= 600
? screenWidth - this.MARGIN.LEFT - this.MARGIN.RIGHT
: 600 - this.MARGIN.LEFT - this.MARGIN.RIGHT;
this.height =
screenWidth >= 800
? this.width / 4 - this.MARGIN.BOTTOM - this.MARGIN.TOP
: this.width / 3 - this.MARGIN.BOTTOM - this.MARGIN.TOP;
this.currentXScale = d3.scaleTime().range([0, this.width]);
this.currentYScale = d3.scaleLinear().range([this.height, 0]);
this.xAxis = d3
.axisBottom(this.currentXScale)
.ticks(d3.timeMinute.every(5))
.tickFormat(d3.timeFormat("%H:%M"));
this.yAxis = d3.axisLeft(this.currentYScale).tickFormat(d3.format("d"));
const svg = d3
.select(this.canvas)
.append("svg")
.attr("viewBox", `0 0 ${this.width + this.MARGIN.LEFT + this.MARGIN.RIGHT} ${this.height + this.MARGIN.BOTTOM + this.MARGIN.TOP}`)
.append("g")
.attr("transform", `translate(${this.MARGIN.LEFT}, ${this.MARGIN.TOP})`);
// add the x axis
svg
.append("g")
.attr("class", "axis x_axis")
.attr("transform", `translate(0,${this.height})`)
.call(this.xAxis);
// add the y axis
svg.append("g").attr("class", "axis y_axis").call(this.yAxis);
const dataSets = !isEmpty(this.historicalPointsResponse)
? this.historicalPointsResponse
: [];
// create timePoints
const historicalTimePoints = [];
for (const dataSet of dataSets) {
const sensorId = parseInt(get(dataSet, "sensor.id"), 10);
if (sensorId === 591) {
this.maxFloors = (get(last(dataSet.rows), "val") || 0) + 1;
}
for (const row of dataSet.rows) {
const value = parseInt(get(row, "val"), 10);
if (!Number.isNaN(value) && this.SENSOR_IDS.includes(sensorId)) {
const newTimePoint = {
name: dayjs(row.ts).format(this.DAYJSFORMAT),
x: dayjs(row.ts).toDate(),
y: value + 1,
id: `${sensorId}-${row.ts}`,
sensor: sensorId,
fake: false,
};
historicalTimePoints.push(newTimePoint);
}
}
}
// merge these time points with the ones we already have
const mergedTimePoints = [
...historicalTimePoints,
...this.timePoints,
];
// sort the time points on date
mergedTimePoints.sort((timePointA, timePointB) => dayjs(timePointA.x).isBefore(dayjs(timePointB.x)) ? -1 : 1);
// set the merged time points
this.timePoints = mergedTimePoints;
// create a graph element
this.graph = svg.append("g");
// create an element for onFocus
const focus = svg.append("g").style("display", "none");
// append the x axis line to the onFocus element
focus
.append("line")
.attr("id", this.focusLineXId)
.attr("class", "focusLine");
// append the y axis line to the onFocus element
focus
.append("line")
.attr("id", this.focusLineYId)
.attr("class", "focusLine");
// append a circle to the onFocus element with a radius of 5px
focus
.append("circle")
.attr("id", this.focusCircleId)
.attr("r", 5)
.attr("class", "circle focusCircle");
// add a text container to the onFocus element
const textContainer = focus
.append("g")
.attr("id", this.focusTextContainerId)
.attr("class", "focus_text_container");
// create the outer box where the text will appear in
textContainer
.append("rect")
.attr("width", 180)
.attr("height", 20)
.attr("x", -90)
.attr("y", -20)
.attr("rx", 5)
.attr("ry", 5);
// create the inner box where the text will appear in
textContainer
.append("text")
.attr("y", -10)
.attr("id", this.focusTextId)
.attr("text-anchor", "middle")
.attr("dy", ".35em");
// use the return value here as we need it later for the overlay
const [floorExtend, datesExtend] = this.updateDataRange();
// create a bisector object to find the current data item on move move on the overlay
const bisectDate = d3.bisector((timepoint) => timepoint.x).left;
// create an overlay which will show the focus items on the correct point
svg
.append("rect")
.attr("class", "overlay")
.attr("width", this.width)
.attr("height", this.height)
.on("mouseout", () => focus.style("display", "none"))
.on("mousemove", (event) => {
const mouse = d3.pointer(event);
const mouseDate = this.currentXScale.invert(mouse[0]);
// we need to sort the time points as they might not be yet. and after that find the index in the sorted time points
const clonedTimePoints = cloneDeep(this.timePoints);
clonedTimePoints.sort((a, b) => a.x.valueOf() - b.x.valueOf());
const i = bisectDate(clonedTimePoints, mouseDate); // returns the index to the current data item
// get the current data item and the one before that so we can see which one is closes to the current mouse position
const timePoint0 = clonedTimePoints[i - 1];
const timePoint1 = clonedTimePoints[i];
if (isObject(timePoint0) && isObject(timePoint1)) {
// work out which date value is closest to the mouse
const closestTimePoint = mouseDate.getTime() - timePoint0.x.getTime() >
timePoint1.x.getTime() - mouseDate.getTime()
? timePoint1
: timePoint0;
// get the x and y values of that closest point
const x = this.currentXScale(closestTimePoint.x);
const y = this.currentYScale(closestTimePoint.y);
focus.select(`#${this.focusCircleId}`).attr("cx", x).attr("cy", y);
focus
.select(`#${this.focusLineXId}`)
.attr("x1", x)
.attr("y1", this.currentYScale(floorExtend[0]))
.attr("x2", x)
.attr("y2", this.currentYScale(floorExtend[1]));
focus
.select(`#${this.focusLineYId}`)
.attr("x1", this.currentXScale(datesExtend[0]))
.attr("y1", y)
.attr("x2", this.currentXScale(datesExtend[1]))
.attr("y2", y);
focus
.select(`#${this.focusTextContainerId}`)
.attr("transform", `translate(${x + 2},${y})`)
.select(`#${this.focusTextId}`)
.text(closestTimePoint.name);
focus.style("display", null);
}
});
};
this.updateDataRange = () => {
var _a, _b, _c;
const now = dayjs();
const start = this.getSelectedStartPeriod();
// set last updated to the current moment
// filter out time points older then the start selected, filter out fake values, and any points too close to 501 points
let filteredTimePoints = filter(this.timePoints, (timePoint) => {
const isAfterStart = dayjs(timePoint.x).isAfter(start);
const isNotFake = !timePoint.fake;
return isAfterStart && isNotFake;
});
// Add a time point for the last known stop, so that graph does not display an empty state
if (filteredTimePoints.length == 0) {
for (const point of this.timePoints) {
if (point.sensor == 515) {
const currentFloorPoint = cloneDeep(point);
currentFloorPoint.x = now.toDate();
currentFloorPoint.name = now.format(this.DAYJSFORMAT);
currentFloorPoint.id = `${currentFloorPoint.y}-${now.toDate()}`;
currentFloorPoint.fake = true;
filteredTimePoints = [...filteredTimePoints, currentFloorPoint];
}
}
}
// clone the last point
const clonedTimePoints = cloneDeep(filteredTimePoints);
clonedTimePoints.sort((a, b) => a.x.valueOf() - b.x.valueOf());
const fakeValueLast = cloneDeep(last(clonedTimePoints));
// add a fake point (to the end) if we were successful on cloning the last point
// We do this to add a leading line
if (fakeValueLast !== undefined) {
fakeValueLast.x = now.toDate();
fakeValueLast.name = now.format(this.DAYJSFORMAT);
fakeValueLast.id = `${fakeValueLast.y}-${now.toDate()}`;
fakeValueLast.fake = true;
const timePointsWithFakeValue = [...filteredTimePoints, fakeValueLast];
this.timePoints = timePointsWithFakeValue;
}
// clone the last point again
const fakeValueFirst = cloneDeep(first(clonedTimePoints));
// add a fake point (to the start) if we were successful on cloning the last point
// We do this to add a trailing line
if (fakeValueFirst !== undefined) {
fakeValueFirst.x = now.subtract(1, "hour").toDate();
fakeValueFirst.name = now.format(this.DAYJSFORMAT);
fakeValueFirst.id = `${fakeValueFirst.y}-${now
.subtract(1, "hour")
.toDate()}`;
fakeValueFirst.fake = true;
const timePointsWithFakeValue = [...this.timePoints, fakeValueFirst];
this.timePoints = timePointsWithFakeValue;
}
const floorExtent = [1, this.maxFloors];
const datesExtend = d3.extent(this.timePoints, (timePoint) => timePoint.x);
datesExtend[1] = now.toDate();
this.currentXScale.domain([start.valueOf(), now.valueOf()]);
this.currentYScale.domain(floorExtent);
// Remove previous lines
if (this.graph !== null) {
(_a = this.graph) === null || _a === void 0 ? void 0 : _a.selectAll(".line").remove();
for (let i = 1; i <= this.maxFloors; i++) {
(_b = this.graph) === null || _b === void 0 ? void 0 : _b.append("line").attr("y1", this.currentYScale(i)).attr("y2", this.currentYScale(i)).attr("x1", 0.5).attr("x2", this.width).attr("class", "line");
}
const yTicks = d3.max(this.timePoints, (timePoint) => timePoint.y) - 1;
this.yAxis.ticks(yTicks);
// Remove all previous paths
(_c = this.graph) === null || _c === void 0 ? void 0 : _c.selectAll(".path").remove();
this.graph
.append("path")
.datum(sortBy(this.timePoints, (t) => t.x.getTime()).map((timePoint) => [
timePoint.x.getTime(),
timePoint.y,
]))
.attr("fill", "none")
.attr("class", "path")
.attr("d", d3
.line()
.x((d) => this.currentXScale(d[0]))
.y((d) => this.currentYScale(d[1])));
// Remove previous circles
this.graph.selectAll(`.point`).remove();
// append new circles for the new time points
this.graph
.selectAll(".circle")
.data(sortBy(this.timePoints, "sensor").reverse())
.enter()
.append("circle")
.attr("cx", (timePoint) => this.currentXScale(timePoint.x))
.attr("cy", (timePoint) => this.currentYScale(timePoint.y))
.attr("r", 5)
.attr("class", (timePoint) => {
let result = "stop";
const sensorType = parseInt(`${timePoint.sensor}`, 10);
if (sensorType === 514 || sensorType === 515) {
result = "passed";
}
if (timePoint.fake) {
result = "fake";
}
return `${result} point`;
});
// update the axis
d3.select(this.element.shadowRoot.querySelector(".x_axis")).call(this.xAxis);
d3.select(this.element.shadowRoot.querySelector(".y_axis")).call(this.yAxis);
return [floorExtent, datesExtend];
}
};
this.getSelectedStartPeriod = () => {
// Default time
const startTime = dayjs().subtract(1, "hour");
return startTime;
};
this.idKey = undefined;
this.loadingState = true;
}
// Listen for resize events and recalculate
resizeWindow() {
// If the component is in loading or error state, we don't want to trigger a render of the graph
if (this.errorState || this.loadingState)
return;
this.handleResize();
}
async componentWillLoad() {
this.store = await getStore(this.idKey);
}
async componentDidLoad() {
this.historicalPointsResponse = this.store.state.historicalSensorData;
await this.drawChart();
this.updateDataRange();
this.loadingState = false;
this.store.onChange("historicalSensorData", (sensorValues) => {
this.historicalPointsResponse = sensorValues;
this.drawChart();
});
this.store.onChange("currentSensorData", (sensorData) => {
const values = sensorData.values;
const timestamps = sensorData.updated;
this.handleSensorUpdate(values, timestamps);
this.updateDataRange();
});
// Initiate the timer that updates the graph
this.timer = window.setInterval(() => {
this.updateDataRange();
}, 2000);
}
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.timer);
this.timer = null;
}
return (h(Host, null, h("div", { ref: (el) => {
this.canvas = el;
} }, h("div", { class: "chart" })), this.loadingState && errorMessage === "" ? (h("div", { class: "loading" }, "Laden...")) : (errorMessage !== "" && h("div", { class: "error" }, errorMessage)), h("slot", null)));
}
disconnectedCallback() {
window.clearInterval(this.timer);
this.timer = null;
}
static get is() { return "ls-recent-stops"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["ls-recent-stops.css"]
};
}
static get styleUrls() {
return {
"$": ["ls-recent-stops.css"]
};
}
static get properties() {
return {
"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 {
"loadingState": {}
};
}
static get elementRef() { return "element"; }
static get listeners() {
return [{
"name": "resize",
"method": "resizeWindow",
"target": "window",
"capture": false,
"passive": true
}];
}
}
//# sourceMappingURL=ls-recent-stops.js.map