Skip to content

Commit

Permalink
Vegalite vis coordination test (#3923)
Browse files Browse the repository at this point in the history
Co-authored-by: Jaehwan Ryu <jryu@uncharted.software>
Co-authored-by: Yohann Paris <github@yohannparis.com>
  • Loading branch information
3 people authored Jun 24, 2024
1 parent 3f03ef0 commit a2be59a
Show file tree
Hide file tree
Showing 6 changed files with 959 additions and 19 deletions.
3 changes: 3 additions & 0 deletions packages/client/hmi-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
"pyodide": "0.25.1",
"sass": "1.56.1",
"uuid": "9.0.1",
"vega": "5.30.0",
"vega-embed": "6.25.0",
"vega-lite": "5.19.0",
"vue": "3.3.13",
"vue-feather": "2.0.0",
"vue-gtag": "2.0.1",
Expand Down
129 changes: 129 additions & 0 deletions packages/client/hmi-client/src/components/widgets/VegaChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<template>
<div class="vega-chart-container">
<p v-if="renderErrorMessage" class="p-error">
{{ renderErrorMessage }}
</p>
<div ref="vegaContainer"></div>
</div>
</template>

<script setup lang="ts">
import embed, { Result, VisualizationSpec } from 'vega-embed';
import { Config as VgConfig } from 'vega';
import { Config as VlConfig } from 'vega-lite';
import { ref, watch, toRaw, isRef, isReactive, isProxy } from 'vue';
export type Config = VgConfig | VlConfig;
const props = withDefaults(
defineProps<{
visualizationSpec: VisualizationSpec;
areEmbedActionsVisible?: boolean;
/**
* A list of signal names for Vega interval-type selections.
* This is equivalent to the name of the `param` in the Vega Lite spec.
* The chart will listen to signals for that selection, and emit the selected interval, or null
* if the selection is empty.
* See https://vega.github.io/vega/docs/api/view/#view_signal for more information.
*/
intervalSelectionSignalNames?: string[];
config?: Config | null;
}>(),
{
areEmbedActionsVisible: true,
intervalSelectionSignalNames: () => [],
config: null
}
);
const vegaContainer = ref<HTMLElement>();
const vegaVisualization = ref<Result>();
const renderErrorMessage = ref<String>();
const emit = defineEmits<{
(
e: 'update-interval-selection',
signalName: string,
intervalExtent: { [fieldName: string]: [number, number] } | null
): void;
(e: 'chart-click', datum: any | null): void;
}>();
/**
* deepToRaw() is a recursive 'deep' version of Vue's `toRaw` function, which converts
* Vue created Refs/Proxy objects back to their original form.
*
* In certain cases, such as when a nested object (like a Vega Spec) is wrapped in a
* Vue ref (including when it is returned using `defineModel` on a component like
* `encoding` is in the `ChartChannelEncodingConfigurator`), each nested object is
* wrapped in its own `Proxy`. Certain libraries don't deal well with `Proxy` objects
* and they need to be converted back to raw objects. Vue's `toRaw` function only
* handles the first level of Proxy objects. This function will recursively go through
* an object with nested Proxy objects and unwrap them all.
*
* This function is taken from https://github.com/vuejs/core/issues/5303
*/
function deepToRaw<T extends Record<string, any>>(sourceObj: T): T {
const objectIterator = (input: any): any => {
if (Array.isArray(input)) {
return input.map((item) => objectIterator(item));
}
if (isRef(input) || isReactive(input) || isProxy(input)) {
return objectIterator(toRaw(input));
}
if (input && typeof input === 'object') {
return Object.keys(input).reduce((acc, key) => {
acc[key as keyof typeof acc] = objectIterator(input[key]);
return acc;
}, {} as T);
}
return input;
};
return objectIterator(sourceObj);
}
async function updateVegaVisualization(
container: HTMLElement,
visualizationSpec: VisualizationSpec
) {
renderErrorMessage.value = undefined;
vegaVisualization.value = undefined;
try {
vegaVisualization.value = await embed(
container,
{ ...visualizationSpec },
{
config: props.config || {},
actions: props.areEmbedActionsVisible === false ? false : undefined
}
);
const { view } = vegaVisualization.value;
props.intervalSelectionSignalNames.forEach((signalName) => {
view.addSignalListener(
signalName,
(name, valueRange: { [fieldName: string]: [number, number] }) => {
if (valueRange === undefined || Object.keys(valueRange).length === 0) {
emit('update-interval-selection', name, null);
return;
}
emit('update-interval-selection', name, valueRange);
}
);
});
view.addEventListener('click', (_event, item) => {
emit('chart-click', item?.datum ?? null);
});
} catch (e) {
// renderErrorMessage.value = getErrorMessage(e);
// renderErrorMessage.value = e;
}
}
watch([vegaContainer, () => props.visualizationSpec], () => {
if (!vegaContainer.value) {
return;
}
const spec = deepToRaw(props.visualizationSpec);
updateVegaVisualization(vegaContainer.value, spec);
});
</script>
4 changes: 3 additions & 1 deletion packages/client/hmi-client/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import PyodideTest from '@/temp/PyodideTest.vue';
import JupyterTest from '@/temp/JupyterTest.vue';
import CustomInputTest from '@/temp/custom-input-test.vue';
import ClipboardTest from '@/temp/Clipboard.vue';
import VegaliteTest from '@/temp/Vegalite.vue';
import { RouteName } from './routes';

export enum RoutePath {
Expand Down Expand Up @@ -69,7 +70,8 @@ const routes = [
{ path: '/pyodide-test', component: PyodideTest },
{ path: '/jupyter-test', component: JupyterTest },
{ path: '/custom-input-test', component: CustomInputTest },
{ path: '/clipboard', component: ClipboardTest }
{ path: '/clipboard', component: ClipboardTest },
{ path: '/vegalite', component: VegaliteTest }
];

const router = createRouter({
Expand Down
160 changes: 160 additions & 0 deletions packages/client/hmi-client/src/temp/Vegalite.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<template>
<div style="padding: 2rem; display: flex; flex-direction: row">
<vega-chart
:interval-selection-signal-names="['brush']"
:visualization-spec="spec"
:config="unchartedVegaTheme"
@chart-click="handleChartClick($event)"
@update-interval-selection="debounceHandleIntervalSelect"
/>
<vega-chart :visualization-spec="spec2" />
</div>
</template>

<script setup lang="ts">
import { debounce } from 'lodash';
import { ref } from 'vue';
import VegaChart from '@/components/widgets/VegaChart.vue';
import unchartedVegaTheme from './vega-theme';
const rand = (v: number) => Math.round(Math.random() * v);
const numPoints = 10;
const numSamples = 40;
const valueRange = 20;
const trueValues: any[] = [];
const dataChart1: any[] = [];
const dataChart2: any[] = [];
// Fake data generation
for (let j = 0; j < numPoints; j++) {
trueValues.push(rand(valueRange));
}
for (let i = 0; i < numSamples; i++) {
let error = 0;
for (let j = 0; j < numPoints; j++) {
const v = rand(valueRange);
dataChart2.push({ sample: i, timestep: j, value: v });
error += Math.abs(trueValues[j] - v);
}
dataChart1.push({ sample: i, error: error });
}
const makeLineChart = (data: any[]) => {
return {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
description: 'Stock prices of 5 Tech Companies over Time.',
// data: { url: 'https://vega.github.io/vega-lite/data/stocks.csv' },
data: { values: data },
mark: {
type: 'line',
strokeWidth: 0.5
},
encoding: {
x: {
field: 'timestep',
type: 'quantitative'
},
y: {
field: 'value',
type: 'quantitative',
scale: { domain: [-20, 40] }
},
color: {
field: 'sample',
type: 'nominal',
// scale: { range: ['#f00'] },
legend: null
}
}
};
};
const spec = ref<any>({
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
data: { values: dataChart1 },
transform: [
{
aggregate: [{ op: 'count', field: '*', as: 'count' }],
groupby: ['error']
},
{ calculate: 'random()', as: 'jitter' }
],
vconcat: [
{
mark: 'area',
width: 500,
height: 100,
encoding: {
x: {
field: 'error',
type: 'quantitative',
scale: { domain: [0, 120] }
},
y: {
field: 'count',
type: 'quantitative'
}
}
},
{
mark: 'point',
width: 500,
height: 80,
encoding: {
data: { value: dataChart1 },
color: { value: '#f80' },
opacity: { value: 0.8 },
size: { value: 15 },
x: {
field: 'error',
type: 'quantitative',
title: '',
scale: { domain: [0, 120] }
},
y: {
field: 'jitter',
type: 'quantitative',
title: '',
axis: null
}
},
params: [
{
name: 'brush',
select: { type: 'interval', encodings: ['x'] }
}
]
}
]
});
const spec2 = ref<any>(makeLineChart(dataChart2));
const handleChartClick = (event: any) => {
console.log('!!', event);
};
const handleIntervalSelect = (name: any, valueRange: any) => {
console.log('>>', name, valueRange);
let samples = dataChart1
.filter((d) => {
return d.error >= valueRange.error[0] && d.error <= valueRange.error[1];
})
.map((d) => d.sample);
if (!samples || samples.length === 0) {
spec2.value = makeLineChart(dataChart2);
return;
}
spec2.value = makeLineChart(
dataChart2.filter((d) => {
return samples.includes(d.sample);
})
);
};
const debounceHandleIntervalSelect = debounce(handleIntervalSelect, 200);
</script>
Loading

0 comments on commit a2be59a

Please sign in to comment.