diff --git a/package.json b/package.json index bdfac42..9b5ee86 100644 --- a/package.json +++ b/package.json @@ -37,27 +37,29 @@ "homepage": "https://github.com/cheminfo/spectra-processor#readme", "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@types/node": "^24.10.2", - "@vitest/coverage-v8": "^4.0.15", + "@types/chroma-js": "^3.1.2", + "@types/node": "^25.0.3", + "@types/object-hash": "^3.0.6", + "@vitest/coverage-v8": "^4.0.16", "@zakodium/tsconfig": "^1.0.2", - "cheminfo-build": "^1.3.1", - "cheminfo-types": "^1.8.1", + "cheminfo-build": "^1.3.2", + "cheminfo-types": "^1.10.0", "codecov": "^3.8.3", - "eslint": "^9.39.1", + "eslint": "^9.39.2", "eslint-config-cheminfo-typescript": "^21.0.1", "jest-matcher-deep-close-to": "^3.0.2", "prettier": "^3.7.4", "rimraf": "^6.1.2", - "vitest": "^4.0.15" + "vitest": "^4.0.16" }, "dependencies": { "chroma-js": "^3.2.0", "is-any-array": "^2.0.1", - "jcampconverter": "^12.0.3", + "jcampconverter": "^12.1.0", "ml-matrix": "^6.12.1", "ml-pca": "^4.1.1", "ml-signal-processing": "^2.1.0", - "ml-spectra-processing": "^14.18.1", + "ml-spectra-processing": "^14.18.2", "object-hash": "^3.0.0", "xy-parser": "^5.0.5" } diff --git a/src/Kinds.js b/src/Kinds.js deleted file mode 100644 index b0f92e3..0000000 --- a/src/Kinds.js +++ /dev/null @@ -1,52 +0,0 @@ -export function getJcampKind(data) { - let dataType = data.dataType.toLowerCase(); - let yUnits = data.spectra[0].yUnits.toLowerCase(); - - if (dataType.match(/infrared/)) { - if (yUnits.match(/absorbance/)) { - return IR_ABSORBANCE; - } else { - return IR_TRANSMITTANCE; - } - } - return undefined; -} - -export const IR_TRANSMITTANCE = { - normalization: {}, - importation: { - converter: (transmittance) => -Math.log10(transmittance), - }, - kind: 'Infrared', - display: { - xLabel: 'wavelength [cm-1]', - xInverted: true, - yLabel: 'Absorbance', - }, -}; - -export const IR_ABSORBANCE = { - normalization: {}, - kind: 'Infrared', - display: { - xLabel: 'wavelength [cm-1]', - xInverted: true, - yLabel: 'Absorbance', - }, -}; - -export const nmr1H = { - display: { - xLabel: 'δ [ppm]', - xInverted: true, - yLabel: 'Intensity', - }, -}; - -export const nmr13C = { - display: { - xLabel: 'δ [ppm]', - xInverted: true, - yLabel: 'Intensity', - }, -}; diff --git a/src/Kinds.ts b/src/Kinds.ts new file mode 100644 index 0000000..d553ccb --- /dev/null +++ b/src/Kinds.ts @@ -0,0 +1,84 @@ +import type { Entry } from 'jcampconverter'; + +/** + * Display options for spectroscopy data + */ +export interface DisplayOptions { + xLabel: string; + xInverted: boolean; + yLabel: string; +} + +/** + * Kind object defining how to handle a spectrum + */ +export interface Kind { + normalization?: Record; + importation?: { + converter: (value: number) => number; + }; + kind?: string; + display: DisplayOptions; +} + +/** + * Determines the appropriate Kind based on JCAMP data + * @param data - JCAMP entry data + * @returns The appropriate Kind or undefined + */ +export function getJcampKind(data: Entry): Kind | undefined { + if (!data.dataType || !data.spectra?.[0]?.yUnits) { + return undefined; + } + + const dataType = data.dataType.toLowerCase(); + const yUnits = data.spectra[0].yUnits.toLowerCase(); + + if (dataType.match(/infrared/)) { + if (yUnits.match(/absorbance/)) { + return IR_ABSORBANCE; + } else { + return IR_TRANSMITTANCE; + } + } + return undefined; +} + +export const IR_TRANSMITTANCE: Kind = { + normalization: {}, + importation: { + converter: (transmittance: number) => -Math.log10(transmittance), + }, + kind: 'Infrared', + display: { + xLabel: 'wavelength [cm-1]', + xInverted: true, + yLabel: 'Absorbance', + }, +}; + +export const IR_ABSORBANCE: Kind = { + normalization: {}, + kind: 'Infrared', + display: { + xLabel: 'wavelength [cm-1]', + xInverted: true, + yLabel: 'Absorbance', + }, +}; + +export const nmr1H: Kind = { + display: { + xLabel: 'δ [ppm]', + xInverted: true, + yLabel: 'Intensity', + }, +}; + +export const nmr13C: Kind = { + display: { + xLabel: 'δ [ppm]', + xInverted: true, + yLabel: 'Intensity', + }, +}; diff --git a/src/SpectraProcessor.ts b/src/SpectraProcessor.ts new file mode 100644 index 0000000..c83b509 --- /dev/null +++ b/src/SpectraProcessor.ts @@ -0,0 +1,634 @@ +import type { + DataXMatrix, + DataXY, + DoubleArray, + DoubleMatrix, +} from 'cheminfo-types'; +import { xFindClosestIndex } from 'ml-spectra-processing'; + +import type { GetAutocorrelationChartOptions } from './jsgraph/getAutocorrelationChart.js'; +import { getAutocorrelationChart } from './jsgraph/getAutocorrelationChart.js'; +import type { GetBoxPlotChartOptions } from './jsgraph/getBoxPlotChart.js'; +import { getBoxPlotChart } from './jsgraph/getBoxPlotChart.js'; +import type { GetChartOptions } from './jsgraph/getChart.js'; +import { getChart } from './jsgraph/getChart.js'; +import type { NormalizationFilter } from './jsgraph/getNormalizationAnnotations.js'; +import { getNormalizationAnnotations } from './jsgraph/getNormalizationAnnotations.js'; +import type { GetNormalizedChartOptions } from './jsgraph/getNormalizedChart.js'; +import { getNormalizedChart } from './jsgraph/getNormalizedChart.js'; +import { getPostProcessedChart } from './jsgraph/getPostProcessedChart.js'; +import type { GetTrackAnnotationOptions } from './jsgraph/getTrackAnnotation.js'; +import { getTrackAnnotation } from './jsgraph/getTrackAnnotation.js'; +import type { GetCategoriesStatsOptions } from './metadata/getCategoriesStats.js'; +import { getCategoriesStats } from './metadata/getCategoriesStats.js'; +import type { GetClassLabelsOptions } from './metadata/getClassLabels.js'; +import { getClassLabels } from './metadata/getClassLabels.js'; +import type { GetClassesOptions } from './metadata/getClasses.js'; +import { getClasses } from './metadata/getClasses.js'; +import type { GetMetadataOptions } from './metadata/getMetadata.js'; +import { getMetadata } from './metadata/getMetadata.js'; +import parseJcamp from './parser/parseJcamp.js'; +import parseMatrix from './parser/parseMatrix.js'; +import type { ParseTextOptions } from './parser/parseText.js'; +import parseText from './parser/parseText.js'; +import type { AutocorrelationResult } from './spectra/getAutocorrelation.js'; +import { getAutocorrelation } from './spectra/getAutocorrelation.js'; +import { getBoxPlotData } from './spectra/getBoxPlotData.js'; +import { getMeanData } from './spectra/getMeanData.js'; +import type { GetNormalizedDataOptions } from './spectra/getNormalizedData.js'; +import { getNormalizedData } from './spectra/getNormalizedData.js'; +import type { GetNormalizedTextOptions } from './spectra/getNormalizedText.js'; +import { getNormalizedText } from './spectra/getNormalizedText.js'; +import type { GetPostProcessedDataOptions } from './spectra/getPostProcessedData.js'; +import { getPostProcessedData } from './spectra/getPostProcessedData.js'; +import type { GetPostProcessedTextOptions } from './spectra/getPostProcessedText.js'; +import { getPostProcessedText } from './spectra/getPostProcessedText.js'; +import type { AxisBoundary, MemoryStats } from './spectrum/Spectrum.js'; +import { Spectrum } from './spectrum/Spectrum.js'; + +export interface NormalizationOptions extends NormalizationFilter { + numberOfPoints?: number; + applyRangeSelectionFirst?: boolean; + filters?: Array<{ name: string; options?: any }>; +} + +export interface SpectraProcessorOptions { + /** + * Maximum memory to use for storing original data + * @default 256 * 1024 * 1024 (256MB) + */ + maxMemory?: number; + /** + * Options to normalize the spectra before comparison + */ + normalization?: NormalizationOptions; +} + +export interface AddFromDataOptions { + /** + * Metadata for the spectrum + */ + meta?: Record; + /** + * Spectrum identifier + */ + id?: string; + /** + * Normalization options + */ + normalization?: NormalizationOptions; + /** + * Pre-normalized data + */ + normalized?: DataXY; +} + +export interface AddFromTextOptions extends ParseTextOptions { + /** + * Metadata for the spectrum + */ + meta?: Record; + /** + * Spectrum identifier + */ + id?: string; + /** + * Replace existing spectrum with same ID + * @default false + */ + force?: boolean; +} + +export interface AddFromJcampOptions { + /** + * Metadata for the spectrum + */ + meta?: Record; + /** + * Spectrum identifier + */ + id?: string; + /** + * Replace existing spectrum with same ID + * @default false + */ + force?: boolean; +} + +export interface GetAutocorrelationOptions extends GetNormalizedDataOptions { + /** + * X value if index is undefined + */ + x?: number; +} + +export interface MinMaxX { + min: number; + max: number; +} + +/** + * Manager for a large number of spectra with the possibility to normalize the data + * and skip the original data. + */ +export class SpectraProcessor { + normalization?: NormalizationOptions; + maxMemory: number; + keepOriginal: boolean; + spectra: Spectrum[]; + + /** + * Create a SpectraProcessor + * @param options - Configuration options + */ + constructor(options: SpectraProcessorOptions = {}) { + this.normalization = options.normalization; + this.maxMemory = options.maxMemory || 256 * 1024 * 1024; + this.keepOriginal = true; + this.spectra = []; + } + + getNormalizationAnnotations() { + return getNormalizationAnnotations(this.normalization); + } + + /** + * Recalculate the normalized data using the stored original data if available + * This will throw an error if the original data is not present + * @param normalization - Normalization options + */ + setNormalization(normalization: NormalizationOptions = {}): void { + if (JSON.stringify(this.normalization) === JSON.stringify(normalization)) { + return; + } + this.normalization = normalization; + for (const spectrum of this.spectra) { + spectrum.updateNormalization(this.normalization); + } + } + + getNormalization(): NormalizationOptions | undefined { + return this.normalization; + } + + /** + * Returns an object {x:[], y:[]} containing the autocorrelation for the + * specified index + * @param index - X index of the spectrum to autocorrelate + * @param options - Options for autocorrelation + * @returns Autocorrelation result + */ + getAutocorrelation( + index: number | undefined, + options: GetAutocorrelationOptions = {}, + ): AutocorrelationResult { + const { x } = options; + const normalizedData = this.getNormalizedData(options); + + let actualIndex = index; + if (actualIndex === undefined && x !== undefined) { + actualIndex = xFindClosestIndex(normalizedData.x, x); + } else { + actualIndex = 0; + } + + return getAutocorrelation(normalizedData, actualIndex); + } + + /** + * Returns a {x:[], y:[]} containing the average of specified spectra + * @param options - Options for mean calculation + * @returns Mean data + */ + getMeanData(options?: GetNormalizedDataOptions): DataXY { + return getMeanData(this.getNormalizedData(options)); + } + + /** + * Returns an object containing 4 parameters with the normalized data + * @param options - Options for normalization + * @returns + */ + getNormalizedData( + options: GetNormalizedDataOptions = {}, + ): DataXMatrix { + const { ids } = options; + const spectra = this.getSpectra(ids); + return getNormalizedData(spectra); + } + + /** + * Returns a tab separated value containing the normalized data + * @param options - Options for export + * @returns Text string + */ + getNormalizedText(options: GetNormalizedTextOptions = {}): string { + const { ids } = options; + const spectra = this.getSpectra(ids); + return getNormalizedText(spectra, options); + } + + /** + * Returns a tab separated value containing the post processed data + * @param options - Options for export + * @returns Text string + */ + getPostProcessedText(options: GetPostProcessedTextOptions = {}): string { + return getPostProcessedText(this, options); + } + + getMinMaxX(): MinMaxX { + let min = Number.MAX_VALUE; + let max = Number.MIN_VALUE; + for (const spectrum of this.spectra) { + if (spectrum.minX < min) min = spectrum.minX; + if (spectrum.maxX > max) max = spectrum.maxX; + } + return { min, max }; + } + + /** + * Returns an object containing 4 parameters with the scaled data + * @param options - Options for post-processing + * @returns + */ + getPostProcessedData(options?: GetPostProcessedDataOptions) { + return getPostProcessedData(this, options); + } + + /** + * Add from text + * By default TITLE from the jcamp will be in the meta information + * @param text - Text data + * @param options - Options for parsing + */ + addFromText(text: string, options: AddFromTextOptions = {}): void { + if (options.force !== true && options.id && this.contains(options.id)) { + return; + } + const parsed = parseText(text, options); + const meta = { ...options.meta }; + this.addFromData(parsed.data, { meta, id: options.id }); + } + + /** + * Add jcamp + * By default TITLE from the jcamp will be in the meta information + * @param jcamp - JCAMP data + * @param options - Options for parsing + */ + addFromJcamp(jcamp: string, options: AddFromJcampOptions = {}): void { + if (options.force !== true && options.id && this.contains(options.id)) { + return; + } + const parsed = parseJcamp(jcamp); + const meta = { ...parsed.info, ...parsed.meta, ...options.meta }; + this.addFromData(parsed.data, { meta, id: options.id }); + } + + updateRangesInfo(options?: any): void { + for (const spectrum of this.spectra) { + spectrum.updateRangesInfo(options); + } + } + + /** + * Returns the metadata for a set of spectra + * @param options - Options for metadata extraction + * @returns Metadata array + */ + getMetadata(options: GetMetadataOptions = {}) { + const { ids } = options; + return getMetadata(this.getSpectra(ids)); + } + + /** + * Get classes from metadata + * @param options - Options for classification + * @returns Array of class numbers + */ + getClasses(options?: GetClassesOptions & GetMetadataOptions) { + return getClasses(this.getMetadata(options), options); + } + + /** + * Get class labels from metadata + * @param options - Options for label extraction + * @returns Array of class labels + */ + getClassLabels(options?: GetClassLabelsOptions & GetMetadataOptions) { + return getClassLabels(this.getMetadata(options), options); + } + + /** + * Get categories statistics + * @param options - Options for statistics + * @returns Category statistics + */ + getCategoriesStats(options?: GetCategoriesStatsOptions) { + return getCategoriesStats(this.getMetadata(), options); + } + + /** + * Add a spectrum based on the data + * @param data - {x, y} + * @param options - Options for adding spectrum + * @returns Spectrum + */ + addFromData(data: DataXY, options: AddFromDataOptions = {}): void { + if (this.spectra.length === 0) this.keepOriginal = true; + const id = options.id || Math.random().toString(36).slice(2, 10); + let index = this.getSpectrumIndex(id); + if (index === undefined) index = this.spectra.length; + const spectrum = new Spectrum(data.x as number[], data.y as number[], id, { + meta: options.meta, + normalized: options.normalized, + normalization: this.normalization, + }); + this.spectra[index] = spectrum; + if (!this.keepOriginal) { + spectrum.removeOriginal(); + } else { + const memoryInfo = this.getMemoryInfo(); + if (memoryInfo.total > this.maxMemory) { + this.keepOriginal = false; + this.removeOriginals(); + } + } + } + + removeOriginals(): void { + for (const spectrum of this.spectra) { + spectrum.removeOriginal(); + } + } + + /** + * Remove the spectrum from the SpectraProcessor for the specified id + * @param id - Spectrum identifier + * @returns Removed spectrum array + */ + removeSpectrum(id: string): Spectrum[] | undefined { + const index = this.getSpectrumIndex(id); + if (index === undefined) return undefined; + return this.spectra.splice(index, 1); + } + + /** + * Remove all the spectra not present in the list + * @param ids - Array of ids of the spectra to keep + */ + removeSpectraNotIn(ids: unknown[]): void { + const currentIDs = this.spectra.map((spectrum) => spectrum.id); + for (const id of currentIDs) { + if (!ids.includes(id)) { + this.removeSpectrum(id); + } + } + } + + /** + * Checks if the ID of a spectrum exists in the SpectraProcessor + * @param id - Spectrum identifier + * @returns True if spectrum exists + */ + contains(id: string): boolean { + return this.getSpectrumIndex(id) !== undefined; + } + + /** + * Returns the index of the spectrum in the spectra array + * @param id - Spectrum identifier + * @returns Index or undefined + */ + getSpectrumIndex(id: string | undefined): number | undefined { + if (!id) return undefined; + for (let i = 0; i < this.spectra.length; i++) { + const spectrum = this.spectra[i]; + if (spectrum.id === id) return i; + } + return undefined; + } + + /** + * Returns an array of all the ids + * @returns Array of IDs + */ + getIDs(): string[] { + return this.spectra.map((spectrum) => spectrum.id); + } + + /** + * Returns an array of spectrum from their ids + * @param ids - Array of spectrum IDs + * @returns Array of Spectrum + */ + getSpectra(ids?: unknown[]): Spectrum[] { + if (!ids || !Array.isArray(ids)) return this.spectra; + const spectra: Spectrum[] = []; + for (const id of ids) { + const index = this.getSpectrumIndex(id as string); + if (index !== undefined) { + spectra.push(this.spectra[index]); + } + } + return spectra; + } + + /** + * Returns the spectrum for the given id + * @param id - Spectrum identifier + * @returns Spectrum or undefined + */ + getSpectrum(id: string): Spectrum | undefined { + const index = this.getSpectrumIndex(id); + if (index === undefined) return undefined; + return this.spectra[index]; + } + + /** + * Returns a JSGraph chart object for all the spectra + * @param options - Chart options + * @returns Chart object + */ + getChart(options?: GetChartOptions) { + return getChart(this.spectra, options); + } + + /** + * Returns a JSGraph chart object for autocorrelation + * @param index - Index in spectrum + * @param options - Chart options + * @returns Chart object + */ + getAutocorrelationChart( + index: number, + options?: GetAutocorrelationChartOptions, + ) { + return getAutocorrelationChart(this, index, options); + } + + /** + * Returns a JSGraph annotation object for the specified index + * @param index - Index in spectrum + * @param options - Annotation options + * @returns Annotation object + */ + getTrackAnnotation(index: number, options?: GetTrackAnnotationOptions) { + return getTrackAnnotation(this.spectra, index, options); + } + + /** + * Returns a JSGraph annotation object for box plot + * @param options - Chart options + * @returns Chart object + */ + getBoxPlotChart( + options: GetBoxPlotChartOptions & GetNormalizedDataOptions = {}, + ) { + const normalizedData = this.getNormalizedData(options); + return getBoxPlotChart(normalizedData, options); + } + + /** + * Returns boxplot information + * @param options - Options for box plot + * @returns Box plot data + */ + getBoxPlotData(options: GetNormalizedDataOptions = {}) { + const normalizedData = this.getNormalizedData(options); + return getBoxPlotData(normalizedData); + } + + /** + * Returns a JSGraph chart object for all the normalized spectra + * @param options - Chart options + * @returns Chart object + */ + getNormalizedChart( + options: GetNormalizedChartOptions & GetNormalizedDataOptions = {}, + ) { + const { ids, ...chartOptions } = options; + const spectra = this.getSpectra(ids); + return getNormalizedChart(spectra, chartOptions); + } + + /** + * Returns a JSGraph chart object for all the scaled normalized spectra + * @param options - Options for post-processing + * @returns Chart object + */ + getPostProcessedChart(options?: GetPostProcessedDataOptions) { + return getPostProcessedChart(this, options); + } + + getMemoryInfo(): MemoryStats & { keepOriginal: boolean; maxMemory: number } { + const memoryInfo: MemoryStats = { original: 0, normalized: 0, total: 0 }; + for (const spectrum of this.spectra) { + const memory = spectrum.memory; + if (memory) { + memoryInfo.original += memory.original; + memoryInfo.normalized += memory.normalized; + memoryInfo.total += memory.total; + } + } + return { + ...memoryInfo, + keepOriginal: this.keepOriginal, + maxMemory: this.maxMemory, + }; + } + + getNormalizedBoundary(): AxisBoundary { + const boundary: AxisBoundary = { + x: { min: Number.MAX_VALUE, max: Number.MIN_VALUE }, + y: { min: Number.MAX_VALUE, max: Number.MIN_VALUE }, + }; + for (const spectrum of this.spectra) { + if (spectrum.normalizedBoundary.x.min < boundary.x.min) { + boundary.x.min = spectrum.normalizedBoundary.x.min; + } + if (spectrum.normalizedBoundary.x.max > boundary.x.max) { + boundary.x.max = spectrum.normalizedBoundary.x.max; + } + if (spectrum.normalizedBoundary.y.min < boundary.y.min) { + boundary.y.min = spectrum.normalizedBoundary.y.min; + } + if (spectrum.normalizedBoundary.y.max > boundary.y.max) { + boundary.y.max = spectrum.normalizedBoundary.y.max; + } + } + return boundary; + } + + /** + * We provide the allowed from / to after normalization + * For the X axis we return the smallest common values + * For the Y axis we return the largest min / max + */ + getNormalizedCommonBoundary(): AxisBoundary { + const boundary: AxisBoundary = { + x: { min: Number.NEGATIVE_INFINITY, max: Number.POSITIVE_INFINITY }, + y: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, + }; + for (const spectrum of this.spectra) { + if (spectrum.normalizedAllowedBoundary) { + if (spectrum.normalizedAllowedBoundary.x.min > boundary.x.min) { + boundary.x.min = spectrum.normalizedAllowedBoundary.x.min; + } + if (spectrum.normalizedAllowedBoundary.x.max < boundary.x.max) { + boundary.x.max = spectrum.normalizedAllowedBoundary.x.max; + } + if (spectrum.normalizedAllowedBoundary.y.min < boundary.y.min) { + boundary.y.min = spectrum.normalizedAllowedBoundary.y.min; + } + if (spectrum.normalizedAllowedBoundary.y.max > boundary.y.max) { + boundary.y.max = spectrum.normalizedAllowedBoundary.y.max; + } + } + } + return boundary; + } + + /** + * Create SpectraProcessor from normalized TSV + * @param text - TSV text + * @param options - Parsing options + * @param options.fs + * @returns SpectraProcessor instance + */ + static fromNormalizedMatrix( + text: string, + options: { fs?: string } = {}, + ): SpectraProcessor { + const parsed = parseMatrix(text, options); + if (!parsed) { + throw new Error('Can not parse TSV file'); + } + const spectraProcessor = new SpectraProcessor(); + + spectraProcessor.setNormalization({ + from: parsed.x[0], + to: parsed.x.at(-1), + numberOfPoints: parsed.x.length, + }); + + for (let i = 0; i < parsed.ids.length; i++) { + spectraProcessor.addFromData( + { x: [], y: [] }, + { + normalized: { + x: parsed.x, + y: parsed.matrix[i], + }, + id: parsed.ids[i], + meta: parsed.meta[i], + }, + ); + } + + spectraProcessor.keepOriginal = false; + + return spectraProcessor; + } +} diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/src/jsgraph/__tests__/__snapshots__/getBoxPlotChart.test.js.snap b/src/jsgraph/__tests__/__snapshots__/getBoxPlotChart.test.js.snap index 0201443..56a8662 100644 --- a/src/jsgraph/__tests__/__snapshots__/getBoxPlotChart.test.js.snap +++ b/src/jsgraph/__tests__/__snapshots__/getBoxPlotChart.test.js.snap @@ -11,27 +11,27 @@ exports[`getBoxPlotChart 1`] = ` "position": [ { "x": 10, - "y": 2, + "y": 1.5, }, { "x": 20, - "y": 3, + "y": 2.5, }, { "x": 30, - "y": 4, + "y": 3.5, }, { "x": 30, - "y": 6, + "y": 7, }, { "x": 20, - "y": 5, + "y": 6, }, { "x": 10, - "y": 4, + "y": 5, }, ], "strokeWidth": 1e-7, @@ -86,7 +86,7 @@ exports[`getBoxPlotChart 1`] = ` 20, 30, ], - "y": [ + "y": Float64Array [ 3, 4, 5, diff --git a/src/jsgraph/__tests__/__snapshots__/getTrackAnnotation.test.js.snap b/src/jsgraph/__tests__/__snapshots__/getTrackAnnotation.test.js.snap index 6945205..56f43c5 100644 --- a/src/jsgraph/__tests__/__snapshots__/getTrackAnnotation.test.js.snap +++ b/src/jsgraph/__tests__/__snapshots__/getTrackAnnotation.test.js.snap @@ -70,74 +70,3 @@ exports[`check jsgraph annotations 1`] = ` }, ] `; - -exports[`getTrackAnnotation > check jsgraph annotations 1`] = ` -[ - { - "label": { - "position": { - "x": "130px", - "y": "20px", - }, - "size": 16, - "text": "x: 20.0000", - }, - "position": [ - { - "x": "70px", - "y": "15px", - }, - { - "x": "85px", - "y": "15px", - }, - ], - "strokeWidth": 1e-7, - "type": "line", - }, - { - "label": { - "position": { - "x": "90px", - "y": "35px", - }, - "text": "3.000 - 2", - }, - "position": [ - { - "x": "70px", - "y": "30px", - }, - { - "x": "85px", - "y": "30px", - }, - ], - "strokeColor": "green", - "strokeWidth": 2, - "type": "line", - }, - { - "label": { - "position": { - "x": "90px", - "y": "50px", - }, - "text": "2.000 - 1", - }, - "position": [ - { - "x": "70px", - "y": "45px", - }, - { - "x": "85px", - "y": "45px", - }, - ], - "strokeColor": "red", - "strokeWidth": 2, - "type": "line", - }, -] -`; diff --git a/src/jsgraph/addChartDataStyle.ts b/src/jsgraph/addChartDataStyle.ts new file mode 100644 index 0000000..d912f9f --- /dev/null +++ b/src/jsgraph/addChartDataStyle.ts @@ -0,0 +1,49 @@ +import type { DataXY } from 'cheminfo-types'; + +import type { Spectrum } from '../spectrum/Spectrum.js'; + +export interface ChartDataStyles { + unselected: { + lineColor: string; + lineWidth: number; + lineStyle: number; + }; + selected: { + lineColor: string; + lineWidth: number; + lineStyle: number; + }; +} + +export interface ChartData extends DataXY { + styles?: ChartDataStyles; + label?: string; +} + +export interface Chart { + data: ChartData[]; +} + +/** + * Add chart data styling based on spectrum metadata + * @param data - Data object to style + * @param spectrum - Spectrum with metadata + */ +export function addChartDataStyle( + data: ChartData, + spectrum: Spectrum | { meta: Record; id: unknown }, +): void { + data.styles = { + unselected: { + lineColor: spectrum.meta.color || 'darkgrey', + lineWidth: 1, + lineStyle: 1, + }, + selected: { + lineColor: spectrum.meta.color || 'darkgrey', + lineWidth: 3, + lineStyle: 1, + }, + }; + data.label = spectrum.meta.id || String(spectrum.id); +} diff --git a/src/jsgraph/getAutocorrelationChart.ts b/src/jsgraph/getAutocorrelationChart.ts new file mode 100644 index 0000000..65df82a --- /dev/null +++ b/src/jsgraph/getAutocorrelationChart.ts @@ -0,0 +1,100 @@ +import type { DoubleArray } from 'cheminfo-types'; +import chroma from 'chroma-js'; +import type { XYFilterXOptions } from 'ml-spectra-processing'; +import { xMinMaxValues, xyFilterX } from 'ml-spectra-processing'; + +import type { SpectraProcessor } from '../SpectraProcessor.js'; + +export interface AutocorrelationData { + x: DoubleArray; + y: Float64Array | DoubleArray; +} + +export interface GetAutocorrelationChartOptions { + /** + * Precalculated autocorrelation {x,y} + */ + autocorrelation?: AutocorrelationData; + /** + * IDs of the spectra to select, by default all + */ + ids?: string[]; + /** + * Filter options for x values + */ + xFilter?: XYFilterXOptions; +} + +export interface ColorSpectrum { + type: string; + x: DoubleArray; + y: DoubleArray; + color: string[]; + styles: { + unselected: { + lineWidth: number; + lineStyle: number; + }; + selected: { + lineWidth: number; + lineStyle: number; + }; + }; +} + +/** + * Retrieve a chart with autocorrelation color + * @param spectraProcessor - SpectraProcessor instance + * @param index - Index in the spectrum + * @param options - Chart options + * @returns Color spectrum chart object + */ +export function getAutocorrelationChart( + spectraProcessor: SpectraProcessor, + index: number, + options: GetAutocorrelationChartOptions = {}, +): ColorSpectrum { + const { + autocorrelation = spectraProcessor.getAutocorrelation(index, options), + xFilter, + ids, + } = options; + + const { min, max } = xMinMaxValues(autocorrelation.y); + // eslint-disable-next-line import/no-named-as-default-member + const colorCallback = chroma + .scale(['blue', 'cyan', 'yellow', 'red']) + .domain([min, max]) + .mode('lch'); + + // Annoying but it seems the color library does not handle TypedArray well + const ys = ArrayBuffer.isView(autocorrelation.y) + ? Array.from(autocorrelation.y) + : autocorrelation.y; + + const colorScale = ys.map((y) => `rgb(${colorCallback(y).rgb().join(',')})`); + + let mean = spectraProcessor.getMeanData({ ids }); + if (xFilter) { + mean = xyFilterX({ x: mean.x, y: mean.y }, xFilter); + } + + const colorSpectrum: ColorSpectrum = { + type: 'color', + x: mean.x, + y: mean.y, + color: colorScale, + styles: { + unselected: { + lineWidth: 1, + lineStyle: 1, + }, + selected: { + lineWidth: 3, + lineStyle: 1, + }, + }, + }; + + return colorSpectrum; +} diff --git a/src/jsgraph/getBoxPlotChart.ts b/src/jsgraph/getBoxPlotChart.ts new file mode 100644 index 0000000..c80f257 --- /dev/null +++ b/src/jsgraph/getBoxPlotChart.ts @@ -0,0 +1,192 @@ +import type { DoubleArray, DoubleMatrix } from 'cheminfo-types'; +import chroma from 'chroma-js'; +import { Matrix } from 'ml-matrix'; +import { xMinMaxValues } from 'ml-spectra-processing'; + +import type { NormalizedData } from '../spectra/getBoxPlotData.js'; +import { getBoxPlotData } from '../spectra/getBoxPlotData.js'; + +export interface AnnotationPosition { + x: number | string; + y: number | string; +} + +export interface Annotation { + type: string; + layer?: number; + properties?: { + fillColor?: string; + fillOpacity?: number; + strokeWidth?: number; + strokeColor?: string; + position?: AnnotationPosition[]; + }; +} + +export interface ColorSpectrum { + type: string; + data: { + x: DoubleArray; + y: Float64Array; + color: string[]; + }; + styles: { + unselected: { + lineWidth: number; + lineStyle: number; + }; + selected: { + lineWidth: number; + lineStyle: number; + }; + }; + annotations: Annotation[]; +} + +export interface GetBoxPlotChartOptions { + /** + * Fill color for Q1-Q3 region + * @default '#000' + */ + q13FillColor?: string; + /** + * Fill opacity for Q1-Q3 region + * @default 0.3 + */ + q13FillOpacity?: number; + /** + * Stroke color for median line (empty string uses color gradient) + * @default '' + */ + medianStrokeColor?: string; + /** + * Stroke width for median line + * @default 3 + */ + medianStrokeWidth?: number; + /** + * Fill color for min-max region + * @default '#000' + */ + minMaxFillColor?: string; + /** + * Fill opacity for min-max region + * @default 0.15 + */ + minMaxFillOpacity?: number; +} + +/** + * Get box plot chart from normalized data + * @param normalizedData - Normalized data with x and matrix + * @param options - Chart styling options + * @returns Color spectrum chart object + */ +export function getBoxPlotChart( + normalizedData: NormalizedData, + options: GetBoxPlotChartOptions = {}, +): ColorSpectrum { + const { + q13FillColor = '#000', + q13FillOpacity = 0.3, + medianStrokeColor = '', + medianStrokeWidth = 3, + minMaxFillColor = '#000', + minMaxFillOpacity = 0.15, + } = options; + const annotations: Annotation[] = []; + + const boxPlotData = getBoxPlotData(normalizedData); + + if (q13FillOpacity && q13FillColor) { + const q13: AnnotationPosition[] = []; + for (let i = 0; i < boxPlotData.x.length; i++) { + q13.push({ + x: boxPlotData.x[i], + y: boxPlotData.q1[i], + }); + } + for (let i = boxPlotData.x.length - 1; i >= 0; i--) { + q13.push({ + x: boxPlotData.x[i], + y: boxPlotData.q3[i], + }); + } + annotations.push({ + type: 'polygon', + layer: 0, + properties: { + fillColor: q13FillColor, + fillOpacity: q13FillOpacity, + strokeWidth: 0.0000001, + position: q13, + }, + }); + } + + if (minMaxFillColor && minMaxFillOpacity) { + const minMax: AnnotationPosition[] = []; + for (let i = 0; i < boxPlotData.x.length; i++) { + minMax.push({ + x: boxPlotData.x[i], + y: boxPlotData.min[i], + }); + } + for (let i = boxPlotData.x.length - 1; i >= 0; i--) { + minMax.push({ + x: boxPlotData.x[i], + y: boxPlotData.max[i], + }); + } + + annotations.push({ + type: 'polygon', + layer: 0, + properties: { + fillColor: minMaxFillColor, + fillOpacity: minMaxFillOpacity, + strokeWidth: 0.0000001, + strokeColor: '#FFF', + position: minMax, + }, + }); + } + + const colorSpectrum: ColorSpectrum = { + type: 'color', + data: { + x: boxPlotData.x, + y: boxPlotData.median, + color: medianStrokeColor + ? new Array(boxPlotData.x.length).fill(medianStrokeColor) + : getColors(normalizedData.matrix), + }, + styles: { + unselected: { + lineWidth: medianStrokeWidth, + lineStyle: 1, + }, + selected: { + lineWidth: medianStrokeWidth, + lineStyle: 1, + }, + }, + annotations, + }; + + return colorSpectrum; +} + +function getColors(dataset: DoubleMatrix): string[] { + const matrix = new Matrix(dataset as number[][]); + const stdevs = matrix.standardDeviation('column'); + const { min, max } = xMinMaxValues(stdevs); + // eslint-disable-next-line import/no-named-as-default-member + const colorCallback = chroma + .scale(['blue', 'cyan', 'yellow', 'red']) + .domain([min, max]) + .mode('lch'); + + const colors = stdevs.map((y) => `rgb(${colorCallback(y).rgb().join(',')})`); + return colors; +} diff --git a/src/jsgraph/getChart.ts b/src/jsgraph/getChart.ts new file mode 100644 index 0000000..0d1a2be --- /dev/null +++ b/src/jsgraph/getChart.ts @@ -0,0 +1,47 @@ +import type { XYFilterXOptions } from 'ml-spectra-processing'; + +import type { Spectrum } from '../spectrum/Spectrum.js'; + +import type { Chart } from './addChartDataStyle.js'; +import { addChartDataStyle } from './addChartDataStyle.js'; + +export interface GetChartOptions { + /** + * List of spectra ids, by default all + */ + ids?: string[]; + /** + * Y-axis multiplication factor + * @default 1 + */ + yFactor?: number; + /** + * Filter options for x values + */ + xFilter?: XYFilterXOptions; +} + +/** + * Retrieve a chart with selected original data + * @param spectra - Array of spectrum objects + * @param options - Chart options + * @returns Chart object with data + */ +export function getChart( + spectra: Spectrum[], + options: GetChartOptions = {}, +): Chart { + const { ids, yFactor, xFilter = {} } = options; + const chart: Chart = { + data: [], + }; + + for (const spectrum of spectra) { + if (!ids || ids.includes(spectrum.id)) { + const data = spectrum.getData({ yFactor, xFilter }); + addChartDataStyle(data, spectrum); + chart.data.push(data); + } + } + return chart; +} diff --git a/src/jsgraph/getFilterAnnotations.ts b/src/jsgraph/getFilterAnnotations.ts new file mode 100644 index 0000000..bd82430 --- /dev/null +++ b/src/jsgraph/getFilterAnnotations.ts @@ -0,0 +1,64 @@ +export interface Exclusion { + from: number; + to: number; + ignore?: boolean; +} + +export interface Filter { + from?: number; + to?: number; + exclusions?: Exclusion[]; +} + +export interface RectAnnotation { + type: string; + position: Array<{ x: number | string; y: string }>; + strokeWidth: number; + fillColor: string; +} + +/** + * Get filter annotations + * @param filter - Filter with from, to, and exclusions + * @returns Array of rectangle annotations + */ +export function getFilterAnnotations(filter: Filter = {}): RectAnnotation[] { + let { exclusions = [] } = filter; + let annotations: RectAnnotation[] = []; + exclusions = exclusions.filter((exclusion) => !exclusion.ignore); + annotations = exclusions.map((exclusion) => { + const annotation: RectAnnotation = { + type: 'rect', + position: [ + { x: exclusion.from, y: '0px' }, + { x: exclusion.to, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,128,1)', + }; + return annotation; + }); + if (filter.from !== undefined) { + annotations.push({ + type: 'rect', + position: [ + { x: Number.MIN_SAFE_INTEGER, y: '0px' }, + { x: filter.from, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,224,1)', + }); + } + if (filter.to !== undefined) { + annotations.push({ + type: 'rect', + position: [ + { x: filter.to, y: '0px' }, + { x: Number.MAX_SAFE_INTEGER, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,224,1)', + }); + } + return annotations; +} diff --git a/src/jsgraph/getNormalizationAnnotations.ts b/src/jsgraph/getNormalizationAnnotations.ts new file mode 100644 index 0000000..0003922 --- /dev/null +++ b/src/jsgraph/getNormalizationAnnotations.ts @@ -0,0 +1,66 @@ +export interface Exclusion { + from: number; + to: number; + ignore?: boolean; +} + +export interface NormalizationFilter { + from?: number; + to?: number; + exclusions?: Exclusion[]; +} + +export interface RectAnnotation { + type: string; + position: Array<{ x: number | string; y: string }>; + strokeWidth: number; + fillColor: string; +} + +/** + * Get normalization annotations for a filter + * @param filter - Normalization filter with from, to, and exclusions + * @returns Array of rectangle annotations + */ +export function getNormalizationAnnotations( + filter: NormalizationFilter = {}, +): RectAnnotation[] { + let { exclusions = [] } = filter; + let annotations: RectAnnotation[] = []; + exclusions = exclusions.filter((exclusion) => !exclusion.ignore); + annotations = exclusions.map((exclusion) => { + const annotation: RectAnnotation = { + type: 'rect', + position: [ + { x: exclusion.from, y: '0px' }, + { x: exclusion.to, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,224,1)', + }; + return annotation; + }); + if (filter.from !== undefined) { + annotations.push({ + type: 'rect', + position: [ + { x: Number.MIN_SAFE_INTEGER, y: '0px' }, + { x: filter.from, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,224,1)', + }); + } + if (filter.to !== undefined) { + annotations.push({ + type: 'rect', + position: [ + { x: filter.to, y: '0px' }, + { x: Number.MAX_SAFE_INTEGER, y: '2000px' }, + ], + strokeWidth: 0, + fillColor: 'rgba(255,255,224,1)', + }); + } + return annotations; +} diff --git a/src/jsgraph/getNormalizedChart.ts b/src/jsgraph/getNormalizedChart.ts new file mode 100644 index 0000000..51dcec8 --- /dev/null +++ b/src/jsgraph/getNormalizedChart.ts @@ -0,0 +1,40 @@ +import type { DataXY } from 'cheminfo-types'; +import type { XYFilterXOptions } from 'ml-spectra-processing'; +import { xyFilterX } from 'ml-spectra-processing'; + +import type { Spectrum } from '../spectrum/Spectrum.js'; + +import type { Chart, ChartData } from './addChartDataStyle.js'; +import { addChartDataStyle } from './addChartDataStyle.js'; + +export interface GetNormalizedChartOptions { + /** + * Filter options for x values + */ + xFilter?: XYFilterXOptions; +} + +/** + * Get chart with normalized data + * @param spectra - Array of spectrum objects + * @param options - Chart options + * @returns Chart object with normalized data + */ +export function getNormalizedChart( + spectra: Spectrum[], + options: GetNormalizedChartOptions = {}, +): Chart { + const { xFilter } = options; + const chart: Chart = { + data: [], + }; + for (const spectrum of spectra) { + let data: DataXY & ChartData = spectrum.normalized; + if (xFilter) { + data = xyFilterX(data, xFilter) as DataXY & ChartData; + } + addChartDataStyle(data, spectrum); + chart.data.push(data); + } + return chart; +} diff --git a/src/jsgraph/getPostProcessedChart.ts b/src/jsgraph/getPostProcessedChart.ts new file mode 100644 index 0000000..41e7e48 --- /dev/null +++ b/src/jsgraph/getPostProcessedChart.ts @@ -0,0 +1,34 @@ +import type { SpectraProcessor } from '../SpectraProcessor.js'; +import type { GetPostProcessedDataOptions } from '../spectra/getPostProcessedData.js'; + +import type { Chart, ChartData } from './addChartDataStyle.js'; +import { addChartDataStyle } from './addChartDataStyle.js'; + +/** + * Get chart with post-processed data + * @param spectraProcessor - SpectraProcessor instance + * @param options - Post-processing options + * @returns Chart object with post-processed data + */ +export function getPostProcessedChart( + spectraProcessor: SpectraProcessor, + options: GetPostProcessedDataOptions = {}, +): Chart { + const scaled = spectraProcessor.getPostProcessedData(options); + + const chart: Chart = { + data: [], + }; + if (!scaled?.matrix || !scaled.x || !scaled.meta || !scaled.ids) return chart; + + for (let i = 0; i < scaled.matrix.length; i++) { + const data: ChartData = { + x: scaled.x, + y: Array.from(scaled.matrix[i]), // need to ensure not a typed array + }; + addChartDataStyle(data, { meta: scaled.meta[i], id: scaled.ids[i] }); + chart.data.push(data); + } + + return chart; +} diff --git a/src/jsgraph/getTrackAnnotation.ts b/src/jsgraph/getTrackAnnotation.ts new file mode 100644 index 0000000..e79b4b8 --- /dev/null +++ b/src/jsgraph/getTrackAnnotation.ts @@ -0,0 +1,113 @@ +import { getNormalizedData } from '../spectra/getNormalizedData.js'; +import type { Spectrum } from '../spectrum/Spectrum.js'; + +export interface AnnotationPosition { + x: number | string; + y: number | string; +} + +export interface LineAnnotation { + type: string; + position: AnnotationPosition[]; + strokeWidth?: number; + strokeColor?: string; + label?: { + size?: number; + text: string; + position: AnnotationPosition; + }; +} + +export interface GetTrackAnnotationOptions { + /** + * IDs of the spectra to select + */ + ids?: string[]; + /** + * Show spectrum ID in annotations + * @default true + */ + showSpectrumID?: boolean; + /** + * Sort annotations by Y value + * @default true + */ + sortY?: boolean; + /** + * Maximum number of annotations to show + * @default 20 + */ + limit?: number; +} + +/** + * Get track annotation for a specific index + * @param spectra - Array of spectrum objects + * @param index - Index in the spectrum + * @param options - Annotation options + * @returns Array of line annotations + */ +export function getTrackAnnotation( + spectra: Spectrum[], + index: number, + options: GetTrackAnnotationOptions = {}, +): LineAnnotation[] { + const { ids, showSpectrumID = true, sortY = true, limit = 20 } = options; + const annotations: LineAnnotation[] = []; + + const normalized = getNormalizedData(spectra, { ids }); + + if (normalized.ids.length === 0) return annotations; + let line = 0; + + // Header containing X coordinate + annotations.push({ + type: 'line', + position: [ + { x: `70px`, y: `${15 + 15 * line}px` }, + { x: `85px`, y: `${15 + 15 * line}px` }, + ], + strokeWidth: 0.0000001, + label: { + size: 16, + text: `x: ${normalized.x[index].toPrecision(6)}`, + position: { x: `130px`, y: `${20 + 15 * line}px` }, + }, + }); + line++; + + let peaks = []; + for (let i = 0; i < normalized.ids.length; i++) { + peaks.push({ + id: normalized.ids[i], + meta: normalized.meta[i], + y: normalized.matrix[i][index], + }); + } + + if (sortY) { + peaks.sort((a, b) => b.y - a.y); + } + if (limit) { + peaks = peaks.slice(0, limit); + } + + for (const { id, meta, y } of peaks) { + annotations.push({ + type: 'line', + position: [ + { x: `70px`, y: `${15 + 15 * line}px` }, + { x: `85px`, y: `${15 + 15 * line}px` }, + ], + strokeColor: meta.color, + strokeWidth: 2, + label: { + text: `${y.toPrecision(4)}${showSpectrumID ? ` - ${id}` : ''}`, + position: { x: `90px`, y: `${20 + 15 * line}px` }, + }, + }); + line++; + } + + return annotations; +} diff --git a/src/metadata/getCategoriesStats.ts b/src/metadata/getCategoriesStats.ts new file mode 100644 index 0000000..6b17489 --- /dev/null +++ b/src/metadata/getCategoriesStats.ts @@ -0,0 +1,41 @@ +export type CategoryStats = Record< + string, + { + classNumber: number; + counter: number; + } +>; + +export interface GetCategoriesStatsOptions { + /** + * Property name to use for categorization + * @default 'category' + */ + propertyName?: string; +} + +/** + * Get statistics about categories in metadata + * @param metadata - Array of metadata objects + * @param options - Options for categorization + * @returns Object with category statistics + */ +export function getCategoriesStats( + metadata: Array>, + options: GetCategoriesStatsOptions = {}, +): CategoryStats { + const { propertyName = 'category' } = options; + const categories: CategoryStats = {}; + let classNumber = 0; + for (const metadatum of metadata) { + const value = metadatum[propertyName]; + if (!categories[value]) { + categories[value] = { + classNumber: classNumber++, + counter: 0, + }; + } + categories[value].counter++; + } + return categories; +} diff --git a/src/metadata/getClassLabels.ts b/src/metadata/getClassLabels.ts new file mode 100644 index 0000000..3f3181f --- /dev/null +++ b/src/metadata/getClassLabels.ts @@ -0,0 +1,25 @@ +export interface GetClassLabelsOptions { + /** + * Property name to use for labels + * @default 'category' + */ + propertyName?: string; +} + +/** + * Get class labels for each metadata entry + * @param metadata - Array of metadata objects + * @param options - Options for label extraction + * @returns Array of class labels + */ +export function getClassLabels( + metadata: Array>, + options: GetClassLabelsOptions = {}, +): any[] { + const { propertyName = 'category' } = options; + const categories: any[] = []; + for (const metadatum of metadata) { + categories.push(metadatum[propertyName]); + } + return categories; +} diff --git a/src/metadata/getClasses.ts b/src/metadata/getClasses.ts new file mode 100644 index 0000000..f01bbdc --- /dev/null +++ b/src/metadata/getClasses.ts @@ -0,0 +1,29 @@ +import type { CategoryStats } from './getCategoriesStats.js'; +import { getCategoriesStats } from './getCategoriesStats.js'; + +export interface GetClassesOptions { + /** + * Property name to use for classification + * @default 'category' + */ + propertyName?: string; +} + +/** + * Get class numbers for each metadata entry + * @param metadata - Array of metadata objects + * @param options - Options for classification + * @returns Array of class numbers + */ +export function getClasses( + metadata: Array>, + options: GetClassesOptions = {}, +): number[] { + const { propertyName = 'category' } = options; + const categoriesStats: CategoryStats = getCategoriesStats(metadata, options); + const result = new Array(metadata.length); + for (let i = 0; i < metadata.length; i++) { + result[i] = categoriesStats[metadata[i][propertyName]].classNumber; + } + return result; +} diff --git a/src/metadata/getMetadata.ts b/src/metadata/getMetadata.ts new file mode 100644 index 0000000..10a889d --- /dev/null +++ b/src/metadata/getMetadata.ts @@ -0,0 +1,33 @@ +import type { Spectrum } from '../spectrum/Spectrum.js'; + +export interface GetMetadataOptions { + /** + * IDs of selected spectra + */ + ids?: string[]; +} + +/** + * Get metadata from spectra + * @param spectra - Array of spectrum objects + * @param options - Options for filtering spectra + * @returns Array of metadata objects + */ +export function getMetadata( + spectra: Spectrum[], + options: GetMetadataOptions = {}, +): Array> { + const { ids } = options; + + const metadata: Array> = []; + + if (Array.isArray(spectra) && spectra.length > 0) { + for (const spectrum of spectra) { + if (!ids || ids.includes(spectrum.id)) { + metadata.push(spectrum.meta); + } + } + } + + return metadata; +} diff --git a/src/parser/parseJcamp.ts b/src/parser/parseJcamp.ts new file mode 100644 index 0000000..d6aa665 --- /dev/null +++ b/src/parser/parseJcamp.ts @@ -0,0 +1,34 @@ +import type { DataXY } from 'cheminfo-types'; +import type { Entry } from 'jcampconverter'; +import { convert } from 'jcampconverter'; + +import type { Kind } from '../Kinds.js'; +import { getJcampKind } from '../Kinds.js'; + +export interface ParseJcampResult { + data: DataXY; + kind?: Kind; + meta: Record; + info: Record; +} + +/** + * Create a spectrum from a JCAMP file + * @param jcampText - String containing the JCAMP data + * @returns Parsed spectrum data with kind, meta, and info + */ +export default function parseJcamp(jcampText: string): ParseJcampResult { + const parsed: Entry = convert(jcampText, { + keepRecordsRegExp: /.*/, + }).flatten[0]; + const kind = getJcampKind(parsed); + const data = parsed.spectra[0].data as unknown as DataXY; + const { meta, info } = parsed; + + // Convert the data if needed + if (kind?.importation?.converter) { + data.y = data.y.map(kind.importation.converter); + } + + return { data, kind, meta, info }; +} diff --git a/src/parser/parseMatrix.ts b/src/parser/parseMatrix.ts new file mode 100644 index 0000000..e7c5a86 --- /dev/null +++ b/src/parser/parseMatrix.ts @@ -0,0 +1,63 @@ +import type { DoubleArray, DoubleMatrix } from 'cheminfo-types'; + +export interface ParseMatrixOptions { + /** + * Field separator + * @default '\t' + */ + fs?: string; +} + +export interface ParseMatrixResult { + x: DoubleArray; + meta: Array>; + matrix: DoubleMatrix; + ids: string[]; +} + +/** + * Parse a matrix from text format + * @param text - String containing the text data + * @param options - Parsing options + * @returns Parsed matrix data with x, meta, matrix, and ids + */ +export default function parseMatrix( + text: string, + options: ParseMatrixOptions = {}, +): ParseMatrixResult { + const lines = text.split(/[\n\r]+/).filter(Boolean); + const { fs = '\t' } = options; + const currentMatrix: DoubleMatrix = []; + const ids: string[] = []; + const meta: Array> = []; + let x: DoubleArray = []; + + const headers = lines[0].split(fs); + const labels: string[] = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + if (Number.isNaN(Number(header))) { + labels[i] = header; + } else { + x = headers.slice(i).map(Number); + break; + } + } + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split('\t'); + ids.push(parts[0]); + const oneMeta: Record = {}; + meta.push(oneMeta); + for (let j = 1; j < parts.length; j++) { + if (j < labels.length) { + oneMeta[labels[j]] = parts[j]; + } else { + currentMatrix.push(parts.slice(labels.length).map(Number)); + break; + } + } + } + return { x, meta, matrix: currentMatrix, ids }; +} diff --git a/src/parser/parseText.ts b/src/parser/parseText.ts new file mode 100644 index 0000000..72b0423 --- /dev/null +++ b/src/parser/parseText.ts @@ -0,0 +1,41 @@ +import type { DataXY } from 'cheminfo-types'; +import { parseXY } from 'xy-parser'; + +import type { Kind } from '../Kinds.js'; + +export interface ParseTextOptions { + /** + * Kind object defining conversion and display options + */ + kind?: Kind; + /** + * Parser options to pass to xy-parser + */ + parserOptions?: any; +} + +export interface ParseTextResult { + data: DataXY; +} + +/** + * Create a spectrum from a text file + * @param value - String containing the text data + * @param options - Parsing options + * @returns Parsed data object + */ +export default function parseText( + value: string, + options: ParseTextOptions = {}, +): ParseTextResult { + const { kind, parserOptions = {} } = options; + + const data = parseXY(value, parserOptions); + + // Convert the data if needed + if (kind?.importation?.converter) { + data.y = data.y.map(kind.importation.converter); + } + + return { data }; +} diff --git a/src/spectra/getAutocorrelation.ts b/src/spectra/getAutocorrelation.ts index 079f973..c7a6860 100644 --- a/src/spectra/getAutocorrelation.ts +++ b/src/spectra/getAutocorrelation.ts @@ -1,5 +1,4 @@ -import type { DoubleArray } from 'cheminfo-types'; -import type { DoubleMatrix } from 'ml-spectra-processing'; +import type { DoubleArray, DoubleMatrix } from 'cheminfo-types'; import { matrixAutoCorrelation } from 'ml-spectra-processing'; export interface NormalizedData { diff --git a/src/spectra/getMeanData.js b/src/spectra/getMeanData.js deleted file mode 100644 index e79cf1c..0000000 --- a/src/spectra/getMeanData.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Matrix } from 'ml-matrix'; - -/** - * @param normalized - * @private - */ - -export function getMeanData(normalized) { - let matrix = new Matrix(normalized.matrix); - return { - x: normalized.x, - y: matrix.mean('column'), - }; -} diff --git a/src/spectra/getMeanData.ts b/src/spectra/getMeanData.ts new file mode 100644 index 0000000..0a9f00d --- /dev/null +++ b/src/spectra/getMeanData.ts @@ -0,0 +1,22 @@ +import type { + DataXMatrix, + DataXY, + DoubleArray, + DoubleMatrix, +} from 'cheminfo-types'; +import { Matrix } from 'ml-matrix'; + +/** + * Calculate mean of normalized data + * @param normalized - Normalized data with x and matrix + * @returns Mean data with x and y values + */ +export function getMeanData( + normalized: DataXMatrix, +): DataXY { + const matrix = new Matrix(normalized.matrix); + return { + x: normalized.x, + y: matrix.mean('column'), + }; +} diff --git a/src/spectra/getNormalizedData.js b/src/spectra/getNormalizedData.js deleted file mode 100644 index ad2f922..0000000 --- a/src/spectra/getNormalizedData.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @private - * @param {*} spectra - * @param {object} [options={}] - * @param {Array} [options.ids] - ids of selected spectra - */ - -export function getNormalizedData(spectra, options = {}) { - const { ids } = options; - - let matrix = []; - let meta = []; - let currentIDs = []; - let x = []; - - if (Array.isArray(spectra) && spectra.length > 0) { - for (let spectrum of spectra) { - if (!ids || ids.includes(spectrum.id)) { - currentIDs.push(spectrum.id); - matrix.push(spectrum.normalized.y); - meta.push(spectrum.meta); - } - } - x = spectra[0].normalized.x; - } - - return { ids: currentIDs, matrix, meta, x }; -} diff --git a/src/spectra/getNormalizedData.ts b/src/spectra/getNormalizedData.ts new file mode 100644 index 0000000..4bf060f --- /dev/null +++ b/src/spectra/getNormalizedData.ts @@ -0,0 +1,48 @@ +import type { DoubleArray, DoubleMatrix } from 'cheminfo-types'; + +import type { Spectrum } from '../spectrum/Spectrum.js'; + +export interface GetNormalizedDataOptions { + /** + * IDs of selected spectra + */ + ids?: string[]; +} + +export interface NormalizedDataResult { + ids: string[]; + meta: Array>; + x: DoubleArray; + matrix: DoubleMatrix; +} + +/** + * Get normalized data from spectra + * @param spectra - Array of spectrum objects + * @param options - Options for filtering spectra + * @returns Normalized data with ids, matrix, meta, and x values + */ +export function getNormalizedData( + spectra: Spectrum[], + options: GetNormalizedDataOptions = {}, +): NormalizedDataResult { + const { ids } = options; + + const matrix: DoubleMatrix = []; + const meta: Array> = []; + const currentIDs: string[] = []; + let x: DoubleArray = []; + + if (Array.isArray(spectra) && spectra.length > 0) { + for (const spectrum of spectra) { + if (!ids || ids.includes(spectrum.id)) { + currentIDs.push(spectrum.id); + matrix.push(spectrum.normalized.y); + meta.push(spectrum.meta); + } + } + x = spectra[0].normalized.x; + } + + return { ids: currentIDs, matrix, meta, x }; +} diff --git a/src/spectra/getNormalizedText.js b/src/spectra/getNormalizedText.js deleted file mode 100644 index 7666d26..0000000 --- a/src/spectra/getNormalizedText.js +++ /dev/null @@ -1,19 +0,0 @@ -import { getNormalizedData } from './getNormalizedData.js'; -import { convertToText } from './util/convertToText.js'; - -/** - * @private - * @param {*} spectra - * @param {object} [options={}] - * @param {string} [options.fs='\t'] - field separator - * @param {string} [options.rs='\n'] - record (line) separator - */ - -export function getNormalizedText(spectra, options = {}) { - let { fs = '\t', rs = '\n' } = options; - - return convertToText(getNormalizedData(spectra), { - rs, - fs, - }); -} diff --git a/src/spectra/getNormalizedText.ts b/src/spectra/getNormalizedText.ts new file mode 100644 index 0000000..76a472b --- /dev/null +++ b/src/spectra/getNormalizedText.ts @@ -0,0 +1,27 @@ +import type { Spectrum } from '../spectrum/Spectrum.js'; + +import type { GetNormalizedDataOptions } from './getNormalizedData.js'; +import { getNormalizedData } from './getNormalizedData.js'; +import type { ConvertToTextOptions } from './util/convertToText.js'; +import { convertToText } from './util/convertToText.js'; + +export interface GetNormalizedTextOptions + extends GetNormalizedDataOptions, ConvertToTextOptions {} + +/** + * Get normalized data as text + * @param spectra - Array of spectrum objects + * @param options - Options for filtering and formatting + * @returns Text representation of normalized data + */ +export function getNormalizedText( + spectra: Spectrum[], + options: GetNormalizedTextOptions = {}, +): string { + const { fs = '\t', rs = '\n' } = options; + + return convertToText(getNormalizedData(spectra, options), { + rs, + fs, + }); +} diff --git a/src/spectra/getPostProcessedData.js b/src/spectra/getPostProcessedData.js deleted file mode 100644 index fc6d8f6..0000000 --- a/src/spectra/getPostProcessedData.js +++ /dev/null @@ -1,174 +0,0 @@ -import { - matrixCenterZMean, - matrixPQN, - matrixZRescale, - xSubtract, - xSum, - xyMaxYPoint, -} from 'ml-spectra-processing'; -import hash from 'object-hash'; - -import { getNormalizedData } from './getNormalizedData.js'; -import { getFromToIndex } from './scaled/getFromToIndex.js'; -import { integration } from './scaled/integration.js'; -import { max } from './scaled/max.js'; -import { min } from './scaled/min.js'; -import { minMax } from './scaled/minMax.js'; - -/** - * Allows to calculate relative intensity between normalized spectra - * @param {SpectraProcessor} spectraProcessor - * @param {object} [options={}] - scale spectra based on various parameters - * @param {Array} [options.ids] - ids of selected spectra - * @param {Array} [options.filters=[]] - Array of object containing {name:'', options:''} - * @param {object} [options.scale={}] - object containing the options for rescaling - * @param {string} [options.scale.targetID=spectra[0].id] - * @param {string} [options.scale.method='max'] - min, max, integration, minMax - * @param {Array} [options.scale.range] - from - to to apply the method and rescale - * @param {boolean} [options.scale.relative=false] - * @param {Array} [options.ranges] - Array of object containing {from:'', to:'', label:''} - * @param {Array} [options.calculations] - Array of object containing {label:'', formula:''} - * @returns {object} { ids:[], matrix:[Array], meta:[object], x:[], ranges:[object] } - */ - -let cache = {}; - -export function getPostProcessedData(spectraProcessor, options = {}) { - /** - * could implement a cache if all the options are identical and the normalized data is identical as well - * in order ot check if the normalized data are identical we should check if the normalized array of all the spectra are identical - * Because we don't make in-place modification when creating normalized array we can check if the 'pointer' to the object - * is identical - */ - - const optionsHash = hash(options); - - if (!spectraProcessor.spectra || !spectraProcessor.spectra[0]) return {}; - const { scale = {}, ids, ranges, calculations, filters = [] } = options; - - const { range, targetID, relative, method = '' } = scale; - - let spectra = spectraProcessor.getSpectra(ids); - - // are we able to reuse the cache ? - // we can if the normalized information didn't change and optionsHash is the same - if (cache.optionsHash === optionsHash) { - let validCache = true; - for (let spectrum of spectra) { - if (!cache.weakMap.get(spectrum.normalized)) validCache = false; - } - if (validCache) return cache; - } - const weakMap = new WeakMap(); - for (let spectrum of spectra) { - weakMap.set(spectrum.normalized, true); - } - - let normalizedData = getNormalizedData(spectra); - - for (let filter of filters) { - switch (filter.name) { - case 'pqn': { - normalizedData.matrix = matrixPQN( - normalizedData.matrix, - filter.options, - ).data; - break; - } - case 'centerMean': { - normalizedData.matrix = matrixCenterZMean(normalizedData.matrix); - break; - } - case 'rescale': { - normalizedData.matrix = matrixZRescale( - normalizedData.matrix, - filter.options, - ); - break; - } - case '': - case undefined: - break; - default: - throw new Error(`Unknown matrix filter name: ${filter.name}`); - } - } - - let normalizedTarget = targetID - ? spectraProcessor.getSpectrum(targetID).normalized - : spectraProcessor.spectra[0].normalized; - - if (method) { - switch (method.toLowerCase()) { - case 'min': - min(normalizedData.matrix, normalizedTarget, range); - break; - case 'max': - max(normalizedData.matrix, normalizedTarget, range); - break; - case 'minmax': - minMax(normalizedData.matrix, normalizedTarget, range); - break; - case 'integration': - integration(normalizedData.matrix, normalizedTarget, range); - break; - default: - throw new Error(`getPostProcessedData: unknown method: ${method}`); - } - } - - if (relative) { - for (let i = 0; i < normalizedData.matrix.length; i++) { - normalizedData.matrix[i] = xSubtract( - normalizedData.matrix[i], - normalizedTarget.y, - ); - } - } - - if (ranges) { - normalizedData.ranges = []; - for (let i = 0; i < normalizedData.matrix.length; i++) { - let rangesCopy = structuredClone(ranges); - let yNormalized = normalizedData.matrix[i]; - let resultRanges = {}; - normalizedData.ranges.push(resultRanges); - for (let currentRange of rangesCopy) { - if (currentRange.label) { - let fromToIndex = getFromToIndex(normalizedTarget.x, currentRange); - - let deltaX = normalizedTarget.x[1] - normalizedTarget.x[0]; - - currentRange.integration = xSum(yNormalized, fromToIndex) * deltaX; - currentRange.maxPoint = xyMaxYPoint( - { x: normalizedData.x, y: yNormalized }, - fromToIndex, - ); - resultRanges[currentRange.label] = currentRange; - } - } - } - } - - if (calculations && normalizedData.ranges) { - normalizedData.calculations = normalizedData.ranges.map(() => { - return {}; - }); - const parameters = Object.keys(normalizedData.ranges[0]); - for (let calculation of calculations) { - // eslint-disable-next-line no-new-func - const callback = new Function( - ...parameters, - `return ${calculation.formula}`, - ); - for (let i = 0; i < normalizedData.ranges.length; i++) { - let oneRanges = normalizedData.ranges[i]; - let values = parameters.map((key) => oneRanges[key].integration); - normalizedData.calculations[i][calculation.label] = callback(...values); - } - } - } - - cache = { ...normalizedData, optionsHash, weakMap }; - return cache; -} diff --git a/src/spectra/getPostProcessedData.ts b/src/spectra/getPostProcessedData.ts new file mode 100644 index 0000000..51eb509 --- /dev/null +++ b/src/spectra/getPostProcessedData.ts @@ -0,0 +1,234 @@ +import type { DoubleArray } from 'cheminfo-types'; +import type { + DoubleMatrix, + PointWithIndex, + XGetFromToIndexOptions, + XYFilterXOptions, +} from 'ml-spectra-processing'; +import { + matrixCenterZMean, + matrixPQN, + matrixZRescale, + xGetFromToIndex, + xSubtract, + xSum, + xyMaxYPoint, +} from 'ml-spectra-processing'; +import hash from 'object-hash'; + +import type { SpectraProcessor } from '../SpectraProcessor.js'; + +import type { GetNormalizedDataOptions } from './getNormalizedData.js'; +import { getNormalizedData } from './getNormalizedData.js'; +import { integration } from './scaled/integration.js'; +import { max } from './scaled/max.js'; +import { min } from './scaled/min.js'; +import { minMax } from './scaled/minMax.js'; + +export interface FilterOptions { + name?: string; + options?: any; +} + +export interface ScaleOptions { + targetID?: string; + method?: string; + range?: XGetFromToIndexOptions; + relative?: boolean; +} + +export interface RangeWithLabel extends XGetFromToIndexOptions { + label?: string; + integration?: number; + maxPoint?: PointWithIndex; +} + +export interface Calculation { + label: string; + formula: string; +} + +export interface GetPostProcessedDataOptions extends GetNormalizedDataOptions { + /** + * Array of filter objects with name and options + */ + filters?: FilterOptions[]; + /** + * Scale options for rescaling spectra + */ + scale?: ScaleOptions; + /** + * Array of range objects with from, to, and label + */ + ranges?: RangeWithLabel[]; + /** + * Array of calculation objects with label and formula + */ + calculations?: Calculation[]; + /** + * Filter options for x values + */ + xFilter?: XYFilterXOptions; +} + +export interface PostProcessedDataResult { + ids?: string[]; + matrix?: DoubleMatrix; + meta?: Array>; + x?: DoubleArray; + ranges?: Array>; + calculations?: Array>; + optionsHash?: string; + weakMap?: WeakMap; +} + +let cache: PostProcessedDataResult = {}; + +/** + * Calculate post-processed data with various transformations and calculations + * @param spectraProcessor - SpectraProcessor instance + * @param options - Processing options + * @returns Post-processed data + */ +export function getPostProcessedData( + spectraProcessor: SpectraProcessor, + options: GetPostProcessedDataOptions = {}, +): PostProcessedDataResult { + const optionsHash = hash(options); + + if (!spectraProcessor.spectra || spectraProcessor.spectra.length === 0) { + return {}; + } + const { scale = {}, ids, ranges, calculations, filters = [] } = options; + + const { range, targetID, relative, method = '' } = scale; + + const spectra = spectraProcessor.getSpectra(ids); + + // Check if we can reuse the cache + if (cache.optionsHash === optionsHash) { + let validCache = true; + for (const spectrum of spectra) { + if (!cache.weakMap?.get(spectrum.normalized)) validCache = false; + } + if (validCache) return cache; + } + const weakMap = new WeakMap(); + for (const spectrum of spectra) { + weakMap.set(spectrum.normalized, true); + } + + const normalizedData = getNormalizedData(spectra); + + for (const filter of filters) { + switch (filter.name) { + case 'pqn': { + normalizedData.matrix = matrixPQN( + normalizedData.matrix, + filter.options, + ).data; + break; + } + case 'centerMean': { + normalizedData.matrix = matrixCenterZMean(normalizedData.matrix); + break; + } + case 'rescale': { + normalizedData.matrix = matrixZRescale( + normalizedData.matrix, + filter.options, + ); + break; + } + case '': + case undefined: + break; + default: + throw new Error(`Unknown matrix filter name: ${filter.name}`); + } + } + + const normalizedTarget = targetID + ? spectraProcessor.getSpectrum(targetID)?.normalized + : spectraProcessor.spectra[0].normalized; + + if (!normalizedTarget) { + throw new Error('No normalized target found'); + } + + if (method) { + switch (method.toLowerCase()) { + case 'min': + min(normalizedData.matrix, normalizedTarget, range); + break; + case 'max': + max(normalizedData.matrix, normalizedTarget, range); + break; + case 'minmax': + minMax(normalizedData.matrix, normalizedTarget, range); + break; + case 'integration': + integration(normalizedData.matrix, normalizedTarget, range); + break; + default: + throw new Error(`getPostProcessedData: unknown method: ${method}`); + } + } + + if (relative) { + for (let i = 0; i < normalizedData.matrix.length; i++) { + normalizedData.matrix[i] = xSubtract( + normalizedData.matrix[i], + normalizedTarget.y, + ); + } + } + + const result: PostProcessedDataResult = normalizedData; + + if (ranges) { + result.ranges = []; + for (const spectrum of normalizedData.matrix) { + const rangesCopy = structuredClone(ranges); + const yNormalized = spectrum; + const resultRanges: Record = {}; + result.ranges.push(resultRanges); + for (const currentRange of rangesCopy) { + if (currentRange.label) { + const fromToIndex = xGetFromToIndex(normalizedTarget.x, currentRange); + + const deltaX = normalizedTarget.x[1] - normalizedTarget.x[0]; + + currentRange.integration = xSum(yNormalized, fromToIndex) * deltaX; + currentRange.maxPoint = xyMaxYPoint( + { x: normalizedData.x, y: yNormalized }, + fromToIndex, + ); + resultRanges[currentRange.label] = currentRange; + } + } + } + } + + if (calculations && result.ranges) { + result.calculations = result.ranges.map(() => { + return {}; + }); + const parameters = Object.keys(result.ranges[0]); + for (const calculation of calculations) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const callback = new Function( + ...parameters, + `return ${calculation.formula}`, + ); + for (let i = 0; i < result.ranges.length; i++) { + const oneRanges = result.ranges[i]; + const values = parameters.map((key) => oneRanges[key].integration); + result.calculations[i][calculation.label] = callback(...values); + } + } + } + + cache = { ...result, optionsHash, weakMap }; + return cache; +} diff --git a/src/spectra/getPostProcessedText.js b/src/spectra/getPostProcessedText.js deleted file mode 100644 index c1d1606..0000000 --- a/src/spectra/getPostProcessedText.js +++ /dev/null @@ -1,26 +0,0 @@ -import { getPostProcessedData } from './getPostProcessedData.js'; -import { convertToText } from './util/convertToText.js'; - -/** - * @private - * @param {SpectraProcessor} spectraProcessor - * @param {object} [options={}] - * @param {string} [options.fs='\t'] - field separator - * @param {string} [options.rs='\n'] - record (line) separator - * @param {object} [options.postProcessing={}] - post processing options - */ - -export function getPostProcessedText(spectraProcessor, options = {}) { - let { - fs = '\t', - rs = '\n', - postProcessing: postProcessingOptions = {}, - } = options; - return convertToText( - getPostProcessedData(spectraProcessor, postProcessingOptions), - { - rs, - fs, - }, - ); -} diff --git a/src/spectra/getPostProcessedText.ts b/src/spectra/getPostProcessedText.ts new file mode 100644 index 0000000..2ed3a95 --- /dev/null +++ b/src/spectra/getPostProcessedText.ts @@ -0,0 +1,49 @@ +import type { SpectraProcessor } from '../SpectraProcessor.js'; + +import type { GetPostProcessedDataOptions } from './getPostProcessedData.js'; +import { getPostProcessedData } from './getPostProcessedData.js'; +import type { ConvertToTextOptions } from './util/convertToText.js'; +import { convertToText } from './util/convertToText.js'; + +export interface GetPostProcessedTextOptions extends ConvertToTextOptions { + /** + * Post processing options + */ + postProcessing?: GetPostProcessedDataOptions; +} + +/** + * Get post-processed data as text + * @param spectraProcessor - SpectraProcessor instance + * @param options - Options for post-processing and formatting + * @returns Text representation of post-processed data + */ +export function getPostProcessedText( + spectraProcessor: SpectraProcessor, + options: GetPostProcessedTextOptions = {}, +): string { + const { + fs = '\t', + rs = '\n', + postProcessing: postProcessingOptions = {}, + } = options; + const data = getPostProcessedData(spectraProcessor, postProcessingOptions); + + // Only convert if we have valid data + if (!data.matrix || !data.x || !data.ids || !data.meta) { + return ''; + } + + return convertToText( + { + matrix: data.matrix, + x: data.x, + ids: data.ids, + meta: data.meta, + }, + { + rs, + fs, + }, + ); +} diff --git a/src/spectra/scaled/__tests__/integration.test.js b/src/spectra/scaled/__tests__/integration.test.ts similarity index 89% rename from src/spectra/scaled/__tests__/integration.test.js rename to src/spectra/scaled/__tests__/integration.test.ts index 826d297..cd46f32 100644 --- a/src/spectra/scaled/__tests__/integration.test.js +++ b/src/spectra/scaled/__tests__/integration.test.ts @@ -12,7 +12,7 @@ test('recale integration', () => { [3, 4, 5, 6], ]; - let normalizedTarget = { x: [0, 1, 2, 3], y: [2, 3, 4, 5] }; + const normalizedTarget = { x: [0, 1, 2, 3], y: [2, 3, 4, 5] }; integration(matrix, normalizedTarget, { from: 0.9, diff --git a/src/spectra/scaled/__tests__/max.test.js b/src/spectra/scaled/__tests__/max.test.ts similarity index 89% rename from src/spectra/scaled/__tests__/max.test.js rename to src/spectra/scaled/__tests__/max.test.ts index 7f82d78..3064541 100644 --- a/src/spectra/scaled/__tests__/max.test.js +++ b/src/spectra/scaled/__tests__/max.test.ts @@ -12,7 +12,7 @@ test('recale max', () => { [3, 4, 5, 6], ]; - let normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; + const normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; max(matrix, normalizedTarget, { from: 0.9, diff --git a/src/spectra/scaled/__tests__/min.test.js b/src/spectra/scaled/__tests__/min.test.ts similarity index 89% rename from src/spectra/scaled/__tests__/min.test.js rename to src/spectra/scaled/__tests__/min.test.ts index a3b242c..6a705f4 100644 --- a/src/spectra/scaled/__tests__/min.test.js +++ b/src/spectra/scaled/__tests__/min.test.ts @@ -12,7 +12,7 @@ test('recale min', () => { [3, 4, 5, 6], ]; - let normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; + const normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; min(matrix, normalizedTarget, { from: 0.9, diff --git a/src/spectra/scaled/__tests__/minMax.test.js b/src/spectra/scaled/__tests__/minMax.test.ts similarity index 89% rename from src/spectra/scaled/__tests__/minMax.test.js rename to src/spectra/scaled/__tests__/minMax.test.ts index 84b6516..59c436d 100644 --- a/src/spectra/scaled/__tests__/minMax.test.js +++ b/src/spectra/scaled/__tests__/minMax.test.ts @@ -13,7 +13,7 @@ test('recale minMax', () => { [3, 5, 7, 9], ]; - let normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; + const normalizedTarget = { x: [0, 1, 2, 3], y: [1, 2, 3, 4] }; minMax(matrix, normalizedTarget, { from: 0.9, to: 2.1 }); diff --git a/src/spectra/scaled/getFromToIndex.js b/src/spectra/scaled/getFromToIndex.js deleted file mode 100644 index 980c58d..0000000 --- a/src/spectra/scaled/getFromToIndex.js +++ /dev/null @@ -1,16 +0,0 @@ -import { xFindClosestIndex } from 'ml-spectra-processing'; - -export function getFromToIndex(xs, range) { - let { from, to } = range; - if (from === undefined) { - from = xs[0]; - } - if (to === undefined) { - to = xs.at(-1); - } - - return { - fromIndex: xFindClosestIndex(xs, from), - toIndex: xFindClosestIndex(xs, to), - }; -} diff --git a/src/spectra/scaled/integration.js b/src/spectra/scaled/integration.js deleted file mode 100644 index 7ef19f1..0000000 --- a/src/spectra/scaled/integration.js +++ /dev/null @@ -1,15 +0,0 @@ -import { xMultiply, xSum } from 'ml-spectra-processing'; - -import { getFromToIndex } from './getFromToIndex.js'; - -export function integration(matrix, normalizedTarget, range = {}) { - let fromToIndex = getFromToIndex(normalizedTarget.x, range); - - let targetValue = xSum(normalizedTarget.y, fromToIndex); - let values = matrix.map((row) => xSum(row, fromToIndex)); - - for (let i = 0; i < matrix.length; i++) { - let factor = targetValue / values[i]; - matrix[i] = xMultiply(matrix[i], factor); - } -} diff --git a/src/spectra/scaled/integration.ts b/src/spectra/scaled/integration.ts new file mode 100644 index 0000000..672a72d --- /dev/null +++ b/src/spectra/scaled/integration.ts @@ -0,0 +1,25 @@ +import type { DataXY, DoubleArray, DoubleMatrix } from 'cheminfo-types'; +import type { XGetFromToIndexOptions } from 'ml-spectra-processing'; +import { xGetFromToIndex, xMultiply, xSum } from 'ml-spectra-processing'; + +/** + * Scale matrix rows to match integration (sum) of target + * @param matrix - Matrix to scale (modified in place) + * @param normalizedTarget - Target data with x and y arrays + * @param range - Optional range to consider + */ +export function integration( + matrix: DoubleMatrix, + normalizedTarget: DataXY, + range: XGetFromToIndexOptions = {}, +): void { + const fromToIndex = xGetFromToIndex(normalizedTarget.x as DoubleArray, range); + + const targetValue = xSum(normalizedTarget.y as DoubleArray, fromToIndex); + const values = matrix.map((row) => xSum(row, fromToIndex)); + + for (let i = 0; i < matrix.length; i++) { + const factor = targetValue / values[i]; + matrix[i] = xMultiply(matrix[i], factor); + } +} diff --git a/src/spectra/scaled/max.js b/src/spectra/scaled/max.js deleted file mode 100644 index 88e0e5f..0000000 --- a/src/spectra/scaled/max.js +++ /dev/null @@ -1,15 +0,0 @@ -import { xMaxValue, xMultiply } from 'ml-spectra-processing'; - -import { getFromToIndex } from './getFromToIndex.js'; - -export function max(matrix, normalizedTarget, range = {}) { - let fromToIndex = getFromToIndex(normalizedTarget.x, range); - - let targetValue = xMaxValue(normalizedTarget.y, fromToIndex); - let values = matrix.map((row) => xMaxValue(row, fromToIndex)); - - for (let i = 0; i < matrix.length; i++) { - let factor = targetValue / values[i]; - matrix[i] = xMultiply(matrix[i], factor); - } -} diff --git a/src/spectra/scaled/max.ts b/src/spectra/scaled/max.ts new file mode 100644 index 0000000..375ba58 --- /dev/null +++ b/src/spectra/scaled/max.ts @@ -0,0 +1,25 @@ +import type { DataXY, DoubleArray, DoubleMatrix } from 'cheminfo-types'; +import type { XGetFromToIndexOptions } from 'ml-spectra-processing'; +import { xGetFromToIndex, xMaxValue, xMultiply } from 'ml-spectra-processing'; + +/** + * Scale matrix rows to match maximum value of target + * @param matrix - Matrix to scale (modified in place) + * @param normalizedTarget - Target data with x and y arrays + * @param range - Optional range to consider + */ +export function max( + matrix: DoubleMatrix, + normalizedTarget: DataXY, + range: XGetFromToIndexOptions = {}, +): void { + const fromToIndex = xGetFromToIndex(normalizedTarget.x as DoubleArray, range); + + const targetValue = xMaxValue(normalizedTarget.y as DoubleArray, fromToIndex); + const values = matrix.map((row) => xMaxValue(row, fromToIndex)); + + for (let i = 0; i < matrix.length; i++) { + const factor = targetValue / values[i]; + matrix[i] = xMultiply(matrix[i], factor); + } +} diff --git a/src/spectra/scaled/min.js b/src/spectra/scaled/min.js deleted file mode 100644 index 610cb65..0000000 --- a/src/spectra/scaled/min.js +++ /dev/null @@ -1,15 +0,0 @@ -import { xMinValue, xMultiply } from 'ml-spectra-processing'; - -import { getFromToIndex } from './getFromToIndex.js'; - -export function min(matrix, normalizedTarget, range = {}) { - let fromToIndex = getFromToIndex(normalizedTarget.x, range); - - let targetValue = xMinValue(normalizedTarget.y, fromToIndex); - let values = matrix.map((row) => xMinValue(row, fromToIndex)); - - for (let i = 0; i < matrix.length; i++) { - let factor = targetValue / values[i]; - matrix[i] = xMultiply(matrix[i], factor); - } -} diff --git a/src/spectra/scaled/min.ts b/src/spectra/scaled/min.ts new file mode 100644 index 0000000..886c0ce --- /dev/null +++ b/src/spectra/scaled/min.ts @@ -0,0 +1,25 @@ +import type { DataXY, DoubleArray, DoubleMatrix } from 'cheminfo-types'; +import type { XGetFromToIndexOptions } from 'ml-spectra-processing'; +import { xGetFromToIndex, xMinValue, xMultiply } from 'ml-spectra-processing'; + +/** + * Scale matrix rows to match minimum value of target + * @param matrix - Matrix to scale (modified in place) + * @param normalizedTarget - Target data with x and y arrays + * @param range - Optional range to consider + */ +export function min( + matrix: DoubleMatrix, + normalizedTarget: DataXY, + range: XGetFromToIndexOptions = {}, +): void { + const fromToIndex = xGetFromToIndex(normalizedTarget.x as DoubleArray, range); + + const targetValue = xMinValue(normalizedTarget.y as DoubleArray, fromToIndex); + const values = matrix.map((row) => xMinValue(row, fromToIndex)); + + for (let i = 0; i < matrix.length; i++) { + const factor = targetValue / values[i]; + matrix[i] = xMultiply(matrix[i], factor); + } +} diff --git a/src/spectra/scaled/minMax.js b/src/spectra/scaled/minMax.js deleted file mode 100644 index 257eeb1..0000000 --- a/src/spectra/scaled/minMax.js +++ /dev/null @@ -1,32 +0,0 @@ -import { xMaxValue, xMinValue } from 'ml-spectra-processing'; - -import { getFromToIndex } from './getFromToIndex.js'; - -export function minMax(matrix, normalizedTarget, range = {}) { - let fromToIndex = getFromToIndex(normalizedTarget.x, range); - let targetValue = { - min: xMinValue(normalizedTarget.y, fromToIndex), - max: xMaxValue(normalizedTarget.y, fromToIndex), - }; - - let deltaTarget = targetValue.max - targetValue.min; - let minTarget = targetValue.min; - - let values = matrix.map((row) => { - return { - min: xMinValue(row, fromToIndex), - max: xMaxValue(row, fromToIndex), - }; - }); - for (let i = 0; i < matrix.length; i++) { - let deltaSource = values[i].max - values[i].min; - let minSource = values[i].min; - let newData = []; - for (let j = 0; j < normalizedTarget.y.length; j++) { - newData.push( - ((matrix[i][j] - minSource) / deltaSource) * deltaTarget + minTarget, - ); - } - matrix[i] = newData; - } -} diff --git a/src/spectra/scaled/minMax.ts b/src/spectra/scaled/minMax.ts new file mode 100644 index 0000000..ab431e3 --- /dev/null +++ b/src/spectra/scaled/minMax.ts @@ -0,0 +1,42 @@ +import type { DataXY, DoubleArray, DoubleMatrix } from 'cheminfo-types'; +import type { XGetFromToIndexOptions } from 'ml-spectra-processing'; +import { xGetFromToIndex, xMaxValue, xMinValue } from 'ml-spectra-processing'; + +/** + * Scale matrix rows to match min-max range of target + * @param matrix - Matrix to scale (modified in place) + * @param normalizedTarget - Target data with x and y arrays + * @param range - Optional range to consider + */ +export function minMax( + matrix: DoubleMatrix, + normalizedTarget: DataXY, + range: XGetFromToIndexOptions = {}, +): void { + const fromToIndex = xGetFromToIndex(normalizedTarget.x as DoubleArray, range); + const targetValue = { + min: xMinValue(normalizedTarget.y as DoubleArray, fromToIndex), + max: xMaxValue(normalizedTarget.y as DoubleArray, fromToIndex), + }; + + const deltaTarget = targetValue.max - targetValue.min; + const minTarget = targetValue.min; + + const values = matrix.map((row) => { + return { + min: xMinValue(row, fromToIndex), + max: xMaxValue(row, fromToIndex), + }; + }); + for (let i = 0; i < matrix.length; i++) { + const deltaSource = values[i].max - values[i].min; + const minSource = values[i].min; + const newData: number[] = []; + for (let j = 0; j < normalizedTarget.y.length; j++) { + newData.push( + ((matrix[i][j] - minSource) / deltaSource) * deltaTarget + minTarget, + ); + } + matrix[i] = newData; + } +} diff --git a/src/spectra/util/convertToText.js b/src/spectra/util/convertToText.js deleted file mode 100644 index ddaceb8..0000000 --- a/src/spectra/util/convertToText.js +++ /dev/null @@ -1,37 +0,0 @@ -export function convertToText(data, options = {}) { - let { fs = '\t', rs = '\n' } = options; - let { matrix, meta, ids, x } = data; - let allKeysObject = {}; - for (let metum of meta) { - if (metum) { - for (let key of Object.keys(metum)) { - let type = typeof metum[key]; - if (type === 'number' || type === 'string' || type === 'boolean') { - allKeysObject[key] = true; - } - } - } - } - let allKeys = Object.keys(allKeysObject); - - let lines = []; - let line = ['id', ...allKeys, ...x]; - lines.push(line.join(fs)); - - for (let i = 0; i < ids.length; i++) { - line = [ids[i]]; - for (let key of allKeys) { - line.push(removeSpecialCharacters(meta[i][key])); - } - line.push(...matrix[i]); - lines.push(line.join(fs)); - } - return lines.join(rs); -} - -function removeSpecialCharacters(string) { - if (typeof string !== 'string') { - return string; - } - return string.replaceAll(/[\t\n\r]+/g, ' '); -} diff --git a/src/spectra/util/convertToText.ts b/src/spectra/util/convertToText.ts new file mode 100644 index 0000000..7a41971 --- /dev/null +++ b/src/spectra/util/convertToText.ts @@ -0,0 +1,68 @@ +import type { DoubleArray, DoubleMatrix } from 'cheminfo-types'; + +export interface ConvertToTextOptions { + /** + * Field separator + * @default '\t' + */ + fs?: string; + /** + * Record (line) separator + * @default '\n' + */ + rs?: string; +} + +export interface DataWithMeta { + matrix: DoubleMatrix; + meta: Array>; + ids: string[]; + x: DoubleArray; +} + +/** + * Convert data to text format + * @param data - Data including matrix, meta, ids, and x values + * @param options - Conversion options + * @returns Formatted text string + */ +export function convertToText( + data: DataWithMeta, + options: ConvertToTextOptions = {}, +): string { + const { fs = '\t', rs = '\n' } = options; + const { matrix, meta, ids, x } = data; + const allKeysObject: Record = {}; + for (const metum of meta) { + if (metum) { + for (const key of Object.keys(metum)) { + const type = typeof metum[key]; + if (type === 'number' || type === 'string' || type === 'boolean') { + allKeysObject[key] = true; + } + } + } + } + const allKeys = Object.keys(allKeysObject); + + const lines: string[] = []; + let line: Array = ['id', ...allKeys, ...x]; + lines.push(line.join(fs)); + + for (let i = 0; i < ids.length; i++) { + line = [ids[i]]; + for (const key of allKeys) { + line.push(removeSpecialCharacters(meta[i][key])); + } + line.push(...matrix[i]); + lines.push(line.join(fs)); + } + return lines.join(rs); +} + +function removeSpecialCharacters(value: any): any { + if (typeof value !== 'string') { + return value; + } + return value.replaceAll(/[\t\n\r]+/g, ' '); +} diff --git a/src/spectrum/Spectrum.ts b/src/spectrum/Spectrum.ts index 04a4b63..9382364 100644 --- a/src/spectrum/Spectrum.ts +++ b/src/spectrum/Spectrum.ts @@ -1,4 +1,4 @@ -import type { DataXY, NumberArray } from 'cheminfo-types'; +import type { DataXY, DoubleArray, NumberArray } from 'cheminfo-types'; import { xMinMaxValues } from 'ml-spectra-processing'; import type { RangeInfo } from './RangeInfo.ts'; @@ -31,7 +31,7 @@ export interface MemoryStats { export interface SpectrumOptions { meta?: Record; normalization?: Record; - normalized?: DataXY; + normalized?: DataXY; } /** @@ -48,7 +48,7 @@ export class Spectrum { id: string; meta: Record; normalizedBoundary: AxisBoundary; - normalized: DataXY; + normalized: DataXY; normalizedAllowedBoundary?: AllowedBoundary; memory?: MemoryStats; ranges?: Record; @@ -149,7 +149,7 @@ export class Spectrum { * Update normalization * @param normalization - normalization configuration */ - updateNormalization(normalization: Record): DataXY { + updateNormalization(normalization: Record): DataXY { const result = getNormalized(this, normalization); this.normalized = result.data; this.normalizedAllowedBoundary = result.allowedBoundary; diff --git a/src/spectrum/getNormalized.ts b/src/spectrum/getNormalized.ts index 4435248..919a556 100644 --- a/src/spectrum/getNormalized.ts +++ b/src/spectrum/getNormalized.ts @@ -1,4 +1,4 @@ -import type { DataXY } from 'cheminfo-types'; +import type { DataXY, DoubleArray } from 'cheminfo-types'; import type { FilterXYType } from 'ml-signal-processing'; import { filterXY } from 'ml-signal-processing'; import { xMinMaxValues, xyCheck } from 'ml-spectra-processing'; @@ -8,7 +8,7 @@ interface NormalizeOptions { to?: number; numberOfPoints?: number; filters?: FilterXYType[]; - exclusions?: any[]; + exclusions?: unknown[]; applyRangeSelectionFirst?: boolean; } @@ -23,8 +23,8 @@ export interface AllowedBoundary { }; } -export interface NormalizeResult { - data: DataXY; +export interface NormalizedResult { + data: DataXY; allowedBoundary: AllowedBoundary; } @@ -37,7 +37,7 @@ export interface NormalizeResult { export function getNormalized( input: DataXY, options: NormalizeOptions = {}, -): NormalizeResult { +): NormalizedResult { xyCheck(input); let { filters = [] } = options;