From 35c3b5fc1b97940e4010042f806350c554642c56 Mon Sep 17 00:00:00 2001 From: Emil Gunnarsson Date: Wed, 15 Oct 2025 14:32:28 +0200 Subject: [PATCH] feat: format metadata floats with significant digits --- src/app/app-config.service.spec.ts | 6 + src/app/app-config.service.ts | 16 +++ .../tree-view/tree-view.component.spec.ts | 29 ++++- .../tree-view/tree-view.component.ts | 9 +- .../metadata-view.component.spec.ts | 27 +++++ .../metadata-view/metadata-view.component.ts | 13 ++- .../services/metadata-value.service.spec.ts | 105 ++++++++++++++++++ .../shared/services/metadata-value.service.ts | 47 ++++++++ 8 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 src/app/shared/services/metadata-value.service.spec.ts create mode 100644 src/app/shared/services/metadata-value.service.ts diff --git a/src/app/app-config.service.spec.ts b/src/app/app-config.service.spec.ts index 8ca39f2618..176766a0d0 100644 --- a/src/app/app-config.service.spec.ts +++ b/src/app/app-config.service.spec.ts @@ -50,6 +50,12 @@ const appConfig: AppConfigInterface = { thumbnailFetchLimitPerPage: 500, maxFileUploadSizeInMb: "16mb", maxDirectDownloadSize: 5000000000, + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, metadataPreviewEnabled: true, metadataStructure: "", multipleDownloadAction: "http://localhost:3012/zip", diff --git a/src/app/app-config.service.ts b/src/app/app-config.service.ts index e20f86898b..69e9eab9b9 100644 --- a/src/app/app-config.service.ts +++ b/src/app/app-config.service.ts @@ -64,6 +64,12 @@ export class MainMenuConfiguration { authenticatedUser: MainMenuOptions; } +export class MetadataFloatFormat { + significantDigits: number; + minCutoff: number; // using scientific notation below this cutoff + maxCutoff: number; // using scientific notation above this cutoff +} + export interface AppConfigInterface { skipSciCatLoginPageEnabled?: boolean; accessTokenPrefix: string; @@ -102,6 +108,8 @@ export interface AppConfigInterface { maxDirectDownloadSize: number | null; metadataPreviewEnabled: boolean; metadataStructure: string; + metadataFloatFormat?: MetadataFloatFormat; + metadataFloatFormatEnabled?: boolean; multipleDownloadAction: string | null; multipleDownloadEnabled: boolean; multipleDownloadUseAuthToken: boolean; @@ -232,6 +240,14 @@ export class AppConfigService { config.dateFormat = "yyyy-MM-dd HH:mm"; } + if (!config.metadataFloatFormat) { + config.metadataFloatFormat = { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }; + } + this.appConfig = config; } diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts index d0f99b062b..483b402892 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.spec.ts @@ -3,23 +3,42 @@ import { waitForAsync, ComponentFixture, TestBed } from "@angular/core/testing"; import { FormatNumberPipe } from "shared/pipes/format-number.pipe"; import { PrettyUnitPipe } from "shared/pipes/pretty-unit.pipe"; import { ScientificMetadataTreeModule } from "../scientific-metadata-tree.module"; - import { TreeViewComponent } from "./tree-view.component"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { AppConfigService } from "app-config.service"; +import { MetadataValueService } from "shared/services/metadata-value.service"; +import { provideHttpClient } from "@angular/common/http"; describe("TreeViewComponent", () => { let component: TreeViewComponent; let fixture: ComponentFixture; beforeEach(waitForAsync(() => { + TestBed.resetTestingModule(); TestBed.configureTestingModule({ declarations: [TreeViewComponent], imports: [ScientificMetadataTreeModule, BrowserAnimationsModule], - providers: [DatePipe, PrettyUnitPipe, FormatNumberPipe], + providers: [ + DatePipe, + PrettyUnitPipe, + FormatNumberPipe, + MetadataValueService, + AppConfigService, + provideHttpClient(), + ], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + (appConfigService as any).appConfig = { + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }; fixture = TestBed.createComponent(TreeViewComponent); component = fixture.componentInstance; component.metadata = { @@ -39,4 +58,10 @@ describe("TreeViewComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("should format flatNode.value when float formatting is enabled", () => { + const nodes = component.treeControl.dataNodes; + const focusNode = nodes.find((node) => node.key === "focus"); + expect(focusNode.value).toBe("-0.272"); + }); }); diff --git a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts index ab0c9e647c..10cfe7bae5 100644 --- a/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts +++ b/src/app/shared/modules/scientific-metadata-tree/tree-view/tree-view.component.ts @@ -16,6 +16,7 @@ import { TreeNode, } from "shared/modules/scientific-metadata-tree/base-classes/tree-base"; import { DatePipe } from "@angular/common"; +import { MetadataValueService } from "shared/services/metadata-value.service"; @Component({ selector: "tree-view", templateUrl: "./tree-view.component.html", @@ -27,9 +28,11 @@ export class TreeViewComponent implements OnInit, OnChanges { @Input() metadata: any; - constructor(datePipe: DatePipe) { + constructor( + public datePipe: DatePipe, + private metadataValueService: MetadataValueService, + ) { super(); - this.datePipe = datePipe; this.treeFlattener = new MatTreeFlattener( this.transformer, this.getLevel, @@ -69,7 +72,7 @@ export class TreeViewComponent : new FlatNode(); flatNode.key = node.key; flatNode.level = level; - flatNode.value = node.value; + flatNode.value = this.metadataValueService.valueFormat(node.value); flatNode.unit = node.unit; flatNode.expandable = node.children?.length > 0; flatNode.visible = true; diff --git a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts index 657cfc1902..fe26c48470 100644 --- a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts +++ b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.spec.ts @@ -8,6 +8,8 @@ import { ReplaceUnderscorePipe } from "shared/pipes/replace-underscore.pipe"; import { LinkyPipe } from "ngx-linky"; import { DatePipe, TitleCasePipe } from "@angular/common"; import { PrettyUnitPipe } from "shared/pipes/pretty-unit.pipe"; +import { AppConfigService } from "app-config.service"; +import { provideHttpClient } from "@angular/common/http"; describe("MetadataViewComponent", () => { let component: MetadataViewComponent; @@ -23,12 +25,24 @@ describe("MetadataViewComponent", () => { DatePipe, LinkyPipe, PrettyUnitPipe, + AppConfigService, + provideHttpClient(), ], declarations: [MetadataViewComponent], }).compileComponents(); })); beforeEach(() => { + const appConfigService = TestBed.inject(AppConfigService); + spyOn(appConfigService as any, "getConfig").and.returnValue({ + metadataFloatFormatEnabled: true, + metadataFloatFormat: { + significantDigits: 3, + minCutoff: 0.001, + maxCutoff: 1000, + }, + }); + fixture = TestBed.createComponent(MetadataViewComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -53,6 +67,19 @@ describe("MetadataViewComponent", () => { expect(metadataArray[0]["unit"]).toEqual(""); }); + it("should round float value if float formatting enabled", () => { + const testMetadata = { + someMetadata: { + value: 12.39421321511, + unit: "m", + }, + }; + const metadataArray = component.createMetadataArray(testMetadata); + + expect(metadataArray[0]["value"]).toEqual("12.4"); + expect(metadataArray[0]["unit"]).toEqual("m"); + }); + it("should parse an untyped metadata object to an array", () => { const testMetadata = { untypedTestName: { diff --git a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts index f1bb4b7670..d9b2aef651 100644 --- a/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts +++ b/src/app/shared/modules/scientific-metadata/metadata-view/metadata-view.component.ts @@ -24,6 +24,7 @@ import { DateTime } from "luxon"; import { MetadataTypes } from "../metadata-edit/metadata-edit.component"; import { actionMenu } from "shared/modules/dynamic-material-table/utilizes/default-table-settings"; import { TablePaginationMode } from "shared/modules/dynamic-material-table/models/table-pagination.model"; +import { MetadataValueService } from "shared/services/metadata-value.service"; @Component({ selector: "metadata-view", @@ -173,6 +174,7 @@ export class MetadataViewComponent implements OnInit, OnChanges { private replaceUnderscore: ReplaceUnderscorePipe, private titleCase: TitleCasePipe, private datePipe: DatePipe, + private metadataValueService: MetadataValueService, public linkyPipe: LinkyPipe, public prettyUnit: PrettyUnitPipe, ) {} @@ -194,9 +196,13 @@ export class MetadataViewComponent implements OnInit, OnChanges { typeof metadata[key] === "object" && "value" in (metadata[key] as ScientificMetadata) ) { + const formattedValue = this.metadataValueService.valueFormat( + metadata[key]["value"], + ); + metadataObject = { name: key, - value: metadata[key]["value"], + value: formattedValue, unit: metadata[key]["unit"], human_name: humanReadableName, type: metadata[key]["type"], @@ -215,9 +221,12 @@ export class MetadataViewComponent implements OnInit, OnChanges { ? metadata[key] : JSON.stringify(metadata[key]); + const formattedValue = + this.metadataValueService.valueFormat(metadataValue); + metadataObject = { name: key, - value: metadataValue, + value: formattedValue, unit: "", human_name: humanReadableName, type: metadata[key]["type"], diff --git a/src/app/shared/services/metadata-value.service.spec.ts b/src/app/shared/services/metadata-value.service.spec.ts new file mode 100644 index 0000000000..82625b9c9e --- /dev/null +++ b/src/app/shared/services/metadata-value.service.spec.ts @@ -0,0 +1,105 @@ +import { TestBed } from "@angular/core/testing"; +import { MetadataValueService } from "./metadata-value.service"; +import { AppConfigInterface, AppConfigService } from "app-config.service"; + +describe("MetadataValueService", () => { + let service: MetadataValueService; + let mockConfigService: jasmine.SpyObj; + + beforeEach(() => { + mockConfigService = jasmine.createSpyObj("AppConfigService", ["getConfig"]); + + TestBed.configureTestingModule({ + providers: [ + MetadataValueService, + { provide: AppConfigService, useValue: mockConfigService }, + ], + }); + }); + + function setConfig( + options?: Partial<{ + enabled: boolean; + significantDigits: number; + minCutoff: number; + maxCutoff: number; + }>, + ) { + const mockConfig: Partial = { + metadataFloatFormatEnabled: options?.enabled ?? true, + metadataFloatFormat: { + significantDigits: options?.significantDigits ?? 3, + minCutoff: options?.minCutoff ?? 0.001, + maxCutoff: options?.maxCutoff ?? 1000, + }, + }; + + mockConfigService.getConfig.and.returnValue( + mockConfig as AppConfigInterface, + ); + } + + it("should not format float if not enabled", () => { + setConfig({ enabled: false }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(1.23456789)).toBe("1.23456789"); + }); + + it("should return string as-is for non-number values", () => { + setConfig(); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat("abc")).toBe("abc"); + expect(service.valueFormat("")).toBe(""); + }); + + it("should return string representation for non-finite numbers", () => { + setConfig(); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(NaN)).toBe("NaN"); + expect(service.valueFormat(Infinity)).toBe("Infinity"); + expect(service.valueFormat(-Infinity)).toBe("-Infinity"); + }); + + it("should return string representation for integers", () => { + setConfig(); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(42)).toBe("42"); + expect(service.valueFormat(-10)).toBe("-10"); + }); + + it("should omit decimals when significant digits fit within the integer part", () => { + setConfig({ significantDigits: 2 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(12.3456)).toBe("12"); + }); + + it("should use exponential notation for very small numbers", () => { + setConfig({ significantDigits: 3, minCutoff: 0.001 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(0.0000123)).toBe("1.23e-5"); + }); + + it("should use exponential notation for very large numbers", () => { + setConfig({ significantDigits: 4, maxCutoff: 1e6 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(50004313.487)).toBe("5.000e+7"); + }); + + it("should handle values just above minCutoff correctly", () => { + setConfig({ minCutoff: 0.001, significantDigits: 4 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(0.0023456)).toBe("0.002346"); + }); + + it("should handle values just below maxCutoff correctly", () => { + setConfig({ maxCutoff: 1e6, significantDigits: 3 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(999999.99)).toBe("1.00e+6"); + }); + + it("should respect significantDigits when using exponential notation", () => { + setConfig({ significantDigits: 5, minCutoff: 1e-3 }); + service = TestBed.inject(MetadataValueService); + expect(service.valueFormat(0.0000123)).toBe("1.2300e-5"); + }); +}); diff --git a/src/app/shared/services/metadata-value.service.ts b/src/app/shared/services/metadata-value.service.ts new file mode 100644 index 0000000000..df0b96e475 --- /dev/null +++ b/src/app/shared/services/metadata-value.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from "@angular/core"; +import { AppConfigService } from "app-config.service"; + +@Injectable({ + providedIn: "root", +}) +export class MetadataValueService { + private enabled: boolean; + private significantDigits: number; + private minCutoff: number; + private maxCutoff: number; + + constructor(private configService: AppConfigService) { + const config = this.configService.getConfig(); + this.enabled = config.metadataFloatFormatEnabled ?? false; + if (this.enabled) { + const metadataFloatFormat = config.metadataFloatFormat; + this.significantDigits = metadataFloatFormat.significantDigits; + this.minCutoff = metadataFloatFormat.minCutoff; + this.maxCutoff = metadataFloatFormat.maxCutoff; + } + } + + valueFormat(value: string | number): string { + if (!this.enabled) { + return String(value); + } + if (typeof value !== "number" || !Number.isFinite(value)) { + // value is not a finite number + return String(value); + } + + // Do not format integers + if (Number.isInteger(value)) { + return String(value); + } + + // use scientific notation if float value is large or small + const absoluteValue = Math.abs(value); + if (absoluteValue < this.minCutoff || absoluteValue > this.maxCutoff) { + // use scientific notation with (significantDigits - 1) decimals + return value.toExponential(this.significantDigits - 1); + } + + return value.toPrecision(this.significantDigits); + } +}