diff --git a/packages/server-admin-ui/src/store/slices/dataSlice.ts b/packages/server-admin-ui/src/store/slices/dataSlice.ts index d0186eaea..7b4972cb2 100644 --- a/packages/server-admin-ui/src/store/slices/dataSlice.ts +++ b/packages/server-admin-ui/src/store/slices/dataSlice.ts @@ -95,7 +95,7 @@ export const createDataSlice: StateCreator = ( const newContextMeta = { ...contextMeta, - [path]: metaData as MetaData + [path]: { ...contextMeta[path], ...metaData } as MetaData } return { diff --git a/packages/server-admin-ui/src/utils/unitConversion.ts b/packages/server-admin-ui/src/utils/unitConversion.ts index 636e2686a..e5492e0b6 100644 --- a/packages/server-admin-ui/src/utils/unitConversion.ts +++ b/packages/server-admin-ui/src/utils/unitConversion.ts @@ -46,7 +46,8 @@ export function convertValue( siUnit: string, category: string, presetDetails: PresetDetails | null, - unitDefinitions: UnitDefinitions | null + unitDefinitions: UnitDefinitions | null, + displayUnits?: { targetUnit?: string; formula?: string; symbol?: string } ): ConvertedValue | null { if (typeof value !== 'number' || !category) { return null @@ -55,6 +56,19 @@ export function convertValue( if (category === 'base' && siUnit) { return { value, unit: siUnit } } + // "custom" category uses explicitly stored formula/targetUnit + if (category === 'custom' && displayUnits?.formula) { + try { + const compiled = getCompiledFormula(displayUnits.formula) + const converted = compiled.evaluate({ value }) + return { + value: converted, + unit: displayUnits.symbol || displayUnits.targetUnit || '' + } + } catch { + return null + } + } if (!presetDetails || !unitDefinitions) { return null } diff --git a/packages/server-admin-ui/src/views/DataBrowser/DataRow.tsx b/packages/server-admin-ui/src/views/DataBrowser/DataRow.tsx index 7085a97b1..12c462b32 100644 --- a/packages/server-admin-ui/src/views/DataBrowser/DataRow.tsx +++ b/packages/server-admin-ui/src/views/DataBrowser/DataRow.tsx @@ -130,6 +130,16 @@ function DataRow({ category = findCategoryForPath(data.path, defaultCategories) ?? undefined } + const displayUnits = + (meta as Record | null)?.displayUnits && + typeof (meta as Record).displayUnits === 'object' + ? ((meta as Record).displayUnits as { + targetUnit?: string + formula?: string + symbol?: string + }) + : undefined + let convertedValue: number | null = null let convertedUnit: string | null = null if (category && typeof data.value === 'number') { @@ -138,7 +148,8 @@ function DataRow({ units, category, presetDetails, - unitDefinitions + unitDefinitions, + displayUnits ) if (converted && converted.unit !== units) { convertedValue = converted.value diff --git a/packages/server-admin-ui/src/views/DataBrowser/Meta.tsx b/packages/server-admin-ui/src/views/DataBrowser/Meta.tsx index 7fe029988..41dbe124d 100644 --- a/packages/server-admin-ui/src/views/DataBrowser/Meta.tsx +++ b/packages/server-admin-ui/src/views/DataBrowser/Meta.tsx @@ -95,6 +95,7 @@ interface MetaFormRowProps { idPrefix: string categories?: string[] siUnit?: string + unitDefinitions?: UnitDefinitions | null description?: string } @@ -203,6 +204,7 @@ const CATEGORY_BADGE_COLORS: Record = { interface CategorySelectProps extends ValueRenderProps { categories?: string[] siUnit?: string + unitDefinitions?: UnitDefinitions | null } const CategorySelect: React.FC = ({ @@ -211,28 +213,65 @@ const CategorySelect: React.FC = ({ setValue, inputId, categories, - siUnit + siUnit, + unitDefinitions }) => { const displayUnits = value as DisplayUnits | undefined const category = displayUnits?.category || '' const categoryList = categories !== undefined ? categories : DEFAULT_CATEGORIES + const conversions = + siUnit && unitDefinitions ? unitDefinitions[siUnit]?.conversions : undefined return ( - setValue({ category: e.target.value })} - > - - {siUnit && } - {categoryList.map((cat) => ( - - ))} - + <> + setValue({ category: e.target.value })} + > + + {siUnit && } + {conversions && } + {categoryList.map((cat) => ( + + ))} + + {category === 'custom' && conversions && ( + { + const targetUnit = e.target.value + if (!targetUnit) { + setValue({ category: 'custom' }) + return + } + const conv = conversions[targetUnit] + setValue({ + category: 'custom', + targetUnit, + formula: conv?.formula, + inverseFormula: conv?.inverseFormula, + symbol: conv?.symbol || targetUnit + }) + }} + > + + {Object.entries(conversions).map(([unit, conv]) => ( + + ))} + + )} + ) } @@ -453,7 +492,7 @@ const DisplaySelect: React.FC = ({ } const DisplayUnitsView: React.FC = (props) => { - const { disabled, categories, siUnit } = props + const { disabled, categories, siUnit, unitDefinitions } = props if (disabled) { const displayUnits = props.value as DisplayUnits | undefined if (!displayUnits || !displayUnits.category) { @@ -480,7 +519,14 @@ const DisplayUnitsView: React.FC = (props) => { ) } - return + return ( + + ) } const METAFIELDRENDERERS: Record< @@ -510,9 +556,14 @@ const METAFIELDRENDERERS: Record< {...p} categories={props.categories} siUnit={props.siUnit} + unitDefinitions={props.unitDefinitions} /> )} - description="Category for unit conversion" + description={ + (props.value as DisplayUnits)?.category !== 'custom' + ? 'Target category for unit conversion' + : '' + } /> ), zones: () => <>, @@ -599,7 +650,8 @@ const Meta: React.FC = ({ meta, path, context }) => { siUnit, category, presetDetails, - unitDefinitions + unitDefinitions, + localMeta.displayUnits ) const handleEdit = () => { @@ -771,6 +823,7 @@ const Meta: React.FC = ({ meta, path, context }) => { disabled: !isEditing, categories: filteredCategories, siUnit, + unitDefinitions, setValue: (metaFieldValue) => setLocalMeta({ ...localMeta, [key]: metaFieldValue }), setKey: (metaFieldKey) => { diff --git a/src/interfaces/ws.ts b/src/interfaces/ws.ts index 31b9eefdd..1fffc9b5f 100644 --- a/src/interfaces/ws.ts +++ b/src/interfaces/ws.ts @@ -41,7 +41,11 @@ import { buildFlushDeltas } from '../LatestValuesAccumulator' import { getExternalPort } from '../ports' -import { resolveDisplayUnits, getDefaultCategory } from '../unitpreferences' +import { + resolveDisplayUnits, + getDefaultCategory, + DisplayUnitsMetadata +} from '../unitpreferences' import { Delta, hasValues } from '@signalk/server-api' const debug = createDebug('signalk-server:interfaces:ws') @@ -900,15 +904,18 @@ function handleValuesMeta( break } else { this.spark.sentMetaData[partialContextPathKey] = true - let meta = getMetadata(partialContextPathKey) as Record< + const meta = getMetadata(partialContextPathKey) as Record< string, unknown > | null if (meta) { // Clone and enhance metadata with displayUnits formulas - meta = JSON.parse(JSON.stringify(meta)) - let storedDisplayUnits = (meta as Record) - .displayUnits as { category?: string } | undefined + const metaClone: Record = JSON.parse( + JSON.stringify(meta) + ) + let storedDisplayUnits = metaClone.displayUnits as + | Record + | undefined if (!storedDisplayUnits?.category && path) { const defaultCategory = getDefaultCategory(path) if (defaultCategory) { @@ -918,12 +925,19 @@ function handleValuesMeta( if (storedDisplayUnits?.category) { const username = this.spark.request.skPrincipal?.identifier const enhanced = resolveDisplayUnits( - { category: storedDisplayUnits.category }, - (meta as Record).units as string | undefined, + storedDisplayUnits as { + category: string + targetUnit?: string + formula?: string + inverseFormula?: string + symbol?: string + displayFormat?: string + }, + metaClone.units as string | undefined, username ) if (enhanced) { - ;(meta as Record).displayUnits = enhanced + metaClone.displayUnits = enhanced } } this.spark.write({ @@ -934,7 +948,7 @@ function handleValuesMeta( meta: [ { path: path, - value: meta + value: metaClone } ] } @@ -1157,12 +1171,13 @@ function handleRealtimeConnection( const fullPath = 'vessels.self.' + path const pathMeta = (getMetadata(fullPath) as Record) || {} - const category = - (pathMeta.displayUnits as { category?: string } | undefined) - ?.category || getDefaultCategory(path) + const storedDU = pathMeta.displayUnits as + | DisplayUnitsMetadata + | undefined + const category = storedDU?.category || getDefaultCategory(path) if (category) { const displayUnits = resolveDisplayUnits( - { category }, + { ...storedDU, category }, pathMeta.units as string | undefined, username ) diff --git a/src/unitpreferences/resolver.ts b/src/unitpreferences/resolver.ts index cf992d4a4..3a379c1e2 100644 --- a/src/unitpreferences/resolver.ts +++ b/src/unitpreferences/resolver.ts @@ -37,6 +37,41 @@ export function resolveDisplayUnits( } } + // "custom" category stores explicit conversion info + if (category === 'custom') { + if (!storedDisplayUnits.targetUnit) { + return null + } + // If formula is stored, use it directly + if (storedDisplayUnits.formula) { + return { + category: 'custom', + targetUnit: storedDisplayUnits.targetUnit, + formula: storedDisplayUnits.formula, + inverseFormula: storedDisplayUnits.inverseFormula || '', + symbol: storedDisplayUnits.symbol || storedDisplayUnits.targetUnit, + displayFormat: storedDisplayUnits.displayFormat + } + } + // Otherwise look up from definitions using pathSiUnit + if (pathSiUnit) { + const definitions = getMergedDefinitions() + const conversion = + definitions[pathSiUnit]?.conversions?.[storedDisplayUnits.targetUnit] + if (conversion) { + return { + category: 'custom', + targetUnit: storedDisplayUnits.targetUnit, + formula: conversion.formula, + inverseFormula: conversion.inverseFormula, + symbol: conversion.symbol || storedDisplayUnits.targetUnit, + displayFormat: storedDisplayUnits.displayFormat + } + } + } + return null + } + const categoriesData = getCategories() const definitions = getMergedDefinitions() const preset = username ? getActivePresetForUser(username) : getActivePreset() @@ -98,6 +133,11 @@ export function validateCategoryAssignment( return null } + // "custom" category is always valid - user picks an explicit target unit + if (category === 'custom') { + return null + } + const categoriesData = getCategories() const preset = getActivePreset() diff --git a/src/unitpreferences/types.ts b/src/unitpreferences/types.ts index 08c763fa5..0a3130fc5 100644 --- a/src/unitpreferences/types.ts +++ b/src/unitpreferences/types.ts @@ -51,6 +51,9 @@ export interface UnitPreferencesConfig { export interface DisplayUnitsMetadata { category: string targetUnit?: string // Only if path override + formula?: string // Only if custom category + inverseFormula?: string // Only if custom category + symbol?: string // Only if custom category displayFormat?: string // Only if path override }