Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions e2e-tests/dateAttribute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { parseDateValue } from '../packages/core/src/utils';
import mockAnnotations from '../playwright/mock-data/date-attributes/date_attributes_annotations.json' with { type: 'json' };
import mockData from '../playwright/mock-data/date-attributes/date_attributes_data.json' with { type: 'json' };

async function mockDateAttributeDataset(page: Page) {
await page.route('*/**/api/**', async (route) => {
const url = route.request().url();

if (
url.includes(
'workspaces/Upset%20Examples/tables/date_attributes/rows/?limit=9007199254740991',
)
) {
await route.fulfill({ json: mockData });
} else if (
url.includes('workspaces/Upset%20Examples/tables/date_attributes/annotations/')
) {
await route.fulfill({ json: mockAnnotations });
} else if (url.includes('workspaces/Upset%20Examples/sessions/table/194/')) {
await route.fulfill({ status: 200 });
} else if (url.includes('workspaces/Upset%20Examples/permissions/me')) {
await route.fulfill({
status: 200,
json: {
permission: 3,
permission_label: 'maintainer',
public: true,
username: 'test',
workspace: 'Upset Examples',
},
});
} else if (url.includes('api/users/me')) {
await route.fulfill({
status: 200,
json: {
username: 'test',
workspace: 'Upset Examples',
permission: 3,
permission_label: 'maintainer',
public: true,
},
});
} else {
await route.continue();
}
});
}

test('date attributes render formatted scales', async ({ page }) => {
await mockDateAttributeDataset(page);
await page.goto(
'http://localhost:3000/?workspace=Upset+Examples&table=date_attributes&sessionId=194',
);

await expect(page.locator('#header-text-ReleaseDate')).toBeVisible();
await expect(page.locator('#header-text-Premiere')).toBeVisible();
await expect(page.getByRole('dialog', { name: 'Import Error' })).toHaveCount(0);
await expect(page.locator('text').filter({ hasText: /^\d{4}$/ }).first()).toBeVisible();
await expect(
page
.locator('text')
.filter({ hasText: /Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec/ })
.first(),
).toBeVisible();
await expect(page.locator('text').filter({ hasText: /^\d{13}$/ })).toHaveCount(0);
});

test('ambiguous slash-formatted dates are ignored', async () => {
expect(parseDateValue('03/04/2024')).toBeUndefined();
});
13 changes: 11 additions & 2 deletions packages/core/src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ import {
Subsets,
ColumnTypes,
} from './types';
import { getNumericValue, parseDateValue } from './utils';

function isValidNumber(value: unknown): value is number {
return typeof value === 'number' && !Number.isNaN(value);
}

function getSortedNumericValues(values: unknown[]): number[] {
return values.filter(isValidNumber).sort((a, b) => a - b);
return values
.map((value) => getNumericValue(value))
.filter(isValidNumber)
.sort((a, b) => a - b);
}

function getMin(values: number[]): number | undefined {
Expand Down Expand Up @@ -139,7 +143,7 @@ function getSetColumns(columns: ColumnTypes): ColumnName[] {
*/
function getAttributeColumns(columns: ColumnTypes): ColumnName[] {
return Object.entries(columns)
.filter(([_, type]) => type === 'number' || type === 'category')
.filter(([_, type]) => type === 'number' || type === 'category' || type === 'date')
.map(([name, _]) => name);
}

Expand Down Expand Up @@ -174,6 +178,11 @@ function processRawData(data: TableRow[], columns: ColumnTypes) {
item.atts[col] = parseFloat(item.atts[col]);
}

if (type === 'date') {
const parsedDate = parseDateValue(item.atts[col]);
if (parsedDate) item.atts[col] = parsedDate;
}

if (type === 'boolean') {
const val = item.atts[col];
item.atts[col] =
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type MultinetTableRow = {
* and any additional fields that may be present in the table.
*/
export type TableRow = MultinetTableRow & {
[key: string]: string | number | boolean;
[key: string]: string | number | boolean | Date;
};

/**
Expand Down Expand Up @@ -83,7 +83,7 @@ export type RowType =
export type Item = {
_id: string;
_label: string;
atts: { [attr: string]: boolean | number | string };
atts: { [attr: string]: boolean | number | string | Date };
};

/** A list of items with labels mapped to item objects */
Expand Down
69 changes: 65 additions & 4 deletions packages/core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,67 @@ import {
QuerySelection,
} from './types';

const YEAR_ONLY_REGEX = /^\d{4}$/;
const ISO_LIKE_DATE_REGEX =
/^\d{4}(?:-\d{2}(?:-\d{2})?)?(?:[T\s]\d{2}(?::\d{2}(?::\d{2}(?:\.\d{1,3})?)?)?(?:\s?(?:Z|[+-]\d{2}:?\d{2}))?)?$/;

/**
* Converts a numeric or Date value into a number, if possible.
* @param value Value to convert
* @returns Numeric representation of the value, or undefined if not numeric
*/
export function getNumericValue(value: unknown): number | undefined {
if (typeof value === 'number' && !Number.isNaN(value)) return value;
if (value instanceof Date) {
const timestamp = value.getTime();
if (!Number.isNaN(timestamp)) return timestamp;
}
return undefined;
}

/**
* Parses supported date values into native Date objects.
* Supports Date instances, 4-digit years, ISO-like date strings, and
* native JS-parsable textual dates. Ambiguous numeric formats (for example
* 01/02/2024) are intentionally rejected.
* @param value Value to parse
* @returns Parsed date, or undefined if the value is not in a supported format
*/
export function parseDateValue(value: unknown): Date | undefined {
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;

if (typeof value === 'number' && Number.isFinite(value)) {
if (Number.isInteger(value) && value >= 1 && value <= 9999) {
return new Date(Date.UTC(value, 0, 1));
}

const parsedNumberDate = new Date(value);
return Number.isNaN(parsedNumberDate.getTime()) ? undefined : parsedNumberDate;
}

if (typeof value !== 'string') return undefined;

const trimmed = value.trim();
if (!trimmed) return undefined;

if (YEAR_ONLY_REGEX.test(trimmed)) {
return new Date(Date.UTC(parseInt(trimmed, 10), 0, 1));
}

// Reject numeric-but-ambiguous formats such as 01/02/2024; only year-only,
// ISO-like numeric dates, and textual dates should be parsed automatically.
if (!ISO_LIKE_DATE_REGEX.test(trimmed) && !/[A-Za-z]/.test(trimmed)) {
return undefined;
}

const normalized = ISO_LIKE_DATE_REGEX.test(trimmed)
? trimmed.replace(/^(\d{4}-\d{2}-\d{2})\s/, '$1T')
: trimmed;
const parsed = new Date(normalized);

return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}

/**
* Version safe deep copy using structured cloning.
* Create a deep copy (with all fields recursively copied) of an object using structured cloning;
Expand Down Expand Up @@ -51,10 +112,10 @@ export function filterByVega(items: Item[], filter: VegaSelection): FilteredItem
items.forEach((item) => {
if (
Object.entries(filter).every(
([key, value]) =>
typeof item.atts[key] === 'number' &&
(item.atts[key] as number) >= value[0] &&
(item.atts[key] as number) <= value[1],
([key, value]) => {
const itemValue = getNumericValue(item.atts[key]);
return itemValue !== undefined && itemValue >= value[0] && itemValue <= value[1];
},
)
)
included.push(item);
Expand Down
8 changes: 4 additions & 4 deletions packages/upset/src/atoms/attributeAtom.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { atom, selector, selectorFamily } from 'recoil';
import { ColumnTypes } from '@visdesignlab/upset2-core';
import { ColumnTypes, getNumericValue } from '@visdesignlab/upset2-core';
import { itemsAtom } from './itemsAtoms';
import { dataAtom } from './dataAtom';
import { rowItemsSelector } from './elementsSelectors';
Expand Down Expand Up @@ -46,9 +46,8 @@ export const attributeValuesSelector = selectorFamily<number[], string>({
({ get }) => {
const items = get(itemsAtom);
const values = Object.values(items)
// Cast to number so we get the correct return type; this cast is guaranteed by the filter statement
.map((item) => item.atts[attribute] as number)
.filter((val) => !Number.isNaN(val));
.map((item) => getNumericValue(item.atts[attribute]))
.filter((val): val is number => val !== undefined);

return values;
},
Expand Down Expand Up @@ -185,6 +184,7 @@ export const attributeMinMaxSelector = selectorFamily<
const values = get(attributeValuesSelector(attribute));

if (attTypes[attribute] === 'category') return { min: 0, max: maxSize };
if (values.length === 0) return { min: 0, max: 0 };
return {
min: Math.min(...values),
max: Math.max(...values),
Expand Down
14 changes: 4 additions & 10 deletions packages/upset/src/atoms/elementsSelectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
filterByQuery,
SelectionType,
isRowAggregate,
getNumericValue,
} from '@visdesignlab/upset2-core';
import { selector, selectorFamily } from 'recoil';
import {
Expand Down Expand Up @@ -177,16 +178,9 @@ export const attValuesSelector = selectorFamily<number[], { row: Row; att: strin
.map((itemId) => allItems[itemId])
.filter((item): item is Item => item !== undefined);

// Basic check for performance reasons; we can eliminate most cases with this
if (!items[0] || typeof items[0].atts[att] !== 'number') {
return [];
}
return (
items
// Safely cast the attribute to a number since we filter after
.map((item) => item.atts[att] as number)
.filter((val) => !Number.isNaN(val))
);
return items
.map((item) => getNumericValue(item.atts[att]))
.filter((val): val is number => val !== undefined);
},
});

Expand Down
15 changes: 14 additions & 1 deletion packages/upset/src/components/Columns/Attribute/AttributeBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { DensityPlot } from './AttributePlots/DensityPlot';
import { DeviationBar } from '../DeviationBar';
import { attributePlotsSelector } from '../../../atoms/config/plotAtoms';
import { attValuesSelector } from '../../../atoms/elementsSelectors';
import { attTypesSelector } from '../../../atoms/attributeAtom';
import { formatDateValue } from '../../../utils/date';

/**
* Attribute bar props
Expand Down Expand Up @@ -47,9 +49,12 @@ const DOT_PLOT_THRESHOLD = 5;
export const AttributeBar: FC<Props> = ({ attribute, summary, row }) => {
const dimensions = useRecoilValue(dimensionsSelector);
const { min, max } = useRecoilValue(attributeMinMaxSelector(attribute));
const attTypes = useRecoilValue(attTypesSelector);
const scale = useScale([min, max], [0, dimensions.attribute.width]);
const values = useRecoilValue(attValuesSelector({ row, att: attribute }));
const attributePlots = useRecoilValue(attributePlotsSelector);
const isDateAttribute = attTypes[attribute] === 'date';
const dateDomain: [number, number] = [min, max];

/*
* Get the attribute plot to render based on the selected attribute plot type
Expand Down Expand Up @@ -111,6 +116,14 @@ export const AttributeBar: FC<Props> = ({ attribute, summary, row }) => {
return Math.round(num * 1000) / 1000;
}, []);

const formatSummaryValue = useCallback(
(num: number | undefined): string | number => {
if (isDateAttribute) return formatDateValue(num, dateDomain);
return round3(num);
},
[dateDomain, isDateAttribute, round3],
);

if (
typeof summary !== 'number' &&
(summary.max === undefined ||
Expand All @@ -130,7 +143,7 @@ export const AttributeBar: FC<Props> = ({ attribute, summary, row }) => {
<Tooltip
title={
<div style={{ whiteSpace: 'pre-line' }}>
{`Q1: ${round3(summary.first)}\nMean: ${round3(summary.mean)}\nMedian: ${round3(summary.median)}\nQ3: ${round3(summary.third)}`}
{`Q1: ${formatSummaryValue(summary.first)}\nMean: ${formatSummaryValue(summary.mean)}\nMedian: ${formatSummaryValue(summary.median)}\nQ3: ${formatSummaryValue(summary.third)}`}
</div>
}
>
Expand Down
3 changes: 2 additions & 1 deletion packages/upset/src/components/ElementView/AddPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { HistogramPlot } from './HistogramPlot';
import { ScatterplotPlot } from './Scatterplot';
import { UpsetActions } from '../../provenance';
import { VegaNamedData } from '../VegaLiteChart';
import { getVegaCompatibleAttributes } from '../../typeutils';

type Props = {
handleClose: () => void;
Expand Down Expand Up @@ -127,7 +128,7 @@ const PLOT_CONTAINER_STYLE = { width: '100%', display: 'flex', justifyContent: '
function createPreviewData(items: Items): VegaNamedData {
return {
elements: Object.values(items).map((item) => ({
...item.atts,
...getVegaCompatibleAttributes(item),
})),
};
}
Expand Down
12 changes: 10 additions & 2 deletions packages/upset/src/components/Header/AttributeHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import { dimensionsSelector } from '../../atoms/dimensionsAtom';
import translate from '../../utils/transform';
import { AttributeButton } from './AttributeButton';
import { AttributeScale } from './AttributeScale';
import { attributeMinMaxSelector } from '../../atoms/attributeAtom';
import { attTypesSelector, attributeMinMaxSelector } from '../../atoms/attributeAtom';
import { createDateTickFormatter } from '../../utils/date';

type Props = {
attribute: string;
};

export const AttributeHeader: FC<Props> = ({ attribute }) => {
const dimensions = useRecoilValue(dimensionsSelector);
const attTypes = useRecoilValue(attTypesSelector);
const { min, max } = useRecoilValue(attributeMinMaxSelector(attribute));
const domain: [number, number] = [min, max];

return (
<>
Expand All @@ -24,7 +27,12 @@ export const AttributeHeader: FC<Props> = ({ attribute }) => {
dimensions.attribute.buttonHeight + dimensions.attribute.gap,
)}
>
<AttributeScale domain={[min, max]} />
<AttributeScale
domain={domain}
tickFormatter={
attTypes[attribute] === 'date' ? createDateTickFormatter(domain) : undefined
}
/>
</g>
</>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/upset/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export type ContextMenuInfo = {
* This is used to generate the processed data. Column annotations are inferred from the data types.
*/
export interface UpsetItem {
[key: string]: string | number | boolean;
[key: string]: string | number | boolean | Date;
}

/**
Expand Down
Loading
Loading