diff --git a/apps/class-solid/src/components/Analysis.tsx b/apps/class-solid/src/components/Analysis.tsx index 18c3d64..50aed1e 100644 --- a/apps/class-solid/src/components/Analysis.tsx +++ b/apps/class-solid/src/components/Analysis.tsx @@ -1,16 +1,10 @@ import type { Config } from "@classmodel/class/config"; -import { calculatePlume, transposePlumeData } from "@classmodel/class/fire"; import { - type ClassOutput, type OutputVariableKey, - getOutputAtTime, outputVariables, } from "@classmodel/class/output"; -import { - type ClassProfile, - NoProfile, - generateProfiles, -} from "@classmodel/class/profiles"; +import type { ClassProfile } from "@classmodel/class/profiles"; +import type { ClassData } from "@classmodel/class/runner"; import * as d3 from "d3"; import { saveAs } from "file-saver"; import { toBlob } from "html-to-image"; @@ -43,7 +37,7 @@ import { MdiCamera, MdiDelete, MdiImageFilterCenterFocus } from "./icons"; import { AxisBottom, AxisLeft, getNiceAxisLimits } from "./plots/Axes"; import { Chart, ChartContainer, type ChartData } from "./plots/ChartContainer"; import { Legend } from "./plots/Legend"; -import { Line, type Point } from "./plots/Line"; +import { Line } from "./plots/Line"; import { SkewTPlot, type SoundingRecord } from "./plots/skewTlogP"; import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; @@ -76,7 +70,7 @@ interface FlatExperiment { color: string; linestyle: string; config: Config; - output?: ClassOutput; + output?: ClassData; } // Create a derived store for looping over all outputs: @@ -117,7 +111,7 @@ const flatObservations: () => Observation[] = createMemo(() => { }); const _allTimes = () => - new Set(flatExperiments().flatMap((e) => e.output?.utcTime ?? [])); + new Set(flatExperiments().flatMap((e) => e.output?.timeseries.utcTime ?? [])); const uniqueTimes = () => [...new Set(_allTimes())].sort((a, b) => a - b); // TODO: could memoize all reactive elements here, would it make a difference? @@ -131,11 +125,15 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { const allX = () => flatExperiments().flatMap((e) => - e.output ? e.output[analysis.xVariable as OutputVariableKey] : [], + e.output + ? e.output.timeseries[analysis.xVariable as OutputVariableKey] + : [], ); const allY = () => flatExperiments().flatMap((e) => - e.output ? e.output[analysis.yVariable as OutputVariableKey] : [], + e.output + ? e.output.timeseries[analysis.yVariable as OutputVariableKey] + : [], ); const granularities: Record = { @@ -155,12 +153,12 @@ export function TimeSeriesPlot({ analysis }: { analysis: TimeseriesAnalysis }) { ...formatting, data: // Zip x[] and y[] into [x, y][] - output?.t.map((_, t) => ({ + output?.timeseries.t.map((_, t) => ({ x: output - ? output[analysis.xVariable as OutputVariableKey][t] + ? output.timeseries[analysis.xVariable as OutputVariableKey][t] : Number.NaN, y: output - ? output[analysis.yVariable as OutputVariableKey][t] + ? output.timeseries[analysis.yVariable as OutputVariableKey][t] : Number.NaN, })) || [], }; @@ -258,87 +256,90 @@ export function VerticalProfilePlot({ variableOptions[analysis.variable as keyof typeof variableOptions]; type PlumeVariable = "theta" | "qt" | "thetav" | "T" | "Td" | "rh" | "w"; + function isPlumeVariable(v: string): v is PlumeVariable { return ["theta", "qt", "thetav", "T", "Td", "rh", "w"].includes(v); } - const showPlume = createMemo(() => isPlumeVariable(classVariable())); + type LineSet = { + label: string; + color: string; + linestyle: string; + data: { x: number; y: number }[]; + }; - const observations = () => - flatObservations().map((o) => observationsForProfile(o, classVariable())); + function getLinesForExperiment( + e: FlatExperiment, + variable: string, + type: "profiles" | "plumes", + timeVal: number, + ): LineSet { + const { label, color, linestyle, output } = e; - const profileData = () => - flatExperiments().map((e) => { - const { config, output, ...formatting } = e; - const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]); - if (config.sw_ml && output && t !== undefined && t !== -1) { - const outputAtTime = getOutputAtTime(output, t); - return { ...formatting, data: generateProfiles(config, outputAtTime) }; - } - return { ...formatting, data: NoProfile }; - }); + if (!output) return { label, color, linestyle, data: [] }; - const firePlumes = () => - flatExperiments().map((e, i) => { - const { config, output, ...formatting } = e; - if (config.sw_fire && isPlumeVariable(classVariable())) { - const plume = transposePlumeData( - calculatePlume(config, profileData()[i].data), - ); - return { - ...formatting, - linestyle: "4", - data: plume.z.map((z, i) => ({ - x: plume[classVariable() as PlumeVariable][i], - y: z, - })), - }; - } - return { ...formatting, data: [] }; - }); + const profile = output[type]; + if (!profile) return { label, color, linestyle, data: [] }; - // TODO: There should be a way that this isn't needed. - const profileDataForPlot = () => - profileData().map(({ data, label, color, linestyle }) => ({ - label, + // Find experiment-specific time index + const tIndex = output.timeseries?.utcTime?.indexOf(timeVal); + if (tIndex === undefined || tIndex === -1) + return { label, color, linestyle, data: [] }; + + const linesAtTime = profile[variable]?.[tIndex] ?? []; + + return { + label: type === "plumes" ? `${label} - plume` : label, color, - linestyle, - data: data.z.map((z, i) => ({ - x: data[classVariable()][i], - y: z, - })), - })) as ChartData[]; - - const allX = () => [ - ...firePlumes().flatMap((p) => p.data.map((d) => d.x)), - ...profileDataForPlot().flatMap((p) => p.data.map((d) => d.x)), - ...observations().flatMap((obs) => obs.data.map((d) => d.x)), - ]; - const allY = () => [ - ...firePlumes().flatMap((p) => p.data.map((d) => d.y)), - ...profileDataForPlot().flatMap((p) => p.data.map((d) => d.y)), - ...observations().flatMap((obs) => obs.data.map((d) => d.y)), - ]; - - // TODO: better to include jump at top in extent calculation rather than adding random margin. - const xLim = () => getNiceAxisLimits(allX(), 1); - const yLim = () => [0, getNiceAxisLimits(allY(), 0)[1]] as [number, number]; + linestyle: type === "plumes" ? "4" : linestyle, + data: linesAtTime.flat(), + }; + } - function chartData() { - return [...profileData(), ...observations()]; + /** Collect all lines across experiments for a given type */ + function collectLines(type: "profiles" | "plumes"): LineSet[] { + const variable = classVariable(); + return flatExperiments().map((e) => + getLinesForExperiment(e, variable, type, uniqueTimes()[analysis.time]), + ); } - const [toggles, setToggles] = createStore>({}); + /** Lines to plot */ + const profileLines = () => collectLines("profiles"); + // Only collect plumes for experiments that actually have plume output + const plumeLines = () => + flatExperiments() + .filter((e) => e.output?.plumes) // only show plume when firemodel enabled + .filter((e) => isPlumeVariable(classVariable())) // only show plume for plume vars + .map((e) => + getLinesForExperiment( + e, + classVariable(), + "plumes", + uniqueTimes()[analysis.time], + ), + ); + const obsLines = () => + flatObservations().map((o) => observationsForProfile(o, classVariable())); + const allLines = () => [...profileLines(), ...plumeLines(), ...obsLines()]; - // Initialize all lines as visible - for (const d of chartData()) { - setToggles(d.label, true); - } + /** Global axes extents across all experiments, times, and observations */ + const allX = () => allLines().flatMap((d) => d.data.map((p) => p.x)); + const allY = () => allLines().flatMap((d) => d.data.map((p) => p.y)); + + const xLim = () => getNiceAxisLimits(allX(), 1); + const yLim = () => [0, getNiceAxisLimits(allY(), 0)[1]] as [number, number]; + /** Initialize toggles for legend */ + const [toggles, setToggles] = createStore>({}); + for (const line of allLines()) { + setToggles(line.label, true); + } function toggleLine(label: string, value: boolean) { setToggles(label, value); } + /** Change variable handler */ function changeVar(v: string) { updateAnalysis(analysis, { variable: v }); setResetPlot(analysis.id); @@ -348,39 +349,20 @@ export function VerticalProfilePlot({ <>
- [...profileData(), ...observations()]} - toggles={toggles} - onChange={toggleLine} - /> + - - {(d) => ( - - - - )} - - + {(d) => ( )} - - {(d) => ( - - - - - - )} - + analysis.variable} setValue={(v) => changeVar(v)} @@ -473,47 +455,71 @@ function Picker(props: PickerProps) { } export function ThermodynamicPlot({ analysis }: { analysis: SkewTAnalysis }) { - const profileData = () => + /** Extract profile lines from CLASS output at the current time index */ + const profileDataForPlot = () => flatExperiments().map((e) => { - const { config, output, ...formatting } = e; - const t = output?.utcTime.indexOf(uniqueTimes()[analysis.time]); - if (config.sw_ml && output && t !== undefined && t !== -1) { - const outputAtTime = getOutputAtTime(output, t); - return { ...formatting, data: generateProfiles(config, outputAtTime) }; - } - return { ...formatting, data: NoProfile }; - }); + const { output, label, color, linestyle } = e; + if (!output?.profiles) return { label, color, linestyle, data: [] }; + + const tIndex = output.timeseries?.utcTime?.indexOf( + uniqueTimes()[analysis.time], + ); + if (tIndex === undefined || tIndex === -1) + return { label, color, linestyle, data: [] }; + + // Make sure each variable exists and has data at this time + const pLine = output.profiles.p?.[tIndex] ?? []; + const TLine = output.profiles.T?.[tIndex] ?? []; + const TdLine = output.profiles.Td?.[tIndex] ?? []; + + // If any line is empty, return empty data + if (!pLine.length || !TLine.length || !TdLine.length) + return { label, color, linestyle, data: [] }; + + const data: SoundingRecord[] = pLine.map((_, i) => ({ + p: pLine[i].x / 100, + T: TLine[i].x, + Td: TdLine[i].x, + })); + + return { label, color, linestyle, data }; + }) as ChartData[]; const firePlumes = () => - flatExperiments().map((e, i) => { - const { config, output, ...formatting } = e; - if (config.sw_fire) { + flatExperiments() + .map((e) => { + const output = e.output; + if (!output?.plumes) return null; // skip if no plume + + const tIndex = output.timeseries?.utcTime?.indexOf( + uniqueTimes()[analysis.time], + ); + if (tIndex === undefined || tIndex === -1) return null; + + const pLine = output.plumes.p?.[tIndex] ?? []; + const TLine = output.plumes.T?.[tIndex] ?? []; + const TdLine = output.plumes.Td?.[tIndex] ?? []; + + if (!pLine.length || !TLine.length || !TdLine.length) return null; + + const data: SoundingRecord[] = pLine.map((_, i) => ({ + p: pLine[i].x, + T: TLine[i].x, + Td: TdLine[i].x, + })); + return { - ...formatting, + label: `${e.label} - fire plume`, color: "#ff0000", - label: `${formatting.label} - fire plume`, - data: calculatePlume(config, profileData()[i].data), + linestyle: "4", + data, }; - } - return { ...formatting, data: [] }; - }) as ChartData[]; + }) + .filter((d): d is ChartData => d !== null); const observations = () => flatObservations().map((o) => observationsForSounding(o)); - // TODO: There should be a way that this isn't needed. - const profileDataForPlot = () => - profileData().map(({ data, label, color, linestyle }) => ({ - label, - color, - linestyle, - data: data.p.map((p, i) => ({ - p: p / 100, - T: data.T[i], - Td: data.Td[i], - })), - })) as ChartData[]; - return ( <> output[h][i]).join(",")); + const lines: string[] = []; + + // CSV header + lines.push(headers.join(",")); + + // Determine number of rows from the first variable + const nRows = output[headers[0]]?.length ?? 0; + + for (let i = 0; i < nRows; i++) { + const row = headers.map((h) => output[h][i]); + lines.push(row.join(",")); } + return lines.join("\n"); } @@ -32,9 +42,12 @@ export async function createArchive(experiment: Experiment) { await zipWriter.add("config.json", new BlobReader(configBlob)); if (experiment.output.reference) { - const csvBlob = new Blob([outputToCsv(experiment.output.reference)], { - type: "text/csv", - }); + const csvBlob = new Blob( + [outputToCsv(experiment.output.reference.timeseries)], + { + type: "text/csv", + }, + ); await zipWriter.add( `${experiment.config.reference.name}.csv`, new BlobReader(csvBlob), @@ -45,7 +58,7 @@ export async function createArchive(experiment: Experiment) { const permConfig = experiment.config.permutations[index]; const permutationOutput = experiment.output.permutations[index]; if (permutationOutput) { - const csvBlob = new Blob([outputToCsv(permutationOutput)], { + const csvBlob = new Blob([outputToCsv(permutationOutput.timeseries)], { type: "text/csv", }); await zipWriter.add(`${permConfig.name}.csv`, new BlobReader(csvBlob)); diff --git a/apps/class-solid/src/lib/runner.ts b/apps/class-solid/src/lib/runner.ts index bae7006..98fdcd7 100644 --- a/apps/class-solid/src/lib/runner.ts +++ b/apps/class-solid/src/lib/runner.ts @@ -1,6 +1,5 @@ import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/output"; -import type { runClass } from "@classmodel/class/runner"; +import type { ClassData, runClass } from "@classmodel/class/runner"; import { wrap } from "comlink"; const worker = new Worker(new URL("./worker.ts", import.meta.url), { @@ -9,7 +8,7 @@ const worker = new Worker(new URL("./worker.ts", import.meta.url), { const asyncRunner = wrap(worker); -export async function runClassAsync(config: Config): Promise { +export async function runClassAsync(config: Config): Promise { try { const output = asyncRunner(config); return output; diff --git a/apps/class-solid/src/lib/store.ts b/apps/class-solid/src/lib/store.ts index d68bc4a..2024ef2 100644 --- a/apps/class-solid/src/lib/store.ts +++ b/apps/class-solid/src/lib/store.ts @@ -2,12 +2,12 @@ import { createUniqueId } from "solid-js"; import { createStore, produce, unwrap } from "solid-js/store"; import type { Config } from "@classmodel/class/config"; -import type { ClassOutput } from "@classmodel/class/output"; import { mergeConfigurations, pruneConfig, } from "@classmodel/class/config_utils"; +import type { ClassData } from "@classmodel/class/runner"; import type { Analysis, ProfilesAnalysis, @@ -21,8 +21,8 @@ import { findPresetByName } from "./presets"; import { runClassAsync } from "./runner"; interface ExperimentOutput { - reference?: ClassOutput; - permutations: Array; + reference?: ClassData; + permutations: Array; running: number | false; } diff --git a/packages/class/package.json b/packages/class/package.json index 33f12ee..66d8a2a 100644 --- a/packages/class/package.json +++ b/packages/class/package.json @@ -95,7 +95,8 @@ "dependencies": { "@commander-js/extra-typings": "^12.1.0", "ajv": "^8.17.1", - "commander": "^12.1.0" + "commander": "^12.1.0", + "simplify-js": "^1.2.4" }, "private": false, "publishConfig": { diff --git a/packages/class/src/cli.ts b/packages/class/src/cli.ts index 60f175b..944c30e 100755 --- a/packages/class/src/cli.ts +++ b/packages/class/src/cli.ts @@ -7,8 +7,8 @@ import { readFile, writeFile } from "node:fs/promises"; import { EOL } from "node:os"; import { Command, Option } from "@commander-js/extra-typings"; import { jsonSchemaOfConfig } from "./config.js"; -import type { ClassOutput, OutputVariableKey } from "./output.js"; -import { runClass } from "./runner.js"; +import type { OutputVariableKey } from "./output.js"; +import { type TimeSeries0D, runClass } from "./runner.js"; import { parse } from "./validate.js"; /** @@ -51,7 +51,7 @@ async function writeTextFile(body: string, fn: string): Promise { /** * Create a DSV (delimiter-separated values) string from an object of arrays. */ -function dsv(output: ClassOutput, delimiter: string): string { +function dsv(output: TimeSeries0D, delimiter: string): string { const keys = Object.keys(output) as OutputVariableKey[]; // order of headers is now in which they were added to the object // TODO make configurable: which columns and in which order @@ -67,7 +67,7 @@ function dsv(output: ClassOutput, delimiter: string): string { /** * Format the output. */ -function formatOutput(output: ClassOutput, format: string): string { +function formatOutput(output: TimeSeries0D, format: string): string { switch (format) { case "json": return JSON.stringify(output, null, 2); @@ -115,7 +115,7 @@ function buildCommand() { } const startTime = Date.now(); - const output = runClass(config); + const output = runClass(config).timeseries; const duration = Date.now() - startTime; if (options.debug) { diff --git a/packages/class/src/output.ts b/packages/class/src/output.ts index c320afa..42d3350 100644 --- a/packages/class/src/output.ts +++ b/packages/class/src/output.ts @@ -118,14 +118,4 @@ export const outputVariables = { } as const satisfies Record; export type OutputVariableKey = keyof typeof outputVariables; -export type ClassOutput = Record; -export type ClassOutputAtSingleTime = Record; - -export function getOutputAtTime( - output: ClassOutput, - timeIndex: number, -): ClassOutputAtSingleTime { - return Object.fromEntries( - Object.entries(output).map(([key, values]) => [key, values[timeIndex]]), - ) as ClassOutputAtSingleTime; -} +export type ClassOutput = Record; diff --git a/packages/class/src/profiles.ts b/packages/class/src/profiles.ts index 06b8de3..f14a0b0 100644 --- a/packages/class/src/profiles.ts +++ b/packages/class/src/profiles.ts @@ -1,7 +1,7 @@ // profiles.ts import type { MixedLayerConfig, NoWindConfig, WindConfig } from "./config.js"; -import type { ClassOutputAtSingleTime } from "./output.js"; +import type { ClassOutput } from "./output.js"; import { dewpoint, qsatLiq, @@ -18,7 +18,7 @@ const CONSTANTS = { /** * Atmospheric vertical profiles */ -export interface ClassProfile { +export interface ClassProfile extends Record { z: number[]; // Height levels (cell centers) [m] theta: number[]; // Potential temperature [K] thetav: number[]; // Virtual potential temperature [K] @@ -34,7 +34,7 @@ export interface ClassProfile { rh: number[]; // Relative humidity [%] } -export const NoProfile: ClassProfile = { +export const noProfile: ClassProfile = { z: [], theta: [], thetav: [], @@ -55,7 +55,7 @@ export const NoProfile: ClassProfile = { */ export function generateProfiles( config: MixedLayerConfig & (WindConfig | NoWindConfig), - output: ClassOutputAtSingleTime, + output: ClassOutput, dz = 1, ): ClassProfile { const { Rd, cp, g } = CONSTANTS; diff --git a/packages/class/src/runner.ts b/packages/class/src/runner.ts index e3ca529..61404fe 100644 --- a/packages/class/src/runner.ts +++ b/packages/class/src/runner.ts @@ -10,8 +10,22 @@ import { type OutputVariableKey, outputVariables, } from "./output.js"; + +import simplify from "simplify-js"; + +import { type Parcel, calculatePlume } from "./fire.js"; +import { generateProfiles } from "./profiles.js"; import { parse } from "./validate.js"; +export type TimeSeries0D = Record; +export type TimeSeries1D = Record; + +export interface ClassData { + timeseries: TimeSeries0D; + profiles?: TimeSeries1D; + plumes?: TimeSeries1D; +} + /** * Runs the CLASS model with the given configuration and frequency. * @@ -19,24 +33,59 @@ import { parse } from "./validate.js"; * @param freq - The frequency in seconds at which to write output, defaults to 600. * @returns An object containing the output variables collected during the simulation. */ -export function runClass(config: Config, freq = 600): ClassOutput { +export function runClass(config: Config, freq = 600): ClassData { const validatedConfig = parse(config); const model = new CLASS(validatedConfig); - const output_keys = Object.keys(outputVariables) as OutputVariableKey[]; + const outputKeys = Object.keys(outputVariables) as OutputVariableKey[]; + + // Initialize output arrays + const timeseries = Object.fromEntries( + outputKeys.map((key) => [key, []]), + ) as unknown as TimeSeries0D; + const profiles: TimeSeries1D = {}; + const plumes: TimeSeries1D = {}; + // Helper function to parse class output + // calculate profiles and fireplumes, + // and export as timeseries const writeOutput = () => { - for (const key of output_keys) { + const output: Partial = {}; + + // Generate timeseries + for (const key of outputKeys) { const value = model.getValue(key); if (value !== undefined) { - (output[key] as number[]).push(value as number); + output[key] = model.getValue(key); + timeseries[key].push(value as number); } } - }; - const output = Object.fromEntries( - output_keys.map((key) => [key, []]), - ) as unknown as ClassOutput; + // Generate profiles + const keysToAlign = ["p", "T", "Td"]; + if (config.sw_ml) { + const profile = generateProfiles(config, output as ClassOutput); + const profileXY = profileToXY(profile as unknown as Profile); + const simplifiedProfile = simplifyProfile(profileXY, 0.01, keysToAlign); + + for (const key of Object.keys(simplifiedProfile)) { + profiles[key] = profiles[key] || []; // Initialize if not exists + profiles[key].push(simplifiedProfile[key]); + } + + // Generate plumes + if (config.sw_fire) { + const plume = calculatePlume(config, profile); + const plumeXY = plumeToXY(plume); + const simplifiedPlume = simplifyProfile(plumeXY, 0.01, keysToAlign); + + for (const key of Object.keys(simplifiedPlume)) { + plumes[key] = plumes[key] || []; + plumes[key].push(simplifiedPlume[key]); + } + } + } + }; // Initial time writeOutput(); @@ -50,5 +99,123 @@ export function runClass(config: Config, freq = 600): ClassOutput { } } - return output; + // Construct ClassData + if (config.sw_ml) { + if (config.sw_fire) { + return { timeseries, profiles, plumes }; + } + return { timeseries, profiles }; + } + return { timeseries }; +} + +type Profile = Record & { z: number[] }; + +/** + * + * Convert a profile like {z: [], theta: [], qt: [], ...} + * to a profile like: {theta: {x: [], y: []}, qt: {x: [], y:[]}, ...} + * + * Useful to simplify profiles independently for each variable + * and also to quickly obtain the data for a line plot + */ +function profileToXY( + profile: Profile, +): Record { + const result: Record = {}; + + for (const key of Object.keys(profile)) { + const values = profile[key]; + if (!Array.isArray(values)) continue; + + // z is always the height + const z = profile.z; + + result[key] = values.map((v, i) => ({ x: v, y: z[i] })); + } + + return result; +} + +function plumeToXY(plume: Parcel[]) { + const vars = Object.keys(plume[0]).filter((k) => k !== "z"); + const result: Record = {}; + + for (const v of vars) result[v] = []; + + for (const row of plume) { + for (const v of vars) { + result[v].push({ x: row[v as keyof Parcel], y: row.z }); + } + } + return result; +} + +/** + * Compress a line by discarding points that are within a certain relative tolerance. + * Using the simplify-js package, which implements the + * Ramer-Douglas-Peucker algorithm + */ +function simplifyLine( + line: { x: number; y: number }[], + tolerance = 0.01, +): { x: number; y: number }[] { + if (line.length <= 2) return line; // Nothing to simplify + + const xs = line.map((p) => p.x); + const ys = line.map((p) => p.y); + const xRange = Math.max(...xs) - Math.min(...xs); + const yRange = Math.max(...ys) - Math.min(...ys); + const relTol = Math.min(xRange, yRange) * tolerance; + + const simplified = simplify(line, relTol, true); + // console.log(`Simplified from ${line.length} to ${simplified.length} points`); + // console.log(`Simplified`); + return simplified; +} + +/** + * Simplify and optionally align a profile. + * + * @param profileXY - Profile in {x: number; y: number}[] format + * @param tolerance - Relative tolerance for simplification (default 0.01) + * @param alignKeys - Array of variable keys to align. If `true`, align all. If `false` or empty, skip alignment. + * @returns The simplified (and optionally partially aligned) profile + */ +function simplifyProfile( + profileXY: Record, + tolerance = 0.01, + alignKeys: string[] | true | false = true, +): Record { + // Simplify each variable + const simplified: Record = {}; + for (const key in profileXY) { + simplified[key] = simplifyLine(profileXY[key], tolerance); + } + + // Decide which keys to align + let keysToAlign: string[]; + if (alignKeys === true) { + keysToAlign = Object.keys(profileXY); + } else if (Array.isArray(alignKeys) && alignKeys.length > 0) { + keysToAlign = alignKeys; + } else { + return simplified; // nothing to align + } + + // Step 3: Build union Z grid only for keys to align + const zSet = new Set( + keysToAlign.flatMap((key) => simplified[key]?.map((pt) => pt.y) ?? []), + ); + + // console.log(zSet.size); + + // Align selected variables using original profileXY + for (const key of keysToAlign) { + if (profileXY[key]) { + simplified[key] = profileXY[key].filter((pt) => zSet.has(pt.y)); + } + } + + return simplified; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eda4966..a0a0b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: commander: specifier: ^12.1.0 version: 12.1.0 + simplify-js: + specifier: ^1.2.4 + version: 1.2.4 devDependencies: '@types/node': specifier: ^20.13.1 @@ -3628,6 +3631,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simplify-js@1.2.4: + resolution: {integrity: sha512-vITfSlwt7h/oyrU42R83mtzFpwYk3+mkH9bOHqq/Qw6n8rtR7aE3NZQ5fbcyCUVVmuMJR6ynsAhOfK2qoah8Jg==} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -7829,6 +7835,8 @@ snapshots: signal-exit@4.1.0: {} + simplify-js@1.2.4: {} + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.25