adds legend tooltips
This commit is contained in:
@@ -7,7 +7,9 @@
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="app"></div>
|
||||
</main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'sass:color';
|
||||
|
||||
$gray-1: oklch(90% 0 0);
|
||||
$gray-2: oklch(80% 0 0);
|
||||
$gray-3: oklch(70% 0 0);
|
||||
@@ -24,6 +26,8 @@ $magenta-2: oklch(75% 0.2 330);
|
||||
|
||||
$text-color: $gray-1;
|
||||
$background-color: $gray-7;
|
||||
$light-background-color: color.adjust($background-color, $lightness: 5%);
|
||||
$dark-background-color: color.adjust($background-color, $lightness: -5%);
|
||||
|
||||
$time-tick: $gray-1;
|
||||
$grid-line: $gray-1;
|
||||
@@ -31,6 +35,7 @@ $major-tick: $gray-4;
|
||||
$minor-tick: $gray-5;
|
||||
|
||||
$text-font: Helvetica, sans-serif;
|
||||
$large-text-size: 20px;
|
||||
$normal-text-size: 16px;
|
||||
$small-text-size: 12px;
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, onWatcherCleanup, provide, ref, useTemplateRef, watch } from 'vue';
|
||||
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';
|
||||
@@ -15,7 +23,7 @@ const props = defineProps<{
|
||||
legend?: GraphSide;
|
||||
}>();
|
||||
|
||||
const divRef = useTemplateRef<HTMLDivElement>("graph-div");
|
||||
const divRef = useTemplateRef<HTMLDivElement>('graph-div');
|
||||
|
||||
const width = ref(0);
|
||||
const height = ref(0);
|
||||
@@ -26,7 +34,9 @@ const resize_observer = new ResizeObserver((elements) => {
|
||||
for (const element of elements) {
|
||||
if (element.target == divRef.value) {
|
||||
width.value = element.contentBoxSize[0].inlineSize;
|
||||
height.value = element.contentBoxSize[0].blockSize - (props.include_controls ? controls_height : 0);
|
||||
height.value =
|
||||
element.contentBoxSize[0].blockSize -
|
||||
(props.include_controls ? controls_height : 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -126,8 +136,10 @@ provide<GraphData>(GRAPH_DATA, {
|
||||
border_top: border_top,
|
||||
min_x: min_x,
|
||||
max_x: now,
|
||||
width: () => Math.max(width.value - border_left.value - border_right.value, 0),
|
||||
height: () => Math.max(height.value - border_top.value - border_bottom.value, 0),
|
||||
width: () =>
|
||||
Math.max(width.value - border_left.value - border_right.value, 0),
|
||||
height: () =>
|
||||
Math.max(height.value - border_top.value - border_bottom.value, 0),
|
||||
x_map: x_map,
|
||||
lines: telemetry_lines,
|
||||
max_update_rate: 1000 / 10,
|
||||
@@ -160,7 +172,11 @@ const lines = computed(() => {
|
||||
|
||||
<template>
|
||||
<div ref="graph-div" class="full-size">
|
||||
<div v-if="include_controls" class="controls-header" :style="`height: ${controls_height}px`">
|
||||
<div
|
||||
v-if="include_controls"
|
||||
class="controls-header"
|
||||
:style="`height: ${controls_height}px`"
|
||||
>
|
||||
<div class="grow"></div>
|
||||
<div>
|
||||
<span>Duration Dropdown</span>
|
||||
@@ -173,7 +189,9 @@ const lines = computed(() => {
|
||||
:x="border_left"
|
||||
:y="border_top"
|
||||
:width="Math.max(width - border_left - border_right, 0)"
|
||||
:height="Math.max(height - border_top - border_bottom, 0)"
|
||||
:height="
|
||||
Math.max(height - border_top - border_bottom, 0)
|
||||
"
|
||||
></rect>
|
||||
</clipPath>
|
||||
<clipPath id="y_ticker">
|
||||
@@ -181,7 +199,9 @@ const lines = computed(() => {
|
||||
:x="0"
|
||||
:y="border_top"
|
||||
:width="width"
|
||||
:height="Math.max(height - border_top - border_bottom, 0)"
|
||||
:height="
|
||||
Math.max(height - border_top - border_bottom, 0)
|
||||
"
|
||||
></rect>
|
||||
</clipPath>
|
||||
<clipPath id="x_ticker">
|
||||
@@ -247,5 +267,4 @@ div.controls-header {
|
||||
div.controls-header > div.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ShallowRef,
|
||||
toValue,
|
||||
triggerRef,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ 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';
|
||||
|
||||
const props = defineProps<{
|
||||
data: string;
|
||||
@@ -39,9 +41,9 @@ const min_sep = computed(() =>
|
||||
Math.min(props.minimum_separation || 0, maximum_minimum_separation_live),
|
||||
);
|
||||
|
||||
const { data } = useTelemetry(() => props.data);
|
||||
const { data: telemetry_data } = useTelemetry(() => props.data);
|
||||
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
|
||||
const value = websocket.value.listen_to_telemetry(data, min_sep);
|
||||
const value = websocket.value.listen_to_telemetry(telemetry_data, min_sep);
|
||||
|
||||
const graph_data = inject<GraphData>(GRAPH_DATA)!;
|
||||
const axis_data = inject<AxisData>(AXIS_DATA)!;
|
||||
@@ -82,7 +84,9 @@ watch([value], ([val]) => {
|
||||
if (val) {
|
||||
const val_t = Date.parse(val.timestamp);
|
||||
if (val_t >= min_x) {
|
||||
const item_val = val.value[data.value!.data_type] as number;
|
||||
const item_val = val.value[
|
||||
telemetry_data.value!.data_type
|
||||
] as number;
|
||||
const new_item = {
|
||||
x: val_t,
|
||||
y: item_val,
|
||||
@@ -100,7 +104,7 @@ watch([value], ([val]) => {
|
||||
}
|
||||
});
|
||||
watch(
|
||||
[data, () => props.fetch_history],
|
||||
[telemetry_data, () => props.fetch_history],
|
||||
async ([data]) => {
|
||||
if (data) {
|
||||
const uuid = data.uuid;
|
||||
@@ -213,7 +217,7 @@ const group_transform = computed(() => {
|
||||
|
||||
watch([recompute_points], () => {
|
||||
let new_points = '';
|
||||
if (memo.value.data.length == 0 || data.value == null) {
|
||||
if (memo.value.data.length == 0 || telemetry_data.value == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -246,7 +250,7 @@ watch([recompute_points], () => {
|
||||
const current_value = computed(() => {
|
||||
const val = value.value;
|
||||
if (val) {
|
||||
return val.value[data.value!.data_type] as number;
|
||||
return val.value[telemetry_data.value!.data_type] as number;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
@@ -270,7 +274,7 @@ const legend_text = computed(() => {
|
||||
(toValue(graph_data.legend_width) -
|
||||
legend_line_length -
|
||||
legend_text_offset * 2) /
|
||||
7;
|
||||
6.5;
|
||||
const start_text = props.data;
|
||||
if (start_text.length > max_chars) {
|
||||
return (
|
||||
@@ -284,9 +288,27 @@ const legend_text = computed(() => {
|
||||
|
||||
const legend_line = computed(() => {
|
||||
const x = legend_x.value;
|
||||
const y = legend_y.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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -306,13 +328,61 @@ const legend_line = computed(() => {
|
||||
>
|
||||
</ValueLabel>
|
||||
<template v-if="toValue(graph_data.legend_enabled)">
|
||||
<polyline fill="none" :points="legend_line"></polyline>
|
||||
<text
|
||||
:x="legend_x + legend_line_length + legend_text_offset"
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="row">
|
||||
<span>Unknown Signal</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</TooltipDialog>
|
||||
</foreignObject>
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
@@ -333,4 +403,59 @@ text {
|
||||
dominant-baseline: middle;
|
||||
font-size: variables.$small-monospace-text-size;
|
||||
}
|
||||
|
||||
rect.legend {
|
||||
stroke: none;
|
||||
stroke-width: 0;
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
rect.legend.selected,
|
||||
rect.legend:hover,
|
||||
rect.legend:has(~ .legend:hover) {
|
||||
stroke: variables.$gray-2;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
div.column {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
div.row {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
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>
|
||||
|
||||
37
frontend/src/components/TooltipDialog.vue
Normal file
37
frontend/src/components/TooltipDialog.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import TooltipDialogContents from '@/components/TooltipDialogContents.vue';
|
||||
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
element: Element | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="main">
|
||||
<Transition name="dialog">
|
||||
<TooltipDialogContents
|
||||
v-if="show"
|
||||
:element="element"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<slot></slot>
|
||||
</TooltipDialogContents>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/variables';
|
||||
.dialog-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/components/TooltipDialogContents.vue
Normal file
83
frontend/src/components/TooltipDialogContents.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
element: Element | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
function onClick() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const top = ref(0);
|
||||
const width = ref(0);
|
||||
const left = ref(0);
|
||||
const height = ref(0);
|
||||
|
||||
function update_for_element(element: Element) {
|
||||
top.value = element.getBoundingClientRect().top + window.scrollY;
|
||||
left.value = element.getBoundingClientRect().left + window.screenX;
|
||||
width.value = element.getBoundingClientRect().width;
|
||||
height.value = element.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((elements) => {
|
||||
for (const element of elements) {
|
||||
if (element.target == props.element && props.element != null) {
|
||||
update_for_element(props.element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.body.addEventListener('click', onClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.removeEventListener('click', onClick);
|
||||
observer.disconnect();
|
||||
});
|
||||
|
||||
if (props.element) {
|
||||
update_for_element(props.element);
|
||||
}
|
||||
|
||||
const dialog_style = computed(() => {
|
||||
const midpoint_x = left.value + width.value / 2;
|
||||
if (midpoint_x > window.innerWidth / 2) {
|
||||
return `top: ${top.value + height.value / 2}px; right: ${window.innerWidth - midpoint_x}px;`;
|
||||
}
|
||||
return `top: ${top.value + height.value / 2}px; left: ${midpoint_x}px;`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dialog" :style="dialog_style" @click.stop="">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/assets/variables';
|
||||
|
||||
.dialog {
|
||||
position: absolute;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 99;
|
||||
padding: 1em;
|
||||
border: variables.$light-background-color 2px solid;
|
||||
background-color: variables.$dark-background-color;
|
||||
}
|
||||
|
||||
.dialog-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -24,4 +24,3 @@ export interface GraphData {
|
||||
legend_y_stride: MaybeRefOrGetter<number>;
|
||||
legend_width: MaybeRefOrGetter<number>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,8 @@ provide(WEBSOCKET_SYMBOL, websocket);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div style="width: 100vw; height: 50vh">
|
||||
<SvgGraph
|
||||
:legend="GraphSide.Left"
|
||||
right_axis
|
||||
include_controls
|
||||
>
|
||||
<SvgGraph :legend="GraphSide.Left" right_axis include_controls>
|
||||
<Axis>
|
||||
<Line data="simple_producer/time_offset"></Line>
|
||||
<Line data="simple_producer/publish_offset"></Line>
|
||||
@@ -67,7 +62,6 @@ provide(WEBSOCKET_SYMBOL, websocket);
|
||||
</Axis>
|
||||
</SvgGraph>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
Reference in New Issue
Block a user