@kanaries/graphic-walker 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- import React, { useEffect, useState, useMemo } from 'react';
1
+ import React, { useEffect, useState, useMemo, useRef } from 'react';
2
2
  import embed from 'vega-embed';
3
- import { Subject } from 'rxjs'
3
+ import { Subject, Subscription } from 'rxjs'
4
4
  import * as op from 'rxjs/operators';
5
5
  import { ScenegraphEvent } from 'vega';
6
6
  import { ISemanticType } from 'visual-insights';
@@ -36,6 +36,8 @@ interface ReactVegaProps {
36
36
  width: number;
37
37
  height: number;
38
38
  onGeomClick?: (values: any, e: any) => void
39
+ selectEncoding: SingleViewProps['selectEncoding'];
40
+ brushEncoding: SingleViewProps['brushEncoding'];
39
41
  }
40
42
  const NULL_FIELD: IViewField = {
41
43
  dragId: '',
@@ -59,6 +61,17 @@ const geomClick$ = selection$.pipe(
59
61
  function getFieldType(field: IViewField): 'quantitative' | 'nominal' | 'ordinal' | 'temporal' {
60
62
  return field.semanticType
61
63
  }
64
+
65
+ const BRUSH_SIGNAL_NAME = "__gw_brush__";
66
+ const POINT_SIGNAL_NAME = "__gw_point__";
67
+
68
+ interface ParamStoreEntry {
69
+ signal: typeof BRUSH_SIGNAL_NAME | typeof POINT_SIGNAL_NAME;
70
+ /** 这个标记用于防止循环 */
71
+ source: number;
72
+ data: any;
73
+ }
74
+
62
75
  interface SingleViewProps {
63
76
  x: IViewField;
64
77
  y: IViewField;
@@ -75,6 +88,10 @@ interface SingleViewProps {
75
88
  defaultAggregated: boolean;
76
89
  stack: IStackMode;
77
90
  geomType: string;
91
+ enableCrossFilter: boolean;
92
+ asCrossFilterTrigger: boolean;
93
+ selectEncoding: 'default' | 'none';
94
+ brushEncoding: 'x' | 'y' | 'default' | 'none';
78
95
  }
79
96
 
80
97
  function availableChannels (geomType: string): Set<string> {
@@ -163,7 +180,11 @@ function getSingleView(props: SingleViewProps) {
163
180
  yOffset,
164
181
  defaultAggregated,
165
182
  stack,
166
- geomType
183
+ geomType,
184
+ selectEncoding,
185
+ brushEncoding,
186
+ enableCrossFilter,
187
+ asCrossFilterTrigger,
167
188
  } = props
168
189
  const fields: IViewField[] = [x, y, color, opacity, size, shape, row, column, xOffset, yOffset, theta, radius]
169
190
  let markType = geomType;
@@ -179,15 +200,156 @@ function getSingleView(props: SingleViewProps) {
179
200
  channelAggregate(encoding, fields);
180
201
  }
181
202
  channelStack(encoding, stack);
182
- const spec = {
183
- mark: {
184
- type: markType,
185
- opacity: 0.96,
186
- tooltip: true
187
- },
188
- encoding
203
+ if (!enableCrossFilter || brushEncoding === 'none' && selectEncoding === 'none') {
204
+ return {
205
+ mark: {
206
+ type: markType,
207
+ opacity: 0.96,
208
+ tooltip: true
209
+ },
210
+ encoding
211
+ };
212
+ }
213
+ const mark = {
214
+ type: markType,
215
+ opacity: 0.96,
216
+ tooltip: true
217
+ };
218
+
219
+ // TODO:
220
+ // 鉴于 Vega 中使用 layer 会导致一些难以覆盖的预期外行为,
221
+ // 破坏掉引入交互后视图的正确性,
222
+ // 目前不使用 layer 来实现交互(注掉以下代码)。
223
+ // 考虑 layer 的目的是 layer + condition 可以用于同时展现“全集”(context)和“选中”两层结构,尤其对于聚合数据有分析帮助;
224
+ // 同时,不需要关心作为筛选器的是哪一张图。
225
+ // 现在采用临时方案,区别产生筛选的来源,并对其他图仅借助 transform 展现筛选后的数据。
226
+ // #[BEGIN bad-layer-interaction]
227
+ // const shouldUseMultipleLayers = brushEncoding !== 'none' && Object.values(encoding).some(channel => {
228
+ // return typeof channel.aggregate === 'string' && /* 这种 case 对应行数 */ typeof channel.field === 'string';
229
+ // });
230
+ // if (shouldUseMultipleLayers) {
231
+ // if (['column', 'row'].some(key => key in encoding && encoding[key].length > 0)) {
232
+ // // 这种情况 Vega 不能处理,是因为 Vega 不支持在 layer 中使用 column / row channel,
233
+ // // 会导致渲染出来的视图无法与不使用交互时的结果不变。
234
+ // return {
235
+ // params: [
236
+ // {
237
+ // name: BRUSH_SIGNAL_NAME,
238
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
239
+ // },
240
+ // ],
241
+ // mark,
242
+ // encoding: {
243
+ // ...encoding,
244
+ // color: {
245
+ // condition: {
246
+ // ...encoding.color,
247
+ // param: BRUSH_SIGNAL_NAME,
248
+ // },
249
+ // value: '#888',
250
+ // },
251
+ // },
252
+ // };
253
+ // }
254
+ // return {
255
+ // layer: [
256
+ // {
257
+ // params: [
258
+ // {
259
+ // name: BRUSH_SIGNAL_NAME,
260
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
261
+ // },
262
+ // ],
263
+ // mark,
264
+ // encoding: {
265
+ // ...encoding,
266
+ // color: 'color' in encoding ? {
267
+ // condition: {
268
+ // ...encoding.color,
269
+ // test: 'false',
270
+ // },
271
+ // value: '#888',
272
+ // } : {
273
+ // value: '#888',
274
+ // },
275
+ // },
276
+ // },
277
+ // {
278
+ // transform: [{ filter: { param: BRUSH_SIGNAL_NAME }}],
279
+ // mark,
280
+ // encoding: {
281
+ // ...encoding,
282
+ // color: encoding.color ?? { value: 'steelblue' },
283
+ // },
284
+ // },
285
+ // ],
286
+ // };
287
+ // } else if (brushEncoding !== 'none') {
288
+ // return {
289
+ // params: [
290
+ // {
291
+ // name: BRUSH_SIGNAL_NAME,
292
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
293
+ // },
294
+ // ],
295
+ // mark,
296
+ // encoding: {
297
+ // ...encoding,
298
+ // color: {
299
+ // condition: {
300
+ // ...encoding.color,
301
+ // param: BRUSH_SIGNAL_NAME,
302
+ // },
303
+ // value: '#888',
304
+ // },
305
+ // },
306
+ // };
307
+ // }
308
+ // #[END bad-layer-interaction]
309
+
310
+ if (brushEncoding !== 'none') {
311
+ return {
312
+ transform: asCrossFilterTrigger ? [] : [
313
+ { filter: { param: BRUSH_SIGNAL_NAME } }
314
+ ],
315
+ params: [
316
+ // {
317
+ // name: BRUSH_SIGNAL_DISPLAY_NAME,
318
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
319
+ // on: '__YOU_CANNOT_MODIFY_THIS_SIGNAL__',
320
+ // },
321
+ {
322
+ name: BRUSH_SIGNAL_NAME,
323
+ select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
324
+ },
325
+ ],
326
+ mark,
327
+ encoding,
328
+ };
329
+ }
330
+
331
+ return {
332
+ transform: asCrossFilterTrigger ? [] : [
333
+ { filter: { param: POINT_SIGNAL_NAME } }
334
+ ],
335
+ params: [
336
+ {
337
+ name: POINT_SIGNAL_NAME,
338
+ select: { type: 'point' },
339
+ },
340
+ ],
341
+ mark,
342
+ encoding: asCrossFilterTrigger ? {
343
+ ...encoding,
344
+ color: {
345
+ condition: {
346
+ ...encoding.color,
347
+ param: POINT_SIGNAL_NAME,
348
+ },
349
+ value: '#888',
350
+ },
351
+ } : encoding,
189
352
  };
190
- return spec;
191
353
  }
192
354
  const ReactVega: React.FC<ReactVegaProps> = props => {
193
355
  const {
@@ -208,7 +370,9 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
208
370
  interactiveScale,
209
371
  layoutMode,
210
372
  width,
211
- height
373
+ height,
374
+ selectEncoding,
375
+ brushEncoding,
212
376
  } = props;
213
377
  // const container = useRef<HTMLDivElement>(null);
214
378
  // const containers = useRef<(HTMLDivElement | null)[]>([]);
@@ -222,7 +386,7 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
222
386
  return () => {
223
387
  clickSub.unsubscribe();
224
388
  }
225
- }, []);
389
+ }, [onGeomClick]);
226
390
  const rowDims = useMemo(() => rows.filter(f => f.analyticType === 'dimension'), [rows]);
227
391
  const colDims = useMemo(() => columns.filter(f => f.analyticType === 'dimension'), [columns]);
228
392
  const rowMeas = useMemo(() => rows.filter(f => f.analyticType === 'measure'), [rows]);
@@ -233,8 +397,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
233
397
  const colRepeatFields = useMemo(() => colMeas.length === 0 ? colDims.slice(-1) : colMeas, [rowDims, rowMeas]);//colMeas.slice(0, -1);
234
398
  const allFieldIds = useMemo(() => [...rows, ...columns, color, opacity, size].filter(f => Boolean(f)).map(f => (f as IViewField).fid), [rows, columns, color, opacity, size]);
235
399
 
400
+ const [crossFilterTriggerIdx, setCrossFilterTriggerIdx] = useState(-1);
236
401
 
237
402
  useEffect(() => {
403
+ setCrossFilterTriggerIdx(-1);
238
404
  setViewPlaceholders(views => {
239
405
  const viewNum = Math.max(1, rowRepeatFields.length * colRepeatFields.length)
240
406
  const nextViews = new Array(viewNum).fill(null).map((v, i) => views[i] || React.createRef())
@@ -295,14 +461,38 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
295
461
  yOffset: NULL_FIELD,
296
462
  defaultAggregated: defaultAggregate,
297
463
  stack,
298
- geomType
464
+ geomType,
465
+ selectEncoding,
466
+ brushEncoding,
467
+ enableCrossFilter: false,
468
+ asCrossFilterTrigger: false,
299
469
  });
300
470
  // if (layoutMode === 'fixed') {
301
471
  // spec.width = 800;
302
472
  // spec.height = 600;
303
473
  // }
304
474
  spec.mark = singleView.mark;
305
- spec.encoding = singleView.encoding;
475
+ if ('encoding' in singleView) {
476
+ spec.encoding = singleView.encoding;
477
+ }
478
+
479
+ // #[BEGIN bad-layer-interaction]
480
+ // if ('layer' in singleView) {
481
+ // if ('params' in spec) {
482
+ // const basicParams = spec['params'];
483
+ // delete spec['params'];
484
+ // singleView.layer![0].params = [...basicParams, ...singleView.layer![0].params ?? []];
485
+ // }
486
+ // spec.layer = singleView.layer;
487
+ // } else if ('params' in singleView) {
488
+ // spec.params.push(...singleView.params!);
489
+ // }
490
+ // #[END bad-layer-interaction]
491
+
492
+ if ('params' in singleView) {
493
+ spec.params.push(...singleView.params!);
494
+ }
495
+ // console.log(JSON.stringify(spec, undefined, 2));
306
496
  if (viewPlaceholders.length > 0 && viewPlaceholders[0].current) {
307
497
  embed(viewPlaceholders[0].current, spec, { mode: 'vega-lite', actions: showActions }).then(res => {
308
498
  try {
@@ -323,8 +513,22 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
323
513
  spec.height = Math.floor(height / rowRepeatFields.length) - 5;
324
514
  spec.autosize = 'fit'
325
515
  }
516
+ const combinedParamStore$ = new Subject<ParamStoreEntry>();
517
+ const throttledParamStore$ = combinedParamStore$.pipe(
518
+ op.throttleTime(
519
+ dataSource.length / 64 * rowRepeatFields.length * colRepeatFields.length,
520
+ undefined,
521
+ { leading: false, trailing: true }
522
+ )
523
+ );
524
+ const subscriptions: Subscription[] = [];
525
+ const subscribe = (cb: (entry: ParamStoreEntry) => void) => {
526
+ subscriptions.push(throttledParamStore$.subscribe(cb));
527
+ };
528
+ let index = 0;
326
529
  for (let i = 0; i < rowRepeatFields.length; i++) {
327
- for (let j = 0; j < colRepeatFields.length; j++) {
530
+ for (let j = 0; j < colRepeatFields.length; j++, index++) {
531
+ const sourceId = index;
328
532
  const singleView = getSingleView({
329
533
  x: colRepeatFields[j] || NULL_FIELD,
330
534
  y: rowRepeatFields[i] || NULL_FIELD,
@@ -340,13 +544,87 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
340
544
  yOffset: NULL_FIELD,
341
545
  defaultAggregated: defaultAggregate,
342
546
  stack,
343
- geomType
547
+ geomType,
548
+ selectEncoding,
549
+ brushEncoding,
550
+ enableCrossFilter: crossFilterTriggerIdx !== -1,
551
+ asCrossFilterTrigger: crossFilterTriggerIdx === sourceId,
344
552
  });
345
553
  const node = i * colRepeatFields.length + j < viewPlaceholders.length ? viewPlaceholders[i * colRepeatFields.length + j].current : null
346
- const ans = { ...spec, ...singleView }
554
+ let commonSpec = { ...spec };
555
+
556
+ // #[BEGIN bad-layer-interaction]
557
+ // if ('layer' in singleView) {
558
+ // if ('params' in commonSpec) {
559
+ // const { params: basicParams, ...spec } = commonSpec;
560
+ // commonSpec = spec;
561
+ // singleView.layer![0].params = [...basicParams, ...singleView.layer![0].params ?? []];
562
+ // }
563
+ // commonSpec.layer = singleView.layer;
564
+ // } else if ('params' in singleView) {
565
+ // commonSpec.params = [...commonSpec.params, ...singleView.params!];
566
+ // }
567
+ // #[END bad-layer-interaction]
568
+
569
+ if ('params' in singleView) {
570
+ commonSpec.params = [...commonSpec.params, ...singleView.params!];
571
+ }
572
+ const ans = { ...commonSpec, ...singleView }
573
+ if ('params' in commonSpec) {
574
+ ans.params = commonSpec.params;
575
+ }
576
+ // console.log(JSON.stringify(ans, undefined, 2));
347
577
  if (node) {
348
578
  embed(node, ans, { mode: 'vega-lite', actions: showActions }).then(res => {
579
+ // 这种 case 下,我们来考虑联动的 params
580
+ // vega 使用 Data 来维护 params 的状态,只需要打通这些状态就可以实现联动
581
+ const paramStores = (res.vgSpec.data?.map(d => d.name) ?? []).filter(
582
+ name => [BRUSH_SIGNAL_NAME, POINT_SIGNAL_NAME].map(p => `${p}_store`).includes(name)
583
+ ).map(name => name.replace(/_store$/, ''));
349
584
  try {
585
+ for (const param of paramStores) {
586
+ let noBroadcasting = false;
587
+ // 发出
588
+ res.view.addSignalListener(param, name => {
589
+ if (noBroadcasting) {
590
+ noBroadcasting = false;
591
+ return;
592
+ }
593
+ if ([BRUSH_SIGNAL_NAME, POINT_SIGNAL_NAME].includes(name)) {
594
+ const data = res.view.getState().data?.[`${name}_store`];
595
+ if (!data || (Array.isArray(data) && data.length === 0)) {
596
+ setCrossFilterTriggerIdx(-1);
597
+ }
598
+ combinedParamStore$.next({
599
+ signal: name as typeof BRUSH_SIGNAL_NAME | typeof POINT_SIGNAL_NAME,
600
+ source: sourceId,
601
+ data: data ?? null,
602
+ });
603
+ }
604
+ });
605
+ // 订阅
606
+ subscribe(entry => {
607
+ if (entry.source === sourceId || !entry.data) {
608
+ return;
609
+ }
610
+ // 防止被动更新触发广播
611
+ noBroadcasting = true;
612
+ res.view.setState({
613
+ data: {
614
+ [`${entry.signal}_store`]: entry.data,
615
+ },
616
+ });
617
+ });
618
+ }
619
+ } catch (error) {
620
+ console.warn('Crossing filter failed', error);
621
+ }
622
+ try {
623
+ res.view.addEventListener('mouseover', () => {
624
+ if (sourceId !== crossFilterTriggerIdx) {
625
+ setCrossFilterTriggerIdx(sourceId);
626
+ }
627
+ });
350
628
  res.view.addEventListener('click', (e) => {
351
629
  click$.next(e);
352
630
  })
@@ -360,8 +638,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
360
638
  }
361
639
  }
362
640
  }
641
+ return () => {
642
+ subscriptions.forEach(sub => sub.unsubscribe());
643
+ };
363
644
  }
364
-
365
645
  }, [
366
646
  dataSource,
367
647
  allFieldIds,
@@ -384,7 +664,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
384
664
  interactiveScale,
385
665
  layoutMode,
386
666
  width,
387
- height
667
+ height,
668
+ selectEncoding,
669
+ brushEncoding,
670
+ crossFilterTriggerIdx,
388
671
  ]);
389
672
 
390
673
  return <CanvaContainer rowSize={Math.max(rowRepeatFields.length, 1)} colSize={Math.max(colRepeatFields.length, 1)}>
@@ -6,9 +6,9 @@ import { ArrowPathIcon } from '@heroicons/react/24/solid';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { LiteForm } from '../components/liteForm';
8
8
  import SizeSetting from '../components/sizeSetting';
9
- import { CHART_LAYOUT_TYPE, GEMO_TYPES, STACK_MODE } from '../config';
9
+ import { GEMO_TYPES, STACK_MODE, CHART_LAYOUT_TYPE } from '../config';
10
10
  import { useGlobalStore } from '../store';
11
- import { IStackMode } from '../interfaces';
11
+ import { IStackMode, EXPLORATION_TYPES, IBrushDirection, BRUSH_DIRECTIONS } from '../interfaces';
12
12
 
13
13
 
14
14
  export const LiteContainer = styled.div`
@@ -226,6 +226,68 @@ const VisualSettings: React.FC = () => {
226
226
  {t('size')}
227
227
  </label>
228
228
  </div>
229
+ <div className="item">
230
+ <label
231
+ id="dropdown:exploration_mode:label"
232
+ htmlFor="dropdown:exploration_mode"
233
+ >
234
+ {tGlobal(`constant.exploration_mode.__enum__`)}
235
+ </label>
236
+ <select
237
+ className="border border-gray-500 rounded-sm text-xs pt-0.5 pb-0.5 pl-2 pr-2 cursor-pointer"
238
+ id="dropdown:exploration_mode"
239
+ aria-describedby="dropdown:exploration_mode:label"
240
+ value={visualConfig.exploration.mode}
241
+ onChange={e => {
242
+ vizStore.setExploration({
243
+ mode: e.target.value as (typeof EXPLORATION_TYPES)[number]
244
+ });
245
+ }}
246
+ >
247
+ {EXPLORATION_TYPES.map(g => (
248
+ <option
249
+ key={g}
250
+ value={g}
251
+ className="cursor-pointer"
252
+ aria-selected={visualConfig.exploration.mode === g}
253
+ >
254
+ {tGlobal(`constant.exploration_mode.${g}`)}
255
+ </option>
256
+ ))}
257
+ </select>
258
+ </div>
259
+ <div className="item" style={{ opacity: visualConfig.exploration.mode !== 'brush' ? 0.3 : undefined }}>
260
+ <label
261
+ id="dropdown:brush_mode:label"
262
+ htmlFor="dropdown:brush_mode"
263
+ >
264
+ {tGlobal(`constant.brush_mode.__enum__`)}
265
+ </label>
266
+ <select
267
+ className="border border-gray-500 rounded-sm text-xs pt-0.5 pb-0.5 pl-2 pr-2 cursor-pointer"
268
+ id="dropdown:brush_mode"
269
+ aria-describedby="dropdown:brush_mode:label"
270
+ disabled={visualConfig.exploration.mode !== 'brush'}
271
+ aria-disabled={visualConfig.exploration.mode !== 'brush'}
272
+ value={visualConfig.exploration.brushDirection}
273
+ onChange={e => {
274
+ vizStore.setExploration({
275
+ brushDirection: e.target.value as IBrushDirection
276
+ });
277
+ }}
278
+ >
279
+ {BRUSH_DIRECTIONS.map(g => (
280
+ <option
281
+ key={g}
282
+ value={g}
283
+ className="cursor-pointer"
284
+ aria-selected={visualConfig.exploration.brushDirection === g}
285
+ >
286
+ {tGlobal(`constant.brush_mode.${g}`)}
287
+ </option>
288
+ ))}
289
+ </select>
290
+ </div>
229
291
  <div className="item">
230
292
  <input
231
293
  type="checkbox"