From caf237a995729418f15d5d8faceea0860a2f0c5c Mon Sep 17 00:00:00 2001 From: islxyqwe Date: Sun, 26 May 2024 16:00:23 +0800 Subject: [PATCH] feat: add iso year and week feature (#386) * feat: add iso datetime function * fix: dataTable * fix: color combo box * fix: time unit * chore: change week def to duckdb * fix: dataTable --- .../src/components/dataTable/pagination.tsx | 2 +- .../components/dropdownSelect/combobox.tsx | 6 +- packages/graphic-walker/src/constants.ts | 19 +- .../src/lib/op/dateTime.test.ts | 539 ++++++++++++++++++ .../src/lib/op/dateTimeDrill.ts | 42 +- .../src/lib/op/dateTimeFeature.ts | 57 +- packages/graphic-walker/src/lib/vega.ts | 4 +- .../graphic-walker/src/locales/en-US.json | 5 +- .../graphic-walker/src/locales/ja-JP.json | 5 +- .../graphic-walker/src/locales/zh-CN.json | 5 +- .../graphic-walker/src/renderer/index.tsx | 21 +- .../graphic-walker/src/vis/spec/encode.ts | 30 +- .../graphic-walker/src/vis/spec/tooltip.ts | 7 +- 13 files changed, 708 insertions(+), 34 deletions(-) create mode 100644 packages/graphic-walker/src/lib/op/dateTime.test.ts diff --git a/packages/graphic-walker/src/components/dataTable/pagination.tsx b/packages/graphic-walker/src/components/dataTable/pagination.tsx index b944d6a5..3ce2a3ce 100644 --- a/packages/graphic-walker/src/components/dataTable/pagination.tsx +++ b/packages/graphic-walker/src/components/dataTable/pagination.tsx @@ -91,7 +91,7 @@ export default function Pagination(props: PaginationProps) { )} - {showIndices.slice(1, -1).map((page) => pageButton(page.index))} + {showIndices.slice(1, showIndices.length > 2 ? -1 : undefined).map((page) => pageButton(page.index))} {showIndices.length > 2 && showIndices[showIndices.length - 1].index > showIndices[showIndices.length - 2].index + 1 && ( diff --git a/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx b/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx index 125e6680..701f304e 100644 --- a/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx +++ b/packages/graphic-walker/src/components/dropdownSelect/combobox.tsx @@ -40,11 +40,11 @@ function Combobox({ { - if (currentValue === '_none') { + onSelect={() => { + if (opt.value === '_none') { onSelect?.(''); } else { - onSelect?.(currentValue === selectedKey ? '' : currentValue); + onSelect?.(opt.value === selectedKey ? '' : opt.value); } setOpen(false); }} diff --git a/packages/graphic-walker/src/constants.ts b/packages/graphic-walker/src/constants.ts index cf83757d..d8f8ab31 100644 --- a/packages/graphic-walker/src/constants.ts +++ b/packages/graphic-walker/src/constants.ts @@ -1,11 +1,20 @@ export const COUNT_FIELD_ID = 'gw_count_fid'; -export const DATE_TIME_DRILL_LEVELS = [ - "year", "quarter", "month", "week", "day", "hour", "minute", "second" -] as const; +export const DATE_TIME_DRILL_LEVELS = ['year', 'quarter', 'month', 'week', 'day', 'hour', 'minute', 'second', 'iso_year', 'iso_week'] as const; export const DATE_TIME_FEATURE_LEVELS = [ - "year", "quarter", "month", "week", "weekday", "day", "hour", "minute", "second" + 'year', + 'quarter', + 'month', + 'week', + 'weekday', + 'day', + 'hour', + 'minute', + 'second', + 'iso_year', + 'iso_week', + 'iso_weekday', ] as const; export const MEA_KEY_ID = 'gw_mea_key_fid'; export const MEA_VAL_ID = 'gw_mea_val_fid'; -export const PAINT_FIELD_ID = 'gw_paint_fid'; \ No newline at end of file +export const PAINT_FIELD_ID = 'gw_paint_fid'; diff --git a/packages/graphic-walker/src/lib/op/dateTime.test.ts b/packages/graphic-walker/src/lib/op/dateTime.test.ts new file mode 100644 index 00000000..f5f68fac --- /dev/null +++ b/packages/graphic-walker/src/lib/op/dateTime.test.ts @@ -0,0 +1,539 @@ +import dateTimeDrill from './dateTimeDrill'; +import dateTimeFeature from './dateTimeFeature'; + +function bind(a: any[], b: any[]) { + return a.map((v, i) => [v, b[i]]); +} + +const getParams = (value: T) => [ + { + type: 'field', + value: 'date', + } as const, + { + type: 'value', + value, + } as const, + { + type: 'displayOffset', + value: 0, + } as const, +]; + +const date = [ + '2010-01-01T07:41:08.000Z', + '2010-04-22T03:21:58.000Z', + '2010-12-31T15:38:24.000Z', + '2011-01-01T08:55:46.000Z', + '2011-08-15T14:23:12.000Z', + '2011-12-31T04:45:38.000Z', + '2012-01-01T21:53:07.000Z', + '2012-02-20T14:33:22.000Z', + '2012-12-31T01:49:05.000Z', + '2013-01-01T17:06:33.000Z', + '2013-10-05T02:11:42.000Z', + '2013-12-31T12:42:53.000Z', + '2014-01-01T13:45:49.000Z', + '2014-07-04T08:33:12.000Z', + '2014-12-30T16:39:56.000Z', + '2015-01-01T20:09:42.000Z', + '2015-11-11T18:25:08.000Z', + '2015-12-31T06:45:31.000Z', + '2016-01-01T11:33:45.000Z', + '2016-09-09T15:01:40.000Z', + '2016-12-31T04:03:11.000Z', + '2017-01-01T13:22:04.000Z', + '2017-06-03T09:38:09.000Z', + '2017-12-31T12:14:58.000Z', + '2018-01-01T22:54:44.000Z', + '2018-05-05T17:43:32.000Z', + '2018-12-31T04:45:25.000Z', +]; + +// export const DATE_TIME_DRILL_LEVELS = [ +// "year", "quarter", "month", "week", "day", "hour", "minute", "second", 'iso_year', 'iso_week', +// ] as const; +describe('drill', () => { + test('year', () => { + const { result } = dateTimeDrill('result', getParams('year'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + ]) + ); + }); + test('quarter', () => { + const { result } = dateTimeDrill( + 'result', + getParams('quarter'), + + { date } + ); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-01T00:00:00.000Z', + '2010-10-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-07-01T00:00:00.000Z', + '2011-10-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-10-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-01T00:00:00.000Z', + '2014-10-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-10-01T00:00:00.000Z', + '2015-10-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-07-01T00:00:00.000Z', + '2016-10-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-04-01T00:00:00.000Z', + '2017-10-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-04-01T00:00:00.000Z', + '2018-10-01T00:00:00.000Z', + ]) + ); + }); + test('month', () => { + const { result } = dateTimeDrill('result', getParams('month'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-01T00:00:00.000Z', + '2010-12-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-08-01T00:00:00.000Z', + '2011-12-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-01T00:00:00.000Z', + '2012-12-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-01T00:00:00.000Z', + '2013-12-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-01T00:00:00.000Z', + '2014-12-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-11-01T00:00:00.000Z', + '2015-12-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-09-01T00:00:00.000Z', + '2016-12-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-06-01T00:00:00.000Z', + '2017-12-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-05-01T00:00:00.000Z', + '2018-12-01T00:00:00.000Z', + ]) + ); + }); + test('week', () => { + const { result } = dateTimeDrill('result', getParams('week'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-12-27T00:00:00.000Z', + '2010-04-18T00:00:00.000Z', + '2010-12-26T00:00:00.000Z', + '2010-12-26T00:00:00.000Z', + '2011-08-14T00:00:00.000Z', + '2011-12-25T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-19T00:00:00.000Z', + '2012-12-30T00:00:00.000Z', + '2012-12-30T00:00:00.000Z', + '2013-09-29T00:00:00.000Z', + '2013-12-29T00:00:00.000Z', + '2013-12-29T00:00:00.000Z', + '2014-06-29T00:00:00.000Z', + '2014-12-28T00:00:00.000Z', + '2014-12-28T00:00:00.000Z', + '2015-11-08T00:00:00.000Z', + '2015-12-27T00:00:00.000Z', + '2015-12-27T00:00:00.000Z', + '2016-09-04T00:00:00.000Z', + '2016-12-25T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-05-28T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-04-29T00:00:00.000Z', + '2018-12-30T00:00:00.000Z', + ]) + ); + }); + test('day', () => { + const { result } = dateTimeDrill('result', getParams('day'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T00:00:00.000Z', + '2010-04-22T00:00:00.000Z', + '2010-12-31T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-08-15T00:00:00.000Z', + '2011-12-31T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2012-02-20T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-10-05T00:00:00.000Z', + '2013-12-31T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-07-04T00:00:00.000Z', + '2014-12-30T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-11-11T00:00:00.000Z', + '2015-12-31T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-09-09T00:00:00.000Z', + '2016-12-31T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-06-03T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-05-05T00:00:00.000Z', + '2018-12-31T00:00:00.000Z', + ]) + ); + }); + test('hour', () => { + const { result } = dateTimeDrill('result', getParams('hour'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:00:00.000Z', + '2010-04-22T03:00:00.000Z', + '2010-12-31T15:00:00.000Z', + '2011-01-01T08:00:00.000Z', + '2011-08-15T14:00:00.000Z', + '2011-12-31T04:00:00.000Z', + '2012-01-01T21:00:00.000Z', + '2012-02-20T14:00:00.000Z', + '2012-12-31T01:00:00.000Z', + '2013-01-01T17:00:00.000Z', + '2013-10-05T02:00:00.000Z', + '2013-12-31T12:00:00.000Z', + '2014-01-01T13:00:00.000Z', + '2014-07-04T08:00:00.000Z', + '2014-12-30T16:00:00.000Z', + '2015-01-01T20:00:00.000Z', + '2015-11-11T18:00:00.000Z', + '2015-12-31T06:00:00.000Z', + '2016-01-01T11:00:00.000Z', + '2016-09-09T15:00:00.000Z', + '2016-12-31T04:00:00.000Z', + '2017-01-01T13:00:00.000Z', + '2017-06-03T09:00:00.000Z', + '2017-12-31T12:00:00.000Z', + '2018-01-01T22:00:00.000Z', + '2018-05-05T17:00:00.000Z', + '2018-12-31T04:00:00.000Z', + ]) + ); + }); + test('minute', () => { + const { result } = dateTimeDrill('result', getParams('minute'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:41:00.000Z', + '2010-04-22T03:21:00.000Z', + '2010-12-31T15:38:00.000Z', + '2011-01-01T08:55:00.000Z', + '2011-08-15T14:23:00.000Z', + '2011-12-31T04:45:00.000Z', + '2012-01-01T21:53:00.000Z', + '2012-02-20T14:33:00.000Z', + '2012-12-31T01:49:00.000Z', + '2013-01-01T17:06:00.000Z', + '2013-10-05T02:11:00.000Z', + '2013-12-31T12:42:00.000Z', + '2014-01-01T13:45:00.000Z', + '2014-07-04T08:33:00.000Z', + '2014-12-30T16:39:00.000Z', + '2015-01-01T20:09:00.000Z', + '2015-11-11T18:25:00.000Z', + '2015-12-31T06:45:00.000Z', + '2016-01-01T11:33:00.000Z', + '2016-09-09T15:01:00.000Z', + '2016-12-31T04:03:00.000Z', + '2017-01-01T13:22:00.000Z', + '2017-06-03T09:38:00.000Z', + '2017-12-31T12:14:00.000Z', + '2018-01-01T22:54:00.000Z', + '2018-05-05T17:43:00.000Z', + '2018-12-31T04:45:00.000Z', + ]) + ); + }); + test('second', () => { + const { result } = dateTimeDrill('result', getParams('second'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2010-01-01T07:41:08.000Z', + '2010-04-22T03:21:58.000Z', + '2010-12-31T15:38:24.000Z', + '2011-01-01T08:55:46.000Z', + '2011-08-15T14:23:12.000Z', + '2011-12-31T04:45:38.000Z', + '2012-01-01T21:53:07.000Z', + '2012-02-20T14:33:22.000Z', + '2012-12-31T01:49:05.000Z', + '2013-01-01T17:06:33.000Z', + '2013-10-05T02:11:42.000Z', + '2013-12-31T12:42:53.000Z', + '2014-01-01T13:45:49.000Z', + '2014-07-04T08:33:12.000Z', + '2014-12-30T16:39:56.000Z', + '2015-01-01T20:09:42.000Z', + '2015-11-11T18:25:08.000Z', + '2015-12-31T06:45:31.000Z', + '2016-01-01T11:33:45.000Z', + '2016-09-09T15:01:40.000Z', + '2016-12-31T04:03:11.000Z', + '2017-01-01T13:22:04.000Z', + '2017-06-03T09:38:09.000Z', + '2017-12-31T12:14:58.000Z', + '2018-01-01T22:54:44.000Z', + '2018-05-05T17:43:32.000Z', + '2018-12-31T04:45:25.000Z', + ]) + ); + }); + + test('iso_year', () => { + const { result } = dateTimeDrill('result', getParams('iso_year'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2010-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2011-01-01T00:00:00.000Z', + '2012-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2013-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2014-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2015-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2016-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2019-01-01T00:00:00.000Z', + ]) + ); + }); + test('iso_week', () => { + const { result } = dateTimeDrill('result', getParams('iso_week'), { date }); + expect( + bind( + date, + result.map((x) => new Date(x).toISOString()) + ) + ).toEqual( + bind(date, [ + '2009-12-28T00:00:00.000Z', + '2010-04-19T00:00:00.000Z', + '2010-12-27T00:00:00.000Z', + '2010-12-27T00:00:00.000Z', + '2011-08-15T00:00:00.000Z', + '2011-12-26T00:00:00.000Z', + '2011-12-26T00:00:00.000Z', + '2012-02-20T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2012-12-31T00:00:00.000Z', + '2013-09-30T00:00:00.000Z', + '2013-12-30T00:00:00.000Z', + '2013-12-30T00:00:00.000Z', + '2014-06-30T00:00:00.000Z', + '2014-12-29T00:00:00.000Z', + '2014-12-29T00:00:00.000Z', + '2015-11-09T00:00:00.000Z', + '2015-12-28T00:00:00.000Z', + '2015-12-28T00:00:00.000Z', + '2016-09-05T00:00:00.000Z', + '2016-12-26T00:00:00.000Z', + '2016-12-26T00:00:00.000Z', + '2017-05-29T00:00:00.000Z', + '2017-12-25T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-04-30T00:00:00.000Z', + '2018-12-31T00:00:00.000Z', + ]) + ); + }); +}); + +describe('feature', () => { + test('year', () => { + const { result } = dateTimeFeature('result', getParams('year'), { date }); + expect(result).toEqual([ + 2010, 2010, 2010, 2011, 2011, 2011, 2012, 2012, 2012, 2013, 2013, 2013, 2014, 2014, 2014, 2015, 2015, 2015, 2016, 2016, 2016, 2017, 2017, 2017, + 2018, 2018, 2018, + ]); + }); + test('quarter', () => { + const { result } = dateTimeFeature('result', getParams('quarter'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 2, 4, 1, 3, 4, 1, 1, 4, 1, 4, 4, 1, 3, 4, 1, 4, 4, 1, 3, 4, 1, 2, 4, 1, 2, 4])); + }); + test('month', () => { + const { result } = dateTimeFeature('result', getParams('month'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 4, 12, 1, 8, 12, 1, 2, 12, 1, 10, 12, 1, 7, 12, 1, 11, 12, 1, 9, 12, 1, 6, 12, 1, 5, 12])); + }); + test('week', () => { + const { result } = dateTimeFeature('result', getParams('week'), { date }); + expect(bind(date, result)).toEqual(bind(date, [0, 16, 52, 0, 33, 52, 1, 8, 53, 0, 39, 52, 0, 26, 52, 0, 45, 52, 0, 36, 52, 1, 22, 53, 0, 17, 52])); + }); + test('weekday', () => { + const { result } = dateTimeFeature('result', getParams('weekday'), { date }); + expect(bind(date, result)).toEqual(bind(date, [5, 4, 5, 6, 1, 6, 0, 1, 1, 2, 6, 2, 3, 5, 2, 4, 3, 4, 5, 5, 6, 0, 6, 0, 1, 6, 1])); + }); + test('day', () => { + const { result } = dateTimeFeature('result', getParams('day'), { date }); + expect(bind(date, result)).toEqual(bind(date, [1, 22, 31, 1, 15, 31, 1, 20, 31, 1, 5, 31, 1, 4, 30, 1, 11, 31, 1, 9, 31, 1, 3, 31, 1, 5, 31])); + }); + test('hour', () => { + const { result } = dateTimeFeature('result', getParams('hour'), { date }); + expect(bind(date, result)).toEqual(bind(date, [7, 3, 15, 8, 14, 4, 21, 14, 1, 17, 2, 12, 13, 8, 16, 20, 18, 6, 11, 15, 4, 13, 9, 12, 22, 17, 4])); + }); + test('minute', () => { + const { result } = dateTimeFeature('result', getParams('minute'), { date }); + expect(bind(date, result)).toEqual( + bind(date, [41, 21, 38, 55, 23, 45, 53, 33, 49, 6, 11, 42, 45, 33, 39, 9, 25, 45, 33, 1, 3, 22, 38, 14, 54, 43, 45]) + ); + }); + test('second', () => { + const { result } = dateTimeFeature('result', getParams('second'), { date }); + expect(bind(date, result)).toEqual(bind(date, [8, 58, 24, 46, 12, 38, 7, 22, 5, 33, 42, 53, 49, 12, 56, 42, 8, 31, 45, 40, 11, 4, 9, 58, 44, 32, 25])); + }); + test('iso_year', () => { + const { result } = dateTimeFeature('result', getParams('iso_year'), { date }); + expect(bind(date, result)).toEqual( + bind( + date, + [ + 2009, 2010, 2010, 2010, 2011, 2011, 2011, 2012, 2013, 2013, 2013, 2014, 2014, 2014, 2015, 2015, 2015, 2015, 2015, 2016, 2016, 2016, 2017, + 2017, 2018, 2018, 2019, + ] + ) + ); + }); + test('iso_week', () => { + const { result } = dateTimeFeature('result', getParams('iso_week'), { date }); + expect(bind(date, result)).toEqual(bind(date, [53, 16, 52, 52, 33, 52, 52, 8, 1, 1, 40, 1, 1, 27, 1, 1, 46, 53, 53, 36, 52, 52, 22, 52, 1, 18, 1])); + }); + test('iso_weekday', () => { + const { result } = dateTimeFeature('result', getParams('iso_weekday'), { date }); + expect(bind(date, result)).toEqual(bind(date, [5, 4, 5, 6, 1, 6, 7, 1, 1, 2, 6, 2, 3, 5, 2, 4, 3, 4, 5, 5, 6, 7, 6, 7, 1, 6, 1])); + }); +}); + +describe('edge', () => { + const edgeDates = [ + '2012-12-31T00:00:00.000Z', + '2016-01-03T00:00:00.000Z', + '2016-01-04T00:00:00.000Z', + '2017-01-01T00:00:00.000Z', + '2017-01-02T00:00:00.000Z', + '2017-01-03T00:00:00.000Z', + '2017-12-31T00:00:00.000Z', + '2018-01-01T00:00:00.000Z', + '2018-01-02T00:00:00.000Z', + ]; + test('week', () => { + const { result } = dateTimeFeature('result', getParams('week'), { date: edgeDates }); + expect(bind(edgeDates, result)).toEqual(bind(edgeDates, [53, 1, 1, 1, 1, 1, 53, 0, 0])); + }); + test('iso_week', () => { + const { result } = dateTimeFeature('result', getParams('iso_week'), { date: edgeDates }); + expect(bind(edgeDates, result)).toEqual(bind(edgeDates, [1, 53, 1, 52, 1, 1, 52, 1, 1])); + }); +}); diff --git a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts index 0c630ae3..a2f86c7b 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts @@ -1,10 +1,16 @@ import { DATE_TIME_DRILL_LEVELS } from '../../constants'; import type { IExpParameter } from '../../interfaces'; import type { IDataFrame } from '../execExp'; -import { newOffsetDate } from './offset'; +import { OffsetDate, newOffsetDate } from './offset'; const formatDate = (date: Date) => date.getTime(); +const isoLargeYears = [ + 4, 9, 15, 20, 26, 32, 37, 43, 48, 54, 60, 65, 71, 76, 82, 88, 93, 99, 105, 111, 116, 122, 128, 133, 139, 144, 150, 156, 161, 167, 172, 178, 184, 189, 195, + 201, 207, 212, 218, 224, 229, 235, 240, 246, 252, 257, 263, 268, 274, 280, 285, 291, 296, 303, 308, 314, 320, 325, 331, 336, 342, 348, 353, 359, 364, 370, + 376, 381, 387, 392, 398, +]; + function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame): IDataFrame { const fieldKey = params.find((p) => p.type === 'field')?.value; const drillLevel = params.find((p) => p.type === 'value')?.value as (typeof DATE_TIME_DRILL_LEVELS)[number] | undefined; @@ -80,6 +86,40 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame [resKey]: newValues, }; } + case 'iso_year': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + const _Y = date.getFullYear(); + const dayInFirstWeek = toOffsetDate(_Y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + if (date.getTime() < firstMondayOfYear.getTime()) { + return formatDate(toOffsetDate(_Y - 1, 0, 1)); + } + const nextDayInFirstWeek = toOffsetDate(_Y + 1, 0, 4); + const nextFirstMondayOfYear = newDate( + newDate(nextDayInFirstWeek).setDate(nextDayInFirstWeek.getDate() - (nextDayInFirstWeek.getDay() || 7) + 1) + ); + return formatDate(toOffsetDate(date.getTime() < nextFirstMondayOfYear.getTime() ? _Y : _Y + 1, 0, 1)); + }); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_week': { + const newValues = fieldValues.map((v) => { + const today = newDate(v); + const date = newDate(today.setDate(today.getDate() - (today.getDay() || 7) + 1)); + const Y = date.getFullYear(); + const M = date.getMonth(); + const D = date.getDate(); + return formatDate(toOffsetDate(Y, M, D)); + }); + return { + ...data, + [resKey]: newValues, + }; + } case 'hour': { const newValues = fieldValues.map((v) => { const date = newDate(v); diff --git a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts index 0f7110a1..ffde6b85 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts @@ -15,6 +15,19 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame const prepareDate = newOffsetDate(offset); const toOffsetDate = newOffsetDate(displayOffset); const newDate = ((...x: []) => toOffsetDate(prepareDate(...x))) as typeof prepareDate; + function getISOYear(v: any) { + const date = newDate(v); + const y = date.getFullYear(); + const dayInFirstWeek = toOffsetDate(y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + if (date.getTime() < firstMondayOfYear.getTime()) { + return y - 1; + } + const nextY = y + 1; + const nextDayInFirstWeek = toOffsetDate(nextY, 0, 4); + const nextFirstMondayOfYear = newDate(newDate(nextDayInFirstWeek).setDate(nextDayInFirstWeek.getDate() - (nextDayInFirstWeek.getDay() || 7) + 1)); + return date.getTime() < nextFirstMondayOfYear.getTime() ? y : nextY; + } switch (drillLevel) { case 'year': { const newValues = fieldValues.map((v) => { @@ -50,13 +63,12 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame case 'week': { const newValues = fieldValues.map((v) => { const date = newDate(v); - const _Y = date.getFullYear(); - const _firstDayOfYear = newDate(_Y, 0, 1); - const _SundayOfFirstWeek = newDate(newDate(_firstDayOfYear).setDate(_firstDayOfYear.getDate() - _firstDayOfYear.getDay())); - const Y = date.getTime() - _SundayOfFirstWeek.getTime() > 1_000 * 60 * 60 * 24 * 7 ? _Y : _SundayOfFirstWeek.getFullYear(); - const firstDayOfYear = newDate(Y, 0, 1); - const SundayOfFirstWeek = newDate(newDate(firstDayOfYear).setDate(firstDayOfYear.getDate() - firstDayOfYear.getDay())); - const W = Math.floor((date.getTime() - SundayOfFirstWeek.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; + const Y = date.getFullYear(); + const firstDayOfYear = toOffsetDate(Y, 0, 1); + const SundayOfFirstWeek = newDate(firstDayOfYear.setDate(firstDayOfYear.getDate() - firstDayOfYear.getDay())); + const FirstSundayOfYear = + SundayOfFirstWeek.getFullYear() === Y ? SundayOfFirstWeek : newDate(SundayOfFirstWeek.setDate(SundayOfFirstWeek.getDate() + 7)); + const W = Math.floor((date.getTime() - FirstSundayOfYear.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; return W; }); return { @@ -74,6 +86,37 @@ function dateTimeDrill(resKey: string, params: IExpParameter[], data: IDataFrame [resKey]: newValues, }; } + case 'iso_year': { + const newValues = fieldValues.map(getISOYear); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_week': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + const y = getISOYear(v); + const dayInFirstWeek = toOffsetDate(y, 0, 4); + const firstMondayOfYear = newDate(newDate(dayInFirstWeek).setDate(dayInFirstWeek.getDate() - (dayInFirstWeek.getDay() || 7) + 1)); + const W = Math.floor((date.getTime() - firstMondayOfYear.getTime()) / (7 * 24 * 60 * 60 * 1_000)) + 1; + return W; + }); + return { + ...data, + [resKey]: newValues, + }; + } + case 'iso_weekday': { + const newValues = fieldValues.map((v) => { + const date = newDate(v); + return date.getDay() || 7; + }); + return { + ...data, + [resKey]: newValues, + }; + } case 'day': { const newValues = fieldValues.map((v) => { const date = newDate(v); diff --git a/packages/graphic-walker/src/lib/vega.ts b/packages/graphic-walker/src/lib/vega.ts index 897a0f9b..ecc6887d 100644 --- a/packages/graphic-walker/src/lib/vega.ts +++ b/packages/graphic-walker/src/lib/vega.ts @@ -70,7 +70,7 @@ export function toVegaSpec({ const rowFacetField = rowLeftFacetFields.length > 0 ? rowLeftFacetFields[rowLeftFacetFields.length - 1] : NULL_FIELD; const colFacetField = colLeftFacetFields.length > 0 ? colLeftFacetFields[colLeftFacetFields.length - 1] : NULL_FIELD; - const geomFieldIds = [...rows, ...columns, color, opacity, size] + const geomFieldIds = [...rows, ...columns, color, opacity, size, ...details] .filter((f) => Boolean(f)) .filter((f) => f!.aggName !== 'expr') .map((f) => (f as IViewField).fid); @@ -123,7 +123,7 @@ export function toVegaSpec({ column: colFacetField, xOffset: NULL_FIELD, yOffset: NULL_FIELD, - details, + details: details.map(guard).filter((x) => x !== NULL_FIELD), defaultAggregated, stack, geomType, diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index b6bbb115..0c8b2bac 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -328,7 +328,10 @@ "day": "Day", "hour": "Hour", "minute": "Minute", - "second": "Second" + "second": "Second", + "iso_year": "ISO Year", + "iso_week": "ISO Week", + "iso_weekday": "ISO Weekday" } } }, diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 9909ea86..e60742b6 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -322,7 +322,10 @@ "day": "日", "hour": "時", "minute": "分", - "second": "秒" + "second": "秒", + "iso_year": "ISO年", + "iso_week": "ISO週", + "iso_weekday": "ISO曜日" } } }, diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 5f588882..b49ae418 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -328,7 +328,10 @@ "day": "日", "hour": "时", "minute": "分", - "second": "秒" + "second": "秒", + "iso_year": "ISO年", + "iso_week": "ISO周", + "iso_weekday": "ISO星期几" } } }, diff --git a/packages/graphic-walker/src/renderer/index.tsx b/packages/graphic-walker/src/renderer/index.tsx index fd005542..3a908d30 100644 --- a/packages/graphic-walker/src/renderer/index.tsx +++ b/packages/graphic-walker/src/renderer/index.tsx @@ -29,6 +29,8 @@ import { GLOBAL_CONFIG } from '../config'; import { Item } from 'vega'; import { viewEncodingKeys } from '@/models/visSpec'; import LoadingLayer from '@/components/loadingLayer'; +import { getTimeFormat } from '@/lib/inferMeta'; +import { unexceptedUTCParsedPatternFormats } from '@/lib/op/offset'; interface RendererProps { vizThemeConfig: IThemeKey | GWGlobalConfig; @@ -149,16 +151,31 @@ const Renderer = forwardRef(function (props, r vizStore.showEmbededMenu([e.clientX, e.clientY]); vizStore.setFilters(values); }); - const selectedMarkObject = values.vlPoint.or[0]; + const { vlPoint, ...datums } = values; + const selectedMarkObject = Object.fromEntries(Object.entries(datums).map(([k, vs]) => [k, vs instanceof Array ? vs[0] : undefined])); // check selected fields include temporal, and return temporal timestamp to original data const allFields = viewEncodingKeys(visualConfig.geoms[0]).flatMap((k) => encodings[k] as IViewField[]); const selectedTemporalFields = Object.keys(selectedMarkObject) .map((k) => allFields.find((x) => x.fid === k)) .filter((x): x is IViewField => !!x && x.semanticType === 'temporal'); if (selectedTemporalFields.length > 0) { + const displayOffset = visualConfig.timezoneDisplayOffset ?? new Date().getTimezoneOffset(); selectedTemporalFields.forEach((f) => { + const offset = f.offset ?? new Date().getTimezoneOffset(); const set = new Set(viewData.map((x) => x[f.fid] as string | number)); - selectedMarkObject[f.fid] = [...set.values()].find((x) => new Date(x).getTime() === selectedMarkObject[f.fid]); + selectedMarkObject[f.fid] = Array.from(set).find((x) => { + const format = getTimeFormat(x); + let offsetTime = displayOffset * -60000; + if (format !== 'timestamp') { + offsetTime += offset * 60000; + if (!unexceptedUTCParsedPatternFormats.includes(format)) { + // the raw data will be parsed as local timezone, so reduce the offset with the local time zone. + offsetTime -= new Date().getTimezoneOffset() * 60000; + } + } + const time = new Date(x).getTime() + offsetTime; + return time === selectedMarkObject[f.fid]; + }); }); } if (e.item.mark.marktype === 'line') { diff --git a/packages/graphic-walker/src/vis/spec/encode.ts b/packages/graphic-walker/src/vis/spec/encode.ts index a222b41a..d718c07d 100644 --- a/packages/graphic-walker/src/vis/spec/encode.ts +++ b/packages/graphic-walker/src/vis/spec/encode.ts @@ -32,24 +32,37 @@ export function availableChannels(geomType: string): Set { } function encodeTimeunit(unit: (typeof DATE_TIME_DRILL_LEVELS)[number]) { switch (unit) { + case 'iso_year': + case 'year': + return 'utcyear'; case 'quarter': - return 'yearquarter'; + return 'utcyearquarter'; case 'month': - return 'yearmonth'; + return 'utcyearmonth'; + case 'iso_week': case 'week': - return 'yearweek'; + return 'utcyearweek'; case 'day': - return 'yearmonthdate'; + return 'utcyearmonthdate'; case 'hour': - return 'yearmonthdatehours'; + return 'utcyearmonthdatehours'; case 'minute': - return 'yearmonthdatehoursminutes'; + return 'utcyearmonthdatehoursminutes'; case 'second': - return 'yearmonthdatehoursminutesseconds'; + return 'utcyearmonthdatehoursminutesseconds'; } return unit; } +function isoTimeformat(unit: string) { + switch (unit) { + case 'iso_year': + return '%G'; + case 'iso_week': + return '%G W%V'; + } +} + export function encodeFid(fid: string) { return fid .replace(/([\"\'\.\[\]\/\\])/g, '\\$1') @@ -100,6 +113,9 @@ export function channelEncode(props: IEncodeProps) { encoding[c].scale = { type: 'utc' }; } if (field.semanticType === 'temporal' && field.timeUnit) { + if (field.timeUnit.startsWith('iso')) { + encoding[c].format = isoTimeformat(field.timeUnit); + } encoding[c].timeUnit = encodeTimeunit(field.timeUnit); } if (c === 'color' && field.expression?.op === 'paint') { diff --git a/packages/graphic-walker/src/vis/spec/tooltip.ts b/packages/graphic-walker/src/vis/spec/tooltip.ts index 16640ad3..36319d03 100644 --- a/packages/graphic-walker/src/vis/spec/tooltip.ts +++ b/packages/graphic-walker/src/vis/spec/tooltip.ts @@ -13,7 +13,8 @@ export function addTooltipEncode(encoding: { [key: string]: any }, details: Read title: encoding[ck].title, } as Record, (draft) => { - if (encoding[ck].timeUnit) { + if (encoding[ck].timeUnit && !encoding[ck].format) { + // timeUnit overrides format draft.timeUnit = encoding[ck].timeUnit; } if (encoding[ck].scale) { @@ -30,8 +31,8 @@ export function addTooltipEncode(encoding: { [key: string]: any }, details: Read }) .concat( details.map((f) => ({ - field: defaultAggregated ? getMeaAggKey(f.fid, f.aggName) : f.fid, - title: defaultAggregated ? getMeaAggName(f.name, f.aggName) : f.name, + field: defaultAggregated && f.analyticType === 'measure' ? getMeaAggKey(f.fid, f.aggName) : f.fid, + title: defaultAggregated && f.analyticType === 'measure' ? getMeaAggName(f.name, f.aggName) : f.name, type: f.semanticType, })) );