diff --git a/app-dist/static/dashboard.js b/app-dist/static/dashboard.js deleted file mode 100644 index 3727f49..0000000 --- a/app-dist/static/dashboard.js +++ /dev/null @@ -1 +0,0 @@ -(()=>{"use strict";const t=JSON.parse('{"eR":60,"II":30,"HG":"/climate/api"}');class e{constructor(t){this.timeseries=[],this.valRange={high:-1/0,low:1/0},this.tickCache=[],this.tickCacheDirty=!0,this.bounds=t}updateIndexRange(t){this.valRange.high=-1/0,this.valRange.low=1/0;for(const e of this.timeseries){const i=e.getExtremaInRange(t.start,t.stop);i.maxVal>this.valRange.high&&(this.valRange.high=i.maxVal),i.minValnew Date(1e3*t).toLocaleTimeString(),this.resolution=1,this.dragging=!1,this.highlightedTimeseries=null,this.subscriptions={scroll:[],mousemove:[],drag:[]},this.ctx=t,this.initLayout(),this.updateDimensions(),this.ctx.fillStyle="rgb(255,255,255)",this.ctx.fillRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height),this.ctx.fill(),this.ctx.translate(.5,.5),this.ctx.canvas.onmousemove=t=>this.handleMouseMove(t),this.ctx.canvas.onmousedown=t=>this.dragging=!0,this.ctx.canvas.onmouseup=t=>this.dragging=!1,this.ctx.canvas.onmouseleave=t=>this.dragging=!1,this.ctx.canvas.onmouseout=t=>this.dragging=!1,this.ctx.canvas.onwheel=t=>this.handleScroll(t)}initLayout(){const t=this.margins.bottom+this.margins.top,i=this.margins.left+this.margins.right;this.leftScale=new e({top:this.margins.top,left:this.margins.left,height:this.ctx.canvas.height-t,width:50}),this.chartBounds={top:this.margins.top,left:this.margins.left+50,height:this.ctx.canvas.height-t,width:this.ctx.canvas.width-(i+50+50)},this.rightScale=new e({top:this.margins.top,left:this.ctx.canvas.width-this.margins.right-50,height:this.ctx.canvas.height-t,width:50})}updateDimensions(){this.chartBounds.width=Number(getComputedStyle(this.ctx.canvas).width.slice(0,-2))-(this.margins.left+this.margins.right+this.rightScale.getBounds().width+this.leftScale.getBounds().width),this.chartBounds.height=Number(getComputedStyle(this.ctx.canvas).height.slice(0,-2))-(this.margins.bottom+this.margins.top)}addTimeseries(t,e){this.timeseries.push(t),e===i.Left?this.leftScale.addTimeseries(t):this.rightScale.addTimeseries(t)}setRange(t){this.indexRange.start=t.start,this.indexRange.stop=t.stop}handleMouseMove(t){const{left:e,top:i}=this.ctx.canvas.getBoundingClientRect(),s=this.lastMousePos.x;this.lastMousePos.x=t.clientX-e,this.lastMousePos.y=t.clientY-i,this.render(),this.dragging&&this.emit("drag",t.movementX,t.movementY,this.getIndex(s)-this.getIndex(this.lastMousePos.x))}handleScroll(t){this.emit("scroll",t.deltaY>0?1:-1,Math.abs(t.deltaY),this.getIndex(this.lastMousePos.x))}emit(t,...e){for(const i of this.subscriptions[t])i(...e)}highlightTimeseries(t){if(!t)return this.highlightedTimeseries=null,void this.render();for(const e of this.timeseries)if(e.getName()===t)return this.highlightedTimeseries=t,void this.render();throw new Error(`The timeseries ${t} could not be highlighted because it doesn't exist on the chart!`)}on(t,e){this.subscriptions[t].push(e)}render(){this.updateDimensions(),this.clearCanvas(),this.updateResolution(),this.renderGuides(),this.leftScale.updateIndexRange(this.indexRange),this.rightScale.updateIndexRange(this.indexRange),this.leftScale.listTimeseries().forEach((t=>this.renderTimeseries(t,i.Left))),this.rightScale.listTimeseries().forEach((t=>this.renderTimeseries(t,i.Right))),this.leftScale.render(this.ctx),this.rightScale.render(this.ctx),this.renderTooltips()}clearCanvas(){this.ctx.fillStyle="rgb(255,255,255)",this.ctx.fillRect(0,0,this.ctx.canvas.width,this.ctx.canvas.height),this.ctx.fill()}updateResolution(){const t=(this.chartBounds.width-this.rightScale.getBounds().width-this.leftScale.getBounds().width)/(this.timeseries[0]?.cachedBetween(this.indexRange.start,this.indexRange.stop,1).length/2??0);this.resolution=t<5?Math.ceil(5/t):1}renderGuides(){this.ctx.strokeStyle="rgb(230, 230, 230)",this.ctx.lineWidth=1;for(const t of this.rightScale.getTicks()){const e=this.rightScale.getY(t);this.ctx.beginPath(),this.ctx.moveTo(this.chartBounds.left,e),this.ctx.lineTo(this.chartBounds.left+this.chartBounds.width,e),this.ctx.stroke()}}renderTooltips(t=20){let e=t,i=this.timeseries[0],s=0,n=0,a=this.leftScale;for(const o of[this.leftScale,this.rightScale])for(const r of o.listTimeseries()){const h=r.cachedBetween(this.getIndex(this.lastMousePos.x-t/2),this.getIndex(this.lastMousePos.x+t/2),this.resolution);for(let l=0;l=this.lastMousePos.y&&d-t/2<=this.lastMousePos.y){const t=this.getX(h[l+1]),c=Math.sqrt((d-this.lastMousePos.y)**2+(t-this.lastMousePos.x)**2);cthis.ctx.canvas.width&&(e-=r+4),i-o<0&&(i+=o+4),this.ctx.fillStyle="rgb(255,255,255)",this.ctx.strokeStyle="rgb(0,0,0)",this.ctx.fillRect(Math.round(e),Math.round(i),Math.round(r),Math.round(o)),this.ctx.strokeRect(Math.round(e),Math.round(i),Math.round(r),Math.round(o)),this.ctx.fillStyle=s,this.ctx.beginPath(),this.ctx.arc(Math.round(e+10),Math.round(i+o/2),5,0,2*Math.PI),this.ctx.fill(),this.ctx.fillStyle="rgb(0,0,0)",this.ctx.textAlign="left",this.ctx.fillText(t,Math.round(e+20),Math.round(i+a+5))}}class n extends Error{constructor(t){super(t),this.name="AppStateError"}}function a(){const e=(new Date).getTime()/1e3;return{overlayText:"",lastUpdateTime:e,minutesDisplayed:t.eR,utcOffset:-(new Date).getTimezoneOffset()/60,dataEndpointBase:t.HG,isLoading:!1,updateIntervalSeconds:t.II,displayMode:"pastMins",fatalError:null,displayWindow:{start:e-60*t.eR,stop:e},documentReady:!1,leftTimeseries:[],rightTimeseries:[],highlightedTimeseries:null}}class o{constructor(t){this.loaders=0,this.state={...a(),...t};const e={};for(const t in this.state)e[t]=[];this.eventCallbacks={newTimeseries:[],timeseriesUpdated:[],stateChange:[]},this.subscriptions=e,this.init(),setInterval((()=>this.getNewTimeseriesData().catch((t=>h().fatalError(t)))),1e3*this.state.updateIntervalSeconds)}async init(){await this.updateTimeseriesFromSettings(),await this.getNewTimeseriesData()}addTimeseriesToScale(t,e){const s=e===i.Left?this.state.leftTimeseries:this.state.rightTimeseries;if(s.indexOf(t)>=0)throw new n("Timeseries has already been added!");i.Left,s.push(t),this.notifyStoreVal(e===i.Left?"leftTimeseries":"rightTimeseries"),this.emit("newTimeseries",t,e),this.updateTimeseriesFromSettings()}notifyStoreVal(t,e,i){this.emit("stateChange",t,e,i);for(const s of this.subscriptions[t])s(e,i)}emit(t,...e){for(const i of this.eventCallbacks[t])i(...e)}async updateTimeseriesFromSettings(){let t,e;"window"===this.state.displayMode?(t=this.state.displayWindow.start,e=this.state.displayWindow.stop):(t=this.state.lastUpdateTime-60*this.state.minutesDisplayed,e=this.state.lastUpdateTime),this.addLoad();try{for(const i of this.state.leftTimeseries)await i.updateFromWindow(t,e);for(const i of this.state.rightTimeseries)await i.updateFromWindow(t,e)}catch(t){h().fatalError(t)}this.finishLoad(),this.notifyAllTimeseriesUpdated()}async getNewTimeseriesData(){const t=(new Date).getTime()/1e3;this.addLoad();try{for(const t of this.state.leftTimeseries)await t.getLatest();for(const t of this.state.rightTimeseries)await t.getLatest()}catch(t){h().fatalError(t)}this.finishLoad(),this.setLastUpdateTime(t),this.notifyAllTimeseriesUpdated()}notifyAllTimeseriesUpdated(){for(const t of this.state.leftTimeseries)this.notifyStoreVal("leftTimeseries"),this.emit("timeseriesUpdated",t);for(const t of this.state.rightTimeseries)this.notifyStoreVal("rightTimeseries"),this.emit("timeseriesUpdated",t)}getState(){return this.state}subscribeStoreVal(t,e){this.subscriptions[t].push(e)}on(t,e){this.eventCallbacks[t].push(e)}setDisplayMode(t){this.state.displayMode=t,this.updateTimeseriesFromSettings(),this.notifyStoreVal("displayMode")}setDisplayWindow(t){t.start0))throw new n("Invalid minutes passed: "+t);this.state.minutesDisplayed=Math.ceil(t),this.notifyStoreVal("minutesDisplayed"),this.updateTimeseriesFromSettings()}setUtcOffset(t){Math.floor(t)===t&&t<=14&&t>=-12?this.state.utcOffset=t:(console.warn("Invalid UTC offset: "+t),this.state.utcOffset=t>14?14:t<-12?-12:Math.floor(t)),this.notifyStoreVal("utcOffset")}setLastUpdateTime(t){if(!(this.state.lastUpdateTime<=t))throw new n(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${t}`);this.state.lastUpdateTime=t,this.notifyStoreVal("lastUpdateTime")}setOverlayText(t){this.state.overlayText=t,this.notifyStoreVal("overlayText")}addLoad(){this.loaders+=1,this.state.isLoading=this.loaders>0,this.notifyStoreVal("isLoading")}finishLoad(){this.loaders-=1,this.state.isLoading=this.loaders>0,this.notifyStoreVal("isLoading")}fatalError(t){this.state.fatalError||(this.state.fatalError=t,this.notifyStoreVal("fatalError"))}setDocumentReady(t){this.state.documentReady=t,this.notifyStoreVal("documentReady")}setHighlightedTimeseries(t){this.state.highlightedTimeseries=t,this.notifyStoreVal("highlightedTimeseries",t)}serialiseState(){const t=[];return"pastMins"===this.state.displayMode?60!==this.state.minutesDisplayed&&t.push("minutesDisplayed="+this.state.minutesDisplayed):t.push(`displayWindow=[${this.state.displayWindow.start},${this.state.displayWindow.stop}]`),this.state.utcOffset!==a().utcOffset&&t.push("utcOffset="+this.state.utcOffset),t.join("&")}deserialise(t){if(t.get("minutesDisplayed")&&t.get("displayWindow")&&console.warn("Options 'minutesDisplayed' and 'displayWindow' should not be used together. Defaulting to 'displayWindow'."),t.get("minutesDisplayed")&&(this.setDisplayMode("pastMins"),this.setMinutesDisplayed(Number(t.get("minutesDisplayed")))),t.get("utcOffset")&&this.setUtcOffset(Number(t.get("utcOffset"))),t.get("displayWindow")){const e=t.get("displayWindow").split(",");2===e.length&&(this.setDisplayMode("window"),this.setDisplayWindow({start:Number(e[0].slice(1)),stop:Number(e[1].slice(0,-1))}))}this.emit("stateChange")}}let r;function h(){if(r)return r;throw new n("Store not yet initialised!")}function l(){if(r)return r.getState();throw new n("Store not yet initialised!")}class d{constructor(){this.id=d.componentCount,d.componentCount++}makeRef(t){return d.reffedComponents.push(t),d.reffedComponentCount++}fromRef(t){return d.reffedComponents[t]??null}}d.componentCount=0,d.reffedComponentCount=0,d.reffedComponents=[];const c=class extends d{constructor(t){super(),this.container=document.createElement("div"),this.title=document.createElement("h2"),this.body=document.createElement("div"),this.container.className="widget"+(t.className?" "+t.className:""),this.title.className="widget-title",this.body.className="widget-body",this.setTitle(t.title),this.setPosition({row:t.row,col:t.col}),this.setSize({width:t.width,height:t.height}),t.title&&this.container.append(this.title),t.body&&this.body.append(t.body),this.container.append(this.body)}setPosition(t){this.container.style.gridRowStart=""+t.row,this.container.style.gridColumnStart=""+t.col}setSize(t){this.container.style.gridRowEnd="span "+t.height,this.container.style.gridColumnEnd="span "+t.width}setTitle(t){this.title.innerText=t}replaceBody(t){this.body.replaceWith(t)}current(){return this.container}};function u(t,e,...i){return"function"==typeof t?i.length>=1?t({...e},i):t({...e}):function(t,e,...i){const s=document.createElement(t);for(const t in e){const i=e[t];t.startsWith("on")&&"function"==typeof i?s.addEventListener(t.substring(2),i):"boolean"==typeof i&&!0===i?s.setAttribute(t,""):"string"==typeof i&&("className"===t?s.setAttribute("class",e[t]):s.setAttribute(t,i))}return s.append(...m(i)),s}(t,e,...i)}function m(t){const e=[];for(const i of t)null!=i&&"boolean"!=typeof i&&(Array.isArray(i)?e.push(...m(i)):"string"==typeof i?e.push(document.createTextNode(String(i))):i instanceof Node&&e.push(i));return e}const p=class extends d{constructor(t){super(),this.display=document.createElement("span"),this.display=u(this.MainBody,{ctx:this}),this.skeleton=new c({...t,title:"Displayed Timezone:",body:this.display}),h().subscribeStoreVal("utcOffset",(()=>this.updateDisplay())),this.updateDisplay()}updateDisplay(){const t=h().getState().utcOffset;this.fromRef(this.timezoneDisplayRef).innerText=`${t>0?"+":"−"} ${Math.abs(t)}`,this.fromRef(this.timezoneInputRef).value=`${t>0?"":"-"}${Math.abs(t)}`}MainBody({ctx:t}){return u("div",{className:"timezone-widget",onclick:()=>t.onTimezoneClick()},u("span",null,"UTC "),u(t.TimezoneDisplay,{ctx:t}),u("span",null,":00"))}TimezoneDisplay({ctx:t}){return t.timezoneDisplayRef=t.makeRef(u("span",null)),t.timezoneInputRef=t.makeRef(u("input",{type:"text",onblur:()=>t.onTimezoneInputBlur()})),t.fromRef(t.timezoneDisplayRef)}onTimezoneInputBlur(){const t=this.fromRef(this.timezoneInputRef),e=this.fromRef(this.timezoneDisplayRef);h().setUtcOffset(Number(t.value)),t.replaceWith(e),this.updateDisplay()}onTimezoneClick(){const t=this.fromRef(this.timezoneInputRef);this.fromRef(this.timezoneDisplayRef).replaceWith(t),t.focus(),t.selectionStart=0,t.selectionEnd=t.value.length}current(){return this.skeleton.current()}};const f=class extends d{constructor(t){super(),this.mainDisplay=this.MainDisplay({ctx:this}),this.skeleton=new c({...t,title:"Displaying:",body:this.mainDisplay}),h().subscribeStoreVal("minutesDisplayed",(()=>this.updateDisplay())),h().subscribeStoreVal("displayMode",(()=>this.updateDisplay())),h().subscribeStoreVal("displayWindow",(()=>this.updateDisplay())),h().subscribeStoreVal("utcOffset",(()=>this.updateDisplay())),this.updateDisplay()}WindowStartTime({ctx:t}){return t.windowStartTimeInputRef=t.makeRef(u("input",{type:"datetime-local",onblur:()=>t.onWindowStartInputBlur()})),t.windowStartTimeRef=t.makeRef(u("div",{className:"display-mode-widget-date",onwheel:e=>t.onStartTimeInputScroll(e),onclick:()=>t.onWindowStartDisplayClick()},new Date(l().displayWindow.start+60*l().utcOffset*60*1e3).toLocaleString())),t.fromRef(t.windowStartTimeRef)}WindowStopTime({ctx:t}){return t.windowStopTimeInputRef=t.makeRef(u("input",{value:new Date,type:"datetime-local",onblur:()=>t.onWindowStopInputBlur()})),t.windowStopTimeRef=t.makeRef(u("div",{className:"display-mode-widget-date",onwheel:e=>t.onStopTimeInputScroll(e),onclick:()=>t.onWindowStopDisplayClick()},new Date(l().displayWindow.stop+60*l().utcOffset*60*1e3).toLocaleString())),t.fromRef(t.windowStopTimeRef)}MinutesCounter({ctx:t,onclick:e}){return t.minsInputRef=t.makeRef(u("input",{value:l().minutesDisplayed.toString(),onblur:e=>t.onMinutesCounterInputBlur(e)})),t.minsCounterRef=t.makeRef(u("div",{className:"min-count",onclick:e,onwheel:e=>t.onMinutesCounterInputScroll(e)},l().minutesDisplayed.toString())),t.fromRef(t.minsCounterRef)}onMinutesCounterInputScroll(t){h().setMinutesDisplayed(l().minutesDisplayed+t.deltaY)}onStopTimeInputScroll(t){const e=l().displayWindow;h().setDisplayWindow({start:e.start,stop:e.stop-60*t.deltaY})}onStartTimeInputScroll(t){const e=l().displayWindow;h().setDisplayWindow({start:e.start-60*t.deltaY,stop:e.stop})}onMinutesCounterInputBlur(t){const e=Number(t.target.value);isNaN(e)?t.target.value=l().minutesDisplayed.toString():e>=1&&h().setMinutesDisplayed(e),this.fromRef(this.minsInputRef).replaceWith(this.fromRef(this.minsCounterRef))}MinutesDisplay({ctx:t}){return u("div",{className:"display-mode-widget-mins"},u("div",null,"Last"),u(t.MinusButton,{onclick:()=>{const t=h().getState().minutesDisplayed;h().setMinutesDisplayed(t-1)}}),u(t.MinutesCounter,{ctx:t,onclick:()=>t.onMinutesCounterClick()}),u(t.PlusButton,{onclick:()=>{const t=h().getState().minutesDisplayed;h().setMinutesDisplayed(t+1)}}),u("div",null,"minutes"))}onMinutesCounterClick(){const t=this.fromRef(this.minsInputRef);this.fromRef(this.minsCounterRef).replaceWith(t),t.focus(),t.selectionStart=0,t.selectionEnd=t.value.length}onWindowStopDisplayClick(){const t=this.fromRef(this.windowStopTimeRef);t.valueAsDate=new Date(l().displayWindow.stop);const e=this.fromRef(this.windowStopTimeInputRef);t.replaceWith(e);const i=new Date(1e3*l().displayWindow.stop+60*l().utcOffset*60*1e3);e.value=`${i.toLocaleDateString()}, ${i.toLocaleTimeString()}`,e.focus()}onWindowStopInputBlur(){const t=this.fromRef(this.windowStopTimeInputRef),e=new Date(t.value).getTime()/1e3;isNaN(e)||h().setDisplayWindow({start:l().displayWindow.start,stop:e}),t.replaceWith(this.fromRef(this.windowStopTimeRef))}onWindowStartDisplayClick(){const t=this.fromRef(this.windowStartTimeRef);t.valueAsDate=new Date(l().displayWindow.start);const e=this.fromRef(this.windowStartTimeInputRef);t.replaceWith(e);const i=new Date(1e3*l().displayWindow.start+60*l().utcOffset*60*1e3);e.value=`${i.toLocaleDateString()}, ${i.toLocaleTimeString()}`,e.focus()}onWindowStartInputBlur(){const t=this.fromRef(this.windowStartTimeInputRef),e=new Date(t.value).getTime()/1e3;isNaN(e)||h().setDisplayWindow({start:e,stop:l().displayWindow.stop}),t.replaceWith(this.fromRef(this.windowStartTimeRef))}MinusButton(t){return u("div",{className:"minus-button",onclick:t.onclick})}PlusButton(t){return u("div",{className:"plus-button",onclick:t.onclick})}WindowedDisplay({ctx:t}){return u("div",null,u("div",null,"From"),u(t.MinusButton,{onclick:()=>{const t=h().getState().displayWindow;h().setDisplayWindow({start:t.start-60,stop:t.stop})}}),u(t.WindowStartTime,{ctx:t}),u(t.PlusButton,{onclick:()=>{const t=h().getState().displayWindow;h().setDisplayWindow({start:t.start+60,stop:t.stop})}}),u("div",null,"to"),u(t.MinusButton,{onclick:()=>{const t=h().getState().displayWindow;h().setDisplayWindow({start:t.start,stop:t.stop-60})}}),u(t.WindowStopTime,{ctx:t}),u(t.PlusButton,{onclick:()=>{const t=h().getState().displayWindow;h().setDisplayWindow({start:t.start,stop:t.stop+60})}}))}MainDisplay({ctx:t}){const e="window"===l().displayMode;return t.windowedDisplayRef=t.makeRef(u(t.WindowedDisplay,{ctx:t})),t.minsDisplayRef=t.makeRef(u(t.MinutesDisplay,{ctx:t})),u("div",{className:"display-mode-widget"},e?t.fromRef(t.windowedDisplayRef):t.fromRef(t.minsDisplayRef))}onSelectMode(t){h().setDisplayMode(t)}updateDisplay(){if("window"===l().displayMode){this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));const t=60*l().utcOffset*60,e=new Date(1e3*(l().displayWindow.start+t)),i=new Date(1e3*(l().displayWindow.stop+t));this.fromRef(this.windowStartTimeRef).innerText=e.toLocaleString(),this.fromRef(this.windowStopTimeRef).innerText=i.toLocaleString()}else this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef)),this.fromRef(this.minsCounterRef).innerText=l().minutesDisplayed.toString(),this.fromRef(this.minsInputRef).value=l().minutesDisplayed.toString()}current(){return this.skeleton.current()}};const g=class extends d{constructor(t){super(),this.display=u(this.MainDisplay,{ctx:this}),this.skeleton=new c({...t,className:"timer-widget",title:"Next update in:",body:this.display}),h().subscribeStoreVal("lastUpdateTime",(()=>this.resetTimer())),h().subscribeStoreVal("utcOffset",(()=>this.resetTimer())),setInterval((()=>this.refreshTimer()),10),this.resetTimer()}resetTimer(){this.nextUpdateTime=l().lastUpdateTime+l().updateIntervalSeconds,this.updateUpdateText(),this.refreshTimer()}updateUpdateText(){this.fromRef(this.lastUpdateRef).innerText=new Date(1e3*l().lastUpdateTime+60*l().utcOffset*60*1e3).toLocaleString()}MainDisplay({ctx:t}){return t.timerRef=t.makeRef(u("div",{className:"countdown"})),t.lastUpdateRef=t.makeRef(u("span",{className:"last-update"},new Date(l().lastUpdateTime).toLocaleString())),u("div",null,t.fromRef(t.timerRef),u("div",null,u("div",{className:"last-update"},"Last update was at:"),u("div",null,t.fromRef(t.lastUpdateRef))))}refreshTimer(){const t=(new Date).getTime()/1e3;t<=this.nextUpdateTime?this.fromRef(this.timerRef).innerText=(this.nextUpdateTime-t).toFixed(2)+"s":this.fromRef(this.timerRef).innerText="0.00s"}current(){return this.skeleton.current()}};const w=class extends d{constructor(t){super(),this.chart=null,this.displayMode="pastMins",this.canvasElement=document.createElement("canvas"),this.initialised=!1,this.canvasElement.className="chart-canvas",this.skeleton=new c({...t,body:this.canvasElement});const e=(new Date).getTime()/1e3;this.latestSnapshotInChartTime=e-60*l().minutesDisplayed,this.setupListeners(),this.updateDisplayMode()}updateDimensions(){const t=getComputedStyle(this.skeleton.current());this.canvasElement.height=this.skeleton.current().clientHeight-Number(t.paddingTop.slice(0,-2))-Number(t.paddingBottom.slice(0,-2)),this.canvasElement.width=this.skeleton.current().clientWidth-Number(t.paddingLeft.slice(0,-2))-Number(t.paddingRight.slice(0,-2))}setupListeners(){h().subscribeStoreVal("displayMode",(()=>this.updateDisplayMode())),h().subscribeStoreVal("minutesDisplayed",(()=>this.rerender())),h().subscribeStoreVal("displayWindow",(()=>this.rerender())),h().on("timeseriesUpdated",(()=>this.rerender())),h().on("newTimeseries",(t=>this.chart.addTimeseries(t))),h().subscribeStoreVal("documentReady",(()=>this.initChart())),h().subscribeStoreVal("utcOffset",(()=>this.updateTimezone())),h().subscribeStoreVal("highlightedTimeseries",(t=>this.chart.highlightTimeseries(t)))}handleScroll(t,e,i){let s=l().displayWindow;if("pastMins"===l().displayMode){h().setDisplayMode("window");const t=(new Date).getTime()/1e3;s={start:t-60*l().minutesDisplayed,stop:t}}const n=1===t?1.1:.9,a=n*(i-s.start),o=n*(s.stop-i);h().setDisplayWindow({start:i-a,stop:i+o})}handleDrag(t,e,i){"pastMins"===l().displayMode&&h().setDisplayMode("window");const s=l().displayWindow;h().setDisplayWindow({start:s.start+i,stop:s.stop+i})}updateTimezone(){const t=60*l().utcOffset*60*1e3;this.chart.setTimestampFormatter((e=>new Date(1e3*e+t).toLocaleTimeString()))}async initChart(){try{h().addLoad();const t=this.canvasElement.getContext("2d",{alpha:!1});this.chart=new s(t),l().leftTimeseries.forEach((t=>this.chart.addTimeseries(t,i.Left))),l().rightTimeseries.forEach((t=>this.chart.addTimeseries(t,i.Right))),this.chart.on("scroll",((...t)=>this.handleScroll(...t))),this.chart.on("drag",((...t)=>this.handleDrag(...t))),await this.rerender(),this.initialised=!0}catch(t){h().fatalError(t)}finally{h().finishLoad()}}async updateDisplayMode(){this.displayMode=l().displayMode,await this.rerender()}async rerender(){if(!this.initialised)return;let t,e;if("window"===this.displayMode)t=l().displayWindow.start,e=l().displayWindow.stop;else if("pastMins"===this.displayMode){const i=l().minutesDisplayed;t=l().lastUpdateTime-60*i,e=l().lastUpdateTime}this.chart.setRange({start:t,stop:e}),this.chart.render()}current(){return this.skeleton.current()}};const y=class extends d{constructor(){super(),this.showingError=!1,this.build(),h().subscribeStoreVal("overlayText",(()=>this.update())),h().subscribeStoreVal("isLoading",(()=>this.update())),h().subscribeStoreVal("fatalError",(()=>this.showError())),this.update()}build(){this.element=document.createElement("div"),this.element.classList.add("overlay","center"),this.textElement=document.createElement("span"),this.textElement.innerText="",this.element.appendChild(this.textElement)}show(){this.element.classList.remove("hidden")}hide(){this.element.classList.add("hidden")}showError(){const t=l().fatalError;this.showingError=!0,this.element.innerText=`${t.name}: ${t.message}!`,this.show()}update(){if(!this.showingError){let t;l().isLoading?t="Loading...":l().overlayText&&(t=l().overlayText),t?(this.textElement.innerText=t,this.show()):this.hide()}}current(){return this.element}};class x extends d{constructor(t){super(),this.mainBody=this.MainBody({ctx:this}),this.gridWidgetSkeleton=new c({...t,title:"Display Mode:",body:this.mainBody}),h().subscribeStoreVal("displayMode",(()=>this.update()))}selectMode(t){h().setDisplayMode(t)}update(){const t="window"===l().displayMode;this.fromRef(this.windowInputRef).checked=t,this.fromRef(this.minSpanInputRef).checked=!t,t?(this.fromRef(this.minSpanInputContainerRef).classList.remove("selected"),this.fromRef(this.windowInputContainerRef).classList.add("selected")):(this.fromRef(this.minSpanInputContainerRef).classList.add("selected"),this.fromRef(this.windowInputContainerRef).classList.remove("selected"))}MainBody({ctx:t}){const e="window"===l().displayMode;return t.windowInputRef=this.makeRef(u("input",{type:"radio",id:"window",name:"display-mode",checked:e})),t.minSpanInputRef=this.makeRef(u("input",{type:"radio",id:"min-span",name:"display-mode",checked:!e})),t.windowInputContainerRef=this.makeRef(u("div",{className:"display-mode-option"+(e?" selected":""),onclick:()=>t.selectMode("window")},this.fromRef(t.windowInputRef),u("label",{htmlFor:"window"},"Time Window"))),t.minSpanInputContainerRef=this.makeRef(u("div",{className:"display-mode-option"+(e?"":" selected"),onclick:()=>t.selectMode("pastMins")},this.fromRef(t.minSpanInputRef),u("label",{htmlFor:"minSpan"},"Rolling Minute Span"))),u("div",null,this.fromRef(t.windowInputContainerRef),this.fromRef(t.minSpanInputContainerRef))}current(){return this.gridWidgetSkeleton.current()}}const R=class extends d{constructor(t){super(),this.display=document.createElement("span"),this.display=u(this.MainBody,{ctx:this}),this.skeleton=new c({...t,title:"Legend:",className:"legend-widget",body:this.display}),h().subscribeStoreVal("highlightedTimeseries",(()=>this.updateDisplay())),this.updateDisplay()}updateDisplay(){this.fromRef(this.bodyRef).replaceWith(u(this.MainBody,{ctx:this}))}MainBody({ctx:t}){return t.bodyRef=t.makeRef(u("div",null,u(t.TimeseriesList,{ctx:t}))),t.fromRef(t.bodyRef)}TimeseriesList({ctx:t}){const e=l().highlightedTimeseries;return u("ul",null,l().rightTimeseries.map((i=>u(t.TimeseriesLegendEntry,{timeseries:i,highlighted:i.getName()===e}))),l().leftTimeseries.map((i=>u(t.TimeseriesLegendEntry,{timeseries:i,highlighted:i.getName()===e}))))}TimeseriesLegendEntry({timeseries:t,highlighted:e}){const i=new Option;return i.style.color=t.getColour(),u("li",{style:"color: "+i.style.color,className:e?"highlighted":"",onmouseover:()=>h().setHighlightedTimeseries(t.getName()),onmouseout:()=>h().setHighlightedTimeseries(null)},t.getName())}current(){return this.skeleton.current()}};const T=class extends d{constructor(){super(),this.element=document.createElement("div"),this.grid=document.createElement("div"),this.messageOverlay=new y,this.setupGrid({width:5,height:10}),this.element.append(Object.assign(document.createElement("h1"),{innerText:"Ledda's Room Climate"}),this.grid,this.messageOverlay.current()),this.element.className="center"}setupGrid(t){this.setupWidgets(),this.grid.append(this.legendWidget.current(),this.chartWidget.current(),this.displayModeSettingsWidget.current(),this.selectModeWidget.current(),this.timerWidget.current(),this.timezoneWidget.current()),this.grid.className="main-content-grid",this.grid.style.gridTemplateRows=`repeat(${t.height}, 1fr)`,this.grid.style.gridTemplateColumns=`repeat(${t.width}, 1fr)`}setupWidgets(){this.displayModeSettingsWidget=new f({row:"auto",col:5,width:1,height:3}),this.selectModeWidget=new x({row:"auto",col:5,width:1,height:2}),this.timezoneWidget=new p({row:"auto",col:5,width:1,height:1}),this.timerWidget=new g({row:"auto",col:5,width:1,height:2}),this.legendWidget=new R({row:"auto",col:5,width:1,height:2}),this.chartWidget=new w({row:1,col:1,width:4,height:10})}bootstrap(t){document.getElementById(t).append(this.element),this.chartWidget.updateDimensions()}current(){return this.element}};const S=class{constructor(t){let e;if(this.fetching=!1,this.extrema={minVal:1/0,maxVal:-1/0,minIndex:1/0,maxIndex:-1/0},this.cache=new Int32Array,this.loader=t.loader,this.name=t.name,this.tolerance=t.tolerance??0,t.colour){const i=(new Option).style;i.color=t.colour,e=i.color===t.colour?t.colour:null}this.colour=e??`rgb(${150*Math.random()},${150*Math.random()},${150*Math.random()})`,t.valueRangeOverride&&(this.valExtremaOverride={...t.valueRangeOverride})}getExtrema(){return Object.assign({},this.extrema)}getExtremaInRange(t,e){let i=-1/0,s=1/0;for(let n=this.findIndexInCache(t)-1;ni&&(i=this.cache[n]);return{minIndex:this.extrema.minIndex,maxIndex:this.extrema.maxIndex,maxVal:this.valExtremaOverride.high>i?this.valExtremaOverride.high:i,minVal:this.valExtremaOverride.lowt+this.tolerance&&(this.fetching=!0,await this.fetchPrior(this.cache[1],t)),this.cache[this.currentEndPointer-1]t+i&&(i=e-t);const s=await this.loader(t,t+i),n=new Int32Array(this.cache.length+s.length);n.set(this.cache,0),n.set(s,this.currentEndPointer),this.cache=n,this.currentEndPointer+=s.length,this.updateExtremaFrom(s)}catch(t){throw new Error("Error fetching anterior data: "+t)}}async fetchPrior(t,e){try{let i=2*(this.cache[this.currentEndPointer-1]-this.cache[1]);ethis.extrema.maxVal&&(this.extrema.maxVal=t[e]);for(let e=1;ethis.extrema.maxIndex&&(this.extrema.maxIndex=t[e])}findIndexInCache(t){return this.findIndexInCacheBinary(t)}findIndexInCacheLinear(t){for(let e=1;e3?e-3:e-1;return this.cache.length-2}findIndexInCacheBinary(t,e=0,i=this.currentEndPointer/2){if(i-e==1)return 2*e+1;{const s=Math.floor((i+e)/2),n=this.cache[2*s+1];return n>t?this.findIndexInCacheBinary(t,e,s):nb("co2",t,e),tolerance:t,valueRangeOverride:{high:800,low:400}})),i.Right),h().addTimeseriesToScale((t=>new S({name:"Temperature (°C)",loader:(t,e)=>b("temp",t,e),tolerance:t,valueRangeOverride:{high:30,low:10}}))(l().updateIntervalSeconds),i.Left),h().addTimeseriesToScale((t=>new S({name:"Humidity (%)",loader:(t,e)=>b("humidity",t,e),tolerance:t,valueRangeOverride:{high:75,low:40}}))(l().updateIntervalSeconds),i.Left);(new T).bootstrap("root")}let M;document.onreadystatechange=async()=>{await D(),h().setDocumentReady(!0),h().on("stateChange",(()=>function(t,e=300){return(...i)=>{clearTimeout(M),M=setTimeout((()=>{t.apply(this,i)}),e)}}((()=>function(){const t=h().serialiseState(),e=`${window.location.pathname}${""!==t?"?"+t:""}`;window.history.replaceState("","",e)}()))())),window.store=h(),document.onreadystatechange=null}})(); \ No newline at end of file diff --git a/app-dist/static/favicon16.png b/app-dist/static/favicon16.png new file mode 100644 index 0000000..7b5cac6 Binary files /dev/null and b/app-dist/static/favicon16.png differ diff --git a/app-dist/static/favicon32.png b/app-dist/static/favicon32.png new file mode 100644 index 0000000..2f1bf80 Binary files /dev/null and b/app-dist/static/favicon32.png differ diff --git a/app-dist/static/favicon64.png b/app-dist/static/favicon64.png new file mode 100644 index 0000000..0f9484f Binary files /dev/null and b/app-dist/static/favicon64.png differ diff --git a/app-dist/static/index.html b/app-dist/static/index.html index 5534474..70ce99e 100644 --- a/app-dist/static/index.html +++ b/app-dist/static/index.html @@ -5,6 +5,7 @@ Ledda's Room Climate +
diff --git a/app-dist/static/styles.css b/app-dist/static/styles.css index 5a99ec8..b404333 100644 --- a/app-dist/static/styles.css +++ b/app-dist/static/styles.css @@ -11,8 +11,8 @@ html, body { } .main-content-grid { - height: 80%; - width: 80%; + width: calc(100% - 12em); + height: calc(100% - 10em); display: grid; text-align: center; } @@ -199,4 +199,67 @@ h1 { .legend-widget li.highlighted { font-weight: bold; cursor: pointer; +} + +.help-box { + position: relative; + background-color: #ffffff; + border: solid 1px #7b5b2f; + width: 40em; + padding: 1em; + z-index: 1; +} +.help-box img { +} +.help-box h1 { + margin: auto; + text-align: center; +} +.help-box .image-advice { + align-items: center; + margin-top: 1em; + margin-bottom: 1em; + display: flex; +} +.help-box .image-advice * { + display: inline-block; + padding: 0.5em; +} +.x-button:before { + content: "×"; + font-size: 1em; + line-height: 1em; +} +.x-button { + cursor: pointer; + text-align: center; + background-color: white; + height: 1.2em; + width: 1.2em; + position: absolute; + right: 0; + top: 0; +} + +.help-button { + cursor: pointer; + width: 2em; + height: 2em; + margin: 0.5em; + position: absolute; + right: 0; + top: 0; +} +.help-button:hover { + filter: brightness(1.1); +} +.help-button:active { + filter: brightness(0.9); +} + +.button:hover { + filter: brightness(1.1); +} +.button:active { + filter: brightness(0.9); } \ No newline at end of file diff --git a/dashboard/assets/favicon.svg b/dashboard/assets/favicon.svg new file mode 100644 index 0000000..8a8eb50 --- /dev/null +++ b/dashboard/assets/favicon.svg @@ -0,0 +1,266 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/assets/help-button.png b/dashboard/assets/help-button.png new file mode 100644 index 0000000..644af14 Binary files /dev/null and b/dashboard/assets/help-button.png differ diff --git a/dashboard/assets/help-button.svg b/dashboard/assets/help-button.svg new file mode 100644 index 0000000..df07aeb --- /dev/null +++ b/dashboard/assets/help-button.svg @@ -0,0 +1,78 @@ + + + + + + + + image/svg+xml + + + + + + + + ? + + diff --git a/dashboard/assets/move-example.gif b/dashboard/assets/move-example.gif new file mode 100644 index 0000000..d3385aa Binary files /dev/null and b/dashboard/assets/move-example.gif differ diff --git a/dashboard/assets/scroll-date-example.gif b/dashboard/assets/scroll-date-example.gif new file mode 100644 index 0000000..2d4f2be Binary files /dev/null and b/dashboard/assets/scroll-date-example.gif differ diff --git a/dashboard/assets/zoom-example.gif b/dashboard/assets/zoom-example.gif new file mode 100644 index 0000000..905ee4b Binary files /dev/null and b/dashboard/assets/zoom-example.gif differ diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 105781c..8e69712 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -3139,6 +3139,16 @@ "escape-string-regexp": "^1.0.5" } }, + "file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index 895fe49..1deddbb 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,6 +17,7 @@ "babel-plugin-syntax-dynamic-import": "^6.18.0", "css-loader": "^5.0.1", "style-loader": "^2.0.0", + "file-loader": "^6.0.0", "terser-webpack-plugin": "^5.0.3", "ts-loader": "^8.0.18", "uglifyjs-webpack-plugin": "^2.2.0", diff --git a/dashboard/src/StateStore.ts b/dashboard/src/StateStore.ts index 613d96a..9cf2767 100644 --- a/dashboard/src/StateStore.ts +++ b/dashboard/src/StateStore.ts @@ -15,6 +15,7 @@ export interface EventCallback { timeseriesUpdated: (timeseries: Timeseries) => void; newTimeseries: (timeseries: Timeseries, scale?: ScaleId) => void; stateChange: StateChangeCallback; + ready: () => void; } type StateChangeCallback = (attrName?: K, oldVal?: T[K], newVal?: T[K]) => void; type EventCallbackListing = Record; @@ -39,6 +40,7 @@ interface AppState { fatalError: Error | null; documentReady: boolean; highlightedTimeseries: string | null; + showingHelp: boolean; } type StoreUpdateCallback = (newValue?: T, oldValue?: T) => void; @@ -62,10 +64,11 @@ function newDefaultState(): AppState { leftTimeseries: [], rightTimeseries: [], highlightedTimeseries: null, + showingHelp: false, }; } -class AppStateStore { +class AppStateStore { private readonly subscriptions: IAppStateSubscriptions; private readonly eventCallbacks: EventCallbackListing; private readonly state: AppState; @@ -77,7 +80,7 @@ class AppStateStore { for (const key in this.state) { subscriptions[key] = []; } - this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: []}; + this.eventCallbacks = {newTimeseries: [], timeseriesUpdated: [], stateChange: [], ready: []}; this.subscriptions = subscriptions as IAppStateSubscriptions; this.init(); setInterval(() => this.getNewTimeseriesData().catch(e => AppStore().fatalError(e)), this.state.updateIntervalSeconds * 1000); @@ -86,6 +89,7 @@ class AppStateStore { async init() { await this.updateTimeseriesFromSettings(); await this.getNewTimeseriesData(); + this.emit("ready"); } addTimeseriesToScale(timeseries: Timeseries, scale?: ScaleId) { @@ -191,7 +195,7 @@ class AppStateStore { setDisplayWindow(newWin: TimeWindow) { if (newWin.start < newWin.stop) { - if (newWin.stop < this.state.lastUpdateTime) { + if (newWin.stop <= this.state.lastUpdateTime) { this.state.displayWindow = {...newWin}; this.notifyStoreVal("displayWindow"); this.updateTimeseriesFromSettings(); @@ -270,6 +274,24 @@ class AppStateStore { this.notifyStoreVal("highlightedTimeseries", name); } + emulateLastMinsWithWindow() { + this.setDisplayMode("window"); + this.setDisplayWindow({ + start: this.state.lastUpdateTime - getAppState().minutesDisplayed * 60 + getAppState().utcOffset * 60, + stop: this.state.lastUpdateTime + }); + } + + showHelp() { + this.state.showingHelp = true; + this.notifyStoreVal("showingHelp"); + } + + hideHelp() { + this.state.showingHelp = false; + this.notifyStoreVal("showingHelp"); + } + serialiseState(): string { const stateStringParams = []; if (this.state.displayMode === "pastMins") { diff --git a/dashboard/src/Timeseries.ts b/dashboard/src/Timeseries.ts index 14fc668..9afb914 100644 --- a/dashboard/src/Timeseries.ts +++ b/dashboard/src/Timeseries.ts @@ -95,7 +95,7 @@ class Timeseries { const cacheStopIndex = this.findIndexInCache(stop); return this.cache.slice( (cacheStartIndex - (cacheStartIndex) % blockSize), - (cacheStopIndex + blockSize - (cacheStopIndex) % blockSize), + (cacheStopIndex + blockSize - (cacheStopIndex) % blockSize) + blockSize * 2, ); } } diff --git a/dashboard/src/chart/Chart.ts b/dashboard/src/chart/Chart.ts index 20ca2d5..de5511e 100644 --- a/dashboard/src/chart/Chart.ts +++ b/dashboard/src/chart/Chart.ts @@ -40,8 +40,9 @@ export default class Chart { constructor(context: CanvasRenderingContext2D) { this.subscriptions = {scroll: [], mousemove: [], drag: []}; this.ctx = context; - this.initLayout(); - this.updateDimensions(); + this.leftScale = new Scale(); + this.rightScale = new Scale(); + this.updateLayout(); this.ctx.fillStyle = "rgb(255,255,255)"; this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); this.ctx.fill(); @@ -54,12 +55,12 @@ export default class Chart { this.ctx.canvas.onwheel = (e) => this.handleScroll(e); } - private initLayout() { + updateLayout() { const leftScaleInitialWidth = 50; const rightScaleInitialWidth = 50; const verticalMargins = this.margins.bottom + this.margins.top; const horizontalMargins = this.margins.left + this.margins.right; - this.leftScale = new Scale({ + this.leftScale.updateBounds({ top: this.margins.top, left: this.margins.left, height: this.ctx.canvas.height - verticalMargins, @@ -71,17 +72,13 @@ export default class Chart { height: this.ctx.canvas.height - verticalMargins, width: this.ctx.canvas.width - (horizontalMargins + leftScaleInitialWidth + rightScaleInitialWidth), }; - this.rightScale = new Scale({ + this.rightScale.updateBounds({ top: this.margins.top, left: this.ctx.canvas.width - this.margins.right - rightScaleInitialWidth, height: this.ctx.canvas.height - verticalMargins, width: rightScaleInitialWidth, }); - } - - private updateDimensions() { - this.chartBounds.width = Number(getComputedStyle(this.ctx.canvas).width.slice(0, -2)) - (this.margins.left + this.margins.right + this.rightScale.getBounds().width + this.leftScale.getBounds().width); - this.chartBounds.height = Number(getComputedStyle(this.ctx.canvas).height.slice(0, -2)) - (this.margins.bottom + this.margins.top); + this.render(); } addTimeseries(timeseries: Timeseries, scale?: ScaleId) { @@ -141,7 +138,6 @@ export default class Chart { } render() { - this.updateDimensions(); this.clearCanvas(); this.updateResolution(); this.renderGuides(); @@ -149,15 +145,43 @@ export default class Chart { this.rightScale.updateIndexRange(this.indexRange); this.leftScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Left)); this.rightScale.listTimeseries().forEach(timeseries => this.renderTimeseries(timeseries, ScaleId.Right)); + this.renderMargins(); this.leftScale.render(this.ctx); this.rightScale.render(this.ctx); this.renderTooltips(); } + private renderMargins() { + this.ctx.fillStyle = "rgb(255,255,255)"; + this.ctx.fillRect( + this.ctx.canvas.clientLeft - 1, + this.ctx.canvas.clientTop - 1, + this.ctx.canvas.width + 1, + this.margins.top + 1, + ); + this.ctx.fillRect( + this.ctx.canvas.clientLeft + this.ctx.canvas.width - this.margins.right - 1, + this.ctx.canvas.clientTop - 1, + this.margins.right + 1, + this.ctx.canvas.height + 1, + ); + this.ctx.fillRect( + this.ctx.canvas.clientLeft - 1, + this.ctx.canvas.clientTop - 1, + this.margins.left + 1, + this.ctx.canvas.height + 1, + ); + this.ctx.fillRect( + this.ctx.canvas.clientLeft, + this.ctx.canvas.clientTop + this.ctx.canvas.height - this.margins.bottom, + this.ctx.canvas.width, + this.margins.bottom, + ); + } + private clearCanvas() { this.ctx.fillStyle = "rgb(255,255,255)"; this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); - this.ctx.fill(); } private updateResolution() { @@ -174,13 +198,13 @@ export default class Chart { private renderGuides() { this.ctx.strokeStyle = "rgb(230, 230, 230)"; this.ctx.lineWidth = 1; + this.ctx.beginPath(); for (const tick of this.rightScale.getTicks()) { - const pos = this.rightScale.getY(tick); - this.ctx.beginPath(); + const pos = Math.floor(this.rightScale.getY(tick)); this.ctx.moveTo(this.chartBounds.left, pos); this.ctx.lineTo(this.chartBounds.left + this.chartBounds.width, pos); - this.ctx.stroke(); } + this.ctx.stroke(); } private renderTooltips(radius = 20) { @@ -250,8 +274,8 @@ export default class Chart { this.ctx.lineWidth = timeseries.getName() === this.highlightedTimeseries ? 2 : 1; let y = scale.getY(timeseriesPoints[0]); let x = this.getX(timeseriesPoints[1]); + this.ctx.beginPath(); for (let i = 0; i < timeseriesPoints.length; i += 2 * this.resolution) { - this.ctx.beginPath(); this.ctx.moveTo(Math.round(x), Math.round(y)); y = 0; x = 0; @@ -262,13 +286,11 @@ export default class Chart { y = scale.getY(y / this.resolution); x = this.getX(x / this.resolution); this.ctx.lineTo(Math.round(x), Math.round(y)); - this.ctx.stroke(); if (this.resolution === 1) { - this.ctx.beginPath(); this.ctx.ellipse(x, y, 2, 2, 0, 0, 2 * Math.PI); - this.ctx.stroke(); } } + this.ctx.stroke(); } private renderTooltip(text: string, x: number, y: number, markerColour: string) { diff --git a/dashboard/src/chart/Scale.ts b/dashboard/src/chart/Scale.ts index 9e88ada..214e180 100644 --- a/dashboard/src/chart/Scale.ts +++ b/dashboard/src/chart/Scale.ts @@ -8,8 +8,8 @@ export default class Scale { private tickCacheDirty = true; private bounds: Bounds; - constructor(bounds: Bounds) { - this.bounds = bounds; + constructor(bounds?: Bounds) { + this.bounds = bounds ?? {height: 0, width: 0, top: 0, left: 0}; } updateIndexRange(indexRange: {start: number, stop: number}) { @@ -27,6 +27,10 @@ export default class Scale { this.tickCacheDirty = true; } + updateBounds(bounds: Bounds) { + Object.assign(this.bounds, bounds); + } + getBounds() { return Object.assign({}, this.bounds); } diff --git a/dashboard/src/config.json b/dashboard/src/config.json index 0bedc3f..4b28a2c 100644 --- a/dashboard/src/config.json +++ b/dashboard/src/config.json @@ -2,5 +2,5 @@ "development": false, "defaultMinuteSpan": 60, "reloadIntervalSec": 30, - "dataEndpoint": "/climate/api" + "dataEndpoint": "http://tortedda.local/climate/api" } diff --git a/dashboard/src/types.d.ts b/dashboard/src/types.d.ts index 436ec8a..9a5934e 100644 --- a/dashboard/src/types.d.ts +++ b/dashboard/src/types.d.ts @@ -1,4 +1,8 @@ -declare module "chart.js/dist/Chart.bundle.min" { - import * as Charts from "chart.js"; - export default Charts; +declare module "*.gif" { + const value: string; + export = value; +} +declare module "*.png" { + const value: string; + export = value; } \ No newline at end of file diff --git a/dashboard/src/ui-components/AppUI.ts b/dashboard/src/ui-components/AppUI.tsx similarity index 83% rename from dashboard/src/ui-components/AppUI.ts rename to dashboard/src/ui-components/AppUI.tsx index a36166c..77c753b 100644 --- a/dashboard/src/ui-components/AppUI.ts +++ b/dashboard/src/ui-components/AppUI.tsx @@ -7,6 +7,10 @@ import MessageOverlay from "./MessageOverlay"; import UIComponent from "./UIComponent"; import SelectDisplayModeWidget from "./SelectDisplayModeWidget"; import LegendWidget from "./LegendWidget"; +import HelpModal from "./HelpModal"; +import * as JSX from "../JSXFactory"; +import {AppStore} from "../StateStore"; +import HelpButtonImg from "../../assets/help-button.png"; class AppUI extends UIComponent { private timezoneWidget: TimezoneWidget; @@ -18,14 +22,21 @@ class AppUI extends UIComponent { private element: HTMLDivElement = document.createElement("div"); private grid: HTMLDivElement = document.createElement("div"); private messageOverlay: MessageOverlay = new MessageOverlay(); + private helpModal: HelpModal = new HelpModal(); constructor() { super(); this.setupGrid({width: 5, height: 10}); this.element.append( - Object.assign(document.createElement("h1"), { innerText: "Ledda's Room Climate" }), + {"Help"} AppStore().showHelp()}/>, +

Ledda's Room Climate

, this.grid, this.messageOverlay.current(), + this.helpModal.current(), ); this.element.className = "center"; } @@ -33,8 +44,8 @@ class AppUI extends UIComponent { private setupGrid(size: GridSize) { this.setupWidgets(); this.grid.append( - this.legendWidget.current(), this.chartWidget.current(), + this.legendWidget.current(), this.displayModeSettingsWidget.current(), this.selectModeWidget.current(), this.timerWidget.current(), @@ -68,7 +79,6 @@ class AppUI extends UIComponent { bootstrap(rootNode: string) { document.getElementById(rootNode).append(this.element); - this.chartWidget.updateDimensions(); } current(): HTMLElement { diff --git a/dashboard/src/ui-components/ClimateChartWidget.ts b/dashboard/src/ui-components/ClimateChartWidget.ts index 6b29faa..779573c 100644 --- a/dashboard/src/ui-components/ClimateChartWidget.ts +++ b/dashboard/src/ui-components/ClimateChartWidget.ts @@ -26,6 +26,8 @@ class ClimateChartWidget extends UIComponent { } updateDimensions() { + this.canvasElement.width = 0; + this.canvasElement.height = 0; const skelStyle = getComputedStyle(this.skeleton.current()); this.canvasElement.height = this.skeleton.current().clientHeight - Number(skelStyle.paddingTop.slice(0, -2)) @@ -33,6 +35,7 @@ class ClimateChartWidget extends UIComponent { this.canvasElement.width = this.skeleton.current().clientWidth - Number(skelStyle.paddingLeft.slice(0, -2)) - Number(skelStyle.paddingRight.slice(0, -2)); + this.chart.updateLayout(); } private setupListeners() { @@ -41,18 +44,22 @@ class ClimateChartWidget extends UIComponent { AppStore().subscribeStoreVal("displayWindow", () => this.rerender()); AppStore().on("timeseriesUpdated", () => this.rerender()); AppStore().on("newTimeseries", (timeseries) => this.chart.addTimeseries(timeseries)); - AppStore().subscribeStoreVal("documentReady", () => this.initChart()); + AppStore().subscribeStoreVal("documentReady", async () => { + await this.initChart(); + this.updateDimensions(); + window.addEventListener("resize", () => { + this.updateDimensions(); + }); + }); AppStore().subscribeStoreVal("utcOffset", () => this.updateTimezone()); AppStore().subscribeStoreVal("highlightedTimeseries", (name) => this.chart.highlightTimeseries(name)); } private handleScroll(direction: number, magnitude: number, index: number) { - let displayedWindow = getAppState().displayWindow; if (getAppState().displayMode === "pastMins") { - AppStore().setDisplayMode("window"); - const now = new Date().getTime() / 1000; - displayedWindow = {start: now - getAppState().minutesDisplayed * 60, stop: now}; + AppStore().emulateLastMinsWithWindow(); } + const displayedWindow = getAppState().displayWindow; const beforeIndex = index - displayedWindow.start; const afterIndex = displayedWindow.stop - index; const factor = direction === 1 ? 1.1 : 0.9; @@ -66,12 +73,12 @@ class ClimateChartWidget extends UIComponent { private handleDrag(deltaX: number, deltaY: number, deltaIndex: number) { if (getAppState().displayMode === "pastMins") { - AppStore().setDisplayMode("window"); + AppStore().emulateLastMinsWithWindow(); } - const displayWindow = getAppState().displayWindow; + const displayedWindow = getAppState().displayWindow; AppStore().setDisplayWindow({ - start: displayWindow.start + deltaIndex, - stop: displayWindow.stop + deltaIndex, + start: displayedWindow.start + deltaIndex, + stop: displayedWindow.stop + deltaIndex, }); } diff --git a/dashboard/src/ui-components/HelpModal.tsx b/dashboard/src/ui-components/HelpModal.tsx new file mode 100644 index 0000000..40d3871 --- /dev/null +++ b/dashboard/src/ui-components/HelpModal.tsx @@ -0,0 +1,80 @@ +import {AppStore, getAppState} from "../StateStore"; +import UIComponent from "./UIComponent"; +import * as JSX from "../JSXFactory"; +import MovePic from "../../assets/move-example.gif"; +import ZoomPic from "../../assets/zoom-example.gif"; +import ScrollPic from "../../assets/scroll-date-example.gif"; + +class HelpModal extends UIComponent { + private element: HTMLElement; + private visible = false; + + constructor() { + super(); + this.build(); + AppStore().subscribeStoreVal("showingHelp", () => this.update()); + this.update(); + } + + private build() { + this.element = ( +
+
this.hide()}/> +
+
this.hide()}/> +

Quick Help

+
+ {"Animated +
+ Clicking the plus and minus buttons will adjust the time spans and minute spans by one minute. + Try scrolling over the numbers or clicking on them for direct editing as well! +
+
+
+ {"Animated +
+ Dragging over the chart will switch to time window mode and allow you to pan back and forth. +
+
+
+ {"Animated +
+ Try scrolling whilst hovering over the chart to zoom in and out. +
+
+
+
+ ); + } + + private show() { + this.visible = true; + this.element.classList.remove("hidden"); + AppStore().showHelp(); + } + + private hide() { + this.visible = false; + this.element.classList.add("hidden"); + AppStore().hideHelp(); + } + + update() { + this.visible = getAppState().showingHelp; + if (this.visible) { + this.element.classList.remove("hidden"); + } else { + this.element.classList.add("hidden"); + } + } + + current() { + return this.element; + } +} + +export default HelpModal; \ No newline at end of file diff --git a/dashboard/webpack.config.js b/dashboard/webpack.config.js index 34af050..abc09b4 100644 --- a/dashboard/webpack.config.js +++ b/dashboard/webpack.config.js @@ -10,23 +10,33 @@ const webpackConfig = { plugins: [new webpack.ProgressPlugin()], module: { - rules: [{ - test: /\.(ts|tsx)$/, - loader: "ts-loader", - include: [path.resolve(__dirname, "src")], - exclude: [/node_modules/] - }, { - test: /.css$/, + rules: [ + { + test: /\.(ts|tsx)$/, + loader: "ts-loader", + include: [path.resolve(__dirname, "src")], + exclude: [/node_modules/] + }, + { + test: /.css$/, - use: [{ - loader: "style-loader" - }, { - loader: "css-loader", - options: { - sourceMap: true - } + use: [{ + loader: "style-loader" + }, { + loader: "css-loader", + options: { + sourceMap: true + } + }] + }, + { + test: /\.(png|jpe?g|gif|ttf|woff2?|eot|svg)$/i, + use: [ + { + loader: "file-loader", + }, + ], }] - }] }, resolve: {