Reviewed-on: #6 Co-authored-by: Sergey Savelyev <sergeysav.nn@gmail.com> Co-committed-by: Sergey Savelyev <sergeysav.nn@gmail.com>
577 lines
16 KiB
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>
|