adds legend tooltips

This commit is contained in:
2025-01-03 00:20:56 -05:00
parent 623c394446
commit 35603c98a4
8 changed files with 343 additions and 79 deletions

View File

@@ -7,7 +7,9 @@
<title>Vite App</title> <title>Vite App</title>
</head> </head>
<body> <body>
<main>
<div id="app"></div> <div id="app"></div>
</main>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -1,3 +1,5 @@
@use 'sass:color';
$gray-1: oklch(90% 0 0); $gray-1: oklch(90% 0 0);
$gray-2: oklch(80% 0 0); $gray-2: oklch(80% 0 0);
$gray-3: oklch(70% 0 0); $gray-3: oklch(70% 0 0);
@@ -24,6 +26,8 @@ $magenta-2: oklch(75% 0.2 330);
$text-color: $gray-1; $text-color: $gray-1;
$background-color: $gray-7; $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; $time-tick: $gray-1;
$grid-line: $gray-1; $grid-line: $gray-1;
@@ -31,6 +35,7 @@ $major-tick: $gray-4;
$minor-tick: $gray-5; $minor-tick: $gray-5;
$text-font: Helvetica, sans-serif; $text-font: Helvetica, sans-serif;
$large-text-size: 20px;
$normal-text-size: 16px; $normal-text-size: 16px;
$small-text-size: 12px; $small-text-size: 12px;

View File

@@ -1,5 +1,13 @@
<script setup lang="ts"> <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 { useNow } from '@/composables/ticker';
import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph'; import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph';
import TimeText from '@/components/TimeText.vue'; import TimeText from '@/components/TimeText.vue';
@@ -15,7 +23,7 @@ const props = defineProps<{
legend?: GraphSide; legend?: GraphSide;
}>(); }>();
const divRef = useTemplateRef<HTMLDivElement>("graph-div"); const divRef = useTemplateRef<HTMLDivElement>('graph-div');
const width = ref(0); const width = ref(0);
const height = ref(0); const height = ref(0);
@@ -26,7 +34,9 @@ const resize_observer = new ResizeObserver((elements) => {
for (const element of elements) { for (const element of elements) {
if (element.target == divRef.value) { if (element.target == divRef.value) {
width.value = element.contentBoxSize[0].inlineSize; 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, border_top: border_top,
min_x: min_x, min_x: min_x,
max_x: now, max_x: now,
width: () => Math.max(width.value - border_left.value - border_right.value, 0), width: () =>
height: () => Math.max(height.value - border_top.value - border_bottom.value, 0), 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, x_map: x_map,
lines: telemetry_lines, lines: telemetry_lines,
max_update_rate: 1000 / 10, max_update_rate: 1000 / 10,
@@ -160,7 +172,11 @@ const lines = computed(() => {
<template> <template>
<div ref="graph-div" class="full-size"> <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 class="grow"></div>
<div> <div>
<span>Duration Dropdown</span> <span>Duration Dropdown</span>
@@ -173,7 +189,9 @@ const lines = computed(() => {
:x="border_left" :x="border_left"
:y="border_top" :y="border_top"
:width="Math.max(width - border_left - border_right, 0)" :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> ></rect>
</clipPath> </clipPath>
<clipPath id="y_ticker"> <clipPath id="y_ticker">
@@ -181,7 +199,9 @@ const lines = computed(() => {
:x="0" :x="0"
:y="border_top" :y="border_top"
:width="width" :width="width"
:height="Math.max(height - border_top - border_bottom, 0)" :height="
Math.max(height - border_top - border_bottom, 0)
"
></rect> ></rect>
</clipPath> </clipPath>
<clipPath id="x_ticker"> <clipPath id="x_ticker">
@@ -247,5 +267,4 @@ div.controls-header {
div.controls-header > div.grow { div.controls-header > div.grow {
flex-grow: 1; flex-grow: 1;
} }
</style> </style>

View File

@@ -10,6 +10,7 @@ import {
type ShallowRef, type ShallowRef,
toValue, toValue,
triggerRef, triggerRef,
useTemplateRef,
watch, watch,
} from 'vue'; } from 'vue';
import { import {
@@ -21,6 +22,7 @@ import { GRAPH_DATA, type GraphData } from '@/graph/graph';
import { AXIS_DATA, type AxisData } from '@/graph/axis'; import { AXIS_DATA, type AxisData } from '@/graph/axis';
import ValueLabel from '@/components/ValueLabel.vue'; import ValueLabel from '@/components/ValueLabel.vue';
import { type Point, PointLine } from '@/graph/line'; import { type Point, PointLine } from '@/graph/line';
import TooltipDialog from '@/components/TooltipDialog.vue';
const props = defineProps<{ const props = defineProps<{
data: string; data: string;
@@ -39,9 +41,9 @@ const min_sep = computed(() =>
Math.min(props.minimum_separation || 0, maximum_minimum_separation_live), 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 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 graph_data = inject<GraphData>(GRAPH_DATA)!;
const axis_data = inject<AxisData>(AXIS_DATA)!; const axis_data = inject<AxisData>(AXIS_DATA)!;
@@ -82,7 +84,9 @@ watch([value], ([val]) => {
if (val) { if (val) {
const val_t = Date.parse(val.timestamp); const val_t = Date.parse(val.timestamp);
if (val_t >= min_x) { 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 = { const new_item = {
x: val_t, x: val_t,
y: item_val, y: item_val,
@@ -100,7 +104,7 @@ watch([value], ([val]) => {
} }
}); });
watch( watch(
[data, () => props.fetch_history], [telemetry_data, () => props.fetch_history],
async ([data]) => { async ([data]) => {
if (data) { if (data) {
const uuid = data.uuid; const uuid = data.uuid;
@@ -213,7 +217,7 @@ const group_transform = computed(() => {
watch([recompute_points], () => { watch([recompute_points], () => {
let new_points = ''; let new_points = '';
if (memo.value.data.length == 0 || data.value == null) { if (memo.value.data.length == 0 || telemetry_data.value == null) {
return ''; return '';
} }
@@ -246,7 +250,7 @@ watch([recompute_points], () => {
const current_value = computed(() => { const current_value = computed(() => {
const val = value.value; const val = value.value;
if (val) { if (val) {
return val.value[data.value!.data_type] as number; return val.value[telemetry_data.value!.data_type] as number;
} }
return undefined; return undefined;
}); });
@@ -270,7 +274,7 @@ const legend_text = computed(() => {
(toValue(graph_data.legend_width) - (toValue(graph_data.legend_width) -
legend_line_length - legend_line_length -
legend_text_offset * 2) / legend_text_offset * 2) /
7; 6.5;
const start_text = props.data; const start_text = props.data;
if (start_text.length > max_chars) { if (start_text.length > max_chars) {
return ( return (
@@ -284,9 +288,27 @@ const legend_text = computed(() => {
const legend_line = computed(() => { const legend_line = computed(() => {
const x = legend_x.value; 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}`; 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> </script>
<template> <template>
@@ -306,13 +328,61 @@ const legend_line = computed(() => {
> >
</ValueLabel> </ValueLabel>
<template v-if="toValue(graph_data.legend_enabled)"> <template v-if="toValue(graph_data.legend_enabled)">
<polyline fill="none" :points="legend_line"></polyline> <rect
<text ref="legend-ref"
:x="legend_x + legend_line_length + legend_text_offset" :class="`legend ${is_selected ? 'selected' : ''}`"
:x="legend_x - legend_text_offset"
:y="legend_y" :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 }} {{ legend_text }}
</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> </template>
</g> </g>
</template> </template>
@@ -333,4 +403,59 @@ text {
dominant-baseline: middle; dominant-baseline: middle;
font-size: variables.$small-monospace-text-size; 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> </style>

View 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>

View 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>

View File

@@ -24,4 +24,3 @@ export interface GraphData {
legend_y_stride: MaybeRefOrGetter<number>; legend_y_stride: MaybeRefOrGetter<number>;
legend_width: MaybeRefOrGetter<number>; legend_width: MaybeRefOrGetter<number>;
} }

View File

@@ -11,13 +11,8 @@ provide(WEBSOCKET_SYMBOL, websocket);
</script> </script>
<template> <template>
<main>
<div style="width: 100vw; height: 50vh"> <div style="width: 100vw; height: 50vh">
<SvgGraph <SvgGraph :legend="GraphSide.Left" right_axis include_controls>
:legend="GraphSide.Left"
right_axis
include_controls
>
<Axis> <Axis>
<Line data="simple_producer/time_offset"></Line> <Line data="simple_producer/time_offset"></Line>
<Line data="simple_producer/publish_offset"></Line> <Line data="simple_producer/publish_offset"></Line>
@@ -67,7 +62,6 @@ provide(WEBSOCKET_SYMBOL, websocket);
</Axis> </Axis>
</SvgGraph> </SvgGraph>
</div> </div>
</main>
</template> </template>
<style lang="scss"> <style lang="scss">