File size: 7,936 Bytes
1380717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
import vegaEmbed from "https://esm.sh/vega-embed@6?deps=vega@5&deps=vega-lite@5.20.1";
import lodashDebounce from "https://esm.sh/lodash-es@4.17.21/debounce";

// Note: For offline support, the import lines above are removed and the remaining script
// is bundled using vl-convert's javascript_bundle function. See the documentation of
// the javascript_bundle function for details on the available imports and their names.
// If an additional import is required in the future, it will need to be added to vl-convert
// in order to preserve offline support.
async function render({ model, el }) {
    let finalize;

    function showError(error){
        el.innerHTML = (
            '<div style="color:red;">'
            + '<p>JavaScript Error: ' + error.message + '</p>'
            + "<p>This usually means there's a typo in your chart specification. "
            + "See the javascript console for the full traceback.</p>"
            + '</div>'
        );
    }

    const reembed = async () => {
        if (finalize != null) {
          finalize();
        }

        model.set("local_tz", Intl.DateTimeFormat().resolvedOptions().timeZone);

        let spec = structuredClone(model.get("spec"));
        if (spec == null) {
            // Remove any existing chart and return
            while (el.firstChild) {
                el.removeChild(el.lastChild);
            }
            model.save_changes();
            return;
        }
        let embedOptions = structuredClone(model.get("embed_options")) ?? undefined;

        let api;
        try {
            api = await vegaEmbed(el, spec, embedOptions);
        } catch (error) {
            showError(error)
            return;
        }

        finalize = api.finalize;

        // Debounce config
        const wait = model.get("debounce_wait") ?? 10;
        const debounceOpts = {leading: false, trailing: true};
        if (model.get("max_wait") ?? true) {
            debounceOpts["maxWait"] = wait;
        }

        const initialSelections = {};
        for (const selectionName of Object.keys(model.get("_vl_selections"))) {
            const storeName = `${selectionName}_store`;
            const selectionHandler = (_, value) => {
                const newSelections = cleanJson(model.get("_vl_selections") ?? {});
                const store = cleanJson(api.view.data(storeName) ?? []);

                newSelections[selectionName] = {value, store};
                model.set("_vl_selections", newSelections);
                model.save_changes();
            };
            api.view.addSignalListener(selectionName, lodashDebounce(selectionHandler, wait, debounceOpts));

            initialSelections[selectionName] = {
                value: cleanJson(api.view.signal(selectionName) ?? {}),
                store: cleanJson(api.view.data(storeName) ?? [])
            }
        }
        model.set("_vl_selections", initialSelections);

        const initialParams = {};
        for (const paramName of Object.keys(model.get("_params"))) {
            const paramHandler = (_, value) => {
                const newParams = JSON.parse(JSON.stringify(model.get("_params"))) || {};
                newParams[paramName] = value;
                model.set("_params", newParams);
                model.save_changes();
            };
            api.view.addSignalListener(paramName, lodashDebounce(paramHandler, wait, debounceOpts));

            initialParams[paramName] = api.view.signal(paramName) ?? null
        }
        model.set("_params", initialParams);
        model.save_changes();

        // Param change callback
        model.on('change:_params', async (new_params) => {
            for (const [param, value] of Object.entries(new_params.changed._params)) {
                api.view.signal(param, value);
            }
            await api.view.runAsync();
        });

        // Add signal/data listeners
        for (const watch of model.get("_js_watch_plan") ?? []) {
            if (watch.namespace === "data") {
                const dataHandler = (_, value) => {
                    model.set("_js_to_py_updates", [{
                        namespace: "data",
                        name: watch.name,
                        scope: watch.scope,
                        value: cleanJson(value)
                    }]);
                    model.save_changes();
                };
                addDataListener(api.view, watch.name, watch.scope, lodashDebounce(dataHandler, wait, debounceOpts))

            } else if (watch.namespace === "signal") {
                const signalHandler = (_, value) => {
                    model.set("_js_to_py_updates", [{
                        namespace: "signal",
                        name: watch.name,
                        scope: watch.scope,
                        value: cleanJson(value)
                    }]);
                    model.save_changes();
                };

                addSignalListener(api.view, watch.name, watch.scope, lodashDebounce(signalHandler, wait, debounceOpts))
            }
        }

        // Add signal/data updaters
        model.on('change:_py_to_js_updates', async (updates) => {
            for (const update of updates.changed._py_to_js_updates ?? []) {
                if (update.namespace === "signal") {
                    setSignalValue(api.view, update.name, update.scope, update.value);
                } else if (update.namespace === "data") {
                    setDataValue(api.view, update.name, update.scope, update.value);
                }
            }
            await api.view.runAsync();
        });
    }

    model.on('change:spec', reembed);
    model.on('change:embed_options', reembed);
    model.on('change:debounce_wait', reembed);
    model.on('change:max_wait', reembed);
    await reembed();
}

function cleanJson(data) {
    return JSON.parse(JSON.stringify(data))
}

function getNestedRuntime(view, scope) {
    var runtime = view._runtime;
    for (const index of scope) {
        runtime = runtime.subcontext[index];
    }
    return runtime
}

function lookupSignalOp(view, name, scope) {
    let parent_runtime = getNestedRuntime(view, scope);
    return parent_runtime.signals[name] ?? null;
}

function dataRef(view, name, scope) {
    let parent_runtime = getNestedRuntime(view, scope);
    return parent_runtime.data[name];
}

export function setSignalValue(view, name, scope, value) {
    let signal_op = lookupSignalOp(view, name, scope);
    view.update(signal_op, value);
}

export function setDataValue(view, name, scope, value) {
    let dataset = dataRef(view, name, scope);
    let changeset = view.changeset().remove(() => true).insert(value)
    dataset.modified = true;
    view.pulse(dataset.input, changeset);
}

export function addSignalListener(view, name, scope, handler) {
    let signal_op = lookupSignalOp(view, name, scope);
    return addOperatorListener(
        view,
        name,
        signal_op,
        handler,
    );
}

export function addDataListener(view, name, scope, handler) {
    let dataset = dataRef(view, name, scope).values;
    return addOperatorListener(
        view,
        name,
        dataset,
        handler,
    );
}

// Private helpers from Vega for dealing with nested signals/data
function findOperatorHandler(op, handler) {
    const h = (op._targets || [])
        .filter(op => op._update && op._update.handler === handler);
    return h.length ? h[0] : null;
}

function addOperatorListener(view, name, op, handler) {
    let h = findOperatorHandler(op, handler);
    if (!h) {
        h = trap(view, () => handler(name, op.value));
        h.handler = handler;
        view.on(op, null, h);
    }
    return view;
}

function trap(view, fn) {
    return !fn ? null : function() {
        try {
            fn.apply(this, arguments);
        } catch (error) {
            view.error(error);
        }
    };
}

export default { render }