From 3eea533c7caee29ced17381737f9f839a17d4e46 Mon Sep 17 00:00:00 2001 From: alanv Date: Tue, 28 Oct 2025 10:19:22 -0500 Subject: [PATCH 01/14] Add CurveFitStatsGrid.tsx --- .../src/internal/components/chart/Chart.tsx | 6 +- .../components/chart/CurveFitStatsGrid.tsx | 184 ++++++++++++++++++ packages/components/src/theme/charts.scss | 5 + packages/components/src/theme/utils.scss | 4 + 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx diff --git a/packages/components/src/internal/components/chart/Chart.tsx b/packages/components/src/internal/components/chart/Chart.tsx index 3ec911400b..28c3938b44 100644 --- a/packages/components/src/internal/components/chart/Chart.tsx +++ b/packages/components/src/internal/components/chart/Chart.tsx @@ -27,6 +27,7 @@ import { LoadingSpinner } from '../base/LoadingSpinner'; import { ChartAPIWrapper, DEFAULT_API_WRAPPER } from './api'; import { ChartConfig, ChartQueryConfig } from './models'; import { getChartRenderMsg } from './ChartBuilderModal'; +import { CurveFitStatsGrid } from './CurveFitStatsGrid'; interface ChartLoadingMaskProps { msg?: string; @@ -95,6 +96,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query const [loadingData, setLoadingData] = useState(false); const [measureStore, setMeasureStore] = useState(undefined); const [trendlineData, setTrendlineData] = useState(undefined); + const [plot, setPlot] = useState(undefined); const [renderMsg, setRenderMsg] = useState(undefined); const [loadError, setLoadError] = useState(undefined); const filterKey = useMemo(() => computeFilterKey(filters), [filters]); @@ -163,7 +165,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query } ); } else { - LABKEY_VIS.GenericChartHelper.generateChartSVG( + const plots = LABKEY_VIS.GenericChartHelper.generateChartSVG( divId, { ...chartConfig, @@ -173,6 +175,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query measureStore, trendlineData ); + setPlot(plots[0]); } } }; @@ -189,6 +192,7 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query {(isLoading(loadingState) || loadingData) && } {renderMsg && {renderMsg}}
+ {trendlineData !== undefined && }
); }); diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx new file mode 100644 index 0000000000..cea8890c34 --- /dev/null +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -0,0 +1,184 @@ +import React, { FC, memo, useMemo } from 'react'; +import { Utils } from '@labkey/api'; + +import { Row } from '../../query/selectRows'; +import { GridColumn } from '../base/models/GridColumn'; +import { Grid } from '../base/Grid'; +import { List as ImmutableList } from 'immutable'; +import { naturalSortByProperty } from '../../../public/sort'; + +// Equivalent to what we're doing when generating the hoverText in GenericChartHelper generateTrendlinePathHover +const roundedCell = (value: number) => Utils.roundNumber(value, 4); +const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedCell }); +const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'TSS', cell: roundedCell }); +const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedCell }); +const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedCell }); +const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedCell }); +const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedCell }); +const COEFFICIENTS_COLUMN = new GridColumn({ + index: 'coefficients', + title: 'Coefficients', + cell: data => { + return data.map((coefficient: number, idx: number) => ( +
+ {Utils.roundNumber(coefficient, 4)} +
+ )); + }, +}); +const STATS_COLUMNS = [R_SQUARED_COLUMN, RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN]; +const LINEAR_REGRESSION_COLUMNS = [SLOPE_COLUMN, INTERCEPT_COLUMN, ...STATS_COLUMNS]; +const POLYNOMIAL_COLUMNS = [COEFFICIENTS_COLUMN, ...STATS_COLUMNS]; + +interface GeneratedPoint { + x: number; + y: number; +} + +interface CurveFitStats { + RMSE: number; + RSquared: number; + RSS: number; + TSS: number; +} + +interface CurveFit { + type: string; // change to enum or string union +} + +interface PolynomialCurveFit extends CurveFit { + coefficients: number[]; + type: 'Polynomial'; +} + +interface LinearRegressionCurveFit extends CurveFit { + intercept: number; + slope: number; + type: 'Linear'; +} + +interface CurveFitData { + curveFit: T; + generatedPoints: GeneratedPoint[]; + stats: CurveFitStats; +} + +interface Trendline { + count: number; + data: CurveFitData; + generatedPoints: GeneratedPoint[]; + name: string; + rawData: Row[]; + total: number; +} + +interface BaseCurveFitRow extends CurveFitStats { + series: string; +} + +interface PolynomialCurveFitRow extends BaseCurveFitRow { + coefficients: number[]; +} + +interface LinearRegressionCurveFitRow extends BaseCurveFitRow { + intercept: number; + slope: number; +} + +type CurveFitRow = LinearRegressionCurveFitRow | PolynomialCurveFitRow; + +export function trendLineToCurveFitRow( + trendline: Trendline +): CurveFitRow { + const { curveFit, stats } = trendline.data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type intentionally ignored + const { type, ...curveFitRest } = curveFit; + return { + series: trendline.name, + ...curveFitRest, + ...stats, + }; +} + +type ShapeMethod = (size: number) => string; +type ScaleMethod = (value: string) => T; + +/** + * This is a subset of what our scale objects look like in our Vis API. Only defining what we're using at this moment. + */ +interface PlotScale { + scale: ScaleMethod; +} + +/** + * This is the subset of attributes from the object returned by new LABKEY.vis.Plot that we need in order to render the + * curve fit statistics. + */ +interface PlotObject { + scales: { + color: PlotScale; + shape: PlotScale; + }; +} + +interface SeriesCellProps { + colorScale: ScaleMethod; + series: string; + shapeScale: ScaleMethod; +} +const SeriesCell: FC = memo(({ colorScale, series, shapeScale }) => { + const color = colorScale(series); + // hard coded size 5 because that's what our chart legends already do + const shape = shapeScale(series)(5); + return ( +
+ + + + {series} +
+ ); +}); +SeriesCell.displayName = 'SeriesCell'; + +interface Props { + plot: PlotObject | undefined; + trendLineData: Trendline[] | Trendline[]; +} + +export const CurveFitStatsGrid: FC = memo(({ plot, trendLineData }) => { + const type = trendLineData[0].data.curveFit.type; + const hasSeries = !(trendLineData.length === 1 && trendLineData[0].name === 'All'); + const gridData = useMemo( + () => trendLineData.map(trendLineToCurveFitRow).sort(naturalSortByProperty('series')), + [trendLineData] + ); + const colorScale = plot?.scales.color; + const shapeScale = plot?.scales.shape; + const gridColumns = useMemo(() => { + const statColumns = type === 'Polynomial' ? POLYNOMIAL_COLUMNS : LINEAR_REGRESSION_COLUMNS; + let seriesColumn: GridColumn; + + if (hasSeries) { + const colConfig = { cell: undefined, index: 'series', title: 'Series' }; + + if (colorScale && shapeScale) { + colConfig.cell = (series: string) => ( + + ); + } + + seriesColumn = new GridColumn(colConfig); + } + const columns = (seriesColumn ? [seriesColumn] : []).concat(statColumns); + return ImmutableList(columns); + }, [type, hasSeries, colorScale, shapeScale]); + + return ( +
+
Statistics
+ +
+ ); +}); +CurveFitStatsGrid.displayName = 'CurveFitStatsGrid'; diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index e66756ee49..3a0d885b97 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -321,3 +321,8 @@ font-family: monospace; font-size: 13px; } + +.curve-fit-statistics__title { + font-size: 16px; + font-weight: normal; +} diff --git a/packages/components/src/theme/utils.scss b/packages/components/src/theme/utils.scss index a0f793b282..64fa7348b7 100644 --- a/packages/components/src/theme/utils.scss +++ b/packages/components/src/theme/utils.scss @@ -70,6 +70,10 @@ margin-left: 10px; } +.margin-left-small { + margin-left: 5px; +} + .margin-left-more { margin-left: 20px; } From 5c323df5a90abbef099d8c458706fca2b0a74d06 Mon Sep 17 00:00:00 2001 From: alanv Date: Tue, 28 Oct 2025 15:06:04 -0500 Subject: [PATCH 02/14] Chart.tsx: lint file --- .../src/internal/components/chart/Chart.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/components/src/internal/components/chart/Chart.tsx b/packages/components/src/internal/components/chart/Chart.tsx index 28c3938b44..181215fd7a 100644 --- a/packages/components/src/internal/components/chart/Chart.tsx +++ b/packages/components/src/internal/components/chart/Chart.tsx @@ -83,7 +83,7 @@ interface Props { chart: DataViewInfo; container?: string; filters?: Filter.IFilter[]; - queryParameters?: { [key: string]: any }; + queryParameters?: Record; } export const SVGChart: FC = memo(({ api, chart, container, filters, queryParameters }) => { @@ -192,18 +192,20 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query {(isLoading(loadingState) || loadingData) && } {renderMsg && {renderMsg}}
- {trendlineData !== undefined && } + {trendlineData !== undefined && }
); }); SVGChart.displayName = 'SVGChart'; +interface FileAnchor { + href: string; + text: string; +} + interface RReportData { error?: string; - fileAnchors: Array<{ - href: string; - text: string; - }>; + fileAnchors: FileAnchor[]; imageUrls: string[]; } @@ -313,7 +315,7 @@ const RReport: FC = memo(({ api, chart, container, filters }) => { {imageUrls !== undefined && (
{imageUrls.map(url => ( -
+
R Report Image Output
))} From fa5af0f5eae72d5337118cda032753c5ee573022 Mon Sep 17 00:00:00 2001 From: alanv Date: Tue, 28 Oct 2025 16:45:55 -0500 Subject: [PATCH 03/14] CurveFitStatsGrid - Render individual coefficient columns - Render non-linear curve statistics - Export curve statistics --- .../src/internal/components/chart/Chart.tsx | 4 +- .../components/chart/CurveFitStatsGrid.tsx | 258 +++++++++++++++--- packages/components/src/theme/charts.scss | 5 + packages/components/src/theme/utils.scss | 4 + 4 files changed, 225 insertions(+), 46 deletions(-) diff --git a/packages/components/src/internal/components/chart/Chart.tsx b/packages/components/src/internal/components/chart/Chart.tsx index 181215fd7a..37fc366297 100644 --- a/packages/components/src/internal/components/chart/Chart.tsx +++ b/packages/components/src/internal/components/chart/Chart.tsx @@ -192,7 +192,9 @@ export const SVGChart: FC = memo(({ api, chart, container, filters, query {(isLoading(loadingState) || loadingData) && } {renderMsg && {renderMsg}}
- {trendlineData !== undefined && } + {trendlineData !== undefined && ( + + )}
); }); diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index cea8890c34..471cb385b9 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -1,34 +1,50 @@ -import React, { FC, memo, useMemo } from 'react'; -import { Utils } from '@labkey/api'; +import React, { FC, memo, useCallback, useMemo } from 'react'; +import { Utils, UtilsDOM } from '@labkey/api'; import { Row } from '../../query/selectRows'; import { GridColumn } from '../base/models/GridColumn'; import { Grid } from '../base/Grid'; import { List as ImmutableList } from 'immutable'; import { naturalSortByProperty } from '../../../public/sort'; +import { DropdownButton, MenuHeader, MenuItem } from '../../dropdowns'; + +enum DelimiterType { + COMMA = 'COMMA', + EXCEL = 'EXCEL', + TAB = 'TAB', +} // Equivalent to what we're doing when generating the hoverText in GenericChartHelper generateTrendlinePathHover const roundedCell = (value: number) => Utils.roundNumber(value, 4); +const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedCell }); +const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedCell }); +const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry' }); +const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedCell }); const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedCell }); +const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ + index: 'adjustedRSquared', + title: 'Adjusted R-Squared', + cell: roundedCell, +}); const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'TSS', cell: roundedCell }); const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedCell }); const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedCell }); const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedCell }); const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedCell }); -const COEFFICIENTS_COLUMN = new GridColumn({ - index: 'coefficients', - title: 'Coefficients', - cell: data => { - return data.map((coefficient: number, idx: number) => ( -
- {Utils.roundNumber(coefficient, 4)} -
- )); - }, -}); -const STATS_COLUMNS = [R_SQUARED_COLUMN, RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN]; +const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedCell }); +const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedCell }); +const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedCell }); +const STATS_COLUMNS = [RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN, R_SQUARED_COLUMN]; +const NONLINEAR_COLUMNS = [ + MIN_COL, + MAX_COL, + ASYMMETRY_COLUMN, + INFLECTION_COLUMN, + ...STATS_COLUMNS, + ADJUSTED_R_SQUARED_COLUMN, +]; const LINEAR_REGRESSION_COLUMNS = [SLOPE_COLUMN, INTERCEPT_COLUMN, ...STATS_COLUMNS]; -const POLYNOMIAL_COLUMNS = [COEFFICIENTS_COLUMN, ...STATS_COLUMNS]; +const POLYNOMIAL_COLUMNS = [COEFFICIENT_1_COLUMN, COEFFICIENT_2_COLUMN, COEFFICIENT_3_COLUMN, ...STATS_COLUMNS]; interface GeneratedPoint { x: number; @@ -42,30 +58,53 @@ interface CurveFitStats { TSS: number; } +interface NonlinearCurveFitStats extends CurveFitStats { + adjustedRSquared: number; +} + interface CurveFit { - type: string; // change to enum or string union + type: string; } -interface PolynomialCurveFit extends CurveFit { +interface PolynomialCurveFitData { coefficients: number[]; +} + +interface PolynomialCurveFit extends CurveFit, PolynomialCurveFitData { type: 'Polynomial'; } -interface LinearRegressionCurveFit extends CurveFit { +interface LinearRegressionCurveFitData { intercept: number; slope: number; +} + +interface LinearRegressionCurveFit extends CurveFit, LinearRegressionCurveFitData { type: 'Linear'; } -interface CurveFitData { +// Note: most nonlinear curve fits also have a fitError attribute, however we are ignoring that because it's just RMSE +// or R-Squared. +interface NonlinearCurveFitData { + asymmetry: number; + inflection: number; + max: number; + min: number; +} + +interface NonlinearCurveFit extends CurveFit, NonlinearCurveFitData { + type: '3 Parameter' | '4 Parameter' | 'Five Parameter' | 'Four Parameter' | 'Three Parameter'; +} + +interface CurveFitData { curveFit: T; generatedPoints: GeneratedPoint[]; - stats: CurveFitStats; + stats: S; } -interface Trendline { +interface Trendline { count: number; - data: CurveFitData; + data: CurveFitData; generatedPoints: GeneratedPoint[]; name: string; rawData: Row[]; @@ -77,27 +116,93 @@ interface BaseCurveFitRow extends CurveFitStats { } interface PolynomialCurveFitRow extends BaseCurveFitRow { - coefficients: number[]; + coefficient1: number; + coefficient2: number; + coefficient3: number; } -interface LinearRegressionCurveFitRow extends BaseCurveFitRow { - intercept: number; - slope: number; -} +type LinearRegressionCurveFitRow = BaseCurveFitRow & LinearRegressionCurveFitData; +type NonlinearCurveFitRow = BaseCurveFitRow & NonlinearCurveFitData & NonlinearCurveFitStats; +type CurveFitRow = LinearRegressionCurveFitRow | NonlinearCurveFitRow | PolynomialCurveFitRow; +type PossibleTrendlines = + | Trendline + | Trendline + | Trendline; -type CurveFitRow = LinearRegressionCurveFitRow | PolynomialCurveFitRow; +const nonLinearTrendlineTypes = ['3 Parameter', '4 Parameter', 'Five Parameter', 'Four Parameter', 'Three Parameter']; +function isNonlinearTrendline( + trendline: PossibleTrendlines +): trendline is Trendline { + const type = trendline.data.curveFit.type; + return nonLinearTrendlineTypes.includes(type); +} -export function trendLineToCurveFitRow( - trendline: Trendline -): CurveFitRow { +function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow { const { curveFit, stats } = trendline.data; - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- type intentionally ignored - const { type, ...curveFitRest } = curveFit; - return { - series: trendline.name, - ...curveFitRest, - ...stats, - }; + const series = trendline.name; + + if (curveFit.type === 'Polynomial') { + return { + ...stats, + series, + coefficient1: curveFit.coefficients[0], + coefficient2: curveFit.coefficients[1], + coefficient3: curveFit.coefficients[2], + }; + } + + if (curveFit.type === 'Linear') { + const { intercept, slope } = curveFit; + return { ...stats, series, intercept, slope }; + } + + if (isNonlinearTrendline(trendline)) { + const { asymmetry, inflection, max, min } = curveFit; + return { ...(stats as NonlinearCurveFitStats), series, asymmetry, inflection, max, min }; + } + + return undefined; +} + +const STATS_EXPORT_COLS = ['RSS', 'TSS', 'RMSE', 'RSquared']; +const LINEAR_EXPORT_COLS = ['slope', 'intercept', ...STATS_EXPORT_COLS]; +const POLYNOMIAL_EXPORT_COLS = ['coefficient1', 'coefficient2', 'coefficient3', ...STATS_EXPORT_COLS]; +const NONLINEAR_EXPORT_COLS = ['min', 'max', 'asymmetry', 'inflection', ...STATS_EXPORT_COLS, 'adjustedRSquared']; + +const STATS_EXPORT_HEADERS = ['RSS', 'TSS', 'RMSE', 'R-Squared']; +const LINEAR_EXPORT_HEADERS = ['Slope', 'Intercept', ...STATS_EXPORT_HEADERS]; +const POLYNOMIAL_EXPORT_HEADERS = ['Coefficient 1', 'Coefficient 2', 'Coefficient 3', ...STATS_EXPORT_HEADERS]; +const NONLINEAR_EXPORT_HEADERS = [ + 'Min', + 'Max', + 'Asymmetry', + 'Inflection', + ...STATS_EXPORT_HEADERS, + 'Adjusted R-Squared', +]; + +/** + * Converts CurveFitRow[] to an array of arrays as expected by convertToExcel and convertToTable + */ +function curveFitRowsToExportFormat(hasSeries: boolean, type: string, rows: CurveFitRow[]): (number | string)[][] { + let headers = hasSeries ? ['Series'] : []; + let cols = hasSeries ? ['series'] : []; + if (type === 'Polynomial') { + headers = headers.concat(POLYNOMIAL_EXPORT_HEADERS); + cols = cols.concat(POLYNOMIAL_EXPORT_COLS); + } else if (type === 'Linear') { + headers = headers.concat(LINEAR_EXPORT_HEADERS); + cols = cols.concat(LINEAR_EXPORT_COLS); + } else { + headers = headers.concat(NONLINEAR_EXPORT_HEADERS); + cols = cols.concat(NONLINEAR_EXPORT_COLS); + } + + const exportRows = rows.map((row: CurveFitRow) => { + return cols.map(col => row[col]); + }); + exportRows.unshift(headers); + return exportRows; } type ShapeMethod = (size: number) => string; @@ -121,17 +226,19 @@ interface PlotObject { }; } +const SHAPE_SIZE = 5; // hard coded size 5 because that's what our chart legends already do + interface SeriesCellProps { colorScale: ScaleMethod; series: string; shapeScale: ScaleMethod; } + const SeriesCell: FC = memo(({ colorScale, series, shapeScale }) => { const color = colorScale(series); - // hard coded size 5 because that's what our chart legends already do - const shape = shapeScale(series)(5); + const shape = shapeScale(series)(SHAPE_SIZE); return ( -
+
@@ -142,11 +249,12 @@ const SeriesCell: FC = memo(({ colorScale, series, shapeScale } SeriesCell.displayName = 'SeriesCell'; interface Props { + name: string; plot: PlotObject | undefined; trendLineData: Trendline[] | Trendline[]; } -export const CurveFitStatsGrid: FC = memo(({ plot, trendLineData }) => { +export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) => { const type = trendLineData[0].data.curveFit.type; const hasSeries = !(trendLineData.length === 1 && trendLineData[0].name === 'All'); const gridData = useMemo( @@ -155,8 +263,8 @@ export const CurveFitStatsGrid: FC = memo(({ plot, trendLineData }) => { ); const colorScale = plot?.scales.color; const shapeScale = plot?.scales.shape; + const gridColumns = useMemo(() => { - const statColumns = type === 'Polynomial' ? POLYNOMIAL_COLUMNS : LINEAR_REGRESSION_COLUMNS; let seriesColumn: GridColumn; if (hasSeries) { @@ -170,13 +278,73 @@ export const CurveFitStatsGrid: FC = memo(({ plot, trendLineData }) => { seriesColumn = new GridColumn(colConfig); } - const columns = (seriesColumn ? [seriesColumn] : []).concat(statColumns); + + let columns = seriesColumn ? [seriesColumn] : []; + + if (type === 'Polynomial') columns = columns.concat(POLYNOMIAL_COLUMNS); + else if (type === 'Linear') columns = columns.concat(LINEAR_REGRESSION_COLUMNS); + else columns = columns.concat(NONLINEAR_COLUMNS); + return ImmutableList(columns); }, [type, hasSeries, colorScale, shapeScale]); + const onExportTextFile = useCallback( + (delimiter: DelimiterType) => { + const exportData = curveFitRowsToExportFormat(hasSeries, type, gridData); + + if (delimiter === DelimiterType.EXCEL) { + UtilsDOM.convertToExcel({ + fileName: `${name}_statistics.xlsx`, + sheets: [ + { + name: 'statistics', + data: exportData, + }, + ], + }); + } else { + UtilsDOM.convertToTable({ + fileNamePrefix: `${name}_statistics`, + delim: + delimiter === DelimiterType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB, + rows: exportData, + }); + } + }, + [gridData, hasSeries, name, type] + ); + const onExportCsv = useCallback(() => onExportTextFile(DelimiterType.COMMA), [onExportTextFile]); + const onExportExcel = useCallback(() => onExportTextFile(DelimiterType.EXCEL), [onExportTextFile]); + const onExportTsv = useCallback(() => onExportTextFile(DelimiterType.TAB), [onExportTextFile]); + return (
-
Statistics
+
+
Statistics
+ } + > + + + + + CSV + + + + + Excel + + + + + TSV + + +
); diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index 3a0d885b97..9c83efd5dd 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -322,6 +322,11 @@ font-size: 13px; } +.curve-fit-statistics__header { + display: flex; + gap: 5px; +} + .curve-fit-statistics__title { font-size: 16px; font-weight: normal; diff --git a/packages/components/src/theme/utils.scss b/packages/components/src/theme/utils.scss index 64fa7348b7..910bf343b1 100644 --- a/packages/components/src/theme/utils.scss +++ b/packages/components/src/theme/utils.scss @@ -50,6 +50,10 @@ margin-right: 10px; } +.margin-right-small { + margin-right: 5px; +} + .margin-right-more { margin-right: 20px; } From 36dad594695f44b4643f4c1b803999cfccbdebd9 Mon Sep 17 00:00:00 2001 From: alanv Date: Tue, 28 Oct 2025 16:52:32 -0500 Subject: [PATCH 04/14] CurveFitStatsGrid: Fix RSS column label --- .../components/chart/CurveFitStatsGrid.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 471cb385b9..5a1193936e 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -8,7 +8,7 @@ import { List as ImmutableList } from 'immutable'; import { naturalSortByProperty } from '../../../public/sort'; import { DropdownButton, MenuHeader, MenuItem } from '../../dropdowns'; -enum DelimiterType { +enum ExportType { COMMA = 'COMMA', EXCEL = 'EXCEL', TAB = 'TAB', @@ -26,7 +26,7 @@ const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ title: 'Adjusted R-Squared', cell: roundedCell, }); -const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'TSS', cell: roundedCell }); +const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedCell }); const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedCell }); const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedCell }); const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedCell }); @@ -289,33 +289,27 @@ export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) }, [type, hasSeries, colorScale, shapeScale]); const onExportTextFile = useCallback( - (delimiter: DelimiterType) => { + (delimiter: ExportType) => { const exportData = curveFitRowsToExportFormat(hasSeries, type, gridData); - if (delimiter === DelimiterType.EXCEL) { + if (delimiter === ExportType.EXCEL) { UtilsDOM.convertToExcel({ fileName: `${name}_statistics.xlsx`, - sheets: [ - { - name: 'statistics', - data: exportData, - }, - ], + sheets: [{ name: 'statistics', data: exportData }], }); } else { UtilsDOM.convertToTable({ fileNamePrefix: `${name}_statistics`, - delim: - delimiter === DelimiterType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB, + delim: delimiter === ExportType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB, rows: exportData, }); } }, [gridData, hasSeries, name, type] ); - const onExportCsv = useCallback(() => onExportTextFile(DelimiterType.COMMA), [onExportTextFile]); - const onExportExcel = useCallback(() => onExportTextFile(DelimiterType.EXCEL), [onExportTextFile]); - const onExportTsv = useCallback(() => onExportTextFile(DelimiterType.TAB), [onExportTextFile]); + const onExportCsv = useCallback(() => onExportTextFile(ExportType.COMMA), [onExportTextFile]); + const onExportExcel = useCallback(() => onExportTextFile(ExportType.EXCEL), [onExportTextFile]); + const onExportTsv = useCallback(() => onExportTextFile(ExportType.TAB), [onExportTextFile]); return (
From 7c11451ae6092cfb76ef14f0adb01fc4dfa5f634 Mon Sep 17 00:00:00 2001 From: alanv Date: Tue, 28 Oct 2025 18:03:01 -0500 Subject: [PATCH 05/14] CurveFitStatsGrid: improve alignment of header --- .../src/internal/components/chart/CurveFitStatsGrid.tsx | 4 ++-- packages/components/src/theme/charts.scss | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 5a1193936e..bc7b0583ba 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -313,8 +313,8 @@ export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) return (
-
-
Statistics
+
+
Statistics
Date: Wed, 29 Oct 2025 10:47:29 -0500 Subject: [PATCH 06/14] CurveFitStatsGrid: add slope to nonlinear curve fits --- .../internal/components/chart/CurveFitStatsGrid.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index bc7b0583ba..15bd478ec8 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -38,6 +38,7 @@ const STATS_COLUMNS = [RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN, R_SQUARED_COLUMN]; const NONLINEAR_COLUMNS = [ MIN_COL, MAX_COL, + SLOPE_COLUMN, ASYMMETRY_COLUMN, INFLECTION_COLUMN, ...STATS_COLUMNS, @@ -90,6 +91,7 @@ interface NonlinearCurveFitData { inflection: number; max: number; min: number; + slope: number; } interface NonlinearCurveFit extends CurveFit, NonlinearCurveFitData { @@ -144,21 +146,21 @@ function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow { if (curveFit.type === 'Polynomial') { return { ...stats, - series, coefficient1: curveFit.coefficients[0], coefficient2: curveFit.coefficients[1], coefficient3: curveFit.coefficients[2], + series, }; } if (curveFit.type === 'Linear') { const { intercept, slope } = curveFit; - return { ...stats, series, intercept, slope }; + return { ...stats, intercept, series, slope }; } if (isNonlinearTrendline(trendline)) { - const { asymmetry, inflection, max, min } = curveFit; - return { ...(stats as NonlinearCurveFitStats), series, asymmetry, inflection, max, min }; + const { asymmetry, inflection, max, min, slope } = curveFit; + return { ...(stats as NonlinearCurveFitStats), asymmetry, inflection, max, min, series, slope }; } return undefined; From ac3cfb0938547acdf8d6c836abcd40a9f4b128ef Mon Sep 17 00:00:00 2001 From: alanv Date: Wed, 29 Oct 2025 17:50:25 -0500 Subject: [PATCH 07/14] Address PR Feedback --- .../components/chart/CurveFitStatsGrid.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 15bd478ec8..a35890e655 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -18,7 +18,7 @@ enum ExportType { const roundedCell = (value: number) => Utils.roundNumber(value, 4); const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedCell }); const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedCell }); -const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry' }); +const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedCell }); const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedCell }); const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedCell }); const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ @@ -169,7 +169,15 @@ function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow { const STATS_EXPORT_COLS = ['RSS', 'TSS', 'RMSE', 'RSquared']; const LINEAR_EXPORT_COLS = ['slope', 'intercept', ...STATS_EXPORT_COLS]; const POLYNOMIAL_EXPORT_COLS = ['coefficient1', 'coefficient2', 'coefficient3', ...STATS_EXPORT_COLS]; -const NONLINEAR_EXPORT_COLS = ['min', 'max', 'asymmetry', 'inflection', ...STATS_EXPORT_COLS, 'adjustedRSquared']; +const NONLINEAR_EXPORT_COLS = [ + 'min', + 'max', + 'slope', + 'asymmetry', + 'inflection', + ...STATS_EXPORT_COLS, + 'adjustedRSquared', +]; const STATS_EXPORT_HEADERS = ['RSS', 'TSS', 'RMSE', 'R-Squared']; const LINEAR_EXPORT_HEADERS = ['Slope', 'Intercept', ...STATS_EXPORT_HEADERS]; @@ -177,6 +185,7 @@ const POLYNOMIAL_EXPORT_HEADERS = ['Coefficient 1', 'Coefficient 2', 'Coefficien const NONLINEAR_EXPORT_HEADERS = [ 'Min', 'Max', + 'Slope', 'Asymmetry', 'Inflection', ...STATS_EXPORT_HEADERS, @@ -250,10 +259,14 @@ const SeriesCell: FC = memo(({ colorScale, series, shapeScale } }); SeriesCell.displayName = 'SeriesCell'; +type PossibleTrendlineArrays = + | Trendline[] + | Trendline[] + | Trendline[]; interface Props { name: string; plot: PlotObject | undefined; - trendLineData: Trendline[] | Trendline[]; + trendLineData: PossibleTrendlineArrays; } export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) => { From 563090524e85d02da1dd19040a13eaf8b79d7e6c Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 30 Oct 2025 10:32:45 -0500 Subject: [PATCH 08/14] CurveFitStatsGrid: handle undefined values, round exported values --- .../components/chart/CurveFitStatsGrid.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index a35890e655..944fc86189 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -15,25 +15,28 @@ enum ExportType { } // Equivalent to what we're doing when generating the hoverText in GenericChartHelper generateTrendlinePathHover -const roundedCell = (value: number) => Utils.roundNumber(value, 4); -const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedCell }); -const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedCell }); -const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedCell }); -const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedCell }); -const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedCell }); +const roundedValue = (value: number) => { + const rounded = Utils.roundNumber(value, 4); + return !isNaN(rounded) ? rounded : value; +}; +const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedValue }); +const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedValue }); +const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedValue }); +const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedValue }); +const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedValue }); const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ index: 'adjustedRSquared', title: 'Adjusted R-Squared', - cell: roundedCell, + cell: roundedValue, }); -const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedCell }); -const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedCell }); -const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedCell }); -const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedCell }); -const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedCell }); -const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedCell }); -const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedCell }); -const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedCell }); +const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedValue }); +const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedValue }); +const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedValue }); +const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedValue }); +const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedValue }); +const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedValue }); +const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedValue }); +const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedValue }); const STATS_COLUMNS = [RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN, R_SQUARED_COLUMN]; const NONLINEAR_COLUMNS = [ MIN_COL, @@ -209,9 +212,7 @@ function curveFitRowsToExportFormat(hasSeries: boolean, type: string, rows: Curv cols = cols.concat(NONLINEAR_EXPORT_COLS); } - const exportRows = rows.map((row: CurveFitRow) => { - return cols.map(col => row[col]); - }); + const exportRows = rows.map((row: CurveFitRow) => cols.map(col => roundedValue(row[col])?.toString(10))); exportRows.unshift(headers); return exportRows; } From 5dd8b1c7efa623d1b7c813d8baa94a9a321606f2 Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 30 Oct 2025 13:05:15 -0500 Subject: [PATCH 09/14] CurveFitStatsGrid: handle trendlines without data --- .../components/chart/CurveFitStatsGrid.tsx | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 944fc86189..6677823a07 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -143,8 +143,10 @@ function isNonlinearTrendline( } function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow { - const { curveFit, stats } = trendline.data; const series = trendline.name; + // We want to render empty rows in the grid if the series doesn't have data + if (trendline.data === undefined) return { series } as CurveFitRow; + const { curveFit, stats } = trendline.data; if (curveFit.type === 'Polynomial') { return { @@ -271,20 +273,24 @@ interface Props { } export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) => { - const type = trendLineData[0].data.curveFit.type; + // A trendline will not have a data attribute if the series doesn't have enough data + const validTrendlines = trendLineData.filter(t => t.data !== undefined); + const hasValidTrendlines = validTrendlines.length > 0; + const type = validTrendlines[0]?.data.curveFit.type; const hasSeries = !(trendLineData.length === 1 && trendLineData[0].name === 'All'); - const gridData = useMemo( - () => trendLineData.map(trendLineToCurveFitRow).sort(naturalSortByProperty('series')), - [trendLineData] - ); - const colorScale = plot?.scales.color; - const shapeScale = plot?.scales.shape; + const gridData = useMemo(() => { + if (!hasValidTrendlines) return undefined; + return trendLineData.map(trendLineToCurveFitRow).sort(naturalSortByProperty('series')); + }, [hasValidTrendlines, trendLineData]); const gridColumns = useMemo(() => { + if (!hasValidTrendlines) return undefined; let seriesColumn: GridColumn; if (hasSeries) { const colConfig = { cell: undefined, index: 'series', title: 'Series' }; + const colorScale = plot?.scales.color; + const shapeScale = plot?.scales.shape; if (colorScale && shapeScale) { colConfig.cell = (series: string) => ( @@ -302,7 +308,7 @@ export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) else columns = columns.concat(NONLINEAR_COLUMNS); return ImmutableList(columns); - }, [type, hasSeries, colorScale, shapeScale]); + }, [hasSeries, hasValidTrendlines, plot, type]); const onExportTextFile = useCallback( (delimiter: ExportType) => { @@ -327,6 +333,8 @@ export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) const onExportExcel = useCallback(() => onExportTextFile(ExportType.EXCEL), [onExportTextFile]); const onExportTsv = useCallback(() => onExportTextFile(ExportType.TAB), [onExportTextFile]); + if (!hasValidTrendlines) return null; + return (
From 7a0773ee6d15e9f3c06a24fd142cb04ccdb0d5cd Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 30 Oct 2025 16:55:01 -0500 Subject: [PATCH 10/14] CurveFitStatsGrid: set quoteChar when exporting as CSV/TSV --- .../src/internal/components/chart/CurveFitStatsGrid.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 6677823a07..4bbf16582b 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -323,6 +323,7 @@ export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) UtilsDOM.convertToTable({ fileNamePrefix: `${name}_statistics`, delim: delimiter === ExportType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB, + quoteChar: UtilsDOM.QuoteCharType.DOUBLE, rows: exportData, }); } From b64386d5fd955434dc499ee355d073d56e2cd566 Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 30 Oct 2025 17:50:54 -0500 Subject: [PATCH 11/14] ChartPanel: use margin in export menu instead of nbsp --- packages/components/src/public/QueryModel/ChartPanel.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/src/public/QueryModel/ChartPanel.tsx b/packages/components/src/public/QueryModel/ChartPanel.tsx index 498e39d33a..c02650e335 100644 --- a/packages/components/src/public/QueryModel/ChartPanel.tsx +++ b/packages/components/src/public/QueryModel/ChartPanel.tsx @@ -120,12 +120,12 @@ export const ChartPanel: FC = memo(({ actions, api = DEFAULT_API_WRAPPER, > - -   PDF + + PDF - -   PNG + + PNG From 72f05778fb7d7a1d617833f55443276c1e47b033 Mon Sep 17 00:00:00 2001 From: alanv Date: Thu, 30 Oct 2025 17:52:03 -0500 Subject: [PATCH 12/14] Update release notes --- packages/components/releaseNotes/components.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 0ece3a35b2..4a1a898887 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,10 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.??.? +*Released*: ?? October 2025 +- Chart: render curve fit statistics when available + ### version 6.68.1 *Released* 2 November 2025 - Issue 52063: Domain designer to handle lookup to a query with a pipe character From 80d987c06b9ec9c9e58db157a94f82dba0d02fb3 Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 3 Nov 2025 13:09:30 -0600 Subject: [PATCH 13/14] CurveFitStatsGrid: Improve layout for grid values --- .../components/chart/CurveFitStatsGrid.tsx | 34 +++++++++++-------- packages/components/src/theme/charts.scss | 14 ++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx index 4bbf16582b..a8a6583519 100644 --- a/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx +++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx @@ -19,24 +19,28 @@ const roundedValue = (value: number) => { const rounded = Utils.roundNumber(value, 4); return !isNaN(rounded) ? rounded : value; }; -const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedValue }); -const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedValue }); -const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedValue }); -const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedValue }); -const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedValue }); +const roundedColumn = (value: number) => { + const str = roundedValue(value); + return
{str}
; +}; +const MIN_COL = new GridColumn({ index: 'min', title: 'Min', cell: roundedColumn }); +const MAX_COL = new GridColumn({ index: 'max', title: 'Max', cell: roundedColumn }); +const ASYMMETRY_COLUMN = new GridColumn({ index: 'asymmetry', title: 'Asymmetry', cell: roundedColumn }); +const INFLECTION_COLUMN = new GridColumn({ index: 'inflection', title: 'Inflection', cell: roundedColumn }); +const R_SQUARED_COLUMN = new GridColumn({ index: 'RSquared', title: 'R-Squared', cell: roundedColumn }); const ADJUSTED_R_SQUARED_COLUMN = new GridColumn({ index: 'adjustedRSquared', title: 'Adjusted R-Squared', - cell: roundedValue, + cell: roundedColumn, }); -const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedValue }); -const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedValue }); -const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedValue }); -const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedValue }); -const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedValue }); -const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedValue }); -const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedValue }); -const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedValue }); +const RSS_COLUMN = new GridColumn({ index: 'RSS', title: 'RSS', cell: roundedColumn }); +const TSS_COLUMN = new GridColumn({ index: 'TSS', title: 'TSS', cell: roundedColumn }); +const RMSE_COLUMN = new GridColumn({ index: 'RMSE', title: 'RMSE', cell: roundedColumn }); +const SLOPE_COLUMN = new GridColumn({ index: 'slope', title: 'Slope', cell: roundedColumn }); +const INTERCEPT_COLUMN = new GridColumn({ index: 'intercept', title: 'Intercept', cell: roundedColumn }); +const COEFFICIENT_1_COLUMN = new GridColumn({ index: 'coefficient1', title: 'Coefficient 1', cell: roundedColumn }); +const COEFFICIENT_2_COLUMN = new GridColumn({ index: 'coefficient2', title: 'Coefficient 2', cell: roundedColumn }); +const COEFFICIENT_3_COLUMN = new GridColumn({ index: 'coefficient3', title: 'Coefficient 3', cell: roundedColumn }); const STATS_COLUMNS = [RSS_COLUMN, TSS_COLUMN, RMSE_COLUMN, R_SQUARED_COLUMN]; const NONLINEAR_COLUMNS = [ MIN_COL, @@ -256,7 +260,7 @@ const SeriesCell: FC = memo(({ colorScale, series, shapeScale } - {series} +
{series}
); }); diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss index 741b186fe5..c8985585ae 100644 --- a/packages/components/src/theme/charts.scss +++ b/packages/components/src/theme/charts.scss @@ -332,3 +332,17 @@ font-size: 16px; font-weight: normal; } + +.curve-fit-series-cell { + display: flex; + align-items: center; + gap: 5px; +} + +.curve-fit-series-cell__name { + padding-top: 2px; +} + +.curve-fit-value-cell { + text-align: right; +} From 1a393387e54be3fc622291938ea57f7afe6676ef Mon Sep 17 00:00:00 2001 From: alanv Date: Mon, 3 Nov 2025 16:42:58 -0600 Subject: [PATCH 14/14] Prep for release --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/releaseNotes/components.md | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index e765c75ba1..6c612eae2d 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.68.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.68.2", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 7093ac4805..65181cd0fe 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.68.1", + "version": "6.68.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 4a1a898887..6e4fa1d1f1 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,9 +1,9 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version 6.??.? -*Released*: ?? October 2025 -- Chart: render curve fit statistics when available +### version 6.68.2 +*Released*: 3 November 2025 +- SVGChart: render curve fit statistics when available ### version 6.68.1 *Released* 2 November 2025