diff --git a/e2e-tests/dateAttribute.spec.ts b/e2e-tests/dateAttribute.spec.ts new file mode 100644 index 000000000..1a3c76353 --- /dev/null +++ b/e2e-tests/dateAttribute.spec.ts @@ -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(); +}); diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index fa4dcbf22..6d4d7e193 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -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 { @@ -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); } @@ -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] = diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cd9279504..f774385a2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; }; /** @@ -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 */ diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 31e5b5711..927e66b4b 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -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; @@ -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); diff --git a/packages/upset/src/atoms/attributeAtom.ts b/packages/upset/src/atoms/attributeAtom.ts index 02948afc2..f88d6dc72 100644 --- a/packages/upset/src/atoms/attributeAtom.ts +++ b/packages/upset/src/atoms/attributeAtom.ts @@ -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'; @@ -46,9 +46,8 @@ export const attributeValuesSelector = selectorFamily({ ({ 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; }, @@ -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), diff --git a/packages/upset/src/atoms/elementsSelectors.ts b/packages/upset/src/atoms/elementsSelectors.ts index 3730be2d1..1aff308ed 100644 --- a/packages/upset/src/atoms/elementsSelectors.ts +++ b/packages/upset/src/atoms/elementsSelectors.ts @@ -9,6 +9,7 @@ import { filterByQuery, SelectionType, isRowAggregate, + getNumericValue, } from '@visdesignlab/upset2-core'; import { selector, selectorFamily } from 'recoil'; import { @@ -177,16 +178,9 @@ export const attValuesSelector = selectorFamily 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); }, }); diff --git a/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx b/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx index 25a8a362f..8c5c88a83 100644 --- a/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx +++ b/packages/upset/src/components/Columns/Attribute/AttributeBar.tsx @@ -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 @@ -47,9 +49,12 @@ const DOT_PLOT_THRESHOLD = 5; export const AttributeBar: FC = ({ 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 @@ -111,6 +116,14 @@ export const AttributeBar: FC = ({ 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 || @@ -130,7 +143,7 @@ export const AttributeBar: FC = ({ attribute, summary, row }) => { - {`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)}`} } > diff --git a/packages/upset/src/components/ElementView/AddPlot.tsx b/packages/upset/src/components/ElementView/AddPlot.tsx index bae988386..fbc185653 100644 --- a/packages/upset/src/components/ElementView/AddPlot.tsx +++ b/packages/upset/src/components/ElementView/AddPlot.tsx @@ -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; @@ -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), })), }; } diff --git a/packages/upset/src/components/Header/AttributeHeader.tsx b/packages/upset/src/components/Header/AttributeHeader.tsx index 621556744..1cad104af 100644 --- a/packages/upset/src/components/Header/AttributeHeader.tsx +++ b/packages/upset/src/components/Header/AttributeHeader.tsx @@ -5,7 +5,8 @@ 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; @@ -13,7 +14,9 @@ type Props = { export const AttributeHeader: FC = ({ attribute }) => { const dimensions = useRecoilValue(dimensionsSelector); + const attTypes = useRecoilValue(attTypesSelector); const { min, max } = useRecoilValue(attributeMinMaxSelector(attribute)); + const domain: [number, number] = [min, max]; return ( <> @@ -24,7 +27,12 @@ export const AttributeHeader: FC = ({ attribute }) => { dimensions.attribute.buttonHeight + dimensions.attribute.gap, )} > - + ); diff --git a/packages/upset/src/types.ts b/packages/upset/src/types.ts index 5531deede..ec5dc6f26 100644 --- a/packages/upset/src/types.ts +++ b/packages/upset/src/types.ts @@ -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; } /** diff --git a/packages/upset/src/typeutils.ts b/packages/upset/src/typeutils.ts index b406af2b6..2e5c4ff8c 100644 --- a/packages/upset/src/typeutils.ts +++ b/packages/upset/src/typeutils.ts @@ -1,6 +1,16 @@ -import { Item, Row, SelectionType } from '@visdesignlab/upset2-core'; +import { Item, Row, SelectionType, getNumericValue } from '@visdesignlab/upset2-core'; import { VegaItem } from './types'; +export function getVegaCompatibleAttributes(item: Item): Record { + return Object.fromEntries( + Object.entries(item.atts).map(([key, value]) => { + const numericValue = getNumericValue(value); + if (value instanceof Date) return [key, numericValue ?? value.toISOString()]; + return [key, value]; + }), + ) as Record; +} + /** * Converts an item to a VegaItem by flattening its attributes and adding selection properties * @param item The item to convert @@ -22,7 +32,7 @@ export function itemToVega( bookmarkProps: { rowID: string; rowName: string; color: string } | false, ): VegaItem { return { - ...item.atts, + ...getVegaCompatibleAttributes(item), color: defaultElementColor, ...(bookmarkProps ? { diff --git a/packages/upset/src/utils/data.ts b/packages/upset/src/utils/data.ts index 9b275ffa7..f3ba4aca3 100644 --- a/packages/upset/src/utils/data.ts +++ b/packages/upset/src/utils/data.ts @@ -12,6 +12,11 @@ function deriveAttributeColumns(data: UpsetItem[]): Record { if (data.length > 0) { Object.entries(data[0]).forEach(([key, value]) => { + if (value instanceof Date) { + attributeColumns[key] = 'date'; + return; + } + const type = typeof value; switch (type) { case 'string': diff --git a/packages/upset/src/utils/date.ts b/packages/upset/src/utils/date.ts new file mode 100644 index 000000000..5abb33418 --- /dev/null +++ b/packages/upset/src/utils/date.ts @@ -0,0 +1,85 @@ +const SECOND_MS = 1000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const YEAR_MS = 365.25 * DAY_MS; + +const yearFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + timeZone: 'UTC', +}); +const monthYearFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + year: 'numeric', + timeZone: 'UTC', +}); +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}); +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', +}); +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: 'UTC', +}); + +function isStartOfUtcYear(value: number) { + const date = new Date(value); + return ( + date.getUTCMonth() === 0 && + date.getUTCDate() === 1 && + date.getUTCHours() === 0 && + date.getUTCMinutes() === 0 && + date.getUTCSeconds() === 0 && + date.getUTCMilliseconds() === 0 + ); +} + +function isStartOfUtcDay(value: number) { + const date = new Date(value); + return ( + date.getUTCHours() === 0 && + date.getUTCMinutes() === 0 && + date.getUTCSeconds() === 0 && + date.getUTCMilliseconds() === 0 + ); +} + +function getDateFormatter(domain: [number, number]) { + const [min, max] = domain; + const span = Math.abs(max - min); + + if (isStartOfUtcYear(min) && isStartOfUtcYear(max)) return yearFormatter; + if (isStartOfUtcDay(min) && isStartOfUtcDay(max) && span >= YEAR_MS / 2) { + return monthYearFormatter; + } + if (isStartOfUtcDay(min) && isStartOfUtcDay(max)) return dateFormatter; + if (span >= DAY_MS * 30) return dateFormatter; + if (span >= MINUTE_MS) return dateTimeFormatter; + return timeFormatter; +} + +export function createDateTickFormatter(domain: [number, number]) { + const formatter = getDateFormatter(domain); + return (value: number) => formatter.format(new Date(value)); +} + +export function formatDateValue( + value: number | undefined, + domain: [number, number], +): string { + if (value === undefined || Number.isNaN(value)) return 'N/A'; + return createDateTickFormatter(domain)(value).toString(); +} diff --git a/packages/upset/stories/Upset.stories.tsx b/packages/upset/stories/Upset.stories.tsx index c3b5f86ba..78d1fb5a6 100644 --- a/packages/upset/stories/Upset.stories.tsx +++ b/packages/upset/stories/Upset.stories.tsx @@ -1,10 +1,36 @@ /* eslint-disable react/destructuring-assignment */ import React from 'react'; -import moviesData from './data/movies/data.json' assert { type: 'json' }; +import moviesData from './data/movies/data.json' with { type: 'json' }; import { Upset } from '../src'; -import { assert } from 'console'; + +const dateData = [ + { + id: 'date/1', + Name: 'Alpha', + 'Group A': true, + 'Group B': false, + ReleaseDate: new Date(Date.UTC(1999, 0, 1)), + Premiere: new Date(Date.UTC(2024, 0, 2)), + }, + { + id: 'date/2', + Name: 'Beta', + 'Group A': true, + 'Group B': true, + ReleaseDate: new Date(Date.UTC(2001, 0, 1)), + Premiere: new Date(Date.UTC(2024, 1, 15)), + }, + { + id: 'date/3', + Name: 'Gamma', + 'Group A': false, + 'Group B': true, + ReleaseDate: new Date(Date.UTC(2004, 0, 1)), + Premiere: new Date(Date.UTC(2024, 4, 20, 13, 30)), + }, +]; const meta = { title: 'Upset', @@ -45,3 +71,15 @@ export const Movies = Template.bind({}); Movies.args = { dataset: 'Movies', }; + +export const DateAttributes = () => ( +
+ +
+); diff --git a/playwright/mock-data/date-attributes/date_attributes_annotations.json b/playwright/mock-data/date-attributes/date_attributes_annotations.json new file mode 100644 index 000000000..1fdec8d98 --- /dev/null +++ b/playwright/mock-data/date-attributes/date_attributes_annotations.json @@ -0,0 +1,8 @@ +{ + "Name": "label", + "Group A": "boolean", + "Group B": "boolean", + "ReleaseDate": "date", + "Premiere": "date", + "Score": "number" +} diff --git a/playwright/mock-data/date-attributes/date_attributes_data.json b/playwright/mock-data/date-attributes/date_attributes_data.json new file mode 100644 index 000000000..5c071f781 --- /dev/null +++ b/playwright/mock-data/date-attributes/date_attributes_data.json @@ -0,0 +1,73 @@ +{ + "count": 6, + "next": null, + "previous": null, + "results": [ + { + "_key": "1", + "_id": "date_attributes/1", + "_rev": "1", + "Name": "Alpha", + "Group A": true, + "Group B": false, + "ReleaseDate": "1999", + "Premiere": "2024-01-02", + "Score": 1 + }, + { + "_key": "2", + "_id": "date_attributes/2", + "_rev": "1", + "Name": "Beta", + "Group A": true, + "Group B": true, + "ReleaseDate": "2001", + "Premiere": "2024-02-15", + "Score": 2 + }, + { + "_key": "3", + "_id": "date_attributes/3", + "_rev": "1", + "Name": "Gamma", + "Group A": false, + "Group B": true, + "ReleaseDate": "2003", + "Premiere": "2024-03-30", + "Score": 3 + }, + { + "_key": "4", + "_id": "date_attributes/4", + "_rev": "1", + "Name": "Delta", + "Group A": false, + "Group B": false, + "ReleaseDate": "2006", + "Premiere": "2024-04-10", + "Score": 4 + }, + { + "_key": "5", + "_id": "date_attributes/5", + "_rev": "1", + "Name": "Epsilon", + "Group A": true, + "Group B": false, + "ReleaseDate": "2004", + "Premiere": "2024-05-20T13:30:00Z", + "Score": 5 + }, + { + "_key": "6", + "_id": "date_attributes/6", + "_rev": "1", + "Name": "Zeta", + "Group A": false, + "Group B": true, + "ReleaseDate": "2002", + "Premiere": "03/04/2024", + "Score": 6 + } + ] +}