Reimplement <dougal-project-settings-online-line-name-format/>.

Closes #297.
This commit is contained in:
D. Berge
2025-07-09 16:45:35 +02:00
parent 9cc21ba06a
commit 071fd7438b
5 changed files with 1017 additions and 55 deletions

View File

@@ -0,0 +1,269 @@
<template>
<v-row dense no-gutters>
<v-col>
<slot name="prepend"></slot>
</v-col>
<v-col cols="2">
<v-chip v-if="value.item && !readonly"
outlined
label
small
:color="colour"
:title="description"
>{{name}}</v-chip>
<v-select v-else-if="items.length && !readonly"
label="Item"
:items=items
v-model="value.item"
dense
title="Select an item to use as a field"
></v-select>
</v-col>
<v-col>
<v-select v-if="type == 'boolean'"
label="Condition"
:items="[true, false]"
v-model="value.when"
dense
title="Use this configuration only when the value of this item matches the selected state. This allows the user to configure different values for true and false conditions."
></v-select>
</v-col>
<v-col>
<v-text-field v-if="type == 'boolean' || type == 'text'"
class="ml-3"
dense
label="Value"
v-model="value.value"
title="This literal text will be inserted at the designated position"
></v-text-field>
<v-menu v-else-if="type == 'number'"
max-width="600"
:close-on-content-click="false"
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-chip
class="ml-3"
small
:light="$vuetify.theme.isDark"
:dark="!$vuetify.theme.isDark"
:color="value.scale_offset != null || value.scale_multiplier != null ? 'primary' : ''"
:title="`Number scaling${ value.scale_offset != null ? ('\nOffset: ' + value.scale_offset) : '' }${ value.scale_multiplier != null ? ('\nMultiplier: ' + value.scale_multiplier) : ''}`"
v-bind="attrs"
v-on="on"
>
<v-icon small>mdi-ruler</v-icon>
</v-chip>
</template>
<v-card rounded outlined>
<v-card-text>
<v-row dense no-gutters>
<v-text-field
type="number"
dense
clearable
label="Offset"
title="Offset the value by this amount (after scaling)"
v-model.number="value.scale_offset"
></v-text-field>
</v-row>
<v-row dense no-gutters>
<v-text-field
type="number"
dense
clearable
label="Scale"
title="Mutiply the value by this amount (before scaling)"
v-model.number="value.scale_multiplier"
></v-text-field>
</v-row>
</v-card-text>
</v-card>
</v-menu>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="From"
type="number"
min="0"
v-model.number="value.offset"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col>
<v-text-field
class="ml-3"
dense
label="Length"
type="number"
min="0"
v-model.number="value.length"
:readonly="readonly"
></v-text-field>
</v-col>
<v-col>
<v-menu v-if="value.length > 1"
max-width="600"
:close-on-content-click="false"
offset-y
:disabled="!(value.length>1)"
>
<template v-slot:activator="{ on, attrs }">
<v-chip
class="ml-3"
small
:light="$vuetify.theme.isDark"
:dark="!$vuetify.theme.isDark"
title="Text alignment"
v-bind="attrs"
v-on="on"
:disabled="!(value.length>1)"
>
<v-icon small v-if="value.pad_side=='right'">mdi-format-align-left</v-icon>
<v-icon small v-else-if="value.pad_side=='left'">mdi-format-align-right</v-icon>
<v-icon small v-else>mdi-format-align-justify</v-icon>
</v-chip>
</template>
<v-card rounded outlined>
<v-card-text>
<v-row dense no-gutters>
<v-select
label="Alignment"
clearable
:items='[{text:"Left", value:"right"}, {text:"Right", value:"left"}]'
v-model="value.pad_side"
></v-select>
</v-row>
<v-row dense no-gutters v-if="value.pad_side">
<v-text-field
dense
label="Pad character"
title="Fill the width of the field on the opposite side by padding with this character"
v-model="value.pad_string"
></v-text-field>
</v-row>
</v-card-text>
</v-card>
</v-menu>
</v-col>
<v-col>
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input >>> .chunk {
padding-inline: 1px;
border: 1px solid;
}
.input >>> .chunk-empty {
padding-inline: 1px;
}
.input >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
</style>
<script>
export default {
name: "DougalFixedStringEncoderField",
components: {
},
props: {
value: Object,
properties: Object,
colour: String,
readonly: Boolean,
},
data () {
return {
}
},
watch: {
"value.value": function (value, old) {
if (value != null && String(value).length > this.value.length) {
this.value.length = String(value).length;
}
}
},
computed: {
field: {
get () {
return this.value;
},
set (v) {
console.log("input", v);
this.$emit("input", v);
}
},
item () {
return this.properties?.[this.value?.item] ?? {};
},
items () {
return Object.entries(this.properties).map(i => ({text: i[1].summary ?? i[0], value: i[0]}))
},
name () {
// TODO Use properties[item].summary or similar
return this.item?.summary ?? this.value.item ?? "???";
},
type () {
return this.item?.type ?? typeof this.value?.item ?? "undefined";
},
description () {
return this.item?.description;
}
},
methods: {
reset () {
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,351 @@
<template>
<v-input
class="v-text-field"
:hint="hint"
persistent-hint
:value="text"
>
<label
class="v-label"
:class="[ $vuetify.theme.isDark && 'theme--dark', text && text.length && 'v-label--active' ]"
style="left: 0px; right: auto; position: absolute;"
>{{ label }}</label>
<div class="input" slot="default"
v-html="html"
>
</div>
<template slot="append">
<v-menu
scrollable
offset-y
:close-on-content-click="false"
>
<template v-slot:activator="{on, attrs}">
<v-btn
icon
v-bind="attrs"
v-on="on"
>
<v-icon title="Configure sample values">mdi-list-box-outline</v-icon>
</v-btn>
</template>
<v-card>
<v-card-title>Sample values</v-card-title>
<v-card-subtitle>Enter sample values to test your configuration</v-card-subtitle>
<v-divider></v-divider>
<v-card-text>
<v-container>
<v-row v-for="(prop, key) in properties" :key="key">
<template v-if="prop.type == 'boolean'">
<v-col cols="6" align-self="center">
<v-chip
outlined
label
small
:color="getHSLColourFor(key)"
:title="prop.description"
>{{prop.summary || key}}</v-chip>
</v-col>
<v-col cols="6" align-self="center">
<v-simple-checkbox v-model="values[key]"></v-simple-checkbox>
</v-col>
</template>
<template v-else-if="key != 'text'">
<v-col cols="6" align-self="center">
<v-chip
outlined
label
small
:color="getHSLColourFor(key)"
:title="prop.description"
>{{prop.summary || key}}</v-chip>
</v-col>
<v-col cols="6" align-self="center">
<v-text-field v-if="prop.type == 'number'"
:type="prop.type"
:label="prop.summary || key"
:hint="prop.description"
v-model.number="values[key]"
></v-text-field>
<v-text-field v-else
:type="prop.type"
:label="prop.summary || key"
:hint="prop.description"
v-model="values[key]"
></v-text-field>
</v-col>
</template>
</v-row>
</v-container>
</v-card-text>
</v-card>
</v-menu>
</template>
<v-icon slot="prepend">mdi-list</v-icon>
</v-input>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
white-space-collapse: preserve;
}
.multiline {
font-family: mono;
white-space: pre;
overflow-x: auto;
overflow-y: auto;
}
.multiline >>> .line-number {
display: inline-block;
font-size: 75%;
width: 5ex;
margin-inline-end: 1ex;
text-align: right;
border: none;
position: relative;
top: -1px;
}
.input, .multiline >>> .chunk-field {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-fixed {
padding-inline: 1px;
border: 1px dashed;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
.input >>> .chunk-mismatch {
padding-inline: 1px;
border: 2px solid red !important;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
export default {
name: "DougalFixedStringEncoderSample",
components: {
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
properties: { type: Object, default: () => ({}) },
fields: { type: Array, default: () => [] },
values: { type: Object, default: () => ({}) },
readonly: Boolean,
label: String,
hint: String,
},
data () {
return {
}
},
computed: {
chunks () {
const properties = this.properties;
const fields = this.fields;
const values = this.values;
const str = "";
const chunks = [];
for (const field of fields) {
const value = this.fieldValue(properties, field, values);
if (value != null) {
const chunk = {
start: field.offset,
end: field.offset + field.length - 1,
colour: this.getHSLColourFor(field.item),
class: field.item == "text" ? "fixed" : "field",
text: value
}
chunks.push(chunk);
}
}
return chunks;
},
text () {
return this.sample(this.properties, this.fields, this.values);
},
html () {
return this.renderTextLine(this.text);
}
},
watch: {
},
methods: {
fieldValue (properties, field, values) {
let value;
if (field.item == "text") {
value = field.value;
} else if (properties[field.item]?.type == "boolean") {
if (values[field.item] === field.when) {
value = field.value;
}
} else {
value = values[field.item];
}
if (value != null) {
if (properties[field.item]?.type == "number") {
if (field.scale_multiplier != null) {
value *= field.scale_multiplier;
}
if (field.scale_offset != null) {
value += field.scale_offset;
}
if (field.format == "integer") {
value = Math.round(value);
}
}
value = String(value);
if (field.pad_side == "left") {
value = value.padStart(field.length, field.pad_string ?? " ");
} else if (field.pad_side == "right") {
value = value.padEnd(field.length, field.pad_string ?? " ");
}
return value;
}
},
sample (properties, fields, values, str = "") {
const length = fields.reduce( (acc, cur) => (cur.offset + cur.length) > acc ? (cur.offset + cur.length) : acc, str.length )
str = str.padEnd(length);
for (const field of fields) {
//console.log("FIELD", field);
const value = this.fieldValue(properties, field, values);
if (value != null) {
str = str.slice(0, field.offset) + value + str.slice(field.offset + field.length);
}
}
return str;
},
/** Return a `<span>` opening tag.
*/
style (name, colour) {
return colour
? `<span class="${name}" style="color:${colour};border-color:${colour}">`
: `<span class="${name}">`;
},
/** Return an array of the intervals that intersect `pos`.
* May be empty.
*/
chunksFor (pos) {
return this.chunks.filter( chunk =>
pos >= chunk.start &&
pos <= chunk.end
)
},
/*
* Algorithm:
*
* Go through every character of one line of text and determine in which
* part(s) it falls in, if any. Collect adjacent same parts into <span/>
* elements.
*/
renderTextLine (text) {
const parts = [];
let prevStyle;
for (const pos in text) {
const chunks = this.chunksFor(pos);
const isEmpty = chunks.length == 0;
const isOverlap = chunks.length > 1;
const isMismatch = chunks[0]?.text &&
(text.substring(chunks[0].start, chunks[0].end+1) != chunks[0].text);
const style = isEmpty
? this.style("chunk-empty")
: isMismatch
? this.style("chunk-mismatch", chunks[0].colour)
: isOverlap
? this.style("chunk-overlap")
: this.style("chunk-"+chunks[0].class, chunks[0].colour);
if (style != prevStyle) {
if (prevStyle) {
parts.push("</span>");
}
parts.push(style);
}
parts.push(text[pos]);
prevStyle = style;
}
if (parts.length) {
parts.push("</span>");
}
return parts.join("");
},
},
mounted () {
}
}
</script>

View File

@@ -0,0 +1,307 @@
<template>
<v-card flat elevation="0">
<v-card-title v-if="title">{{ title }}</v-card-title>
<v-card-subtitle v-if="subtitle">{{ subtitle }}</v-card-subtitle>
<v-card-text>
<v-form>
<!-- Sample text -->
<dougal-fixed-string-encoder-sample
:label="label"
:hint="hint"
:properties="properties"
:fields="fields"
:values.sync="values"
></dougal-fixed-string-encoder-sample>
<!-- Fields -->
<v-container>
<v-row no-gutters class="mb-2">
<h4>Fields</h4>
</v-row>
<dougal-fixed-string-encoder-field v-for="(field, key) in fields" :key="key"
v-model="fields[key]"
:properties="properties"
:colour="getHSLColourFor(field.item)"
:readonly="readonly"
>
<template v-slot:append v-if="editableFieldList && !readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this field"
>
<v-icon
color="error"
@click="removeField(key)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-encoder-field>
<v-row no-gutters class="mb-2" v-if="editableFieldList && !readonly">
<h4>Add new field</h4>
</v-row>
<dougal-fixed-string-encoder-field v-if="editableFieldList && !readonly"
v-model="newField"
:properties="properties"
:colour="getHSLColourFor(newField.item)"
>
<template v-slot:prepend>
<v-btn v-if="isFieldDirty(newField)"
top
text
small
title="Reset"
>
<v-icon
color="warning"
@click="resetField(newField)"
>mdi-backspace-reverse-outline</v-icon>
</v-btn>
</template>
<template v-slot:append>
<v-btn
class="ml-3"
fab
text
small
title="Add field"
:disabled="isFieldValid(newField) !== true"
>
<v-icon
color="primary"
@click="addField(newField)"
>mdi-plus</v-icon>
</v-btn>
</template>
</dougal-fixed-string-encoder-field>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<style scoped>
.input {
flex: 1 1 auto;
line-height: 20px;
padding: 8px 0 8px;
min-height: 32px;
max-height: 32px;
max-width: 100%;
min-width: 0px;
width: 100%;
}
.input, .multiline >>> .chunk-field {
padding-inline: 1px;
border: 1px solid;
}
.input, .multiline >>> .chunk-fixed {
padding-inline: 1px;
border: 1px dashed;
}
.input, .multiline >>> .chunk-empty {
padding-inline: 1px;
}
.input, .multiline >>> .chunk-overlap {
padding-inline: 1px;
border: 1px solid grey;
color: grey;
}
.input >>> .chunk-mismatch {
padding-inline: 1px;
border: 2px solid red !important;
}
</style>
<script>
import { getHSLColourFor } from '@/lib/hsl'
import DougalFixedStringEncoderField from './fixed-string-encoder-field'
import DougalFixedStringEncoderSample from './fixed-string-encoder-sample'
export default {
name: "DougalFixedStringEncoder",
components: {
DougalFixedStringEncoderField,
DougalFixedStringEncoderSample
},
mixins: [
{
methods: {
getHSLColourFor
}
}
],
props: {
properties: { type: Object },
fields: { type: Array },
values: { type: Object },
editableFieldList: { type: Boolean, default: true },
readonly: Boolean,
title: String,
subtitle: String,
label: String,
hint: String,
},
data () {
return {
//< The reason for not using this.text directly is that at some point
//< we might extend this component to allow editing the sample text.
text_: "",
//< The value of a fixed string that should be always present at a specific position
fixedName: "",
fixedOffset: 0,
//< The name of a new field to add.
fieldName: "",
newField: {
item: null,
when: null,
offset: null,
length: null,
value: null,
pad_side: null,
pad_string: null
}
}
},
computed: {
chunks () {
const properties = this.properties;
const fields = this.fields;
const values = this.values;
const str = "";
const chunks = [];
for (const field of fields) {
//console.log("FIELD", structuredClone(field));
//console.log("VALUES DATA", values[field.item]);
let value;
if (field.item == "text") {
value = field.value;
} else if (properties[field.item]?.type == "boolean") {
if (values[field.item] === field.when) {
value = field.value;
}
} else {
value = values[field.item];
}
if (value != null) {
value = String(value);
if (field.pad_side == "left") {
value = value.padStart(field.length, field.pad_string);
} else {
value = value.padEnd(field.length, field.pad_string);
}
const chunk = {
start: field.offset,
end: field.offset + field.length - 1,
colour: this.getHSLColourFor(field.item),
class: field.item == "text" ? "fixed" : "field",
text: value
}
//console.log("CHUNK", chunk);
chunks.push(chunk);
}
}
return chunks;
},
html () {
return this.renderTextLine(this.sample(this.properties, this.fields, this.values));
//return this.sample(this.properties, this.fields, this.values);
}
},
watch: {
},
methods: {
isFieldDirty (field) {
return Object.entries(field).reduce( (acc, cur) => cur[1] === null ? acc : true, false );
},
isFieldValid (field) {
if (!field.item) return "Missing item";
if (typeof field.offset !== "number" || field.offset < 0) return "Missing offset";
if (typeof field.length !== "number" || field.length < 1) return "Missing length";
if (!this.properties[field.item]) return "Unrecognised property";
if (this.properties[field.item].type == "text" && !field.value?.length) return "Missing value";
if (this.properties[field.item].type == "boolean" && !field.value?.length) return "Missing value (boolean)";
if(!!field.pad_side && !field.pad_string) return "Missing pad string";
return true;
},
resetField (field) {
field.item = null;
field.when = null;
field.offset = null;
field.length = null;
field.value = null;
field.pad_side = null;
field.pad_string = null;
return field;
},
addField (field) {
if (this.isFieldValid(field)) {
const fields = structuredClone(this.fields);
fields.push({...field});
this.resetField(field);
console.log("update:fields", fields);
this.$emit("update:fields", fields);
}
},
removeField (key) {
console.log("REMOVE", "update:fields", key, this.fields);
const fields = structuredClone(this.fields);
fields.splice(key, 1);
this.$emit("update:fields", fields);
},
},
mounted () {
}
}
</script>

View File

@@ -4,20 +4,16 @@
<v-card-subtitle>Line name decoding configuration for real-time data</v-card-subtitle> <v-card-subtitle>Line name decoding configuration for real-time data</v-card-subtitle>
<v-card-text> <v-card-text>
<v-form> <v-form>
<v-text-field <dougal-fixed-string-encoder
label="Example file name"
hint="Enter the name of a representative file to make it easier to visualise your configuration"
persistent-hint
v-model="cwo.lineNameInfo.example"
></v-text-field>
<dougal-fixed-string-decoder
title="Line name format" title="Line name format"
subtitle="Format of line names as configured in the navigation system" subtitle="Format of line names as configured in the navigation system"
label="Example line name"
hint="Visualise your line name configuration with example values"
:multiline="true" :multiline="true"
:text="cwo.lineNameInfo.example" :properties="properties"
:fields="cwo.lineNameInfo.fields" :fields.sync="fields_"
></dougal-fixed-string-decoder> :values.sync="values_"
></dougal-fixed-string-encoder>
</v-form> </v-form>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
@@ -50,16 +46,19 @@
<script> <script>
import { deepSet } from '@/lib/utils'; import { deepSet } from '@/lib/utils';
import DougalFixedStringDecoder from '@/components/decoder/fixed-string-decoder'; import DougalFixedStringEncoder from '@/components/encoder/fixed-string-encoder';
export default { export default {
name: "DougalProjectSettingsOnlineLineNameFormat", name: "DougalProjectSettingsOnlineLineNameFormat",
components: { components: {
DougalFixedStringDecoder DougalFixedStringEncoder
}, },
props: { props: {
fields: Array,
values: Object,
properties: Object,
value: Object value: Object
}, },
@@ -68,53 +67,27 @@ export default {
} }
}, },
watch: {
},
computed: { computed: {
// Current working object.
// A shortcut so we don't have to specify the full path
// on every input control. It also makes it easier to
// change that path if necessary. Finally, it ensures that
// the properties being modified are always available.
cwo: {
fields_: {
get () { get () {
if (this.value) { return this.fields;
if (!this.value?.online?.line) {
deepSet(this.value, [ "online", "line" ], {
lineNameInfo: {
example: "",
fields: {
line: {
length: 4,
type: "int"
}, },
sequence: {
length: 3,
type: "int"
},
incr: {
length: 1,
type: "bool"
},
attempt: {
length: 1,
type: "int"
}
}
}
});
}
return this.value.online.line;
} else {
return {};
}
},
set (v) { set (v) {
if (this.value) { this.$emit("update", structuredClone({values: this.values, fields: v}));
deepSet(this.value, [ "online", "line" ], v);
}
} }
},
values_: {
get () {
return this.values;
},
set (v) {
this.$emit("update", structuredClone({values: v, fields: this.fields}));
}
} }
}, },

View File

@@ -67,6 +67,7 @@
:is="activeComponent" :is="activeComponent"
v-bind="activeValues" v-bind="activeValues"
v-model.sync="configuration" v-model.sync="configuration"
@update="activeUpdateHandler"
@close="deselect" @close="deselect"
></component> ></component>
</v-col> </v-col>
@@ -279,6 +280,7 @@ export default {
data () { data () {
return { return {
configuration: null, configuration: null,
settings: null,
active: [], active: [],
open: [], open: [],
files: [], files: [],
@@ -420,8 +422,17 @@ export default {
id: "line_name_format", id: "line_name_format",
name: "Line name format", name: "Line name format",
values: (obj) => ({ values: (obj) => ({
lineNameInfo: obj?.online?.line?.lineNameInfo fields: this.makeSection(obj, "online.line.lineNameBuilder.fields", []),
}) values: this.makeSection(obj, "online.line.lineNameBuilder.values",
Object.fromEntries(
Object.keys(this.settings.lineNameBuilder.properties ?? {}).map( k => [k, undefined] ))),
properties: this.settings.lineNameBuilder.properties ?? {}
}),
update: (obj) => {
const configuration = structuredClone(this.configuration);
deepSet(configuration, ["online", "line", "lineNameBuilder"], obj);
this.configuration = configuration;
}
}, },
{ {
id: "planner_settings", id: "planner_settings",
@@ -518,6 +529,12 @@ export default {
this.activeItem.values(this.configuration); this.activeItem.values(this.configuration);
}, },
activeUpdateHandler () {
return this.activeItem?.update ?? ((obj) => {
console.warn("Unhandled update event on", this.activeItem?.id, obj);
})
},
surveyState: { surveyState: {
get () { get () {
return !this.configuration?.archived; return !this.configuration?.archived;
@@ -559,6 +576,35 @@ export default {
methods: { methods: {
makeSection (obj, path, defaultVaue) {
function reduced (obj = {}, path = []) {
return path.reduce( (acc, cur) =>
{
if (!(cur in acc)) {
acc[cur] = {} ;
};
return acc[cur]
}, obj);
}
if (!obj) {
obj = {};
}
if (typeof path == "string") {
path = path.split(".");
}
let value = reduced(obj, path);
const key = path.pop();
if (!Object.keys(value ?? {}).length && defaultVaue !== undefined) {
reduced(obj, path)[key] = defaultVaue;
}
return reduced(obj, path)[key];
},
async getConfiguration () { async getConfiguration () {
this.configuration = null; this.configuration = null;
const url = `/project/${this.$route.params.project}/configuration`; const url = `/project/${this.$route.params.project}/configuration`;
@@ -571,6 +617,21 @@ export default {
this.dirty = false; this.dirty = false;
}, },
async getSettings () {
this.settings = null;
let url = `/project/${this.$route.params.project}/linename/properties`;
const init = {
headers: {
"If-None-Match": "" // Ensure we get a fresh response
}
};
this.settings = {
lineNameBuilder: {
properties: await this.api([url, init])
}
};
},
makeTree (obj, id=0) { makeTree (obj, id=0) {
const isObject = typeof obj === "object" && !Array.isArray(obj) && obj !== null; const isObject = typeof obj === "object" && !Array.isArray(obj) && obj !== null;
return isObject return isObject
@@ -681,6 +742,7 @@ export default {
}, },
async mounted () { async mounted () {
this.getSettings();
this.getConfiguration(); this.getConfiguration();
}, },