UNPKG

ednl-liftstatus-web-components

Version:
608 lines (607 loc) 20.1 kB
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