455 lines
12 KiB
Vue
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>
|