diff --git a/practices/professional/src/App.css b/practices/professional/src/App.css index e78496b152..b0000eeb9a 100644 --- a/practices/professional/src/App.css +++ b/practices/professional/src/App.css @@ -1,100 +1,200 @@ .app-root { height: 100%; width: 100%; - background: #0b0c1d; - display: flex; - justify-content: stretch; - align-items: stretch; - box-sizing: border-box; - overflow: hidden; min-height: 0; min-width: 0; - position: relative; + overflow: hidden; + padding: 12px; + box-sizing: border-box; + background: var(--app-bg); + color: var(--app-text); +} + +.app-root-dark { + --app-bg: radial-gradient(circle at top left, rgba(108, 140, 255, 0.14), transparent 28%), + linear-gradient(180deg, #0a1020 0%, #121a28 100%); + --panel-bg: rgba(15, 21, 34, 0.88); + --panel-muted: rgba(255, 255, 255, 0.05); + --panel-border: rgba(146, 167, 255, 0.16); + --panel-border-strong: rgba(146, 167, 255, 0.26); + --app-text: rgba(255, 255, 255, 0.92); + --app-text-muted: rgba(255, 255, 255, 0.56); + --app-hover: rgba(255, 255, 255, 0.08); +} + +.app-root-light { + --app-bg: radial-gradient(circle at top left, rgba(39, 93, 245, 0.1), transparent 30%), + linear-gradient(180deg, #eef3fb 0%, #f8fbff 100%); + --panel-bg: rgba(255, 255, 255, 0.9); + --panel-muted: rgba(26, 36, 56, 0.04); + --panel-border: rgba(39, 93, 245, 0.12); + --panel-border-strong: rgba(39, 93, 245, 0.22); + --app-text: #1b2536; + --app-text-muted: rgba(27, 37, 54, 0.58); + --app-hover: rgba(39, 93, 245, 0.08); } .builder-layout { width: 100%; height: 100%; display: flex; - background: #15162b; - border-radius: 8px; overflow: hidden; - gap: 0; + border-radius: 22px; + background: rgba(255, 255, 255, 0.02); + box-shadow: 0 18px 70px rgba(10, 16, 32, 0.18); } -.builder-panel { - display: flex; - flex: 1; - background: #1a1b33; +.left-panel, +.middle-panel, +.canvas-panel { + min-height: 0; } -.left-panel { +.left-panel, +.middle-panel { display: flex; flex-direction: column; - background: #1a1b33; + background: var(--panel-bg); + backdrop-filter: blur(12px); +} + +.left-panel { + border: 1px solid var(--panel-border); + border-right: none; } .middle-panel { - width: 200px; - background: #202244; - position: relative; + width: 296px; + border-top: 1px solid var(--panel-border); + border-bottom: 1px solid var(--panel-border); } .canvas-panel { flex: 1; - background: #26285a; position: relative; + border: 1px solid var(--panel-border); + background: var(--panel-bg); + overflow: hidden; +} + +.builder-toolbar { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border-bottom: 1px solid var(--panel-border); +} + +.builder-toolbar-main, +.builder-toolbar-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.builder-toolbar-meta { + flex-wrap: wrap; +} + +.toolbar-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.left-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px 8px; +} + +.left-panel-scroll, +.middle-panel-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0 12px 12px; +} + +.middle-panel-scroll { display: flex; flex-direction: column; + gap: 12px; +} + +.panel-card { + border: 1px solid var(--panel-border); + border-radius: 16px; + background: var(--panel-muted); overflow: hidden; } +.panel-stack { + display: flex; + flex-direction: column; +} + +.encoding-card { + min-height: 220px; +} + +.panel-section-title { + padding: 10px 4px 6px; + color: var(--app-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + .splitter { - width: 4px; + width: 6px; cursor: col-resize; + background: linear-gradient( + 180deg, + transparent 0%, + var(--panel-border-strong) 16%, + var(--panel-border-strong) 84%, + transparent 100% + ); } -.collapse-btn { - position: absolute; - right: 4px; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: transparent; +.collapse-btn, +.expand-btn { border: none; - color: #e0e0e0; - cursor: pointer; - font-size: 16px; - z-index: 10; + box-shadow: none; } -.collapse-btn:hover { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; +.collapse-btn { + margin-left: 0; + flex-shrink: 0; } .expand-btn { position: absolute; top: 12px; left: 12px; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: transparent; - border: none; - color: #e0e0e0; - cursor: pointer; - font-size: 16px; - z-index: 10; + z-index: 2; } .title { - padding: 12px; - font-size: 18px; + font-size: 15px; font-weight: 600; - color: #e0e0e0; + color: var(--app-text); +} + +.data-hint { + padding: 18px 8px 0; + color: var(--app-text-muted); + font-size: 12px; + text-align: center; +} + +.canvas-empty { + display: flex; + height: 100%; + align-items: center; + justify-content: center; } diff --git a/practices/professional/src/App.tsx b/practices/professional/src/App.tsx index 0f774633c8..f8861aad6b 100644 --- a/practices/professional/src/App.tsx +++ b/practices/professional/src/App.tsx @@ -1,828 +1,638 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import type { + VBIBuilder, + VBIDimension, + VBIHavingAggregate, + VBIMeasure, +} from '@visactor/vbi'; +import { isVBIHavingFilter } from '@visactor/vbi'; import './App.css'; -import { ConfigProvider, theme, Dropdown, Button } from 'antd'; -import { LeftOutlined, RightOutlined, UploadOutlined } from '@ant-design/icons'; +import { + Button, + ConfigProvider, + Dropdown, + Empty, + InputNumber, + Segmented, + Spin, + theme, + Tooltip, +} from 'antd'; +import enUS from 'antd/locale/en_US'; +import zhCN from 'antd/locale/zh_CN'; +import { + LeftOutlined, + MoonOutlined, + RedoOutlined, + RightOutlined, + SunOutlined, + UndoOutlined, + UploadOutlined, +} from '@ant-design/icons'; import DimensionShelf from './components/Shelfs/DimensionShelf'; import MeasureShelf from './components/Shelfs/MeasureShelf'; import { FilterPanel, type FilterItem } from './components/Filter/FilterPanel'; - +import { HavingFilterPanel } from './components/Filter/HavingFilterPanel'; import { ChartTypeSelector } from './components/ChartType'; -import FieldsList from './components/Fields/FieldList'; +import DimensionFieldList from './components/Fields/DimensionFieldList'; import MeasureFieldList from './components/Fields/MeasureFieldList'; -import EncodingPanel from './components/Fields/EncodingPanel'; +import EncodingPanel, { + type MeasureEncodingInfo, +} from './components/Fields/EncodingPanel'; import { VSeedRender } from './components/Render'; +import { useTranslation } from './i18n'; import { useVBIStore } from './model'; import { useShallow } from 'zustand/shallow'; +import { + useVBIBuilder, + useVBIChartType, + useVBIDimensions, + useVBIHavingFilter, + useVBIMeasures, + useVBISchemaFields, + useVBIUndoManager, + useVBIWhereFilter, +} from './hooks'; import { setLocalDataWithSchema } from './utils/localConnector'; import { parseCsv } from './utils/parseCsv'; +import { supermarketSchema } from './utils/supermarketSchema'; import { - supermarketDimensions, - supermarketMeasures, - supermarketSchema, -} from './utils/supermarketSchema'; - -type EncodingChannel = - | 'yAxis' - | 'xAxis' - | 'color' - | 'label' - | 'tooltip' - | 'size'; + PROFESSIONAL_DEFAULT_LIMIT, + type ProfessionalLocale, + type ProfessionalTheme, +} from './constants/builder'; + +const ANT_LOCALES = { + 'zh-CN': zhCN, + 'en-US': enUS, +} as const; + +const createThemeConfig = (themeMode: ProfessionalTheme) => ({ + algorithm: + themeMode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm, + token: { + colorPrimary: themeMode === 'dark' ? '#6c8cff' : '#275df5', + borderRadius: 12, + controlHeight: 30, + fontSize: 12, + }, +}); + +const normalizeLimit = (value: number) => Math.max(1, Math.round(value)); +type MeasureEncoding = NonNullable; +type MeasureAggregate = NonNullable; +type DimensionEncoding = NonNullable; +type DimensionAggregate = NonNullable; + +const clearBuilderState = (builder: VBIBuilder) => { + const dimensionIds = builder.dimensions.toJSON().map((item) => item.id).reverse(); + const measureIds = builder.measures.toJSON().map((item) => item.id).reverse(); + + builder.doc.transact(() => { + dimensionIds.forEach((id) => { + builder.dimensions.remove(id); + }); + measureIds.forEach((id) => { + builder.measures.remove(id); + }); + builder.whereFilter.clear(); + builder.havingFilter.clear(); + }); + + builder.chartType.changeChartType('table'); + builder.limit.setLimit(PROFESSIONAL_DEFAULT_LIMIT); +}; export function APP() { - const [leftWidth, setLeftWidth] = useState(220); + const [leftWidth, setLeftWidth] = useState(244); const [dragging, setDragging] = useState(false); const [builderCollapsed, setBuilderCollapsed] = useState(false); + const [schemaRefreshKey, setSchemaRefreshKey] = useState(0); - // VBI builder 相关 - - const builderRef = useRef(null); - - // 可用的字段和选中的字段 - const [dimensions, setDimensions] = useState([]); - const [measures, setMeasures] = useState([]); - const [dimensionFields, setDimensionFields] = useState([]); - const [measureFields, setMeasureFields] = useState([]); - const [measuresDetail, setMeasuresDetail] = useState< - Record< - string, - { - alias?: string; - aggregate?: { func: string; quantile?: number }; - encoding?: EncodingChannel; - } - > - >({}); - const [dimensionMeasures, setDimensionMeasures] = useState([]); - const [chartTypeOptions, setChartTypeOptions] = useState([]); - const [currentChartType, setCurrentChartType] = useState('table'); - const [renderKey, setRenderKey] = useState(0); - - // 获取 vbi store 的状态 - const { initialize, initialized, builder, vseed, dsl } = useVBIStore( + const { initialize, initialized, builder, vseed } = useVBIStore( useShallow((state) => ({ initialize: state.initialize, initialized: state.initialized, builder: state.builder, vseed: state.vseed, - dsl: state.dsl, })), ); - - const activeFields = useMemo(() => { - if (!dsl) return []; - const fields = new Set(); - - const extractFields = (items: any[]) => { - items?.forEach((item) => { - if (item && typeof item === 'object') { - if ('field' in item && typeof item.field === 'string') { - fields.add(item.field); - } - if ('children' in item && Array.isArray(item.children)) { - extractFields(item.children); - } - } - }); - }; - - extractFields(dsl.dimensions || []); - extractFields(dsl.measures || []); - return Array.from(fields); - }, [dsl]); - - const [allFields, setAllFields] = useState< - { name: string; role: 'dimension' | 'measure' }[] - >([]); - const [filters, setFilters] = useState([]); - - const getDimensionIdByField = (field: string) => { - const dimension = builderRef.current?.dimensions - ?.toJSON?.() - ?.find((item: { field: string; id: string }) => item.field === field); - - return dimension?.id; - }; - - const getMeasureIdByField = (field: string) => { - const measure = builderRef.current?.measures - ?.toJSON?.() - ?.find((item: { field: string; id: string }) => item.field === field); - - return measure?.id; - }; - - useEffect(() => { - const handleFilterError = () => { - setFilters((prev) => prev.slice(0, -1)); - }; - window.addEventListener('vbi-filter-error', handleFilterError); - return () => - window.removeEventListener('vbi-filter-error', handleFilterError); - }, []); + const { locale, setLocale, t } = useTranslation(); + const { theme: themeMode, setTheme, limit, setLimit } = useVBIBuilder(builder); + const { chartType, changeChartType, availableChartTypes } = + useVBIChartType(builder); + const { schemaFields, fieldRoleMap, fieldTypeMap } = useVBISchemaFields( + builder, + schemaRefreshKey, + ); + const { dimensions, addDimension, removeDimension, updateDimension } = + useVBIDimensions(builder); + const { + filters: havingFilters, + rootOperator: havingRootOperator, + setRootOperator: setHavingRootOperator, + replaceFilters: replaceHavingFilters, + } = useVBIHavingFilter(builder); + const { measures, addMeasure, removeMeasure, updateMeasure } = + useVBIMeasures(builder); + const { + flatFilters, + rootOperator: whereRootOperator, + setRootOperator: setWhereRootOperator, + replaceFilters, + } = useVBIWhereFilter(builder); + const { canUndo, canRedo, undo, redo } = useVBIUndoManager(builder); useEffect(() => { - if (initialized && builder) { - const fetchSchema = async () => { - const schema = await builder.getSchema(); - setAllFields( - schema.map((s: { name: string; type: string }) => ({ - name: s.name, - role: s.type === 'number' ? 'measure' : 'dimension', - })), - ); - }; - fetchSchema(); - } - }, [initialized, builder]); - - const handleFilterChange = (newFilters: FilterItem[]) => { - setFilters(newFilters); - if (builder) { - builder.doc.transact(() => { - builder.whereFilter.clear(); - newFilters.forEach((f) => { - builder.whereFilter.add(f.field, (node) => { - node.setOperator(f.operator); - node.setValue(f.value); - }); - }); - }); - setRenderKey((prev) => prev + 1); - } - }; + return initialize(); + }, [initialize]); - // 初始化 useEffect(() => { - initialize(); - builderRef.current = builder; - // setCurrentBuilder(builder); - - // 获取可用的图表类型 - if (builder?.chartType?.getAvailableChartTypes) { - const types = builder.chartType.getAvailableChartTypes(); - setChartTypeOptions(types); + if (!dragging) { + return; } - // 从 connector schema 获取可用的字段 - const loadSchema = async () => { - if (builder?.getSchema) { - try { - const schema = await builder.getSchema(); - const dims = schema - .filter((d: any) => d.type !== 'number') - .map((d: any) => d.name); - const meas = schema - .filter((d: any) => d.type === 'number') - .map((d: any) => d.name); - setDimensions(dims); - setMeasures(meas); - } catch (err) { - console.error('Failed to load schema:', err); - } - } + const onMove = (event: MouseEvent) => { + setLeftWidth((width) => Math.max(180, Math.min(360, width + event.movementX))); + }; + const onUp = () => { + setDragging(false); + document.body.style.userSelect = ''; }; - loadSchema(); - - // 初始化度量字段详情 - if (builder?.measures?.toJSON) { - const measures = builder.measures.toJSON(); - const detail: Record< - string, - { - alias?: string; - aggregate?: { func: string; quantile?: number }; - encoding?: EncodingChannel; - } - > = {}; - - if (Array.isArray(measures)) { - measures.forEach((value: any) => { - const field = value.field; - const alias = value.alias || ''; - const aggregate = value.aggregate; - // 使用 field 作为 key,而不是 alias - detail[field] = { - alias, - aggregate: aggregate - ? { func: aggregate.func, quantile: aggregate.quantile } - : undefined, - encoding: value.encoding, - }; - }); - } + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); - setMeasuresDetail(detail); - } - }, []); + return () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + }, [dragging]); - // Compute encoding information from DSL as the single source of truth - const encodingInfo = useMemo(() => { - if (!measureFields.length) { - return []; - } + const availableDimensions = useMemo(() => { + return schemaFields + .filter((field) => field.role === 'dimension') + .map((field) => field.name); + }, [schemaFields]); + + const availableMeasures = useMemo(() => { + return schemaFields + .filter((field) => field.role === 'measure') + .map((field) => field.name); + }, [schemaFields]); + + const dimensionFields = useMemo(() => { + return dimensions.map((item) => item.field); + }, [dimensions]); + + const measureFields = useMemo(() => { + return measures.map((item) => item.field); + }, [measures]); + + const measureDetails = useMemo(() => { + return Object.fromEntries( + measures.map((item) => [ + item.field, + { + alias: item.alias, + aggregate: item.aggregate, + encoding: item.encoding, + }, + ]), + ); + }, [measures]); - const map: Record = {}; + const dimensionMeasureFields = useMemo(() => { + return measures + .filter((item) => fieldRoleMap[item.field] === 'dimension') + .map((item) => item.field); + }, [fieldRoleMap, measures]); - for (const field of measureFields) { - const detail = measuresDetail[field]; - const encoding = detail?.encoding ?? 'yAxis'; - const displayName = detail?.alias || field; + const encodingInfo = useMemo(() => { + const grouped = new Map(); - if (!map[encoding]) { - map[encoding] = []; + measures.forEach((measureItem) => { + const encoding = measureItem.encoding; + if (!encoding) { + return; } - map[encoding].push(displayName); - } - - return Object.entries(map).map(([encoding, measures]) => ({ - encoding, - measures, - })); - }, [measureFields, measuresDetail]); - - // Compute supported encodings for current chart type - const supportedEncodings = useMemo(() => { - return []; - }, [currentChartType]); - - // 加载 demo 数据 - const handleLoadDemo = async () => { - try { - const url = 'https://visactor.github.io/VBI/dataset/supermarket.csv'; - const response = await fetch(url); - const csv = await response.text(); - - const [headerRow = [], ...dataRows] = parseCsv(csv); - const headers = headerRow.map((header: string) => header.trim()); - const schemaByName = new Map( - supermarketSchema.map((field) => [field.name, field.type]), - ); - const data = dataRows - .map((values: string[]) => { - const row: Record = {}; - headers.forEach((header: string, index: number) => { - const rawValue = values[index]?.trim() ?? ''; - const schemaType = schemaByName.get(header); - - if (schemaType === 'number') { - row[header] = rawValue === '' ? null : Number(rawValue); - return; - } - - row[header] = rawValue; - }); - return row; - }) - .filter((row: Record) => - Object.values(row).some((value) => value !== '' && value !== null), - ); - - // 设置本地数据 - setLocalDataWithSchema(data, supermarketSchema); + const list = grouped.get(encoding) ?? []; + list.push(measureItem.alias || measureItem.field); + grouped.set(encoding, list); + }); - if (data.length > 0) { - const dims = headers.filter((header: string) => - supermarketDimensions.includes(header), - ); - const meas = headers.filter((header: string) => - supermarketMeasures.includes(header), - ); - setDimensions(dims); - setMeasures(meas); - setDimensionFields([]); - setMeasureFields([]); - } + return Array.from(grouped.entries()).map( + ([encoding, assignedMeasures]) => ({ + encoding, + measures: assignedMeasures, + }), + ); + }, [measures]); - console.log('Demo 数据已加载'); - } catch (err) { - console.error('加载 Demo 数据失败:', err); + const activeFields = useMemo(() => { + return Array.from(new Set([...dimensionFields, ...measureFields])); + }, [dimensionFields, measureFields]); + + const allFields = useMemo(() => { + return schemaFields.map(({ name, role }) => ({ name, role })); + }, [schemaFields]); + + const filterItems = useMemo(() => { + return flatFilters.map((item) => ({ + field: item.field, + operator: item.op, + value: item.value, + })); + }, [flatFilters]); + + const supportedEncodings = + (builder?.chartType.getSupportedMeasureEncodings() ?? []) as MeasureEncoding[]; + const supportedDimensionEncodings = + (builder?.chartType.getSupportedDimensionEncodings() ?? []) as DimensionEncoding[]; + + const havingFilterItems = useMemo(() => { + return havingFilters + .filter(isVBIHavingFilter) + .map((item) => ({ + field: item.field, + aggregate: item.aggregate, + operator: item.op, + value: item.value, + })); + }, [havingFilters]); + + const antdLocale = ANT_LOCALES[locale]; + const themeConfig = useMemo(() => createThemeConfig(themeMode), [themeMode]); + + const addFieldAsDimension = (field: string) => { + if (dimensionFields.includes(field)) { + return; } - }; - // 上传 CSV - const handleUploadCSV = () => { - alert( - 'Function not yet implemented. Currently only demo data is supported.', - ); + addDimension(field, (node) => { + node.setAlias(field); + }); }; - const dataMenuItems = [ - { key: 'demo', label: 'Demo Data' }, - { key: 'csv', label: 'Upload CSV' }, - ]; - - const handleDataMenuClick = ({ key }: { key: string }) => { - if (key === 'demo') { - handleLoadDemo(); - } else if (key === 'csv') { - handleUploadCSV(); - } + const findMeasureByField = (field: string) => { + return measures.find((item) => item.field === field); }; - // 维度字段变化 - const handleAddDimension = (field: string) => { - if (!dimensionFields.includes(field)) { - const newDims = [...dimensionFields, field]; - setDimensionFields(newDims); - if (builderRef.current?.dimensions && builderRef.current.doc) { - const { dimensions, doc } = builderRef.current; - doc.transact(() => { - dimensions.add(field, (node: unknown) => { - const nodeObj = node as Record void>; - if (nodeObj?.setAlias) { - nodeObj.setAlias(field); - } - }); - }); - } - setRenderKey((prev) => prev + 1); + const addFieldAsMeasure = (field: string, encoding?: MeasureEncoding) => { + if (findMeasureByField(field)) { + return; } - }; - const handleRemoveDimension = (field: string) => { - const newDims = dimensionFields.filter((d) => d !== field); - setDimensionFields(newDims); - if (builderRef.current?.dimensions && builderRef.current.doc) { - const { dimensions, doc } = builderRef.current; - const dimensionId = getDimensionIdByField(field); - - if (!dimensionId) { - return; + addMeasure(field, (node) => { + node.setAlias(field); + if (fieldRoleMap[field] === 'dimension') { + node.setAggregate({ func: 'count' }); } - - doc.transact(() => { - dimensions.remove(dimensionId); - }); - } - setRenderKey((prev) => prev + 1); + if (encoding) { + node.setEncoding(encoding); + } + }); }; - // 同步度量字段详情 - const syncMeasuresDetail = () => { - if (builderRef.current?.measures) { - const measures = builderRef.current.measures.toJSON(); - const detail: Record< - string, - { - alias?: string; - aggregate?: { func: string; quantile?: number }; - encoding?: EncodingChannel; - } - > = {}; - - if (Array.isArray(measures)) { - measures.forEach((value: any) => { - const field = value.field; - const alias = value.alias || ''; - const aggregate = value.aggregate; - // 使用 field 作为 key,而不是 alias - detail[field] = { - alias, - aggregate: aggregate - ? { func: aggregate.func, quantile: aggregate.quantile } - : undefined, - encoding: value.encoding, - }; - }); - } + const handleRenameDimension = (id: string, alias: string) => { + updateDimension(id, (node) => { + node.setAlias(alias); + }); + }; - setMeasuresDetail(detail); - } + const handleChangeDimensionEncoding = ( + id: string, + encoding: DimensionEncoding, + ) => { + updateDimension(id, (node) => { + node.setEncoding(encoding); + }); }; - // 度量字段变化 - const handleAddMeasure = (field: string) => { - if (!measureFields.includes(field)) { - const newMeas = [...measureFields, field]; - setMeasureFields(newMeas); - if (builderRef.current?.measures && builderRef.current.doc) { - const { measures, doc } = builderRef.current; - doc.transact(() => { - measures.add(field, (node: unknown) => { - const nodeObj = node as Record void>; - if (nodeObj?.setAlias) { - nodeObj.setAlias(field); - } - }); - }); + const handleChangeDimensionAggregate = ( + id: string, + aggregate?: DimensionAggregate, + ) => { + updateDimension(id, (node) => { + if (aggregate) { + node.setAggregate(aggregate); + } else { + node.clearAggregate(); } - setRenderKey((prev) => prev + 1); - } + }); }; const handleRemoveMeasure = (field: string) => { - const newMeas = measureFields.filter((m) => m !== field); - setMeasureFields(newMeas); - setDimensionMeasures((prev) => prev.filter((measure) => measure !== field)); - if (builderRef.current?.measures && builderRef.current.doc) { - const { measures, doc } = builderRef.current; - const measureId = getMeasureIdByField(field); - - if (!measureId) { - return; - } - - doc.transact(() => { - measures.remove(measureId); - }); + const target = findMeasureByField(field); + if (target) { + removeMeasure(target.id); } - setRenderKey((prev) => prev + 1); }; - const handleRenameMeasure = (field: string, newAlias: string) => { - if (builderRef.current?.measures && builderRef.current.doc) { - const { measures, doc } = builderRef.current; - const measureId = getMeasureIdByField(field); - - if (!measureId) { - return; - } - - doc.transact(() => { - measures.update( - measureId, - (node: { setAlias: (alias: string) => void }) => { - node.setAlias(newAlias); - }, - ); - }); - // measureFields 存的是 field,不需要改 - syncMeasuresDetail(); - setRenderKey((prev) => prev + 1); + const handleRenameMeasure = (field: string, alias: string) => { + const target = findMeasureByField(field); + if (!target) { + return; } + + updateMeasure(target.id, (node) => { + node.setAlias(alias); + }); }; const handleChangeAggregateFunc = ( field: string, - func: string, + func: MeasureAggregate['func'], quantile?: number, ) => { - if (builderRef.current?.measures && builderRef.current.doc) { - const { measures, doc } = builderRef.current; - const measureId = getMeasureIdByField(field); - - if (!measureId) { - return; - } - - doc.transact(() => { - measures.update( - measureId, - (node: { - setAggregate: (aggregate: { - func: string; - quantile?: number; - }) => void; - }) => { - node.setAggregate( - quantile === undefined ? { func } : { func, quantile }, - ); - }, - ); - }); - syncMeasuresDetail(); - setRenderKey((prev) => prev + 1); - } - }; - - const handleAddMeasureFromDimension = (field: string) => { - if (!builderRef.current?.measures || !builderRef.current.doc) { + const target = findMeasureByField(field); + if (!target) { return; } - const { measures, doc } = builderRef.current; - const hasMeasure = measureFields.includes(field); - - if (!hasMeasure) { - doc.transact(() => { - measures.add(field, (node: unknown) => { - const nodeObj = node as any; - if (nodeObj?.setAlias) { - nodeObj.setAlias(field); - } - if (nodeObj?.setAggregate) { - nodeObj.setAggregate({ func: 'count' }); - } - }); - }); - setMeasureFields((prev) => [...prev, field]); - setDimensionMeasures((prev) => [...prev, field]); - syncMeasuresDetail(); - setRenderKey((prev) => prev + 1); - } + updateMeasure(target.id, (node) => { + const aggregate: MeasureAggregate = + func === 'quantile' ? { func, quantile } : { func }; + node.setAggregate(aggregate); + }); }; const handleDropMeasureToEncoding = ( field: string, - encoding: EncodingChannel, + encoding: MeasureEncoding, ) => { - if (!builderRef.current?.measures || !builderRef.current.doc) { + const target = findMeasureByField(field); + if (!target) { + addFieldAsMeasure(field, encoding); return; } - const { measures, doc } = builderRef.current; - const hasMeasure = measureFields.includes(field); - - return; - - doc.transact(() => { - if (hasMeasure) { - measures.modifyEncoding(field, encoding); - } else { - measures.add(field); - measures.modifyEncoding(field, encoding); - } + updateMeasure(target.id, (node) => { + node.setEncoding(encoding); }); - - if (!hasMeasure) { - setMeasureFields((prev) => [...prev, field]); - } - - syncMeasuresDetail(); - setRenderKey((prev) => prev + 1); }; const handleDropDimensionToEncoding = ( field: string, - encoding: EncodingChannel, + encoding: MeasureEncoding, ) => { - if (!builderRef.current?.measures || !builderRef.current.doc) { + const target = findMeasureByField(field); + if (!target) { + addMeasure(field, (node) => { + node.setAlias(field); + node.setAggregate({ func: 'count' }); + node.setEncoding(encoding); + }); return; } - const { measures, doc } = builderRef.current; - const hasMeasure = measureFields.includes(field); - - return; - - doc.transact(() => { - if (hasMeasure) { - // Already added as measure, just modify encoding - measures.modifyEncoding(field, encoding); - } else { - // Add dimension as measure with count aggregate - measures.add(field, (node: unknown) => { - const nodeObj = node as any; - if (nodeObj?.setAlias) { - nodeObj.setAlias(field); - } - if (nodeObj?.setAggregate) { - nodeObj.setAggregate({ func: 'count' }); - } - }); - measures.modifyEncoding(field, encoding); - } + updateMeasure(target.id, (node) => { + node.setEncoding(encoding); + node.setAggregate({ func: 'count' }); }); - - if (!hasMeasure) { - setMeasureFields((prev) => [...prev, field]); - setDimensionMeasures((prev) => [...prev, field]); - } - - syncMeasuresDetail(); - setRenderKey((prev) => prev + 1); }; - // 图表类型变化 - const handleChangeChartType = (type: string) => { - setCurrentChartType(type); - if (builderRef.current?.chartType) { - builderRef.current.chartType.changeChartType(type); - } - setRenderKey((prev) => prev + 1); + const handleFilterChange = (nextFilters: FilterItem[]) => { + replaceFilters(nextFilters); }; - // 数据筛选变化 + const handleHavingChange = ( + nextFilters: Array<{ + field: string; + aggregate: VBIHavingAggregate; + operator: string; + value: unknown; + }>, + ) => { + replaceHavingFilters(nextFilters); + }; - useEffect(() => { - if (!dragging) return; + const handleLoadDemo = async () => { + try { + const response = await fetch( + 'https://visactor.github.io/VBI/dataset/supermarket.csv', + ); + if (!response.ok) { + throw new Error(`Failed to fetch demo data: ${response.status}`); + } - const onMove = (e: MouseEvent) => { - setLeftWidth((w) => Math.max(140, Math.min(400, w + e.movementX))); - }; + const csv = await response.text(); + const [headerRow = [], ...dataRows] = parseCsv(csv); + const headers = headerRow.map((item: string) => item.trim()); + const schemaByName = new Map( + supermarketSchema.map((field) => [field.name, field.type]), + ); - const onUp = () => { - setDragging(false); - document.body.style.userSelect = ''; - }; + const data = dataRows + .map((values: string[]) => { + const row: Record = {}; + headers.forEach((header: string, index: number) => { + const rawValue = values[index]?.trim() ?? ''; + row[header] = + schemaByName.get(header) === 'number' + ? rawValue === '' + ? null + : Number(rawValue) + : rawValue; + }); + return row; + }) + .filter((row) => Object.values(row).some((value) => value !== '' && value !== null)); - document.body.style.userSelect = 'none'; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + setLocalDataWithSchema(data, supermarketSchema); + clearBuilderState(builder); + setSchemaRefreshKey((value) => value + 1); + } catch (error) { + console.error('Failed to load demo data:', error); + } + }; - return () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - }, [dragging]); + const handleUploadCsv = () => { + window.alert('CSV upload is not connected yet.'); + }; if (!initialized) { - return null; + return ( + + + + ); } return ( - -
+ +
{!builderCollapsed && ( <>
-
- Data +
+
+ + { + if (typeof value === 'number') { + setLimit(normalizeLimit(value)); + } + }} + style={{ width: 96 }} + addonBefore={t('toolbarRows')} + /> +
+
+
+ +
+ + value={locale} + options={[ + { label: '中', value: 'zh-CN' }, + { label: 'EN', value: 'en-US' }, + ]} + onChange={(value) => setLocale(value)} + /> + + value={themeMode} + options={[ + { label: , value: 'light' }, + { label: , value: 'dark' }, + ]} + onChange={(value) => setTheme(value)} + /> +
+
+ +
+ {t('toolbarData')} { + if (key === 'demo') { + void handleLoadDemo(); + } else { + handleUploadCsv(); + } + }, }} placement="bottomRight" > -
- {(dimensions.length > 0 || measures.length > 0) && ( -
+ +
+
0 - ? allFields - : [ - ...dimensions.map((d) => ({ - name: d, - role: 'dimension' as const, - })), - ...measures.map((m) => ({ - name: m, - role: 'measure' as const, - })), - ] - } + fields={allFields} activeFields={activeFields} - filters={filters} + filters={filterItems} + rootOperator={whereRootOperator} + onRootOperatorChange={setWhereRootOperator} onChange={handleFilterChange} />
- )} - {dimensions.length > 0 && ( - <> -
- Dimensions -
- - - )} - {measures.length > 0 && ( - <> -
- Measures -
- + - - )} - {dimensions.length === 0 && measures.length === 0 && ( -
- -
点击右上角上传数据
- )} -
-
setDragging(true)} - >
-
-
- + {t('fieldsAvailableDimensions')} +
+ - + +
+ {t('fieldsAvailableMeasures')} +
+ addFieldAsMeasure(field)} + existingFields={measureFields} + /> + + {schemaFields.length === 0 && ( +
{t('dataHint')}
+ )}
-
-
- + +
setDragging(true)} /> + +
+
+
+ addFieldAsMeasure(field)} />
-
+
@@ -830,16 +640,23 @@ export function APP() {
)} +
{builderCollapsed && ( - + /> + )} + {vseed ? ( + + ) : ( + )} - {vseed && }
diff --git a/practices/professional/src/components/Fields/DimensionFieldList/index.tsx b/practices/professional/src/components/Fields/DimensionFieldList/index.tsx new file mode 100644 index 0000000000..e2d5a3078d --- /dev/null +++ b/practices/professional/src/components/Fields/DimensionFieldList/index.tsx @@ -0,0 +1,235 @@ +import React, { useMemo, useState } from 'react'; +import type { VBIDimension } from '@visactor/vbi'; +import { + DeleteOutlined, + EditOutlined, + NumberOutlined, +} from '@ant-design/icons'; +import { Form, Input, Modal, Select } from 'antd'; +import { useTranslation } from 'src/i18n'; +import '../FieldList.css'; + +type DimensionEncoding = NonNullable; +type DimensionAggregate = NonNullable; + +const DATE_AGGREGATE_OPTIONS: Array<{ + label: string; + value: DimensionAggregate['func']; +}> = [ + { label: 'Year', value: 'toYear' }, + { label: 'Quarter', value: 'toQuarter' }, + { label: 'Month', value: 'toMonth' }, + { label: 'Week', value: 'toWeek' }, + { label: 'Day', value: 'toDay' }, + { label: 'Hour', value: 'toHour' }, + { label: 'Minute', value: 'toMinute' }, + { label: 'Second', value: 'toSecond' }, +]; + +export interface DimensionFieldListProps { + items: VBIDimension[]; + fieldTypeMap: Record; + supportedEncodings: DimensionEncoding[]; + onRemove: (id: string) => void; + onRename: (id: string, alias: string) => void; + onChangeEncoding: (id: string, encoding: DimensionEncoding) => void; + onChangeAggregate: (id: string, aggregate?: DimensionAggregate) => void; + onDropDimension?: (field: string) => void; + style?: React.CSSProperties; +} + +const isDateField = (fieldType?: string) => { + return ['date', 'datetime', 'timestamp'].includes(fieldType ?? ''); +}; + +const formatAggregate = (aggregate?: DimensionAggregate) => { + if (!aggregate) { + return ''; + } + + return aggregate.func.replace('to', ''); +}; + +const DimensionFieldList: React.FC = ({ + items, + fieldTypeMap, + supportedEncodings, + onRemove, + onRename, + onChangeEncoding, + onChangeAggregate, + onDropDimension, + style, +}) => { + const { locale } = useTranslation(); + const isZh = locale === 'zh-CN'; + const [editingId, setEditingId] = useState(null); + const [editAlias, setEditAlias] = useState(''); + const [editEncoding, setEditEncoding] = useState('column'); + const [editAggregate, setEditAggregate] = useState< + DimensionAggregate['func'] | 'none' + >('none'); + const [hoveredDropZone, setHoveredDropZone] = useState(false); + + const editingDimension = useMemo(() => { + return items.find((item) => item.id === editingId); + }, [editingId, items]); + + const handleEdit = (item: VBIDimension) => { + setEditingId(item.id); + setEditAlias(item.alias || item.field); + setEditEncoding( + (item.encoding || supportedEncodings[0] || 'column') as DimensionEncoding, + ); + setEditAggregate(item.aggregate?.func || 'none'); + }; + + const handleSave = () => { + if (!editingDimension) { + return; + } + + onRename(editingDimension.id, editAlias); + onChangeEncoding(editingDimension.id, editEncoding); + onChangeAggregate( + editingDimension.id, + editAggregate === 'none' ? undefined : { func: editAggregate }, + ); + setEditingId(null); + }; + + return ( + <> +
{ + if (!onDropDimension) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setHoveredDropZone(true); + }} + onDragLeave={() => setHoveredDropZone(false)} + onDrop={(event) => { + if (!onDropDimension) { + return; + } + event.preventDefault(); + const field = + event.dataTransfer.getData('application/x-vbi-dimension-field') || + event.dataTransfer.getData('text/plain'); + if (field) { + onDropDimension(field); + } + setHoveredDropZone(false); + }} + > +
+ {isZh ? '维度' : 'DIMENSIONS'} +
+
+ {items.length === 0 && ( +
+ {isZh ? '还没有添加维度' : 'No dimensions added'} +
+ )} + {items.map((item) => { + const aggregateLabel = formatAggregate(item.aggregate); + const displayName = item.alias || item.field; + const suffix = aggregateLabel ? `${aggregateLabel} · ` : ''; + return ( +
+ + + {suffix} + {displayName} + +
+ + +
+
+ ); + })} +
+
+ + setEditingId(null)} + > +
+ + setEditAlias(event.target.value)} + placeholder={isZh ? '输入维度显示名' : 'Enter dimension alias'} + /> + + + setEditAggregate(value)} + options={[ + { + label: isZh ? '原始值' : 'Raw Value', + value: 'none', + }, + ...DATE_AGGREGATE_OPTIONS, + ]} + /> + + )} +
+
+ + ); +}; + +export default DimensionFieldList; diff --git a/practices/professional/src/components/Fields/DimensionsList/index.tsx b/practices/professional/src/components/Fields/DimensionsList/index.tsx deleted file mode 100644 index b75982cd90..0000000000 --- a/practices/professional/src/components/Fields/DimensionsList/index.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { List, Card } from 'antd'; -import { memo, useEffect, useState } from 'react'; -import { CalendarOutlined, FontSizeOutlined } from '@ant-design/icons'; -import { useVBIStore } from 'src/model'; - -export const DimensionsList = memo( - ({ - style, - onDropDimension, - }: { - style?: React.CSSProperties; - onDropDimension?: (field: string) => void; - }) => { - const builder = useVBIStore((state) => state.builder); - const [hoveredDropZone, setHoveredDropZone] = useState(false); - console.log('debug DimensionsList rerender'); - - const [schema, setSchema] = useState< - { - name: string; - type: string; - }[] - >([]); - - useEffect(() => { - const run = async () => { - const schema = await builder.getSchema(); - setSchema(schema); - }; - run(); - }, [builder]); - - const addDimension = (dimensionName: string) => () => { - builder.doc.transact(() => { - builder.dimensions.add(dimensionName, (node) => { - node.setAlias(dimensionName); - }); - }); - }; - - const dimensions = schema.filter((d) => d.type !== 'number'); - - const getIcon = (type: string) => { - if (type === 'date') { - return ; - } - return ; - }; - - return ( - { - if (!onDropDimension) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - setHoveredDropZone(true); - }} - onDragLeave={() => { - setHoveredDropZone(false); - }} - onDrop={(e) => { - if (!onDropDimension) return; - e.preventDefault(); - const field = - e.dataTransfer.getData('application/x-vbi-dimension-field') || - e.dataTransfer.getData('text/plain'); - if (field) { - onDropDimension(field); - } - setHoveredDropZone(false); - }} - > - ( - -
- (e.currentTarget.style.backgroundColor = - 'rgba(0, 0, 0, 0.04)') - } - onMouseLeave={(e) => - (e.currentTarget.style.backgroundColor = 'transparent') - } - > - - {getIcon(item.type)} - - - {item.name} - -
-
- )} - /> -
- ); - }, -); diff --git a/practices/professional/src/components/Fields/EncodingPanel.tsx b/practices/professional/src/components/Fields/EncodingPanel.tsx index f3e3e0483f..a5d1c3719f 100644 --- a/practices/professional/src/components/Fields/EncodingPanel.tsx +++ b/practices/professional/src/components/Fields/EncodingPanel.tsx @@ -1,31 +1,29 @@ import React, { useMemo, useState } from 'react'; +import type { VBIMeasure } from '@visactor/vbi'; import { Empty, Tag } from 'antd'; -type EncodingChannel = - | 'yAxis' - | 'xAxis' - | 'color' - | 'label' - | 'tooltip' - | 'size'; +type MeasureEncoding = NonNullable; export interface MeasureEncodingInfo { - encoding: string; + encoding: MeasureEncoding; measures: string[]; } export interface EncodingPanelProps { /** Array of supported encoding channels for this chart type */ - supportedEncodings?: EncodingChannel[]; + supportedEncodings?: MeasureEncoding[]; /** Array of {encoding, measures} pairs - currently configured encodings */ encodingInfo?: MeasureEncodingInfo[]; /** Handle dropping a measure field into an encoding channel */ - onDropMeasureToEncoding?: (field: string, encoding: EncodingChannel) => void; + onDropMeasureToEncoding?: (field: string, encoding: MeasureEncoding) => void; /** Handle dropping a dimension field into an encoding channel (as a measure) */ onDropDimensionToEncoding?: ( field: string, - encoding: EncodingChannel, + encoding: MeasureEncoding, ) => void; + title?: string; + emptyText?: string; + dropText?: string; style?: React.CSSProperties; } @@ -39,20 +37,26 @@ const EncodingPanel: React.FC = ({ encodingInfo = [], onDropMeasureToEncoding, onDropDimensionToEncoding, + title = 'Measure Encoding', + emptyText = 'No chart selected', + dropText = 'Drop measure here', style, }) => { - const [hoveredEncoding, setHoveredEncoding] = useState(null); + const [hoveredEncoding, setHoveredEncoding] = useState( + null, + ); const encodingState = useMemo(() => { // Create a map of configured encodings - const configuredMap: Record = {}; + const configuredMap: Partial> = {}; encodingInfo.forEach((item) => { configuredMap[item.encoding] = item.measures; }); // Create state for all supported encodings (configured or empty) - const state: Record = - {}; + const state: Partial< + Record + > = {}; supportedEncodings.forEach((encoding) => { state[encoding] = { configured: encoding in configuredMap, @@ -76,9 +80,9 @@ const EncodingPanel: React.FC = ({ fontWeight: 'bold', }} > - Measure Encoding + {title}
- +
); } @@ -93,7 +97,7 @@ const EncodingPanel: React.FC = ({ fontWeight: 'bold', }} > - Measure Encoding + {title}
= ({ padding: '8px 0', }} > - {Object.entries(encodingState).map( - ([encoding, { configured, measures }]) => ( + {( + Object.entries(encodingState) as Array< + [MeasureEncoding, { configured: boolean; measures: string[] }] + > + ).map(([encoding, { configured, measures }]) => (
{ @@ -144,12 +151,9 @@ const EncodingPanel: React.FC = ({ if (field) { if (isMeasure && onDropMeasureToEncoding) { - onDropMeasureToEncoding(field, encoding as EncodingChannel); + onDropMeasureToEncoding(field, encoding); } else if (!isMeasure && onDropDimensionToEncoding) { - onDropDimensionToEncoding( - field, - encoding as EncodingChannel, - ); + onDropDimensionToEncoding(field, encoding); } } setHoveredEncoding(null); @@ -197,7 +201,7 @@ const EncodingPanel: React.FC = ({ )) ) : ( - Drop measure here + {dropText} )}
diff --git a/practices/professional/src/components/Fields/FieldList.css b/practices/professional/src/components/Fields/FieldList.css index 77f2c88149..6ecf8b025d 100644 --- a/practices/professional/src/components/Fields/FieldList.css +++ b/practices/professional/src/components/Fields/FieldList.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; padding: 2px 8px; - color: #e0e0e0; + color: var(--app-text); font-size: 10px; width: 100%; box-sizing: border-box; @@ -14,7 +14,7 @@ font-size: 10px; letter-spacing: 0.6px; margin-bottom: 4px; - color: #b0b0b0; + color: var(--app-text-muted); } .fieldlist-items { @@ -36,13 +36,13 @@ } .fieldlist-item:hover { - background: rgba(255, 255, 255, 0.08); + background: var(--app-hover); } .fieldlist-item-text { flex: 1; font-size: 10px; - color: #e0e0e0; + color: var(--app-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -64,6 +64,6 @@ .fieldlist-empty { font-style: italic; - color: #999; + color: var(--app-text-muted); padding: 4px 0; } diff --git a/practices/professional/src/components/Fields/MeasureFieldList/index.tsx b/practices/professional/src/components/Fields/MeasureFieldList/index.tsx index 4953e78fc3..ea313b84ef 100644 --- a/practices/professional/src/components/Fields/MeasureFieldList/index.tsx +++ b/practices/professional/src/components/Fields/MeasureFieldList/index.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import type { VBIMeasure } from '@visactor/vbi'; import { DeleteOutlined, FontSizeOutlined, @@ -6,26 +7,36 @@ import { } from '@ant-design/icons'; import { Modal, Input, Select, Form } from 'antd'; import '../FieldList.css'; +import { useTranslation } from 'src/i18n'; export interface MeasureFieldListProps { items: string[]; measures?: Record< string, - { alias?: string; aggregate?: { func: string; quantile?: number } } + { + alias?: string; + aggregate?: NonNullable; + } >; dimensionMeasures?: string[]; onRemove: (field: string) => void; onRename?: (field: string, newAlias: string) => void; - onChangeAggregate?: (field: string, func: string, quantile?: number) => void; + onChangeAggregate?: ( + field: string, + func: NonNullable['func'], + quantile?: number, + ) => void; onDropDimension?: (field: string) => void; style?: React.CSSProperties; } +type MeasureAggregateFunc = NonNullable['func']; + // 所有 11 种聚合方式的选项 const ALL_AGGREGATE_OPTIONS = [ { label: 'Sum', value: 'sum' }, { label: 'Count', value: 'count' }, - { label: 'Count Distinct', value: 'count_distinct' }, + { label: 'Count Distinct', value: 'countDistinct' }, { label: 'Average', value: 'avg' }, { label: 'Min', value: 'min' }, { label: 'Max', value: 'max' }, @@ -39,7 +50,7 @@ const ALL_AGGREGATE_OPTIONS = [ // Dimension 字段只能用 count 聚合 const DIMENSION_AGGREGATE_OPTIONS = [ { label: 'Count', value: 'count' }, - { label: 'Count Distinct', value: 'count_distinct' }, + { label: 'Count Distinct', value: 'countDistinct' }, ]; const MeasureFieldList: React.FC = ({ @@ -52,9 +63,11 @@ const MeasureFieldList: React.FC = ({ onDropDimension, style, }) => { + const { locale } = useTranslation(); + const isZh = locale === 'zh-CN'; const [editingField, setEditingField] = useState(null); const [editAlias, setEditAlias] = useState(''); - const [editAggregate, setEditAggregate] = useState('sum'); + const [editAggregate, setEditAggregate] = useState('sum'); const [editQuantile, setEditQuantile] = useState(0.5); const [hoveredDropZone, setHoveredDropZone] = useState(false); @@ -63,17 +76,18 @@ const MeasureFieldList: React.FC = ({ setEditingField(field); setEditAlias(measure?.alias || field); - // 如果这个字段来自 dimension,聚合函数只能是 count 或 count_distinct + // 如果这个字段来自 dimension,聚合函数只能是 count 或 countDistinct const isDimensionMeasure = dimensionMeasures.includes(field); let defaultFunc = measure?.aggregate?.func || 'sum'; - if ( - isDimensionMeasure && - !['count', 'count_distinct'].includes(defaultFunc) - ) { + if (isDimensionMeasure && !['count', 'countDistinct'].includes(defaultFunc)) { defaultFunc = 'count'; } setEditAggregate(defaultFunc); - setEditQuantile(measure?.aggregate?.quantile || 0.5); + setEditQuantile( + measure?.aggregate?.func === 'quantile' + ? measure.aggregate.quantile || 0.5 + : 0.5, + ); }; const handleSave = () => { @@ -130,7 +144,7 @@ const MeasureFieldList: React.FC = ({ transition: 'background-color 0.2s', }} > - MEASURES + {isZh ? '指标' : 'MEASURES'}
= ({ }} > {items.length === 0 && ( -
No measures added
+
+ {isZh ? '还没有添加指标' : 'No measures added'} +
)} {items.map((field) => { const measure = measures[field]; @@ -164,7 +180,7 @@ const MeasureFieldList: React.FC = ({ e.stopPropagation(); handleEdit(field); }} - title="Edit" + title={isZh ? '编辑' : 'Edit'} > @@ -185,20 +201,20 @@ const MeasureFieldList: React.FC = ({
setEditingField(null)} >
- + setEditAlias(e.target.value)} - placeholder="Enter measure alias" + placeholder={isZh ? '输入指标显示名' : 'Enter measure alias'} /> - + { - const builder = useVBIStore((state) => state.builder); - console.log('debug DimensionsList rerender'); - - const [schema, setSchema] = useState< - { - name: string; - type: string; - }[] - >([]); - - useEffect(() => { - const run = async () => { - const schema = await builder.getSchema(); - setSchema(schema); - }; - run(); - }, [builder]); - - const addMeasure = (measureName: string) => () => { - builder.doc.transact(() => { - builder.measures.add(measureName, (node) => { - node.setAlias(measureName); - node.setAggregate({ - func: 'sum', - }); - }); - }); - }; - - const measures = schema.filter((d) => d.type === 'number'); - - return ( - - ( - -
- (e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.04)') - } - onMouseLeave={(e) => - (e.currentTarget.style.backgroundColor = 'transparent') - } - > - - - - - {item.name} - -
-
- )} - /> -
- ); -}; diff --git a/practices/professional/src/components/Filter/FilterPanel.tsx b/practices/professional/src/components/Filter/FilterPanel.tsx index 9e0503901a..9dcd3c0a5a 100644 --- a/practices/professional/src/components/Filter/FilterPanel.tsx +++ b/practices/professional/src/components/Filter/FilterPanel.tsx @@ -19,6 +19,7 @@ import { PlusOutlined, EditOutlined, } from '@ant-design/icons'; +import { useTranslation } from 'src/i18n'; const { Option } = Select; const { Text } = Typography; @@ -39,30 +40,36 @@ interface FilterPanelProps { fields: FilterField[]; // 可供筛选的字段列表 activeFields?: string[]; // 正在使用的字段 filters: FilterItem[]; + rootOperator?: 'and' | 'or'; + onRootOperatorChange?: (operator: 'and' | 'or') => void; onChange: (filters: FilterItem[]) => void; } -const DIMENSION_OPERATORS = [ - { label: '包含 (in)', value: 'in' }, - { label: '不包含 (not in)', value: 'not in' }, +const getDimensionOperators = (isZh: boolean) => [ + { label: isZh ? '包含 (in)' : 'Includes (in)', value: 'in' }, + { label: isZh ? '不包含 (not in)' : 'Excludes (not in)', value: 'not in' }, ]; -const MEASURE_OPERATORS = [ - { label: '等于 (=)', value: '=' }, - { label: '不等于 (!=)', value: '!=' }, - { label: '大于 (>)', value: '>' }, - { label: '大于等于 (>=)', value: '>=' }, - { label: '小于 (<)', value: '<' }, - { label: '小于等于 (<=)', value: '<=' }, - { label: '范围 (between)', value: 'between' }, +const getMeasureOperators = (isZh: boolean) => [ + { label: isZh ? '等于 (=)' : 'Equals (=)', value: '=' }, + { label: isZh ? '不等于 (!=)' : 'Not equal (!=)', value: '!=' }, + { label: isZh ? '大于 (>)' : 'Greater than (>)', value: '>' }, + { label: isZh ? '大于等于 (>=)' : 'Greater or equal (>=)', value: '>=' }, + { label: isZh ? '小于 (<)' : 'Less than (<)', value: '<' }, + { label: isZh ? '小于等于 (<=)' : 'Less or equal (<=)', value: '<=' }, + { label: isZh ? '范围 (between)' : 'Between', value: 'between' }, ]; export const FilterPanel: React.FC = ({ fields, activeFields = [], filters = [], + rootOperator = 'and', + onRootOperatorChange, onChange, }) => { + const { locale, t } = useTranslation(); + const isZh = locale === 'zh-CN'; const [isModalOpen, setIsModalOpen] = useState(false); const [editingIndex, setEditingIndex] = useState(null); const [form] = Form.useForm(); @@ -86,8 +93,10 @@ export const FilterPanel: React.FC = ({ }, [sortedFields, selectedRole]); const availableOperators = React.useMemo(() => { - return selectedRole === 'measure' ? MEASURE_OPERATORS : DIMENSION_OPERATORS; - }, [selectedRole]); + return selectedRole === 'measure' + ? getMeasureOperators(isZh) + : getDimensionOperators(isZh); + }, [isZh, selectedRole]); React.useEffect(() => { if (isModalOpen) { @@ -129,11 +138,12 @@ export const FilterPanel: React.FC = ({ const handleFilterError = (e: any) => { const lastFilter = e.detail; if (lastFilter) { + const operator = lastFilter.operator ?? lastFilter.op; // Find role based on field const fieldRole = fields.find((f) => f.name === lastFilter.field)?.role || 'dimension'; const value = - lastFilter.operator === 'between' + operator === 'between' ? lastFilter.value : Array.isArray(lastFilter.value) ? lastFilter.value.join(',') @@ -141,7 +151,7 @@ export const FilterPanel: React.FC = ({ form.setFieldsValue({ role: fieldRole, field: lastFilter.field, - operator: lastFilter.operator, + operator, value: value, }); } @@ -231,16 +241,30 @@ export const FilterPanel: React.FC = ({ title={ - 数据筛选器 + {t('filtersTitle')} } extra={ -
) : ( = ({ +