├── src ├── changeFetcher │ ├── index.js │ ├── state │ │ ├── compute │ │ │ ├── index.js │ │ │ ├── view.js │ │ │ └── util.js │ │ └── util.js │ └── change.js ├── default │ ├── ease.js │ ├── util.js │ ├── index.js │ ├── style.js │ ├── encode │ │ ├── index.js │ │ ├── mark.js │ │ ├── axis.js │ │ └── legend.js │ ├── browser.js │ └── vegaConfig.js ├── index.js ├── parser │ ├── resolveCollector.js │ ├── stepEnumerator.js │ └── conflictChecker.js ├── util │ ├── vgSpecHelper.js │ ├── vl2vg4gemini.js │ └── vgDataHelper.js ├── animationSequence.js ├── resolver.js ├── actuator │ ├── vega-render-util.js │ ├── timings.js │ ├── index.js │ ├── view.js │ └── staggering.js ├── animation.js ├── schedule.js ├── recommender │ ├── util.js │ ├── pseudoTimelineEvaluator.js │ ├── diffApplier.js │ ├── index.js │ ├── sequence │ │ └── index.js │ └── designGuidelines.v1.js ├── gemini.js └── enumerator.js ├── .babelrc ├── .gitignore ├── test ├── setupData.sh ├── exampleLoader.js ├── gemini.test.js ├── parser │ └── specChecker.test.js ├── animationSequence.html ├── util.test.js ├── recommender │ ├── markCompChecker.test.js │ ├── pseudoTimelineValidator.test.js │ ├── pseudoTimelineEvaluator.test.js │ ├── designGuidelines.test.js │ ├── diffApplier.test.js │ ├── sequence │ │ └── index.test.js │ ├── pseudoTimelineEnumerator.test.js │ ├── timelineGenerator.test.js │ └── index.test.js └── examples │ ├── sequence │ ├── input-mergedScale.json │ ├── filter_aggregate.json │ ├── addY_addColor.json │ └── addY_aggregate_scale.json │ └── transition │ ├── stimuliB.json │ ├── stackTogroup.json │ ├── addYAxis.json │ ├── changeYEncode_bar.json │ ├── removeLegendUpdateData.json │ ├── barToPoint.json │ └── staggering.json ├── webpack.config.js ├── .eslintrc.json ├── rollup.config.js ├── LICENSE ├── package.json └── jest.config.js /src/changeFetcher/index.js: -------------------------------------------------------------------------------- 1 | export { attachStates } from "./state"; 2 | export { attachChanges } from "./change"; 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": ["transform-es2015-modules-commonjs"] 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /src/default/ease.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_EASE = { 2 | mark: "cubic", 3 | line: "cubic", 4 | axis: "cubic", 5 | legend: "cubic", 6 | view: "cubic" 7 | }; -------------------------------------------------------------------------------- /src/default/util.js: -------------------------------------------------------------------------------- 1 | export function encodify(obj) { 2 | return Object.keys(obj).reduce((encode, key) => { 3 | encode[key] = { value: obj[key] }; 4 | return encode; 5 | }, {}); 6 | } 7 | -------------------------------------------------------------------------------- /src/default/index.js: -------------------------------------------------------------------------------- 1 | export { BR_PROP_DEFAULT } from "./browser"; 2 | export { DEFAULT_EASE } from "./ease"; 3 | export { DEFAULT_STYLE } from "./style"; 4 | export { DEFAULT_ENCODE } from "./encode"; -------------------------------------------------------------------------------- /src/changeFetcher/state/compute/index.js: -------------------------------------------------------------------------------- 1 | export { compute as mark } from "./mark"; 2 | export { compute as axis } from "./axis"; 3 | export { compute as legend } from "./legend"; 4 | export { compute as view } from "./view"; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test_deprecated 3 | notes 4 | yarn-error.log 5 | bug 6 | paper 7 | editor/test 8 | exp 9 | user_study/ 10 | editor/ 11 | etc/ 12 | .vscode 13 | 14 | .yalc/ 15 | yalc.lock 16 | test/data/ 17 | -------------------------------------------------------------------------------- /test/setupData.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | DATA=test/data 6 | 7 | CWD=$(pwd) 8 | 9 | echo "Copying data to '$DATA'." 10 | 11 | if [ ! -d "$DATA" ]; then 12 | mkdir $DATA 13 | fi 14 | 15 | eval rsync -r "$CWD/node_modules/vega-datasets/data/*" $DATA -------------------------------------------------------------------------------- /src/default/style.js: -------------------------------------------------------------------------------- 1 | import { vegaConfig } from "./vegaConfig"; 2 | import { encodify } from "./util"; 3 | export const DEFAULT_STYLE = Object.keys(vegaConfig.style).reduce( 4 | (styles, key) => { 5 | styles[key] = encodify(vegaConfig.style[key]); 6 | return styles; 7 | }, 8 | {} 9 | ); 10 | -------------------------------------------------------------------------------- /src/default/encode/index.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_ENCODE_LEGEND } from "./legend"; 2 | import { DEFAULT_ENCODE_MARK } from "./mark"; 3 | import { DEFAULT_ENCODE_AXIS } from "./axis"; 4 | 5 | // DEFAULT enter means initials of the enter 6 | export const DEFAULT_ENCODE = { 7 | mark: DEFAULT_ENCODE_MARK, 8 | axis: DEFAULT_ENCODE_AXIS, 9 | legend: DEFAULT_ENCODE_LEGEND 10 | }; 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './editor/js/main.js', 6 | output: { 7 | filename: 'main.js', 8 | path: path.resolve(__dirname, 'editor', 'dist'), 9 | publicPath: "./dist/" 10 | }, 11 | module: { 12 | rules: [{ 13 | test: /\.css$/, 14 | use: ['style-loader', 'css-loader'] 15 | }] 16 | }, 17 | plugins: [ 18 | new MonacoWebpackPlugin({ 19 | languages: ["json"] 20 | }) 21 | ], 22 | devtool: 'eval-source-map' 23 | }; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module", 9 | "ecmaVersion": 2018 10 | }, 11 | "plugins": [ "prettier", "jest" ], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:jest/recommended" 15 | ], 16 | 17 | "rules": { 18 | "indent": [ 19 | "error", 20 | 2 21 | ], 22 | "linebreak-style": [ 23 | "error", 24 | "unix" 25 | ], 26 | "quotes": [ 27 | "error", 28 | "double" 29 | ], 30 | "semi": [ 31 | "error", 32 | "always" 33 | ] 34 | } 35 | } -------------------------------------------------------------------------------- /src/default/encode/mark.js: -------------------------------------------------------------------------------- 1 | import { encodify } from "../util"; 2 | import { vegaConfig as vgConfig } from "../vegaConfig"; 3 | export const DEFAULT_ENCODE_MARK = { 4 | enter: { opacity: { value: 0 } }, 5 | exit: { opacity: { value: 0 } }, 6 | line: { 7 | update: { 8 | ...encodify(vgConfig.line), 9 | fill: { value: "none" } 10 | } 11 | }, 12 | area: {update: encodify(vgConfig.area)}, 13 | trail: {update: encodify(vgConfig.trail)}, 14 | symbol: { update: encodify(vgConfig.symbol) }, 15 | rect: { update: encodify(vgConfig.rect) }, 16 | rule: { update: encodify(vgConfig.rule) }, 17 | text: { update: encodify(vgConfig.text) } 18 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { Gemini } from "./gemini.js"; 2 | import { default as recommend, compareCost, canRecommend, allAtOnce } from "./recommender"; 3 | import { recommendForSeq, 4 | recommendKeyframes, 5 | recommendWithPath, 6 | canRecommendKeyframes, 7 | canRecommendForSeq } from "./recommender/sequence"; 8 | import { default as vl2vg4gemini, castVL2VG } from "./util/vl2vg4gemini"; 9 | 10 | const { animate, animateSequence } = Gemini; 11 | export { animate, 12 | animateSequence, 13 | recommend, 14 | canRecommend, 15 | recommendForSeq, 16 | canRecommendForSeq, 17 | recommendKeyframes, 18 | canRecommendKeyframes, 19 | recommendWithPath, 20 | compareCost, 21 | vl2vg4gemini, 22 | castVL2VG, 23 | allAtOnce 24 | }; 25 | -------------------------------------------------------------------------------- /src/default/browser.js: -------------------------------------------------------------------------------- 1 | const textDefault = { 2 | fill: "#000", 3 | font: "sans-serif", 4 | fontSize: 11, 5 | opacity: 1, 6 | baseline: "alphabetic" 7 | }; 8 | // the browsers' default 9 | export const BR_PROP_DEFAULT = { 10 | none: {}, 11 | rect: { opacity: 1 }, 12 | gradient: { opacity: 1 }, 13 | group: { opacity: 1 }, 14 | tick: { opacity: 1 }, 15 | grid: { opacity: 1 }, 16 | domain: { opacity: 1 }, 17 | symbol: { opacity: 1, stroke: "transparent" }, 18 | line: { opacity: 1, fill: "none" }, 19 | // area: { opacity: 1, strokeWidth: "1px", stroke: "none" }, 20 | area: { opacity: 1, strokeWidth: "0px" }, 21 | trail: { opacity: 1, strokeWidth: "0px" }, 22 | rule: { opacity: 1 }, 23 | text: textDefault, 24 | title: textDefault 25 | }; 26 | -------------------------------------------------------------------------------- /test/exampleLoader.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import {copy} from "../src/util/util"; 3 | 4 | const examples = {}; 5 | function loadExample (path, sub) { 6 | let collection = examples; 7 | if (sub ) { 8 | examples[sub] = examples[sub] || {}; 9 | collection = examples[sub]; 10 | } 11 | 12 | fs.readdirSync(__dirname + `/examples${path}`) 13 | .filter(filename => filename.indexOf(".json") >= 0) 14 | .forEach(filename => { 15 | 16 | let example = JSON.parse(fs.readFileSync(__dirname + `/examples${path}/` + filename)); 17 | if (example.data) { 18 | example.sSpec.data.find(dataObj => dataObj.name === "source_0").values = copy(example.data); 19 | example.eSpec.data.find(dataObj => dataObj.name === "source_0").values = copy(example.data); 20 | } 21 | 22 | collection[filename.replace(".json","")] = example; 23 | }); 24 | } 25 | 26 | loadExample("/transition"); 27 | loadExample("/sequence", "sequence") 28 | export default examples -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from "rollup-plugin-node-resolve"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import json from "rollup-plugin-json"; 4 | import sourcemaps from "rollup-plugin-sourcemaps"; 5 | 6 | export default { 7 | input: "src/index.js", 8 | output: [{ 9 | file: "gemini.js", 10 | format: "esm", 11 | sourcemap: "inline", 12 | name: "gemini", 13 | globals: { 14 | vega: "vega", 15 | "vega-lite": "vegaLite", 16 | d3: "d3", 17 | "vega-embed": "vegaEmbed" 18 | } 19 | }, 20 | 21 | { 22 | file: "gemini.web.js", 23 | format: "umd", 24 | sourcemap: true, 25 | name: "gemini", 26 | globals: { 27 | vega: "vega", 28 | "vega-lite": "vegaLite", 29 | d3: "d3", 30 | "vega-embed": "vegaEmbed" 31 | } 32 | } 33 | ], 34 | plugins: [ 35 | nodeResolve(), 36 | commonjs({ 37 | namedExports: { 38 | "graphscape": ["path"] 39 | } 40 | }), 41 | json(), 42 | sourcemaps()], 43 | external: ["vega", "vega-lite", "d3", "vega-embed"] 44 | }; -------------------------------------------------------------------------------- /test/gemini.test.js: -------------------------------------------------------------------------------- 1 | import {Gemini} from "../src/gemini.js" 2 | import { default as vl2vg4gemini } from "../src/util/vl2vg4gemini.js"; 3 | 4 | 5 | describe("AnimationSequence", () => { 6 | const genCharts = (t) => { 7 | return vl2vg4gemini({ 8 | "$schema": "https://vega.github.io/schema/vega-lite/v6.json", 9 | "mark": "bar", 10 | "data": {"values": [{"t": t}]}, 11 | "encoding": { "x": {"field": "t", "type":"quantitative"} } 12 | }) 13 | } 14 | 15 | test("Should compile multiple transitions correctly.", async () => { 16 | const charts = [genCharts(0), genCharts(1), genCharts(2)]; 17 | const gemSpec = { 18 | "timeline": {"component": {"mark": "marks"}, "timing": {"duration": 1000}} 19 | } 20 | const animationSequence = await Gemini.animateSequence(charts, [gemSpec, gemSpec] ); 21 | expect(animationSequence.animations.length).toBe(2); 22 | expect(animationSequence.animations[0].rawInfo.eVis.spec).toBe(charts[1]); 23 | expect(animationSequence.animations[1].rawInfo.sVis.spec).toBe(charts[1]); 24 | 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /src/parser/resolveCollector.js: -------------------------------------------------------------------------------- 1 | export default function(parsedBlock, parsedSteps) { 2 | let resolves = collect(parsedBlock); 3 | // 1-2. collect the alternative timelines. (alterIds) 4 | resolves.forEach(r => { 5 | r.alterIds = parsedSteps 6 | .filter( 7 | step => step.alterId && 8 | (step.alterId.split(":")[0] === r.alterName) 9 | ) 10 | .map(step => step.alterId) 11 | .unique(); 12 | 13 | // Place ":main" at first 14 | const i = r.alterIds.findIndex(d => d.indexOf(":main") >= 0); 15 | const head = r.alterIds.splice(i, 1); 16 | r.alterIds = head.concat(r.alterIds); 17 | }); 18 | return resolves; 19 | } 20 | 21 | function collect(block) { 22 | let resolves = []; 23 | 24 | if (block.sync) { 25 | block.sync.forEach(blk => { 26 | resolves = resolves.concat(collect(blk)); 27 | }); 28 | } else if (block.concat) { 29 | block.concat.forEach(blk => { 30 | resolves = resolves.concat(collect(blk)); 31 | }); 32 | } 33 | if (block.resolve) { 34 | resolves.push(block.resolve); 35 | } 36 | return resolves; 37 | } -------------------------------------------------------------------------------- /test/parser/specChecker.test.js: -------------------------------------------------------------------------------- 1 | import {specChecker} from "../../src/parser/specChecker"; 2 | import { default as EXAMPLES } from "../exampleLoader.js"; 3 | describe("specChecker", () => { 4 | for (const egName in EXAMPLES) { 5 | if (egName!=="addLayer") { 6 | return; 7 | } 8 | const example = EXAMPLES[egName]; 9 | test(`Should return true for the example:${egName}.`, () => { 10 | 11 | if (example.gemSpec) { 12 | expect(specChecker(example.gemSpec)).toBe(true); 13 | } else if (example.gemSpecs) { 14 | example.gemSpecs.forEach(spec => { 15 | expect(specChecker(spec)).toBe(true); 16 | }); 17 | } 18 | }); 19 | } 20 | test("Should return false for illegal specs.", () => { 21 | const illegal_1 = { 22 | timemine: { 23 | component: "view", 24 | timing: {duration: 100} 25 | } 26 | }, illegal_2 = { 27 | timeline: { 28 | component: "view" 29 | } 30 | }; 31 | expect(() => (specChecker(illegal_1))).toThrow(); 32 | expect(() => (specChecker(illegal_2))).toThrow(); 33 | 34 | }); 35 | 36 | }); 37 | -------------------------------------------------------------------------------- /src/util/vgSpecHelper.js: -------------------------------------------------------------------------------- 1 | function getLegendType(legendCompSpec, view) { 2 | if (legendCompSpec.fill) { 3 | const scale = view._runtime.scales[legendCompSpec.fill].value; 4 | if ( 5 | [ 6 | "sequential-linear", 7 | "linear", 8 | "log", 9 | "pow", 10 | "sqrt", 11 | "symlog", 12 | "bin-ordinal" 13 | ].indexOf(scale.type) >= 0 14 | ) { 15 | if (scale.type === "bin-ordinal") { 16 | return { type: "gradient", isBand: true }; 17 | } 18 | return { type: "gradient" }; 19 | } 20 | } 21 | 22 | if (legendCompSpec.stroke) { 23 | const scale = view._runtime.scales[legendCompSpec.stroke].value; 24 | if ( 25 | [ 26 | "sequential-linear", 27 | "linear", 28 | "log", 29 | "pow", 30 | "sqrt", 31 | "symlog", 32 | "bin-ordinal" 33 | ].indexOf(scale.type) >= 0 34 | ) { 35 | if (scale.type === "bin-ordinal") { 36 | return { type: "gradient", isBand: true }; 37 | } 38 | return { type: "gradient" }; 39 | } 40 | } 41 | return { type: "symbol" }; 42 | } 43 | 44 | export { getLegendType }; 45 | -------------------------------------------------------------------------------- /test/animationSequence.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 36 | 37 | -------------------------------------------------------------------------------- /src/animationSequence.js: -------------------------------------------------------------------------------- 1 | class AnimationSequence { 2 | 3 | constructor(animations) { 4 | this.animations = animations; 5 | this.status = "ready"; 6 | this.specs = animations.map(anim => anim.spec); 7 | this.logs = []; 8 | this.rawInfos = animations.map(anim => anim.rawInfo); 9 | } 10 | 11 | log(timestamp, message, info) { 12 | if (typeof message === "string" && typeof timestamp === "number") { 13 | this.logs.push({ 14 | timestamp, 15 | message, 16 | info 17 | }); 18 | } 19 | return this.logs; 20 | } 21 | 22 | async play (targetElm) { 23 | // play and return the promsie 24 | const globalSTime = new Date(); 25 | 26 | 27 | for (let i = 0; i < this.animations.length; i++) { 28 | const animation = this.animations[i]; 29 | this.log(new Date() - globalSTime, `Start the ${i}-th animated transition.`); 30 | await animation.play(targetElm); 31 | if (i < (this.animations.length - 1)) { 32 | const target = document.querySelector(targetElm); 33 | target.textContent = ""; 34 | target.append(animation.rawInfo.eVis.htmlDiv); 35 | } 36 | } 37 | } 38 | } 39 | 40 | export { AnimationSequence }; 41 | -------------------------------------------------------------------------------- /src/resolver.js: -------------------------------------------------------------------------------- 1 | import {attachStates} from "./changeFetcher"; 2 | 3 | export async function autoScaleOrder(extendedSchedule, resolves, rawInfo) { 4 | const mainTimeline = extendedSchedule.getTimeline(":main"); 5 | 6 | let extendedTimeline = await attachStates(mainTimeline, rawInfo); 7 | const scaleOrderResovles = resolves.filter(r => r.autoScaleOrder), 8 | scheduleAlternator = extendedSchedule.getTimelineAlternator(scaleOrderResovles); 9 | 10 | while (!validateScaleOrder(scaleOrderResovles, extendedTimeline)) { 11 | const altTimeline = scheduleAlternator(); 12 | if (!altTimeline) { 13 | extendedTimeline = await attachStates(mainTimeline, rawInfo); 14 | break; 15 | } 16 | extendedTimeline = await attachStates(altTimeline, rawInfo); 17 | } 18 | return extendedTimeline; 19 | } 20 | 21 | function validateScaleOrder(resolves, timeline) { 22 | let valid = true; 23 | resolves.forEach(resolve => { 24 | resolve.autoScaleOrder.forEach(compName => { 25 | const foundTrack = timeline.find(track => track.compName === compName); 26 | if (foundTrack && foundTrack.scaleOrderValid === false) { 27 | valid = false; 28 | } 29 | }); 30 | }); 31 | return valid; 32 | } -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | import { partition, permutate, crossJoinArrays } from "../src/util/util"; 2 | 3 | 4 | describe("partition", () => { 5 | test("Should partition the given array correctly.", async () => { 6 | let B4 = 0 7 | let partitions = partition([0,1,2,3], 1) 8 | expect(partitions).toEqual([[[0,1,2,3]]]); 9 | B4 += partitions.length 10 | 11 | partitions = partition([0,1,2,3], 2) 12 | expect(partitions.length).toEqual(7); 13 | B4 += partitions.length 14 | 15 | partitions = partition([0,1,2,3], 3) 16 | expect(partitions.length).toEqual(6); 17 | B4 += partitions.length 18 | 19 | partitions = partition([0,1,2,3], 4) 20 | expect(partitions).toEqual([[[0],[1],[2],[3]]]); 21 | B4 += partitions.length 22 | 23 | expect(B4).toEqual(15) 24 | }); 25 | }); 26 | 27 | describe("permutate", () => { 28 | test("Should permutate the given array correctly.", async () => { 29 | let B4 = 0 30 | let permutation = permutate([0,1,2,3]) 31 | expect(permutation.length).toEqual(24) 32 | }); 33 | }); 34 | 35 | describe("crossJoinArrays", () => { 36 | test("Should do cross-join the given two arrrays correctly.", () => { 37 | let arrs = [ 38 | [0,1,2], 39 | ["a","b"], 40 | ["A", "B"] 41 | ] 42 | let C = crossJoinArrays(arrs); 43 | expect(C.length).toEqual(12); 44 | expect(C).toEqual([ 45 | [0,"a", "A"], [1,"a", "A"], [2,"a", "A"], [0,"b", "A"], [1,"b", "A"], [2,"b", "A"], 46 | [0,"a", "B"], [1,"a", "B"], [2,"a", "B"], [0,"b", "B"], [1,"b", "B"], [2,"b", "B"] 47 | ]); 48 | }) 49 | }) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, UW Interactive Data Lab 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/changeFetcher/state/util.js: -------------------------------------------------------------------------------- 1 | function computeHasFacet(compSpec) { 2 | if ( 3 | compSpec && 4 | compSpec.parent && 5 | compSpec.parent.from && 6 | compSpec.parent.from.facet && 7 | compSpec.parent.from.facet.data && 8 | compSpec.parent.from.facet.name === compSpec.from.data 9 | ) { 10 | return true; 11 | } 12 | return false; 13 | } 14 | 15 | function getFacet(compSpec) { 16 | return computeHasFacet(compSpec) ? compSpec.parent.from.facet : undefined; 17 | } 18 | 19 | function isGroupingMarktype(marktype) { 20 | return marktype === "line" || marktype === "area" || marktype === "trail"; 21 | } 22 | 23 | function findMark(marks, markName) { 24 | let result; 25 | for (let i = 0; i < marks.length; i++) { 26 | const m = marks[i]; 27 | if (m.name === markName) { 28 | return m; 29 | } 30 | if (m.marks) { 31 | result = findMark(m.marks, markName); 32 | if (result) { 33 | break; 34 | } 35 | } 36 | } 37 | return result; 38 | } 39 | 40 | function findData(spec, dataName) { 41 | for (let i = 0; i < spec.data.length; i++) { 42 | if (spec.data[i].name === dataName) { 43 | return spec.data[i]; 44 | } 45 | } 46 | } 47 | function findFilter(spec, name) { 48 | for (let i = 0; i < spec.data.length; i++) { 49 | const d = spec.data[i]; 50 | if (d.transform) { 51 | const filter = d.transform.find(filter => filter.name === name); 52 | if (filter) { 53 | return filter; 54 | } 55 | } 56 | } 57 | } 58 | 59 | export { 60 | computeHasFacet, 61 | getFacet, 62 | isGroupingMarktype, 63 | findMark, 64 | findData, 65 | findFilter 66 | }; 67 | -------------------------------------------------------------------------------- /src/changeFetcher/state/compute/view.js: -------------------------------------------------------------------------------- 1 | import { copy } from "../../../util/util"; 2 | 3 | export function compute(rawInfo, step, lastState) { 4 | const { change } = step; 5 | const signals = { 6 | initial: lastState.signal 7 | }; 8 | const encodes = { 9 | initial: copy(lastState.encode), 10 | final: copy(lastState.encode) 11 | }; 12 | 13 | const signalsFinal = {}; 14 | const finalSignalNames = Array.isArray(change.signal) 15 | ? change.signal 16 | : ["width", "height", "padding"]; 17 | 18 | finalSignalNames.forEach(sgName => { 19 | signalsFinal[sgName] = rawInfo.eVis.view.signal(sgName); 20 | }); 21 | 22 | signals.final = { ...signals.initial, ...signalsFinal }; 23 | 24 | if (step.change.signal !== false) { 25 | if (finalSignalNames.indexOf("height") >= 0) { 26 | encodes.final.svg.y = { value: change.final.y + change.final.padding }; 27 | encodes.final.svg.height = { 28 | value: change.final.viewHeight + change.final.padding * 2 29 | }; 30 | encodes.final.root.height = { value: signals.final.height }; 31 | } 32 | if (finalSignalNames.indexOf("width") >= 0) { 33 | encodes.final.svg.x = { value: change.final.x + change.final.padding }; 34 | encodes.final.svg.width = { 35 | value: change.final.viewWidth + change.final.padding * 2 36 | }; 37 | encodes.final.root.width = { value: signals.final.width }; 38 | } 39 | } 40 | 41 | // Todo Encodes for view comp 42 | const fRootDatum = rawInfo.eVis.view._runtime.data.root.values.value[0]; 43 | encodes.final.root.fill = { value: fRootDatum.fill }; 44 | encodes.final.root.stroke = { value: fRootDatum.stroke }; 45 | 46 | return { 47 | signals, 48 | encodes 49 | }; 50 | 51 | } -------------------------------------------------------------------------------- /src/actuator/vega-render-util.js: -------------------------------------------------------------------------------- 1 | // https://github.com/vega/vega/blob/master/packages/vega-scenegraph/src/util/text.js 2 | function textOffset(item) { 3 | // perform our own font baseline calculation 4 | // why? not all browsers support SVG 1.1 'alignment-baseline' :( 5 | const { baseline } = item; 6 | const h = fontSize(item); 7 | switch (baseline) { 8 | case "top": 9 | return 0.79 * h; 10 | case "middle": 11 | return 0.3 * h; 12 | case "bottom": 13 | return -0.21 * h; 14 | case "line-top": 15 | return 0.29 * h + 0.5 * lineHeight(item); 16 | case "line-bottom": 17 | return 0.29 * h - 0.5 * lineHeight(item); 18 | default: 19 | return 0; 20 | } 21 | } 22 | 23 | function fontSize(item) { 24 | return item.fontSize != null ? +item.fontSize || 0 : 11; 25 | } 26 | 27 | function lineHeight(item) { 28 | return item.lineHeight != null ? item.lineHeight : fontSize(item) + 2; 29 | } 30 | 31 | function getStyle(attr) { 32 | switch (attr) { 33 | case "font": 34 | return "font-family"; 35 | case "fontSize": 36 | return "font-size"; 37 | case "fontStyle": 38 | return "font-style"; 39 | case "fontVariant": 40 | return "font-variant"; 41 | case "fontWeight": 42 | return "font-weight"; 43 | case "strokeWidth": 44 | return "stroke-width"; 45 | case "strokeDasharray": 46 | return "stroke-dasharray"; 47 | } 48 | return attr; 49 | } 50 | 51 | function setTextAnchor(d3Selection, fn) { 52 | const textAnchor = { 53 | left: "start", 54 | center: "middle", 55 | right: "end" 56 | }; 57 | d3Selection.attr("text-anchor", d => textAnchor[fn(d)]); 58 | } 59 | 60 | function transformItem(item) { 61 | return `translate(${item.x || 0}, ${item.y || 0})${ 62 | item.angle ? ` rotate(${item.angle})` : "" 63 | }`; 64 | } 65 | 66 | export { textOffset, getStyle, setTextAnchor, transformItem }; 67 | -------------------------------------------------------------------------------- /src/parser/stepEnumerator.js: -------------------------------------------------------------------------------- 1 | import { copy } from "../util/util"; 2 | import {computeFilteringValues} from "../enumerator"; 3 | export default function enumerateSteps(block, rawInfo, enumDefs) { 4 | if (block.sync) { 5 | block.sync = block.sync.map(blk => enumerateSteps(blk, rawInfo, enumDefs)); 6 | } else if (block.concat) { 7 | block.concat = block.concat.map(blk => 8 | enumerateSteps(blk, rawInfo, enumDefs) 9 | ); 10 | 11 | if (block.enumerator) { 12 | const foundEnumDef = enumDefs.find( 13 | enumDef => enumDef.name === block.enumerator 14 | ); 15 | 16 | if (foundEnumDef) { 17 | const filteringValues = computeFilteringValues(foundEnumDef, rawInfo); 18 | return filteringValues.slice(1).reduce( 19 | (acc, fVal, i) => { 20 | const enumedConcatBlock = copy(block); 21 | enumedConcatBlock.concat.forEach(blk => { 22 | fetchEnumVal(blk, foundEnumDef, fVal); 23 | }); 24 | enumedConcatBlock.enumerated = copy(foundEnumDef); 25 | enumedConcatBlock.enumerated.val = fVal; 26 | enumedConcatBlock.enumerated.N = filteringValues.length; 27 | enumedConcatBlock.enumerated.last = 28 | i === filteringValues.length - 2; 29 | 30 | acc.concat.push(enumedConcatBlock); 31 | return acc; 32 | }, 33 | { concat: [] } 34 | ); 35 | } 36 | } 37 | } 38 | return block; 39 | } 40 | 41 | function fetchEnumVal(block, enumDef, val) { 42 | if (block.sync || block.concat) { 43 | (block.sync || block.concat).forEach(blk => { 44 | fetchEnumVal(blk, enumDef, val); 45 | }); 46 | } else if (Array.isArray(block.enumVals)) { 47 | block.enumerated.push({def: enumDef, val: val}); 48 | } else { 49 | block.enumerated = [{def: enumDef, val: val}]; 50 | } 51 | } -------------------------------------------------------------------------------- /src/util/vl2vg4gemini.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import * as vegaLite from "vega-lite"; 3 | 4 | export default function vl2vg4gemini(vlSpec) { 5 | let vgSpec = vegaLite.compile(vlSpec).spec; 6 | vgSpec.axes = mergeDuplicatedAxes(vgSpec.axes); 7 | appendNamesOnGuides(vgSpec); 8 | return vgSpec; 9 | } 10 | 11 | 12 | export function castVL2VG(vlSpec) { 13 | if (vlSpec && vlSpec.$schema && vlSpec.$schema.indexOf("https://vega.github.io/schema/vega-lite") >= 0){ 14 | return vl2vg4gemini(vlSpec) 15 | } 16 | return vlSpec 17 | } 18 | 19 | 20 | function appendNamesOnGuides(vgSpec){ 21 | if (vgSpec.axes) { 22 | vgSpec.axes.forEach(axis => { 23 | if (!axis.encode) { 24 | axis.encode = {axis: {name: axis.scale}}; 25 | } else { 26 | axis.encode.axis = { ...axis.encode.axis, name: axis.scale }; 27 | } 28 | }); 29 | } 30 | if (vgSpec.legends) { 31 | vgSpec.legends.forEach((legend, i) => { 32 | if (!legend.encode) { 33 | legend.encode = {legend: {name: `legend${i}`}}; 34 | } else { 35 | legend.encode.legend = Object.assign({}, legend.encode.legend, {name: `legend${i}`}); 36 | } 37 | }); 38 | } 39 | } 40 | 41 | 42 | function mergeDuplicatedAxes(vegaAxes) { 43 | if (!vegaAxes || vegaAxes.length <= 0) { 44 | return []; 45 | } 46 | let axesScales = vegaAxes.filter(a => a.grid).map(a => a.scale); 47 | 48 | return d3.rollups(vegaAxes, 49 | axes => { 50 | let axisWithGrid = axes.find(a => a.grid); 51 | let axisWithoutGrid = { ...axes.find(a => !a.grid) }; 52 | 53 | if (axisWithGrid) { 54 | axisWithoutGrid.grid = true; 55 | if (axisWithGrid.gridScale) { 56 | axisWithoutGrid.gridScale = axisWithGrid.gridScale; 57 | } 58 | axisWithoutGrid.zindex = 0; 59 | } 60 | return axisWithoutGrid; 61 | }, 62 | axis => axis.scale 63 | ).map(d => d[1]) 64 | .sort((a,b) => (axesScales.indexOf(a.scale) - axesScales.indexOf(b.scale))); 65 | } 66 | 67 | -------------------------------------------------------------------------------- /src/animation.js: -------------------------------------------------------------------------------- 1 | class Animation { 2 | constructor(schedule, rawInfo, spec) { 3 | this.schedule = schedule; 4 | this.moments = this.schedule.moments; 5 | this.status = "ready"; 6 | this.spec = spec; 7 | this.logs = []; 8 | this._queue = []; 9 | this.rawInfo = rawInfo; 10 | } 11 | 12 | log(timestamp, message, info) { 13 | if (typeof message === "string" && typeof timestamp === "number") { 14 | this.logs.push({ 15 | timestamp, 16 | message, 17 | info 18 | }); 19 | } 20 | return this.logs; 21 | } 22 | 23 | async play(targetElm) { 24 | this.status = "playing"; 25 | // get moments and sort by sTime 26 | const { moments } = this; 27 | 28 | const globalSTime = new Date(); 29 | this._start(moments[0].starting, targetElm); 30 | this.log(new Date() - globalSTime, "0-th moment"); 31 | 32 | for (let i = 1; i < moments.length; i++) { 33 | const moment = moments[i]; 34 | 35 | await this._end(moment).then(() => { 36 | const delay = Math.max(moment.time - (new Date() - globalSTime), 0); 37 | return new Promise(resolve => setTimeout(() => resolve(), delay)); 38 | }); 39 | this._start(moment.starting, targetElm); 40 | this.log(new Date() - globalSTime, `${i}-th moment`); 41 | 42 | if (i === moments.length - 1) { 43 | this.status = "ready"; 44 | return; 45 | } 46 | } 47 | } 48 | 49 | _start(steps, targetElm) { 50 | steps.forEach(step => { 51 | this._queue.push({ 52 | sTime: step.sTime, 53 | eTime: step.eTime, 54 | step, 55 | result: step.template(this.rawInfo, step, targetElm) // contains the promise 56 | }); 57 | }); 58 | } 59 | 60 | async _end(moment) { 61 | const { time } = moment; 62 | 63 | const workingSteps = this._queue.filter(item => item.eTime === time); 64 | for (let i = 0; i < workingSteps.length; i++) { 65 | await workingSteps[i].result; 66 | } 67 | } 68 | } 69 | 70 | export { Animation }; 71 | -------------------------------------------------------------------------------- /src/actuator/timings.js: -------------------------------------------------------------------------------- 1 | import { staggeredTiming } from "./staggering"; 2 | 3 | function computeTiming(initialData, finalData, stepTiming, joinKey, joinSet) { 4 | let timings = initialData.map((d_i, i) => { 5 | const key = joinKey(d_i, i, "initial"); 6 | const found = finalData.find((d_f, j) => key === joinKey(d_f, j, "final")); 7 | return { 8 | initial: d_i.datum, 9 | final: found ? found.datum : null, 10 | set: found ? "update" : "exit", 11 | id: key, 12 | duration: stepTiming.duration, 13 | delay: stepTiming.delay 14 | }; 15 | }); 16 | timings = timings.concat( 17 | finalData 18 | .filter(d => joinSet(d) === "enter") 19 | .map((d, i) => { 20 | const key = joinKey(d, i, "final"); 21 | return { 22 | initial: null, 23 | final: d.datum, 24 | set: "enter", 25 | id: key, 26 | duration: stepTiming.duration, 27 | delay: stepTiming.delay 28 | }; 29 | }) 30 | ); 31 | 32 | if (stepTiming.staggering) { 33 | timings = staggeredTiming( 34 | stepTiming.staggering, 35 | timings, 36 | stepTiming.duration 37 | ); 38 | } 39 | return timings; 40 | } 41 | 42 | function enumStepComputeTiming(enumerator, stepTiming) { 43 | // staggering 44 | let timings = enumerator.allKeys.map((d, i) => { 45 | let datum_i = enumerator.getDatum(d, 0); 46 | let datum_f = enumerator.getDatum(d, 0); 47 | for (let k = 1; k < enumerator.stopN; k++) { 48 | datum_i = datum_i || enumerator.getDatum(d, 0); 49 | datum_f = enumerator.getDatum(d, k) || datum_f; 50 | } 51 | 52 | return { 53 | initial: datum_i, 54 | final: datum_f, 55 | set: "update", // Todo 56 | id: i, 57 | key: d, 58 | duration: stepTiming.duration, 59 | delay: stepTiming.delay 60 | }; 61 | }); 62 | 63 | if (stepTiming.staggering) { 64 | timings = staggeredTiming( 65 | stepTiming.staggering, 66 | timings, 67 | stepTiming.duration 68 | ); 69 | } 70 | timings = timings.sort((a, b) => a.id - b.id); 71 | return timings; 72 | } 73 | 74 | export { computeTiming, enumStepComputeTiming }; 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gemini", 3 | "version": "0.1.0", 4 | "description": "Animate the transition between two Vega visualizations with a declrative high-level spec.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "quick-build": "rollup -c", 9 | "build": "npm run lint && rollup -c", 10 | "lint": "eslint '{src, test}/**/*.js'", 11 | "watch": "./test/setupData.sh && jest --watch" 12 | }, 13 | "author": "Younghoon Kim", 14 | "license": "BSD-3-Clause", 15 | "devDependencies": { 16 | "@babel/core": "^7.7.4", 17 | "@babel/plugin-transform-modules-commonjs": "^7.7.4", 18 | "@babel/preset-env": "^7.7.4", 19 | "babel-jest": "^24.9.0", 20 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 21 | "babel-preset-es2015": "^6.24.1", 22 | "canvas": "^2.5.0", 23 | "css-loader": "^3.2.0", 24 | "eslint": "^6.8.0", 25 | "eslint-config-airbnb": "^17.1.0", 26 | "eslint-config-prettier": "^6.5.0", 27 | "eslint-plugin-import": "^2.18.0", 28 | "eslint-plugin-jest": "^23.8.2", 29 | "eslint-plugin-jsx-a11y": "^6.2.1", 30 | "eslint-plugin-prettier": "^3.1.0", 31 | "eslint-plugin-react": "^7.14.2", 32 | "jest": "^24.8.0", 33 | "jest-canvas-mock": "^2.2.0", 34 | "prettier": "^1.19.1", 35 | "rollup": "^1.27.0", 36 | "rollup-plugin-babel": "^4.4.0", 37 | "rollup-plugin-commonjs": "^10.1.0", 38 | "rollup-plugin-json": "^4.0.0", 39 | "rollup-plugin-node-resolve": "^5.0.4", 40 | "rollup-plugin-sourcemaps": "^0.5.0", 41 | "style-loader": "^1.0.0", 42 | "terser": "^4.6.7", 43 | "vega-datasets": "^2.1.0", 44 | "vega-embed": "^6.12.2", 45 | "webpack": "4.39.2", 46 | "webpack-bundle-analyzer": "^3.6.0", 47 | "webpack-cli": "^3.3.10", 48 | "webpack-dev-server": "^3.9.0" 49 | }, 50 | "dependencies": { 51 | "ajv": "^6.12.0", 52 | "d3": "^6.7", 53 | "d3-interpolate-path": "^2.1.1", 54 | "d3-selection-multi": "^1.0.1", 55 | "graphscape": "^1.1.0", 56 | "vega": "^5.20.2", 57 | "vega-expression": "^2.6.2", 58 | "vega-lite": "^4.17.0", 59 | "vega-parser": "^5.11.0" 60 | }, 61 | "files": [ 62 | "gemini.js", 63 | "gemini.web.js", 64 | ".yalc/" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /test/recommender/markCompChecker.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import {checkMarkComp} from "../../src/recommender/markCompChecker"; 3 | 4 | describe("checkMarkComp", () => { 5 | test("should find illegal encode-marktype combination. ", () => { 6 | let checkResult = checkMarkComp({marktype: "text", encode: {}}); 7 | expect(checkResult.reasons).toEqual(["encode", "marktype"]); 8 | 9 | checkResult = checkMarkComp({marktype: "text", encode: {text: {value: "hi"}}}); 10 | expect(checkResult.result).toEqual(true); 11 | 12 | checkResult = checkMarkComp({ 13 | marktype: "area", 14 | encode: {x: {}, y: {}} 15 | }); 16 | expect(checkResult.result).toEqual(false); 17 | }); 18 | test("should find illegal marktype", () => { 19 | let checkResult = checkMarkComp({ 20 | marktype: undefined, 21 | 22 | }); 23 | expect(checkResult.result).toEqual(false); 24 | }); 25 | test("should find illegal encode-data combination. ", () => { 26 | let checkResult = checkMarkComp({ 27 | marktype: "symbol", 28 | encode: { x: { field: "A" }}, 29 | data: {fields: ["B"]} 30 | }); 31 | expect(checkResult.reasons).toEqual(["encode", "data"]); 32 | }); 33 | 34 | test("should find illegal encode-scale combination. ", () => { 35 | let checkResult = checkMarkComp({ 36 | marktype: "symbol", 37 | encode: { x: { field: "A", scale: "x" }}, 38 | scales: { y: { } }, 39 | data: { fields: ["A"], values: [{"datum": {"A": 12}}]} 40 | }); 41 | expect(checkResult.reasons).toEqual(["encode", "scale"]); 42 | }); 43 | 44 | test("should find illegal encode-scale-data combination. ", () => { 45 | let checkResult = checkMarkComp({ 46 | marktype: "symbol", 47 | encode: { x: { field: "A", scale: "x" }}, 48 | scales: { x: { domain: () => [12, 14], type: "linear"} }, 49 | data: { fields: ["A"], values: [{"datum": {"A": 12}}, {"datum": {"A": 15}}]} 50 | }); 51 | expect(checkResult.reasons).toEqual(["encode", "data", "scale"]); 52 | 53 | checkResult = checkMarkComp({ 54 | marktype: "symbol", 55 | encode: { x: { field: "A", scale: "x" }}, 56 | scales: { x: { domain: () => [12, 20], type: "linear"} }, 57 | data: { fields: ["A"], values: [{"datum": {"A": 12}}, {"datum": {"A": 15}}]} 58 | }); 59 | expect(checkResult.reasons).toEqual(undefined); 60 | }); 61 | }); 62 | 63 | -------------------------------------------------------------------------------- /src/actuator/index.js: -------------------------------------------------------------------------------- 1 | import { markInterpolate } from "./mark/mark"; 2 | import { areaLineInterpolate } from "./mark/areaLine"; 3 | import { axisInterpolate } from "./axis"; 4 | import { legendInterpolate } from "./legend"; 5 | import { viewInterpolate } from "./view"; 6 | import { isLinearMarktype} from "./util"; 7 | 8 | const LIBRARY = { 9 | legend: legendInterpolate, 10 | axis: axisInterpolate, 11 | mark: { 12 | interpolate: { 13 | others: markInterpolate, 14 | areaLine: areaLineInterpolate 15 | }, 16 | marktypeChange 17 | }, 18 | view: viewInterpolate, 19 | pause: step => { 20 | return new Promise((resolve) => { 21 | setTimeout(function() { 22 | resolve(); 23 | }, step.duration + step.delay); 24 | }); 25 | } 26 | }; 27 | export default function(step) { 28 | let template; 29 | const { marktypes } = step; 30 | if (step.compType === "mark") { 31 | if ( 32 | marktypes.final && 33 | marktypes.initial && 34 | marktypes.initial !== step.marktypes.final 35 | ) { 36 | template = LIBRARY.mark.marktypeChange; 37 | } else if ( 38 | isLinearMarktype(marktypes.initial) || isLinearMarktype(marktypes.final) 39 | ) { 40 | template = LIBRARY.mark.interpolate.areaLine; 41 | } else { 42 | template = LIBRARY.mark.interpolate.others; 43 | } 44 | } else { 45 | template = LIBRARY[step.compType]; 46 | } 47 | 48 | return template; 49 | } 50 | 51 | async function marktypeChange(rawInfo, step, targetElm) { 52 | const mTypeI = step.marktypes.initial; 53 | const mTypeF = step.marktypes.final; 54 | if ( isLinearMarktype(mTypeF) && isLinearMarktype(mTypeI)) { 55 | return LIBRARY.mark.interpolate.areaLine(rawInfo, step, targetElm); 56 | } 57 | if ( 58 | ( isLinearMarktype(mTypeI) && ["rule", "rect", "symbol", "text"].indexOf(mTypeF) >= 0) || 59 | ( isLinearMarktype(mTypeF) && ["rule", "rect", "symbol", "text"].indexOf(mTypeI) >= 0) 60 | ) { 61 | return Promise.all([ 62 | LIBRARY.mark.interpolate.others(rawInfo, step, targetElm), 63 | LIBRARY.mark.interpolate.areaLine(rawInfo, step, targetElm) 64 | ]); 65 | } 66 | return LIBRARY.mark.interpolate.others(rawInfo, step, targetElm); 67 | } 68 | export function testInterpolator(step, state) { 69 | return new Promise((resolve) => { 70 | setTimeout(function() { 71 | const nextState = `${state} ${step.compName}`; 72 | resolve(nextState); 73 | }, step.duration + step.delay); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/actuator/view.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { transformItem } from "./vega-render-util"; 3 | import { getEaseFn } from "./util.js"; 4 | import { fetchAttributes } from "./attributeFetcher"; 5 | 6 | function viewInterpolate(rawInfo, step, targetElm) { 7 | const animVis = targetElm; 8 | 9 | return new Promise((resolve) => { 10 | const easeFn = getEaseFn(step.timing.ease); 11 | 12 | if (step.change.signal === false) { 13 | resolve(); 14 | } 15 | 16 | const view = d3.select(`${animVis} svg`); 17 | const svgEncode = step.encodes.final.svg; 18 | // update svg 19 | // When just applying attr("width", ...), the size of the chart jitters. 20 | view 21 | .transition() 22 | .tween("resize", function() { 23 | const w = d3.interpolate( 24 | this.getAttribute("width"), 25 | svgEncode.width.value 26 | ); 27 | const h = d3.interpolate( 28 | this.getAttribute("height"), 29 | svgEncode.height.value 30 | ); 31 | return function(t) { 32 | const _w = Math.round(w(t) * 1) / 1; 33 | const _h = Math.round(h(t) * 1) / 1; 34 | this.setAttribute("width", _w); 35 | this.setAttribute("height", _h); 36 | this.setAttribute("viewBox", `0 0 ${_w} ${_h}`); 37 | }; 38 | }) 39 | .duration(step.duration) 40 | .delay(step.delay) 41 | .ease(easeFn) 42 | .end() 43 | .then(() => { 44 | resolve(); 45 | }); 46 | 47 | // update svg > g 48 | view 49 | .select("g") 50 | .transition() 51 | .attr( 52 | "transform", 53 | transformItem({ x: svgEncode.x.value, y: svgEncode.y.value }) 54 | ) 55 | .duration(step.duration) 56 | .delay(step.delay) 57 | .ease(easeFn); 58 | 59 | // update background 60 | let root = view.select(".root g > .background"); 61 | const rootEncode = step.encodes.final.root; 62 | const fDatum = Object.keys(rootEncode).reduce((fDatum, key) => { 63 | fDatum[key] = rootEncode[key].value; 64 | return fDatum; 65 | }, {}); 66 | root = root.data([fDatum]).transition(); 67 | fetchAttributes( 68 | root, 69 | ["background", "fill", "stroke"], 70 | {}, 71 | step.signals.final, 72 | rootEncode 73 | ); 74 | 75 | root 76 | .duration(step.duration) 77 | .delay(step.delay) 78 | .ease(easeFn); 79 | 80 | // update frame 81 | }); 82 | } 83 | 84 | export { viewInterpolate }; 85 | -------------------------------------------------------------------------------- /test/recommender/pseudoTimelineValidator.test.js: -------------------------------------------------------------------------------- 1 | import { checkViewAxisConstraint, validate, checkUnempty } from "../../src/recommender/pseudoTimelineValidator"; 2 | import { MIN_POS_DELTA } from "../../src/recommender/util"; 3 | 4 | describe("checkUnempty", () => { 5 | test("should detect the pseudo timeline having any empty stage. ", () => { 6 | let pseudoTimeline = { 7 | concat: [ 8 | { 9 | sync: [ 10 | {diff: {compType: "mark"}, factorSets: {current: ["scale.shape", "scale.color"]}}, 11 | {diff: {compType: "legend"}, factorSets: {current: ["scale.color"]}}, 12 | {diff: {compType: "axis"}, factorSets: {current: ["remove.y"]}} 13 | ] 14 | } 15 | ] 16 | } 17 | expect(checkUnempty(pseudoTimeline)).toEqual(true) 18 | pseudoTimeline.concat.push({sync: []}); 19 | expect(checkUnempty(pseudoTimeline)).toEqual(false) 20 | }); 21 | }); 22 | 23 | describe("checkViewAxisConstraint", () => { 24 | test("should detect the pseudo timeline having any empty stage. ", () => { 25 | 26 | 27 | let pseudoTimeline = { 28 | concat: [ 29 | { 30 | sync: [ 31 | { 32 | diff: { 33 | compType: "axis", 34 | compName: "y", 35 | initial: {orient: "left"}, 36 | meta: {view: {y: MIN_POS_DELTA} } 37 | } 38 | } 39 | ] 40 | }, 41 | { 42 | sync: [ 43 | { 44 | diff: { 45 | compType: "axis", 46 | compName: "x", 47 | initial: {orient: "bottom"}, 48 | meta: {view: {y: MIN_POS_DELTA} } 49 | } 50 | } 51 | ] 52 | } 53 | ] 54 | } 55 | expect(checkViewAxisConstraint(pseudoTimeline)).toEqual(false) 56 | 57 | pseudoTimeline = { 58 | concat: [ 59 | { 60 | sync: [ 61 | { 62 | diff: { 63 | compType: "mark", 64 | meta: {view: {y: -MIN_POS_DELTA} } 65 | } 66 | } 67 | ] 68 | }, 69 | { 70 | sync: [ 71 | { 72 | diff: { 73 | compType: "axis", 74 | compName: "x", 75 | initial: {orient: "bottom"}, 76 | meta: {view: {y: -MIN_POS_DELTA} } 77 | } 78 | } 79 | ] 80 | } 81 | ] 82 | } 83 | expect(checkViewAxisConstraint(pseudoTimeline)).toEqual(true) 84 | }); 85 | }); -------------------------------------------------------------------------------- /test/recommender/pseudoTimelineEvaluator.test.js: -------------------------------------------------------------------------------- 1 | import { getComboCost, getCost } from "../../src/recommender/pseudoTimelineEvaluator"; 2 | import { PERCEPTION_COST } from "../../src/recommender/designGuidelines"; 3 | 4 | 5 | describe("getComboCost", () => { 6 | test("should calculate the total discount of the given pseudo timeline. ", () => { 7 | let pseudoStage = { 8 | sync: [ 9 | {diff: {compType: "mark"}, factorSets: {current: ["scale.y"]}}, 10 | ] 11 | } 12 | 13 | expect(getComboCost(pseudoStage.sync)).toEqual(0) 14 | 15 | let sameDomainTest = { domainSpaceDiff: false }; 16 | 17 | pseudoStage = { 18 | sync: [ 19 | { 20 | diff: {compType: "mark", meta: { scale: { y: sameDomainTest } }}, 21 | factorSets: {current: ["scale.y", "encode.y"]} 22 | }, 23 | { 24 | diff: {compType: "axis", meta: { scale: { y: sameDomainTest } }}, 25 | factorSets: {current: ["scale.y"]} 26 | } 27 | ] 28 | } 29 | expect(getComboCost(pseudoStage.sync)).toEqual(-0.5) 30 | sameDomainTest.domainSpaceDiff = true; 31 | expect(getComboCost(pseudoStage.sync)).toEqual(-1) 32 | 33 | pseudoStage = { 34 | sync: [ 35 | {diff: {compType: "mark"}, factorSets: {current: ["scale.shape", "scale.color"]}}, 36 | {diff: {compType: "legend"}, factorSets: {current: ["remove.color_shape"]}} 37 | ] 38 | } 39 | expect(getComboCost(pseudoStage.sync)).toEqual(-0.5 - 0.5 - 0.1) 40 | 41 | 42 | 43 | 44 | }); 45 | }); 46 | 47 | 48 | describe("getCost", () => { 49 | test("should calculate the total cost of the given pseudo step. ", () => { 50 | let pseudoStage = { 51 | sync: [ 52 | {diff: {compType: "mark"}, factorSets: {current: ["scale.shape", "scale.color"]}}, 53 | {diff: {compType: "legend"}, factorSets: {current: ["scale.color"]}}, 54 | { 55 | diff: { 56 | compType: "axis", 57 | meta: {scale: {y: {domainSpaceDiff: true}}} 58 | }, 59 | factorSets: {current: ["scale.y"]} 60 | } 61 | ] 62 | } 63 | const scaleCost = PERCEPTION_COST.mark.find(cond => cond.factor === "scale.shape" && !cond.with).cost; 64 | expect(getCost(pseudoStage.sync[0])).toEqual(scaleCost * 2); 65 | const legendScaleCost = PERCEPTION_COST.legend.find(cond => cond.factor === "scale" && !cond.with).cost 66 | expect(getCost(pseudoStage.sync[1])).toEqual(legendScaleCost); 67 | const axisScaleCost = PERCEPTION_COST.axis.find(cond => cond.factor === "scale" && !cond.with).cost 68 | expect(getCost(pseudoStage.sync[2])).toEqual(axisScaleCost); 69 | }); 70 | }); -------------------------------------------------------------------------------- /src/schedule.js: -------------------------------------------------------------------------------- 1 | class Schedule { 2 | constructor(parsedSteps) { 3 | // Assgin the sTime and eTime 4 | 5 | let newParsedSteps = parsedSteps.map((stp, i) => { 6 | return { ...stp, stepId: i }; 7 | }); 8 | 9 | this.tracks = newParsedSteps 10 | .map(d => { 11 | const trackName = (d.trackName = d.compName 12 | ? `${d.compType}.${d.compName}` 13 | : d.compType); 14 | 15 | return { 16 | name: trackName, 17 | compType: d.compType, 18 | compName: d.compName 19 | }; 20 | }) 21 | .unique(d => d.name) 22 | .map(track => { 23 | return { 24 | ...track, 25 | steps: newParsedSteps.filter(d => d.trackName === track.name) 26 | }; 27 | }); 28 | } 29 | 30 | 31 | getTimeline(alterId) { 32 | return this.tracks.map(track => { 33 | return Object.assign({}, track, { 34 | steps: track.steps.filter( 35 | step => step.alterId === undefined || step.alterId.indexOf(alterId) >= 0 36 | ) 37 | }); 38 | }); 39 | } 40 | 41 | getTimelineAlternator(scaleOrderResovles) { 42 | let counter = 0; 43 | let dividers = scaleOrderResovles.reduce( 44 | (acc, r, i) => { 45 | acc.push(r.alterIds.length * acc[i]); 46 | return acc; 47 | }, 48 | [1] 49 | ); 50 | const totalCount = dividers[dividers.length - 1]; 51 | dividers = dividers.slice(0, dividers.length - 1).sort((a, b) => b - a); 52 | return () => { 53 | counter += 1; 54 | counter %= totalCount; 55 | if (counter === 0) { 56 | console.warn("Gemini cannot find the order to resolve."); 57 | return false; 58 | } 59 | return dividers.reduce( 60 | (acc, divider, i) => { 61 | const q = Math.floor(acc.remainder / divider); 62 | acc.remainder -= q * divider; 63 | const resolve = scaleOrderResovles[i]; 64 | 65 | const alterId = resolve.alterIds[q]; 66 | 67 | acc.tracks = acc.tracks.map(track => { 68 | const newSteps = track.steps.filter(step => { 69 | if (step.alterId === undefined) { 70 | return true; 71 | } 72 | if ( 73 | step.alterId.split(":")[0] === alterId.split(":")[0] && 74 | step.alterId.split(":")[1] !== alterId.split(":")[1] 75 | ) { 76 | return false; 77 | } 78 | 79 | return true; 80 | }); 81 | return Object.assign({}, track, { steps: newSteps }); 82 | }); 83 | 84 | return acc; 85 | }, 86 | { tracks: this.tracks, remainder: counter } 87 | ).tracks; 88 | }; 89 | } 90 | 91 | 92 | } 93 | 94 | 95 | 96 | 97 | 98 | export {Schedule}; -------------------------------------------------------------------------------- /test/examples/sequence/input-mergedScale.json: -------------------------------------------------------------------------------- 1 | { 2 | "charts":[ 3 | { 4 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 5 | "data": { 6 | "values": [ 7 | {"Hungry": 10, "Name": "Gemini"}, 8 | {"Hungry": 60, "Name": "Cordelia"}, 9 | {"Hungry": 80, "Name": "Gemini"}, 10 | {"Hungry": 100, "Name": "Cordelia"}, 11 | {"Hungry": 40, "Name": "Mango"}, 12 | {"Hungry": 100, "Name": "Mango"} 13 | ] 14 | }, 15 | "mark": "point", 16 | "encoding": { 17 | "x": { "field": "Hungry", "type": "quantitative"}, 18 | "y": { "field": "Name", "type": "nominal"} 19 | } 20 | }, 21 | { 22 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 23 | "data": { 24 | "values": [ 25 | {"Hungry": 10, "Name": "Gemini"}, 26 | {"Hungry": 60, "Name": "Cordelia"}, 27 | {"Hungry": 80, "Name": "Gemini"}, 28 | {"Hungry": 100, "Name": "Cordelia"}, 29 | {"Hungry": 40, "Name": "Mango"}, 30 | {"Hungry": 100, "Name": "Mango"} 31 | ] 32 | }, 33 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 34 | "mark": "point", 35 | "encoding": { 36 | "x": { "field": "Hungry", "type": "quantitative", "aggregate": "mean"}, 37 | "y": { "field": "Name", "type": "nominal"} 38 | } 39 | } 40 | ], 41 | "gemSpecs": [ 42 | { 43 | "timeline": { 44 | "sync": [ 45 | { 46 | "component": {"axis": "x"}, 47 | "change": {"scale": {"domainDimension": "same"}}, 48 | "timing": {"duration": 1000} 49 | }, 50 | { 51 | "component": {"axis": "y"}, 52 | "change": {"scale": {"domainDimension": "same"}}, 53 | "timing": {"duration": 1000} 54 | }, 55 | { 56 | "component": "view", "timing": {"duration": 1000} 57 | }, 58 | { 59 | "component": {"mark": "marks"}, 60 | "change": {"data": ["Name"]}, 61 | "timing": {"duration": 1000} 62 | } 63 | ] 64 | } 65 | }, 66 | { 67 | "timeline": { 68 | "sync": [ 69 | { 70 | "component": {"axis": "x"}, 71 | "change": {"scale": {"domainDimension": "same"}}, 72 | "timing": {"duration": 1000} 73 | }, 74 | { 75 | "component": "view", "timing": {"duration": 1000} 76 | }, 77 | { 78 | "component": {"axis": "y"}, 79 | "change": {"scale": {"domainDimension": "same"}}, 80 | "timing": {"duration": 1000} 81 | }, 82 | { 83 | "component": {"mark": "marks"}, 84 | "change": {"data": ["Name"]}, 85 | "timing": {"duration": 1000} 86 | } 87 | ] 88 | } 89 | } 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /src/recommender/util.js: -------------------------------------------------------------------------------- 1 | import { copy } from "../util/util"; 2 | 3 | const MIN_POS_DELTA = 3; 4 | const CHANNEL_TO_ATTRS = [ 5 | { channel: "x", attrs: ["x", "x2", "xc", "width"] }, 6 | { channel: "y", attrs: ["y", "y2", "yc", "height"] }, 7 | { channel: "color", attrs: ["stroke", "fill"] }, 8 | { channel: "shape", attrs: ["shape"] }, 9 | { channel: "size", attrs: ["size"] }, 10 | { channel: "opacity", attrs: ["opacity"] }, 11 | { channel: "text", attrs: ["text"] }, 12 | { channel: "others", attrs: ["tooltip", "define", "strokeWidth"] } 13 | ]; 14 | const CHANNELS = ["x", "y", "color", "shape", "size", "opacity", "text"]; 15 | 16 | const CHANNEL_TO_ATTRS_OBJ = { 17 | x: ["x", "x2", "xc", "width"], 18 | y: ["y", "y2", "yc", "height"], 19 | color: ["stroke", "fill"], 20 | shape: ["shape"], 21 | size: ["size"], 22 | opacity: ["opacity"], 23 | text: ["text"], 24 | others: ["tooltip", "define", "strokeWidth"] 25 | }; 26 | 27 | function getSubEncodeByChannel(encode, channel) { 28 | const subEncode = {}; 29 | if (channel === "others") { 30 | const otherEncode = copy(encode); 31 | CHANNEL_TO_ATTRS.reduce((channelRelatedAttrs, ch2Attrs) => { 32 | return (channelRelatedAttrs = channelRelatedAttrs.concat(ch2Attrs.attrs)); 33 | }, []).forEach(attr => { 34 | delete otherEncode[attr]; 35 | }); 36 | 37 | return otherEncode; 38 | } 39 | 40 | CHANNEL_TO_ATTRS_OBJ[channel] 41 | // .filter(attr => encode[attr]) 42 | .forEach(attr => { 43 | subEncode[attr] = encode[attr]; 44 | }); 45 | return subEncode; 46 | } 47 | function getCoreAttr(subEncode, channel, marktype){ 48 | if (!subEncode) { 49 | return; 50 | } 51 | if (channel === "color") { 52 | let coreAttr = ["line", "rule", "symbol"].indexOf(marktype) >= 0 53 | ? subEncode.stroke 54 | : subEncode.fill; 55 | 56 | if ( 57 | coreAttr === "symbol" && 58 | subEncode.fill && 59 | subEncode.fill.value !== "transparent" 60 | ) { 61 | coreAttr = subEncode.fill; 62 | } 63 | 64 | return coreAttr; 65 | } 66 | if (channel==="x") { 67 | return subEncode.x || subEncode.xc; 68 | } else if (channel==="y") { 69 | return subEncode.y || subEncode.yc; 70 | } 71 | return subEncode[channel]; 72 | } 73 | 74 | function setUpRecomOpt(opt) { 75 | let _opt = copy(opt); 76 | _opt.axes = _opt.axes || {}; 77 | for (const scaleName in _opt.scales || {}) { 78 | _opt.axes[scaleName] = _opt.axes[scaleName] || {}; 79 | _opt.axes[scaleName].change = _opt.axes[scaleName].change || {}; 80 | _opt.axes[scaleName].change.scale = _opt.axes[scaleName].change.scale || {}; 81 | if (_opt.axes[scaleName].change.scale !== false) { 82 | _opt.axes[scaleName].change.scale.domainDimension = _opt.scales[scaleName].domainDimension; 83 | } 84 | } 85 | return _opt 86 | } 87 | 88 | export { 89 | CHANNELS, 90 | CHANNEL_TO_ATTRS_OBJ, 91 | MIN_POS_DELTA, 92 | getSubEncodeByChannel, 93 | getCoreAttr, 94 | setUpRecomOpt 95 | }; 96 | -------------------------------------------------------------------------------- /test/recommender/designGuidelines.test.js: -------------------------------------------------------------------------------- 1 | import { PERCEPTION_COST as PC, DISCOUNT_COMBOS, PENALTY_COMBOS } from "../../src/recommender/designGuidelines"; 2 | 3 | describe("PERCEPTION_COST", () => { 4 | describe("for mark compoents", () => { 5 | const PC_mark = PC.mark; 6 | test("should be consist with GraphScape.", () => { 7 | 8 | // marktype e.o. < any transform e.o. 9 | expect(PC_mark.find(cond => cond.factor === "marktype").cost) 10 | .toBeLessThan(PC_mark.find(cond => cond.factor === "data").cost); 11 | 12 | expect(PC_mark.find(cond => cond.factor === "marktype").cost) 13 | .toBeLessThan(PC_mark.find(cond => cond.factor === "scale.size" && cond.with).cost); 14 | 15 | // scale e.o. < the other transform e.o. 16 | expect(PC_mark.find(cond => cond.factor === "scale.size" && cond.with).cost) 17 | .toBeLessThan(PC_mark.find(cond => cond.factor === "data" ).cost); 18 | 19 | // any transform e.o < encoding e.o. (scale + encode) 20 | const ENCODING_X_cost = PC_mark.find(cond => cond.factor === "scale.x" && !cond.with).cost 21 | + PC_mark.find(cond => cond.factor === "encode.x").cost 22 | 23 | expect(PC_mark.find(cond => cond.factor === "data").cost) 24 | .toBeLessThan(ENCODING_X_cost); 25 | // expect(PC_mark.find(cond => cond.factor === "marktype").cost) 26 | // .toBeLessThan(PC_mark.find(cond => cond.factor === "data").cost); 27 | }); 28 | }); 29 | 30 | describe("for axis compoents", () => { 31 | const PC_axis = PC.axis; 32 | test("should be consist with GraphScape.", () => { 33 | // axis.encode indicates just minor look changes 34 | expect(PC_axis.find(cond => cond.factor === "encode").cost) 35 | .toBeLessThan(PC_axis.find(cond => cond.factor === "scale" && cond.with).cost); 36 | 37 | // any transform e.o < encoding e.o. (scale + encode) 38 | expect(PC_axis.find(cond => cond.factor === "scale" && cond.with).cost) 39 | .toBeLessThan(PC_axis.find(cond => cond.factor === "add").cost); 40 | 41 | // add,remvoe < modify 42 | expect(PC_axis.find(cond => cond.factor === "add").cost) 43 | .toBeLessThan(PC_axis.find(cond => cond.factor === "scale" && !cond.with).cost); 44 | }); 45 | }); 46 | 47 | describe("for legend compoents", () => { 48 | const PC_legend = PC.legend; 49 | test("should be consist with GraphScape.", () => { 50 | // legend.encode indicates just minor look changes 51 | expect(PC_legend.find(cond => cond.factor === "encode").cost) 52 | .toBeLessThan(PC_legend.find(cond => cond.factor === "scale" && cond.with).cost); 53 | 54 | // any transform e.o < encoding e.o. (scale + encode) 55 | expect(PC_legend.find(cond => cond.factor === "scale" && cond.with).cost) 56 | .toBeLessThan(PC_legend.find(cond => cond.factor === "add").cost); 57 | 58 | // add,remvoe < modify 59 | expect(PC_legend.find(cond => cond.factor === "add").cost) 60 | .toBeLessThan(PC_legend.find(cond => cond.factor === "scale" && !cond.with).cost); 61 | }); 62 | }); 63 | }) 64 | 65 | 66 | -------------------------------------------------------------------------------- /test/recommender/diffApplier.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { default as EXAMPLES } from "../exampleLoader.js"; 3 | import { applyMarkDiffs } from "../../src/recommender/diffApplier"; 4 | import { detectDiffs } from "../../src/recommender/diffDetector"; 5 | import * as vega from "vega"; 6 | 7 | describe("applyDiff", () => { 8 | test("should apply and get the correct markCompSummary.", () => { 9 | let sView = new vega.View(vega.parse(EXAMPLES.zoomingOut.sSpec), { renderer: "svg" }); 10 | let eView = new vega.View(vega.parse(EXAMPLES.zoomingOut.eSpec), { renderer: "svg" }); 11 | //run toSVG to get view.scale("...") 12 | sView.toSVG().then(result => { }); 13 | eView.toSVG().then(result => { }); 14 | 15 | const sVis = { spec: EXAMPLES.zoomingOut.sSpec, view: sView }; 16 | const eVis = { spec: EXAMPLES.zoomingOut.eSpec, view: eView }; 17 | const detected = detectDiffs( { sVis, eVis } ); 18 | let markDiff = detected.compDiffs[3]; 19 | let markCompSummary = applyMarkDiffs(markDiff, [], { sVis, eVis }); 20 | 21 | 22 | expect(markCompSummary.marktype).toEqual("line"); 23 | expect(markCompSummary.data.hasFacet).toEqual(true); 24 | expect(markCompSummary.data.fields).toEqual(["date", "profit", "store"]); 25 | expect(markCompSummary.encode.opacity).toEqual(undefined); 26 | expect(markCompSummary.encode.y).toEqual({field: "profit", scale: "y"}); 27 | expect(markCompSummary.scales.y.domain()).toEqual([0, 18]); 28 | 29 | markCompSummary = applyMarkDiffs(markDiff, ["marktype", "data", "encode.opacity", "scale.y"], { sVis, eVis }, ["encode.color"]); 30 | 31 | expect(markCompSummary.marktype).toEqual("symbol"); 32 | expect(markCompSummary.data.hasFacet).toEqual(false); 33 | expect(markCompSummary.data.fields).toEqual(["date", "profit", "store"]); 34 | expect(markCompSummary.encode.opacity).toEqual({value: 0.7}); 35 | expect(markCompSummary.encode.fill).toEqual({value: "transparent"}); 36 | expect(markCompSummary.encode.y).toEqual({field: "profit", scale: "y"}); 37 | expect(markCompSummary.scales.y.domain()).toEqual([0, 30]); 38 | }); 39 | 40 | test("should apply the scale.y correctly.", () => { 41 | let sView = new vega.View(vega.parse(EXAMPLES.addYAxis.sSpec), { renderer: "svg" }); 42 | let eView = new vega.View(vega.parse(EXAMPLES.addYAxis.eSpec), { renderer: "svg" }); 43 | //run toSVG to get view.scale("...") 44 | sView.toSVG().then(result => { }); 45 | eView.toSVG().then(result => { }); 46 | 47 | const sVis = { spec: EXAMPLES.addYAxis.sSpec, view: sView }; 48 | const eVis = { spec: EXAMPLES.addYAxis.eSpec, view: eView }; 49 | const detected = detectDiffs( { sVis, eVis } ); 50 | let markDiff = detected.compDiffs[1]; 51 | let markCompSummary = applyMarkDiffs(markDiff, [], { sVis, eVis }); 52 | expect(markCompSummary.marktype).toEqual("symbol"); 53 | expect(markCompSummary.data.hasFacet).toEqual(false); 54 | expect(markCompSummary.encode.y.field).toEqual(undefined); 55 | expect(markCompSummary.scales.y).toEqual(undefined); 56 | 57 | markCompSummary = applyMarkDiffs(markDiff, ["scale.y"], { sVis, eVis }); 58 | expect(markCompSummary.scales.y.domain()).toEqual([0, 16]); 59 | 60 | }); 61 | }) 62 | 63 | -------------------------------------------------------------------------------- /src/parser/conflictChecker.js: -------------------------------------------------------------------------------- 1 | export default function check(schedule, resolves) { 2 | // 3. check if there is any component whose steps are overlapped by themselves. 3 | let tracksPerAlterId = [{ alterId: ":main", tracks: schedule.tracks }]; 4 | if (resolves.length > 0) { 5 | tracksPerAlterId = resolves 6 | .reduce((allAlterIds, resolve) => { 7 | return allAlterIds.concat(resolve.alterIds); 8 | }, []) 9 | .map(alterId => { 10 | return { 11 | tracks: schedule.tracks.map(track => { 12 | return { 13 | ...track, 14 | steps: track.steps.filter( 15 | stp => !stp.alterId || alterId === stp.alterId 16 | ) 17 | }; 18 | }), 19 | alterId 20 | }; 21 | }); 22 | } 23 | const conflictsPerAlterId = tracksPerAlterId.map(findConflicts); 24 | const conflictedAlterIds = conflictsPerAlterId 25 | .filter(conflicts => conflicts.length > 0) 26 | .map(conflicts => conflicts.alterId); 27 | 28 | if (conflictedAlterIds.length === tracksPerAlterId.length) { 29 | if (conflictsPerAlterId.length > 1) { 30 | throw new Error( 31 | "All possible timelines have 1+ schedule conflict.", 32 | conflictsPerAlterId 33 | ); 34 | } else { 35 | throw new Error( 36 | "The timeline has 1+ schedule conflict.", 37 | conflictsPerAlterId 38 | ); 39 | } 40 | } else if (conflictedAlterIds.length > 0) { 41 | if ( 42 | conflictsPerAlterId.find(conflicts => 43 | conflicts.find(conf => conf.alterId.indexOf(":main") >= 0) 44 | ) 45 | ) { 46 | console.warn( 47 | "The main timeline (specified timeline) has 1+ schedule conflict.", 48 | conflictsPerAlterId 49 | ); 50 | } else { 51 | console.warn( 52 | "Some possible timelines have 1+ schedule conflict.", 53 | conflictsPerAlterId 54 | ); 55 | } 56 | } 57 | schedule.tracks = schedule.tracks.map(track => { 58 | return { 59 | ...track, 60 | steps: track.steps.filter( 61 | stp => conflictedAlterIds.indexOf(stp.alterId) < 0 62 | ) 63 | }; 64 | }); 65 | resolves = resolves.map(resolve => { 66 | return { 67 | ...resolve, 68 | alterIds: resolve.alterIds.filter( 69 | id => conflictedAlterIds.indexOf(id) < 0 70 | ) 71 | }; 72 | }); 73 | 74 | return {conflictsPerAlterId}; 75 | } 76 | 77 | 78 | function findConflicts(tracksWithAlterId) { 79 | const conflicts = []; 80 | const { tracks } = tracksWithAlterId; 81 | const { alterId } = tracksWithAlterId; 82 | 83 | for (const track of tracks) { 84 | const sortedSteps = track.steps.sort( 85 | (stp1, stp2) => stp1.sTime - stp2.sTime 86 | ); 87 | for (let i = 0; i < sortedSteps.length - 1; i++) { 88 | if (sortedSteps[i].eTime > sortedSteps[i + 1].sTime) { 89 | conflicts.push({ 90 | alterId, 91 | conflictedSteps: [sortedSteps[i], sortedSteps[i + 1]], 92 | compName: track.compName, 93 | compType: track.compType 94 | }); 95 | } 96 | } 97 | } 98 | 99 | return conflicts; 100 | } -------------------------------------------------------------------------------- /src/gemini.js: -------------------------------------------------------------------------------- 1 | import * as vega from "vega"; 2 | import { Animation } from "./animation"; 3 | import { parse } from "./parser"; 4 | import { attachChanges } from "./changeFetcher"; 5 | import { default as Actuator } from "./actuator"; 6 | import { autoScaleOrder } from "./resolver"; 7 | import { AnimationSequence } from "./animationSequence"; 8 | import { castVL2VG } from "./util/vl2vg4gemini"; 9 | 10 | 11 | function attachAnimTemplates(schedule) { 12 | schedule.forEach(track => { 13 | track.steps = track.steps.map(step => { 14 | const template = Actuator(step); 15 | if (!template) { 16 | console.error( 17 | `There is no such animation template for ${step.compType}.` 18 | ); 19 | } 20 | 21 | step.template = template; 22 | return step; 23 | }); 24 | }); 25 | return schedule; 26 | } 27 | 28 | class Gemini { 29 | 30 | static async animateSequence(visSequence, animSpecs) { 31 | // 1) compile the each hop 32 | const views = new Array(visSequence.length); 33 | const animations = []; 34 | for (let i = 1; i < visSequence.length; i++) { 35 | const sSpec = castVL2VG(visSequence[i-1]); 36 | const eSpec = castVL2VG(visSequence[i]); 37 | const gemSpec = animSpecs[i-1]; 38 | const sDiv = document.createElement("div"); 39 | const eDiv = document.createElement("div"); 40 | const sView = await new vega.View(vega.parse(sSpec), { 41 | renderer: "svg" 42 | }).runAsync(); 43 | const eView = await new vega.View(vega.parse(eSpec), { 44 | renderer: "svg" 45 | }).runAsync(); 46 | 47 | // create ones for replacing divs. 48 | await new vega.View(vega.parse(sSpec), { 49 | renderer: "svg" 50 | }).initialize(sDiv).runAsync(); 51 | await new vega.View(vega.parse(eSpec), { 52 | renderer: "svg" 53 | }).initialize(eDiv).runAsync(); 54 | 55 | const rawInfo = { 56 | sVis: { view: sView, spec: sSpec, htmlDiv: sDiv }, 57 | eVis: { view: eView, spec: eSpec, htmlDiv: eDiv } 58 | }; 59 | 60 | 61 | animations.push(await _animate(gemSpec, rawInfo)) 62 | 63 | if (i===1 && !views[i-1]){ 64 | views[i-1] = sView; 65 | }; 66 | if (!views[i]){ 67 | views[i] = eView; 68 | }; 69 | } 70 | 71 | return new AnimationSequence(animations); 72 | } 73 | static async animate(startVisSpec, endVisSpec, geminiSpec) { 74 | const sSpec = castVL2VG(startVisSpec), eSpec = (endVisSpec); 75 | const eView = await new vega.View(vega.parse(eSpec), { 76 | renderer: "svg" 77 | }).runAsync(); 78 | 79 | const sView = await new vega.View(vega.parse(sSpec), { 80 | renderer: "svg" 81 | }).runAsync(); 82 | 83 | const rawInfo = { 84 | sVis: { view: sView, spec: sSpec }, 85 | eVis: { view: eView, spec: eSpec } 86 | }; 87 | 88 | 89 | return await _animate(geminiSpec, rawInfo); 90 | } 91 | } 92 | async function _animate(gemSpec, rawInfo){ 93 | const { schedule, resolves } = parse(gemSpec, rawInfo); 94 | schedule.tracks = attachChanges(rawInfo, schedule.tracks); 95 | const finalTimeline = await autoScaleOrder(schedule, resolves, rawInfo); 96 | 97 | return new Animation(attachAnimTemplates(finalTimeline), rawInfo, gemSpec); 98 | } 99 | 100 | export { 101 | Gemini, 102 | attachAnimTemplates as attachAnimTemplate 103 | }; 104 | -------------------------------------------------------------------------------- /src/changeFetcher/state/compute/util.js: -------------------------------------------------------------------------------- 1 | import { copy } from "../../../util/util"; 2 | import * as vega from "vega"; 3 | function dataPreservedScale(sSpec, eSpec, scName) { 4 | const tempSpec = copy(eSpec); 5 | const scaleSpec = tempSpec.scales.find(sc => sc.name === scName); 6 | if (scaleSpec.domain.data) { 7 | const index = tempSpec.data.findIndex( 8 | d => d.name === scaleSpec.domain.data 9 | ); 10 | if (index >= 0) { 11 | tempSpec.data.splice( 12 | index, 13 | 1, 14 | sSpec.data.find(d => d.name === scaleSpec.domain.data) 15 | ); 16 | } 17 | } 18 | const tempView = new vega.View(vega.parse(tempSpec), { 19 | renderer: "none" 20 | }).run(); 21 | return tempView._runtime.scales[scName].value; 22 | } 23 | 24 | function computeKeptEncode(manualEncode, referenceEncode, set = null) { 25 | 26 | let manual = manualEncode; 27 | if (set !== null) { 28 | manual = manualEncode && manualEncode[set] ? manualEncode[set] : {}; 29 | } 30 | const ref = set !== null ? referenceEncode[set] : referenceEncode; 31 | 32 | 33 | return Object.keys(manual) 34 | .filter(attr => manual[attr] === false) 35 | .reduce((keptEncode, attr) => { 36 | keptEncode[attr] = ref[attr]; 37 | return keptEncode; 38 | }, {}); 39 | } 40 | 41 | 42 | function replacePositionAttrs(targetMarktype, targetEncode, referenceEncode) { 43 | const encode = Object.assign({}, targetEncode); 44 | const POSITION_ATTRS = ["x", "x2", "xc", "width", "y", "y2", "yc", "height"]; 45 | const replaceRules = { 46 | rect: [ 47 | {replaceBy: ["x", "x2"], }, 48 | {replaceBy: ["x", "width"], }, 49 | {replaceBy: ["xc", "width"], remove: ["x"] }, 50 | {replaceBy: ["y", "y2"], }, 51 | {replaceBy: ["y", "height"], }, 52 | {replaceBy: ["yc", "height"], remove: ["y"] } 53 | ], 54 | area: [ 55 | {replaceBy: ["x", "x2", "y"], remove: "*" }, 56 | {replaceBy: ["x", "width", "y"], remove: "*" }, 57 | {replaceBy: ["xc", "width", "y"], remove: "*" }, 58 | {replaceBy: ["y", "y2", "x"], remove: "*" }, 59 | {replaceBy: ["y", "height", "x"], remove: "*" }, 60 | {replaceBy: ["yc", "height", "x"], remove: "*" }, 61 | {replaceBy: ["x", "x2", "yc"], remove: "*" }, 62 | {replaceBy: ["x", "width", "yc"], remove: "*" }, 63 | {replaceBy: ["xc", "width", "yc"], remove: "*" }, 64 | {replaceBy: ["y", "y2", "xc"], remove: "*" }, 65 | {replaceBy: ["y", "height", "xc"], remove: "*" }, 66 | {replaceBy: ["yc", "height", "xc"], remove: "*" } 67 | ], 68 | default: [ 69 | {replaceBy: ["x"] }, 70 | {replaceBy: ["xc"], remove: ["x"] }, 71 | {replaceBy: ["y"] }, 72 | {replaceBy: ["yc"], remove: ["y"] } 73 | ] 74 | }; 75 | let rules = replaceRules[targetMarktype] || replaceRules.default; 76 | 77 | rules.forEach(rule => { 78 | const hasAll = rule.replaceBy.reduce((hasAll, attr) => { 79 | return hasAll && referenceEncode[attr]; 80 | }, true); 81 | if (hasAll) { 82 | rule.replaceBy.forEach(attr => { 83 | encode[attr] = referenceEncode[attr]; 84 | }); 85 | let removedAttrs = rule.remove || []; 86 | if (rule.remove === "*") { 87 | removedAttrs = POSITION_ATTRS.filter(attr => rule.replaceBy.indexOf(attr) < 0); 88 | } 89 | removedAttrs.forEach(attr => { 90 | delete encode[attr]; 91 | }); 92 | } 93 | }); 94 | 95 | 96 | return encode; 97 | } 98 | 99 | export { dataPreservedScale, computeKeptEncode, replacePositionAttrs }; -------------------------------------------------------------------------------- /src/recommender/pseudoTimelineEvaluator.js: -------------------------------------------------------------------------------- 1 | import { roundUp, variance, mean } from "../util/util"; 2 | import * as DG from "./designGuidelines"; 3 | 4 | function evaluate(pseudoTimeline) { 5 | const stageCosts = []; 6 | const cappedStageCosts = []; 7 | const N = pseudoTimeline.concat.length; 8 | const cost = pseudoTimeline.concat.reduce((cost, stage, i) => { 9 | const totalCost = stage.sync.reduce((sum, pseudoStep) => { 10 | pseudoStep.meta = { cost: getCost(pseudoStep) }; 11 | return sum + getCost(pseudoStep); 12 | }, 0); 13 | const comboCost = getComboCost(stage.sync); 14 | const dur = pseudoTimeline.totalDuration / pseudoTimeline.concat.length; 15 | const cap = DG.PERCEPTION_CAP(dur) * Math.pow(0.99, N - 1 - i); 16 | stage.meta = { 17 | totalCost: roundUp(totalCost), 18 | comboCost: roundUp(comboCost), 19 | cap, 20 | cost: roundUp(Math.max(totalCost + comboCost - cap, 0)) 21 | }; 22 | stageCosts.push(Math.max(totalCost + comboCost)); 23 | cappedStageCosts.push(Math.max(totalCost + comboCost - cap, 0)); 24 | return cost + Math.max(totalCost + comboCost - cap, 0); 25 | }, 0); 26 | 27 | return { 28 | cost: roundUp(cost), 29 | tiebreaker: mean(stageCosts), 30 | tiebreaker2: variance(cappedStageCosts) 31 | }; 32 | } 33 | 34 | function getComboCost(pseudoSteps) { 35 | const check = (piece, factorSet) => { 36 | return factorSet.find( 37 | fctr => fctr === piece.factor || fctr.indexOf(piece.contain) >= 0 38 | ); 39 | }; 40 | 41 | return DG.DISCOUNT_COMBOS.concat(DG.PENALTY_COMBOS).reduce( 42 | (totalDiscount, combo) => { 43 | for (const chunk of combo.chunks) { 44 | const isChunk = chunk.reduce((isChunk, piece) => { 45 | const found = pseudoSteps.find(pStep => { 46 | return ( 47 | pStep.diff.compType === piece.compType && 48 | check(piece, pStep.factorSets.current) 49 | ); 50 | }); 51 | if (!found) { 52 | return false; 53 | } 54 | 55 | if (piece.with) { 56 | isChunk = piece.with.reduce((isChunk, subCondition) => { 57 | return isChunk && subCondition(found, piece.factor); 58 | }, isChunk); 59 | } 60 | 61 | return isChunk && !!found; 62 | }, true); 63 | if (isChunk) { 64 | totalDiscount += combo.cost; 65 | break; 66 | } 67 | } 68 | return totalDiscount; 69 | }, 70 | 0 71 | ); 72 | } 73 | 74 | function getCost(pseudoStep) { 75 | // Todo 76 | if ( 77 | pseudoStep.diff.compType === "view" || 78 | pseudoStep.diff.compType === "pause" 79 | ) { 80 | return 0; 81 | } 82 | let stepCost = 0; 83 | for (const condition of DG.PERCEPTION_COST[pseudoStep.diff.compType]) { 84 | const foundFactor = pseudoStep.factorSets.current.find( 85 | fctr => fctr.indexOf(condition.factor) >= 0 86 | ); 87 | let with_without = true; 88 | if (foundFactor && condition.with) { 89 | with_without = condition.with.reduce((sat, subCondition) => { 90 | return sat && subCondition(pseudoStep, foundFactor); 91 | }, true); 92 | } else if (foundFactor && condition.without) { 93 | with_without = condition.without.reduce((sat, subCondition) => { 94 | return sat && !subCondition(pseudoStep, foundFactor); 95 | }, true); 96 | } 97 | 98 | stepCost += foundFactor && with_without ? condition.cost : 0; 99 | } 100 | return stepCost; 101 | } 102 | 103 | export { getComboCost, getCost, evaluate }; 104 | -------------------------------------------------------------------------------- /src/recommender/diffApplier.js: -------------------------------------------------------------------------------- 1 | import { getMarkData, unpackData } from "../util/vgDataHelper"; 2 | import { get } from "../util/util"; 3 | import { computeHasFacet, isGroupingMarktype } from "../changeFetcher/state/util"; 4 | import { getSubEncodeByChannel } from "./util"; 5 | 6 | function applyMarkDiffs( 7 | markDiff, 8 | applyingDiffs, 9 | rawInfo, 10 | extraDiffsByMarktypeChange = [] 11 | ) { 12 | 13 | const markCompSummary = new MarkSummary(markDiff, rawInfo); 14 | 15 | applyingDiffs.forEach(diff => { 16 | markCompSummary.applyDiff(diff, extraDiffsByMarktypeChange); 17 | }); 18 | return markCompSummary; 19 | } 20 | 21 | 22 | class MarkSummary { 23 | constructor(markDiff, rawInfo) { 24 | const vegaView = rawInfo.sVis.view; 25 | let data = getMarkData( 26 | vegaView, 27 | markDiff.initial, 28 | markDiff.compName, 29 | 30 | ); 31 | let hasFacet = markDiff.initial ? computeHasFacet(markDiff.initial) : undefined; 32 | let isGroupingMtype = markDiff.initial ? isGroupingMarktype(markDiff.initial.type) : undefined; 33 | data = hasFacet || isGroupingMtype ? unpackData(data) : data; 34 | this.markDiff = markDiff; 35 | this.rawInfo = rawInfo; 36 | this.isEmpty = markDiff.add; 37 | this.marktype = get(markDiff, "initial", "type"); 38 | this.encode = get(markDiff, "initial", "encode", "update") || {}; 39 | 40 | this.data = { 41 | hasFacet, 42 | isGroupingMarktype: isGroupingMtype, 43 | fields: data[0] ? Object.keys(data[0].datum) : [], 44 | values: data 45 | }; 46 | 47 | this.scales = markDiff.meta.usedScales.reduce((scales, scName) => { 48 | const scale_i = vegaView._runtime.scales[scName]; 49 | if (scale_i) { 50 | scales[scName] = scale_i.value; 51 | } 52 | return scales; 53 | }, {}); 54 | 55 | this.style = get(markDiff, "initial", "style"); 56 | } 57 | 58 | applyDiff(diff, extraDiffsByMarktypeChange) { 59 | 60 | if (diff === "add") { 61 | this.isEmpty = true; 62 | } 63 | if (diff === "remove") { 64 | this.isEmpty = false; 65 | } 66 | if (diff === "marktype") { 67 | this.marktype = get(this.markDiff, "final", "type"); 68 | extraDiffsByMarktypeChange.forEach(extraDiff => { 69 | this.applyDiff(extraDiff); 70 | }); 71 | } else if (diff === "data") { 72 | this.data = { 73 | isGroupingMarktype: this.markDiff.final ? isGroupingMarktype(this.markDiff.final.type) : undefined, 74 | hasFacet: this.markDiff.final ? computeHasFacet(this.markDiff.final) : undefined, 75 | }; 76 | let data = getMarkData( 77 | this.rawInfo.eVis.view, 78 | this.markDiff.final, 79 | this.markDiff.compName 80 | ); 81 | data = this.data.hasFacet || this.data.isGroupingMarktype ? unpackData(data) : data; 82 | this.data.fields = data[0] ? Object.keys(data[0].datum) : []; 83 | this.data.values = data; 84 | } else if (diff.indexOf("encode.") >= 0) { 85 | const channel = diff.replace("encode.", ""); 86 | this.encode = Object.assign( 87 | {}, 88 | this.encode, 89 | this.markDiff.final ? getSubEncodeByChannel(this.markDiff.final.encode.update, channel) : {} 90 | ); 91 | } else if (diff.indexOf("scale.") >= 0) { 92 | const scName = diff.replace("scale.", ""); 93 | const scale_f = this.rawInfo.eVis.view._runtime.scales[scName]; 94 | if (scale_f) { 95 | this.scales[scName] = scale_f.value; 96 | } else { 97 | delete this.scales[scName]; 98 | } 99 | } else if (diff === "style") { 100 | this.style = get(this.markDiff, "final", "style"); 101 | } 102 | return this; 103 | } 104 | 105 | } 106 | 107 | 108 | export { applyMarkDiffs }; 109 | // Todo 110 | // Make Test 111 | -------------------------------------------------------------------------------- /test/recommender/sequence/index.test.js: -------------------------------------------------------------------------------- 1 | import { default as vl2vg4gemini } from "../../../src/util/vl2vg4gemini"; 2 | import { default as EXAMPLES } from "../../exampleLoader"; 3 | import { 4 | recommendForSeq, 5 | canRecommendKeyframes, 6 | splitStagesPerTransition, 7 | recommendWithPath 8 | } from "../../../src/recommender/sequence/index.js"; 9 | 10 | describe("recommendForSeq", () => { 11 | test("should recommend gemini specs for the given sequence", async () => { 12 | let {sequence, opt} = EXAMPLES.sequence.filter_aggregate; 13 | opt = {...opt, stageN: 3}; 14 | let recommendations = await recommendForSeq(sequence.map(vl2vg4gemini), opt); 15 | let topRecomSpecs = recommendations[0].specs; 16 | 17 | expect(recommendations[0].cost).toBeLessThan(recommendations[1].cost) 18 | expect(topRecomSpecs.reduce((dur, recom) => { 19 | return dur + recom.spec.totalDuration 20 | }, 0)) 21 | .toEqual(opt.totalDuration); 22 | 23 | expect(topRecomSpecs.reduce((dur, recom) => { 24 | return dur + recom.spec.timeline.concat.length 25 | }, 0)) 26 | .toEqual(opt.stageN); 27 | }) 28 | 29 | test("should recommend gemini specs for the sequence adding Y and aggregating", async () => { 30 | const {sequence, opt} = EXAMPLES.sequence.addY_aggregate_scale; 31 | 32 | let recommendations = await recommendForSeq( 33 | sequence.map(vl2vg4gemini), 34 | {...opt, stageN: 2} 35 | ); 36 | let topRecom = recommendations[0]; 37 | 38 | expect(recommendations.length).toEqual(1); 39 | expect(topRecom.specs[0].spec.totalDuration).toEqual(opt.totalDuration/2) 40 | expect(topRecom.specs[0].spec.timeline.concat.length).toEqual(1) 41 | 42 | }) 43 | }) 44 | 45 | describe("canRecommendKeyframes", () => { 46 | 47 | test("should return an error if the given charts are invalid VL charts.", async () => { 48 | const {start, end} = EXAMPLES.sequence.filter_aggregate; 49 | 50 | expect(canRecommendKeyframes({ hconcat: [ {mark: "point", encode: {x: {field: "X"}}}] }, end)) 51 | .toMatchObject({reason: "Gemini++ cannot recommend keyframes for the given Vega-Lite charts."}); 52 | 53 | }) 54 | 55 | }) 56 | 57 | describe("splitStagesPerTransition", () => { 58 | test("should return all possible splits.", () => { 59 | expect(splitStagesPerTransition(3,2).length).toBe(2); 60 | expect(splitStagesPerTransition(4,2).length).toBe(3); 61 | expect(splitStagesPerTransition(4,3).length).toBe(3); 62 | 63 | }) 64 | }) 65 | 66 | describe("recommendWithPath", () => { 67 | test("should recommend with paths for the given transition and stageN(=1).", async () => { 68 | let {start, end, opt} = EXAMPLES.sequence.filter_aggregate; 69 | opt = {...opt, stageN: 1}; 70 | let recommendations = await recommendWithPath(start, end, opt); 71 | expect(recommendations['2']).toEqual(undefined); 72 | expect(recommendations['1'].length).toEqual(1); 73 | expect(recommendations['1'][0].path.sequence.length).toEqual(2); 74 | expect(recommendations['1'][0].recommendations.length).toEqual(1); 75 | }) 76 | 77 | test("should recommend with paths for the given transition and stageN(=2).", async () => { 78 | let {start, end, opt} = EXAMPLES.sequence.filter_aggregate; 79 | opt = {...opt, stageN: 2}; 80 | let recommendations = await recommendWithPath(start, end, opt); 81 | expect(recommendations['3']).toEqual(undefined); 82 | expect(recommendations['1'].length).toEqual(1); 83 | expect(recommendations['1'][0].path.sequence.length).toEqual(2); 84 | expect(recommendations['1'][0].recommendations[0].specs.length).toEqual(1); 85 | expect(recommendations['1'][0].recommendations[0].specs[0].spec.timeline.concat.length).toEqual(2); 86 | expect(recommendations['2'][0].recommendations[0].specs.length).toEqual(2); 87 | expect(recommendations['2'][0].recommendations[0].specs[0].spec.timeline.concat.length).toEqual(1); 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/default/encode/axis.js: -------------------------------------------------------------------------------- 1 | import { vegaConfig as vgConfig } from "../vegaConfig"; 2 | import * as vg from "./vegaDefault"; 3 | import { isNumber, copy } from "../../util/util"; 4 | 5 | 6 | export const DEFAULT_ENCODE_AXIS = { 7 | axis: () => { 8 | return copy(vg.EMPTY_ENCODE); 9 | }, 10 | labels: spec => { 11 | if (!spec) { 12 | return copy(vg.EMPTY_ENCODE); 13 | } 14 | 15 | const orient = spec ? spec.orient : undefined; 16 | const scaleType = spec ? spec.scaleType : undefined; 17 | 18 | const defaultEncode = { 19 | ...vg.axisCompPos(spec), 20 | text: { field: "label" }, 21 | fontSize: { 22 | value: spec && spec.labelFontSize ? spec.labelFontSize : vgConfig.style["guide-label"].fontSize 23 | }, 24 | dx: { value: vg.axisTextDpos("dx", spec) }, 25 | dy: { value: vg.axisTextDpos("dy", spec) }, 26 | align: { value: vg.axisLabelAlign(spec) }, 27 | baseline: { 28 | value: spec && spec.baseline ? spec.baseline : vg.baseline(orient) 29 | }, 30 | angle: { 31 | value: 32 | spec && isNumber(spec.labelAngle) 33 | ? spec.labelAngle 34 | : vg.lableAngle(orient, scaleType) 35 | } 36 | }; 37 | 38 | return { 39 | enter: { ...defaultEncode, opacity: { value: 0 } }, 40 | exit: { ...defaultEncode, opacity: { value: 0 } }, 41 | update: defaultEncode 42 | }; 43 | }, 44 | ticks: spec => { 45 | if (!spec) { 46 | return copy(vg.EMPTY_ENCODE); 47 | } 48 | 49 | const orient = spec ? spec.orient : undefined; 50 | const defaultEncode = Object.assign({}, vg.axisCompPos(spec), { 51 | x2: { value: vg.tickLength("x2", orient) }, 52 | y2: { value: vg.tickLength("y2", orient) }, 53 | strokeWidth: { value: vgConfig.axis.tickWidth }, 54 | stroke: { value: vgConfig.axis.tickColor } 55 | }); 56 | return { 57 | enter: { ...defaultEncode, opacity: { value: 0 } }, 58 | exit: { ...defaultEncode, opacity: { value: 0 } }, 59 | update: defaultEncode 60 | }; 61 | }, 62 | grid: spec => { 63 | 64 | const orient = spec ? spec.orient : undefined; 65 | const gridScale = spec ? spec.gridScale : undefined; 66 | let defaultEncode = Object.assign( 67 | {}, 68 | vg.axisCompPos(spec), 69 | vg.gridLength(orient, gridScale), 70 | { 71 | strokeWidth: { value: vgConfig.axis.gridWidth }, 72 | stroke: { value: vgConfig.axis.gridColor } 73 | } 74 | ); 75 | if (spec && spec.gridDash) { 76 | defaultEncode.strokeDasharray = {"value": spec.gridDash.join(",")}; 77 | } 78 | 79 | return { 80 | enter: { ...defaultEncode, opacity: { value: 0 } }, 81 | exit: { ...defaultEncode, opacity: { value: 0 } }, 82 | update: defaultEncode 83 | }; 84 | }, 85 | title: spec => { 86 | 87 | 88 | const orient = spec ? spec.orient : undefined; 89 | const defaultEncode = Object.assign( 90 | { 91 | baseline: { value: vg.baseline(orient) }, 92 | align: { value: "center" }, 93 | angle: { value: vg.titleAngle(orient) }, 94 | fontSize: { value: vgConfig.style["guide-title"].fontSize }, 95 | fontWeight: { value: "bold" } 96 | }, 97 | vg.titlePos(orient) 98 | ); 99 | return { 100 | enter: { ...defaultEncode, opacity: { value: 0 } }, 101 | exit: { ...defaultEncode, opacity: { value: 0 } }, 102 | update: defaultEncode 103 | }; 104 | }, 105 | domain: spec => { 106 | 107 | const defaultEncode = { 108 | ...(spec ? vg.domainLength(spec.orient) : {}), 109 | strokeWidth: { value: vgConfig.axis.domainWidth }, 110 | stroke: { value: vgConfig.axis.domainColor } 111 | }; 112 | 113 | return { 114 | enter: { ...defaultEncode, opacity: { value: 0 } }, 115 | exit: { ...defaultEncode, opacity: { value: 0 } }, 116 | update: defaultEncode 117 | }; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /test/examples/sequence/filter_aggregate.json: -------------------------------------------------------------------------------- 1 | { 2 | "start": { 3 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 4 | "data": { 5 | "values": [ 6 | {"Hungry": 10, "Name": "Gemini"}, 7 | {"Hungry": 60, "Name": "Cordelia"}, 8 | {"Hungry": 80, "Name": "Gemini"}, 9 | {"Hungry": 100, "Name": "Cordelia"} 10 | ] 11 | }, 12 | "mark": "point", 13 | "encoding": { 14 | "x": { "field": "Hungry", "type": "quantitative"} 15 | } 16 | }, 17 | "end": { 18 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 19 | "data": { 20 | "values": [ 21 | {"Hungry": 10, "Name": "Gemini"}, 22 | {"Hungry": 60, "Name": "Cordelia"}, 23 | {"Hungry": 80, "Name": "Gemini"}, 24 | {"Hungry": 100, "Name": "Cordelia"} 25 | ] 26 | }, 27 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 28 | "mark": "point", 29 | "encoding": { 30 | "x": { "field": "Hungry", "type": "quantitative", "aggregate": "mean"} 31 | } 32 | }, 33 | "gemSpecs": [ 34 | { 35 | "timeline": { 36 | "sync": [ 37 | { 38 | "component": {"axis": "x"}, 39 | "change": {"scale": {"domainDimension": "same"}}, 40 | "timing": {"duration": 1000} 41 | }, 42 | { 43 | "component": {"mark": "marks"}, 44 | "change": {"data": ["Name"]}, 45 | "timing": {"duration": 1000} 46 | } 47 | ] 48 | } 49 | }, 50 | { 51 | "timeline": { 52 | "sync": [ 53 | { 54 | "component": {"axis": "x"}, 55 | "change": {"scale": {"domainDimension": "same"}}, 56 | "timing": {"duration": 1000} 57 | }, 58 | { 59 | "component": {"mark": "marks"}, 60 | "change": {"data": ["Name"]}, 61 | "timing": {"duration": 1000} 62 | } 63 | ] 64 | } 65 | } 66 | ], 67 | "sequence":[ 68 | { 69 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 70 | "data": { 71 | "values": [ 72 | {"Hungry": 10, "Name": "Gemini"}, 73 | {"Hungry": 60, "Name": "Cordelia"}, 74 | {"Hungry": 80, "Name": "Gemini"}, 75 | {"Hungry": 100, "Name": "Cordelia"}, 76 | {"Hungry": 40, "Name": "Mango"}, 77 | {"Hungry": 100, "Name": "Mango"} 78 | ] 79 | }, 80 | "mark": "point", 81 | "encoding": { 82 | "x": { "field": "Hungry", "type": "quantitative"}, 83 | "y": { "field": "Name", "type": "nominal"} 84 | } 85 | }, 86 | { 87 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 88 | "data": { 89 | "values": [ 90 | {"Hungry": 10, "Name": "Gemini"}, 91 | {"Hungry": 60, "Name": "Cordelia"}, 92 | {"Hungry": 80, "Name": "Gemini"}, 93 | {"Hungry": 100, "Name": "Cordelia"}, 94 | {"Hungry": 40, "Name": "Mango"}, 95 | {"Hungry": 100, "Name": "Mango"} 96 | ] 97 | }, 98 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 99 | "mark": "point", 100 | "encoding": { 101 | "x": { "field": "Hungry", "type": "quantitative"}, 102 | "y": { "field": "Name", "type": "nominal"} 103 | } 104 | }, 105 | { 106 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 107 | "data": { 108 | "values": [ 109 | {"Hungry": 10, "Name": "Gemini"}, 110 | {"Hungry": 60, "Name": "Cordelia"}, 111 | {"Hungry": 80, "Name": "Gemini"}, 112 | {"Hungry": 100, "Name": "Cordelia"}, 113 | {"Hungry": 40, "Name": "Mango"}, 114 | {"Hungry": 100, "Name": "Mango"} 115 | ] 116 | }, 117 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 118 | "mark": "point", 119 | "encoding": { 120 | "x": { "field": "Hungry", "type": "quantitative", "aggregate": "mean"}, 121 | "y": { "field": "Name", "type": "nominal"} 122 | } 123 | } 124 | ], 125 | "opt": { 126 | "totalDuration": 2000, 127 | "stageN": 3 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /test/examples/sequence/addY_addColor.json: -------------------------------------------------------------------------------- 1 | { 2 | "start": { 3 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 4 | "data": { 5 | "values": [ 6 | {"Hungry": 10, "Name": "Gemini", "t": 1}, 7 | {"Hungry": 60, "Name": "Cordelia", "t": 1}, 8 | {"Hungry": 80, "Name": "Gemini", "t": 2}, 9 | {"Hungry": 100, "Name": "Cordelia", "t": 2} 10 | ] 11 | }, 12 | "mark": "point", 13 | "encoding": { 14 | "x": { "field": "Hungry", "type": "quantitative"} 15 | } 16 | }, 17 | "end": { 18 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 19 | "data": { 20 | "values": [ 21 | {"Hungry": 10, "Name": "Gemini", "t": 1}, 22 | {"Hungry": 60, "Name": "Cordelia", "t": 1}, 23 | {"Hungry": 80, "Name": "Gemini", "t": 2}, 24 | {"Hungry": 100, "Name": "Cordelia", "t": 2} 25 | ] 26 | }, 27 | "mark": "point", 28 | "encoding": { 29 | "x": { "field": "Hungry", "type": "quantitative"}, 30 | "y": { "field": "Name", "type": "nominal"}, 31 | "color": { "field": "t", "type": "nominal"} 32 | } 33 | }, 34 | "gemSpecs": [ 35 | { 36 | "timeline": { 37 | "sync": [ 38 | { 39 | "component": {"axis": "x"}, 40 | "change": {"scale": {"domainDimension": "same"}}, 41 | "timing": {"duration": 1000} 42 | }, 43 | { 44 | "component": {"mark": "marks"}, 45 | "change": {"data": ["Name"]}, 46 | "timing": {"duration": 1000} 47 | } 48 | ] 49 | } 50 | }, 51 | { 52 | "timeline": { 53 | "sync": [ 54 | { 55 | "component": {"axis": "x"}, 56 | "change": {"scale": {"domainDimension": "same"}}, 57 | "timing": {"duration": 1000} 58 | }, 59 | { 60 | "component": {"mark": "marks"}, 61 | "change": {"data": ["Name"]}, 62 | "timing": {"duration": 1000} 63 | } 64 | ] 65 | } 66 | } 67 | ], 68 | "sequence":[ 69 | { 70 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 71 | "data": { 72 | "values": [ 73 | {"Hungry": 10, "Name": "Gemini"}, 74 | {"Hungry": 60, "Name": "Cordelia"}, 75 | {"Hungry": 80, "Name": "Gemini"}, 76 | {"Hungry": 100, "Name": "Cordelia"}, 77 | {"Hungry": 40, "Name": "Mango"}, 78 | {"Hungry": 100, "Name": "Mango"} 79 | ] 80 | }, 81 | "mark": "point", 82 | "encoding": { 83 | "x": { "field": "Hungry", "type": "quantitative"}, 84 | "y": { "field": "Name", "type": "nominal"} 85 | } 86 | }, 87 | { 88 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 89 | "data": { 90 | "values": [ 91 | {"Hungry": 10, "Name": "Gemini"}, 92 | {"Hungry": 60, "Name": "Cordelia"}, 93 | {"Hungry": 80, "Name": "Gemini"}, 94 | {"Hungry": 100, "Name": "Cordelia"}, 95 | {"Hungry": 40, "Name": "Mango"}, 96 | {"Hungry": 100, "Name": "Mango"} 97 | ] 98 | }, 99 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 100 | "mark": "point", 101 | "encoding": { 102 | "x": { "field": "Hungry", "type": "quantitative"}, 103 | "y": { "field": "Name", "type": "nominal"} 104 | } 105 | }, 106 | { 107 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 108 | "data": { 109 | "values": [ 110 | {"Hungry": 10, "Name": "Gemini"}, 111 | {"Hungry": 60, "Name": "Cordelia"}, 112 | {"Hungry": 80, "Name": "Gemini"}, 113 | {"Hungry": 100, "Name": "Cordelia"}, 114 | {"Hungry": 40, "Name": "Mango"}, 115 | {"Hungry": 100, "Name": "Mango"} 116 | ] 117 | }, 118 | "transform": [{"filter": {"field": "Name", "equal": "Gemini"}}], 119 | "mark": "point", 120 | "encoding": { 121 | "x": { "field": "Hungry", "type": "quantitative", "aggregate": "mean"}, 122 | "y": { "field": "Name", "type": "nominal"} 123 | } 124 | } 125 | ], 126 | "opt": { 127 | "totalDuration": 2000 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/actuator/staggering.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { flatten } from "../util/util.js"; 3 | import { getEaseFn } from "./util"; 4 | 5 | const ORDER = { 6 | ascending: d3.ascending, 7 | descending: d3.descending 8 | }; 9 | 10 | function getOrderFn(isNumber, order) { 11 | if (isNumber && order) { 12 | return (a, b) => ORDER[order](Number(a), Number(b)); 13 | } 14 | return ORDER[order]; 15 | } 16 | 17 | function staggeredTiming(staggering, data, duration) { 18 | let N; 19 | let grouped; 20 | const dataWithTiming = data.map((d, i) => { 21 | return { ...d, __staggering_id__: i }; 22 | }); 23 | const subStaggering = staggering.staggering; 24 | 25 | const isNumber = 26 | staggering.by && 27 | dataWithTiming.reduce((acc, d) => { 28 | let val; 29 | if (typeof staggering.by === "string") { 30 | val = (d.initial || d.final)[staggering.by]; 31 | } else if (staggering.by.initial || staggering.by.final) { 32 | const which = staggering.by.initial ? "initial" : "final"; 33 | val = (which === "initial" 34 | ? d.initial || d.final 35 | : d.final || d.initial)[staggering.by[which]]; 36 | } 37 | return (acc = acc && (val !== undefined ? !isNaN(Number(val)) : true)); 38 | }, true); 39 | if (!staggering.by) { 40 | 41 | 42 | const orderFn = getOrderFn(true, staggering.order); 43 | grouped = d3.groups(dataWithTiming, d => { 44 | const val = d.__staggering_id__; 45 | return val === undefined ? "__empty__" : val; 46 | }) 47 | if (typeof(orderFn) === "function") { 48 | grouped.sort((a,b) => orderFn(a[0], b[0])); 49 | } 50 | } else if (typeof staggering.by === "string") { 51 | 52 | 53 | grouped = d3.groups(dataWithTiming, d => { 54 | const val = (d.initial || d.final)[staggering.by]; 55 | return val === undefined ? "__empty__" : val; 56 | }) 57 | 58 | const orderFn = getOrderFn(isNumber, staggering.order); 59 | if (typeof(orderFn) === "function") { 60 | grouped.sort((a,b) => orderFn(a[0], b[0])); 61 | } 62 | } else if (staggering.by.initial || staggering.by.final) { 63 | const which = staggering.by.initial ? "initial" : "final"; 64 | 65 | 66 | grouped = d3.groups(dataWithTiming, d => { 67 | const val = (which === "initial" 68 | ? d.initial || d.final 69 | : d.final || d.initial)[staggering.by[which]]; 70 | return val === undefined ? "__empty__" : val; 71 | }) 72 | 73 | const orderFn = getOrderFn(isNumber, staggering.order); 74 | if (typeof(orderFn) === "function") { 75 | grouped.sort((a,b) => orderFn(a[0], b[0])); 76 | } 77 | } 78 | 79 | N = grouped.length; 80 | 81 | const ease = getEaseFn(staggering.ease || "linear") || d3.easeLinear; 82 | const r = staggering.overlap === undefined ? 1 : staggering.overlap; 83 | const delta_e = i => ease((i + 1) / N) - ease(i / N); 84 | const alpha = 1 / (delta_e(0) * r + 1 - r); 85 | 86 | let durations = new Array(N).fill(0); 87 | durations = durations.map((d, i) => delta_e(i) * alpha * duration); 88 | let delayAcc = 0; 89 | const delays = durations.map((dur, i, durations) => { 90 | const currDelay = delayAcc; 91 | if (i < N - 1) { 92 | delayAcc = delayAcc + dur - durations[i + 1] * r; 93 | } 94 | return currDelay; 95 | }); 96 | 97 | if (subStaggering) { 98 | const timings = delays.map((d, i) => { 99 | return { 100 | delay: d, 101 | duration: durations[i] 102 | }; 103 | }); 104 | 105 | timings.groups = grouped.map((g, i) => { 106 | return staggeredTiming(subStaggering, g[1], durations[i]); 107 | }); 108 | 109 | return getFlattenTimings(timings); 110 | } 111 | grouped.forEach((group, i) => { 112 | group[1].forEach(datum => { 113 | datum.delay = delays[i]; 114 | datum.duration = durations[i]; 115 | }); 116 | }); 117 | 118 | return dataWithTiming; 119 | } 120 | 121 | function getFlattenTimings(timings) { 122 | if (!timings.groups) { 123 | return timings; 124 | } 125 | return flatten( 126 | timings.map((g_t, i) => { 127 | return getFlattenTimings(timings.groups[i]).map(t => { 128 | return Object.assign({}, t, { delay: t.delay + g_t.delay }); 129 | }); 130 | }) 131 | ); 132 | } 133 | 134 | export { staggeredTiming }; 135 | -------------------------------------------------------------------------------- /src/recommender/index.js: -------------------------------------------------------------------------------- 1 | import * as vega from "vega"; 2 | import { detectDiffs } from "./diffDetector"; 3 | import { enumeratePseudoTimelines } from "./pseudoTimelineEnumerator"; 4 | import { evaluate } from "./pseudoTimelineEvaluator"; 5 | import { generateTimeline } from "./timelineGenerator"; 6 | import { copy } from "../util/util"; 7 | import { castVL2VG } from "../util/vl2vg4gemini"; 8 | import { getComponents, getChanges } from "../changeFetcher/change"; 9 | import { setUpRecomOpt } from "./util" 10 | 11 | export default async function ( 12 | sSpec, 13 | eSpec, 14 | opt = { marks: {}, axes: {}, legends: {}, scales: {} } 15 | ) { 16 | const { 17 | rawInfo, 18 | userInput, 19 | stageN, 20 | includeMeta, 21 | timing 22 | } = await initialSetUp(sSpec, eSpec, opt); 23 | 24 | if (!canRecommend(sSpec, eSpec).result && stageN !== 1) { 25 | return canRecommend(sSpec, eSpec); 26 | } 27 | 28 | const detected = detectDiffs(rawInfo, userInput); 29 | 30 | let pseudoTls = enumeratePseudoTimelines(detected, stageN, rawInfo, timing); 31 | pseudoTls = pseudoTls 32 | .map(pseudoTl => { 33 | pseudoTl.eval = evaluate(pseudoTl); 34 | return pseudoTl; 35 | }) 36 | .sort((a, b) => compareCost(a.eval, b.eval)); 37 | 38 | return pseudoTls.map(pseudoTl => { 39 | const meta = includeMeta ? pseudoTl.eval : undefined; 40 | return { 41 | spec: { 42 | timeline: generateTimeline(pseudoTl, userInput, includeMeta), 43 | totalDuration: timing.totalDuration, 44 | meta 45 | }, 46 | pseudoTimeline: pseudoTl 47 | }; 48 | }); 49 | } 50 | export function compareCost(a, b) { 51 | if (a.cost === b.cost) { 52 | if (a.tiebreaker === b.tiebreaker) { 53 | return a.tiebreaker2 - b.tiebreaker2; 54 | } 55 | return a.tiebreaker - b.tiebreaker; 56 | } 57 | return a.cost - b.cost; 58 | } 59 | 60 | async function initialSetUp(sSpec, eSpec, opt = { marks: {}, axes: {}, legends: {}, scales: {} }) { 61 | let _opt = copy(opt); 62 | const stageN = Number(opt.stageN) || 2; 63 | const { includeMeta } = opt; 64 | const timing = { totalDuration: _opt.totalDuration || 2000 }; 65 | _opt = setUpRecomOpt(_opt); 66 | const eView = await new vega.View(vega.parse(castVL2VG(eSpec)), { 67 | renderer: "svg" 68 | }).runAsync(); 69 | 70 | const sView = await new vega.View(vega.parse(castVL2VG(sSpec)), { 71 | renderer: "svg" 72 | }).runAsync(); 73 | 74 | 75 | const rawInfo = { 76 | sVis: { spec: copy(castVL2VG(sSpec)), view: sView }, 77 | eVis: { spec: copy(castVL2VG(eSpec)), view: eView } 78 | }; 79 | 80 | return { rawInfo, userInput: _opt, stageN, includeMeta, timing} 81 | } 82 | 83 | export function canRecommend(sSpec, eSpec, stageN) { 84 | 85 | const compDiffs = getChanges( 86 | getComponents(castVL2VG(sSpec)), 87 | getComponents(castVL2VG(eSpec)) 88 | ).filter(match => { 89 | return ( 90 | ["root", "pathgroup"].indexOf(match.compName) < 0 && 91 | match.compType !== "scale" 92 | ); 93 | }) 94 | if (compDiffs.filter(comp => comp.compType === "mark").length >= 2 && stageN >1) { 95 | return { result: false, reason: "Gemini cannot recomend animations for transitions with multiple marks." }; 96 | } 97 | return { result: true }; 98 | } 99 | 100 | export async function allAtOnce(sSpec, 101 | eSpec, 102 | opt = { marks: {}, axes: {}, legends: {}, scales: {} } 103 | ) { 104 | const sVGSpec = castVL2VG(sSpec), eVGSpec = castVL2VG(eSpec) 105 | const { 106 | rawInfo, 107 | userInput, 108 | stageN, 109 | includeMeta, 110 | timing 111 | } = await initialSetUp(sVGSpec, eVGSpec, {stageN:1, ...opt}); 112 | 113 | const detected = detectDiffs(rawInfo, userInput); 114 | 115 | const steps = detected.compDiffs.map(cmpDiff => { 116 | let comp = {} 117 | comp[cmpDiff.compType] = cmpDiff.compName; 118 | return { 119 | component: comp, 120 | timing: {duration: {ratio: 1}} 121 | } 122 | }); 123 | for (const incOrDec of ["increase", "decrease"]) { 124 | if (detected.viewDiffs.height[incOrDec] || detected.viewDiffs.width[incOrDec]) { 125 | steps.push({ 126 | component: "view", 127 | timing: {duration: {ratio: 1}} 128 | }) 129 | break; 130 | } 131 | } 132 | 133 | return { 134 | timeline: { 135 | sync: steps 136 | }, 137 | totalDuration: opt.totalDuration || 2000 138 | } 139 | 140 | } -------------------------------------------------------------------------------- /src/recommender/sequence/index.js: -------------------------------------------------------------------------------- 1 | import { default as recommend, canRecommend } from "../index.js" 2 | import { crossJoinArrays, copy } from "../../util/util.js"; 3 | import * as gs from "graphscape"; 4 | import {NSplits} from "../../util/util.js"; 5 | import {setUpRecomOpt} from "../util.js"; 6 | import vl2vg4gemini from "../../util/vl2vg4gemini.js"; 7 | 8 | export async function recommendKeyframes(sSpec, eSpec, M) { 9 | return await gs.path(copy(sSpec), copy(eSpec), M); 10 | } 11 | 12 | 13 | export async function recommendWithPath(sVlSpec, eVlSpec, opt ={ stageN: 1, totalDuration: 2000 }) { 14 | 15 | let _opt = copy(opt) 16 | _opt.totalDuration = opt.totalDuration || 2000; 17 | _opt.stageN = opt.stageN || 1; 18 | _opt = setUpRecomOpt(_opt); 19 | 20 | const recommendations = {}; 21 | 22 | for (let transM = 1; transM <= _opt.stageN; transM++) { 23 | let paths; 24 | try { 25 | paths = await gs.path(copy(sVlSpec), copy(eVlSpec), transM); 26 | } catch (error) { 27 | if (error.name === "CannotEnumStagesMoreThanTransitions") { 28 | continue; 29 | } 30 | throw error; 31 | } 32 | 33 | recommendations[transM] = []; 34 | for (const path of paths) { 35 | const sequence = path.sequence.map(vl2vg4gemini); 36 | 37 | //enumerate all possible gemini++ specs for the sequence; 38 | let recomsPerPath = await recommendForSeq(sequence, opt) 39 | recommendations[transM].push({ 40 | path, 41 | recommendations: recomsPerPath 42 | }) 43 | 44 | } 45 | } 46 | return recommendations; 47 | } 48 | 49 | 50 | 51 | export function splitStagesPerTransition(stageN, transitionM) { 52 | return NSplits(new Array(stageN).fill(1), transitionM) 53 | .map(arr => arr.map(a => a.length)); 54 | } 55 | 56 | export async function recommendForSeq(sequence, opt = {}) { 57 | let globalOpt = copy(opt), 58 | transM = sequence.length-1, 59 | stageN = opt.stageN; 60 | if (stageN < transM) { 61 | throw new Error(`Cannot recommend ${stageN}-stage animations for a sequence with ${transM} transitions.`) 62 | } 63 | 64 | globalOpt = setUpRecomOpt(globalOpt) 65 | 66 | let stageNSplits = splitStagesPerTransition(stageN, transM) 67 | let recomsForSequence = []; 68 | for (const stageNSplit of stageNSplits) { 69 | const recommendationPerTransition = []; 70 | 71 | for (let i = 0; i < transM; i++) { 72 | const sVgVis = (sequence[i]), 73 | eVgVis = (sequence[i+1]); 74 | 75 | const _opt = { 76 | ...globalOpt, 77 | ...(opt.perTransitions || [])[i], 78 | ...{includeMeta: false}, 79 | ...{ 80 | stageN: stageNSplit[i], 81 | totalDuration: (opt.totalDuration || 2000) / stageN * stageNSplit[i] 82 | } 83 | } 84 | const _recom = await recommend(sVgVis, eVgVis, _opt); 85 | recommendationPerTransition.push(_recom); 86 | } 87 | 88 | recomsForSequence = recomsForSequence.concat(crossJoinArrays(recommendationPerTransition)); 89 | } 90 | 91 | return recomsForSequence.map(recom => { 92 | return { 93 | specs: recom, 94 | cost: sumCost(recom) 95 | } 96 | }).sort((a,b) => { 97 | return a.cost - b.cost 98 | }); 99 | } 100 | 101 | function sumCost(geminiSpecs) { 102 | return geminiSpecs.reduce((cost, spec) => { 103 | cost += spec.pseudoTimeline.eval.cost; 104 | return cost 105 | }, 0) 106 | } 107 | 108 | export function canRecommendForSeq(sequence) { 109 | for (let i = 0; i < (sequence.length - 1); i++) { 110 | const sVis = sequence[i], eVis = sequence[i+1]; 111 | let isRecommendable = canRecommend(sVis, eVis).result; 112 | if (isRecommendable.result) { 113 | return {result: false, reason: isRecommendable.reason} 114 | } 115 | } 116 | return {result: true}; 117 | } 118 | 119 | export function canRecommendKeyframes(sSpec, eSpec) { 120 | //check if specs are single-view vega-lite chart 121 | if (!isValidVLSpec(sSpec) || !isValidVLSpec(eSpec)) { 122 | return {result: false, reason: "Gemini++ cannot recommend keyframes for the given Vega-Lite charts."} 123 | } 124 | return {result: true} 125 | } 126 | 127 | 128 | 129 | export function isValidVLSpec(spec) { 130 | if (spec.layer || spec.hconcat || spec.vconcat || spec.concat || spec.spec) { 131 | return false; 132 | } 133 | if (spec.$schema && (spec.$schema.indexOf("https://vega.github.io/schema/vega-lite") >= 0)){ 134 | return true 135 | } 136 | return false 137 | 138 | } -------------------------------------------------------------------------------- /test/examples/sequence/addY_aggregate_scale.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "[Penguin] ADD_X(f1) & AGGREGATE", 3 | "gemSpecs": [ 4 | { 5 | "timeline": { 6 | "concat": [ 7 | { 8 | "sync": [ 9 | { 10 | "component": {"mark": "marks"}, 11 | "change": { 12 | "scale": ["x", "y"], 13 | "data": ["Flipper Length (mm)", "Body Mass (g)"], 14 | "encode": {"update": true, "enter": true, "exit": true} 15 | }, 16 | "timing": {"duration": {"ratio": 1}} 17 | }, 18 | { 19 | "component": {"axis": "x"}, 20 | "change": {"scale": {"domainDimension": "same"}}, 21 | "timing": {"duration": {"ratio": 1}} 22 | }, 23 | { 24 | "component": {"axis": "y"}, 25 | "change": {"scale": {"domainDimension": "same"}}, 26 | "timing": {"duration": {"ratio": 1}} 27 | }, 28 | { 29 | "component": "view", 30 | "change": {"signal": ["width", "height"]}, 31 | "timing": {"duration": {"ratio": 1}} 32 | } 33 | ] 34 | } 35 | ] 36 | }, 37 | "totalDuration": 2000 38 | }, 39 | { 40 | "timeline": { 41 | "concat": [ 42 | { 43 | "sync": [ 44 | { 45 | "component": {"mark": "marks"}, 46 | "change": { 47 | "scale": ["x"], 48 | "data": ["Flipper Length (mm)", "Body Mass (g)"], 49 | "encode": {"update": true, "enter": true, "exit": true} 50 | }, 51 | "timing": {"duration": {"ratio": 1}} 52 | }, 53 | { 54 | "component": {"axis": "x"}, 55 | "change": {"scale": {"domainDimension": "same"}}, 56 | "timing": {"duration": {"ratio": 1}} 57 | } 58 | ] 59 | } 60 | ] 61 | }, 62 | "totalDuration": 2000 63 | } 64 | ], 65 | "sequence":[ 66 | { 67 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 68 | "data": { 69 | "url": "data/penguins.json" 70 | }, 71 | "mark": "point", 72 | "encoding": { 73 | "x": { 74 | "field": "Flipper Length (mm)", 75 | "type": "quantitative", 76 | "scale": {"zero": false} 77 | } 78 | } 79 | }, 80 | { 81 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 82 | "data": {"url": "data/penguins.json"}, 83 | "mark": "point", 84 | "encoding": { 85 | "x": { 86 | "field": "Flipper Length (mm)", 87 | "type": "quantitative", 88 | "scale": {"zero": false, "domain": [0, 235]}, 89 | "aggregate": "mean" 90 | }, 91 | "y": {"field": "Species", "type": "nominal"} 92 | } 93 | }, 94 | { 95 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 96 | "data": {"url": "data/penguins.json"}, 97 | "mark": "point", 98 | "encoding": { 99 | "x": { 100 | "field": "Flipper Length (mm)", 101 | "type": "quantitative", 102 | "aggregate": "mean" 103 | }, 104 | "y": {"field": "Species", "type": "nominal"} 105 | } 106 | } 107 | 108 | ], 109 | "start": { 110 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 111 | "data": { 112 | "url": "data/penguins.json" 113 | }, 114 | "mark": "point", 115 | "encoding": { 116 | "x": { 117 | "field": "Flipper Length (mm)", 118 | "type": "quantitative", 119 | "scale": {"zero": false} 120 | } 121 | } 122 | }, 123 | "end": { 124 | "$schema": "https://vega.github.io/schema/vega-lite/v4.json", 125 | "data": { 126 | "url": "data/penguins.json" 127 | }, 128 | "mark": "point", 129 | "encoding": { 130 | "x": { 131 | "field": "Flipper Length (mm)", 132 | "type": "quantitative", 133 | "aggregate": "mean" 134 | }, 135 | "y": {"field": "Species", "type": "nominal"} 136 | } 137 | }, 138 | "opt": { 139 | "totalDuration": 4000, 140 | "marks": { 141 | "marks": {"change": {"data": ["Flipper Length (mm)", "Body Mass (g)", "Species"]}} 142 | }, 143 | "axes": { 144 | "x": {"change": { "scale": {"domainDimension": "same"} }}, 145 | "y": {"change": { "scale": {"domainDimension": "same"} }} 146 | }, 147 | "scales": {"x": {"domainDimension": "same"}, "y": {"domainDimension": "same"}} 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/util/vgDataHelper.js: -------------------------------------------------------------------------------- 1 | import * as d3 from "d3"; 2 | import { computeHasFacet, isGroupingMarktype } from "../changeFetcher/state/util"; 3 | // Join and return nextData 4 | function facetData(data, facetDef) { 5 | if (!facetDef) { 6 | return [ 7 | { 8 | datum: {}, 9 | mark: { role: "group", marktype: "group" }, 10 | items: [{ items: data }] 11 | } 12 | ]; 13 | } 14 | 15 | let {groupby} = facetDef; 16 | if (typeof groupby === "string") { 17 | groupby = [groupby]; 18 | } 19 | return d3.groups(data, d => groupby.map(f => d.datum[f]).join("@@_@@")) 20 | .map(group => { 21 | const values = group[1] 22 | let datum = groupby.reduce((datum, f) => { 23 | datum[f] = values[0].datum[f]; 24 | return datum; 25 | }, { count: values.length }); 26 | return { 27 | datum: datum, 28 | mark: {role: "group", marktype: "group"}, 29 | items: [{items: values }] 30 | }; 31 | }); 32 | 33 | } 34 | function unpackData(data) { 35 | if (data[0].mark.marktype !== "group") { 36 | return data; 37 | } 38 | 39 | return data.reduce((unpacked, group) => { 40 | return unpacked.concat(group.items[0].items); 41 | }, []); 42 | } 43 | 44 | function getMarkData(view, compSpec, compName, marktype, facet) { 45 | let dataName = computeHasFacet(compSpec) ? compSpec.parent.name : compName; 46 | let data = view._runtime.data[dataName] ? (view._runtime.data[dataName].values.value) || [] : []; 47 | if (data.length === 0) { 48 | return data; 49 | } 50 | let isGroupingMtype = isGroupingMarktype(marktype || compSpec.type), 51 | hasFacet = computeHasFacet(compSpec); 52 | 53 | if ( isGroupingMtype && !hasFacet ) { 54 | return facetData(data, facet || (compSpec.parent.from ? compSpec.parent.from.facet : undefined)); 55 | } if (!isGroupingMtype && hasFacet) { 56 | data = unpackData(data); 57 | } 58 | return data; 59 | } 60 | 61 | function getEmptyLegendData(compSpec) { 62 | if (compSpec.type === "symbol") { 63 | return { 64 | labels: [], 65 | symbols: [], 66 | pairs: [], 67 | title: [] 68 | }; 69 | } if (compSpec.type === "gradient") { 70 | return { 71 | labels: [], 72 | gradient: [], 73 | bands: [], 74 | title: [] 75 | }; 76 | } 77 | } 78 | function getLegendData(view, compName, compSpec) { 79 | const titleDatum = view._runtime.data[compName].values.value[0].items.find( 80 | item => item.role === "legend-title" 81 | ).items[0]; 82 | let returnData = { title: [titleDatum] }; 83 | if (compSpec.type === "symbol") { 84 | returnData = Object.assign(returnData, { 85 | labels: [], 86 | symbols: [], 87 | pairs: [] 88 | }); 89 | const fPairs = view._runtime.data[compName].values.value[0].items.find( 90 | item => item.role === "legend-entry" 91 | ).items[0].items[0].items; 92 | fPairs.forEach(pair => { 93 | returnData.pairs.push(pair); 94 | returnData.labels.push( 95 | pair.items.find(item => item.role === "legend-label").items[0] 96 | ); 97 | returnData.symbols.push( 98 | pair.items.find(item => item.role === "legend-symbol").items[0] 99 | ); 100 | }); 101 | } else if (compSpec.type === "gradient") { 102 | returnData = Object.assign(returnData, { 103 | labels: [], 104 | gradient: [], 105 | bands: [] 106 | }); 107 | const entryG = view._runtime.data[compName].values.value[0].items.find( 108 | item => item.role === "legend-entry" 109 | ).items[0]; 110 | let labelG; let bandG; let gradientG; 111 | if ((labelG = entryG.items.find(item => item.role === "legend-label"))) { 112 | returnData.labels = returnData.labels.concat(labelG.items); 113 | } 114 | if ((bandG = entryG.items.find(item => item.role === "legend-band"))) { 115 | returnData.bands = returnData.bands.concat(bandG.items); 116 | } 117 | if ( 118 | (gradientG = entryG.items.find(item => item.role === "legend-gradient")) 119 | ) { 120 | returnData.gradient.push(gradientG.items[0]); 121 | } 122 | } 123 | return returnData; 124 | } 125 | 126 | function getEmptyAxisData() { 127 | return { 128 | tick: [], 129 | label: [], 130 | grid: [] 131 | }; 132 | } 133 | function getAxisData(view, compName) { 134 | return ["tick", "label", "grid"].reduce((acc, subComp) => { 135 | let data = view._runtime.data[compName].values.value[0].items 136 | .find(item => item.role === `axis-${subComp}`); 137 | 138 | data = data && data.items ? data.items : []; 139 | acc[subComp] = data; 140 | return acc; 141 | }, {}); 142 | } 143 | 144 | export { 145 | facetData, 146 | unpackData, 147 | getMarkData, 148 | getEmptyLegendData, 149 | getLegendData, 150 | getEmptyAxisData, 151 | getAxisData 152 | }; 153 | -------------------------------------------------------------------------------- /test/recommender/pseudoTimelineEnumerator.test.js: -------------------------------------------------------------------------------- 1 | import { default as EXAMPLES } from "../exampleLoader.js"; 2 | import { findAllFactors, SkippingConditions, enumeratePseudoTimelines } from "../../src/recommender/pseudoTimelineEnumerator"; 3 | import { detectDiffs } from "../../src/recommender/diffDetector"; 4 | import * as vega from "vega"; 5 | 6 | describe("findAllFactors", () => { 7 | test("should factorize the diffs", () => { 8 | let markDiffMeta = { 9 | marktype: true, 10 | encode: { 11 | x: false, 12 | y: false, 13 | color: true, 14 | shape: false, 15 | size: false, 16 | others: true 17 | }, 18 | usedScales: [ "color", "x", "y" ], 19 | data: true, 20 | scale: { 21 | color: false, 22 | x: { domainValueDiff: true }, 23 | y: { domainValueDiff: true } 24 | } 25 | }; 26 | let factors = findAllFactors({meta: markDiffMeta}).allFactors 27 | expect(factors).toEqual([ "marktype", "data", "scale.x", "scale.y", "encode.color", "encode.others" ]); 28 | }); 29 | }) 30 | 31 | 32 | describe("SkippingConditions", () => { 33 | test("should skip if the given factors satisfy the registered condition", () => { 34 | const skippingConds = new SkippingConditions(); 35 | skippingConds.register( 36 | [ {factor: "A", include: true}, {factor: "B", include: false} ] 37 | ); 38 | 39 | expect(skippingConds.check(["A"])).toEqual(true); 40 | expect(skippingConds.check(["A", "B"])).toEqual(false); 41 | 42 | skippingConds.register([ {factor: "A", include: true}, {factor: "B", include: true} ]) 43 | 44 | expect(skippingConds.check(["A", "B"])).toEqual(true); 45 | 46 | }); 47 | }) 48 | 49 | describe("enumeratePseudoTimelines", () => { 50 | test("should Enuemerate a set of steps (N = targetStepNum) by splitting the diffs. ", () => { 51 | let sView = new vega.View(vega.parse(EXAMPLES.zoomingOut.sSpec), { renderer: 'svg' }); 52 | let eView = new vega.View(vega.parse(EXAMPLES.zoomingOut.eSpec), { renderer: 'svg' }); 53 | //run toSVG to get view.scale("...") 54 | sView.toSVG().then(result => { }); 55 | eView.toSVG().then(result => { }); 56 | 57 | const sVis = { spec: EXAMPLES.zoomingOut.sSpec, view: sView }; 58 | const eVis = { spec: EXAMPLES.zoomingOut.eSpec, view: eView }; 59 | const detected = detectDiffs( { sVis, eVis } ); 60 | 61 | let enumResult = enumeratePseudoTimelines(detected, 2, { sVis, eVis }); 62 | 63 | expect(enumResult.length).toEqual(10 * 8 - 2) 64 | }); 65 | 66 | test("should Enuemerate a set of steps (N = targetStepNum) by splitting the diffs. ", () => { 67 | let sView = new vega.View(vega.parse(EXAMPLES.noDiff.sSpec), { renderer: 'svg' }); 68 | let eView = new vega.View(vega.parse(EXAMPLES.noDiff.eSpec), { renderer: 'svg' }); 69 | //run toSVG to get view.scale("...") 70 | sView.toSVG().then(result => { }); 71 | eView.toSVG().then(result => { }); 72 | 73 | const sVis = { spec: EXAMPLES.noDiff.sSpec, view: sView }; 74 | const eVis = { spec: EXAMPLES.noDiff.eSpec, view: eView }; 75 | const detected = detectDiffs( { sVis, eVis } ); 76 | 77 | let enumResult = enumeratePseudoTimelines(detected, 2, { sVis, eVis }); 78 | expect(enumResult.length).toEqual(0) 79 | 80 | }); 81 | 82 | test.only("should Enuemerate a set of steps (N = targetStepNum) by splitting the diffs. ", () => { 83 | let sView = new vega.View(vega.parse(EXAMPLES.addYAxis.sSpec), { renderer: 'svg' }); 84 | let eView = new vega.View(vega.parse(EXAMPLES.addYAxis.eSpec), { renderer: 'svg' }); 85 | //run toSVG to get view.scale("...") 86 | sView.toSVG().then(result => { }); 87 | eView.toSVG().then(result => { }); 88 | 89 | const sVis = { spec: EXAMPLES.addYAxis.sSpec, view: sView }; 90 | const eVis = { spec: EXAMPLES.addYAxis.eSpec, view: eView }; 91 | const detected = detectDiffs( { sVis, eVis } ); 92 | 93 | let enumResult = enumeratePseudoTimelines(detected, 2, { sVis, eVis }); 94 | 95 | expect(enumResult.length).toEqual(6) 96 | }); 97 | 98 | test("should Enuemerate a set of steps (N = targetStepNum) by splitting the diffs. ", () => { 99 | let sView = new vega.View(vega.parse(EXAMPLES.removeLegendUpdateData.sSpec), { renderer: 'svg' }); 100 | let eView = new vega.View(vega.parse(EXAMPLES.removeLegendUpdateData.eSpec), { renderer: 'svg' }); 101 | //run toSVG to get view.scale("...") 102 | sView.toSVG().then(result => { }); 103 | eView.toSVG().then(result => { }); 104 | 105 | const sVis = { spec: EXAMPLES.removeLegendUpdateData.sSpec, view: sView }; 106 | const eVis = { spec: EXAMPLES.removeLegendUpdateData.eSpec, view: eView }; 107 | const detected = detectDiffs( { sVis, eVis } ); 108 | 109 | let enumResult = enumeratePseudoTimelines(detected, 2, { sVis, eVis }); 110 | // console.log(enumResult.enumedFactorsPerMarkDiff) 111 | expect(enumResult.length).toEqual(70); 112 | }); 113 | }) 114 | 115 | -------------------------------------------------------------------------------- /src/recommender/designGuidelines.v1.js: -------------------------------------------------------------------------------- 1 | import { get } from "../util/util"; 2 | 3 | export const PERCEPTION_CAP = 1.0; 4 | const sameDomain = (pseudoStep, foundFactor) => { 5 | return ( 6 | get( 7 | pseudoStep, 8 | "diff", 9 | "meta", 10 | "scale", 11 | foundFactor.split(".")[1], 12 | "domainSpaceDiff" 13 | ) === false 14 | ); 15 | }; 16 | const diffDomain = (pseudoStep, foundFactor) => { 17 | return ( 18 | get( 19 | pseudoStep, 20 | "diff", 21 | "meta", 22 | "scale", 23 | foundFactor.split(".")[1], 24 | "domainSpaceDiff" 25 | ) === true 26 | ); 27 | }; 28 | const noFactors = factors => { 29 | return function(pseudoStep) { 30 | for (const fctr of factors) { 31 | if (pseudoStep.factorSets.current.indexOf(fctr) >= 0) { 32 | return false; 33 | } 34 | } 35 | return true; 36 | }; 37 | }; 38 | export const PERCEPTION_COST = { 39 | mark: [ 40 | { factor: "marktype", cost: 0.3 }, 41 | { factor: "data", cost: 0.7 }, 42 | { factor: "scale.y", cost: 0.7 }, 43 | { factor: "scale.x", cost: 0.7 }, 44 | { factor: "scale.color", cost: 0.7 }, 45 | { factor: "scale.shape", cost: 0.7 }, 46 | { factor: "scale.size", cost: 0.7 }, 47 | { factor: "encode.x", cost: 0.3 }, 48 | { factor: "encode.y", cost: 0.3 }, 49 | { factor: "encode.color", cost: 0.3 }, 50 | { factor: "encode.shape", cost: 0.3 }, 51 | { factor: "encode.size", cost: 0.3 }, 52 | { factor: "encode.opacity", cost: 0.01 } 53 | ], 54 | axis: [ 55 | // { factor: "scale.*", cost: 0.7 }, 56 | // { factor: "add.*", cost: 1 }, 57 | // { factor: "remove.*", cost: 1 }, 58 | { factor: "scale", cost: 0.7 }, 59 | { factor: "add", cost: 1 }, 60 | { factor: "remove", cost: 1 }, 61 | { factor: "encode", cost: 0.3 } 62 | ], 63 | legend: [ 64 | // { factor: "scale.*", cost: 0.7 }, 65 | // { factor: "add.*", cost: 1 }, 66 | // { factor: "remove.*", cost: 1 }, 67 | { factor: "scale", cost: 0.7 }, 68 | { factor: "add", cost: 1 }, 69 | { factor: "remove", cost: 1 }, 70 | { factor: "encode", cost: 0.3 } 71 | ] 72 | }; 73 | 74 | export const PENALTY_COMBOS = [ 75 | { 76 | chunks: [ 77 | [ 78 | { 79 | compType: "mark", 80 | factor: "scale.y", 81 | with: [diffDomain, noFactors(["encode.y"])] 82 | } 83 | ] 84 | ], 85 | cost: 1.0 86 | } 87 | ]; 88 | export const DISCOUNT_COMBOS = [ 89 | { 90 | chunks: [ 91 | [ 92 | { compType: "mark", factor: "scale.y", with: [sameDomain] }, 93 | { compType: "mark", factor: "scale.x", with: [sameDomain] } 94 | ] 95 | ], 96 | cost: -0.1 97 | }, 98 | 99 | { 100 | chunks: [[{ compType: "mark", factor: "encode.y", with: [sameDomain] }]], 101 | cost: -0.3 102 | }, 103 | { 104 | chunks: [[{ compType: "mark", factor: "encode.x", with: [sameDomain] }]], 105 | cost: -0.3 106 | }, 107 | { 108 | chunks: [ 109 | [ 110 | { compType: "mark", factor: "scale.y" }, 111 | { compType: "axis", factor: "scale.y" } 112 | ], 113 | [ 114 | { compType: "mark", factor: "scale.y" }, 115 | { compType: "axis", factor: "add.y" } 116 | ], 117 | [ 118 | { compType: "mark", factor: "scale.y" }, 119 | { compType: "axis", factor: "remove.y" } 120 | ] 121 | // [{compType: "mark", factor: "encode.y"}, {compType: "axis", factor: "scale.y"}], 122 | // [{compType: "mark", factor: "encode.y"}, {compType: "axis", factor: "add.y"}], 123 | // [{compType: "mark", factor: "encode.y"}, {compType: "axis", factor: "remove.y"}] 124 | ], 125 | cost: -0.7 126 | }, 127 | { 128 | chunks: [ 129 | [ 130 | { compType: "mark", factor: "scale.x" }, 131 | { compType: "axis", factor: "scale.x" } 132 | ], 133 | [ 134 | { compType: "mark", factor: "scale.x" }, 135 | { compType: "axis", factor: "add.x" } 136 | ], 137 | [ 138 | { compType: "mark", factor: "scale.x" }, 139 | { compType: "axis", factor: "remove.x" } 140 | ] 141 | // [{compType: "mark", factor: "encode.x"}, {compType: "axis", factor: "scale.x"}], 142 | // [{compType: "mark", factor: "encode.x"}, {compType: "axis", factor: "add.x"}], 143 | // [{compType: "mark", factor: "encode.x"}, {compType: "axis", factor: "remove.x"}] 144 | ], 145 | cost: -0.7 146 | }, 147 | { 148 | chunks: [ 149 | [ 150 | { compType: "mark", factor: "scale.color" }, 151 | { compType: "legend", contain: "color" } 152 | ] 153 | ], 154 | cost: -0.5 155 | }, 156 | { 157 | chunks: [ 158 | [ 159 | { compType: "mark", factor: "scale.size" }, 160 | { compType: "legend", contain: "size" } 161 | ] 162 | ], 163 | cost: -0.5 164 | }, 165 | { 166 | chunks: [ 167 | [ 168 | { compType: "mark", factor: "scale.shape" }, 169 | { compType: "legend", contain: "shape" } 170 | ] 171 | ], 172 | cost: -0.5 173 | } 174 | ]; 175 | -------------------------------------------------------------------------------- /src/default/encode/legend.js: -------------------------------------------------------------------------------- 1 | import { vegaConfig, vegaConfig as vgConfig } from "../vegaConfig"; 2 | import * as vg from "./vegaDefault"; 3 | import { copy } from "../../util/util"; 4 | const LEGEND_SYMBOL_CHANNEL = [ 5 | "fill", 6 | "opacity", 7 | "shape", 8 | "size", 9 | "stroke", 10 | "strokeDash", 11 | "strokeWidth" 12 | ]; 13 | 14 | export const DEFAULT_ENCODE_LEGEND = { 15 | 16 | title: () => { 17 | const defaultTitleEncode = { 18 | fontSize: { value: vgConfig.style["guide-title"].fontSize }, 19 | fontWeight: { value: "bold" } 20 | }; 21 | 22 | return { 23 | update: defaultTitleEncode, 24 | enter: {...defaultTitleEncode, opacity: {value: 0}}, 25 | exit: {...defaultTitleEncode, opacity: {value: 0}}, 26 | }; 27 | }, 28 | labels: spec => { 29 | if (!spec) { 30 | return copy(vg.EMPTY_ENCODE); 31 | } 32 | 33 | let defaultEncode = { 34 | ...vg.legendLablePos(spec), 35 | text: { field: "label" }, 36 | fontSize: { value: vgConfig.style["guide-label"].fontSize }, 37 | align: { value: vg.legendLabelAlign(spec) }, 38 | baseline: { 39 | value: spec.baseline || vg.baseline(spec.orient) 40 | }, 41 | angle: { 42 | value: spec.labelAngle || vg.lableAngle(spec.orient, spec.scaleType) 43 | } 44 | }; 45 | 46 | 47 | return { 48 | enter: {...defaultEncode, opacity: { value: 0 } }, 49 | exit: {...defaultEncode, opacity: { value: 0 } }, 50 | update: defaultEncode 51 | }; 52 | }, 53 | symbols: spec => { 54 | const columns = !spec ? 1 : (spec.columns || (spec.direction === "vertical" ? 1 : 0)) 55 | const clipHeight = (spec && spec.clipHeight) ? spec.clipHeight : null; 56 | const defaultEncode = { 57 | y: { signal: clipHeight ? `${clipHeight}` : `datum['size']`, mult: 0.5}, 58 | x: { signal: columns ? `datum['offset']` : `datum['size']`, mult: 0.5, offset: vegaConfig.legend.symbolOffset }, 59 | shape: { value: vgConfig.legend.symbolType }, 60 | size: { value: vgConfig.legend.symbolSize }, 61 | strokeWidth: { value: vgConfig.legend.symbolStrokeWidth } 62 | }; 63 | 64 | if (spec) { 65 | if (!spec.fill) { 66 | defaultEncode.stroke = { 67 | value: vgConfig.legend.symbolBaseStrokeColor 68 | }; 69 | defaultEncode.fill = { 70 | value: vgConfig.legend.symbolBaseFillColor 71 | }; 72 | } 73 | 74 | LEGEND_SYMBOL_CHANNEL.forEach(channel => { 75 | if (channel === "shape") { 76 | defaultEncode[channel] = { value: spec.symbolType }; 77 | } 78 | if (spec[channel]) { 79 | defaultEncode[channel] = { scale: spec[channel], field: "value" }; 80 | } 81 | }); 82 | } 83 | 84 | return { 85 | update: defaultEncode, 86 | enter: Object.assign({}, defaultEncode, { opacity: { value: 0 } }), 87 | exit: Object.assign({}, defaultEncode, { opacity: { value: 0 } }) 88 | }; 89 | }, 90 | gradient: spec => { 91 | if (!spec) { 92 | return copy(vg.EMPTY_ENCODE); 93 | } 94 | 95 | let grLength = spec.gradientLength || 96 | { 97 | signal: `clamp(${spec.direction === "vertical" ? "height" : "width"}, 64, ${vgConfig.legend.gradientLength})` 98 | }, 99 | grThickness = spec.gradientThickness || vgConfig.legend.gradientThickness; 100 | if (typeof grLength === "number") { 101 | grLength= { 102 | signal: `clamp(${spec.direction === "vertical" ? "height" : "width"}, 64, ${grLength})` 103 | }; 104 | } 105 | const defaultEncode = { 106 | x: { value: 0 }, 107 | y: { value: 0 }, 108 | width: 109 | spec.direction === "vertical" 110 | ? { value: grThickness } 111 | : grLength, 112 | height: 113 | spec.direction === "vertical" 114 | ? grLength 115 | : { value: grThickness } 116 | }; 117 | 118 | return { 119 | update: defaultEncode, 120 | enter: Object.assign({}, defaultEncode, { opacity: { value: 0 } }), 121 | exit: Object.assign({}, defaultEncode, { opacity: { value: 0 } }) 122 | }; 123 | }, 124 | entries: () => { 125 | return copy(vg.EMPTY_ENCODE); 126 | }, 127 | legend: () => { 128 | return copy(vg.EMPTY_ENCODE); 129 | }, 130 | pairs: spec => { 131 | let defaultEncode = { y: { signal: "datum.index * 13" } }; 132 | if (spec && spec.direction === "horizontal") { 133 | defaultEncode = { x: { signal: "datum.index * 50" } }; 134 | } 135 | 136 | return { 137 | update: defaultEncode, 138 | enter: Object.assign({}, defaultEncode, { opacity: { value: 0 } }), 139 | exit: Object.assign({}, defaultEncode, { opacity: { value: 0 } }) 140 | }; 141 | }, 142 | bands: spec => { 143 | let defaultEncode = { 144 | ...vg.legendBandPos(spec), 145 | fill: { 146 | value: vgConfig.legend.symbolBaseFillColor 147 | } 148 | }; 149 | if (spec && (spec.fill || spec.stroke)) { 150 | defaultEncode.fill = { scale: spec.fill || spec.stroke, field: "value" }; 151 | } 152 | 153 | return { 154 | update: defaultEncode, 155 | enter: { 156 | ...defaultEncode, 157 | opacity: { value: 0 } 158 | }, 159 | exit: { 160 | ...defaultEncode, 161 | opacity: { value: 0 } 162 | } 163 | }; 164 | } 165 | }; -------------------------------------------------------------------------------- /test/examples/transition/stimuliB.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Stimuli B", 3 | "gemSpecs": [ 4 | { 5 | } 6 | ], 7 | "data": [ 8 | {"store": "A", "profit": 1.3, "t": 1}, 9 | {"store": "B", "profit": 1.7, "t": 1}, 10 | {"store": "A", "profit": 0.9, "t": 2}, 11 | {"store": "B", "profit": 1.1, "t": 2}, 12 | {"store": "A", "profit": 1.2, "t": 3}, 13 | {"store": "B", "profit": 1.2, "t": 3}, 14 | {"store": "A", "profit": 1.1, "t": 4}, 15 | {"store": "B", "profit": 1, "t": 4} 16 | ], 17 | "sSpec": { 18 | "$schema": "https://vega.github.io/schema/vega/v5.json", 19 | "autosize": "pad", 20 | "padding": 5, 21 | "height": 200, 22 | "style": "cell", 23 | "data": [ 24 | { 25 | "name": "source_0", 26 | "values": [] 27 | }, 28 | { 29 | "name": "data_0", 30 | "source": "source_0", 31 | "transform": [ 32 | {"type": "filter", "expr": "datum.t === 1", "name": "t"} 33 | ] 34 | } 35 | ], 36 | "signals": [ 37 | {"name": "x_step", "value": 20}, 38 | { 39 | "name": "width", 40 | "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" 41 | } 42 | ], 43 | "marks": [ 44 | { 45 | "name": "marks", 46 | "type": "rect", 47 | "style": ["bar"], 48 | "from": {"data": "data_0"}, 49 | "encode": { 50 | "update": { 51 | "fill": {"value": "#4c78a8"}, 52 | "x": {"scale": "x", "field": "store"}, 53 | "width": {"scale": "x", "band": true}, 54 | "y": {"scale": "y", "field": "profit"}, 55 | "y2": {"scale": "y", "value": 0} 56 | } 57 | } 58 | } 59 | ], 60 | "scales": [ 61 | { 62 | "name": "x", 63 | "type": "band", 64 | "domain": {"data": "data_0", "field": "store", "sort": true}, 65 | "range": {"step": {"signal": "x_step"}}, 66 | "paddingInner": 0.1, 67 | "paddingOuter": 0.05 68 | }, 69 | { 70 | "name": "y", 71 | "type": "linear", 72 | "domain": {"data": "data_0", "field": "profit"}, 73 | "range": [{"signal": "height"}, 0], 74 | "nice": true, 75 | "zero": true 76 | } 77 | ], 78 | "axes": [ 79 | { 80 | "scale": "x", 81 | "orient": "bottom", 82 | "grid": false, 83 | "title": "Store", 84 | "labelAlign": "center", 85 | "labelAngle": 0, 86 | "labelBaseline": "top", 87 | "zindex": 1, 88 | "encode": {"axis": {"name": "x"}} 89 | }, 90 | { 91 | "scale": "y", 92 | "orient": "left", 93 | "grid": true, 94 | "gridScale": "x", 95 | "title": "Profit", 96 | "labelFlush": true, 97 | "labelOverlap": true, 98 | "tickCount": {"signal": "ceil(height/40)"}, 99 | "zindex": 0, 100 | "encode": {"axis": {"name": "y"}} 101 | } 102 | ] 103 | }, 104 | "eSpec": { 105 | "$schema": "https://vega.github.io/schema/vega/v5.json", 106 | "autosize": "pad", 107 | "padding": 5, 108 | "height": 200, 109 | "style": "cell", 110 | "data": [ 111 | { 112 | "name": "source_0", 113 | "values": [] 114 | }, 115 | { 116 | "name": "data_0", 117 | "source": "source_0", 118 | "transform": [ 119 | {"type": "filter", "expr": "datum.t === 4", "name": "t"} 120 | ] 121 | } 122 | ], 123 | "signals": [ 124 | {"name": "x_step", "value": 20}, 125 | { 126 | "name": "width", 127 | "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" 128 | } 129 | ], 130 | "marks": [ 131 | { 132 | "name": "marks", 133 | "type": "rect", 134 | "style": ["bar"], 135 | "from": {"data": "data_0"}, 136 | "encode": { 137 | "update": { 138 | "fill": {"value": "#4c78a8"}, 139 | "x": {"scale": "x", "field": "store"}, 140 | "width": {"scale": "x", "band": true}, 141 | "y": {"scale": "y", "field": "profit"}, 142 | "y2": {"scale": "y", "value": 0} 143 | } 144 | } 145 | } 146 | ], 147 | "scales": [ 148 | { 149 | "name": "x", 150 | "type": "band", 151 | "domain": {"data": "data_0", "field": "store", "sort": true}, 152 | "range": {"step": {"signal": "x_step"}}, 153 | "paddingInner": 0.1, 154 | "paddingOuter": 0.05 155 | }, 156 | { 157 | "name": "y", 158 | "type": "linear", 159 | "domain": {"data": "data_0", "field": "profit"}, 160 | "range": [{"signal": "height"}, 0], 161 | "nice": true, 162 | "zero": true 163 | } 164 | ], 165 | "axes": [ 166 | { 167 | "scale": "x", 168 | "orient": "bottom", 169 | "grid": false, 170 | "title": "Store", 171 | "labelAlign": "center", 172 | "labelAngle": 0, 173 | "labelBaseline": "top", 174 | "zindex": 1, 175 | "encode": {"axis": {"name": "x"}} 176 | }, 177 | { 178 | "scale": "y", 179 | "orient": "left", 180 | "grid": true, 181 | "gridScale": "x", 182 | "title": "Profit", 183 | "labelFlush": true, 184 | "labelOverlap": true, 185 | "tickCount": {"signal": "ceil(height/40)"}, 186 | "zindex": 0, 187 | "encode": {"axis": {"name": "y"}} 188 | } 189 | ] 190 | } 191 | } -------------------------------------------------------------------------------- /test/recommender/timelineGenerator.test.js: -------------------------------------------------------------------------------- 1 | import { default as EXAMPLES } from "../exampleLoader.js"; 2 | import { generateMarkCompStep, generateTimeline } from "../../src/recommender/timelineGenerator"; 3 | import { findAllFactors, enumeratePseudoTimelines } from "../../src/recommender/pseudoTimelineEnumerator"; 4 | import { detectDiffs } from "../../src/recommender/diffDetector"; 5 | import * as vega from "vega"; 6 | 7 | 8 | describe("generateStep", () => { 9 | test("should generate the mark comp step correctly", () => { 10 | let sView = new vega.View(vega.parse(EXAMPLES.addYAxis.sSpec), { renderer: 'svg' }); 11 | let eView = new vega.View(vega.parse(EXAMPLES.addYAxis.eSpec), { renderer: 'svg' }); 12 | //run toSVG to get view.scale("...") 13 | sView.toSVG().then(result => { }); 14 | eView.toSVG().then(result => { }); 15 | 16 | const sVis = { spec: EXAMPLES.addYAxis.sSpec, view: sView }; 17 | const eVis = { spec: EXAMPLES.addYAxis.eSpec, view: eView }; 18 | const detected = detectDiffs( { sVis, eVis } ); 19 | const foundFactors = findAllFactors(detected.compDiffs[1]); 20 | let step = generateMarkCompStep( 21 | { 22 | diff: detected.compDiffs[1], 23 | factorSets: { 24 | applied: ["scale.y", "encode.y"], 25 | all: foundFactors.allFactors, 26 | extraByMarktype: foundFactors.extraFactorsByMarktype 27 | } 28 | }, 29 | {timing: { duration: 2000}} 30 | ); 31 | expect(step.change.scale).toEqual(["y"]) 32 | expect(step.change.encode.update).toEqual(true); 33 | 34 | step = generateMarkCompStep( 35 | { 36 | diff: detected.compDiffs[1], 37 | factorSets: { 38 | applied: ["encode.y"], 39 | all: foundFactors.allFactors, 40 | extraByMarktype: foundFactors.extraFactorsByMarktype 41 | } 42 | }, 43 | {timing: { duration: 2000}} 44 | ); 45 | 46 | expect(step.change.scale).toEqual(false) 47 | expect(step.change.encode.update).toEqual(true); 48 | }) 49 | 50 | test("should generate the the mark comp step correctly", () => { 51 | let sView = new vega.View(vega.parse(EXAMPLES.zoomingOut.sSpec), { renderer: 'svg' }); 52 | let eView = new vega.View(vega.parse(EXAMPLES.zoomingOut.eSpec), { renderer: 'svg' }); 53 | //run toSVG to get view.scale("...") 54 | sView.toSVG().then(result => { }); 55 | eView.toSVG().then(result => { }); 56 | 57 | const sVis = { spec: EXAMPLES.zoomingOut.sSpec, view: sView }; 58 | const eVis = { spec: EXAMPLES.zoomingOut.eSpec, view: eView }; 59 | const detected = detectDiffs( { sVis, eVis } ); 60 | const foundFactors = findAllFactors(detected.compDiffs[3]); 61 | 62 | let step = generateMarkCompStep( 63 | { 64 | diff: detected.compDiffs[3], 65 | factorSets: { 66 | applied: ["scale.y", "scale.x", "marktype"], 67 | all: foundFactors.allFactors, 68 | extraByMarktype: foundFactors.extraFactorsByMarktype 69 | } 70 | }, 71 | {timing: { duration: 2000}} 72 | ); 73 | 74 | expect(step.change.marktype).toEqual(true) 75 | expect(step.change.scale).toEqual(["y", "x"]); 76 | }) 77 | }) 78 | 79 | describe("generateTimeline", ()=> { 80 | test("should create a timeline properly.", () => { 81 | let sView = new vega.View(vega.parse(EXAMPLES.zoomingOut.sSpec), { renderer: 'svg' }); 82 | let eView = new vega.View(vega.parse(EXAMPLES.zoomingOut.eSpec), { renderer: 'svg' }); 83 | //run toSVG to get view.scale("...") 84 | sView.toSVG().then(result => { }); 85 | eView.toSVG().then(result => { }); 86 | 87 | const sVis = { spec: EXAMPLES.zoomingOut.sSpec, view: sView }; 88 | const eVis = { spec: EXAMPLES.zoomingOut.eSpec, view: eView }; 89 | const detected = detectDiffs( { sVis, eVis } ); 90 | 91 | const pseudoTls = enumeratePseudoTimelines(detected, 2, { sVis, eVis }) 92 | // const userInput = { marks: {}, axes: {}, legends: {} }; 93 | const userInput = { 94 | marks: { marks: {change: {data: ["date", "store"]}}}, 95 | axes: {x: {change: {sameDomain: true}}, y: {change: {sameDomain: true}} } 96 | }; 97 | let Tl = generateTimeline(pseudoTls[0], userInput); 98 | 99 | expect(Tl.concat[1].sync[0]).toEqual({"component":{"mark":"marks"},"change":{"scale":["x","y"],"data":["date","store"],"encode":{"update":true,"enter":true,"exit":true},"marktype":true},"timing":{"duration":{"ratio":0.5}}}) 100 | 101 | }) 102 | 103 | test("should create a timeline properly.", () => { 104 | let sView = new vega.View(vega.parse(EXAMPLES.addYAxis.sSpec), { renderer: 'svg' }); 105 | let eView = new vega.View(vega.parse(EXAMPLES.addYAxis.eSpec), { renderer: 'svg' }); 106 | //run toSVG to get view.scale("...") 107 | sView.toSVG().then(result => { }); 108 | eView.toSVG().then(result => { }); 109 | 110 | const sVis = { spec: EXAMPLES.addYAxis.sSpec, view: sView }; 111 | const eVis = { spec: EXAMPLES.addYAxis.eSpec, view: eView }; 112 | const detected = detectDiffs( { sVis, eVis } ); 113 | 114 | const pseudoTls = enumeratePseudoTimelines(detected, 2, { sVis, eVis }) 115 | 116 | // const userInput = { marks: {}, axes: {}, legends: {} }; 117 | const userInput = { 118 | marks: { marks: {change: {data: ["name"]}}}, 119 | axes: {x: {change: {sameDomain: true}}, y: {change: {sameDomain: true}} } 120 | }; 121 | let Tl = generateTimeline(pseudoTls[0], userInput); 122 | 123 | expect(Tl.concat[0].sync[2].component).toEqual("view") 124 | expect(Tl.concat[0].sync[2].change.signal).toEqual(expect.arrayContaining(["width", "height"])); 125 | 126 | 127 | expect(Tl.concat[1].sync.find(stage => stage.component === "view")).toEqual(undefined) 128 | }) 129 | }) -------------------------------------------------------------------------------- /src/default/vegaConfig.js: -------------------------------------------------------------------------------- 1 | // From https://github.com/vega/vega/blob/master/packages/vega-parser/src/config.js 2 | /** 3 | * Standard configuration defaults for Vega specification parsing. 4 | * Users can provide their own (sub-)set of these default values 5 | * by passing in a config object to the top-level parse method. 6 | */ 7 | const defaultFont = "sans-serif", 8 | defaultSymbolSize = 30, 9 | defaultStrokeWidth = 2, 10 | defaultColor = "#4c78a8", 11 | black = "#000", 12 | gray = "#888", 13 | lightGray = "#ddd"; 14 | export const vegaConfig = { 15 | // default visualization description 16 | description: "Vega visualization", 17 | 18 | // default padding around visualization 19 | padding: 0, 20 | 21 | // default for automatic sizing; options: 'none', 'pad', 'fit' 22 | // or provide an object (e.g., {'type': 'pad', 'resize': true}) 23 | autosize: "pad", 24 | 25 | // default view background color 26 | // covers the entire view component 27 | background: null, 28 | 29 | // default event handling configuration 30 | // preventDefault for view-sourced event types except 'wheel' 31 | events: { 32 | defaults: {allow: ["wheel"]} 33 | }, 34 | 35 | // defaults for top-level group marks 36 | // accepts mark properties (fill, stroke, etc) 37 | // covers the data rectangle within group width/height 38 | group: null, 39 | 40 | // defaults for basic mark types 41 | // each subset accepts mark properties (fill, stroke, etc) 42 | mark: null, 43 | arc: { fill: defaultColor }, 44 | area: { fill: defaultColor }, 45 | image: null, 46 | line: { 47 | stroke: defaultColor, 48 | strokeWidth: defaultStrokeWidth 49 | }, 50 | path: { stroke: defaultColor }, 51 | rect: { fill: defaultColor }, 52 | rule: { stroke: black }, 53 | shape: { stroke: defaultColor }, 54 | symbol: { 55 | fill: defaultColor, 56 | size: 64 57 | }, 58 | text: { 59 | fill: black, 60 | font: defaultFont, 61 | fontSize: 11 62 | }, 63 | trail: { 64 | fill: defaultColor, 65 | size: defaultStrokeWidth 66 | }, 67 | 68 | // style definitions 69 | style: { 70 | // axis & legend labels 71 | "guide-label": { 72 | fill: black, 73 | font: defaultFont, 74 | fontSize: 10 75 | }, 76 | // axis & legend titles 77 | "guide-title": { 78 | fill: black, 79 | font: defaultFont, 80 | fontSize: 11, 81 | fontWeight: "bold" 82 | }, 83 | // headers, including chart title 84 | "group-title": { 85 | fill: black, 86 | font: defaultFont, 87 | fontSize: 13, 88 | fontWeight: "bold" 89 | }, 90 | // chart subtitle 91 | "group-subtitle": { 92 | fill: black, 93 | font: defaultFont, 94 | fontSize: 12 95 | }, 96 | // defaults for styled point marks in Vega-Lite 97 | point: { 98 | size: defaultSymbolSize, 99 | strokeWidth: defaultStrokeWidth, 100 | shape: "circle" 101 | }, 102 | circle: { 103 | size: defaultSymbolSize, 104 | strokeWidth: defaultStrokeWidth 105 | }, 106 | square: { 107 | size: defaultSymbolSize, 108 | strokeWidth: defaultStrokeWidth, 109 | shape: "square" 110 | }, 111 | // defaults for styled group marks in Vega-Lite 112 | cell: { 113 | fill: "transparent", 114 | stroke: lightGray 115 | } 116 | }, 117 | 118 | // defaults for title 119 | title: { 120 | orient: "top", 121 | anchor: "middle", 122 | offset: 4, 123 | subtitlePadding: 3 124 | }, 125 | 126 | // defaults for axes 127 | axis: { 128 | minExtent: 0, 129 | maxExtent: 200, 130 | bandPosition: 0.5, 131 | domain: true, 132 | domainWidth: 1, 133 | domainColor: gray, 134 | grid: false, 135 | gridWidth: 1, 136 | gridColor: lightGray, 137 | labels: true, 138 | labelAngle: 0, 139 | labelLimit: 180, 140 | labelOffset: 0, 141 | labelPadding: 2, 142 | ticks: true, 143 | tickColor: gray, 144 | tickOffset: 0, 145 | tickRound: true, 146 | tickSize: 5, 147 | tickWidth: 1, 148 | titlePadding: 4 149 | }, 150 | 151 | // correction for centering bias 152 | axisBand: { 153 | tickOffset: -0.5 154 | }, 155 | 156 | // defaults for cartographic projection 157 | projection: { 158 | type: "mercator" 159 | }, 160 | 161 | // defaults for legends 162 | legend: { 163 | orient: "right", 164 | padding: 0, 165 | gridAlign: "each", 166 | columnPadding: 10, 167 | rowPadding: 2, 168 | symbolDirection: "vertical", 169 | gradientDirection: "vertical", 170 | gradientLength: 200, 171 | gradientThickness: 16, 172 | gradientStrokeColor: lightGray, 173 | gradientStrokeWidth: 0, 174 | gradientLabelOffset: 2, 175 | labelAlign: "left", 176 | labelBaseline: "middle", 177 | labelLimit: 160, 178 | labelOffset: 4, 179 | labelOverlap: true, 180 | symbolLimit: 30, 181 | symbolType: "circle", 182 | symbolSize: 100, 183 | symbolOffset: 0, 184 | symbolStrokeWidth: 1.5, 185 | symbolBaseFillColor: "transparent", 186 | symbolBaseStrokeColor: gray, 187 | titleLimit: 180, 188 | titleOrient: "top", 189 | titlePadding: 5, 190 | layout: { 191 | offset: 18, 192 | direction: "horizontal", 193 | left: { direction: "vertical" }, 194 | right: { direction: "vertical" } 195 | } 196 | }, 197 | 198 | // defaults for scale ranges 199 | range: { 200 | category: { 201 | scheme: "tableau10" 202 | }, 203 | ordinal: { 204 | scheme: "blues" 205 | }, 206 | heatmap: { 207 | scheme: "yellowgreenblue" 208 | }, 209 | ramp: { 210 | scheme: "blues" 211 | }, 212 | diverging: { 213 | scheme: "blueorange", 214 | extent: [1, 0] 215 | }, 216 | symbol: [ 217 | "circle", 218 | "square", 219 | "triangle-up", 220 | "cross", 221 | "diamond", 222 | "triangle-right", 223 | "triangle-down", 224 | "triangle-left" 225 | ] 226 | } 227 | }; 228 | 229 | -------------------------------------------------------------------------------- /src/enumerator.js: -------------------------------------------------------------------------------- 1 | import { parse, codegen as vgCodegen } from "vega-expression"; 2 | import * as d3 from "d3"; 3 | import * as vega from "vega"; 4 | import {copy, flatten} from "./util/util.js"; 5 | import { getEaseFn } from "./actuator/util"; 6 | import { findFilter } from "./changeFetcher/state/util"; 7 | 8 | 9 | class Enumerator { 10 | constructor(enumDef, spec, rawInfo) { 11 | this.views = []; 12 | this.stopN = this.views.length; 13 | this.enumDef = enumDef; 14 | this.currSpec = spec; 15 | this.easeFn = getEaseFn(enumDef.ease); 16 | this.delay = enumDef.delay || 0; 17 | this.staggering = enumDef.staggering; 18 | this.rawInfo = rawInfo; 19 | } 20 | 21 | async init() { 22 | const workingSpec = copy(this.currSpec); 23 | 24 | const enumVals = computeFilteringValues(this.enumDef, this.rawInfo); 25 | const filter = findFilter(workingSpec, this.enumDef.filter); 26 | 27 | 28 | for (const v of enumVals) { 29 | this.views.push(await new vega.View(vega.parse(computeNewSpec(workingSpec, filter, v)), { 30 | renderer: "svg" 31 | }).runAsync()); 32 | } 33 | this.stopN = this.views.length; 34 | } 35 | 36 | _getScales(stop_n) { 37 | return scName => { 38 | const view = this.views[stop_n]; 39 | return view._runtime.scales[scName] 40 | ? view._runtime.scales[scName].value 41 | : undefined; 42 | }; 43 | } 44 | 45 | getData(stop_n) { 46 | if (this.extractData && this.getId) { 47 | return this.extractData(this.views[stop_n]); 48 | } 49 | throw Error("Cannot return data without joining data."); 50 | } 51 | 52 | getDatum(id, stop_n) { 53 | if (this.extractData && this.getId) { 54 | const found = this.extractData(this.views[stop_n]).find( 55 | (d, i) => this.getId(d, i) === id 56 | ); 57 | return found && found.datum ? found.datum : found; 58 | } 59 | throw Error("Cannot return data without joining data."); 60 | } 61 | 62 | joinData(extractData, identifyData) { 63 | this.extractData = extractData; 64 | this.getId = identifyData; 65 | let currDataKeys = this.extractData(this.views[0]).map(this.getId); 66 | 67 | this.joinDataInfo = this.views.slice(1).map(view => { 68 | const join = { 69 | update: [], 70 | enter: [], 71 | exit: [] 72 | }; 73 | const newDataKeys = extractData(view).map(this.getId); 74 | currDataKeys.forEach(cKey => { 75 | const foundIndex = newDataKeys.indexOf(cKey); 76 | if (foundIndex >= 0) { 77 | join.update.push(cKey); 78 | } else { 79 | join.exit.push(cKey); 80 | } 81 | }); 82 | join.enter = newDataKeys.filter(nKey => currDataKeys.indexOf(nKey) < 0); 83 | 84 | currDataKeys = newDataKeys; 85 | 86 | return ["update", "enter", "exit"].reduce((accMap, set) => { 87 | return Object.assign( 88 | accMap, 89 | join[set].reduce((acc, key) => { 90 | acc[key] = set; 91 | return acc; 92 | }, {}) 93 | ); 94 | }, {}); 95 | }); 96 | this.allKeys = flatten(this.joinDataInfo.map(Object.keys)).unique(); 97 | this.set = (id, stop_n) => this.joinDataInfo[stop_n][id]; 98 | } 99 | 100 | getPropVal(prop, encodes, stop_n, id) { 101 | const d = this.extractData(this.views[stop_n]).find( 102 | (x, i) => this.getId(x, i) === id 103 | ); 104 | const dSet = this.set(id, stop_n); 105 | if (!dSet) { 106 | return ["x2", "y2"].indexOf(prop.val) >= 0 ? 0 : ""; 107 | } 108 | const getScales = { 109 | initial: this._getScales(stop_n), 110 | final: this._getScales(stop_n + 1) 111 | }; 112 | return encodes[dSet].initial(prop, getScales, d); 113 | } 114 | 115 | interpolateAlongEnumMaker(prop, encodes, elem) { 116 | return id => { 117 | return t => { 118 | const stop_n = Math.min(Math.floor(t * (this.stopN - 1)), this.stopN - 2); 119 | let d_i = this.extractData(this.views[stop_n]).find( 120 | (x, i) => this.getId(x, i) === id 121 | ); 122 | let d_f = this.extractData(this.views[stop_n + 1]).find( 123 | (x, i) => this.getId(x, i) === id 124 | ); 125 | 126 | const dSet = this.set(id, stop_n); 127 | switch (dSet) { 128 | case "enter": 129 | d_i = d_f; 130 | break; 131 | case "exit": 132 | d_f = d_i; 133 | break; 134 | case "update": 135 | break; 136 | default: 137 | return ["x2", "y2"].indexOf(prop.val) >= 0 ? 0 : ""; 138 | } 139 | const getScales = { 140 | initial: this._getScales(stop_n), 141 | final: this._getScales(stop_n + 1) 142 | }; 143 | if (prop.type === "attrTween") { 144 | return encodes[dSet].custom.bind(elem)(prop, getScales, d_i, d_f)( 145 | t * (this.stopN - 1) - stop_n 146 | ); 147 | } 148 | const valI = encodes[dSet].initial.bind(elem)(prop, getScales, d_i); 149 | const valF = encodes[dSet].final.bind(elem)(prop, getScales, d_f); 150 | 151 | return d3.interpolate(valI, valF)(t * (this.stopN - 1) - stop_n); 152 | // apply one of corresponding interpolation 153 | }; 154 | }; 155 | } 156 | } 157 | 158 | function computeFilteringValues(enumDef, rawInfo) { 159 | const filteringValues = enumDef.values || []; 160 | if (filteringValues.length === 0 && enumDef.stepSize) { 161 | // find initial value 162 | const iFilter = findFilter(rawInfo.sVis.spec, enumDef.filter); 163 | const iVal = parse(iFilter.expr).right.value; 164 | 165 | // find end value 166 | const fFilter = findFilter(rawInfo.eVis.spec, enumDef.filter); 167 | const fVal = parse(fFilter.expr).right.value; 168 | 169 | for (let v = iVal; v < fVal; v += enumDef.stepSize) { 170 | filteringValues.push(v); 171 | } 172 | 173 | filteringValues.push(fVal); 174 | } 175 | return filteringValues; 176 | } 177 | 178 | 179 | function computeNewSpec(workingSpec, filter, fVal) { 180 | const codegen = vgCodegen({ 181 | whitelist: ["datum", "event", "signals"], 182 | globalvar: "global" 183 | }); 184 | 185 | const parsedASTNode = parse(filter.expr); 186 | 187 | parsedASTNode.right.value = fVal; 188 | parsedASTNode.right.raw = fVal.toString(); 189 | const newFilterExpr = codegen(parsedASTNode).code; 190 | filter.expr = newFilterExpr; 191 | 192 | return copy(workingSpec); 193 | } 194 | 195 | 196 | 197 | export { Enumerator, computeFilteringValues, computeNewSpec }; 198 | -------------------------------------------------------------------------------- /test/recommender/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { default as EXAMPLES } from "../exampleLoader.js"; 3 | import { default as recommend, canRecommend } from "../../src/recommender"; 4 | 5 | describe("recommend should return the expected timeline as a top recommendation.", () => { 6 | test("[line: zooming out]", async () => { 7 | let example = EXAMPLES.line; 8 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 9 | let topRec = recommendations[0].pseudoTimeline; 10 | 11 | 12 | expect(topRec.concat[0].sync[0]).toMatchObject({ 13 | diff: {compType: "mark", compName: "marks"}, 14 | factorSets: {current: ["scale.x", "scale.y"]} 15 | }); 16 | expect(!!topRec.concat[0].sync.find(step => step.diff.compType === "axis" && step.diff.compName === "x")).toEqual(true); 17 | expect(!!topRec.concat[0].sync.find(step => step.diff.compType === "axis" && step.diff.compName === "y")).toEqual(true); 18 | expect(topRec.concat[1].sync[0]).toMatchObject({ 19 | diff: {compType: "mark"} 20 | }); 21 | }); 22 | 23 | test("[addYAxis]", async () => { 24 | let example = EXAMPLES.addYAxis; 25 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 26 | let topRec = recommendations[0].pseudoTimeline; 27 | expect(topRec.concat[0].sync[0]).toMatchObject({ 28 | diff: {compType: "axis", compName: "x"} 29 | }); 30 | expect(topRec.concat[0].sync[1]).toMatchObject({ 31 | diff: {compType: "view"} 32 | }); 33 | 34 | expect(topRec.concat[1].sync[1]).toMatchObject({ 35 | diff: {compType: "axis", compName: "y"} 36 | }); 37 | 38 | expect(topRec.concat[1].sync[0]).toMatchObject({ 39 | diff: {compType: "mark"} 40 | }); 41 | }); 42 | 43 | test("[removeLegendUpdateData]", async () => { 44 | let example = EXAMPLES.removeLegendUpdateData; 45 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 46 | let topRec = recommendations[0].pseudoTimeline; 47 | expect(topRec.concat[0].sync[0]).toMatchObject({ 48 | diff: {compType: "mark", compName: "marks"}, 49 | factorSets: {current: ["scale.color", "scale.shape", "encode.color", "encode.shape"]} 50 | }); 51 | expect(topRec.concat[0].sync[1]).toMatchObject({ 52 | diff: {compType: "legend", compName: "legend0"} 53 | }); 54 | expect(topRec.concat[0].sync[2]).toMatchObject({ 55 | diff: {compType: "view"} 56 | }); 57 | expect(topRec.concat[1].sync[0]).toMatchObject({ 58 | diff: {compType: "mark"} 59 | }); 60 | expect(topRec.concat[1].sync[1]).toMatchObject({ 61 | diff: {compType: "axis", compName: "x"} 62 | }); 63 | expect(topRec.concat[1].sync[2]).toMatchObject({ 64 | diff: {compType: "axis", compName: "y"} 65 | }); 66 | }); 67 | 68 | test("[aggregate]",async () => { 69 | let example = EXAMPLES.aggregate; 70 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 71 | let topRec = recommendations[0].pseudoTimeline; 72 | expect(topRec.concat[0].sync[0]).toMatchObject({ 73 | diff: {compType: "mark", compName: "marks"}, 74 | factorSets: {current: ["data", "encode.x", "encode.y"]} 75 | }); 76 | 77 | expect(!!topRec.concat[1].sync.find(step => step.diff.compType === "axis" && step.diff.compName === "x")).toEqual(true); 78 | expect(!!topRec.concat[1].sync.find(step => step.diff.compType === "axis" && step.diff.compName === "y")).toEqual(true); 79 | 80 | expect(topRec.concat[1].sync[0]).toMatchObject({ 81 | diff: {compType: "mark", compName: "marks"} 82 | }); 83 | 84 | }); 85 | 86 | test("[changeYAxis]", async () => { 87 | let example = EXAMPLES.changeYAxis; 88 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 89 | let topRec = recommendations[0].pseudoTimeline; 90 | expect(topRec.concat[0].sync[0]).toMatchObject({ 91 | diff: {compType: "mark", compName: "marks"}, 92 | factorSets: {current: ["data"]} 93 | }); 94 | 95 | expect(topRec.concat[1].sync[0]).toMatchObject({ 96 | diff: {compType: "mark", compName: "marks"}, 97 | factorSets: {current: ["scale.y", "encode.y"]} 98 | }); 99 | expect(topRec.concat[1].sync[1]).toMatchObject({ 100 | diff: {compType: "axis", compName: "y"} 101 | }); 102 | 103 | }); 104 | 105 | test("[changeYEncode_bar]", async () => { 106 | let example = EXAMPLES.changeYEncode_bar; 107 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 108 | let topRec = recommendations[0].pseudoTimeline; 109 | expect(topRec.concat[0].sync[0]).toMatchObject({ 110 | diff: {compType: "mark", compName: "marks"}, 111 | factorSets: {current: ["data", "scale.y", "encode.y"]} 112 | }); 113 | expect(topRec.concat[0].sync[1]).toMatchObject({ 114 | diff: {compType: "axis", compName: "y"} 115 | }); 116 | 117 | expect(topRec.concat[1].sync[0]).toMatchObject({ 118 | diff: {compType: "mark", compName: "marks"}, 119 | factorSets: {current: ["scale.x"]} 120 | }); 121 | expect(topRec.concat[1].sync[1]).toMatchObject({ 122 | diff: {compType: "axis", compName: "x"} 123 | }); 124 | expect(topRec.concat[1].sync[2]).toMatchObject({ 125 | diff: {compType: "view"} 126 | }); 127 | 128 | 129 | }); 130 | 131 | test("[sortBars]", async () => { 132 | let example = EXAMPLES.sortBars; 133 | let recommendations = await recommend(example.sSpec, example.eSpec, example.userInput); 134 | let topRec = recommendations[0].pseudoTimeline; 135 | 136 | 137 | 138 | expect(topRec.concat[0].sync[1]).toMatchObject({ 139 | diff: {compType: "axis", compName: "x"} 140 | }); 141 | expect(topRec.concat[0].sync[0]).toMatchObject({ 142 | diff: {compType: "mark", compName: "marks"}, 143 | factorSets: {current: [ "scale.x"]} 144 | }); 145 | 146 | 147 | 148 | expect(topRec.concat[1].sync[0]).toMatchObject({ 149 | diff: {compType: "mark", compName: "marks"}, 150 | factorSets: {current: [ "scale.y", "encode.y"]} 151 | }); 152 | expect(topRec.concat[1].sync[1]).toMatchObject({ 153 | diff: {compType: "axis", compName: "y"} 154 | }); 155 | }); 156 | }); 157 | 158 | describe("canRecommend should return an error if Gemini cannot recommend for the given input.", () => { 159 | test("multiple marks", async () => { 160 | let example = EXAMPLES.addLayer; 161 | 162 | let isRecommendable = canRecommend(example.sSpec, example.eSpec, 2); 163 | expect(isRecommendable).toMatchObject({reason: "Gemini cannot recomend animations for transitions with multiple marks."}) 164 | 165 | }) 166 | }) -------------------------------------------------------------------------------- /test/examples/transition/stackTogroup.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "test", 3 | "data": [ 4 | {"category":"A", "position":0, "value":0.1}, 5 | {"category":"A", "position":1, "value":0.6}, 6 | {"category":"A", "position":2, "value":0.9}, 7 | {"category":"A", "position":3, "value":0.4}, 8 | {"category":"B", "position":0, "value":0.7}, 9 | {"category":"B", "position":1, "value":0.2}, 10 | {"category":"B", "position":2, "value":1.1}, 11 | {"category":"B", "position":3, "value":0.8}, 12 | {"category":"C", "position":0, "value":0.6}, 13 | {"category":"C", "position":1, "value":0.1}, 14 | {"category":"C", "position":2, "value":0.2}, 15 | {"category":"C", "position":3, "value":0.7} 16 | ], 17 | "sSpec": { 18 | "$schema": "https://vega.github.io/schema/vega/v5.json", 19 | "width": 300, 20 | "height": 240, 21 | "padding": 5, 22 | 23 | "data": [ 24 | { 25 | "name": "source_0", 26 | "values": [] 27 | } 28 | ], 29 | 30 | "scales": [ 31 | { 32 | "name": "yscale", 33 | "type": "band", 34 | "domain": {"data": "source_0", "field": "category"}, 35 | "range": [{"signal": "height"}, 0 ], 36 | "padding": 0.2 37 | }, 38 | { 39 | "name": "xscale", 40 | "type": "linear", 41 | "domain": {"data": "source_0", "field": "value"}, 42 | "range": "width", 43 | "round": true, 44 | "zero": true, 45 | "nice": true 46 | }, 47 | { 48 | "name": "color", 49 | "type": "ordinal", 50 | "domain": {"data": "source_0", "field": "position"}, 51 | "range": {"scheme": "category20"} 52 | } 53 | ], 54 | 55 | "axes": [ 56 | {"orient": "left", "scale": "yscale", "tickSize": 0, "labelPadding": 4, "zindex": 1, "encode": {"axis": {"name": "yscale"}}}, 57 | {"orient": "bottom", "scale": "xscale", "encode": {"axis": {"name": "xscale"}}} 58 | ], 59 | 60 | "marks": [ 61 | { 62 | "type": "group", 63 | 64 | "from": { 65 | "facet": { 66 | "data": "source_0", 67 | "name": "facet", 68 | "groupby": "category" 69 | } 70 | }, 71 | 72 | "encode": { 73 | "enter": { 74 | "y": {"scale": "yscale", "field": "category"} 75 | } 76 | }, 77 | 78 | "signals": [ 79 | {"name": "height", "update": "bandwidth('yscale')"} 80 | ], 81 | 82 | "scales": [ 83 | { 84 | "name": "pos", 85 | "type": "band", 86 | "range": "height", 87 | "domain": {"data": "facet", "field": "position"} 88 | } 89 | ], 90 | 91 | "marks": [ 92 | { 93 | "name": "bars", 94 | "from": {"data": "facet"}, 95 | "type": "rect", 96 | "encode": { 97 | "enter": { 98 | "y": {"scale": "pos", "field": "position"}, 99 | "height": {"scale": "pos", "band": 1}, 100 | "x": {"scale": "xscale", "field": "value"}, 101 | "x2": {"scale": "xscale", "value": 0}, 102 | "fill": {"scale": "color", "field": "position"} 103 | } 104 | } 105 | } 106 | ] 107 | } 108 | ] 109 | }, 110 | "eSpec": { 111 | "$schema": "https://vega.github.io/schema/vega/v5.json", 112 | "width": 300, 113 | "height": 240, 114 | "padding": 5, 115 | 116 | "data": [ 117 | { 118 | "name": "source_0", 119 | "values": [], 120 | "transform": [ 121 | { 122 | "type": "stack", 123 | "groupby": ["category"], 124 | "sort": {"field": "position"}, 125 | "field": "value", 126 | "as": ["value0", "value1"] 127 | } 128 | ] 129 | } 130 | ], 131 | 132 | "scales": [ 133 | { 134 | "name": "yscale", 135 | "type": "band", 136 | "domain": {"data": "source_0", "field": "category"}, 137 | "range": [{"signal": "height"}, 0 ], 138 | "padding": 0.2 139 | }, 140 | { 141 | "name": "xscale", 142 | "type": "linear", 143 | "domain": {"data": "source_0", "field": "value1"}, 144 | "range": "width", 145 | "round": true, 146 | "zero": true, 147 | "nice": true 148 | }, 149 | { 150 | "name": "color", 151 | "type": "ordinal", 152 | "domain": {"data": "source_0", "field": "position"}, 153 | "range": {"scheme": "category20"} 154 | } 155 | ], 156 | 157 | "axes": [ 158 | {"orient": "left", "scale": "yscale", "tickSize": 0, "labelPadding": 4, "zindex": 1, "encode": {"axis": {"name": "yscale"}}}, 159 | {"orient": "bottom", "scale": "xscale", "encode": {"axis": {"name": "xscale"}}} 160 | ], 161 | 162 | "marks": [ 163 | { 164 | "type": "group", 165 | 166 | "from": { 167 | "facet": { 168 | "data": "source_0", 169 | "name": "facet", 170 | "groupby": "category" 171 | } 172 | }, 173 | 174 | "encode": { 175 | "enter": { 176 | "y": {"scale": "yscale", "field": "category"} 177 | } 178 | }, 179 | 180 | "signals": [ 181 | {"name": "height", "update": "bandwidth('yscale')"} 182 | ], 183 | 184 | "scales": [ 185 | { 186 | "name": "pos", 187 | "type": "band", 188 | "range": "height", 189 | "domain": {"data": "facet", "field": "position"} 190 | } 191 | ], 192 | 193 | "marks": [ 194 | { 195 | "name": "bars", 196 | "from": {"data": "facet"}, 197 | "type": "rect", 198 | "encode": { 199 | "enter": { 200 | "y": { "signal": "height/2 - bandwidth('pos') * 0.5"}, 201 | "height": {"scale": "pos", "band": 1}, 202 | "x": {"scale": "xscale", "field": "value0"}, 203 | "x2": {"scale": "xscale", "field": "value1"}, 204 | "fill": {"scale": "color", "field": "position"} 205 | } 206 | } 207 | } 208 | ] 209 | } 210 | ] 211 | }, 212 | "gemSpecs": [ 213 | { 214 | "meta": {"name": "test"}, 215 | "timeline": { 216 | "sync": [ 217 | {"component": {"axis": "xscale"}, "timing": {"duration": {"ratio": 1}}}, 218 | {"component": {"axis": "yscale"}, "timing": {"duration": {"ratio": 1}}} 219 | ] 220 | }, 221 | "totalDuration": 2000 222 | } 223 | ], 224 | "userInput": { 225 | "marks": {"marks": {"change": {"data": ["category"]}}}, 226 | "scales": {"x": {"domainDimension": "same"}, "y": {"domainDimension": "same"}} 227 | } 228 | 229 | } -------------------------------------------------------------------------------- /test/examples/transition/addYAxis.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Adding a Y-Axis", 3 | "sSpec": { 4 | "$schema": "https://vega.github.io/schema/vega/v5.json", 5 | "autosize": "pad", 6 | "padding": 5, 7 | "width": 200, 8 | "height": 20, 9 | "style": "cell", 10 | "data": [ 11 | { 12 | "name": "source_0", 13 | "values": [ 14 | {"t": 1, "A": "3.4", "B": 10, "name": "alex"}, 15 | {"t": 1, "A": "1.2", "B": 12, "name": "bob"}, 16 | {"t": 1, "A": "3.3", "B": 13, "name": "carol"}, 17 | {"t": 1, "A": "0.2", "B": 10, "name": "david"}, 18 | {"t": 1, "A": "5", "B": 12, "name": "eric"}, 19 | {"t": 2, "A": "2.4", "B": 10, "name": "alex"}, 20 | {"t": 2, "A": "1.5", "B": 2, "name": "bob"}, 21 | {"t": 2, "A": "0.7", "B": 5.6, "name": "carol"}, 22 | {"t": 2, "A": "0.2", "B": 16, "name": "daniel"}, 23 | {"t": 2, "A": "-1.3", "B": 12, "name": "eric"} 24 | ] 25 | }, 26 | { 27 | "name": "data_0", 28 | "source": "source_0", 29 | "transform": [ 30 | { 31 | "type": "filter", 32 | "expr": "datum[\"A\"] !== null && !isNaN(datum[\"A\"]) && datum[\"B\"] !== null && !isNaN(datum[\"B\"])" 33 | } 34 | ] 35 | } 36 | ], 37 | "signals": [{"name": "height", "update": "height"}], 38 | "marks": [ 39 | { 40 | "name": "marks", 41 | "type": "symbol", 42 | "style": ["circle"], 43 | "from": {"data": "source_0"}, 44 | "encode": { 45 | "update": { 46 | "opacity": {"value": 0.7}, 47 | "fill": {"value": "#4c78a8"}, 48 | "x": {"scale": "x", "field": "A"}, 49 | "y": {"signal": "height", "mult": 0.5}, 50 | "shape": {"value": "circle"} 51 | } 52 | } 53 | } 54 | ], 55 | "scales": [ 56 | { 57 | "name": "x", 58 | "type": "linear", 59 | "domain": {"data": "source_0", "field": "A"}, 60 | "range": [0, {"signal": "width"}], 61 | "nice": true, 62 | "zero": true 63 | } 64 | ], 65 | "axes": [ 66 | { 67 | "scale": "x", 68 | "orient": "bottom", 69 | "grid": true, 70 | "title": "A", 71 | "labelFlush": true, 72 | "labelOverlap": true, 73 | "tickCount": {"signal": "ceil(width/40)"}, 74 | "zindex": 0, 75 | "encode": { 76 | "axis": { 77 | "name": "x" 78 | } 79 | } 80 | } 81 | ] 82 | }, 83 | "eSpec": { 84 | "$schema": "https://vega.github.io/schema/vega/v5.json", 85 | "autosize": "pad", 86 | "padding": 5, 87 | "width": 200, 88 | "height": 200, 89 | "style": "cell", 90 | "data": [ 91 | { 92 | "name": "source_0", 93 | "values": [ 94 | {"t": 1, "A": "3.4", "B": 10, "name": "alex"}, 95 | {"t": 1, "A": "1.2", "B": 12, "name": "bob"}, 96 | {"t": 1, "A": "3.3", "B": 13, "name": "carol"}, 97 | {"t": 1, "A": "0.2", "B": 10, "name": "david"}, 98 | {"t": 1, "A": "5", "B": 12, "name": "eric"}, 99 | {"t": 2, "A": "2.4", "B": 10, "name": "alex"}, 100 | {"t": 2, "A": "1.5", "B": 2, "name": "bob"}, 101 | {"t": 2, "A": "0.7", "B": 5.6, "name": "carol"}, 102 | {"t": 2, "A": "0.2", "B": 16, "name": "daniel"}, 103 | {"t": 2, "A": "-1.3", "B": 12, "name": "eric"} 104 | ] 105 | }, 106 | { 107 | "name": "data_0", 108 | "source": "source_0", 109 | "transform": [ 110 | { 111 | "type": "filter", 112 | "expr": "datum[\"A\"] !== null && !isNaN(datum[\"A\"]) && datum[\"B\"] !== null && !isNaN(datum[\"B\"])" 113 | } 114 | ] 115 | } 116 | ], 117 | "signals": [{"name": "height", "update": "height"}], 118 | "marks": [ 119 | { 120 | "name": "marks", 121 | "type": "symbol", 122 | "style": ["circle"], 123 | "from": {"data": "source_0"}, 124 | "encode": { 125 | "update": { 126 | "opacity": {"value": 0.7}, 127 | "fill": {"value": "#4c78a8"}, 128 | "x": {"scale": "x", "field": "A"}, 129 | "y": {"scale": "y", "field": "B"}, 130 | "shape": {"value": "circle"} 131 | } 132 | } 133 | } 134 | ], 135 | "scales": [ 136 | { 137 | "name": "x", 138 | "type": "linear", 139 | "domain": {"data": "source_0", "field": "A"}, 140 | "range": [0, {"signal": "width"}], 141 | "nice": true, 142 | "zero": true 143 | }, 144 | { 145 | "name": "y", 146 | "type": "linear", 147 | "domain": {"data": "source_0", "field": "B"}, 148 | "range": [{"signal": "height"}, 0], 149 | "nice": true, 150 | "zero": true 151 | } 152 | ], 153 | "axes": [ 154 | { 155 | "scale": "x", 156 | "orient": "bottom", 157 | "grid": true, 158 | "gridScale": "y", 159 | "title": "A", 160 | "labelFlush": true, 161 | "labelOverlap": true, 162 | "tickCount": {"signal": "ceil(width/40)"}, 163 | "zindex": 0, 164 | "encode": { 165 | "axis": { 166 | "name": "x" 167 | } 168 | } 169 | }, 170 | { 171 | "scale": "y", 172 | "orient": "left", 173 | "grid": true, 174 | "gridScale": "x", 175 | "title": "B", 176 | "labelFlush": true, 177 | "labelOverlap": true, 178 | "tickCount": {"signal": "ceil(width/40)"}, 179 | "zindex": 0, 180 | "encode": { 181 | "axis": { 182 | "name": "y" 183 | } 184 | } 185 | } 186 | ] 187 | }, 188 | "gemSpecs": [ 189 | { 190 | "meta": {"name": "View -> Mark"}, 191 | "timeline": { 192 | "concat": [ 193 | { 194 | "sync": [ 195 | { 196 | "component": "view", 197 | "timing": { "duration": 1000 } 198 | }, 199 | { 200 | "component": {"axis": "x"}, 201 | "timing": { "duration": 1000 } 202 | }, 203 | { 204 | "component": {"axis": "y"}, 205 | "timing": { "duration": 1000 } 206 | } 207 | ] 208 | }, 209 | { 210 | "sync": [ 211 | { 212 | "component": {"mark": "marks"}, 213 | "timing": { "duration": 1000 } 214 | } 215 | ] 216 | } 217 | ] 218 | } 219 | } 220 | ], 221 | "userInput": { 222 | "marks": {"marks": {"change": {"data": ["name"]}}}, 223 | "scales": {"x": {"domainDimension": "same"}, "y": {"domainDimension": "diff"}} 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/changeFetcher/change.js: -------------------------------------------------------------------------------- 1 | import { copy } from "../util/util"; 2 | import { getLegendType } from "../util/vgSpecHelper"; 3 | 4 | function getComponents(vgSpec) { 5 | // By traveling vgSpec, collect the marks, axes, legends, and scales with their bound data and encoding. 6 | const components = []; 7 | function collectComp(mark, currComp, isRoot = false) { 8 | if (isRoot) { 9 | mark.name = mark.name || "root"; 10 | } 11 | let newComp = currComp; 12 | if (mark.axes) { 13 | newComp = newComp.concat( 14 | mark.axes.map(d => { 15 | return { 16 | ...copy(d), 17 | compType: "axis" 18 | }; 19 | }) 20 | ); 21 | } 22 | 23 | if (mark.legends) { 24 | newComp = newComp.concat( 25 | mark.legends.map(d => { return { ...copy(d), compType: "legend" }; }) 26 | ); 27 | } 28 | 29 | if (mark.scales) { 30 | newComp = newComp.concat( 31 | mark.scales.map(d => { return { ...copy(d), compType: "scale" }; }) 32 | ); 33 | } 34 | 35 | if (!isRoot) { 36 | const newMark = { ...copy(mark), compType: "mark" }; 37 | delete newMark.marks; 38 | newComp.push(newMark); 39 | } 40 | 41 | if (mark.marks) { 42 | newComp = mark.marks.reduce((acc, curr) => { 43 | const subComps = collectComp( 44 | { ...curr, parent: (mark || "root") }, 45 | [] 46 | ); 47 | return acc.concat(subComps); 48 | }, newComp); 49 | } 50 | return newComp; 51 | } 52 | 53 | return collectComp(copy(vgSpec), components, true); 54 | } 55 | function getChanges(sComponents, eComponents) { 56 | const IDENTIFIERS = { 57 | axis: comp => 58 | comp.encode && comp.encode.axis ? comp.encode.axis.name : comp.scale, 59 | legend: comp => 60 | comp.encode && comp.encode.legend ? comp.encode.legend.name : comp.scale, 61 | mark: comp => comp.name, 62 | scale: comp => comp.name 63 | }; 64 | const merged = []; 65 | sComponents.forEach(sComp => { 66 | const id = IDENTIFIERS[sComp.compType]; 67 | const matchedId = eComponents.findIndex( 68 | eComp => sComp.compType === eComp.compType && id(sComp) === id(eComp) 69 | ); 70 | if (matchedId >= 0) { 71 | merged.push({ 72 | compType: sComp.compType, 73 | compName: id(sComp), 74 | parent: sComp.parent, 75 | initial: sComp, 76 | final: eComponents.splice(matchedId, 1)[0] 77 | }); 78 | } else { 79 | merged.push({ 80 | compType: sComp.compType, 81 | compName: id(sComp), 82 | parent: sComp.parent, 83 | initial: sComp, 84 | final: null 85 | }); 86 | } 87 | }); 88 | 89 | return merged.concat( 90 | eComponents.map(eComp => { 91 | const id = IDENTIFIERS[eComp.compType]; 92 | return { 93 | compType: eComp.compType, 94 | compName: id(eComp), 95 | parent: eComp.parent, 96 | initial: null, 97 | final: eComp 98 | }; 99 | }) 100 | ); 101 | } 102 | 103 | function getViewChange(rawInfo) { 104 | return { 105 | compType: "view", 106 | compName: "global", 107 | 108 | initial: { 109 | viewWidth: rawInfo.sVis.view._viewWidth, 110 | viewHeight: rawInfo.sVis.view._viewHeight, 111 | width: rawInfo.sVis.view.width(), 112 | height: rawInfo.sVis.view.height(), 113 | x: rawInfo.sVis.view._origin[0], 114 | y: rawInfo.sVis.view._origin[1], 115 | padding: rawInfo.sVis.spec.padding 116 | }, 117 | final: { 118 | viewWidth: rawInfo.eVis.view._viewWidth, 119 | viewHeight: rawInfo.eVis.view._viewHeight, 120 | width: rawInfo.eVis.view.width(), 121 | height: rawInfo.eVis.view.height(), 122 | x: rawInfo.eVis.view._origin[0], 123 | y: rawInfo.eVis.view._origin[1], 124 | padding: rawInfo.eVis.spec.padding 125 | } 126 | }; 127 | } 128 | 129 | 130 | function getDefaultChange(step, rawInfo) { 131 | const change = copy(step.change || {}); 132 | if (step.compType === "mark") { 133 | change.data = change.data === undefined ? true : change.data; 134 | if ( 135 | change.data.update === false && 136 | change.data.enter === false && 137 | change.data.exit === false 138 | ) { 139 | change.data = false; 140 | } 141 | change.marktype = change.marktype === undefined ? true : change.marktype; 142 | change.scale = change.scale === undefined ? true : change.scale; 143 | change.signal = change.signal === undefined ? true : change.signal; 144 | change.encode = change.encode === undefined ? true : change.encode; 145 | } else if (step.compType === "axis" || step.compType === "legend") { 146 | change.signal = change.signal === undefined ? true : change.signal; 147 | change.scale = change.scale === undefined ? {} : change.scale; 148 | 149 | if (step.compType === "legend") { 150 | change.scale = change.scale === undefined ? true : change.scale; 151 | change.signal = change.signal === undefined ? true : change.signal; 152 | // specify the type of the legend 153 | if (change.initial) { 154 | change.initial = Object.assign( 155 | change.initial, 156 | getLegendType(change.initial, rawInfo.sVis.view) 157 | ); 158 | 159 | change.initial.direction = change.initial.direction || "vertical"; 160 | change.initial.orient = change.initial.orient || "right"; 161 | } 162 | if (change.final) { 163 | change.final = Object.assign( 164 | change.final, 165 | getLegendType(change.final, rawInfo.eVis.view) 166 | ); 167 | change.final.direction = change.final.direction || "vertical"; 168 | change.final.orient = change.final.orient || "right"; 169 | } 170 | } 171 | } else if (step.compType === "view") { 172 | change.signal = change.signal === undefined ? true : change.signal; 173 | } 174 | return change; 175 | } 176 | 177 | 178 | function attachChanges(rawInfo, schedule) { 179 | const changes = getChanges( 180 | getComponents(rawInfo.sVis.spec), 181 | getComponents(rawInfo.eVis.spec) 182 | ); 183 | 184 | // attach the view change 185 | changes.push(getViewChange(rawInfo)); 186 | 187 | schedule.forEach(track => { 188 | track.steps = track.steps.map(step => { 189 | if (step.compType === "pause") { 190 | return step; 191 | } 192 | 193 | const found = changes.find(change => { 194 | return ( 195 | change.compType === step.compType && 196 | (change.compName === step.compName || step.compType === "view") 197 | ); 198 | }); 199 | if (!found) { 200 | console.error(`cannot found the changes of ${step.compName}.`); 201 | } 202 | 203 | step.change = { 204 | ...step.change, 205 | ...found 206 | }; 207 | step.change = { 208 | ...step.change, 209 | ...getDefaultChange(step, rawInfo) 210 | }; 211 | 212 | return step; 213 | }); 214 | }); 215 | 216 | return schedule; 217 | } 218 | 219 | export { getComponents, getChanges, getViewChange, getDefaultChange, attachChanges }; 220 | -------------------------------------------------------------------------------- /test/examples/transition/changeYEncode_bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "[AutoGen]Change Y Encode of The Bars.", 3 | "sSpec": { 4 | "$schema": "https://vega.github.io/schema/vega/v5.json", 5 | "description": "A scatterplot showing horsepower and miles per gallons.", 6 | "autosize": "pad", 7 | "padding": 5, 8 | "width": 200, 9 | "style": "cell", 10 | "data": [ 11 | { 12 | "name": "source_0", 13 | "values": [ 14 | {"t": 1, "A": "3.4", "B": 10, "name": "alex"}, 15 | {"t": 1, "A": "1.2", "B": 12, "name": "bob"}, 16 | {"t": 1, "A": "3.3", "B": 13, "name": "carol"}, 17 | {"t": 1, "A": "0.2", "B": 10, "name": "david"}, 18 | {"t": 1, "A": "5", "B": 12, "name": "eric"}, 19 | {"t": 2, "A": "2.4", "B": 10, "name": "alex"}, 20 | {"t": 2, "A": "1.5", "B": 2, "name": "bob"}, 21 | {"t": 2, "A": "0.7", "B": 5.6, "name": "carol"}, 22 | {"t": 2, "A": "0.2", "B": 16, "name": "daniel"}, 23 | {"t": 2, "A": "-1.3", "B": 12, "name": "eric"} 24 | ] 25 | }, 26 | { 27 | "name": "data_0", 28 | "source": "source_0", 29 | "transform": [ 30 | { 31 | "type": "aggregate", 32 | "groupby": ["name"], 33 | "ops": ["mean"], 34 | "fields": ["A"], 35 | "as": ["mean_A"] 36 | } 37 | ] 38 | } 39 | ], 40 | "signals": [ 41 | {"name": "y_step", "value": 20}, 42 | { 43 | "name": "height", 44 | "update": "bandspace(domain('y').length, 0.1, 0.05) * y_step" 45 | } 46 | ], 47 | "marks": [ 48 | { 49 | "name": "marks", 50 | "type": "rect", 51 | "style": ["bar"], 52 | "from": {"data": "data_0"}, 53 | "encode": { 54 | "update": { 55 | "fill": {"value": "#4c78a8"}, 56 | "tooltip": { 57 | "signal": "{\"Mean of A\": format(datum[\"mean_A\"], \"\"), \"name\": ''+datum[\"name\"]}" 58 | }, 59 | "x": {"scale": "x", "field": "mean_A"}, 60 | "x2": {"scale": "x", "value": 0}, 61 | "y": {"scale": "y", "field": "name"}, 62 | "height": {"scale": "y", "band": true} 63 | } 64 | } 65 | } 66 | ], 67 | "scales": [ 68 | { 69 | "name": "x", 70 | "type": "linear", 71 | "domain": {"data": "data_0", "field": "mean_A"}, 72 | "range": [0, {"signal": "width"}], 73 | "nice": true, 74 | "zero": true 75 | }, 76 | { 77 | "name": "y", 78 | "type": "band", 79 | "domain": {"data": "data_0", "field": "name", "sort": true}, 80 | "range": {"step": {"signal": "y_step"}}, 81 | "paddingInner": 0.1, 82 | "paddingOuter": 0.05 83 | } 84 | ], 85 | "axes": [ 86 | { 87 | "scale": "x", 88 | "orient": "bottom", 89 | "grid": true, 90 | "title": "Mean of A", 91 | "labelFlush": true, 92 | "labelOverlap": true, 93 | "tickCount": {"signal": "ceil(width/40)"}, 94 | "zindex": 0, 95 | "gridScale": "y", 96 | "encode": {"axis": {"name": "x"}} 97 | }, 98 | { 99 | "scale": "y", 100 | "orient": "left", 101 | "grid": false, 102 | "title": "name", 103 | "zindex": 1, 104 | "encode": {"axis": {"name": "y"}} 105 | } 106 | ] 107 | }, 108 | "eSpec": { 109 | "$schema": "https://vega.github.io/schema/vega/v5.json", 110 | "description": "A scatterplot showing horsepower and miles per gallons.", 111 | "autosize": "pad", 112 | "padding": 5, 113 | "width": 200, 114 | "style": "cell", 115 | "data": [ 116 | { 117 | "name": "source_0", 118 | "values": [ 119 | {"t": 1, "A": "3.4", "B": 10, "name": "alex"}, 120 | {"t": 1, "A": "1.2", "B": 12, "name": "bob"}, 121 | {"t": 1, "A": "3.3", "B": 13, "name": "carol"}, 122 | {"t": 1, "A": "0.2", "B": 10, "name": "david"}, 123 | {"t": 1, "A": "5", "B": 12, "name": "eric"}, 124 | {"t": 2, "A": "2.4", "B": 10, "name": "alex"}, 125 | {"t": 2, "A": "1.5", "B": 2, "name": "bob"}, 126 | {"t": 2, "A": "0.7", "B": 5.6, "name": "carol"}, 127 | {"t": 2, "A": "0.2", "B": 16, "name": "daniel"}, 128 | {"t": 2, "A": "-1.3", "B": 12, "name": "eric"} 129 | ] 130 | }, 131 | { 132 | "name": "data_0", 133 | "source": "source_0", 134 | "transform": [ 135 | { 136 | "type": "aggregate", 137 | "groupby": ["t"], 138 | "ops": ["mean"], 139 | "fields": ["A"], 140 | "as": ["mean_A"] 141 | } 142 | ] 143 | } 144 | ], 145 | "signals": [ 146 | {"name": "y_step", "value": 20}, 147 | { 148 | "name": "height", 149 | "update": "bandspace(domain('y').length, 0.1, 0.05) * y_step" 150 | } 151 | ], 152 | "marks": [ 153 | { 154 | "name": "marks", 155 | "type": "rect", 156 | "style": ["bar"], 157 | "from": {"data": "data_0"}, 158 | "encode": { 159 | "update": { 160 | "fill": {"value": "#4c78a8"}, 161 | "tooltip": { 162 | "signal": "{\"Mean of A\": format(datum[\"mean_A\"], \"\"), \"t\": ''+datum[\"t\"]}" 163 | }, 164 | "x": {"scale": "x", "field": "mean_A"}, 165 | "x2": {"scale": "x", "value": 0}, 166 | "y": {"scale": "y", "field": "t"}, 167 | "height": {"scale": "y", "band": true} 168 | } 169 | } 170 | } 171 | ], 172 | "scales": [ 173 | { 174 | "name": "x", 175 | "type": "linear", 176 | "domain": {"data": "data_0", "field": "mean_A"}, 177 | "range": [0, {"signal": "width"}], 178 | "nice": true, 179 | "zero": true 180 | }, 181 | { 182 | "name": "y", 183 | "type": "band", 184 | "domain": {"data": "data_0", "field": "t", "sort": true}, 185 | "range": {"step": {"signal": "y_step"}}, 186 | "paddingInner": 0.1, 187 | "paddingOuter": 0.05 188 | } 189 | ], 190 | "axes": [ 191 | { 192 | "scale": "x", 193 | "orient": "bottom", 194 | "grid": true, 195 | "title": "Mean of A", 196 | "labelFlush": true, 197 | "labelOverlap": true, 198 | "tickCount": {"signal": "ceil(width/40)"}, 199 | "zindex": 0, 200 | "gridScale": "y", 201 | "encode": {"axis": {"name": "x"}} 202 | }, 203 | { 204 | "scale": "y", 205 | "orient": "left", 206 | "grid": false, 207 | "title": "t", 208 | "zindex": 1, 209 | "encode": {"axis": {"name": "y"}} 210 | } 211 | ] 212 | }, 213 | "recommend": true, 214 | "userInput": { 215 | "marks": { "marks": {"change": {"data": null }}}, 216 | "axes": {"x": {"change": {"sameDomain": true}}, "y": {"change": {"sameDomain": false}} }, 217 | "scales": { 218 | "x": {"sameDomain": true}, 219 | "y": {"sameDomain": false} 220 | } 221 | } 222 | } -------------------------------------------------------------------------------- /test/examples/transition/removeLegendUpdateData.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "[AutoGen]Remove a legend and update data", 3 | "data": [ 4 | {"t": 1, "A": 3.4, "B": 10, "name": "alex"}, 5 | {"t": 1, "A": 1.2, "B": 12, "name": "bob"}, 6 | {"t": 1, "A": 3.3, "B": 13, "name": "carol"}, 7 | {"t": 1, "A": 0.2, "B": 10, "name": "david"}, 8 | {"t": 2, "A": 2.4, "B": 10, "name": "alex"}, 9 | {"t": 2, "A": 1.5, "B": 2, "name": "bob"}, 10 | {"t": 2, "A": 0.7, "B": 5.6, "name": "carol"}, 11 | {"t": 2, "A": 0.2, "B": 16, "name": "daniel"}, 12 | {"t": 2, "A": -1.3, "B": 12, "name": "eric"} 13 | ], 14 | "sSpec": { 15 | "$schema": "https://vega.github.io/schema/vega/v5.json", 16 | "description": "A scatterplot showing horsepower and miles per gallons.", 17 | "autosize": "pad", 18 | "padding": 5, 19 | "width": 200, 20 | "height": 200, 21 | "style": "cell", 22 | "data": [ 23 | { 24 | "name": "source_0", 25 | "values": [ ] 26 | }, 27 | { 28 | "name": "data_0", 29 | "source": "source_0", 30 | "transform": [ 31 | {"type": "filter", "expr": "datum.t === 1"}, 32 | { 33 | "type": "filter", 34 | "expr": "datum[\"A\"] !== null && !isNaN(datum[\"A\"]) && datum[\"B\"] !== null && !isNaN(datum[\"B\"])" 35 | } 36 | ] 37 | } 38 | ], 39 | "marks": [ 40 | { 41 | "name": "marks", 42 | "type": "symbol", 43 | "style": ["point"], 44 | "from": {"data": "data_0"}, 45 | "encode": { 46 | "update": { 47 | "opacity": {"value": 0.7}, 48 | "fill": {"value": "transparent"}, 49 | "stroke": {"scale": "color", "field": "name"}, 50 | "tooltip": { 51 | "signal": "{\"A\": format(datum[\"A\"], \"\"), \"B\": format(datum[\"B\"], \"\"), \"name\": ''+datum[\"name\"]}" 52 | }, 53 | "x": {"scale": "x", "field": "A"}, 54 | "y": {"scale": "y", "field": "B"}, 55 | "shape": {"scale": "shape", "field": "name"} 56 | } 57 | } 58 | } 59 | ], 60 | "scales": [ 61 | { 62 | "name": "x", 63 | "type": "linear", 64 | "domain": {"data": "data_0", "field": "A"}, 65 | "range": [0, {"signal": "width"}], 66 | "nice": true, 67 | "zero": true 68 | }, 69 | { 70 | "name": "y", 71 | "type": "linear", 72 | "domain": {"data": "data_0", "field": "B"}, 73 | "range": [{"signal": "height"}, 0], 74 | "nice": true, 75 | "zero": true 76 | }, 77 | { 78 | "name": "color", 79 | "type": "ordinal", 80 | "domain": {"data": "data_0", "field": "name", "sort": true}, 81 | "range": "category" 82 | }, 83 | { 84 | "name": "shape", 85 | "type": "ordinal", 86 | "domain": {"data": "data_0", "field": "name", "sort": true}, 87 | "range": "symbol" 88 | } 89 | ], 90 | "axes": [ 91 | { 92 | "scale": "x", 93 | "orient": "bottom", 94 | "grid": true, 95 | "title": "A", 96 | "labelFlush": true, 97 | "labelOverlap": true, 98 | "tickCount": {"signal": "ceil(width/40)"}, 99 | "zindex": 0, 100 | "gridScale": "y", 101 | "encode": {"axis": {"name": "x"}} 102 | }, 103 | { 104 | "scale": "y", 105 | "orient": "left", 106 | "grid": true, 107 | "title": "B", 108 | "labelOverlap": true, 109 | "tickCount": {"signal": "ceil(height/40)"}, 110 | "zindex": 0, 111 | "gridScale": "x", 112 | "encode": {"axis": {"name": "y"}} 113 | } 114 | ], 115 | "legends": [ 116 | { 117 | "stroke": "color", 118 | "gradientLength": {"signal": "clamp(height, 64, 200)"}, 119 | "symbolType": "circle", 120 | "title": "name", 121 | "encode": { 122 | "symbols": { 123 | "update": { 124 | "fill": {"value": "transparent"}, 125 | "opacity": {"value": 0.7} 126 | } 127 | }, 128 | "legend": {"name": "legend0"} 129 | }, 130 | "shape": "shape" 131 | } 132 | ] 133 | }, 134 | "eSpec": { 135 | "$schema": "https://vega.github.io/schema/vega/v5.json", 136 | "description": "A scatterplot showing horsepower and miles per gallons.", 137 | "autosize": "pad", 138 | "padding": 5, 139 | "width": 200, 140 | "height": 200, 141 | "style": "cell", 142 | "data": [ 143 | { 144 | "name": "source_0", 145 | "values": [ ] 146 | }, 147 | { 148 | "name": "data_0", 149 | "source": "source_0", 150 | "transform": [ 151 | {"type": "filter", "expr": "datum.t === 2"}, 152 | { 153 | "type": "filter", 154 | "expr": "datum[\"A\"] !== null && !isNaN(datum[\"A\"]) && datum[\"B\"] !== null && !isNaN(datum[\"B\"])" 155 | } 156 | ] 157 | } 158 | ], 159 | "marks": [ 160 | { 161 | "name": "marks", 162 | "type": "symbol", 163 | "style": ["point"], 164 | "from": {"data": "data_0"}, 165 | "encode": { 166 | "update": { 167 | "opacity": {"value": 0.7}, 168 | "fill": {"value": "transparent"}, 169 | "stroke": {"value": "#4c78a8"}, 170 | "tooltip": { 171 | "signal": "{\"A\": format(datum[\"A\"], \"\"), \"B\": format(datum[\"B\"], \"\")}" 172 | }, 173 | "x": {"scale": "x", "field": "A"}, 174 | "y": {"scale": "y", "field": "B"} 175 | } 176 | } 177 | } 178 | ], 179 | "scales": [ 180 | { 181 | "name": "x", 182 | "type": "linear", 183 | "domain": {"data": "data_0", "field": "A"}, 184 | "range": [0, {"signal": "width"}], 185 | "nice": true, 186 | "zero": true 187 | }, 188 | { 189 | "name": "y", 190 | "type": "linear", 191 | "domain": {"data": "data_0", "field": "B"}, 192 | "range": [{"signal": "height"}, 0], 193 | "nice": true, 194 | "zero": true 195 | } 196 | ], 197 | "axes": [ 198 | { 199 | "scale": "x", 200 | "orient": "bottom", 201 | "grid": true, 202 | "title": "A", 203 | "labelFlush": true, 204 | "labelOverlap": true, 205 | "tickCount": {"signal": "ceil(width/40)"}, 206 | "zindex": 0, 207 | "gridScale": "y", 208 | "encode": {"axis": {"name": "x"}} 209 | }, 210 | { 211 | "scale": "y", 212 | "orient": "left", 213 | "grid": true, 214 | "title": "B", 215 | "labelOverlap": true, 216 | "tickCount": {"signal": "ceil(height/40)"}, 217 | "zindex": 0, 218 | "gridScale": "x", 219 | "encode": {"axis": {"name": "y"}} 220 | } 221 | ] 222 | }, 223 | "recommend": true, 224 | "userInput": { 225 | "marks": { "marks": {"change": {"data": ["name"]}}}, 226 | "axes": {"x": {"change": {"sameDomain": true}}, "y": {"change": {"sameDomain": true}} }, 227 | "scales": { 228 | "x": {"sameDomain": true}, 229 | "y": {"sameDomain": true} 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /test/examples/transition/barToPoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Bar to Points", 3 | "gemSpecs": [ 4 | { 5 | "meta": {"name": "Morph and Disaggregate"}, 6 | "timeline": { 7 | "concat": [ 8 | { 9 | "sync": [ 10 | { 11 | "component": {"mark": "marks"}, 12 | "change": { 13 | "data": false, 14 | "scale": false, 15 | "encode": { "update": false } 16 | }, 17 | "timing": {"duration": {"ratio": 0.5}} 18 | } 19 | ] 20 | }, 21 | { 22 | "sync": [ 23 | { 24 | "component": {"mark": "marks"}, 25 | 26 | "timing": {"duration": {"ratio": 0.5}} 27 | }, 28 | {"component": {"axis": "y"}, "timing": {"duration": {"ratio": 0.5}}} 29 | ] 30 | } 31 | ] 32 | }, 33 | "totalDuration": 2000 34 | } 35 | ], 36 | "sSpec": { 37 | "$schema": "https://vega.github.io/schema/vega/v5.json", 38 | "autosize": "pad", 39 | "padding": 5, 40 | "height": 200, 41 | "style": "cell", 42 | "data": [ 43 | { 44 | "name": "source_0", 45 | "values": [ 46 | {"A": "a", "B": 10}, 47 | {"A": "a", "B": 12}, 48 | {"A": "a", "B": 13}, 49 | {"A": "b", "B": 10}, 50 | {"A": "b", "B": 12}, 51 | {"A": "a", "B": 9.6}, 52 | {"A": "a", "B": 11}, 53 | {"A": "a", "B": 7}, 54 | {"A": "b", "B": 3}, 55 | {"A": "b", "B": 11} 56 | ] 57 | }, 58 | { 59 | "name": "data_0", 60 | "source": "source_0", 61 | "transform": [ 62 | { 63 | "type": "aggregate", 64 | "groupby": ["A"], 65 | "ops": ["mean"], 66 | "fields": ["B"], 67 | "as": ["mean_B"] 68 | } 69 | ] 70 | } 71 | ], 72 | "signals": [ 73 | {"name": "x_step", "value": 20}, 74 | { 75 | "name": "width", 76 | "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" 77 | } 78 | ], 79 | "marks": [ 80 | { 81 | "name": "marks", 82 | "type": "rect", 83 | "style": ["bar"], 84 | "from": {"data": "data_0"}, 85 | "encode": { 86 | "update": { 87 | "fill": {"value": "#4c78a8"}, 88 | "xc": {"scale": "x", "field": "A", "band": 0.5}, 89 | "width": {"scale": "x", "band": true}, 90 | "y": {"scale": "y", "field": "mean_B"}, 91 | "y2": {"scale": "y", "value": 0}, 92 | "shape": {"value": "square"} 93 | } 94 | } 95 | } 96 | ], 97 | "scales": [ 98 | { 99 | "name": "x", 100 | "type": "band", 101 | "domain": {"data": "data_0", "field": "A", "sort": true}, 102 | "range": {"step": {"signal": "x_step"}}, 103 | "paddingInner": 0.1, 104 | "paddingOuter": 0.05 105 | }, 106 | { 107 | "name": "y", 108 | "type": "linear", 109 | "domain": {"data": "data_0", "field": "mean_B"}, 110 | "range": [{"signal": "height"}, 0], 111 | "nice": true, 112 | "zero": true 113 | } 114 | ], 115 | "axes": [ 116 | { 117 | "scale": "x", 118 | "orient": "bottom", 119 | "grid": false, 120 | "title": "A", 121 | "labelAlign": "right", 122 | "labelAngle": 270, 123 | "labelBaseline": "middle", 124 | "zindex": 1, 125 | "encode": {"axis": {"name": "x"}} 126 | }, 127 | { 128 | "scale": "y", 129 | "orient": "left", 130 | "grid": true, 131 | "gridScale": "x", 132 | "title": "Mean of B", 133 | "labelOverlap": true, 134 | "tickCount": {"signal": "ceil(height/40)"}, 135 | "zindex": 0, 136 | "encode": {"axis": {"name": "y"}} 137 | } 138 | ] 139 | }, 140 | "eSpec": { 141 | "$schema": "https://vega.github.io/schema/vega/v5.json", 142 | "autosize": "pad", 143 | "padding": 5, 144 | "height": 200, 145 | "style": "cell", 146 | "data": [ 147 | { 148 | "name": "source_0", 149 | "values": [ 150 | {"A": "a", "B": 10}, 151 | {"A": "a", "B": 12}, 152 | {"A": "a", "B": 13}, 153 | {"A": "b", "B": 10}, 154 | {"A": "b", "B": 12}, 155 | {"A": "a", "B": 9.6}, 156 | {"A": "a", "B": 11}, 157 | {"A": "a", "B": 7}, 158 | {"A": "b", "B": 3}, 159 | {"A": "b", "B": 11} 160 | ] 161 | }, 162 | {"name": "data_0", "source": "source_0"}, 163 | { 164 | "name": "data_1", 165 | "source": "source_0", 166 | "transform": [ 167 | { 168 | "type": "aggregate", 169 | "groupby": ["A"], 170 | "ops": ["mean"], 171 | "fields": ["B"], 172 | "as": ["mean_B"] 173 | } 174 | ] 175 | } 176 | ], 177 | "signals": [ 178 | {"name": "x_step", "value": 20}, 179 | { 180 | "name": "width", 181 | "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" 182 | } 183 | ], 184 | "marks": [ 185 | { 186 | "name": "marks", 187 | "type": "symbol", 188 | "style": ["square"], 189 | "from": {"data": "data_0"}, 190 | "encode": { 191 | "update": { 192 | "fill": {"value": "#4c78a8"}, 193 | "xc": {"scale": "x", "field": "A", "band": 0.5}, 194 | "y": {"scale": "y", "field": "B"}, 195 | "shape": {"value": "square"} 196 | } 197 | } 198 | } 199 | ], 200 | "scales": [ 201 | { 202 | "name": "x", 203 | "type": "band", 204 | "domain": {"data": "data_0", "field": "A", "sort": true}, 205 | "range": {"step": {"signal": "x_step"}}, 206 | "paddingInner": 0.1, 207 | "paddingOuter": 0.05 208 | }, 209 | { 210 | "name": "y", 211 | "type": "linear", 212 | "domain": {"data": "data_0", "field": "B"}, 213 | "range": [{"signal": "height"}, 0], 214 | "nice": true, 215 | "zero": true 216 | } 217 | ], 218 | "axes": [ 219 | { 220 | "scale": "x", 221 | "orient": "bottom", 222 | "grid": false, 223 | "title": "A", 224 | "labelAlign": "right", 225 | "labelAngle": 270, 226 | "labelBaseline": "middle", 227 | "zindex": 1, 228 | "encode": {"axis": {"name": "x"}} 229 | }, 230 | { 231 | "scale": "y", 232 | "orient": "left", 233 | "grid": true, 234 | "gridScale": "x", 235 | "title": "B", 236 | "labelOverlap": true, 237 | "tickCount": {"signal": "ceil(height/40)"}, 238 | "zindex": 0, 239 | "encode": {"axis": {"name": "y"}} 240 | } 241 | ] 242 | }, 243 | "data": [ 244 | {"A": "a", "B": 10}, 245 | {"A": "a", "B": 12}, 246 | {"A": "a", "B": 13}, 247 | {"A": "b", "B": 10}, 248 | {"A": "b", "B": 12}, 249 | {"A": "a", "B": 9.6}, 250 | {"A": "a", "B": 11}, 251 | {"A": "a", "B": 7}, 252 | {"A": "b", "B": 3}, 253 | {"A": "b", "B": 11} 254 | ] 255 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/rs/t7x6src91s5f9ngylz98456m0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: null, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: null, 95 | 96 | // Run tests from one or more projects 97 | // projects: null, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: null, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: null, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "