Skip to content

Commit

Permalink
show experiments and readings
Browse files Browse the repository at this point in the history
  • Loading branch information
timcowlishaw committed Jan 29, 2025
1 parent fce97b4 commit dd2e168
Show file tree
Hide file tree
Showing 43 changed files with 618 additions and 100 deletions.
2 changes: 1 addition & 1 deletion app/assets/images/user_details_icon_light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
@import "nav";
@import "breadcrumbs";
@import "footer";
@import "device";
@import "listing";
@import "components/profile_header";
@import "components/copyable_input";
@import "components/device_map";
@import "components/map";
@import "components/map_location_picker";
@import "components/reading";
4 changes: 0 additions & 4 deletions app/assets/stylesheets/components/device_map.scss

This file was deleted.

7 changes: 7 additions & 0 deletions app/assets/stylesheets/components/map.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.map {
width: 100%;
height: 100%;
aspect-ratio: 1;
cursor: default;
}

1 change: 1 addition & 0 deletions app/assets/stylesheets/global.scss
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,4 @@ a.subtle-link {
text-decoration: underline;
}
}

Original file line number Diff line number Diff line change
@@ -1,28 +1,18 @@
.device {
.listing {
position: relative;

.device-link {
.listing-link {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
z-index: 1000;
}

.device-map {
aspect-ratio: 1.66;
}

&:has(:hover) {
h3 {
text-decoration: underline;
}
.device-map {

.map {
background-color: $yellow;

.leaflet-pane {
mix-blend-mode: multiply;
}
Expand All @@ -31,5 +21,9 @@
filter: brightness(0) saturate(100%);
}
}

h3 {
text-decoration: underline;
}
}
}
51 changes: 51 additions & 0 deletions app/controllers/ui/experiments_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module Ui
class ExperimentsController < ApplicationController
def show
find_experiment!
return unless authorize_experiment! :show?, :show_experiment_forbidden
@title = I18n.t(:show_experiment_title, name: @experiment.name)
add_breadcrumbs(
[I18n.t(:show_user_title, owner: helpers.possessive(@experiment.owner, current_user)), ui_user_path(@experiment.owner.username)],
[@title, ui_experiment_path(@experiment.id)]
)
render "show", layout: "base"
end

def readings
find_experiment!
return unless authorize_experiment! :show?, :show_experiment_forbidden
return unless find_measurement!
@title = I18n.t(:readings_experiment_title, name: @experiment.name)
add_breadcrumbs(
[I18n.t(:show_user_title, owner: helpers.possessive(@experiment.owner, current_user)), ui_user_path(@experiment.owner.username)],
[I18n.t(:show_experiment_title, name: @experiment.name), ui_experiment_path(@experiment.id)],
[@title, readings_ui_experiment_path(@experiment.id)]
)
render "readings", layout: "base"
end

private

def find_experiment!
@experiment = Experiment.find(params[:id])
end

def find_measurement!
@measurement = params[:measurement_id] && Measurement.find(params[:measurement_id])
if @measurement
return @measurement
else
measurement = @experiment.all_measurements.first
redirect_to measurement ? readings_ui_experiment_path(@experiment, measurement_id: measurement.id) : ui_experiment_path(@experiment)
return nil
end
end

def authorize_experiment!(action, alert)
return true if authorize? @experiment, action
flash[:alert] = I18n.t(alert)
redirect_to current_user ? ui_user_path(current_user.username) : login_path
return false
end
end
end
4 changes: 2 additions & 2 deletions app/javascript/application.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as $ from "jquery";
import Tags from "bootstrap5-tags";
import {setupCopyableInputs} from "components/copyable_input";
import {setupDeviceMaps} from "components/device_map";
import {setupMaps} from "components/map";
import {setupMapLocationPickers} from "components/map_location_picker";
import {setupReadings} from "components/reading";

export default function setupApplication() {
$(function() {
setupCopyableInputs();
setupDeviceMaps();
setupMaps();
setupMapLocationPickers();
setupReadings();
Tags.init(".tag-select", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import L from 'leaflet';
import 'leaflet-defaulticon-compatibility';


export function setupDeviceMaps() {
$(".device-map").each(function(ix, element) {
const latitude = element.dataset["latitude"];
const longitude = element.dataset["longitude"];
export function setupMaps() {
$(".map").each(function(ix, element) {
const points = JSON.parse(element.dataset["points"]);
const markerUrl = element.dataset["markerUrl"];
const markerShadowUrl = element.dataset["markerShadowUrl"];
const icon = L.icon({
Expand All @@ -17,9 +16,8 @@ export function setupDeviceMaps() {
iconAnchor: [16, 40],
shadowAnchor: [10, 59]
});
const featureGroup = L.featureGroup()
const map = L.map(element, {
center: [latitude, longitude],
zoom: 7,
attributionControl: false,
zoomControl: false,
scrollWheelZoom: false,
Expand All @@ -36,7 +34,13 @@ export function setupDeviceMaps() {
attribution: '&copy; <a href="https://www.stadiamaps.com/" target="_blank">Stadia Maps</a> &copy; <a href="https://www.stamen.com/" target="_blank">Stamen Design</a> &copy; <a href="https://openmaptiles.org/" target="_blank">OpenMapTiles</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
ext: 'png'
}).addTo(map);
L.marker([latitude, longitude], {icon: icon }).addTo(map);
element.style.cursor="default";
points.forEach((point) => {
if (point.lat && point.lng) {
const marker = L.marker([point.lat, point.lng], { icon: icon });
marker.addTo(map);
marker.addTo(featureGroup);
}
});
map.fitBounds(featureGroup.getBounds(), { padding: L.point(32, 40), maxZoom: 16});
});
}
59 changes: 45 additions & 14 deletions app/javascript/components/reading.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,26 @@ import * as strftime from "strftime";
import BTree from "sorted-btree";

class Reading {

static instances = [];

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.trendElement = $(this.element).find(".trend")[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.syncAllOnPage = element.dataset["syncAllOnPage"] == "true";
this.initialValue = this.valueElement.innerHTML;
this.initialDateLabel = this.dateLabelElement.innerHTML;
this.hoveredDateLabel = this.dateLabelElement.dataset["hoveredText"];
this.noReadingLabel = this.dateLabelElement.dataset["noReadingText"];
this.initialDate = this.dateElement.innerHTML;
Reading.instances.push(this);
}

getDateString(offset = 0) {
Expand Down Expand Up @@ -48,7 +55,6 @@ class Reading {

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;
Expand Down Expand Up @@ -78,7 +84,8 @@ class Reading {
g.append("path")
.attr("class", "stroke")
.attr("d", line(this.data));
const cursor = svg.append("path")

this.cursor = svg.append("path")
.attr("class", "cursor")
.attr("visibility", "hidden")
.attr("d", `M 0,0 L 0,${height} Z`)
Expand All @@ -87,25 +94,25 @@ class Reading {
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));
if(this.syncAllOnPage) {
Reading.instances.forEach((instance) => { instance.showSpecificTime(time, mouseX) });
} else {
this.showSpecificTime(time, mouseX)
}
}

const enterHandler = (event) => {
cursor.attr("visibility", "visible");
trendElement.style.visibility = "hidden";
this.cursor.attr("visibility", "visible");
this.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;
if(this.syncAllOnPage) {
Reading.instances.forEach((instance) => { instance.showLatest() });
} else {
this.showLatest()
}
}

$(sparklineElement).on("mouseenter", enterHandler);
Expand All @@ -116,6 +123,30 @@ class Reading {
svg.on("touchmove", moveHandler);
sparklineElement.appendChild(svg.node());
}

showSpecificTime(time, mouseX) {
const timestamp = this.dataTree.nextLowerKey(time);
const value = this.dataTree.get(timestamp);
this.cursor.attr("visibility", "visible");
this.cursor.attr("transform", `translate(${mouseX}, 0)`);
this.trendElement.style.visibility = "hidden";
this.dateLabelElement.innerHTML = this.hoveredDateLabel;
if(value) {
this.dateElement.innerHTML = strftime("%B %d, %Y %H:%M", new Date(timestamp));
this.valueElement.innerHTML = value.toFixed(2);
} else {
this.dateElement.innerHTML = this.noReadingLabel;
this.valueElement.innerHTML = "-.--";
}
}

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

export function setupReadings() {
Expand Down
10 changes: 3 additions & 7 deletions app/models/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,8 @@ 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
def measurement
self&.sensor&.measurement
end

def value_unit
Expand All @@ -61,7 +57,7 @@ def previous_value
end

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

def trend
Expand Down
19 changes: 18 additions & 1 deletion app/models/experiment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,24 @@ def active?
(!starts_at || Time.now >= starts_at) && (!ends_at || Time.now <= ends_at)
end

def last_reading_at
devices.map(&:last_reading_at).compact.max
end

def all_tags
devices.flat_map(&:all_tags).compact.uniq
end

def all_measurements
devices.flat_map(&:sensors).filter {|s| !s.is_raw? }.map(&:measurement).uniq
end

def components_for_measurement(measurement)
devices.flat_map(&:components).filter {
|c| c.measurement == measurement && !c.is_raw?
}.uniq
end

private

def cannot_add_private_devices_of_other_users
Expand All @@ -38,5 +56,4 @@ def start_date_is_before_end_date
errors.add(:ends_at, "is before starts_at")
end
end

end
3 changes: 3 additions & 0 deletions app/models/sensor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ def tags
tag_sensors.map(&:name)
end

def is_raw?
tags&.include?("raw")
end
end
2 changes: 2 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class User < ActiveRecord::Base
has_many :devices, foreign_key: 'owner_id', after_add: :update_cached_device_ids!, after_remove: :update_cached_device_ids!
has_many :sensors, through: :devices
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner
has_many :experiments, foreign_key: "owner_id"

has_one_attached :profile_picture

before_create :generate_legacy_api_key
Expand Down
10 changes: 6 additions & 4 deletions app/views/ui/devices/_device.html.erb
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<div class="device row border-top g-0">
<%= link_to(ui_device_path(device.id), class: "device-link") do %>
<div class="device listing row border-top g-0 z-0">
<%= link_to(ui_device_path(device.id), class: "listing-link z-1") do %>
<span class="sr-only"><%= device.name %></span>
<% end %>
<div class="col-12 col-md-3">
<%= render partial: "ui/devices/map", locals: { device: device} %>
<% if device.latitude && device.longitude %>
<%= render partial: "ui/shared/map", locals: { points: [{lat: device.latitude, lng: device.longitude}] } %>
<% end %>
</div>
<div class="col-12 col-md-9 p-3 pb-4">
<h3 class="mb-1"><%= device.name %></h3>
<h3 class="mb-1 text-break"><%= device.name %></h3>
<%= render partial: "ui/devices/meta", locals: { device: device, hide_owner: local_assigns[:hide_owner] } %>
</div>
</div>
1 change: 0 additions & 1 deletion app/views/ui/devices/_map.html.erb

This file was deleted.

Loading

0 comments on commit dd2e168

Please sign in to comment.