@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.
- package/dist/assets/explainer.worker.90990e9a.js.map +1 -0
- package/dist/fields/filterField/tabs.d.ts +1 -1
- package/dist/graphic-walker.es.js +18046 -17762
- package/dist/graphic-walker.es.js.map +1 -1
- package/dist/graphic-walker.umd.js +140 -116
- package/dist/graphic-walker.umd.js.map +1 -1
- package/dist/insightBoard/std2vegaSpec.d.ts +1 -1
- package/dist/interfaces.d.ts +14 -5
- package/dist/renderer/index.d.ts +2 -1
- package/dist/store/visualSpecStore.d.ts +2 -1
- package/dist/style.css +1 -1
- package/dist/utils/save.d.ts +3 -1
- package/dist/vis/react-vega.d.ts +30 -1
- package/dist/visualSettings/index.d.ts +5 -1
- package/package.json +2 -1
- package/src/App.tsx +10 -7
- package/src/components/modal.tsx +7 -2
- package/src/fields/datasetFields/index.tsx +10 -3
- package/src/fields/fieldsContext.tsx +5 -0
- package/src/interfaces.ts +19 -1
- package/src/locales/en-US.json +23 -2
- package/src/locales/zh-CN.json +23 -2
- package/src/renderer/index.tsx +19 -12
- package/src/store/visualSpecStore.ts +28 -3
- package/src/utils/save.ts +4 -1
- package/src/vis/react-vega.tsx +357 -23
- package/src/visualSettings/index.tsx +127 -4
- package/dist/assets/explainer.worker.0cf1948d.js.map +0 -1
package/src/vis/react-vega.tsx
CHANGED
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|