-
Notifications
You must be signed in to change notification settings - Fork 7
feat: vbi encoding fixes, measure builder improvements, and professional demo function extensions #333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: vbi encoding fixes, measure builder improvements, and professional demo function extensions #333
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,17 +23,23 @@ export class DimensionsBuilder { | |
| if (typeof fieldOrDimension === 'string') { | ||
| defaultDimension.alias = fieldOrDimension | ||
| defaultDimension.field = fieldOrDimension | ||
| // Don't set encoding - let VSeed auto-select based on chart type | ||
| } else { | ||
| defaultDimension.alias = fieldOrDimension.alias | ||
| defaultDimension.field = fieldOrDimension.field || fieldOrDimension.alias | ||
| // Only use encoding if explicitly provided | ||
| if (fieldOrDimension.encoding) { | ||
| defaultDimension.encoding = fieldOrDimension.encoding | ||
| } | ||
| } | ||
|
|
||
| const yMap = new Y.Map<any>() | ||
| for (const [key, value] of Object.entries(defaultDimension)) { | ||
| yMap.set(key, value) | ||
| } | ||
|
|
||
| this.dsl.get('dimensions').push([yMap]) | ||
| const dimensionsArray = this.dsl.get('dimensions') as Y.Array<any> | ||
| dimensionsArray.insert(dimensionsArray.length, [yMap]) | ||
| const dimensionNode = new DimensionNodeBuilder(yMap) | ||
|
|
||
| if (callback) { | ||
|
|
@@ -52,6 +58,17 @@ export class DimensionsBuilder { | |
| } | ||
| } | ||
|
|
||
| updateEncoding(field: string, encoding: string) { | ||
| const dimensions = this.dsl.get('dimensions') | ||
| const index = dimensions.toArray().findIndex((item: any) => item.get('field') === field) | ||
| if (index !== -1) { | ||
| const dimensionYMap = dimensions.get(index) | ||
| if (dimensionYMap) { | ||
| dimensionYMap.set('encoding', encoding) | ||
| } | ||
| } | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 建议实现一个 modifyDimension, 然后基于它, 实现modifyEncoding. 另外取名的话, update和modify需要选择一个统一的, 我建议modify, 因为我们的修改大多都是局部的. |
||
|
|
||
| getDimensions(): VBIDimension[] { | ||
| return this.dsl.get('dimensions').toJSON() | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import * as Y from 'yjs' | ||
| import type { ObserveCallback, VBIMeasure, VBIMeasureGroup, VBIMeasureTree } from 'src/types' | ||
| import { fieldExpr } from '../../../types' | ||
| import { MeasureNodeBuilder } from './measure-node-builder' | ||
|
|
||
| export class MeasuresBuilder { | ||
|
|
@@ -10,34 +11,51 @@ export class MeasuresBuilder { | |
| this.dsl = dsl | ||
| } | ||
|
|
||
| addMeasure(fieldOrMeasure: VBIMeasure['field'] | VBIMeasure): MeasureNodeBuilder | ||
| addMeasure(fieldOrMeasure: string | VBIMeasure): MeasureNodeBuilder | ||
| addMeasure( | ||
| fieldOrMeasure: VBIMeasure['field'] | VBIMeasure, | ||
| fieldOrMeasure: string | VBIMeasure, | ||
| callback: (measureNode: MeasureNodeBuilder) => void, | ||
| ): MeasuresBuilder | ||
| addMeasure( | ||
| fieldOrMeasure: VBIMeasure['field'] | VBIMeasure, | ||
| fieldOrMeasure: string | VBIMeasure, | ||
| callback?: (measureNode: MeasureNodeBuilder) => void, | ||
| ): MeasureNodeBuilder | MeasuresBuilder { | ||
| const defaultMeasure: VBIMeasure = {} as VBIMeasure | ||
| if (typeof fieldOrMeasure === 'string') { | ||
| defaultMeasure.alias = fieldOrMeasure | ||
| defaultMeasure.field = fieldOrMeasure | ||
| defaultMeasure.encoding = 'yAxis' | ||
| defaultMeasure.aggregate = { func: 'sum' } | ||
| defaultMeasure.expr = fieldExpr(fieldOrMeasure) | ||
| // Don't set encoding - let VSeed auto-select based on chart type | ||
| defaultMeasure.aggregate = { func: 'count' } | ||
| } else { | ||
| defaultMeasure.alias = fieldOrMeasure.alias | ||
| defaultMeasure.field = fieldOrMeasure.field | ||
| defaultMeasure.encoding = fieldOrMeasure.encoding | ||
| defaultMeasure.expr = fieldOrMeasure.expr | ||
| // Only use encoding if explicitly provided | ||
| if (fieldOrMeasure.encoding) { | ||
| defaultMeasure.encoding = fieldOrMeasure.encoding | ||
| } | ||
| defaultMeasure.aggregate = fieldOrMeasure.aggregate | ||
| } | ||
|
|
||
| const yMap = new Y.Map<any>() | ||
|
|
||
| for (const [key, value] of Object.entries(defaultMeasure)) { | ||
| yMap.set(key, value) | ||
| yMap.set('alias', defaultMeasure.alias) | ||
| yMap.set('encoding', defaultMeasure.encoding) | ||
|
|
||
| // 为expr创建一个Y.Map来正确存储嵌套对象 | ||
| const exprMap = new Y.Map<any>() | ||
| for (const [key, value] of Object.entries(defaultMeasure.expr as Record<string, unknown>)) { | ||
| exprMap.set(key, value) | ||
| } | ||
| yMap.set('expr', exprMap) | ||
|
|
||
| // 为aggregate创建一个Y.Map | ||
| const aggregateMap = new Y.Map<any>() | ||
| for (const [key, value] of Object.entries(defaultMeasure.aggregate as Record<string, unknown>)) { | ||
| aggregateMap.set(key, value) | ||
| } | ||
| this.dsl.get('measures').push([yMap]) | ||
| yMap.set('aggregate', aggregateMap) | ||
|
|
||
| const measuresArray = this.dsl.get('measures') as Y.Array<any> | ||
| measuresArray.insert(measuresArray.length, [yMap]) | ||
|
|
||
| const measureNode = new MeasureNodeBuilder(yMap) | ||
|
|
||
|
|
@@ -49,14 +67,64 @@ export class MeasuresBuilder { | |
| } | ||
| } | ||
|
|
||
| removeMeasure(field: VBIMeasure['field']) { | ||
| removeMeasure(measureAlias: string) { | ||
| const measures = this.dsl.get('measures') | ||
| const index = measures.toArray().findIndex((item: any) => item.get('field') === field) | ||
| // Use alias as unique identifier, not field | ||
| const index = measures.toArray().findIndex((item: any) => { | ||
| return item.get('alias') === measureAlias | ||
| }) | ||
| if (index !== -1) { | ||
| this.dsl.get('measures').delete(index, 1) | ||
| } | ||
| } | ||
|
|
||
| renameMeasure(measureAlias: string, newAlias: string) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同上, 先实现modifyMeasure, 然后再rename功能 |
||
| const measures = this.dsl.get('measures') | ||
| // Use alias as unique identifier, not field | ||
| const index = measures.toArray().findIndex((item: any) => { | ||
| return item.get('alias') === measureAlias | ||
| }) | ||
| if (index !== -1) { | ||
| const measureYMap = measures.get(index) | ||
| if (measureYMap) { | ||
| measureYMap.set('alias', newAlias) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| updateAggregate(measureAlias: string, func: string) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同上, 基于modifyMeasure, 来实现modifyAggregate |
||
| const measures = this.dsl.get('measures') | ||
| // Use alias as unique identifier, not field | ||
| const index = measures.toArray().findIndex((item: any) => { | ||
| return item.get('alias') === measureAlias | ||
| }) | ||
| if (index !== -1) { | ||
| const measureYMap = measures.get(index) | ||
| const aggregateYMap = measureYMap.get('aggregate') | ||
| if (aggregateYMap) { | ||
| aggregateYMap.set('func', func) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| updateEncoding(measureAlias: string, encoding: string) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同上 |
||
| const measures = this.dsl.get('measures') | ||
| if (measures.length === 0) { | ||
| return | ||
| } | ||
|
|
||
| const index = measures.toArray().findIndex((item: any) => { | ||
| // Measure's identity is alias, not expr.field | ||
| return item.get('alias') === measureAlias | ||
| }) | ||
| if (index !== -1) { | ||
| const measureYMap = measures.get(index) | ||
| if (measureYMap) { | ||
| measureYMap.set('encoding', encoding) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| getMeasures(): VBIMeasure[] { | ||
| return this.dsl.get('measures').toJSON() | ||
| } | ||
|
|
@@ -70,7 +138,7 @@ export class MeasuresBuilder { | |
| } | ||
|
|
||
| static isMeasureNode(node: VBIMeasureTree[0]): node is VBIMeasure { | ||
| return 'field' in node | ||
| return 'expr' in node | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这个似乎改错了? 我理解field没问题 |
||
| } | ||
|
|
||
| static isMeasureGroup(node: VBIMeasureTree[0]): node is VBIMeasureGroup { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ import * as Y from 'yjs' | |
| import { VSeedDSL } from '@visactor/vseed' | ||
| import { DimensionsBuilder } from './sub-builders/dimensions' | ||
| import { MeasuresBuilder } from './sub-builders/measures' | ||
| import { VBIDSL, VBIBuilderInterface } from 'src/types' | ||
| import { VBIDSL, VBIBuilderInterface, VBIMeasureTree, VBIMeasure, VBIDimensionTree, VBIDimension } from 'src/types' | ||
| import { buildVQuery } from 'src/pipeline' | ||
| import { ChartTypeBuilder } from './sub-builders/chart-type' | ||
| import { getConnector } from './connector' | ||
|
|
@@ -36,6 +36,45 @@ export class VBIBuilder implements VBIBuilderInterface { | |
| return Y.encodeStateAsUpdate(this.doc, targetStateVector) | ||
| } | ||
|
|
||
| // 辅助函数:将 VBIMeasureTree 展开为平面的 VBIMeasure 数组 | ||
| private flattenMeasureTree(tree?: VBIMeasureTree): VBIMeasure[] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 共建函数可以放在这里, packages/vbi/src/utils/tree/traverse.ts 这类函数和业务完全无关, 不应放在builder内, 可以单独抽出来作为工具函数, 用更通用的形式来实现. |
||
| if (!tree) return [] | ||
| const result: VBIMeasure[] = [] | ||
| const traverse = (node: VBIMeasureTree[number]) => { | ||
| if ('expr' in node) { | ||
| // 这是 VBIMeasure | ||
| result.push(node as VBIMeasure) | ||
| } else { | ||
| // 这是 VBIMeasureGroup,遍历 children | ||
| if ('children' in node && node.children) { | ||
| node.children.forEach((child) => traverse(child)) | ||
| } | ||
| } | ||
| } | ||
| tree.forEach((node) => traverse(node)) | ||
| return result | ||
| } | ||
|
|
||
| // 辅助函数:将 VBIDimensionTree 展开为平面的 VBIDimension 数组 | ||
| private flattenDimensionTree(tree?: VBIDimensionTree): VBIDimension[] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同上, 应该从builder中剥离 |
||
| if (!tree) return [] | ||
| const result: VBIDimension[] = [] | ||
| const traverse = (node: VBIDimensionTree[number]) => { | ||
| if ('field' in node && !('children' in node)) { | ||
| // 这是 VBIDimension | ||
| result.push(node as VBIDimension) | ||
| } else if ('children' in node) { | ||
| // 这是 VBIDimensionGroup,遍历 children | ||
| const group = node as any | ||
| if (group.children) { | ||
| group.children.forEach((child: any) => traverse(child)) | ||
| } | ||
| } | ||
| } | ||
| tree.forEach((node) => traverse(node)) | ||
| return result | ||
| } | ||
|
|
||
| public buildVSeed = async (): Promise<VSeedDSL> => { | ||
| const vbiDSL = this.build() | ||
| const connectorId = vbiDSL.connectorId | ||
|
|
@@ -45,9 +84,29 @@ export class VBIBuilder implements VBIBuilderInterface { | |
| const schema = await connector.discoverSchema() | ||
| const queryResult = await connector.query({ queryDSL, schema, connectorId }) | ||
|
|
||
| // 转换 VBI measures 为 VSeed measures 格式 | ||
| const flatMeasures = this.flattenMeasureTree(vbiDSL.measures) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. buildVSeed越来越大了, 我强烈建议抽出一个公共方法, builder内仅描述逻辑即可, |
||
| const vseedMeasures = flatMeasures.map((m) => ({ | ||
| id: m.alias || (m.expr?.type === 'field' ? m.expr.field : 'measure'), | ||
| alias: m.alias, | ||
| encoding: m.encoding, | ||
| })) | ||
|
|
||
| // 转换 VBI dimensions 为 VSeed dimensions 格式 | ||
| const flatDimensions = this.flattenDimensionTree(vbiDSL.dimensions) | ||
| const vseedDimensions = flatDimensions.map((d) => ({ | ||
| id: d.field, | ||
| alias: d.alias, | ||
| encoding: d.encoding, | ||
| })) | ||
|
|
||
| return { | ||
| chartType: vbiDSL.chartType, | ||
| dataset: queryResult.dataset, | ||
| measures: vseedMeasures, | ||
| dimensions: vseedDimensions, | ||
| theme: vbiDSL.theme, | ||
| locale: vbiDSL.locale, | ||
| } as VSeedDSL | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,17 +32,47 @@ const createVBI = () => { | |
| if (vbi.locale) dsl.set('locale', vbi.locale) | ||
| if (vbi.version) dsl.set('version', vbi.version) | ||
|
|
||
| if (!dsl.get('measures')) { | ||
| dsl.set('measures', new Y.Array<any>()) | ||
| } else { | ||
| dsl.set('measures', vbi.measures) | ||
| // Always create Y.Array for measures, converting plain arrays if needed | ||
| const measuresArray = new Y.Array<any>() | ||
| if (vbi.measures && Array.isArray(vbi.measures) && vbi.measures.length > 0) { | ||
| for (const m of vbi.measures) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 我当时写的逻辑, 是业务yjs内部就会自动设置, 可以省去很多递归, 难道不是这样的? |
||
| const yMap = new Y.Map<any>() | ||
| for (const [key, value] of Object.entries(m as Record<string, any>)) { | ||
| if (typeof value === 'object' && value !== null && !Array.isArray(value)) { | ||
| const nestedMap = new Y.Map<any>() | ||
| for (const [k, v] of Object.entries(value)) { | ||
| nestedMap.set(k, v) | ||
| } | ||
| yMap.set(key, nestedMap) | ||
| } else { | ||
| yMap.set(key, value) | ||
| } | ||
| } | ||
| measuresArray.insert(measuresArray.length, [yMap]) | ||
| } | ||
| } | ||
| dsl.set('measures', measuresArray) | ||
|
|
||
| if (!dsl.get('dimensions')) { | ||
| dsl.set('dimensions', new Y.Array<any>()) | ||
| } else { | ||
| dsl.set('dimensions', vbi.dimensions) | ||
| // Always create Y.Array for dimensions, converting plain arrays if needed | ||
| const dimensionsArray = new Y.Array<any>() | ||
| if (vbi.dimensions && Array.isArray(vbi.dimensions) && vbi.dimensions.length > 0) { | ||
| for (const d of vbi.dimensions) { | ||
| const yMap = new Y.Map<any>() | ||
| for (const [key, value] of Object.entries(d as Record<string, any>)) { | ||
| if (typeof value === 'object' && value !== null && !Array.isArray(value)) { | ||
| const nestedMap = new Y.Map<any>() | ||
| for (const [k, v] of Object.entries(value)) { | ||
| nestedMap.set(k, v) | ||
| } | ||
| yMap.set(key, nestedMap) | ||
| } else { | ||
| yMap.set(key, value) | ||
| } | ||
| } | ||
| dimensionsArray.insert(dimensionsArray.length, [yMap]) | ||
| } | ||
| } | ||
| dsl.set('dimensions', dimensionsArray) | ||
| }) | ||
|
|
||
| return new VBIBuilder(doc) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import type { Select, VQueryDSL } from '@visactor/vquery' | ||
| import { VBIDSL } from '../../types' | ||
| import { VBIDSL, exprField } from '../../types' | ||
| import { DimensionsBuilder, MeasuresBuilder, VBIBuilder } from 'src' | ||
| import { pipe } from 'remeda' | ||
|
|
||
|
|
@@ -21,11 +21,16 @@ const buildSelect: buildPipe = (queryDSL, context) => { | |
| const result = { ...queryDSL } | ||
| const measureNodes = measures.filter((measure) => MeasuresBuilder.isMeasureNode(measure)) | ||
| const measureSelects: Select<Record<string, unknown>> = measureNodes.map((measure) => { | ||
| return { | ||
| field: measure.field, | ||
| const field = exprField(measure.expr) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 不太理解这个expr的具体作用, 具体有啥用? 是要支持用户自定义表达式? |
||
| if (!field) { | ||
| throw new Error(`Cannot extract field from measure expr: ${JSON.stringify(measure.expr)}`) | ||
| } | ||
| const selectItem: any = { | ||
| field, | ||
| alias: measure.alias, | ||
| func: measure.aggregate.func, | ||
| } | ||
| return selectItem | ||
| }) | ||
|
|
||
| const dimensionNodes = dimensions.filter((dimension) => DimensionsBuilder.isDimensionNode(dimension)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod' | |
| export const zVBIDimensionSchema = z.object({ | ||
| field: z.string(), | ||
| alias: z.string(), | ||
| encoding: z.string().optional(), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. vseed有具体的encoding值, 不应只写一个string |
||
| }) | ||
|
|
||
| export const zVBIDimensionGroupSchema: z.ZodType<VBIDimensionGroup> = z.object({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| export type { VBIDSL } from './vbi/vbi' | ||
| export type { VBIDimensionTree, VBIDimensionGroup, VBIDimension } from './dimensions/dimensions' | ||
| export type { VBIMeasureTree, VBIMeasureGroup, VBIMeasure } from './measures/measures' | ||
| export type { MeasureExpr } from './measures/expr' | ||
| export { fieldExpr, exprField } from './measures/expr' | ||
| export type { VBIDSLTheme } from './theme/theme' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
为什么要拆成行? 之前的写法有啥问题吗?