Skip to content

Commit

Permalink
Device show page
Browse files Browse the repository at this point in the history
  • Loading branch information
timcowlishaw committed Jan 14, 2025
1 parent 36a6a9d commit cf1131f
Show file tree
Hide file tree
Showing 15 changed files with 626 additions and 8 deletions.
1 change: 0 additions & 1 deletion .browserslistrc

This file was deleted.

1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@
@import "components/copyable_input";
@import "components/device_map";
@import "components/map_location_picker";
@import "components/reading";
42 changes: 42 additions & 0 deletions app/assets/stylesheets/components/reading.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
.reading {
.big-number {
line-height: 3.6rem;
height: 4rem;
.trend {
vertical-align: top;
line-height: 3.6rem;
font-size: 1.8rem;

}
.value {
font-size: 3.6rem;
line-height: 3.6rem;
font-weight: bold;
}
.unit {
font-size: 1.8rem;
line-height: 3.6rem;
}
}
.sparkline {
height: 3.6rem;
width: 100%;
svg {
.cursor {
stroke: $black;
fill: none;
stroke-width: 1.5;
}

.fill {
fill: $yellow;
}

.stroke {
stroke: $black;
fill: none;
stroke-width: 1.5;
}
}
}
}
5 changes: 5 additions & 0 deletions app/assets/stylesheets/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ h1, h2, h3, h4, h5, h6 {
}


html {
overflow-x: hidden;
}

body{
background-color: $grey-300;
background-image: image-url("sck_bg.png");
Expand All @@ -12,6 +16,7 @@ body{
height: 100%;
display: flex;
flex-direction: column;
overflow-x: hidden;
}


Expand Down
2 changes: 2 additions & 0 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import Tags from "bootstrap5-tags";
import {setupCopyableInputs} from "components/copyable_input";
import {setupDeviceMaps} from "components/device_map";
import {setupMapLocationPickers} from "components/map_location_picker";
import {setupReadings} from "components/reading";

export default function setupApplication() {
$(function() {
setupCopyableInputs();
setupDeviceMaps();
setupMapLocationPickers();
setupReadings();
Tags.init(".tag-select", {
baseClass: "tags-badge badge bg-light border text-dark text-truncate p-2 rounded-4"
});
Expand Down
127 changes: 127 additions & 0 deletions app/javascript/components/reading.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as $ from "jquery";
import * as d3 from "d3";
import * as strftime from "strftime";
import BTree from "sorted-btree";

class Reading {
constructor(element) {
this.element = element;
this.valueElement = $(this.element).find(".big-number .value")[0];
this.dateLabelElement = $(this.element).find(".date-line .label")[0];
this.dateElement = $(this.element).find(".date-line .date")[0];
this.deviceId = element.dataset["deviceId"];
this.sensorId = element.dataset["sensorId"];
this.fromDate = element.dataset["fromDate"] ?? this.getDateString(-24 * 60 * 60 * 1000);
this.toDate = element.dataset["toDate"] ?? this.getDateString();
this.initialValue = this.valueElement.innerHTML;
this.initialDateLabel = this.dateLabelElement.innerHTML;
this.hoveredDateLabel = this.dateLabelElement.dataset["hoveredText"];
this.initialDate = this.dateElement.innerHTML;
}

getDateString(offset = 0) {
return new Date(new Date() - offset).toISOString()
}

async init() {
await this.initData();
this.initSparkline();
}

async initData() {
const response = await $.ajax({
url:`/devices/${this.deviceId}/readings?rollup=5m&sensor_id=${this.sensorId}&from=${this.fromDate}&to=${this.toDate}`,
method: "GET",
});
const timestamps = response.readings.map(x => Date.parse(x[0]));
const values = response.readings.map(x => x[1]);
this.dataTree = new BTree();
this.data = values.map((value, i) => {
const timestamp = timestamps[i];
this.dataTree.set(timestamp, value);
return { value: value, time: timestamp };
});
this.minTimestamp = Math.min(...timestamps);
this.maxTimestamp = Math.max(...timestamps);
this.maxValue = Math.max(...values)
}

initSparkline() {
const sparklineElement = $(this.element).find(".sparkline")[0];
const trendElement = $(this.element).find(".trend")[0];
const STROKE_OFFSET = 2;
const width = sparklineElement.offsetWidth;
const height = sparklineElement.offsetHeight;
const x = d3.scaleUtc()
.domain([this.minTimestamp, this.maxTimestamp])
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, this.maxValue])
.range([height - STROKE_OFFSET, 0]);
const area = d3.area()
.x(d => x(d.time))
.y0(height)
.y1(d => y(d.value));
const line = d3.line()
.x(d => x(d.time))
.y(d => y(d.value));
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const g = svg.append("g")
.attr("width", width)
.attr("height", height - STROKE_OFFSET)
.attr("transform", "translate(0, 2)");
g.append("path")
.attr("class", "fill")
.attr("d", area(this.data))
g.append("path")
.attr("class", "stroke")
.attr("d", line(this.data));
const cursor = svg.append("path")
.attr("class", "cursor")
.attr("visibility", "hidden")
.attr("d", `M 0,0 L 0,${height} Z`)

const moveHandler = (event) => {
if (window.TouchEvent && event instanceof TouchEvent) event = event.touches[event.touches.length -1];
const mouseX = d3.pointer(event)[0];
const time = x.invert(mouseX).getTime();
const timestamp = this.dataTree.nextLowerKey(time);
const value = this.dataTree.get(timestamp);
cursor.attr("transform", `translate(${mouseX}, 0)`);
this.valueElement.innerHTML = value.toFixed(2);
this.dateElement.innerHTML = strftime("%B %d, %Y %H:%M", new Date(timestamp));
}

const enterHandler = (event) => {
cursor.attr("visibility", "visible");
trendElement.style.visibility = "hidden";
this.dateLabelElement.innerHTML = this.hoveredDateLabel;
}

const leaveHandler = (event) => {
cursor.attr("visibility", "hidden");
this.valueElement.innerHTML = this.initialValue;
trendElement.style.visibility = "visible";
this.dateLabelElement.innerHTML = this.initialDateLabel;
this.dateElement.innerHTML = this.initialDate;
}

$(sparklineElement).on("mouseenter", enterHandler);
$(sparklineElement).on("mouseleave", leaveHandler)
$(sparklineElement).on("touchstart", enterHandler);
$(sparklineElement).on("touchend", leaveHandler)
svg.on("mousemove", moveHandler);
svg.on("touchmove", moveHandler);
sparklineElement.appendChild(svg.node());
}
}

export function setupReadings() {
$(".reading").each(function(ix, element) {
const reading = new Reading(element);
reading.init();
});
}

30 changes: 30 additions & 0 deletions app/models/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class Component < ActiveRecord::Base
# validates :sensor_id, :uniqueness => { :scope => [:device_id] }
# validates :key, :uniqueness => { :scope => [:device_id] }

scope :order_by_sensor_id, -> { order("sensor_id ASC") }

before_validation :set_key, on: :create

Expand All @@ -39,6 +40,35 @@ def get_unique_key(default_key, other_keys)
ix == 0 ? default_key : "#{default_key}_#{ix}"
end

def measurement_name
self&.sensor&.measurement&.name
end

def measurement_description
self&.sensor&.measurement&.description
end

def value_unit
self&.sensor&.unit
end

def latest_value
self.device.data[self.sensor.id.to_s]
end

def previous_value
self.device.old_data[self.sensor.id.to_s]
end

def is_raw?
sensor&.tags&.include?("raw")
end

def trend
return 0 if !self.latest_value || !self.previous_value
(self.latest_value - self.previous_value) <=> 0
end

private

def set_key
Expand Down
8 changes: 8 additions & 0 deletions app/views/ui/devices/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@
<%= render partial: "ui/devices/actions", locals: { device: @device } %>
<% end %>
<%= render partial: "ui/shared/profile_header", locals: { title: t(:show_device_headline, name: @device.name) } %>

<%= render layout: "ui/shared/container" do %>
<% @device.components.order_by_sensor_id.each do |component| %>
<% unless component.is_raw? %>
<%= render partial: "ui/shared/reading", locals: { component: component } %>
<% end %>
<% end %>
<% end %>
2 changes: 1 addition & 1 deletion app/views/ui/shared/_box.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<div class="bg-white w-100 border border-thick <%= "p-3 pb-4 pb-md-5" unless local_assigns[:no_padding] %>">
<div class="bg-white w-100 border border-thick <%= "p-3 pb-4 pb-md-5" unless local_assigns[:no_padding] %> <%= local_assigns[:class] || "" %>">
<%= yield %>
</div>
28 changes: 28 additions & 0 deletions app/views/ui/shared/_reading.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<div class="reading"
data-device-id="<%= component.device_id %>"
data-sensor-id="<%= component.sensor_id %>"
data-from-date="<%= local_assigns[:from] || component.last_reading_at - 1.day %>"
data-to-date="<%= local_assigns[:to] || component.last_reading_at %>"
>
<%= render layout: "ui/shared/box", locals: { class: "mb-3 p-3", no_padding: true } do %>
<div class="row align-items-top mb-3">
<div class="col-12 col-md-6 col-lg-4">
<h3><%= component.measurement_name %></h3>
<p class="date-line"><span data-hovered-text="<%= t(:reading_hover_reading_label) %>" class="label"><%= t(:reading_last_reading_label) %></span>: <span class="date"><%= component.last_reading_at.to_s(:long) %></span></p>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="big-number text-md-end text-lg-start">
<span class="trend"><% case component.trend %><% when 1 %><% when -1 %><% else %><strong>=</strong><% end %></span> <span class="value"><%= component.latest_value.round(2) %></span><span class="unit"><%= component.value_unit %></span>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="sparkline"></div>
</div>
</div>
<div class="row">
<div class="col-12">
<p><i><%= component.measurement_description %></i></p>
</div>
</div>
<% end %>
</div>
2 changes: 1 addition & 1 deletion babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ module.exports = function(api) {
{
async: false
}
]
],
].filter(Boolean)
}
}
3 changes: 3 additions & 0 deletions config/locales/views/shared/reading/en.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
en:
reading_last_reading_label: Last reading at
reading_hover_reading_label: Reading at
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
{
"browserslist": [
"defaults",
"IE 11"
],
"dependencies": {
"@popperjs/core": "^2.11.8",
"@rails/activestorage": "^8.0.100",
"@rails/ujs": "^7.1.3-4",
"@rails/webpacker": "5.4.4",
"bootstrap": "^5.3.3",
"bootstrap5-tags": "^1.7.6",
"d3": "^7.9.0",
"jquery": "^3.7.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.2",
"sorted-btree": "^1.8.1",
"strftime": "^0.10.3",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12"
},
Expand Down
Loading

0 comments on commit cf1131f

Please sign in to comment.