adds initial user defined panels

This commit is contained in:
2025-12-23 16:41:21 -05:00
parent a110aa6376
commit ebbf864af9
33 changed files with 2188 additions and 370 deletions

View File

@@ -110,3 +110,7 @@
justify-items: stretch;
align-items: center;
}
.justify-right {
justify-self: end;
}

View File

@@ -59,3 +59,26 @@ a:active {
text-decoration: inherit;
color: inherit;
}
.panel-content {
flex-grow: 1;
max-height: 100vh;
}
.panel-content * {
border: transparent 1px solid;
min-width: 1em;
min-height: 1em;
}
.panel-content span {
display: inline-block;
}
.editable {
border: white 1px dashed;
}
.editable.selected {
border: yellow 1px solid;
}

View File

@@ -79,7 +79,12 @@ onUnmounted(() => {
<template>
<span class="test monospace" :data-after-content="value">
<span ref="data-span" class="transparent" :copy-value="value">
<span
ref="data-span"
class="transparent"
:copy-value="value"
v-bind="$attrs"
>
{{ overlay }}
</span>
</span>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, defineAsyncComponent } from 'vue';
const TelemetryValue = defineAsyncComponent(
() => import('@/components/TelemetryValue.vue'),
);
const GridLayout = defineAsyncComponent(
() => import('@/components/layout/GridLayout.vue'),
);
const model = defineModel<OptionalDynamicComponentData>('data', {
required: true,
});
const selection = defineModel<symbol>('selection');
const props = defineProps<{
editable: boolean;
}>();
const thisSymbol = Symbol();
const isSelected = computed(() => {
return selection.value == thisSymbol && props.editable;
});
function selectThis() {
selection.value = thisSymbol;
}
function deleteThis() {
model.value = {
type: 'none',
};
}
function makeText() {
model.value = {
type: 'text',
text: '',
justify_right: false,
};
}
function makeTelemetry() {
model.value = {
type: 'telemetry',
data: '',
};
}
function makeGrid() {
model.value = {
type: 'grid',
columns: 1,
equal_width: false,
cells: [],
};
}
function addRow() {
const grid = model.value;
if (grid.type == 'grid') {
const row: OptionalDynamicComponentData[] = [];
for (let i = 0; i < grid.columns; i++) {
row.push({ type: 'none' });
}
grid.cells.push(row);
model.value = grid;
}
}
function deleteRow() {
const grid = model.value;
if (grid.type == 'grid') {
grid.cells.pop();
model.value = grid;
}
}
function addColumn() {
const grid = model.value;
if (grid.type == 'grid') {
for (let i = 0; i < grid.cells.length; i++) {
grid.cells[i].push({ type: 'none' });
}
grid.columns += 1;
model.value = grid;
}
}
function deleteColumn() {
const grid = model.value;
if (grid.type == 'grid') {
for (let i = 0; i < grid.cells.length; i++) {
grid.cells[i].pop();
}
grid.columns -= 1;
model.value = grid;
}
}
</script>
<template>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<button
v-if="model.type != 'none'"
@click.stop.prevent="deleteThis"
>
Delete
</button>
<button v-if="model.type != 'text'" @click.stop.prevent="makeText">
Make Text
</button>
<button
v-if="model.type != 'telemetry'"
@click.stop.prevent="makeTelemetry"
>
Make Telemetry
</button>
<button v-if="model.type != 'grid'" @click.stop.prevent="makeGrid">
Make Grid
</button>
</div>
</Teleport>
<template v-if="model.type == 'none'">
<!-- Intentionally Left Empty -->
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
></span>
</template>
<template v-else-if="model.type == 'text'">
<span
:class="`${model.justify_right ? 'justify-right' : ''} ${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
>
{{ model.text }}
</span>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Text: </label>
<input v-model="model.text" />
</div>
<div class="row">
<label>Justify Right: </label>
<input type="checkbox" v-model="model.justify_right" />
</div>
</Teleport>
</template>
<template v-else-if="model.type == 'telemetry'">
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
>
{{ '{' }} {{ model.data }} {{ '}' }}
</span>
<TelemetryValue
v-else
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:data="model.data"
@click.stop.prevent="selectThis"
></TelemetryValue>
<Teleport v-if="isSelected" to="#inspector">
<label>Telemetry Item: </label>
<input v-model="model.data" />
</Teleport>
</template>
<template v-else-if="model.type == 'grid'">
<GridLayout
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:cols="model.columns"
:equal_col_width="model.equal_width"
@click.stop.prevent="selectThis"
>
<template v-for="x in model.cells.length" :key="x">
<template v-for="y in model.columns" :key="y">
<DynamicComponent
v-model:data="model.cells[x - 1][y - 1]"
:editable="editable"
v-model:selection="selection"
></DynamicComponent>
</template>
</template>
</GridLayout>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Equal Width: </label>
<input type="checkbox" v-model="model.equal_width" />
</div>
<div class="row">
<button @click.stop.prevent="addRow">Add Row</button>
<button
:disabled="model.cells.length <= 0"
@click.stop.prevent="deleteRow"
>
Delete Row
</button>
<button @click.stop.prevent="addColumn">Add Column</button>
<button
:disabled="model.columns <= 0"
@click.stop.prevent="deleteColumn"
>
Delete Column
</button>
</div>
</Teleport>
</template>
<template v-else> ERROR: Unknown data: {{ model }} </template>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -68,10 +68,15 @@ watch([display_value], ([display_str]) => {
<template>
<template v-if="copyable">
<CopyableDynamicSpan :value="display_value"></CopyableDynamicSpan>
<CopyableDynamicSpan
:value="display_value"
v-bind="$attrs"
></CopyableDynamicSpan>
</template>
<template v-else>
{{ display_value }}
<span v-bind="$attrs">
{{ display_value }}
</span>
</template>
</template>

View File

@@ -48,14 +48,15 @@ const numeric_data = computed(() => {
</script>
<template>
<span v-if="!is_data_present"> No Data </span>
<span v-if="!is_data_present" v-bind="$attrs"> No Data </span>
<template v-else>
<NumericText
v-if="numeric_data"
v-bind="$attrs"
:value="numeric_data"
:max_width="10"
></NumericText>
<span v-else>
<span v-else v-bind="$attrs">
Cannot Display Data of Type {{ telemetry_data!.data_type }}
</span>
</template>

View File

@@ -0,0 +1,13 @@
export type DynamicComponentData =
| { type: 'text'; text: string; justify_right: boolean }
| { type: 'telemetry'; data: string }
| {
type: 'grid';
columns: number;
equal_width: boolean;
cells: OptionalDynamicComponentData[][];
};
export type OptionalDynamicComponentData =
| { type: 'none' }
| DynamicComponentData;

View File

@@ -1,4 +1,5 @@
import type { RouteLocationRaw } from 'vue-router';
import { ref, type Ref, watchEffect } from 'vue';
export enum PanelHeirarchyType {
LEAF = 'leaf',
@@ -22,31 +23,57 @@ export type PanelHeirarchyChildren = (
| PanelHeirarchyLeaf
)[];
export function getPanelHeirarchy(): PanelHeirarchyChildren {
const result: PanelHeirarchyChildren = [];
export function usePanelHeirarchy(): Ref<PanelHeirarchyChildren> {
const internal: PanelHeirarchyFolder = {
name: 'Internal',
children: [
{
name: 'Graph Test',
to: { name: 'graph' },
type: PanelHeirarchyType.LEAF,
},
{
name: 'Telemetry Elements',
to: { name: 'list' },
type: PanelHeirarchyType.LEAF,
},
{
name: 'Chart',
to: { name: 'chart' },
type: PanelHeirarchyType.LEAF,
},
{
name: 'Panel Editor',
to: { name: 'panel_editor' },
type: PanelHeirarchyType.LEAF,
},
],
type: PanelHeirarchyType.FOLDER,
};
result.push({
name: 'Graph Test',
to: { name: 'graph' },
type: PanelHeirarchyType.LEAF,
});
const result: Ref<PanelHeirarchyChildren> = ref([internal]);
result.push({
name: 'Telemetry Elements',
to: { name: 'list' },
type: PanelHeirarchyType.LEAF,
});
watchEffect(async () => {
try {
const res = await fetch(`/api/panel`);
const data = await res.json();
result.push({
name: 'Chart',
to: { name: 'chart' },
type: PanelHeirarchyType.LEAF,
});
const server_panels: PanelHeirarchyFolder = {
name: 'Server Panels',
children: [],
type: PanelHeirarchyType.FOLDER,
};
result.push({
name: 'Panel Test',
to: { name: 'panel_test' },
type: PanelHeirarchyType.LEAF,
for (const entry of data) {
server_panels.children.push({
name: entry['name'],
to: { name: 'panel', params: { id: entry['id'] } },
type: PanelHeirarchyType.LEAF,
});
}
result.value = [internal, server_panels];
} catch {}
});
return result;

View File

@@ -24,9 +24,14 @@ const router = createRouter({
component: () => import('../views/ChartView.vue'),
},
{
path: '/panel_test',
name: 'panel_test',
component: () => import('../views/PanelTest.vue'),
path: '/panel_editor',
name: 'panel_editor',
component: () => import('../views/PanelEditorView.vue'),
},
{
path: '/panel/:id',
name: 'panel',
component: () => import('../views/PanelView.vue'),
},
],
});

View File

@@ -3,7 +3,7 @@ import { computed, ref } from 'vue';
import {
filterHeirarchy,
getFirstLeaf,
getPanelHeirarchy,
usePanelHeirarchy,
} from '@/panels/panel';
import PanelHeirarchy from '@/components/PanelHeirarchy.vue';
import router from '@/router';
@@ -14,10 +14,10 @@ import { ScreenType } from '@/composables/ScreenType.ts';
const searchValue = ref('');
const heirarchy = getPanelHeirarchy();
const heirarchy = usePanelHeirarchy();
const filtered_heirarchy = computed(() =>
filterHeirarchy(heirarchy, (leaf) => {
filterHeirarchy(heirarchy.value, (leaf) => {
return leaf.name
.toLowerCase()
.includes(searchValue.value.toLowerCase());

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import DynamicComponent from '@/components/DynamicComponent.vue';
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { ref, watchEffect } from 'vue';
const data = ref<OptionalDynamicComponentData>({ type: 'none' });
const panel_id = ref<string | null>(null);
const panel_name = ref<string>('Untitled Panel');
const loading = ref(false);
const selected = ref<symbol>();
const panel_list = ref<{ name: string; id: string }[]>([]);
const load_screen = ref(true);
const unselect = () => {
selected.value = undefined;
};
async function reload_panel_list() {
const res = await fetch(`/api/panel`);
panel_list.value = await res.json();
}
watchEffect(reload_panel_list);
async function load(id: string) {
data.value = { type: 'none' };
load_screen.value = false;
loading.value = true;
const panel_data = await fetch(`/api/panel/${id}`);
const panel_json_value = await panel_data.json();
data.value = JSON.parse(
panel_json_value['data'],
) as OptionalDynamicComponentData;
panel_name.value = panel_json_value['name'];
panel_id.value = id;
loading.value = false;
}
async function newPanel() {
data.value = { type: 'none' };
panel_name.value = 'Untitled Panel';
panel_id.value = null;
loading.value = false;
load_screen.value = false;
}
async function save() {
loading.value = true;
const panel_id_value = panel_id.value;
if (panel_id_value) {
const res = await fetch(`/api/panel/${panel_id_value}`, {
method: 'PUT',
body: JSON.stringify({
name: panel_name.value,
data: JSON.stringify(data.value),
}),
headers: {
'Content-Type': 'application/json',
},
});
await res.json();
} else {
const res = await fetch('/api/panel', {
method: 'POST',
body: JSON.stringify({
name: panel_name.value,
data: JSON.stringify(data.value),
}),
headers: {
'Content-Type': 'application/json',
},
});
const uuid = await res.json();
panel_id.value = uuid as string;
}
loading.value = false;
}
async function showLoadScreen() {
load_screen.value = true;
await reload_panel_list();
}
</script>
<template>
<div v-if="load_screen" class="column grow">
<div class="row">
<button @click.stop.prevent="newPanel">New</button>
</div>
<div v-for="panel of panel_list" :key="panel.id" class="row">
<button @click.stop.prevent="() => load(panel.id)">Load</button>
<span>
{{ panel.name }}
</span>
</div>
</div>
<div v-else class="row grow">
<div class="panel-content scroll no-min-height" @click="unselect">
<DynamicComponent
v-model:data="data"
:editable="true"
v-model:selection="selected"
></DynamicComponent>
</div>
<div id="sidebar" class="column">
<div class="row">
<label>Name:</label>
<input :disabled="loading" type="text" v-model="panel_name" />
</div>
<div class="row">
<button :disabled="loading" @click.prevent.stop="save">
{{ panel_id ? 'Save' : 'New' }}
</button>
<button @click.prevent.stop="showLoadScreen">Load</button>
</div>
<div id="inspector" class="column"></div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
#sidebar {
width: 25%;
height: 100vh;
background: variables.$dark-background-color;
}
</style>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import GridLayout from '@/components/layout/GridLayout.vue';
import TelemetryValue from '@/components/TelemetryValue.vue';
</script>
<template>
<GridLayout :cols="2" equal_col_width>
<span class="justify-right"> simple_producer/cos </span>
<TelemetryValue data="simple_producer/cos"></TelemetryValue>
<span class="justify-right"> simple_producer/sin </span>
<TelemetryValue data="simple_producer/sin"></TelemetryValue>
<span class="justify-right"> simple_producer/cos2 </span>
<TelemetryValue data="simple_producer/cos2"></TelemetryValue>
<span class="justify-right"> simple_producer/sin2 </span>
<TelemetryValue data="simple_producer/sin2"></TelemetryValue>
</GridLayout>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.justify-right {
justify-self: end;
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import DynamicComponent from '@/components/DynamicComponent.vue';
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, ref, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const id = computed<string>(() => route.params.id as string);
const panel = ref<OptionalDynamicComponentData>({
type: 'none',
});
watchEffect(async () => {
const panel_data = await fetch(`/api/panel/${id.value}`);
const panel_json_value = await panel_data.json();
panel.value = JSON.parse(
panel_json_value['data'],
) as OptionalDynamicComponentData;
});
</script>
<template>
<DynamicComponent v-model:data="panel" :editable="false"></DynamicComponent>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>