diff --git a/elements/heatmap.js b/elements/heatmap.js new file mode 100644 index 0000000..76a2573 --- /dev/null +++ b/elements/heatmap.js @@ -0,0 +1,82 @@ +import { withComponent, props } from 'skatejs'; +import { Timeseries } from '../lib/timeseries'; + +const Component = withComponent(); + +class Heatmap extends Component { + static props = { + timeseries: props.string, + days: props.number({ + default: 7, + }), + interpolate: props.boolean, + accumulate: props.boolean, + }; + connected() { + this.enableFetch = true; + } + renderer(renderRoot, render) { + const root = renderRoot; + root.innerHtml = ''; + root.appendChild(render()); + } + + render({ timeseries, interpolate, accumulate, days }) { + const el = document.createElement('div'); + if (!this.enableFetch) { + // Not yet connected + return el; + } + if (this.ts) { + // We're re-rendering, cancel previous + this.ts.canceled = true; + } + const ts = new Timeseries(timeseries, new Date(), days); + const data = [{ + x: ts.getSlotLabels(), + y: ts.getDayLabels(), + z: ts.prepareSlots(), + type: 'heatmap', + colorscale: [ + ['0.0', 'rgb(0, 0, 0)'], + ['0.9', 'rgb(255, 0, 0)'], + ['1.0', 'rgb(128, 0, 0)'], + ], + showlegend: false, + showscale: false, + }]; + const layout = { + yaxis: { + autorange: 'reversed', + tickfont: { + family: 'Source Code Pro', + }, + }, + xaxis: { + type: 'category', + tickfont: { + family: 'Source Code Pro', + }, + }, + paper_bgcolor: 'transparent', + }; + this.ts = ts; + ts.getData({ + interpolate, + accumulate, + }) + .then((values) => { + if (ts.canceled) { + el.innerHTML = ''; + return; + } + data[0].z = values; + Plotly.newPlot(el, data, layout, { + staticPlot: true, + }); + }); + return el; + } +} + +customElements.define('cbase-heatmap', Heatmap); diff --git a/elements/polar.js b/elements/polar.js new file mode 100644 index 0000000..18bcc64 --- /dev/null +++ b/elements/polar.js @@ -0,0 +1,108 @@ +import { withComponent, props } from 'skatejs'; +import { Timeseries } from '../lib/timeseries'; + +const Component = withComponent(); + +class Polar extends Component { + static props = { + timeseries: props.string, + days: props.number({ + default: 7, + }), + interpolate: props.boolean, + accumulate: props.boolean, + percentage: props.boolean, + }; + + connected() { + this.enableFetch = true; + } + + renderer(renderRoot, render) { + const root = renderRoot; + root.innerHtml = ''; + root.appendChild(render()); + } + + render({ timeseries, interpolate, accumulate, percentage, days }) { + const el = document.createElement('div'); + if (!this.enableFetch) { + // Not yet connected + return el; + } + if (this.ts) { + // We're re-rendering, cancel previous + this.ts.canceled = true; + } + const ts = new Timeseries(timeseries, new Date(), days); + const layout = { + orientation: 270, + direction: 'clockwise', + angularaxis: { + type: 'category', + tickcolor: '#204a87', + }, + radialaxis: { + ticksuffix: percentage ? '%' : '', + tickcolor: '#204a87', + }, + font: { + family: ['Source Code Pro', 'sans-serif'], + size: 16, + color: '#fff', + outlineColor: 'transparent', + }, + showlegend: false, + paper_bgcolor: 'transparent', + }; + let data = []; + const dayLabels = ts.getDayLabels(); + ts.prepareSlots().forEach((day, dayIdx) => { + data.push({ + r: day, + t: ts.getSlotLabels(), + name: dayLabels[dayIdx], + type: 'area', + marker: { + color: '#f57900', + }, + opacity: 0.8, + showlegend: false, + }); + }); + this.ts = ts; + ts.getData({ + interpolate, + accumulate, + usePreviousValue: false, + }) + .then((values) => { + if (ts.canceled) { + el.innerHTML = ''; + return; + } + if (percentage) { + data = data.slice(0); + const result = ts.getSlotLabels().map(() => 0); + values.forEach((day) => { + day.forEach((val, idx) => { + result[idx] += val; + }); + }); + data[0].r = result.map(r => (r / ts.days) * 100); + } else { + values.forEach((day, dayIdx) => { + day.forEach((val, idx) => { + data[dayIdx].r[idx] = val; + }); + }); + } + Plotly.newPlot(el, data, layout, { + staticPlot: true, + }); + }); + return el; + } +} + +customElements.define('cbase-polar', Polar); diff --git a/index.js b/index.js new file mode 100644 index 0000000..ffb3485 --- /dev/null +++ b/index.js @@ -0,0 +1,7 @@ +import heatmap from './elements/heatmap'; +import polar from './elements/polar'; + +export default { + heatmap, + polar, +}; diff --git a/lib/timeseries.js b/lib/timeseries.js new file mode 100644 index 0000000..0ec3413 --- /dev/null +++ b/lib/timeseries.js @@ -0,0 +1,176 @@ +export class Timeseries { + constructor(id, endDate, days = 7, slots = 24) { + this.id = id; + this.endDate = endDate; + this.startDate = this.getStartDate(endDate, days); + this.days = this.getDays(); + this.slots = slots; + } + + getStartDate(endDate, days) { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0); + return startDate; + } + + getDays() { + return Math.ceil((this.endDate - this.startDate) / (1000 * 60 * 60 * 24)); + } + + prepareSlots(defaultValue = 0) { + const timeSlots = []; + for (let i = 0; i < this.days; i++) { + const daySlot = []; + for (let ii = 0; ii < this.slots; ii++) { + daySlot.push(defaultValue); + } + timeSlots.push(daySlot); + } + return timeSlots; + } + + getDayLabels() { + const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const labels = []; + const day = new Date(this.startDate.getTime()); + for (let i = 0; i < this.days; i++) { + labels.push(`${day.getDate()} ${weekDays[day.getDay()]}`); + day.setHours(day.getHours() + 24); + } + return labels; + } + + getSlotLabels() { + const labels = []; + const day = new Date(this.startDate.getTime()); + const pad = (time) => { + let val = `${time}`; + if (val.length === 1) { + val = `0${time}`; + } + return val; + }; + + day.setHours(0, 0, 0); + for (let i = 0; i < this.slots; i++) { + const from = day.getHours(); + day.setHours(day.getHours() + (24 / this.slots)); + let to = day.getHours(); + if (to === 0) { + to = 24; + } + if (this.slots === 24) { + labels.push(`${pad(from)}`); + } else { + labels.push(`${pad(from)}-${pad(to)}`); + } + } + return labels; + } + + parseValue(val) { + if (typeof val === 'boolean') { + if (val) { + return 1; + } + return 0; + } + return parseFloat(val); + } + + flattenData(data, fromDay = 0, fromHour = 0) { + const flattened = []; + data.forEach((dayData, dayIdx) => { + if (dayIdx < fromDay) { + return; + } + dayData.forEach((point, hourIdx) => { + if (dayIdx === fromDay && hourIdx <= fromHour) { + return; + } + flattened.push(point); + }); + }); + + return flattened; + } + + mapData(data, options) { + const slots = this.prepareSlots(null); + data.forEach((point) => { + const pointDate = new Date(point.timestamp); + const pointValue = this.parseValue(point.value); + const daySlot = Math.floor((pointDate - this.startDate) / (1000 * 60 * 60 * 24)); + const timeSlot = Math.floor(pointDate.getHours() / (24 / this.slots)); + if (slots[daySlot][timeSlot] === null) { + slots[daySlot][timeSlot] = 0; + } + if (options.accumulate) { + slots[daySlot][timeSlot] += pointValue; + } else { + slots[daySlot][timeSlot] = Math.max(pointValue, slots[daySlot][timeSlot]); + } + }); + + // Deal with slots without data + let prevVal = 0; + slots.forEach((dayData, dayIdx) => { + let prevValIdx = 0; + const dayVals = dayData; + dayVals.forEach((val, idx) => { + if (val !== null) { + prevVal = val; + prevValIdx = idx; + return; + } + if (options.interpolate) { + const flattened = this.flattenData(slots, dayIdx, idx); + for (let nextSlot = 0; nextSlot < flattened.length; nextSlot++) { + if (flattened[nextSlot] === null) { + continue; + } + const nextVal = flattened[nextSlot]; + if (nextVal === prevVal) { + dayVals[idx] = prevVal; + return; + } + let difference = 0; + if (nextVal > prevVal) { + difference = nextVal - prevVal; + } else { + difference = prevVal - nextVal; + } + const distance = nextSlot; + let totalDistance = (nextSlot + idx) - prevValIdx; + if (totalDistance < 1) { + totalDistance = 1; + } + dayVals[idx] = Math.max(((difference / totalDistance) * distance) + nextVal, 0); + return; + } + dayVals[idx] = 0; + return; + } + if (options.usePreviousValue) { + dayVals[idx] = prevVal; + return; + } + dayVals[idx] = 0; + }); + }); + return slots; + } + + getUrl() { + return `http://openmct.cbrp3.c-base.org/telemetry/${this.id}?start=${this.startDate.getTime()}&end=${this.endDate.getTime()}`; + } + + getData(options) { + return fetch(this.getUrl()) + .then(data => data.json()) + .then(data => this.mapData(data, options)); + } +} + +export default Timeseries; diff --git a/package.json b/package.json index 734f2d9..18186a2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "webpack", "pretest": "eslint lib/*.js elements/*.js index.js", "start": "http-server . -p 3000 -s", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "npm run pretest" }, "author": "Henri Bergius ", "license": "MIT", diff --git a/screens/test.html b/screens/test.html new file mode 100644 index 0000000..4732999 --- /dev/null +++ b/screens/test.html @@ -0,0 +1,33 @@ + + + + + Components test + + + + + + + +
+ + +
+ + +