Files
telemetry_visualization/frontend/src/components/TelemetryLine.vue

577 lines
16 KiB
Vue

<script setup lang="ts">
import { useTelemetry } from '@/composables/telemetry';
import {
computed,
inject,
onMounted,
onUnmounted,
ref,
shallowRef,
type ShallowRef,
toValue,
triggerRef,
useTemplateRef,
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';
import ValueLabel from '@/components/ValueLabel.vue';
import { type Point, PointLine } from '@/graph/line';
import TooltipDialog from '@/components/TooltipDialog.vue';
import {
type DynamicDataType,
isBooleanType,
isNumericType,
} from '@/composables/dynamic.ts';
const props = defineProps<{
data: string;
minimum_separation?: number;
class?: string;
}>();
const smoothing_distance_x = 5;
const maximum_minimum_separation_live = 100; // ms
const legend_line_length = 8;
const legend_text_offset = 4;
const marker_radius = 3;
const text_offset = computed(() => 10);
const graph_data = inject<GraphData>(GRAPH_DATA)!;
const axis_data = inject<AxisData>(AXIS_DATA)!;
const data_min_sep = computed(() => {
return Math.max(
props.minimum_separation || 0,
toValue(graph_data.max_temporal_resolution),
);
});
const live_min_sep = computed(() =>
Math.min(data_min_sep.value, maximum_minimum_separation_live),
);
const { data: telemetry_data } = useTelemetry(() => props.data);
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
const value = websocket.value.listen_to_telemetry(
telemetry_data,
live_min_sep,
graph_data.live,
);
const min = ref(Infinity);
const max = ref(-Infinity);
const recompute_points = shallowRef(0);
function trigger_recompute() {
recompute_points.value = Date.now();
}
function debounced_recompute() {
if (
recompute_points.value + toValue(graph_data.max_update_rate) <
Date.now()
) {
trigger_recompute();
}
}
const line = ref(Symbol());
const index = computed(() => {
return graph_data.lines.value.indexOf(line.value);
});
onMounted(() => {
graph_data.lines.value.push(line.value);
});
onUnmounted(() => {
graph_data.lines.value = graph_data.lines.value.filter(
(x) => x != line.value,
);
});
const memo = shallowRef(new PointLine());
watch([value], ([val]) => {
const min_x = toValue(graph_data.min_x);
if (val) {
const val_t = Date.parse(val.timestamp);
if (val_t >= min_x) {
const raw_item_val = val.value[telemetry_data.value!.data_type];
let item_val = 0;
if (isNumericType(telemetry_data.value!.data_type)) {
item_val = raw_item_val as number;
} else if (isBooleanType(telemetry_data.value!.data_type)) {
item_val = (raw_item_val as boolean) ? 1 : 0;
}
const new_item = {
x: val_t,
y: item_val,
value: raw_item_val,
} as Point;
memo.value.insert(new_item, data_min_sep.value);
if (item_val < min.value) {
min.value = item_val;
}
if (item_val > max.value) {
max.value = item_val;
}
triggerRef(memo);
debounced_recompute();
}
}
});
const recompute_bounds = ref(0);
watch(
[telemetry_data, () => toValue(graph_data.fetch_history)],
async ([data]) => {
if (data) {
const uuid = data.uuid;
const type = data.data_type;
try {
const min_x = new Date(toValue(graph_data.min_x));
const max_x = new Date(toValue(graph_data.max_x));
const res = await fetch(
`/api/tlm/history/${uuid}?from=${min_x.toISOString()}&to=${max_x.toISOString()}&resolution=${data_min_sep.value}`,
);
const response = (await res.json()) as TelemetryDataItem[];
for (const data_item of response) {
const val_t = Date.parse(data_item.timestamp);
const raw_item_val = data_item.value[
type
] as DynamicDataType;
let item_val = 0;
if (isNumericType(type)) {
item_val = raw_item_val as number;
} else if (isBooleanType(type)) {
item_val = (raw_item_val as boolean) ? 1 : 0;
}
const new_item = {
x: val_t,
y: item_val,
value: raw_item_val,
} as Point;
memo.value.insert(new_item);
if (item_val < min.value) {
min.value = item_val;
}
if (item_val > max.value) {
max.value = item_val;
}
}
memo.value.reduce_to_maximum_separation(data_min_sep.value);
triggerRef(memo);
debounced_recompute();
recompute_bounds.value++;
} catch (e) {
// TODO: Response?
console.error(e);
}
}
},
{
immediate: true,
},
);
watch([graph_data.min_x, graph_data.max_x], ([min_x, max_x]) => {
let memo_changed = false;
if (min_x) {
while (
memo.value.data.length > 2 &&
memo.value.data[1].x < toValue(min_x)
) {
memo.value.data.shift();
memo_changed = true;
}
}
if (max_x) {
while (
memo.value.data.length > 2 &&
memo.value.data[memo.value.data.length - 2].x > toValue(max_x)
) {
memo.value.data.pop();
memo_changed = true;
}
}
if (memo_changed) {
recompute_bounds.value++;
}
});
watch([recompute_bounds], () => {
let min_val = Infinity;
let max_val = -Infinity;
for (let i = 1; i < memo.value.data.length; i++) {
const item_val = memo.value.data[i].y;
min_val = Math.min(min_val, item_val);
max_val = Math.max(max_val, item_val);
}
triggerRef(memo);
debounced_recompute();
max.value = max_val;
min.value = min_val;
});
watch(
[min, axis_data.axis_update_watch],
([min_val]) => {
trigger_recompute();
axis_data.min_y_callback(min_val);
},
{
immediate: true,
},
);
watch(
[max, axis_data.axis_update_watch],
([max_val]) => {
trigger_recompute();
axis_data.max_y_callback(max_val);
},
{
immediate: true,
},
);
const points = ref('');
const old_max = ref(0);
const group_transform = computed(() => {
const new_max = toValue(graph_data.max_x);
const offset = graph_data.x_map(old_max.value) - graph_data.x_map(new_max);
return `translate(${offset} 0)`;
});
watch([recompute_points], () => {
let new_points = '';
if (memo.value.data.length == 0 || telemetry_data.value == null) {
return '';
}
let last_x = graph_data.x_map(
memo.value.data[memo.value.data.length - 1].x,
);
old_max.value = toValue(graph_data.max_x);
for (let i = memo.value.data.length - 1; i >= 0; i--) {
const data_item = memo.value.data[i];
const t = data_item.x;
const v = data_item.y;
const x = graph_data.x_map(t);
const y = axis_data.y_map(v);
if (last_x - x < smoothing_distance_x) {
new_points += ` ${x},${y}`;
} else {
new_points += ` ${last_x},${y} ${x},${y}`;
}
last_x = x;
if (last_x <= 0.0) {
break;
}
}
points.value = new_points;
});
const current_data_point = computed(() => {
if (memo.value.data.length == 0) {
return undefined;
}
const cursor_time = toValue(graph_data.cursor_time);
const index = Math.max(memo.value.find_index(cursor_time) - 1, 0);
return memo.value.data[index];
});
const current_data_point_line = computed(() => {
if (!current_data_point.value) {
return '';
}
const x = current_data_point.value.x;
const y = axis_data.y_map(current_data_point.value.y);
return `${graph_data.x_map(x)},${y} ${graph_data.x_map(toValue(graph_data.max_x))},${y}`;
});
const legend_x = computed(() => {
return (
toValue(graph_data.legend_x) +
toValue(graph_data.legend_x_stride) * index.value
);
});
const legend_y = computed(() => {
return (
toValue(graph_data.legend_y) +
toValue(graph_data.legend_y_stride) * index.value
);
});
const legend_text = computed(() => {
const max_chars =
(toValue(graph_data.legend_width) -
legend_line_length -
legend_text_offset * 2) /
6.5;
const start_text = props.data;
if (start_text.length > max_chars) {
return (
start_text.substring(0, 3) +
'...' +
start_text.substring(start_text.length - max_chars + 6)
);
}
return start_text;
});
const legend_line = computed(() => {
const x = legend_x.value;
const y = legend_y.value + 1 + toValue(graph_data.legend_y_stride) / 2;
return `${x},${y} ${x + legend_line_length},${y}`;
});
const is_selected = ref(false);
const legendRectRef = useTemplateRef<SVGRectElement>('legend-ref');
function onOpenLegend() {
if (!is_selected.value) {
setTimeout(() => {
is_selected.value = true;
}, 1);
}
}
function onCloseLegend() {
if (is_selected.value) {
setTimeout(() => {
is_selected.value = false;
}, 1);
}
}
const legend_moused_over = ref(false);
function onMouseEnter(event: MouseEvent) {
if (event.target == event.currentTarget) {
legend_moused_over.value = true;
graph_data.should_fade(true);
}
}
function onMouseExit(event: MouseEvent) {
if (event.target == event.currentTarget) {
legend_moused_over.value = false;
graph_data.should_fade(false);
}
}
</script>
<template>
<g
:class="`indexed-color color-${index} ${legend_moused_over ? 'no-fade' : ''}`"
>
<defs>
<marker
:id="`dot-${index}`"
:refX="marker_radius"
:refY="marker_radius"
markerUnits="strokeWidth"
:markerWidth="marker_radius * 2"
:markerHeight="marker_radius * 2"
>
<circle
:cx="marker_radius"
:cy="marker_radius"
:r="marker_radius"
:class="`indexed-color color-${index} marker`"
/>
</marker>
</defs>
<g clip-path="url(#content)">
<polyline
class="fade_other_selected"
fill="none"
:transform="group_transform"
:points="points"
></polyline>
<polyline
class="fade_other_selected"
fill="none"
:marker-start="`url(#dot-${index})`"
:points="current_data_point_line"
>
</polyline>
</g>
<ValueLabel
v-if="current_data_point"
class="fade_other_selected label"
:x="graph_data.x_map(toValue(graph_data.max_x)) + text_offset"
:y="axis_data.y_map(current_data_point.y)"
:value="current_data_point.value"
>
</ValueLabel>
<template v-if="toValue(graph_data.legend_enabled)">
<g @mouseenter="onMouseEnter" @mouseleave="onMouseExit">
<rect
ref="legend-ref"
:class="`legend ${is_selected ? 'selected' : ''}`"
:x="legend_x - legend_text_offset"
:y="legend_y"
:width="toValue(graph_data.legend_width)"
:height="toValue(graph_data.legend_y_stride)"
@click="onOpenLegend"
>
</rect>
<polyline
class="legend"
fill="none"
:points="legend_line"
@click="onOpenLegend"
></polyline>
<text
class="legend"
:x="legend_x + legend_line_length + legend_text_offset"
:y="legend_y + 1 + toValue(graph_data.legend_y_stride) / 2"
@click="onOpenLegend"
>
{{ legend_text }}
</text>
</g>
<foreignObject height="0" width="0">
<TooltipDialog
:show="is_selected"
:element="legendRectRef"
@close="onCloseLegend"
>
<div class="column">
<div class="row header">
<div
:class="`indexed-color color-${index} colored dash`"
></div>
<span class="large">{{ props.data }}</span>
</div>
<template v-if="telemetry_data">
<div class="row">
<span class="small">{{
telemetry_data?.uuid
}}</span>
</div>
<div class="row">
<span>{{ telemetry_data?.data_type }}</span>
</div>
<div class="row">
<span v-if="current_data_point !== undefined">{{
current_data_point.value
}}</span>
<span v-else>Missing Data</span>
</div>
</template>
<template v-else>
<div class="row">
<span>Unknown Signal</span>
</div>
</template>
</div>
</TooltipDialog>
</foreignObject>
</template>
</g>
</template>
<style lang="scss">
@use '@/assets/variables';
.fade rect.fade_other_selected {
opacity: 10%;
}
.fade .fade_other_selected {
opacity: 25%;
}
.fade .fade_other_selected.label {
opacity: 0;
}
.fade .no-fade .fade_other_selected {
opacity: 100%;
}
</style>
<style scoped lang="scss">
@use '@/assets/variables';
.indexed-color {
stroke: var(--indexed-color);
fill: var(--indexed-color);
}
circle.marker {
stroke: variables.$background-color;
stroke-width: 1px;
}
polyline {
stroke-width: 1px;
}
text {
font-family: variables.$monospace-text-font;
text-anchor: start;
stroke: variables.$text-color;
fill: variables.$text-color;
dominant-baseline: middle;
font-size: variables.$small-monospace-text-size;
}
rect.legend {
stroke: none;
stroke-width: 0;
fill: transparent;
}
.legend {
pointer-events: all;
}
rect.legend.selected,
rect.legend:hover,
rect.legend:has(~ .legend:hover) {
stroke: variables.$gray-2;
stroke-width: 1px;
}
.legend {
cursor: help;
}
div.column,
div.row {
gap: 0.25em;
}
div.header.row {
margin-bottom: 1ex;
}
div.colored.dash {
background-color: var(--indexed-color);
width: 0.75em;
height: 2px;
display: inline;
margin-right: 0.5em;
}
span.large {
font-size: variables.$large-text-size;
}
span.small {
font-size: variables.$small-text-size;
}
</style>