diff --git a/package-lock.json b/package-lock.json index b7540873..730b10ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "esbuild": "^0.24.0", "eslint": "^9.17.0", "eslint-plugin-jsdoc": "^50.6.1", + "jsdom": "^25.0.1", "lerna": "^8.1.9", "nodemon": "^3.1.9", "rimraf": "^6.0.1", @@ -4957,6 +4958,19 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", + "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5373,6 +5387,57 @@ "node": ">=8" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -5432,6 +5497,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -7265,6 +7337,19 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -7694,6 +7779,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-ssh": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", @@ -7889,6 +7981,84 @@ "node": ">=12.0.0" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", + "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/json-bignum": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/json-bignum/-/json-bignum-0.0.3.tgz", @@ -9483,6 +9653,13 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nx": { "version": "20.1.1", "resolved": "https://registry.npmjs.org/nx/-/nx-20.1.1.tgz", @@ -9965,6 +10142,19 @@ "parse-path": "^7.0.0" } }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -10912,6 +11102,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -10990,6 +11187,19 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/search-insights": { "version": "2.17.2", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.2.tgz", @@ -11488,6 +11698,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", @@ -11703,6 +11920,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.67", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.67.tgz", + "integrity": "sha512-714VbegxoZ9WF5/IsVCy9rWXKUpPkJq87ebWLXQzNawce96l5oRrRf2eHzB4pT2g/4HQU1dYbu+sdXClYxlDKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.67" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.67", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.67.tgz", + "integrity": "sha512-12K5O4m3uUW6YM5v45Z7wc6NTSmAYj4Tq3de7eXghZkp879IlfPJrUWeWFwu1FS94U5t2vwETgJ1asu8UGNKVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -11751,6 +11988,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.0.0.tgz", + "integrity": "sha512-FRKsF7cz96xIIeMZ82ehjC3xW2E+O2+v11udrDYewUbszngYhsGa8z6YUMMzO9QJZzzyd0nGGXnML/TReX6W8Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14335,6 +14585,19 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-up-path": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-3.0.1.tgz", @@ -14355,6 +14618,42 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14588,6 +14887,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 8897b195..03d90d35 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "esbuild": "^0.24.0", "eslint": "^9.17.0", "eslint-plugin-jsdoc": "^50.6.1", + "jsdom": "^25.0.1", "lerna": "^8.1.9", "nodemon": "^3.1.9", "rimraf": "^6.0.1", diff --git a/packages/core/src/Coordinator.js b/packages/core/src/Coordinator.js index de2c1cd1..d5ac3e04 100644 --- a/packages/core/src/Coordinator.js +++ b/packages/core/src/Coordinator.js @@ -6,12 +6,6 @@ import { voidLogger } from './util/void-logger.js'; import { MosaicClient } from './MosaicClient.js'; import { QueryManager, Priority } from './QueryManager.js'; -/** - * @typedef {import('@uwdata/mosaic-sql').Query - * | import('@uwdata/mosaic-sql').DescribeQuery - * | string} QueryType - */ - /** * The singleton Coordinator instance. * @type {Coordinator} @@ -116,13 +110,13 @@ export class Coordinator { /** * Issue a query for which no result (return value) is needed. - * @param {QueryType | QueryType[]} query The query or an array of queries. + * @param { import('./types.js').QueryType[] | + * import('./types.js').QueryType} query The query or an array of queries. * Each query should be either a Query builder object or a SQL string. * @param {object} [options] An options object. * @param {number} [options.priority] The query priority, defaults to * `Priority.Normal`. - * @returns {QueryResult} A query result - * promise. + * @returns {QueryResult} A query result promise. */ exec(query, { priority = Priority.Normal } = {}) { query = Array.isArray(query) ? query.filter(x => x).join(';\n') : query; @@ -132,8 +126,8 @@ export class Coordinator { /** * Issue a query to the backing database. The submitted query may be * consolidate with other queries and its results may be cached. - * @param {QueryType} query The query as either a Query builder object - * or a SQL string. + * @param {import('./types.js').QueryType} query The query as either a Query + * builder object or a SQL string. * @param {object} [options] An options object. * @param {'arrow' | 'json'} [options.type] The query result format type. * @param {boolean} [options.cache=true] If true, cache the query result @@ -156,8 +150,8 @@ export class Coordinator { /** * Issue a query to prefetch data for later use. The query result is cached * for efficient future access. - * @param {QueryType} query The query as either a Query builder object - * or a SQL string. + * @param {import('./types.js').QueryType} query The query as either a Query + * builder object or a SQL string. * @param {object} [options] An options object. * @param {'arrow' | 'json'} [options.type] The query result format type. * @returns {QueryResult} A query result promise. @@ -196,13 +190,13 @@ export class Coordinator { * Update client data by submitting the given query and returning the * data (or error) to the client. * @param {MosaicClient} client A Mosaic client. - * @param {QueryType} query The data query. + * @param {import('./types.js').QueryType} query The data query. * @param {number} [priority] The query priority. * @returns {Promise} A Promise that resolves upon completion of the update. */ updateClient(client, query, priority = Priority.Normal) { client.queryPending(); - return this.query(query, { priority }) + return client._pending = this.query(query, { priority }) .then( data => client.queryResult(data).update(), err => { this._logger.error(err); client.queryError(err); } @@ -215,7 +209,7 @@ export class Coordinator { * the client is simply updated. Otherwise `updateClient` is called. As a * side effect, this method clears the current preaggregator state. * @param {MosaicClient} client The client to update. - * @param {QueryType | null} [query] The query to issue. + * @param {import('./types.js').QueryType | null} [query] The query to issue. */ requestQuery(client, query) { this.preaggregator.clear(); @@ -238,7 +232,7 @@ export class Coordinator { client.coordinator = this; // initialize client lifecycle - this.initializeClient(client); + client._pending = this.initializeClient(client); // connect filter selection connectSelection(this, client.filterBy, client); diff --git a/packages/core/src/MosaicClient.js b/packages/core/src/MosaicClient.js index 9b3b715e..ae396913 100644 --- a/packages/core/src/MosaicClient.js +++ b/packages/core/src/MosaicClient.js @@ -1,3 +1,5 @@ +import { Coordinator } from './Coordinator.js'; +import { Selection } from './Selection.js'; import { throttle } from './util/throttle.js'; /** @@ -11,9 +13,13 @@ export class MosaicClient { * the client when the selection updates. */ constructor(filterSelection) { + /** @type {Selection} */ this._filterBy = filterSelection; this._requestUpdate = throttle(() => this.requestQuery(), true); + /** @type {Coordinator} */ this._coordinator = null; + /** @type {Promise} */ + this._pending = Promise.resolve(); } /** @@ -30,6 +36,13 @@ export class MosaicClient { this._coordinator = coordinator; } + /** + * Return a Promise that resolves once the client has updated. + */ + get pending() { + return this._pending; + } + /** * Return this client's filter selection. */ diff --git a/packages/core/src/QueryManager.js b/packages/core/src/QueryManager.js index 83087862..821eb67d 100644 --- a/packages/core/src/QueryManager.js +++ b/packages/core/src/QueryManager.js @@ -10,6 +10,7 @@ export class QueryManager { constructor( maxConcurrentRequests = 32 ) { + /** @type {PriorityQueue} */ this.queue = new PriorityQueue(3); this.db = null; this.clientCache = null; @@ -18,11 +19,12 @@ export class QueryManager { this._consolidate = null; /** * Requests pending with the query manager. - * * @type {QueryResult[]} */ this.pendingResults = []; + /** @type {number} */ this.maxConcurrentRequests = maxConcurrentRequests; + /** @type {boolean} */ this.pendingExec = false; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 24e19a1f..13832895 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,10 @@ -import type { ExprNode } from '@uwdata/mosaic-sql'; +import type { DescribeQuery, ExprNode, Query } from '@uwdata/mosaic-sql'; + +/** Query type accepted by a coordinator. */ +export type QueryType = + | string + | Query + | DescribeQuery; /** String indicating a JavaScript data type. */ export type JSType = diff --git a/packages/core/src/util/throttle.js b/packages/core/src/util/throttle.js index dd33755d..a3842ddb 100644 --- a/packages/core/src/util/throttle.js +++ b/packages/core/src/util/throttle.js @@ -5,10 +5,12 @@ const NIL = {}; * a Promise. Upon repeated invocation, the callback will not be invoked * until a prior Promise resolves. If multiple invocations occurs while * waiting, only the most recent invocation will be pending. - * @param {(event: *) => Promise} callback The callback function. + * @template E, T + * @param {(event: E) => Promise} callback The callback function. * @param {boolean} [debounce=true] Flag indicating if invocations * should also be debounced within the current animation frame. - * @returns A new function that throttles access to the callback. + * @returns {(event: E) => void} A new function that throttles + * access to the callback. */ export function throttle(callback, debounce = false) { let curr; diff --git a/packages/core/test/client.test.js b/packages/core/test/client.test.js index 9d054c77..8cefc818 100644 --- a/packages/core/test/client.test.js +++ b/packages/core/test/client.test.js @@ -40,12 +40,12 @@ describe('MosaicClient', () => { } queryPending() { // add result promise to global pending queue - this.pending = new QueryResult(); - pending.push(this.pending); + this.pendingResult = new QueryResult(); + pending.push(this.pendingResult); } queryResult(data) { // fulfill pending promise with sorted data - this.pending.fulfill( + this.pendingResult.fulfill( data.toArray().sort((a, b) => a.key - b.key) ); return this; diff --git a/packages/inputs/src/Table.js b/packages/inputs/src/Table.js index b0082b70..f02c75db 100644 --- a/packages/inputs/src/Table.js +++ b/packages/inputs/src/Table.js @@ -40,7 +40,7 @@ export class Table extends MosaicClient { this.offset = 0; this.limit = +rowBatch; - this.pending = false; + this.isPending = false; this.selection = as; this.currentRow = -1; @@ -59,15 +59,15 @@ export class Table extends MosaicClient { let prevScrollTop = -1; this.element.addEventListener('scroll', evt => { - const { pending, loaded } = this; + const { isPending, loaded } = this; const { scrollHeight, scrollTop, clientHeight } = evt.target; const back = scrollTop < prevScrollTop; prevScrollTop = scrollTop; - if (back || pending || loaded) return; + if (back || isPending || loaded) return; if (scrollHeight - scrollTop < 2 * clientHeight) { - this.pending = true; + this.isPending = true; this.requestData(this.offset + this.limit); } }); @@ -168,7 +168,7 @@ export class Table extends MosaicClient { } queryResult(data) { - if (!this.pending) { + if (!this.isPending) { // data is not from an internal request, so reset table this.loaded = false; this.data = []; @@ -204,7 +204,7 @@ export class Table extends MosaicClient { this.loaded = true; } - this.pending = false; + this.isPending = false; return this; } diff --git a/packages/plot/src/marks/Mark.js b/packages/plot/src/marks/Mark.js index 9c66ae96..4d62f0a1 100644 --- a/packages/plot/src/marks/Mark.js +++ b/packages/plot/src/marks/Mark.js @@ -230,7 +230,7 @@ export function markQuery(channels, table, skip = []) { if (skip.includes(channel)) continue; if (channel === 'orderby') { - q.orderby(c.value); + q.orderby(c.value ?? field); } else if (field) { if (isAggregateExpression(field)) { aggr = true; diff --git a/packages/plot/src/plot.js b/packages/plot/src/plot.js index a9c23f70..aec9b751 100644 --- a/packages/plot/src/plot.js +++ b/packages/plot/src/plot.js @@ -10,19 +10,30 @@ const DEFAULT_ATTRIBUTES = { }; export class Plot { + /** + * @param {HTMLElement} [element] + */ constructor(element) { + /** @type {Record} */ this.attributes = { ...DEFAULT_ATTRIBUTES }; this.listeners = null; this.interactors = []; + /** @type {{ legend: import('./legend.js').Legend, include: boolean }[]} */ this.legends = []; + /** @type {import('./marks/Mark.js').Mark[]} */ this.marks = []; + /** @type {Set | null} */ this.markset = null; + /** @type {Map} */ + this.params = new Map; + /** @type {ReturnType} */ + this.synch = synchronizer(); + + /** @type {HTMLElement} */ this.element = element || document.createElement('div'); this.element.setAttribute('class', 'plot'); this.element.style.display = 'flex'; - this.element.value = this; - this.params = new Map; - this.synch = synchronizer(); + Object.assign(this.element, { value: this }); } margins() { diff --git a/packages/vgplot/test/output/airline-travelers.html b/packages/vgplot/test/output/airline-travelers.html new file mode 100644 index 00000000..f7e856cf --- /dev/null +++ b/packages/vgplot/test/output/airline-travelers.html @@ -0,0 +1,11 @@ +
0.0M0.2M0.4M0.6M0.8M1.0M1.2M1.4M1.6M1.8M2.0M2.2M2.4M2.6M2.8M↑ Travelers per dayMar2020AprMayJunJulAugSepOctNovDecdate →20192020
\ No newline at end of file diff --git a/packages/vgplot/test/output/density1d.html b/packages/vgplot/test/output/density1d.html new file mode 100644 index 00000000..cb90b88d --- /dev/null +++ b/packages/vgplot/test/output/density1d.html @@ -0,0 +1,21 @@ +
−50050100150delay →
2003004005006007001k2kdistance →
\ No newline at end of file diff --git a/packages/vgplot/test/output/weather.html b/packages/vgplot/test/output/weather.html new file mode 100644 index 00000000..df67f249 --- /dev/null +++ b/packages/vgplot/test/output/weather.html @@ -0,0 +1,43 @@ +
05101520253035↑ temp_maxJanFebMarAprMayJunJulAugSepOctNovDecdate →
sun
fog
drizzle
rain
snow
sunfogdrizzlerainsnow0100200300400500600count →
\ No newline at end of file diff --git a/packages/vgplot/test/render.test.js b/packages/vgplot/test/render.test.js new file mode 100644 index 00000000..e7a0f8e9 --- /dev/null +++ b/packages/vgplot/test/render.test.js @@ -0,0 +1,54 @@ +import { beforeEach, afterEach, expect, describe, it } from 'vitest'; +import { resolve } from 'node:path'; +import { Coordinator } from '@uwdata/mosaic-core'; +import { JSDOM } from 'jsdom'; +import { nodeConnector } from './util/node-connector.js'; +import { createAPIContext } from '../src/index.js'; +import { clientsReady } from './util/clients-ready.js'; + +const cwd = import.meta.dirname; + +beforeEach(() => { + const dom = new JSDOM( + ``, + { pretendToBeVisual: true } + ); + + // assign browser environment globals + globalThis.window = dom.window; + globalThis.document = dom.window.document; + globalThis.navigator ??= dom.window.navigator; + globalThis.requestAnimationFrame = window.requestAnimationFrame; +}); + +afterEach(() => { + // remove browser environment globals + delete globalThis.requestAnimationFrame; + if (globalThis.navigator === globalThis.window.navigator) { + delete globalThis.navigator; + } + delete globalThis.document; + delete globalThis.window; +}); + +describe('render', () => { + it('should render the density1d spec', () => { + return renderTest('density1d'); + }); + it('should render the airline-travelers spec', () => { + return renderTest('airline-travelers'); + }); + it('should render the weather spec', () => { + return renderTest('weather'); + }); +}); + +async function renderTest(name) { + const specPath = resolve(cwd, `specs/${name}.js`); + const htmlPath = resolve(cwd, `output/${name}.html`); + const { default: run } = await import(specPath); + const mc = new Coordinator(nodeConnector(), { logger: null }); + const el = await run(createAPIContext({ coordinator: mc })); + await clientsReady(el); + await expect(el.outerHTML).toMatchFileSnapshot(htmlPath); +} diff --git a/packages/vgplot/test/specs/airline-travelers.js b/packages/vgplot/test/specs/airline-travelers.js new file mode 100644 index 00000000..4e13b56d --- /dev/null +++ b/packages/vgplot/test/specs/airline-travelers.js @@ -0,0 +1,38 @@ +import { dataPath } from '../util/data-path.js'; + +export default async function(vg) { + await vg.context.coordinator.exec([ + vg.loadParquet("travelers", dataPath("travelers.parquet")), + `CREATE TABLE IF NOT EXISTS endpoint AS SELECT * FROM travelers ORDER BY date DESC LIMIT 1` + ]); + + return vg.plot( + vg.ruleY([0]), + vg.lineY( + vg.from("travelers"), + {x: "date", y: "previous", strokeOpacity: 0.35} + ), + vg.lineY( + vg.from("travelers"), + {x: "date", y: "current"} + ), + vg.text( + vg.from("endpoint"), + { + x: "date", + y: "previous", + text: ["2019"], + fillOpacity: 0.5, + lineAnchor: "bottom", + dy: -6 + } + ), + vg.text( + vg.from("endpoint"), + {x: "date", y: "current", text: ["2020"], lineAnchor: "top", dy: 6} + ), + vg.yGrid(true), + vg.yLabel("↑ Travelers per day"), + vg.yTickFormat("s") + ); +} diff --git a/packages/vgplot/test/specs/density1d.js b/packages/vgplot/test/specs/density1d.js new file mode 100644 index 00000000..b0f511dd --- /dev/null +++ b/packages/vgplot/test/specs/density1d.js @@ -0,0 +1,39 @@ +import { dataPath } from '../util/data-path.js'; + +export default async function(vg) { + await vg.context.coordinator.exec([ + vg.loadParquet("flights", dataPath("flights-200k.parquet")) + ]); + + const $brush = vg.Selection.crossfilter(); + const $bandwidth = vg.Param.value(20); + + return vg.vconcat( + vg.slider({label: "Bandwidth (σ)", as: $bandwidth, min: 0.1, max: 100, step: 0.1}), + vg.plot( + vg.densityY( + vg.from("flights", {filterBy: $brush}), + {x: "delay", fill: "#888", fillOpacity: 0.5, bandwidth: $bandwidth} + ), + vg.intervalX({as: $brush}), + vg.yAxis(null), + vg.xDomain(vg.Fixed), + vg.width(600), + vg.marginLeft(10), + vg.height(200) + ), + vg.plot( + vg.densityY( + vg.from("flights", {filterBy: $brush}), + {x: "distance", fill: "#888", fillOpacity: 0.5, bandwidth: $bandwidth} + ), + vg.intervalX({as: $brush}), + vg.yAxis(null), + vg.xScale("log"), + vg.xDomain(vg.Fixed), + vg.width(600), + vg.marginLeft(10), + vg.height(200) + ) + ); +} diff --git a/packages/vgplot/test/specs/weather.js b/packages/vgplot/test/specs/weather.js new file mode 100644 index 00000000..21bd0112 --- /dev/null +++ b/packages/vgplot/test/specs/weather.js @@ -0,0 +1,58 @@ +import { dataPath } from '../util/data-path.js'; + +export default async function(vg) { + await vg.context.coordinator.exec([ + vg.loadParquet("weather", dataPath("seattle-weather.parquet")) + ]); + + const $click = vg.Selection.single(); + const $domain = vg.Param.array(["sun", "fog", "drizzle", "rain", "snow"]); + const $colors = vg.Param.array(["#e7ba52", "#a7a7a7", "#aec7e8", "#1f77b4", "#9467bd"]); + const $range = vg.Selection.intersect(); + + return vg.vconcat( + vg.hconcat( + vg.plot( + vg.dot( + vg.from("weather", {filterBy: $click}), + { + x: vg.dateMonthDay("date"), + y: "temp_max", + fill: "weather", + r: "precipitation", + fillOpacity: 0.7 + } + ), + vg.intervalX({as: $range, brush: {fill: "none", stroke: "#888"}}), + vg.highlight({by: $range, fill: "#ccc", fillOpacity: 0.2}), + vg.colorLegend({as: $click, columns: 1}), + vg.xyDomain(vg.Fixed), + vg.xTickFormat("%b"), + vg.colorDomain($domain), + vg.colorRange($colors), + vg.rDomain(vg.Fixed), + vg.rRange([2, 10]), + vg.width(680), + vg.height(300) + ) + ), + vg.plot( + vg.barX( + vg.from("weather"), + {x: vg.count(), y: "weather", fill: "#ccc", fillOpacity: 0.2, orderby: "weather"} + ), + vg.barX( + vg.from("weather", {filterBy: $range}), + {x: vg.count(), y: "weather", fill: "weather"} + ), + vg.toggleY({as: $click}), + vg.highlight({by: $click}), + vg.xDomain(vg.Fixed), + vg.yDomain($domain), + vg.yLabel(null), + vg.colorDomain($domain), + vg.colorRange($colors), + vg.width(680) + ) + ); +} diff --git a/packages/vgplot/test/util/clients-ready.js b/packages/vgplot/test/util/clients-ready.js new file mode 100644 index 00000000..5181e6df --- /dev/null +++ b/packages/vgplot/test/util/clients-ready.js @@ -0,0 +1,35 @@ +import { MosaicClient } from '@uwdata/mosaic-core'; +import { Plot } from '@uwdata/mosaic-plot'; + +/** + * Extract Mosaic clients from a DOM subtree. + * @param {HTMLElement} el The root element to search for clients. + * @returns {MosaicClient[]} + */ +function extractClients(el) { + const clients = []; + const queue = [el]; + while (queue.length) { + const node = queue.shift(); + const value = node.value; + if (value instanceof MosaicClient) { + clients.push(value); + } else if (value instanceof Plot) { + clients.push(...value.marks); + } else { + queue.push(...node.children); + } + } + return clients; +} + +/** + * Return a Promise that resolves when all plots in an + * instantiated vgplot context have completed rendering. + * @param {HTMLElement} el The root element of the rendered spec. + * @returns {Promise} + */ +export function clientsReady(el) { + const clients = extractClients(el); + return Promise.allSettled(clients.map(c => c.pending)); +} diff --git a/packages/vgplot/test/util/data-path.js b/packages/vgplot/test/util/data-path.js new file mode 100644 index 00000000..5ec57d75 --- /dev/null +++ b/packages/vgplot/test/util/data-path.js @@ -0,0 +1,7 @@ +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '../../../../data/'); + +export function dataPath(file) { + return resolve(root, file); +} diff --git a/packages/vgplot/test/util/node-connector.js b/packages/vgplot/test/util/node-connector.js new file mode 100644 index 00000000..33bc645a --- /dev/null +++ b/packages/vgplot/test/util/node-connector.js @@ -0,0 +1,25 @@ +import { decodeIPC } from '@uwdata/mosaic-core'; +import { DuckDB } from '@uwdata/mosaic-duckdb'; + +export function nodeConnector(db = new DuckDB()) { + return { + /** + * Query an in-process DuckDB instance. + * @param {object} query + * @param {string} [query.type] The query type: 'exec', 'arrow', or 'json'. + * @param {string} query.sql A SQL query string. + * @returns the query result + */ + query: async query => { + const { type, sql } = query; + switch (type) { + case 'exec': + return db.exec(sql); + case 'arrow': + return decodeIPC(await db.arrowBuffer(sql)); + default: + return db.query(sql); + } + } + }; +}