+
))}
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..a8a6583519
--- /dev/null
+++ b/packages/components/src/internal/components/chart/CurveFitStatsGrid.tsx
@@ -0,0 +1,375 @@
+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 ExportType {
+ COMMA = 'COMMA',
+ EXCEL = 'EXCEL',
+ TAB = 'TAB',
+}
+
+// Equivalent to what we're doing when generating the hoverText in GenericChartHelper generateTrendlinePathHover
+const roundedValue = (value: number) => {
+ const rounded = Utils.roundNumber(value, 4);
+ return !isNaN(rounded) ? rounded : value;
+};
+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: roundedColumn,
+});
+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,
+ MAX_COL,
+ SLOPE_COLUMN,
+ ASYMMETRY_COLUMN,
+ INFLECTION_COLUMN,
+ ...STATS_COLUMNS,
+ ADJUSTED_R_SQUARED_COLUMN,
+];
+const LINEAR_REGRESSION_COLUMNS = [SLOPE_COLUMN, INTERCEPT_COLUMN, ...STATS_COLUMNS];
+const POLYNOMIAL_COLUMNS = [COEFFICIENT_1_COLUMN, COEFFICIENT_2_COLUMN, COEFFICIENT_3_COLUMN, ...STATS_COLUMNS];
+
+interface GeneratedPoint {
+ x: number;
+ y: number;
+}
+
+interface CurveFitStats {
+ RMSE: number;
+ RSquared: number;
+ RSS: number;
+ TSS: number;
+}
+
+interface NonlinearCurveFitStats extends CurveFitStats {
+ adjustedRSquared: number;
+}
+
+interface CurveFit {
+ type: string;
+}
+
+interface PolynomialCurveFitData {
+ coefficients: number[];
+}
+
+interface PolynomialCurveFit extends CurveFit, PolynomialCurveFitData {
+ type: 'Polynomial';
+}
+
+interface LinearRegressionCurveFitData {
+ intercept: number;
+ slope: number;
+}
+
+interface LinearRegressionCurveFit extends CurveFit, LinearRegressionCurveFitData {
+ type: 'Linear';
+}
+
+// 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;
+ slope: number;
+}
+
+interface NonlinearCurveFit extends CurveFit, NonlinearCurveFitData {
+ type: '3 Parameter' | '4 Parameter' | 'Five Parameter' | 'Four Parameter' | 'Three Parameter';
+}
+
+interface CurveFitData
{
+ curveFit: T;
+ generatedPoints: GeneratedPoint[];
+ stats: S;
+}
+
+interface Trendline {
+ count: number;
+ data: CurveFitData;
+ generatedPoints: GeneratedPoint[];
+ name: string;
+ rawData: Row[];
+ total: number;
+}
+
+interface BaseCurveFitRow extends CurveFitStats {
+ series: string;
+}
+
+interface PolynomialCurveFitRow extends BaseCurveFitRow {
+ coefficient1: number;
+ coefficient2: number;
+ coefficient3: number;
+}
+
+type LinearRegressionCurveFitRow = BaseCurveFitRow & LinearRegressionCurveFitData;
+type NonlinearCurveFitRow = BaseCurveFitRow & NonlinearCurveFitData & NonlinearCurveFitStats;
+type CurveFitRow = LinearRegressionCurveFitRow | NonlinearCurveFitRow | PolynomialCurveFitRow;
+type PossibleTrendlines =
+ | Trendline
+ | Trendline
+ | Trendline;
+
+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);
+}
+
+function trendLineToCurveFitRow(trendline: PossibleTrendlines): CurveFitRow {
+ 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 {
+ ...stats,
+ coefficient1: curveFit.coefficients[0],
+ coefficient2: curveFit.coefficients[1],
+ coefficient3: curveFit.coefficients[2],
+ series,
+ };
+ }
+
+ if (curveFit.type === 'Linear') {
+ const { intercept, slope } = curveFit;
+ return { ...stats, intercept, series, slope };
+ }
+
+ if (isNonlinearTrendline(trendline)) {
+ const { asymmetry, inflection, max, min, slope } = curveFit;
+ return { ...(stats as NonlinearCurveFitStats), asymmetry, inflection, max, min, series, slope };
+ }
+
+ 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',
+ '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];
+const POLYNOMIAL_EXPORT_HEADERS = ['Coefficient 1', 'Coefficient 2', 'Coefficient 3', ...STATS_EXPORT_HEADERS];
+const NONLINEAR_EXPORT_HEADERS = [
+ 'Min',
+ 'Max',
+ 'Slope',
+ '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) => cols.map(col => roundedValue(row[col])?.toString(10)));
+ exportRows.unshift(headers);
+ return exportRows;
+}
+
+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;
+ };
+}
+
+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);
+ const shape = shapeScale(series)(SHAPE_SIZE);
+ return (
+
+ );
+});
+SeriesCell.displayName = 'SeriesCell';
+
+type PossibleTrendlineArrays =
+ | Trendline[]
+ | Trendline[]
+ | Trendline[];
+interface Props {
+ name: string;
+ plot: PlotObject | undefined;
+ trendLineData: PossibleTrendlineArrays;
+}
+
+export const CurveFitStatsGrid: FC = memo(({ name, plot, trendLineData }) => {
+ // 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(() => {
+ 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) => (
+
+ );
+ }
+
+ seriesColumn = new GridColumn(colConfig);
+ }
+
+ 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);
+ }, [hasSeries, hasValidTrendlines, plot, type]);
+
+ const onExportTextFile = useCallback(
+ (delimiter: ExportType) => {
+ const exportData = curveFitRowsToExportFormat(hasSeries, type, gridData);
+
+ if (delimiter === ExportType.EXCEL) {
+ UtilsDOM.convertToExcel({
+ fileName: `${name}_statistics.xlsx`,
+ sheets: [{ name: 'statistics', data: exportData }],
+ });
+ } else {
+ UtilsDOM.convertToTable({
+ fileNamePrefix: `${name}_statistics`,
+ delim: delimiter === ExportType.COMMA ? UtilsDOM.DelimiterType.COMMA : UtilsDOM.DelimiterType.TAB,
+ quoteChar: UtilsDOM.QuoteCharType.DOUBLE,
+ rows: exportData,
+ });
+ }
+ },
+ [gridData, hasSeries, name, type]
+ );
+ const onExportCsv = useCallback(() => onExportTextFile(ExportType.COMMA), [onExportTextFile]);
+ const onExportExcel = useCallback(() => onExportTextFile(ExportType.EXCEL), [onExportTextFile]);
+ const onExportTsv = useCallback(() => onExportTextFile(ExportType.TAB), [onExportTextFile]);
+
+ if (!hasValidTrendlines) return null;
+
+ return (
+
+
+
Statistics
+
}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+CurveFitStatsGrid.displayName = 'CurveFitStatsGrid';
diff --git a/packages/components/src/internal/components/search/QueryFilterPanel.tsx b/packages/components/src/internal/components/search/QueryFilterPanel.tsx
index 96321a5268..598b5dac55 100644
--- a/packages/components/src/internal/components/search/QueryFilterPanel.tsx
+++ b/packages/components/src/internal/components/search/QueryFilterPanel.tsx
@@ -9,6 +9,7 @@ import { ChoicesListItem } from '../base/ChoicesListItem';
import { QueryColumn } from '../../../public/QueryColumn';
import { QueryInfo } from '../../../public/QueryInfo';
+import { naturalSortByProperty } from '../../../public/sort';
import { NOT_ANY_FILTER_TYPE } from '../../url/NotAnyFilterType';
@@ -40,7 +41,7 @@ interface Props {
entityDataType?: EntityDataType;
fieldKey?: string;
fields?: QueryColumn[];
- filters: { [key: string]: FieldFilter[] };
+ filters: Record;
fullWidth?: boolean;
hasNotInQueryFilter?: boolean;
hasNotInQueryFilterLabel?: string;
@@ -146,7 +147,7 @@ export const QueryFilterPanel: FC = memo(props => {
setActiveField(undefined);
if (!queryInfo) return;
- let validFields;
+ let validFields: QueryColumn[];
if (fields) validFields = fields;
else {
const qFields = skipDefaultViewCheck
@@ -159,6 +160,11 @@ export const QueryFilterPanel: FC = memo(props => {
);
}
+ // Issue 53983: Sort fields by caption
+ if (validFields) {
+ validFields = validFields.sort(naturalSortByProperty('caption'));
+ }
+
setQueryFields(validFields);
if (fieldKey) {
const field = validFields.find(f => f.getDisplayFieldKey() === fieldKey);
@@ -239,11 +245,11 @@ export const QueryFilterPanel: FC = memo(props => {
{entityDataType?.supportHasNoValueInQuery && (
onHasNoValueInQueryChange(event.target.checked)}
- checked={hasNotInQueryFilter}
+ type="checkbox"
/>
{hasNotInQueryFilterLabel ?? 'Without data from this type'}
@@ -261,14 +267,14 @@ export const QueryFilterPanel: FC
= memo(props => {
return (
{caption}}
- onSelect={() => onFieldClick(field)}
componentRight={
hasFilters(field) &&
}
disabled={hasNotInQueryFilter}
+ index={index}
+ key={field.fieldKeyPath}
+ label={{caption}}
+ onSelect={() => onFieldClick(field)}
/>
);
})}
@@ -291,16 +297,16 @@ export const QueryFilterPanel: FC = memo(props => {
{activeTab === FieldFilterTabs.Filter && (
filter.filter)}
- onFieldFilterUpdate={(newFilters, index) =>
- onFilterUpdate(activeField, newFilters, index)
- }
- disabled={hasNotInQueryFilter}
includeAllAncestorFilter={
isAncestor && activeField?.fieldKey.toLowerCase() === 'name'
}
+ key={activeFieldKey}
+ onFieldFilterUpdate={(newFilters, index) =>
+ onFilterUpdate(activeField, newFilters, index)
+ }
/>
)}
@@ -310,6 +316,14 @@ export const QueryFilterPanel: FC = memo(props => {
Find values for {activeField.caption}
filter.filter)}
+ fieldKey={activeFieldKey}
+ key={activeFieldKey}
+ onFieldFilterUpdate={(newFilters, index) =>
+ onFilterUpdate(activeField, newFilters, index)
+ }
selectDistinctOptions={{
...selectDistinctOptions,
column: activeFieldKey,
@@ -318,14 +332,6 @@ export const QueryFilterPanel: FC = memo(props => {
viewName,
filterArray: fieldDistinctValueFilters,
}}
- fieldFilters={currentFieldFilters?.map(filter => filter.filter)}
- fieldKey={activeFieldKey}
- canBeBlank={!activeField?.required && !activeField.nameExpression}
- key={activeFieldKey}
- onFieldFilterUpdate={(newFilters, index) =>
- onFilterUpdate(activeField, newFilters, index)
- }
- disabled={hasNotInQueryFilter}
/>
)}
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,
>
diff --git a/packages/components/src/theme/charts.scss b/packages/components/src/theme/charts.scss
index e66756ee49..c8985585ae 100644
--- a/packages/components/src/theme/charts.scss
+++ b/packages/components/src/theme/charts.scss
@@ -321,3 +321,28 @@
font-family: monospace;
font-size: 13px;
}
+
+.curve-fit-statistics__header {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+
+.curve-fit-statistics__title {
+ 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;
+}
diff --git a/packages/components/src/theme/filter.scss b/packages/components/src/theme/filter.scss
index 72ab068882..a5d910884e 100644
--- a/packages/components/src/theme/filter.scss
+++ b/packages/components/src/theme/filter.scss
@@ -25,6 +25,10 @@
.list-group-item .field-caption {
flex: 1;
cursor: default;
+
+ // Issue 53927: Prevent clipping/overlap with field caption
+ overflow: hidden;
+ word-wrap: break-word;
}
.list-group-item .field-expand-icon {
diff --git a/packages/components/src/theme/utils.scss b/packages/components/src/theme/utils.scss
index a0f793b282..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;
}
@@ -70,6 +74,10 @@
margin-left: 10px;
}
+.margin-left-small {
+ margin-left: 5px;
+}
+
.margin-left-more {
margin-left: 20px;
}