Files
telemetry_visualization/frontend/src/components/SvgGraph.vue
2025-03-16 14:57:49 -07:00

455 lines
12 KiB
Vue

<script setup lang="ts">
import {
computed,
onUnmounted,
onWatcherCleanup,
provide,
ref,
useTemplateRef,
watch,
} from 'vue';
import { useNow } from '@/composables/ticker';
import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph';
import TimeText from '@/components/TimeText.vue';
import {
getDateString,
getDurationString,
parseDurationString,
} from '@/datetime';
import { onDocumentShown } from '@/composables/document.ts';
const props = defineProps<{
initial_duration?: number;
utc?: boolean;
left_axis?: boolean;
right_axis?: boolean;
hide_time_labels?: boolean;
hide_time_ticks?: boolean;
include_controls?: boolean;
legend?: GraphSide;
cursor?: boolean;
}>();
const divRef = useTemplateRef<HTMLDivElement>('graph-div');
const width = ref(0);
const raw_height = ref(0);
const controls_height = 32;
const min_time_label_separation = 250;
const resize_observer = new ResizeObserver((elements) => {
for (const element of elements) {
if (element.target == divRef.value) {
width.value = element.contentBoxSize[0].inlineSize;
raw_height.value = element.contentBoxSize[0].blockSize;
}
}
});
const height = computed(() => {
return Math.max(
raw_height.value - (props.include_controls ? controls_height : 0),
1,
);
});
watch([divRef], ([divRef]) => {
if (divRef) {
resize_observer.observe(divRef);
onWatcherCleanup(() => {
resize_observer.unobserve(divRef);
});
}
});
onUnmounted(() => {
resize_observer.disconnect();
});
const update_ms = 33;
const now = useNow(update_ms);
const window_duration = ref(props.initial_duration || 10 * 1000);
const time_lines = [
1, // 1ms
2, // 2ms
5, // 5ms
10, // 10ms
20, // 20ms
50, // 50ms
100, // 100ms
200, // 200ms
500, // 500ms
1000, // 1s
2000, // 2s
5000, // 5s
10000, // 10s
30000, // 30s
60000, // 1m
150000, // 2.5m
300000, // 5m
6000000, // 10m
18000000, // 30m
36000000, // 1h
72000000, // 2h
144000000, // 4h
216000000, // 6h
432000000, // 12h
864000000, // 1d
1728000000, // 2d
6048000000, // 1w
];
// time_lines.reverse();
const text_offset = computed(() => 5);
const legend_width = 160;
const border_left = computed(
() =>
(props.left_axis ? 96 : 0) +
(props.legend == GraphSide.Left ? legend_width : 0),
);
const border_right = computed(
() =>
(props.right_axis ? 80 : 0) +
(props.legend == GraphSide.Right ? legend_width : 0),
);
const border_top = computed(() => 6);
const border_bottom = computed(() => (props.hide_time_labels ? 6 : 24));
const max_x = ref(now.value);
const min_x = computed(() => max_x.value - window_duration.value);
const fetch_history = ref(0);
const diff_x = computed(() => max_x.value - min_x.value);
const live = ref(true);
watch([live], ([live_value]) => {
if (live_value) {
fetch_history.value++;
}
});
const valid_max_x_text = ref(true);
watch([now], ([now_value]) => {
if (live.value) {
max_x.value = now_value;
valid_max_x_text.value = true;
}
});
onDocumentShown(() => {
fetch_history.value++;
});
const max_x_text = computed({
// getter
get() {
return getDateString(new Date(max_x.value), props.utc, true);
},
// setter
set(newValue) {
const new_max_x = Date.parse(
newValue.replace('/', '-').replace('/', '-').replace(' ', 'T'),
);
if (!Number.isNaN(new_max_x)) {
fetch_history.value++;
max_x.value = new_max_x;
valid_max_x_text.value = true;
} else {
valid_max_x_text.value = false;
}
},
});
const duration_text = computed({
get() {
return getDurationString(window_duration.value);
},
set(newValue) {
const new_duration = parseDurationString(newValue);
if (!Number.isNaN(new_duration)) {
window_duration.value = Math.max(new_duration, 1);
fetch_history.value++;
}
},
});
const x_map = (x: number) => {
return (
((width.value - border_left.value - border_right.value) *
(x - min_x.value)) /
diff_x.value +
border_left.value
);
};
const inv_x_map = (x: number) => {
return (
((x - border_left.value) * diff_x.value) /
(width.value - border_left.value - border_right.value) +
min_x.value
);
};
const telemetry_lines = ref([]);
const legend_enabled = computed(
() => props.legend === GraphSide.Left || props.legend === GraphSide.Right,
);
const legend_x = computed(() =>
props.legend === GraphSide.Left ? 8 : width.value - legend_width + 8,
);
const legend_y = computed(() => border_top.value);
const legend_x_stride = computed(() => 0);
const legend_y_stride = computed(() => 16);
const legend_width_output = computed(() => legend_width - 8);
const graph_width = computed(() => {
return Math.max(width.value - border_left.value - border_right.value, 1);
});
const line_duration = computed(() => {
const width_px = graph_width.value;
const diff_x = max_x.value - min_x.value;
return time_lines.find((duration) => {
const line_count = diff_x / duration;
const width_per_line = width_px / line_count;
return width_per_line >= min_time_label_separation;
})!;
});
const lines = computed(() => {
const result = [];
for (
let i = Math.ceil(max_x.value / line_duration.value);
i >= Math.floor(min_x.value / line_duration.value);
i--
) {
const x = i * line_duration.value;
result.push(x);
}
return result;
});
const mouse_x = ref<number | null>(null);
function onMouseMove(event: MouseEvent) {
if (props.cursor) {
mouse_x.value =
event.clientX -
(event.currentTarget as Element).getBoundingClientRect().left;
}
}
function onMouseOut() {
mouse_x.value = null;
}
const mouse_t = computed(() => {
if (mouse_x.value === null) {
return null;
}
const t = inv_x_map(mouse_x.value);
if (t < min_x.value || t > max_x.value) {
return null;
}
return t;
});
const show_data_at_time = computed(() => {
if (mouse_t.value === null) {
return max_x.value + update_ms;
} else {
return mouse_t.value;
}
});
const should_fade = ref(false);
const max_temporal_resolution = computed(() => {
const delta_t = window_duration.value;
return Math.floor(delta_t / 1000); // Aim for a maximum of 1000 data points
});
provide<GraphData>(GRAPH_DATA, {
border_top: border_top,
min_x: min_x,
max_x: max_x,
max_temporal_resolution: max_temporal_resolution,
live: live,
fetch_history: fetch_history,
width: graph_width,
height: () =>
Math.max(height.value - border_top.value - border_bottom.value, 0),
x_map: x_map,
lines: telemetry_lines,
max_update_rate: 1000 / 10,
legend_enabled: legend_enabled,
legend_x: legend_x,
legend_y: legend_y,
legend_x_stride: legend_x_stride,
legend_y_stride: legend_y_stride,
legend_width: legend_width_output,
cursor_time: show_data_at_time,
should_fade: (value) => (should_fade.value = value),
});
</script>
<template>
<div ref="graph-div" class="full-size">
<div
v-if="include_controls"
class="controls-header"
:style="`height: ${controls_height}px`"
>
<div class="grow"></div>
<div>
<input
type="text"
autocomplete="off"
:size="15"
v-model="duration_text"
/>
</div>
<div>
<input
type="text"
autocomplete="off"
:disabled="live"
:size="max_x_text.length"
v-model="max_x_text"
/>
</div>
<div>
<input type="checkbox" v-model="live" />
<label>Live</label>
</div>
</div>
<svg
ref="svg_graph"
class="graph"
:width="width"
:height="height"
@mousemove="onMouseMove"
@mouseleave="onMouseOut"
>
<defs>
<clipPath id="content">
<rect
:x="border_left"
:y="border_top"
:width="Math.max(width - border_left - border_right, 0)"
:height="
Math.max(height - border_top - border_bottom, 0)
"
></rect>
</clipPath>
<clipPath id="y_ticker">
<rect
:x="0"
:y="border_top"
:width="width"
:height="
Math.max(height - border_top - border_bottom, 0)
"
></rect>
</clipPath>
<clipPath id="x_ticker">
<rect
:x="border_left"
:y="0"
:width="Math.max(width - border_left - border_right, 0)"
:height="height"
></rect>
</clipPath>
</defs>
<g class="time_tick" clip-path="url(#x_ticker)">
<template v-for="tick of lines" :key="tick">
<polyline
v-if="!hide_time_ticks"
:points="`${x_map(tick)},${border_top} ${x_map(tick)},${height - border_bottom}`"
></polyline>
<TimeText
v-if="!hide_time_labels"
class="bottom_edge"
:x="x_map(tick)"
:y="height - border_bottom + text_offset"
:timestamp="tick"
:utc="props.utc"
:show_millis="line_duration < 1000"
:key="tick"
></TimeText>
</template>
</g>
<g :class="`${should_fade ? 'fade' : ''}`">
<slot></slot>
</g>
<g class="cursor_tick" v-if="mouse_t && cursor">
<rect
:x="x_map(mouse_t) - 100"
:y="height - border_bottom"
width="200"
height="20"
></rect>
<polyline
:points="`${x_map(mouse_t)},${border_top} ${x_map(mouse_t)},${height - border_bottom}`"
></polyline>
<TimeText
v-if="mouse_t"
id="cursor_text"
class="bottom_edge"
:x="x_map(mouse_t)"
:y="height - border_bottom + text_offset"
:timestamp="mouse_t"
:utc="props.utc"
show_millis
></TimeText>
</g>
</svg>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.bottom_edge {
text-anchor: middle;
dominant-baseline: hanging;
font-family: variables.$text-font;
}
.time_tick {
stroke: variables.$time-tick;
fill: variables.$time-tick;
}
.cursor_tick {
stroke: variables.$cursor-tick;
fill: variables.$cursor-tick;
}
.cursor_tick > rect {
fill: variables.$background-color;
opacity: 66%;
}
div.full-size {
width: 100%;
height: 100%;
position: relative;
}
div.controls-header {
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
align-content: stretch;
gap: 0 1em;
margin: 0 1em 0 1em;
}
svg.graph {
position: absolute;
}
</style>