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

@@ -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;

View File

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

View File

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

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_width: MaybeRefOrGetter<number>;
}

View File

@@ -11,63 +11,57 @@ provide(WEBSOCKET_SYMBOL, websocket);
</script>
<template>
<main>
<div style="width: 100vw; height: 50vh">
<SvgGraph
:legend="GraphSide.Left"
right_axis
include_controls
>
<Axis>
<Line data="simple_producer/time_offset"></Line>
<Line data="simple_producer/publish_offset"></Line>
<Line data="simple_producer/await_offset"></Line>
</Axis>
</SvgGraph>
</div>
<div style="width: 100vw; height: 50vh">
<SvgGraph
:duration="60 * 1000 * 10"
:legend="GraphSide.Right"
right_axis
>
<Axis>
<Line
data="simple_producer/sin"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin3"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos3"
:minimum_separation="1000"
></Line>
</Axis>
</SvgGraph>
</div>
</main>
<div style="width: 100vw; height: 50vh">
<SvgGraph :legend="GraphSide.Left" right_axis include_controls>
<Axis>
<Line data="simple_producer/time_offset"></Line>
<Line data="simple_producer/publish_offset"></Line>
<Line data="simple_producer/await_offset"></Line>
</Axis>
</SvgGraph>
</div>
<div style="width: 100vw; height: 50vh">
<SvgGraph
:duration="60 * 1000 * 10"
:legend="GraphSide.Right"
right_axis
>
<Axis>
<Line
data="simple_producer/sin"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin3"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos3"
:minimum_separation="1000"
></Line>
</Axis>
</SvgGraph>
</div>
</template>
<style lang="scss">