@kanaries/graphic-walker 0.2.7 → 0.2.9

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,8 +1,8 @@
1
- import React, { useEffect, useState, useMemo } from 'react';
1
+ import React, { useEffect, useState, useMemo, forwardRef, useImperativeHandle, 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
- import { ScenegraphEvent } from 'vega';
5
+ import type { ScenegraphEvent, View } from 'vega';
6
6
  import { ISemanticType } from 'visual-insights';
7
7
  import styled from 'styled-components';
8
8
  import { autoMark } from '../utils/autoMark';
@@ -17,6 +17,12 @@ const CanvaContainer = styled.div<{rowSize: number; colSize: number;}>`
17
17
  `
18
18
 
19
19
  const SELECTION_NAME = 'geom';
20
+ export interface IReactVegaHandler {
21
+ getSVGData: () => Promise<string[]>;
22
+ getCanvasData: () => Promise<string[]>;
23
+ downloadSVG: (filename?: string) => Promise<string[]>;
24
+ downloadPNG: (filename?: string) => Promise<string[]>;
25
+ }
20
26
  interface ReactVegaProps {
21
27
  rows: Readonly<IViewField[]>;
22
28
  columns: Readonly<IViewField[]>;
@@ -36,6 +42,8 @@ interface ReactVegaProps {
36
42
  width: number;
37
43
  height: number;
38
44
  onGeomClick?: (values: any, e: any) => void
45
+ selectEncoding: SingleViewProps['selectEncoding'];
46
+ brushEncoding: SingleViewProps['brushEncoding'];
39
47
  }
40
48
  const NULL_FIELD: IViewField = {
41
49
  dragId: '',
@@ -59,6 +67,17 @@ const geomClick$ = selection$.pipe(
59
67
  function getFieldType(field: IViewField): 'quantitative' | 'nominal' | 'ordinal' | 'temporal' {
60
68
  return field.semanticType
61
69
  }
70
+
71
+ const BRUSH_SIGNAL_NAME = "__gw_brush__";
72
+ const POINT_SIGNAL_NAME = "__gw_point__";
73
+
74
+ interface ParamStoreEntry {
75
+ signal: typeof BRUSH_SIGNAL_NAME | typeof POINT_SIGNAL_NAME;
76
+ /** 这个标记用于防止循环 */
77
+ source: number;
78
+ data: any;
79
+ }
80
+
62
81
  interface SingleViewProps {
63
82
  x: IViewField;
64
83
  y: IViewField;
@@ -75,6 +94,10 @@ interface SingleViewProps {
75
94
  defaultAggregated: boolean;
76
95
  stack: IStackMode;
77
96
  geomType: string;
97
+ enableCrossFilter: boolean;
98
+ asCrossFilterTrigger: boolean;
99
+ selectEncoding: 'default' | 'none';
100
+ brushEncoding: 'x' | 'y' | 'default' | 'none';
78
101
  }
79
102
 
80
103
  function availableChannels (geomType: string): Set<string> {
@@ -163,7 +186,11 @@ function getSingleView(props: SingleViewProps) {
163
186
  yOffset,
164
187
  defaultAggregated,
165
188
  stack,
166
- geomType
189
+ geomType,
190
+ selectEncoding,
191
+ brushEncoding,
192
+ enableCrossFilter,
193
+ asCrossFilterTrigger,
167
194
  } = props
168
195
  const fields: IViewField[] = [x, y, color, opacity, size, shape, row, column, xOffset, yOffset, theta, radius]
169
196
  let markType = geomType;
@@ -179,17 +206,158 @@ function getSingleView(props: SingleViewProps) {
179
206
  channelAggregate(encoding, fields);
180
207
  }
181
208
  channelStack(encoding, stack);
182
- const spec = {
183
- mark: {
184
- type: markType,
185
- opacity: 0.96,
186
- tooltip: true
187
- },
188
- encoding
209
+ if (!enableCrossFilter || brushEncoding === 'none' && selectEncoding === 'none') {
210
+ return {
211
+ mark: {
212
+ type: markType,
213
+ opacity: 0.96,
214
+ tooltip: true
215
+ },
216
+ encoding
217
+ };
218
+ }
219
+ const mark = {
220
+ type: markType,
221
+ opacity: 0.96,
222
+ tooltip: true
223
+ };
224
+
225
+ // TODO:
226
+ // 鉴于 Vega 中使用 layer 会导致一些难以覆盖的预期外行为,
227
+ // 破坏掉引入交互后视图的正确性,
228
+ // 目前不使用 layer 来实现交互(注掉以下代码)。
229
+ // 考虑 layer 的目的是 layer + condition 可以用于同时展现“全集”(context)和“选中”两层结构,尤其对于聚合数据有分析帮助;
230
+ // 同时,不需要关心作为筛选器的是哪一张图。
231
+ // 现在采用临时方案,区别产生筛选的来源,并对其他图仅借助 transform 展现筛选后的数据。
232
+ // #[BEGIN bad-layer-interaction]
233
+ // const shouldUseMultipleLayers = brushEncoding !== 'none' && Object.values(encoding).some(channel => {
234
+ // return typeof channel.aggregate === 'string' && /* 这种 case 对应行数 */ typeof channel.field === 'string';
235
+ // });
236
+ // if (shouldUseMultipleLayers) {
237
+ // if (['column', 'row'].some(key => key in encoding && encoding[key].length > 0)) {
238
+ // // 这种情况 Vega 不能处理,是因为 Vega 不支持在 layer 中使用 column / row channel,
239
+ // // 会导致渲染出来的视图无法与不使用交互时的结果不变。
240
+ // return {
241
+ // params: [
242
+ // {
243
+ // name: BRUSH_SIGNAL_NAME,
244
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
245
+ // },
246
+ // ],
247
+ // mark,
248
+ // encoding: {
249
+ // ...encoding,
250
+ // color: {
251
+ // condition: {
252
+ // ...encoding.color,
253
+ // param: BRUSH_SIGNAL_NAME,
254
+ // },
255
+ // value: '#888',
256
+ // },
257
+ // },
258
+ // };
259
+ // }
260
+ // return {
261
+ // layer: [
262
+ // {
263
+ // params: [
264
+ // {
265
+ // name: BRUSH_SIGNAL_NAME,
266
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
267
+ // },
268
+ // ],
269
+ // mark,
270
+ // encoding: {
271
+ // ...encoding,
272
+ // color: 'color' in encoding ? {
273
+ // condition: {
274
+ // ...encoding.color,
275
+ // test: 'false',
276
+ // },
277
+ // value: '#888',
278
+ // } : {
279
+ // value: '#888',
280
+ // },
281
+ // },
282
+ // },
283
+ // {
284
+ // transform: [{ filter: { param: BRUSH_SIGNAL_NAME }}],
285
+ // mark,
286
+ // encoding: {
287
+ // ...encoding,
288
+ // color: encoding.color ?? { value: 'steelblue' },
289
+ // },
290
+ // },
291
+ // ],
292
+ // };
293
+ // } else if (brushEncoding !== 'none') {
294
+ // return {
295
+ // params: [
296
+ // {
297
+ // name: BRUSH_SIGNAL_NAME,
298
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
299
+ // },
300
+ // ],
301
+ // mark,
302
+ // encoding: {
303
+ // ...encoding,
304
+ // color: {
305
+ // condition: {
306
+ // ...encoding.color,
307
+ // param: BRUSH_SIGNAL_NAME,
308
+ // },
309
+ // value: '#888',
310
+ // },
311
+ // },
312
+ // };
313
+ // }
314
+ // #[END bad-layer-interaction]
315
+
316
+ if (brushEncoding !== 'none') {
317
+ return {
318
+ transform: asCrossFilterTrigger ? [] : [
319
+ { filter: { param: BRUSH_SIGNAL_NAME } }
320
+ ],
321
+ params: [
322
+ // {
323
+ // name: BRUSH_SIGNAL_DISPLAY_NAME,
324
+ // select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
325
+ // on: '__YOU_CANNOT_MODIFY_THIS_SIGNAL__',
326
+ // },
327
+ {
328
+ name: BRUSH_SIGNAL_NAME,
329
+ select: { type: 'interval', encodings: brushEncoding === 'default' ? undefined : [brushEncoding] },
330
+ },
331
+ ],
332
+ mark,
333
+ encoding,
334
+ };
335
+ }
336
+
337
+ return {
338
+ transform: asCrossFilterTrigger ? [] : [
339
+ { filter: { param: POINT_SIGNAL_NAME } }
340
+ ],
341
+ params: [
342
+ {
343
+ name: POINT_SIGNAL_NAME,
344
+ select: { type: 'point' },
345
+ },
346
+ ],
347
+ mark,
348
+ encoding: asCrossFilterTrigger ? {
349
+ ...encoding,
350
+ color: {
351
+ condition: {
352
+ ...encoding.color,
353
+ param: POINT_SIGNAL_NAME,
354
+ },
355
+ value: '#888',
356
+ },
357
+ } : encoding,
189
358
  };
190
- return spec;
191
359
  }
192
- const ReactVega: React.FC<ReactVegaProps> = props => {
360
+ const ReactVega = forwardRef<IReactVegaHandler, ReactVegaProps>(function ReactVega (props, ref) {
193
361
  const {
194
362
  dataSource = [],
195
363
  rows = [],
@@ -208,7 +376,9 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
208
376
  interactiveScale,
209
377
  layoutMode,
210
378
  width,
211
- height
379
+ height,
380
+ selectEncoding,
381
+ brushEncoding,
212
382
  } = props;
213
383
  // const container = useRef<HTMLDivElement>(null);
214
384
  // const containers = useRef<(HTMLDivElement | null)[]>([]);
@@ -222,7 +392,7 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
222
392
  return () => {
223
393
  clickSub.unsubscribe();
224
394
  }
225
- }, []);
395
+ }, [onGeomClick]);
226
396
  const rowDims = useMemo(() => rows.filter(f => f.analyticType === 'dimension'), [rows]);
227
397
  const colDims = useMemo(() => columns.filter(f => f.analyticType === 'dimension'), [columns]);
228
398
  const rowMeas = useMemo(() => rows.filter(f => f.analyticType === 'measure'), [rows]);
@@ -233,8 +403,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
233
403
  const colRepeatFields = useMemo(() => colMeas.length === 0 ? colDims.slice(-1) : colMeas, [rowDims, rowMeas]);//colMeas.slice(0, -1);
234
404
  const allFieldIds = useMemo(() => [...rows, ...columns, color, opacity, size].filter(f => Boolean(f)).map(f => (f as IViewField).fid), [rows, columns, color, opacity, size]);
235
405
 
406
+ const [crossFilterTriggerIdx, setCrossFilterTriggerIdx] = useState(-1);
236
407
 
237
408
  useEffect(() => {
409
+ setCrossFilterTriggerIdx(-1);
238
410
  setViewPlaceholders(views => {
239
411
  const viewNum = Math.max(1, rowRepeatFields.length * colRepeatFields.length)
240
412
  const nextViews = new Array(viewNum).fill(null).map((v, i) => views[i] || React.createRef())
@@ -242,7 +414,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
242
414
  })
243
415
  }, [rowRepeatFields, colRepeatFields])
244
416
 
417
+ const vegaRefs = useRef<View[]>([]);
418
+
245
419
  useEffect(() => {
420
+ vegaRefs.current = [];
246
421
 
247
422
  const yField = rows.length > 0 ? rows[rows.length - 1] : NULL_FIELD;
248
423
  const xField = columns.length > 0 ? columns[columns.length - 1] : NULL_FIELD;
@@ -295,16 +470,41 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
295
470
  yOffset: NULL_FIELD,
296
471
  defaultAggregated: defaultAggregate,
297
472
  stack,
298
- geomType
473
+ geomType,
474
+ selectEncoding,
475
+ brushEncoding,
476
+ enableCrossFilter: false,
477
+ asCrossFilterTrigger: false,
299
478
  });
300
479
  // if (layoutMode === 'fixed') {
301
480
  // spec.width = 800;
302
481
  // spec.height = 600;
303
482
  // }
304
483
  spec.mark = singleView.mark;
305
- spec.encoding = singleView.encoding;
484
+ if ('encoding' in singleView) {
485
+ spec.encoding = singleView.encoding;
486
+ }
487
+
488
+ // #[BEGIN bad-layer-interaction]
489
+ // if ('layer' in singleView) {
490
+ // if ('params' in spec) {
491
+ // const basicParams = spec['params'];
492
+ // delete spec['params'];
493
+ // singleView.layer![0].params = [...basicParams, ...singleView.layer![0].params ?? []];
494
+ // }
495
+ // spec.layer = singleView.layer;
496
+ // } else if ('params' in singleView) {
497
+ // spec.params.push(...singleView.params!);
498
+ // }
499
+ // #[END bad-layer-interaction]
500
+
501
+ if ('params' in singleView) {
502
+ spec.params.push(...singleView.params!);
503
+ }
504
+ // console.log(JSON.stringify(spec, undefined, 2));
306
505
  if (viewPlaceholders.length > 0 && viewPlaceholders[0].current) {
307
506
  embed(viewPlaceholders[0].current, spec, { mode: 'vega-lite', actions: showActions }).then(res => {
507
+ vegaRefs.current = [res.view];
308
508
  try {
309
509
  res.view.addEventListener('click', (e) => {
310
510
  click$.next(e);
@@ -323,8 +523,22 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
323
523
  spec.height = Math.floor(height / rowRepeatFields.length) - 5;
324
524
  spec.autosize = 'fit'
325
525
  }
526
+ const combinedParamStore$ = new Subject<ParamStoreEntry>();
527
+ const throttledParamStore$ = combinedParamStore$.pipe(
528
+ op.throttleTime(
529
+ dataSource.length / 64 * rowRepeatFields.length * colRepeatFields.length,
530
+ undefined,
531
+ { leading: false, trailing: true }
532
+ )
533
+ );
534
+ const subscriptions: Subscription[] = [];
535
+ const subscribe = (cb: (entry: ParamStoreEntry) => void) => {
536
+ subscriptions.push(throttledParamStore$.subscribe(cb));
537
+ };
538
+ let index = 0;
326
539
  for (let i = 0; i < rowRepeatFields.length; i++) {
327
- for (let j = 0; j < colRepeatFields.length; j++) {
540
+ for (let j = 0; j < colRepeatFields.length; j++, index++) {
541
+ const sourceId = index;
328
542
  const singleView = getSingleView({
329
543
  x: colRepeatFields[j] || NULL_FIELD,
330
544
  y: rowRepeatFields[i] || NULL_FIELD,
@@ -340,13 +554,88 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
340
554
  yOffset: NULL_FIELD,
341
555
  defaultAggregated: defaultAggregate,
342
556
  stack,
343
- geomType
557
+ geomType,
558
+ selectEncoding,
559
+ brushEncoding,
560
+ enableCrossFilter: crossFilterTriggerIdx !== -1,
561
+ asCrossFilterTrigger: crossFilterTriggerIdx === sourceId,
344
562
  });
345
563
  const node = i * colRepeatFields.length + j < viewPlaceholders.length ? viewPlaceholders[i * colRepeatFields.length + j].current : null
346
- const ans = { ...spec, ...singleView }
564
+ let commonSpec = { ...spec };
565
+
566
+ // #[BEGIN bad-layer-interaction]
567
+ // if ('layer' in singleView) {
568
+ // if ('params' in commonSpec) {
569
+ // const { params: basicParams, ...spec } = commonSpec;
570
+ // commonSpec = spec;
571
+ // singleView.layer![0].params = [...basicParams, ...singleView.layer![0].params ?? []];
572
+ // }
573
+ // commonSpec.layer = singleView.layer;
574
+ // } else if ('params' in singleView) {
575
+ // commonSpec.params = [...commonSpec.params, ...singleView.params!];
576
+ // }
577
+ // #[END bad-layer-interaction]
578
+
579
+ if ('params' in singleView) {
580
+ commonSpec.params = [...commonSpec.params, ...singleView.params!];
581
+ }
582
+ const ans = { ...commonSpec, ...singleView }
583
+ if ('params' in commonSpec) {
584
+ ans.params = commonSpec.params;
585
+ }
586
+ // console.log(JSON.stringify(ans, undefined, 2));
347
587
  if (node) {
348
588
  embed(node, ans, { mode: 'vega-lite', actions: showActions }).then(res => {
589
+ vegaRefs.current.push(res.view);
590
+ // 这种 case 下,我们来考虑联动的 params
591
+ // vega 使用 Data 来维护 params 的状态,只需要打通这些状态就可以实现联动
592
+ const paramStores = (res.vgSpec.data?.map(d => d.name) ?? []).filter(
593
+ name => [BRUSH_SIGNAL_NAME, POINT_SIGNAL_NAME].map(p => `${p}_store`).includes(name)
594
+ ).map(name => name.replace(/_store$/, ''));
595
+ try {
596
+ for (const param of paramStores) {
597
+ let noBroadcasting = false;
598
+ // 发出
599
+ res.view.addSignalListener(param, name => {
600
+ if (noBroadcasting) {
601
+ noBroadcasting = false;
602
+ return;
603
+ }
604
+ if ([BRUSH_SIGNAL_NAME, POINT_SIGNAL_NAME].includes(name)) {
605
+ const data = res.view.getState().data?.[`${name}_store`];
606
+ if (!data || (Array.isArray(data) && data.length === 0)) {
607
+ setCrossFilterTriggerIdx(-1);
608
+ }
609
+ combinedParamStore$.next({
610
+ signal: name as typeof BRUSH_SIGNAL_NAME | typeof POINT_SIGNAL_NAME,
611
+ source: sourceId,
612
+ data: data ?? null,
613
+ });
614
+ }
615
+ });
616
+ // 订阅
617
+ subscribe(entry => {
618
+ if (entry.source === sourceId || !entry.data) {
619
+ return;
620
+ }
621
+ // 防止被动更新触发广播
622
+ noBroadcasting = true;
623
+ res.view.setState({
624
+ data: {
625
+ [`${entry.signal}_store`]: entry.data,
626
+ },
627
+ });
628
+ });
629
+ }
630
+ } catch (error) {
631
+ console.warn('Crossing filter failed', error);
632
+ }
349
633
  try {
634
+ res.view.addEventListener('mouseover', () => {
635
+ if (sourceId !== crossFilterTriggerIdx) {
636
+ setCrossFilterTriggerIdx(sourceId);
637
+ }
638
+ });
350
639
  res.view.addEventListener('click', (e) => {
351
640
  click$.next(e);
352
641
  })
@@ -360,8 +649,10 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
360
649
  }
361
650
  }
362
651
  }
652
+ return () => {
653
+ subscriptions.forEach(sub => sub.unsubscribe());
654
+ };
363
655
  }
364
-
365
656
  }, [
366
657
  dataSource,
367
658
  allFieldIds,
@@ -384,15 +675,58 @@ const ReactVega: React.FC<ReactVegaProps> = props => {
384
675
  interactiveScale,
385
676
  layoutMode,
386
677
  width,
387
- height
678
+ height,
679
+ selectEncoding,
680
+ brushEncoding,
681
+ crossFilterTriggerIdx,
388
682
  ]);
389
683
 
684
+ useImperativeHandle(ref, () => ({
685
+ getSVGData() {
686
+ return Promise.all(vegaRefs.current.map(view => view.toSVG()));
687
+ },
688
+ async getCanvasData() {
689
+ const canvases = await Promise.all(vegaRefs.current.map(view => view.toCanvas()));
690
+ return canvases.map(canvas => canvas.toDataURL('image/png'));
691
+ },
692
+ async downloadSVG(filename = `gw chart ${Date.now() % 1_000_000}`.padStart(6, '0')) {
693
+ const data = await Promise.all(vegaRefs.current.map(view => view.toSVG()));
694
+ const files: string[] = [];
695
+ for (let i = 0; i < data.length; i += 1) {
696
+ const d = data[i];
697
+ const file = new File([d], `${filename}${data.length > 1 ? `_${i + 1}` : ''}.svg`);
698
+ const url = URL.createObjectURL(file);
699
+ const a = document.createElement('a');
700
+ a.download = file.name;
701
+ a.href = url;
702
+ a.click();
703
+ requestAnimationFrame(() => {
704
+ URL.revokeObjectURL(url);
705
+ });
706
+ }
707
+ return files;
708
+ },
709
+ async downloadPNG(filename = `gw chart ${Date.now() % 1_000_000}`.padStart(6, '0')) {
710
+ const canvases = await Promise.all(vegaRefs.current.map(view => view.toCanvas(2)));
711
+ const data = canvases.map(canvas => canvas.toDataURL('image/png', 1));
712
+ const files: string[] = [];
713
+ for (let i = 0; i < data.length; i += 1) {
714
+ const d = data[i];
715
+ const a = document.createElement('a');
716
+ a.download = `${filename}${data.length > 1 ? `_${i + 1}` : ''}.png`;
717
+ a.href = d.replace(/^data:image\/[^;]/, 'data:application/octet-stream');
718
+ a.click();
719
+ }
720
+ return files;
721
+ },
722
+ }));
723
+
390
724
  return <CanvaContainer rowSize={Math.max(rowRepeatFields.length, 1)} colSize={Math.max(colRepeatFields.length, 1)}>
391
725
  {/* <div ref={container}></div> */}
392
726
  {
393
727
  viewPlaceholders.map((view, i) => <div key={i} ref={view}></div>)
394
728
  }
395
729
  </CanvaContainer>
396
- }
730
+ });
397
731
 
398
732
  export default ReactVega;
@@ -1,4 +1,4 @@
1
- import { BarsArrowDownIcon, BarsArrowUpIcon } from '@heroicons/react/24/outline';
1
+ import { BarsArrowDownIcon, BarsArrowUpIcon, ChevronDownIcon, PhotoIcon } from '@heroicons/react/24/outline';
2
2
  import { observer } from 'mobx-react-lite';
3
3
  import React from 'react';
4
4
  import styled from 'styled-components'
@@ -6,9 +6,10 @@ 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
+ import { IReactVegaHandler } from '../vis/react-vega';
12
13
 
13
14
 
14
15
  export const LiteContainer = styled.div`
@@ -16,9 +17,27 @@ export const LiteContainer = styled.div`
16
17
  border: 1px solid #d9d9d9;
17
18
  padding: 1em;
18
19
  background-color: #fff;
20
+ .menu-root {
21
+ position: relative;
22
+ & > *:not(.trigger) {
23
+ display: flex;
24
+ flex-direction: column;
25
+ position: absolute;
26
+ right: 0;
27
+ top: 100%;
28
+ border: 1px solid #8884;
29
+ }
30
+ &:not(:hover) > *:not(.trigger):not(:hover) {
31
+ display: none;
32
+ }
33
+ }
19
34
  `;
20
35
 
21
- const VisualSettings: React.FC = () => {
36
+ interface IVisualSettings {
37
+ rendererHandler?: React.RefObject<IReactVegaHandler>;
38
+ }
39
+
40
+ const VisualSettings: React.FC<IVisualSettings> = ({ rendererHandler }) => {
22
41
  const { vizStore } = useGlobalStore();
23
42
  const { visualConfig, sortCondition } = vizStore;
24
43
  const { t: tGlobal } = useTranslation();
@@ -226,6 +245,68 @@ const VisualSettings: React.FC = () => {
226
245
  {t('size')}
227
246
  </label>
228
247
  </div>
248
+ <div className="item">
249
+ <label
250
+ id="dropdown:exploration_mode:label"
251
+ htmlFor="dropdown:exploration_mode"
252
+ >
253
+ {tGlobal(`constant.exploration_mode.__enum__`)}
254
+ </label>
255
+ <select
256
+ className="border border-gray-500 rounded-sm text-xs pt-0.5 pb-0.5 pl-2 pr-2 cursor-pointer"
257
+ id="dropdown:exploration_mode"
258
+ aria-describedby="dropdown:exploration_mode:label"
259
+ value={visualConfig.exploration.mode}
260
+ onChange={e => {
261
+ vizStore.setExploration({
262
+ mode: e.target.value as (typeof EXPLORATION_TYPES)[number]
263
+ });
264
+ }}
265
+ >
266
+ {EXPLORATION_TYPES.map(g => (
267
+ <option
268
+ key={g}
269
+ value={g}
270
+ className="cursor-pointer"
271
+ aria-selected={visualConfig.exploration.mode === g}
272
+ >
273
+ {tGlobal(`constant.exploration_mode.${g}`)}
274
+ </option>
275
+ ))}
276
+ </select>
277
+ </div>
278
+ <div className="item" style={{ opacity: visualConfig.exploration.mode !== 'brush' ? 0.3 : undefined }}>
279
+ <label
280
+ id="dropdown:brush_mode:label"
281
+ htmlFor="dropdown:brush_mode"
282
+ >
283
+ {tGlobal(`constant.brush_mode.__enum__`)}
284
+ </label>
285
+ <select
286
+ className="border border-gray-500 rounded-sm text-xs pt-0.5 pb-0.5 pl-2 pr-2 cursor-pointer"
287
+ id="dropdown:brush_mode"
288
+ aria-describedby="dropdown:brush_mode:label"
289
+ disabled={visualConfig.exploration.mode !== 'brush'}
290
+ aria-disabled={visualConfig.exploration.mode !== 'brush'}
291
+ value={visualConfig.exploration.brushDirection}
292
+ onChange={e => {
293
+ vizStore.setExploration({
294
+ brushDirection: e.target.value as IBrushDirection
295
+ });
296
+ }}
297
+ >
298
+ {BRUSH_DIRECTIONS.map(g => (
299
+ <option
300
+ key={g}
301
+ value={g}
302
+ className="cursor-pointer"
303
+ aria-selected={visualConfig.exploration.brushDirection === g}
304
+ >
305
+ {tGlobal(`constant.brush_mode.${g}`)}
306
+ </option>
307
+ ))}
308
+ </select>
309
+ </div>
229
310
  <div className="item">
230
311
  <input
231
312
  type="checkbox"
@@ -245,6 +326,48 @@ const VisualSettings: React.FC = () => {
245
326
  {t('toggle.debug')}
246
327
  </label>
247
328
  </div>
329
+ <div className='item'>
330
+ <label
331
+ className="text-xs text-color-gray-700 mr-2"
332
+ htmlFor="button:transpose"
333
+ id="button:transpose:label"
334
+ >
335
+ {t('button.export_chart')}
336
+ </label>
337
+ <PhotoIcon
338
+ className="w-4 inline-block cursor-pointer"
339
+ role="button"
340
+ tabIndex={0}
341
+ id="button:export_chart"
342
+ aria-describedby="button:export_chart:label"
343
+ xlinkTitle={t('button.export_chart')}
344
+ aria-label={t('button.export_chart')}
345
+ onClick={() => rendererHandler?.current?.downloadPNG()}
346
+ />
347
+ <div className="menu-root flex flex-col items-center justify-center">
348
+ <ChevronDownIcon
349
+ className="w-4 h-3 inline-block mr-1 cursor-pointer trigger"
350
+ role="button"
351
+ tabIndex={0}
352
+ />
353
+ <div>
354
+ <button
355
+ className="text-xs min-w-96 w-full pt-1 pb-1 pl-6 pr-6 bg-white hover:bg-gray-200"
356
+ aria-label={t('button.export_chart_as', { type: 'png' })}
357
+ onClick={() => rendererHandler?.current?.downloadPNG()}
358
+ >
359
+ {t('button.export_chart_as', { type: 'png' })}
360
+ </button>
361
+ <button
362
+ className="text-xs min-w-96 w-full pt-1 pb-1 pl-6 pr-6 bg-white hover:bg-gray-200"
363
+ aria-label={t('button.export_chart_as', { type: 'svg' })}
364
+ onClick={() => rendererHandler?.current?.downloadSVG()}
365
+ >
366
+ {t('button.export_chart_as', { type: 'svg' })}
367
+ </button>
368
+ </div>
369
+ </div>
370
+ </div>
248
371
  </LiteForm>
249
372
  </LiteContainer>
250
373
  }