diff --git a/packages/graphic-walker/src/components/pivotTable/index.tsx b/packages/graphic-walker/src/components/pivotTable/index.tsx index 3d899e40..a68f7576 100644 --- a/packages/graphic-walker/src/components/pivotTable/index.tsx +++ b/packages/graphic-walker/src/components/pivotTable/index.tsx @@ -13,6 +13,7 @@ import { unstable_batchedUpdates } from 'react-dom'; import MetricTable from './metricTable'; import { toJS } from 'mobx'; import LoadingLayer from '../loadingLayer'; +import { fold2 } from '../../lib/op/fold'; interface PivotTableProps { themeKey?: IThemeKey; @@ -35,7 +36,7 @@ const PivotTable: React.FC = observer(function PivotTableCompon const { vizStore, commonStore } = useGlobalStore(); const { allFields, viewFilters, viewMeasures, sort, limit, draggableFieldState } = vizStore; const { rows, columns } = draggableFieldState; - const { showTableSummary, defaultAggregated } = visualConfig; + const { showTableSummary, defaultAggregated, folds } = visualConfig; const { tableCollapsedHeaderMap } = commonStore; const aggData = useRef([]); const [topTreeHeaderRowNum, setTopTreeHeaderRowNum] = useState(0); @@ -128,11 +129,13 @@ const PivotTable: React.FC = observer(function PivotTableCompon setIsLoading(true); appRef.current?.updateRenderStatus('computing'); const groupbyPromises: Promise[] = groupbyCombList.map((dimComb) => { - const workflow = toWorkflow(viewFilters, allFields, dimComb, viewMeasures, defaultAggregated, sort, limit > 0 ? limit : undefined); - return dataQueryServer(computationFunction, workflow, limit > 0 ? limit : undefined).catch((err) => { - appRef.current?.updateRenderStatus('error'); - return []; - }); + const workflow = toWorkflow(viewFilters, allFields, dimComb, viewMeasures, defaultAggregated, sort, folds ?? [], limit > 0 ? limit : undefined); + return dataQueryServer(computationFunction, workflow, limit > 0 ? limit : undefined) + .then((res) => fold2(res, defaultAggregated, allFields, viewMeasures, dimComb, folds)) + .catch((err) => { + appRef.current?.updateRenderStatus('error'); + return []; + }); }); return new Promise((resolve, reject) => { Promise.all(groupbyPromises) diff --git a/packages/graphic-walker/src/components/selectContext/index.tsx b/packages/graphic-walker/src/components/selectContext/index.tsx new file mode 100644 index 00000000..8a99450b --- /dev/null +++ b/packages/graphic-walker/src/components/selectContext/index.tsx @@ -0,0 +1,93 @@ +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { Listbox, Transition } from '@headlessui/react'; +import { CheckIcon, Cog6ToothIcon } from '@heroicons/react/24/outline'; + +export interface ISelectContextOption { + key: string; + label: string; + disabled?: boolean; +} +interface ISelectContextProps { + options?: ISelectContextOption[]; + disable?: boolean; + selectedKeys?: string[]; + onSelect?: (selectedKeys: string[]) => void; + className?: string; + required?: boolean; +} +const SelectContext: React.FC = (props) => { + const { options = [], disable = false, selectedKeys = [], onSelect, className = '', required } = props; + + const [selected, setSelected] = useState(options.filter((opt) => selectedKeys.includes(opt.key))); + + useEffect(() => { + setSelected(options.filter((opt) => selectedKeys.includes(opt.key))); + }, [options, selectedKeys]); + + const selectedKeysRef = useRef(selectedKeys); + selectedKeysRef.current = selectedKeys; + + const onSelectRef = useRef(onSelect); + onSelectRef.current = onSelect; + + useEffect(() => { + const keys = selected.map((opt) => opt.key); + if (keys.length !== selectedKeysRef.current.length || keys.some((key) => !selectedKeysRef.current.includes(key))) { + onSelectRef.current?.(keys); + } + }, [selected]); + + if (disable) { + return {props.children}; + } + + return ( + +
+
+ {props.children} + + +
+ + + {options.map((option) => ( + + `relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-amber-100 text-amber-900' : 'text-gray-900'}` + } + value={option} + > + {({ selected }) => ( + <> + {option.label} + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+
+ ); +}; + +export default SelectContext; diff --git a/packages/graphic-walker/src/components/tabs/editableTab.tsx b/packages/graphic-walker/src/components/tabs/editableTab.tsx index 045e38e0..7c775387 100644 --- a/packages/graphic-walker/src/components/tabs/editableTab.tsx +++ b/packages/graphic-walker/src/components/tabs/editableTab.tsx @@ -38,7 +38,6 @@ const Slider = (props: { className?: string; children: React.ReactNode; safeDist e.stopPropagation(); const rect = ref.current?.children[0]?.getBoundingClientRect(); if (rect) { - console.log(rect); setX((x) => Math.min(rect.width - props.safeDistance, Math.max(0, x + e.deltaY))); } return false; diff --git a/packages/graphic-walker/src/constants.ts b/packages/graphic-walker/src/constants.ts index 4ca208c8..0c177f54 100644 --- a/packages/graphic-walker/src/constants.ts +++ b/packages/graphic-walker/src/constants.ts @@ -5,3 +5,6 @@ export const DATE_TIME_DRILL_LEVELS = [ export const DATE_TIME_FEATURE_LEVELS = [ "year", "quarter", "month", "week", "weekday", "day", "hour", "minute", "second" ] as const; + +export const MEA_KEY_ID = 'gw_mea_key_fid'; +export const MEA_VAL_ID = 'gw_mea_val_fid'; diff --git a/packages/graphic-walker/src/fields/aestheticFields.tsx b/packages/graphic-walker/src/fields/aestheticFields.tsx index 99dc41b5..da51a9d9 100644 --- a/packages/graphic-walker/src/fields/aestheticFields.tsx +++ b/packages/graphic-walker/src/fields/aestheticFields.tsx @@ -36,7 +36,7 @@ const AestheticFields: React.FC = props => { }, [geoms[0]]) return
{ - channels.map(dkey => + channels.map((dkey, i, { length }) => {(provided, snapshot) => ( // diff --git a/packages/graphic-walker/src/fields/components.tsx b/packages/graphic-walker/src/fields/components.tsx index fce75570..eec0b6b5 100644 --- a/packages/graphic-walker/src/fields/components.tsx +++ b/packages/graphic-walker/src/fields/components.tsx @@ -1,13 +1,13 @@ -import React from 'react'; +import React, { CSSProperties } from 'react'; import styled from 'styled-components'; import { useTranslation } from 'react-i18next'; import { GLOBAL_CONFIG } from '../config'; -export const FieldListContainer: React.FC<{ name: string }> = (props) => { +export const FieldListContainer: React.FC<{ name: string; style?: Omit }> = (props) => { const { t } = useTranslation('translation', { keyPrefix: 'constant.draggable_key' }); return ( - +

{t(props.name)}

@@ -16,11 +16,11 @@ export const FieldListContainer: React.FC<{ name: string }> = (props) => { ); }; -export const AestheticFieldContainer: React.FC<{ name: string }> = (props) => { +export const AestheticFieldContainer: React.FC<{ name: string; style?: CSSProperties }> = (props) => { const { t } = useTranslation('translation', { keyPrefix: 'constant.draggable_key' }); return ( -
+

{t(props.name)}

@@ -78,7 +78,9 @@ export const FieldListSegment = styled.div` } } div.fl-container { - flex-grow: 10; + flex-grow: 10; + position: relative; + z-index: 1; } `; diff --git a/packages/graphic-walker/src/fields/datasetFields/meaFields.tsx b/packages/graphic-walker/src/fields/datasetFields/meaFields.tsx index 256f3000..6a65afd6 100644 --- a/packages/graphic-walker/src/fields/datasetFields/meaFields.tsx +++ b/packages/graphic-walker/src/fields/datasetFields/meaFields.tsx @@ -7,6 +7,7 @@ import DataTypeIcon from '../../components/dataTypeIcon'; import ActionMenu from '../../components/actionMenu'; import { useMenuActions } from './utils'; import { FieldPill } from './fieldPill'; +import { MEA_KEY_ID } from '../../constants'; interface Props { provided: DroppableProvided; @@ -25,7 +26,7 @@ const MeaFields: React.FC = (props) => { {(provided, snapshot) => { return (
- + = (props) => { const { dkey, provided, snapshot } = props; const { vizStore } = useGlobalStore(); - const { draggableFieldState, visualConfig } = vizStore; + const { draggableFieldState, visualConfig, allFields } = vizStore; + const folds = visualConfig.folds ?? []; const channelItem = draggableFieldState[dkey.id][0]; const { t } = useTranslation(); @@ -35,16 +37,33 @@ const SingleEncodeEditor: React.FC = (props) => { })); }, []); + const foldOptions = useMemo(() => { + const validFoldBy = allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); + return validFoldBy.map((f) => ({ + key: f.fid, + label: f.name, + })); + }, [allFields]); + return (
-
+
{t('actions.drop_field')}
{channelItem && ( {(provided, snapshot) => { return ( -
+
{ vizStore.removeField(dkey.id, 0); @@ -54,10 +73,20 @@ const SingleEncodeEditor: React.FC = (props) => {
- - {channelItem.name} - - {channelItem.analyticType === "measure" && channelItem.fid !== COUNT_FIELD_ID && visualConfig.defaultAggregated && ( + {channelItem.fid === MEA_KEY_ID && ( + { + vizStore.setVisualConfig('folds', keys); + }} + className="flex-1" + > + {channelItem.name} + + )} + {channelItem.fid !== MEA_KEY_ID && {channelItem.name}}{' '} + {channelItem.analyticType === 'measure' && channelItem.fid !== COUNT_FIELD_ID && visualConfig.defaultAggregated && ( { @@ -65,7 +94,7 @@ const SingleEncodeEditor: React.FC = (props) => { }} > - {channelItem.aggName || ""} + {channelItem.aggName || ''} diff --git a/packages/graphic-walker/src/fields/obComponents/obPill.tsx b/packages/graphic-walker/src/fields/obComponents/obPill.tsx index 7402ec53..66b3fbc1 100644 --- a/packages/graphic-walker/src/fields/obComponents/obPill.tsx +++ b/packages/graphic-walker/src/fields/obComponents/obPill.tsx @@ -1,14 +1,15 @@ -import { BarsArrowDownIcon, BarsArrowUpIcon, ChevronUpDownIcon } from "@heroicons/react/24/outline"; -import { observer } from "mobx-react-lite"; -import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { DraggableProvided } from "@kanaries/react-beautiful-dnd"; -import { COUNT_FIELD_ID } from "../../constants"; -import { IDraggableStateKey } from "../../interfaces"; -import { useGlobalStore } from "../../store"; -import { Pill } from "../components"; -import { GLOBAL_CONFIG } from "../../config"; -import DropdownContext from "../../components/dropdownContext"; +import { BarsArrowDownIcon, BarsArrowUpIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline'; +import { observer } from 'mobx-react-lite'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DraggableProvided } from '@kanaries/react-beautiful-dnd'; +import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; +import { IDraggableStateKey } from '../../interfaces'; +import { useGlobalStore } from '../../store'; +import { Pill } from '../components'; +import { GLOBAL_CONFIG } from '../../config'; +import DropdownContext from '../../components/dropdownContext'; +import SelectContext, { type ISelectContextOption } from '../../components/selectContext'; interface PillProps { provided: DraggableProvided; @@ -18,9 +19,9 @@ interface PillProps { const OBPill: React.FC = (props) => { const { provided, dkey, fIndex } = props; const { vizStore } = useGlobalStore(); - const { visualConfig } = vizStore; + const { visualConfig, allFields } = vizStore; const field = vizStore.draggableFieldState[dkey.id][fIndex]; - const { t } = useTranslation("translation", { keyPrefix: "constant.aggregator" }); + const { t } = useTranslation('translation', { keyPrefix: 'constant.aggregator' }); const aggregationOptions = useMemo(() => { return GLOBAL_CONFIG.AGGREGATOR_LIST.map((op) => ({ @@ -29,15 +30,37 @@ const OBPill: React.FC = (props) => { })); }, []); + const foldOptions = useMemo(() => { + const validFoldBy = allFields.filter((f) => f.analyticType === 'measure' && f.fid !== MEA_VAL_ID); + return validFoldBy.map((f) => ({ + key: f.fid, + label: f.name, + })); + }, [allFields]); + + const folds = field.fid === MEA_KEY_ID ? visualConfig.folds ?? [] : null; + return ( - {field.name}  - {field.analyticType === "measure" && field.fid !== COUNT_FIELD_ID && visualConfig.defaultAggregated && ( + {folds && ( + { + vizStore.setVisualConfig('folds', keys); + }} + > + {field.name} + + )} + {!folds && {field.name}} +   + {field.analyticType === 'measure' && field.fid !== COUNT_FIELD_ID && visualConfig.defaultAggregated && ( { @@ -45,7 +68,7 @@ const OBPill: React.FC = (props) => { }} > - {field.aggName || ""} + {field.aggName || ''} @@ -65,10 +88,10 @@ const OBPill: React.FC = (props) => { ))} )} */} - {field.analyticType === "dimension" && field.sort === "ascending" && ( + {field.analyticType === 'dimension' && field.sort === 'ascending' && ( )} - {field.analyticType === "dimension" && field.sort === "descending" && ( + {field.analyticType === 'dimension' && field.sort === 'descending' && ( )} diff --git a/packages/graphic-walker/src/fields/posFields/index.tsx b/packages/graphic-walker/src/fields/posFields/index.tsx index 821c5be2..5337160f 100644 --- a/packages/graphic-walker/src/fields/posFields/index.tsx +++ b/packages/graphic-walker/src/fields/posFields/index.tsx @@ -6,6 +6,10 @@ import { FieldListContainer } from "../components"; import { DRAGGABLE_STATE_KEYS } from '../fieldsContext'; import OBFieldContainer from '../obComponents/obFContainer'; +const firstChannelStyle = { + zIndex: 2, +}; + const PosFields: React.FC = props => { const { vizStore } = useGlobalStore(); const { visualConfig } = vizStore; @@ -25,7 +29,7 @@ const PosFields: React.FC = props => { }, [geoms[0], coordSystem]) return
{ - channels.map(dkey => + channels.map((dkey, i) => {(provided, snapshot) => ( diff --git a/packages/graphic-walker/src/interfaces.ts b/packages/graphic-walker/src/interfaces.ts index e7d0d87b..94f2b00e 100644 --- a/packages/graphic-walker/src/interfaces.ts +++ b/packages/graphic-walker/src/interfaces.ts @@ -70,7 +70,7 @@ export interface IFieldStats { range: [number, number]; } -export type IExpParamter = +export type IExpParameter = | { type: 'field'; value: string; @@ -94,7 +94,7 @@ export type IExpParamter = export interface IExpression { op: 'bin' | 'log2' | 'log10' | 'one' | 'binCount' | 'dateTimeDrill' | 'dateTimeFeature' | 'log'; - params: IExpParamter[]; + params: IExpParameter[]; as: string; num?: number; } @@ -281,6 +281,7 @@ export interface IVisualConfig { geoKey?: string; geoUrl?: IGeoUrl; limit: number; + folds?: string[]; } export interface IGeoUrl { diff --git a/packages/graphic-walker/src/lib/execExp.ts b/packages/graphic-walker/src/lib/execExp.ts index 1810b9df..fda02e9f 100644 --- a/packages/graphic-walker/src/lib/execExp.ts +++ b/packages/graphic-walker/src/lib/execExp.ts @@ -1,4 +1,4 @@ -import { IExpParamter, IExpression, IRow } from "../interfaces"; +import { IExpParameter, IExpression, IRow } from "../interfaces"; import dateTimeDrill from "./op/dateTimeDrill"; import dateTimeFeature from "./op/dateTimeFeature"; @@ -51,7 +51,7 @@ export function execExpression (exp: IExpression, dataFrame: IDataFrame): IDataF } } -function bin(resKey: string, params: IExpParamter[], data: IDataFrame, binSize: number | undefined = 10): IDataFrame { +function bin(resKey: string, params: IExpParameter[], data: IDataFrame, binSize: number | undefined = 10): IDataFrame { const { value: fieldKey } = params[0]; const fieldValues = data[fieldKey] as number[]; let _min = Infinity; @@ -81,7 +81,7 @@ function bin(resKey: string, params: IExpParamter[], data: IDataFrame, binSize: } } -function binCount(resKey: string, params: IExpParamter[], data: IDataFrame, binSize: number | undefined = 10): IDataFrame { +function binCount(resKey: string, params: IExpParameter[], data: IDataFrame, binSize: number | undefined = 10): IDataFrame { const { value: fieldKey } = params[0]; const fieldValues = data[fieldKey] as number[]; @@ -108,7 +108,7 @@ function binCount(resKey: string, params: IExpParamter[], data: IDataFrame, binS } } -function log2(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFrame { +function log2(resKey: string, params: IExpParameter[], data: IDataFrame): IDataFrame { const { value } = params[0]; const field = data[value]; const newField = field.map((v: number) => Math.log2(v)); @@ -118,7 +118,7 @@ function log2(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFr } } -function log10(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFrame { +function log10(resKey: string, params: IExpParameter[], data: IDataFrame): IDataFrame { const { value: fieldKey } = params[0]; const fieldValues = data[fieldKey]; const newField = fieldValues.map((v: number) => Math.log10(v)); @@ -128,7 +128,7 @@ function log10(resKey: string, params: IExpParamter[], data: IDataFrame): IDataF } } -function log(resKey: string, params: IExpParamter[], data: IDataFrame, baseNum: number | undefined=10): IDataFrame { +function log(resKey: string, params: IExpParameter[], data: IDataFrame, baseNum: number | undefined=10): IDataFrame { const { value: fieldKey } = params[0]; const fieldValues = data[fieldKey]; const newField = fieldValues.map((v: number) => Math.log(v) / Math.log(baseNum) ); @@ -138,7 +138,7 @@ function log(resKey: string, params: IExpParamter[], data: IDataFrame, baseNum: } } -function one(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFrame { +function one(resKey: string, params: IExpParameter[], data: IDataFrame): IDataFrame { // const { value: fieldKey } = params[0]; if (Object.keys(data).length === 0) return data; const len = data[Object.keys(data)[0]].length; diff --git a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts index 5a7b0c2f..40202200 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeDrill.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeDrill.ts @@ -1,5 +1,5 @@ import { DATE_TIME_DRILL_LEVELS } from '../../constants'; -import type { IExpParamter } from '../../interfaces'; +import type { IExpParameter } from '../../interfaces'; import type { IDataFrame } from '../execExp'; const formatDate = (date: Date) => { @@ -13,7 +13,7 @@ const formatDate = (date: Date) => { return `${Y}-${pad(M)}-${D} ${pad(H)}:${pad(m)}:${pad(s)}`; }; -function dateTimeDrill(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFrame { +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; if (!fieldKey || !drillLevel) { diff --git a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts index d33b308a..0a700713 100644 --- a/packages/graphic-walker/src/lib/op/dateTimeFeature.ts +++ b/packages/graphic-walker/src/lib/op/dateTimeFeature.ts @@ -1,9 +1,9 @@ import { DATE_TIME_FEATURE_LEVELS } from "../../constants"; -import type { IExpParamter } from "../../interfaces"; +import type { IExpParameter } from "../../interfaces"; import type { IDataFrame } from "../execExp"; -function dateTimeDrill(resKey: string, params: IExpParamter[], data: IDataFrame): IDataFrame { +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_FEATURE_LEVELS[number] | undefined; if (!fieldKey || !drillLevel) { diff --git a/packages/graphic-walker/src/lib/op/fold.ts b/packages/graphic-walker/src/lib/op/fold.ts index 8ea26135..d867bf77 100644 --- a/packages/graphic-walker/src/lib/op/fold.ts +++ b/packages/graphic-walker/src/lib/op/fold.ts @@ -1,7 +1,9 @@ -import { IRow } from "../../interfaces"; -import { IFoldQuery } from "../interfaces"; +import { MEA_VAL_ID, MEA_KEY_ID } from '../../constants'; +import { IRow, IViewField } from '../../interfaces'; +import { getMeaAggKey } from '../../utils'; +import { IFoldQuery } from '../interfaces'; -export function fold (data: IRow[], query: IFoldQuery): IRow[] { +export function fold(data: IRow[], query: IFoldQuery): IRow[] { const { foldBy, newFoldKeyCol, newFoldValueCol } = query; const ans: IRow[] = []; for (let row of data) { @@ -14,4 +16,42 @@ export function fold (data: IRow[], query: IFoldQuery): IRow[] { } } return ans; -} \ No newline at end of file +} + +export function fold2( + data: IRow, + defaultAggregated: boolean, + allFields: Omit[], + viewMeasures: Omit[], + viewDimensions: Omit[], + folds?: string[] +) { + const meaVal = viewMeasures.find((x) => x.fid === MEA_VAL_ID); + if (viewDimensions.find((x) => x.fid === MEA_KEY_ID) && meaVal) { + if (!folds?.length) { + return []; + } + const foldedFields = (folds ?? []) + .map((x) => allFields.find((y) => y.fid === x)!) + .filter(Boolean) + .map((x) => { + if (defaultAggregated) { + return { + name: `${meaVal.aggName}(${x.name})`, + fid: getMeaAggKey(x.fid, meaVal.aggName), + }; + } + return { name: x.name, fid: x.fid }; + }); + const set = new Set(foldedFields.map((x) => x.fid)); + return data.flatMap((x) => { + const i = Object.fromEntries(Object.entries(x).filter((x) => !set.has(x[0]))); + return foldedFields.map((k) => ({ + ...i, + [MEA_KEY_ID]: k.name, + [defaultAggregated ? getMeaAggKey(MEA_VAL_ID, meaVal.aggName) : MEA_VAL_ID]: x[k.fid], + })); + }); + } + return data; +} diff --git a/packages/graphic-walker/src/locales/en-US.json b/packages/graphic-walker/src/locales/en-US.json index ccf1027c..6340f9e4 100644 --- a/packages/graphic-walker/src/locales/en-US.json +++ b/packages/graphic-walker/src/locales/en-US.json @@ -25,6 +25,8 @@ }, "constant": { "row_count": "Row count", + "mea_key": "Measure names", + "mea_val": "Measure values", "analytic_type": { "dimension": "dimension", "measure": "measure" diff --git a/packages/graphic-walker/src/locales/ja-JP.json b/packages/graphic-walker/src/locales/ja-JP.json index 0e0fe97c..c67422f1 100644 --- a/packages/graphic-walker/src/locales/ja-JP.json +++ b/packages/graphic-walker/src/locales/ja-JP.json @@ -25,6 +25,8 @@ }, "constant": { "row_count": "行数", + "dim_key": "名", + "dim_val": "値", "analytic_type": { "dimension": "ディメンション", "measure": "メジャー" diff --git a/packages/graphic-walker/src/locales/zh-CN.json b/packages/graphic-walker/src/locales/zh-CN.json index 6c51b884..6eb62ad9 100644 --- a/packages/graphic-walker/src/locales/zh-CN.json +++ b/packages/graphic-walker/src/locales/zh-CN.json @@ -25,6 +25,8 @@ }, "constant": { "row_count": "行数", + "mea_key": "度量名称", + "mea_val": "度量值", "analytic_type": { "dimension": "维度", "measure": "度量" diff --git a/packages/graphic-walker/src/renderer/hooks.ts b/packages/graphic-walker/src/renderer/hooks.ts index 7d1077c7..8ff2851d 100644 --- a/packages/graphic-walker/src/renderer/hooks.ts +++ b/packages/graphic-walker/src/renderer/hooks.ts @@ -5,6 +5,7 @@ import { useGlobalStore } from '../store'; import { useAppRootContext } from '../components/appRoot'; import { toWorkflow } from '../utils/workflow'; import { dataQueryServer } from '../computation/serverComputation'; +import { fold2 } from '../lib/op/fold'; export const useComputationFunc = (): IComputationFunction => { const { vizStore } = useGlobalStore(); @@ -20,6 +21,7 @@ interface UseRendererProps { sort: 'none' | 'ascending' | 'descending'; limit: number; computationFunction: IComputationFunction; + folds?: string[]; } interface UseRendererResult { @@ -31,29 +33,12 @@ interface UseRendererResult { } export const useRenderer = (props: UseRendererProps): UseRendererResult => { - const { - allFields, - viewDimensions, - viewMeasures, - filters, - defaultAggregated, - sort, - limit, - computationFunction, - } = props; + const { allFields, viewDimensions, viewMeasures, filters, defaultAggregated, sort, limit, computationFunction, folds } = props; const [computing, setComputing] = useState(false); const taskIdRef = useRef(0); const workflow = useMemo(() => { - return toWorkflow( - filters, - allFields, - viewDimensions, - viewMeasures, - defaultAggregated, - sort, - limit > 0 ? limit : undefined - ); + return toWorkflow(filters, allFields, viewDimensions, viewMeasures, defaultAggregated, sort, folds, limit > 0 ? limit : undefined); }, [filters, allFields, viewDimensions, viewMeasures, defaultAggregated, sort, limit]); const [viewData, setViewData] = useState([]); @@ -65,28 +50,31 @@ export const useRenderer = (props: UseRendererProps): UseRendererResult => { const taskId = ++taskIdRef.current; appRef.current?.updateRenderStatus('computing'); setComputing(true); - dataQueryServer(computationFunction, workflow, limit > 0 ? limit : undefined).then(data => { - if (taskId !== taskIdRef.current) { - return; - } - appRef.current?.updateRenderStatus('rendering'); - unstable_batchedUpdates(() => { - setComputing(false); - setViewData(data); - setParsedWorkflow(workflow); + dataQueryServer(computationFunction, workflow, limit > 0 ? limit : undefined) + .then((res) => fold2(res, defaultAggregated, allFields, viewMeasures, viewDimensions, folds)) + .then((data) => { + if (taskId !== taskIdRef.current) { + return; + } + appRef.current?.updateRenderStatus('rendering'); + unstable_batchedUpdates(() => { + setComputing(false); + setViewData(data); + setParsedWorkflow(workflow); + }); + }) + .catch((err) => { + if (taskId !== taskIdRef.current) { + return; + } + appRef.current?.updateRenderStatus('error'); + console.error(err); + unstable_batchedUpdates(() => { + setComputing(false); + setViewData([]); + setParsedWorkflow([]); + }); }); - }).catch((err) => { - if (taskId !== taskIdRef.current) { - return; - } - appRef.current?.updateRenderStatus('error'); - console.error(err); - unstable_batchedUpdates(() => { - setComputing(false); - setViewData([]); - setParsedWorkflow([]); - }); - }); }, [computationFunction, workflow]); const parseResult = useMemo(() => { diff --git a/packages/graphic-walker/src/renderer/index.tsx b/packages/graphic-walker/src/renderer/index.tsx index f8d88cbe..e74a9a6b 100644 --- a/packages/graphic-walker/src/renderer/index.tsx +++ b/packages/graphic-walker/src/renderer/index.tsx @@ -58,6 +58,7 @@ const Renderer = forwardRef(function (props, r defaultAggregated: visualConfig.defaultAggregated, sort, limit: limit, + folds: visualConfig.folds, computationFunction, }); diff --git a/packages/graphic-walker/src/renderer/pureRenderer.tsx b/packages/graphic-walker/src/renderer/pureRenderer.tsx index d2acaaf0..eecefe1f 100644 --- a/packages/graphic-walker/src/renderer/pureRenderer.tsx +++ b/packages/graphic-walker/src/renderer/pureRenderer.tsx @@ -77,6 +77,7 @@ const PureRenderer = forwardRef(function filters, defaultAggregated, sort: sort ?? 'none', + folds: visualConfig.folds, limit: limit ?? -1, computationFunction: computation, }); diff --git a/packages/graphic-walker/src/store/visualSpecStore.ts b/packages/graphic-walker/src/store/visualSpecStore.ts index 129147e3..fb01731b 100644 --- a/packages/graphic-walker/src/store/visualSpecStore.ts +++ b/packages/graphic-walker/src/store/visualSpecStore.ts @@ -19,7 +19,7 @@ import { IGeoUrl, ISemanticType, } from '../interfaces'; -import { DATE_TIME_DRILL_LEVELS, DATE_TIME_FEATURE_LEVELS } from '../constants'; +import { DATE_TIME_DRILL_LEVELS, DATE_TIME_FEATURE_LEVELS, MEA_KEY_ID, MEA_VAL_ID } from '../constants'; import { GLOBAL_CONFIG } from '../config'; import { VisSpecWithHistory } from '../models/visSpecHistory'; import { @@ -34,7 +34,7 @@ import { initEncoding, } from '../utils/save'; import { CommonStore } from './commonStore'; -import { createCountField } from '../utils'; +import { createCountField, createVirtualFields } from '../utils'; import { COUNT_FIELD_ID } from '../constants'; import { nanoid } from 'nanoid'; import { toWorkflow } from '../utils/workflow'; @@ -363,6 +363,7 @@ export class VizSpecStore { } public initMetaState(dataset: DataSet) { const countField = createCountField(); + const virtualFields = createVirtualFields(); this.useMutable(({ encodings }) => { encodings.dimensions = dataset.rawFields .filter((f) => f.analyticType === 'dimension') @@ -386,6 +387,9 @@ export class VizSpecStore { aggName: 'sum', })); encodings.measures.push(countField); + for (const vf of virtualFields) { + encodings[`${vf.analyticType}s`].push(vf); + } }); this.freezeHistory(); @@ -422,6 +426,7 @@ export class VizSpecStore { case configKey === 'primaryColor': case configKey === 'colorPalette': case configKey === 'scale': + case configKey === 'folds': case configKey === 'stack': { return (config[configKey] = value); } @@ -1016,6 +1021,7 @@ export class VizSpecStore { this.viewMeasures, this.visualConfig.defaultAggregated, this.sort, + this.visualConfig.folds ?? [], this.limit > 0 ? this.limit : undefined ); } diff --git a/packages/graphic-walker/src/utils/index.ts b/packages/graphic-walker/src/utils/index.ts index f18b9de3..9dfb8b32 100644 --- a/packages/graphic-walker/src/utils/index.ts +++ b/packages/graphic-walker/src/utils/index.ts @@ -1,5 +1,5 @@ import i18next from "i18next"; -import { COUNT_FIELD_ID } from "../constants"; +import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from "../constants"; import { IRow, Filters, IViewField } from "../interfaces"; interface NRReturns { normalizedData: IRow[]; @@ -254,6 +254,27 @@ export function createCountField(): IViewField { }; } + +export function createVirtualFields(): IViewField[] { + return [ + { + dragId: MEA_KEY_ID, + fid: MEA_KEY_ID, + name: i18next.t("constant.mea_key"), + analyticType: "dimension", + semanticType: "nominal", + }, + { + dragId: MEA_VAL_ID, + fid: MEA_VAL_ID, + name: i18next.t("constant.mea_val"), + analyticType: "measure", + semanticType: "quantitative", + aggName: 'sum', + }, + ]; +} + export function getRange (nums: number[]): [number, number] { let _min = Infinity; let _max = -Infinity; diff --git a/packages/graphic-walker/src/utils/workflow.ts b/packages/graphic-walker/src/utils/workflow.ts index b390d7a4..02656775 100644 --- a/packages/graphic-walker/src/utils/workflow.ts +++ b/packages/graphic-walker/src/utils/workflow.ts @@ -1,7 +1,16 @@ -import type { IDataQueryWorkflowStep, IExpression, IFilterWorkflowStep, ITransformWorkflowStep, IViewField, IViewWorkflowStep, IVisFilter, ISortWorkflowStep } from "../interfaces"; -import type { VizSpecStore } from "../store/visualSpecStore"; -import { getMeaAggKey } from "."; - +import type { + IDataQueryWorkflowStep, + IExpression, + IFilterWorkflowStep, + ITransformWorkflowStep, + IViewField, + IViewWorkflowStep, + IVisFilter, + ISortWorkflowStep, +} from '../interfaces'; +import type { VizSpecStore } from '../store/visualSpecStore'; +import { getMeaAggKey } from '.'; +import { MEA_KEY_ID, MEA_VAL_ID } from '../constants'; const walkExpression = (expression: IExpression, each: (field: string) => void): void => { for (const param of expression.params) { @@ -13,19 +22,22 @@ const walkExpression = (expression: IExpression, each: (field: string) => void): } }; -const treeShake = (computedFields: readonly { key: string; expression: IExpression }[], viewKeys: readonly string[]): { key: string; expression: IExpression }[] => { +const treeShake = ( + computedFields: readonly { key: string; expression: IExpression }[], + viewKeys: readonly string[] +): { key: string; expression: IExpression }[] => { const usedFields = new Set(viewKeys); - const result = computedFields.filter(f => usedFields.has(f.key)); + const result = computedFields.filter((f) => usedFields.has(f.key)); let currentFields = result.slice(); - let rest = computedFields.filter(f => !usedFields.has(f.key)); + let rest = computedFields.filter((f) => !usedFields.has(f.key)); while (currentFields.length && rest.length) { const dependencies = new Set(); for (const f of currentFields) { - walkExpression(f.expression, field => dependencies.add(field)); + walkExpression(f.expression, (field) => dependencies.add(field)); } - const nextFields = rest.filter(f => dependencies.has(f.key)); + const nextFields = rest.filter((f) => dependencies.has(f.key)); currentFields = nextFields; - rest = rest.filter(f => !dependencies.has(f.key)); + rest = rest.filter((f) => !dependencies.has(f.key)); } return result; }; @@ -33,13 +45,26 @@ const treeShake = (computedFields: readonly { key: string; expression: IExpressi export const toWorkflow = ( viewFilters: VizSpecStore['viewFilters'], allFields: Omit[], - viewDimensions: Omit[], - viewMeasures: Omit[], + viewDimensionsRaw: Omit[], + viewMeasuresRaw: Omit[], defaultAggregated: VizSpecStore['visualConfig']['defaultAggregated'], sort: 'none' | 'ascending' | 'descending', - limit?: number, + folds = [] as string[], + limit?: number ): IDataQueryWorkflowStep[] => { - const viewKeys = new Set([...viewDimensions, ...viewMeasures].map(f => f.fid)); + const hasFold = viewDimensionsRaw.find(x => x.fid === MEA_KEY_ID) && viewMeasuresRaw.find(x => x.fid === MEA_VAL_ID); + const viewDimensions = viewDimensionsRaw.filter((x) => x.fid !== MEA_KEY_ID); + const viewMeasures = viewMeasuresRaw.filter((x) => x.fid !== MEA_VAL_ID); + if (hasFold) { + const aggName = viewMeasuresRaw.find((x) => x.fid === MEA_VAL_ID)!.aggName; + const newFields = folds + .map((k) => allFields.find((x) => x.fid === k)!) + .map((x) => ({ ...x, aggName })) + .filter(Boolean); + viewDimensions.push(...newFields.filter((x) => x?.analyticType === 'dimension')); + viewMeasures.push(...newFields.filter((x) => x?.analyticType === 'measure')); + } + const viewKeys = new Set([...viewDimensions, ...viewMeasures].map((f) => f.fid)); let filterWorkflow: IFilterWorkflowStep | null = null; let transformWorkflow: ITransformWorkflowStep | null = null; @@ -47,39 +72,41 @@ export const toWorkflow = ( let sortWorkflow: ISortWorkflowStep | null = null; // TODO: apply **fold** before filter - + // First, to apply filters on the detailed data - const filters = viewFilters.filter(f => f.rule).map(f => { - viewKeys.add(f.fid); - const rule = f.rule!; - if (rule.type === 'one of') { - return { - fid: f.fid, - rule: { - type: 'one of', - value: [...rule.value], - }, - }; - } else if (rule.type === 'temporal range') { - const range = [new Date(rule.value[0]).getTime(), new Date(rule.value[1]).getTime()] as const; - return { - fid: f.fid, - rule: { - type: 'temporal range', - value: range, - }, - }; - } else { - const range = [Number(rule.value[0]), Number(rule.value[1])] as const; - return { - fid: f.fid, - rule: { - type: 'range', - value: range, - }, - }; - } - }); + const filters = viewFilters + .filter((f) => f.rule) + .map((f) => { + viewKeys.add(f.fid); + const rule = f.rule!; + if (rule.type === 'one of') { + return { + fid: f.fid, + rule: { + type: 'one of', + value: [...rule.value], + }, + }; + } else if (rule.type === 'temporal range') { + const range = [new Date(rule.value[0]).getTime(), new Date(rule.value[1]).getTime()] as const; + return { + fid: f.fid, + rule: { + type: 'temporal range', + value: range, + }, + }; + } else { + const range = [Number(rule.value[0]), Number(rule.value[1])] as const; + return { + fid: f.fid, + rule: { + type: 'range', + value: range, + }, + }; + } + }); if (filters.length) { filterWorkflow = { type: 'filter', @@ -88,10 +115,15 @@ export const toWorkflow = ( } // Second, to transform the data by rows 1 by 1 - const computedFields = treeShake(allFields.filter(f => f.computed && f.expression).map(f => ({ - key: f.fid, - expression: f.expression!, - })), [...viewKeys]); + const computedFields = treeShake( + allFields + .filter((f) => f.computed && f.expression) + .map((f) => ({ + key: f.fid, + expression: f.expression!, + })), + [...viewKeys] + ); if (computedFields.length) { transformWorkflow = { type: 'transform', @@ -103,46 +135,44 @@ export const toWorkflow = ( // When aggregation is enabled, there're 2 cases: // 1. If any of the measures is aggregated, then we apply the aggregation // 2. If there's no measure in the view, then we apply the aggregation - const aggregateOn = viewMeasures.filter(f => f.aggName).map(f => [f.fid, f.aggName as string]); + const aggregateOn = viewMeasures.filter((f) => f.aggName).map((f) => [f.fid, f.aggName as string]); const aggergated = defaultAggregated && (aggregateOn.length || (viewMeasures.length === 0 && viewDimensions.length > 0)); + if (aggergated) { viewQueryWorkflow = { type: 'view', - query: [{ - op: 'aggregate', - groupBy: viewDimensions.map(f => f.fid), - measures: viewMeasures.map((f) => ({ - field: f.fid, - agg: f.aggName as any, - asFieldKey: getMeaAggKey(f.fid, f.aggName!), - })), - }], + query: [ + { + op: 'aggregate', + groupBy: viewDimensions.map((f) => f.fid), + measures: viewMeasures.map((f) => ({ + field: f.fid, + agg: f.aggName as any, + asFieldKey: getMeaAggKey(f.fid, f.aggName!), + })), + }, + ], }; } else { viewQueryWorkflow = { type: 'view', - query: [{ - op: 'raw', - fields: [...new Set([...viewDimensions, ...viewMeasures])].map(f => f.fid), - }], + query: [ + { + op: 'raw', + fields: [...new Set([...viewDimensions, ...viewMeasures])].map((f) => f.fid), + }, + ], }; } - if (sort !== "none" && limit) { + if (sort !== 'none' && limit) { sortWorkflow = { type: 'sort', - by: viewMeasures.map(f => aggergated ? getMeaAggKey(f.fid, f.aggName) : f.fid), + by: viewMeasures.map((f) => (aggergated ? getMeaAggKey(f.fid, f.aggName) : f.fid)), sort, }; } - - const steps: IDataQueryWorkflowStep[] = [ - filterWorkflow!, - transformWorkflow!, - viewQueryWorkflow!, - sortWorkflow!, - ].filter(Boolean); - + const steps: IDataQueryWorkflowStep[] = [filterWorkflow!, transformWorkflow!, viewQueryWorkflow!, sortWorkflow!].filter(Boolean); return steps; };