initial graph
This commit is contained in:
24
frontend/src/components/Axis.vue
Normal file
24
frontend/src/components/Axis.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
import { AXIS_DATA, type AxisData } from '@/graph/axis'
|
||||
|
||||
const props = defineProps<{
|
||||
y_limits?: [number, number]
|
||||
}>();
|
||||
|
||||
provide<AxisData>(AXIS_DATA, {
|
||||
min_y: -1.05,
|
||||
max_y: 1.05,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<g>
|
||||
<slot></slot>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
40
frontend/src/components/Graph.vue
Normal file
40
frontend/src/components/Graph.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useNow } from '@/composables/ticker'
|
||||
import { GRAPH_DATA, type GraphData } from '@/graph/graph'
|
||||
|
||||
const props = defineProps<{
|
||||
width: number
|
||||
height: number
|
||||
// min_t?: number
|
||||
// max_t?: number
|
||||
}>();
|
||||
|
||||
const svg_viewbox = computed(() => {
|
||||
return `0 0 ${props.width} ${props.height}`;
|
||||
})
|
||||
const now = useNow(33);
|
||||
const window_duration = 30 * 1000; // 30 seconds
|
||||
|
||||
const min_x = computed(() => now.value - window_duration);
|
||||
|
||||
provide<GraphData>(GRAPH_DATA, {
|
||||
min_x: min_x,
|
||||
max_x: now,
|
||||
width: () => props.width,
|
||||
height: () => props.height,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="svg_viewbox" :width="props.width" :height="props.height">
|
||||
<slot></slot>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
svg {
|
||||
border: white 1px solid;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/components/Line.vue
Normal file
84
frontend/src/components/Line.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { useTelemetry } from '@/composables/telemetry'
|
||||
import { computed, inject, shallowRef, type ShallowRef, toValue, useId, watch } from 'vue'
|
||||
import { type TelemetryDataItem, WEBSOCKET_SYMBOL, type WebsocketHandle } from '@/composables/websocket'
|
||||
import { GRAPH_DATA, type GraphData } from '@/graph/graph'
|
||||
import { AXIS_DATA, type AxisData } from '@/graph/axis'
|
||||
|
||||
const props = defineProps<{
|
||||
data: string
|
||||
color: string
|
||||
}>();
|
||||
|
||||
const smoothing_distance = 0.15 * 1000;
|
||||
|
||||
const { data, error } = useTelemetry(() => props.data);
|
||||
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
|
||||
let value = websocket.value.listen_to_telemetry(data);
|
||||
|
||||
const graph_data = inject<GraphData>(GRAPH_DATA)!;
|
||||
const axis_data = inject<AxisData>(AXIS_DATA)!;
|
||||
|
||||
let memo = shallowRef<TelemetryDataItem[]>([]);
|
||||
watch([value], ([val]) => {
|
||||
const min_x = toValue(graph_data.min_x);
|
||||
if (val) {
|
||||
const new_memo = [val].concat(memo.value);
|
||||
while (new_memo.length > 2 && Date.parse(new_memo[new_memo.length - 2].timestamp) < min_x) {
|
||||
new_memo.pop();
|
||||
}
|
||||
memo.value = new_memo;
|
||||
}
|
||||
});
|
||||
|
||||
let points = computed(() => {
|
||||
let points = "";
|
||||
if (memo.value.length == 0 || data.value == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max_x = toValue(graph_data.max_x);
|
||||
const min_x = toValue(graph_data.min_x);
|
||||
const diff_x = max_x - min_x;
|
||||
|
||||
const max_y = toValue(axis_data.max_y);
|
||||
const min_y = toValue(axis_data.min_y);
|
||||
const diff_y = max_y - min_y;
|
||||
|
||||
const width = toValue(graph_data.width);
|
||||
const height = toValue(graph_data.height);
|
||||
|
||||
let last_x = width;
|
||||
let last_t = Math.max(max_x, Date.now());
|
||||
|
||||
for (let data_item of memo.value) {
|
||||
const t = Date.parse(data_item.timestamp);
|
||||
const v = data_item.value[data.value.data_type];
|
||||
const x = width * (t - min_x) / diff_x;
|
||||
|
||||
const y = height * (1 - (v - min_y) / diff_y);
|
||||
if (last_t - t < smoothing_distance) {
|
||||
points += ` ${x},${y}`;
|
||||
} else {
|
||||
points += ` ${last_x},${y} ${x},${y}`;
|
||||
}
|
||||
last_x = x;
|
||||
last_t = t;
|
||||
if (last_x <= 0.0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return points;
|
||||
});
|
||||
|
||||
console.log(useId());
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<polyline fill="none" :stroke="color" stroke-width="1" :points="points"></polyline>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,13 +1,28 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, toValue, watchEffect } from 'vue'
|
||||
import { type MaybeRefOrGetter } from '@vue/reactivity'
|
||||
|
||||
export function useTelemetry(name: string) {
|
||||
const data = ref<any | null>(null);
|
||||
export interface TelemetryDefinition {
|
||||
uuid: string;
|
||||
name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export function useTelemetry(name: MaybeRefOrGetter<string>) {
|
||||
const data = ref<TelemetryDefinition | null>(null);
|
||||
const error = ref<any | null>(null);
|
||||
|
||||
fetch(`/api/tlm/${name}`)
|
||||
.then((res) => res.json())
|
||||
.then((json) => (data.value = json))
|
||||
.catch((err) => (error.value = err));
|
||||
watchEffect(async () => {
|
||||
const name_value = toValue(name);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tlm/${name_value}`);
|
||||
data.value = await res.json();
|
||||
error.value = null;
|
||||
} catch (e) {
|
||||
data.value = null;
|
||||
error.value = e;
|
||||
}
|
||||
});
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
19
frontend/src/composables/ticker.ts
Normal file
19
frontend/src/composables/ticker.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
|
||||
export function useNow(update_ms: number) {
|
||||
const handle = shallowRef<number | undefined>(undefined);
|
||||
const now = ref(Date.now());
|
||||
|
||||
onMounted(() => {
|
||||
handle.value = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, update_ms);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (handle.value) {
|
||||
clearInterval(handle.value);
|
||||
}
|
||||
});
|
||||
|
||||
return now;
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { computed, onMounted, onUnmounted, ref, type Ref, shallowRef, watch } from 'vue'
|
||||
|
||||
class WebsocketHandle {
|
||||
export interface TelemetryDataItem {
|
||||
value: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface TlmValue {
|
||||
uuid: string;
|
||||
value: TelemetryDataItem;
|
||||
}
|
||||
|
||||
export class WebsocketHandle {
|
||||
websocket: WebSocket | null;
|
||||
should_be_connected: boolean;
|
||||
connected: Ref<boolean>;
|
||||
on_telem_value: Map<string, Array<(value: any)=>void>>;
|
||||
on_telem_value: Map<string, Array<(value: TelemetryDataItem)=>void>>;
|
||||
|
||||
constructor() {
|
||||
this.websocket = null;
|
||||
@@ -42,16 +52,14 @@ class WebsocketHandle {
|
||||
this.websocket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message["TlmValue"]) {
|
||||
const uuid = message["TlmValue"]["uuid"];
|
||||
if (uuid) {
|
||||
const listeners = this.on_telem_value.get(uuid);
|
||||
const tlm_value = message["TlmValue"] as TlmValue;
|
||||
const listeners = this.on_telem_value.get(tlm_value.uuid);
|
||||
if (listeners) {
|
||||
listeners.forEach((listener) => {
|
||||
listener(message["TlmValue"]["value"]);
|
||||
listener(tlm_value.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,14 +69,14 @@ class WebsocketHandle {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connected.value = false;
|
||||
this.websocket.close();
|
||||
this.websocket = null;
|
||||
this.connected.value = false;
|
||||
this.on_telem_value.clear();
|
||||
}
|
||||
|
||||
listen_to_telemetry(telemetry: Ref<any>) {
|
||||
const value_result = ref(null);
|
||||
const value_result = ref<TelemetryDataItem | null>(null);
|
||||
|
||||
const uuid = computed(() => {
|
||||
if (telemetry.value) {
|
||||
@@ -77,9 +85,8 @@ class WebsocketHandle {
|
||||
return null;
|
||||
});
|
||||
|
||||
watch([uuid, this.connected], () => {
|
||||
if (this.connected) {
|
||||
let uuid_value = uuid.value
|
||||
watch([uuid, this.connected], ([uuid_value, connected]) => {
|
||||
if (connected && uuid_value) {
|
||||
this.websocket?.send(JSON.stringify({
|
||||
"RegisterTlmListener": {
|
||||
uuid: uuid_value
|
||||
@@ -98,6 +105,8 @@ class WebsocketHandle {
|
||||
}
|
||||
}
|
||||
|
||||
export const WEBSOCKET_SYMBOL = Symbol()
|
||||
|
||||
export function useWebsocket() {
|
||||
const handle = shallowRef<WebsocketHandle>(new WebsocketHandle());
|
||||
|
||||
|
||||
7
frontend/src/graph/axis.ts
Normal file
7
frontend/src/graph/axis.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { MaybeRefOrGetter } from '@vue/reactivity'
|
||||
|
||||
export const AXIS_DATA = Symbol()
|
||||
export interface AxisData {
|
||||
min_y: MaybeRefOrGetter<number>;
|
||||
max_y: MaybeRefOrGetter<number>;
|
||||
}
|
||||
10
frontend/src/graph/graph.ts
Normal file
10
frontend/src/graph/graph.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { MaybeRefOrGetter } from '@vue/reactivity'
|
||||
|
||||
export const GRAPH_DATA = Symbol()
|
||||
|
||||
export interface GraphData {
|
||||
min_x: MaybeRefOrGetter<number>;
|
||||
max_x: MaybeRefOrGetter<number>;
|
||||
width: MaybeRefOrGetter<number>;
|
||||
height: MaybeRefOrGetter<number>;
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { useTelemetry } from '@/composables/telemetry'
|
||||
import { useWebsocket } from '@/composables/websocket'
|
||||
|
||||
const { data: sin_data, error: sin_error } = useTelemetry("simple_producer/sin");
|
||||
const { data: cos_data, error: cos_error } = useTelemetry("simple_producer/cos");
|
||||
import { useWebsocket, WEBSOCKET_SYMBOL } from '@/composables/websocket'
|
||||
import { provide } from 'vue'
|
||||
import Graph from '@/components/Graph.vue'
|
||||
import Axis from '@/components/Axis.vue'
|
||||
import Line from '@/components/Line.vue'
|
||||
|
||||
const websocket = useWebsocket();
|
||||
|
||||
let sin_value = websocket.value.listen_to_telemetry(sin_data);
|
||||
let cos_value = websocket.value.listen_to_telemetry(cos_data);
|
||||
provide(WEBSOCKET_SYMBOL, websocket);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div v-if="sin_value">
|
||||
{{ sin_data.name }} ({{ sin_data.data_type }}) = {{ sin_value[sin_data.data_type] }}
|
||||
</div>
|
||||
<div v-if="sin_error">
|
||||
{{ sin_error }}
|
||||
</div>
|
||||
<div v-if="cos_value">
|
||||
{{ cos_data.name }} ({{ cos_data.data_type }}) = {{ cos_value[cos_data.data_type] }}
|
||||
</div>
|
||||
<div v-if="cos_error">
|
||||
{{ cos_error }}
|
||||
</div>
|
||||
<Graph :width=1500 :height=800>
|
||||
<Axis>
|
||||
<Line data="simple_producer/sin" color="#FF0000"></Line>
|
||||
<Line data="simple_producer/cos" color="#00FF00"></Line>
|
||||
</Axis>
|
||||
</Graph>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::error::Error;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use log::{error, trace};
|
||||
use tokio::select;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -13,7 +14,7 @@ use tonic::transport::Server;
|
||||
use crate::core::telemetry_service_server::{TelemetryService, TelemetryServiceServer};
|
||||
use crate::core::{TelemetryDataType, TelemetryDefinitionRequest, TelemetryDefinitionResponse, TelemetryInsertResponse, TelemetryItem, TelemetryValue, Uuid};
|
||||
use crate::core::telemetry_value::Value;
|
||||
use crate::telemetry::{TelemetryDataValue, TelemetryManagementService};
|
||||
use crate::telemetry::{TelemetryDataItem, TelemetryDataValue, TelemetryManagementService};
|
||||
|
||||
pub struct CoreTelemetryService {
|
||||
pub tlm_management: Arc<TelemetryManagementService>,
|
||||
@@ -89,6 +90,10 @@ impl CoreTelemetryService {
|
||||
return Err(Status::failed_precondition("Value Missing"));
|
||||
};
|
||||
|
||||
let Some(timestamp) = tlm_item.timestamp else {
|
||||
return Err(Status::failed_precondition("Timestamp Missing"));
|
||||
};
|
||||
|
||||
let expected_type = match value {
|
||||
Value::Float32(_) => TelemetryDataType::Float32,
|
||||
Value::Float64(_) => TelemetryDataType::Float64,
|
||||
@@ -97,9 +102,16 @@ impl CoreTelemetryService {
|
||||
return Err(Status::failed_precondition("Data Type Mismatch"));
|
||||
};
|
||||
|
||||
let _ = tlm_data.data.send_replace(Some(match value {
|
||||
let Some(timestamp) = DateTime::from_timestamp(timestamp.secs, timestamp.nanos as u32) else {
|
||||
return Err(Status::invalid_argument("Failed to construct UTC DateTime"));
|
||||
};
|
||||
|
||||
let _ = tlm_data.data.send_replace(Some(TelemetryDataItem {
|
||||
value: match value {
|
||||
Value::Float32(x) => TelemetryDataValue::Float32(x),
|
||||
Value::Float64(x) => TelemetryDataValue::Float64(x),
|
||||
},
|
||||
timestamp: timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}));
|
||||
|
||||
Ok(TelemetryInsertResponse {})
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::select;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tonic::codegen::tokio_stream::StreamExt;
|
||||
use crate::telemetry::{TelemetryDataValue, TelemetryManagementService};
|
||||
use crate::telemetry::{TelemetryDataItem, TelemetryDataValue, TelemetryManagementService};
|
||||
|
||||
#[derive(Debug, Display, Error)]
|
||||
enum UserError {
|
||||
@@ -41,7 +41,7 @@ enum WebsocketRequest {
|
||||
enum WebsocketResponse {
|
||||
TlmValue {
|
||||
uuid: String,
|
||||
value: Option<TelemetryDataValue>
|
||||
value: Option<TelemetryDataItem>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt::Formatter;
|
||||
use std::sync::Arc;
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::trace;
|
||||
use serde::de::Visitor;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use tokio::sync::Mutex;
|
||||
use crate::core::{TelemetryDataType, TelemetryDefinitionRequest, Uuid};
|
||||
use crate::core::{TelemetryDataType, TelemetryDefinitionRequest, TelemetryItem, Timestamp, Uuid};
|
||||
|
||||
fn tlm_data_type_serialzier<S>(tlm_data_type: &TelemetryDataType, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer {
|
||||
serializer.serialize_str(tlm_data_type.as_str_name())
|
||||
@@ -48,10 +49,16 @@ pub enum TelemetryDataValue {
|
||||
Float64(f64),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TelemetryDataItem {
|
||||
pub value: TelemetryDataValue,
|
||||
pub timestamp: String
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TelemetryData {
|
||||
pub definition: TelemetryDefinition,
|
||||
pub data: tokio::sync::watch::Sender<Option<TelemetryDataValue>>,
|
||||
pub data: tokio::sync::watch::Sender<Option<TelemetryDataItem>>,
|
||||
}
|
||||
|
||||
pub struct TelemetryManagementService {
|
||||
|
||||
Reference in New Issue
Block a user