UNPKG

ednl-liftstatus-web-components

Version:
444 lines (443 loc) 17.6 kB
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