├── .gitignore ├── examples ├── k-means │ ├── assets │ │ └── kmeans.png │ ├── sse │ │ ├── package.json │ │ ├── Dockerfile │ │ └── index.js │ ├── web │ │ ├── scatterplot │ │ │ ├── data.js │ │ │ ├── object-properties.js │ │ │ ├── index.js │ │ │ ├── pic-definition.js │ │ │ └── pic-selections.js │ │ ├── index.html │ │ ├── index.js │ │ └── mouse.js │ ├── package.json │ ├── docker-compose.yml │ └── README.md └── functions.md ├── .eslintignore ├── .editorconfig ├── CHANGELOG.md ├── .eslintrc.json ├── package.json ├── LICENSE ├── lib ├── functions.js ├── index.js └── script.js ├── README.md ├── docs ├── api.md └── concepts.md ├── assets └── SSE.proto └── test └── e2e.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.rej 2 | *.tmp 3 | *.log 4 | .DS_Store 5 | .vscode/ 6 | .cache/ 7 | node_modules/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /examples/k-means/assets/kmeans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miralemd/qlik-sse/HEAD/examples/k-means/assets/kmeans.png -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/* 2 | test/unit/coverage/* 3 | test/component/coverage/* 4 | docs/node_modules/* 5 | test/config/*.js 6 | -------------------------------------------------------------------------------- /examples/k-means/sse/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "ml-kmeans": "4.2.1", 4 | "qlik-sse": "0.3.0" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/k-means/sse/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY ./ . 6 | 7 | RUN npm install --no-optional --no-package-lock 8 | 9 | EXPOSE 50051 10 | 11 | CMD [ "node", "index.js" ] 12 | 13 | -------------------------------------------------------------------------------- /examples/k-means/web/scatterplot/data.js: -------------------------------------------------------------------------------- 1 | export default { 2 | targets: [{ 3 | path: 'qHyperCubeDef', 4 | dimensions: { 5 | min: 1, 6 | max: 1, 7 | }, 8 | measures: { 9 | min: 2, 10 | max: 3, 11 | }, 12 | }], 13 | }; 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 2 space indentation 12 | [*.*] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /examples/k-means/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "k-means", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "start": "parcel ./web/index.html" 6 | }, 7 | "dependencies": { 8 | "@nebula.js/nucleus": "0.1.0-alpha.21", 9 | "enigma.js": "2.4.0", 10 | "parcel-bundler": "1.12.4", 11 | "picasso-plugin-q": "0.27.0", 12 | "picasso.js": "0.27.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.3.0 (2019-10-13) 2 | 3 | #### Features 4 | 5 | - support table load 6 | - handle async function 7 | - add `close()` method on server 8 | 9 | #### Bug fixes 10 | 11 | - catch outer function errors 12 | - catch script errors 13 | 14 | ## 0.2.0 (2018-12-04) 15 | 16 | #### Features 17 | 18 | - enable script evaluation 19 | 20 | ## 0.1.0 (2018-08-23) 21 | 22 | Initial release 23 | -------------------------------------------------------------------------------- /examples/k-means/web/scatterplot/object-properties.js: -------------------------------------------------------------------------------- 1 | const properties = { 2 | qHyperCubeDef: { 3 | qDimensions: [], 4 | qMeasures: [], 5 | qInitialDataFetch: [ 6 | { qWidth: 4, qHeight: 1000 }, 7 | ], 8 | qSuppressZero: false, 9 | qSuppressMissing: true, 10 | }, 11 | showTitles: true, 12 | title: '', 13 | subtitle: '', 14 | footnote: '', 15 | }; 16 | 17 | export default properties; 18 | -------------------------------------------------------------------------------- /examples/k-means/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | engine: 5 | image: qlikcore/engine:${ENGINE_VERSION} 6 | command: | 7 | -S AcceptEULA=${ACCEPT_EULA} 8 | -S SSEPlugin=sse,sse-cluster:50051 9 | ports: 10 | - "9076:9076" 11 | depends_on: 12 | - sse 13 | sse: 14 | build: 15 | context: ./sse 16 | dockerfile: Dockerfile 17 | container_name: "sse-cluster" 18 | ports: 19 | - "50051:50051" 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true 5 | }, 6 | "extends": "airbnb-base", 7 | "rules": { 8 | "no-console": 0, 9 | "no-nested-ternary": 0, 10 | "no-plusplus": 0, 11 | "max-len": 0 12 | }, 13 | "overrides": [ 14 | { 15 | "files": ["examples/**/*.js"], 16 | "env": { 17 | "browser": true 18 | } 19 | }, 20 | { 21 | "files": ["**/*.spec.js"], 22 | "env": { 23 | "browser": false, 24 | "node": true, 25 | "mocha": true 26 | }, 27 | "globals": { 28 | "chai": false, 29 | "expect": false, 30 | "sinon": false, 31 | "aw": false 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /examples/k-means/README.md: -------------------------------------------------------------------------------- 1 | # K-means 2 | 3 | A simple example of a k-means algorithm implemented as a server side extension. 4 | 5 | ## Prerequisites 6 | 7 | - Node 8.0.0+ 8 | - Docker 9 | 10 | ## Run the example 11 | 12 | 1. `npm install` 13 | 1. `ACCEPT_EULA=yes ENGINE_VERSION= docker-compose up --build` 14 | - [Qlik Core EULA](https://core.qlik.com/eula/) 15 | - Go to [Docker hub](https://hub.docker.com/r/qlikcore/engine/tags) to find a `qlikcore/engine` release and specify the tagged version, e.g. `ENGINE_VERSION=12.477.0` 16 | 1. `npm run start` 17 | 1. The dev server should now be running on http://localhost:1234, where you can interact with the example data 18 | 19 | 20 | ![k-means](./assets/kmeans.png) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qlik-sse", 3 | "version": "0.3.0", 4 | "description": "Qlik Sense Server Side Extension in nodejs", 5 | "license": "MIT", 6 | "author": "miralemd", 7 | "keywords": [ 8 | "qlik", 9 | "sse", 10 | "grpc" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/miralemd/qlik-sse.git" 15 | }, 16 | "files": [ 17 | "lib", 18 | "assets" 19 | ], 20 | "main": "lib/index.js", 21 | "scripts": { 22 | "test": "aw node --glob test/e2e.spec.js --no-babel" 23 | }, 24 | "devDependencies": { 25 | "@after-work.js/aw": "^6.0.8", 26 | "enigma.js": "^2.4.0", 27 | "eslint": "^5.4.0", 28 | "eslint-config-airbnb-base": "^13.1.0", 29 | "eslint-plugin-import": "^2.14.0", 30 | "ws": "^7.1.2" 31 | }, 32 | "dependencies": { 33 | "grpc": "^1.14.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018-present, Miralem Drek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/k-means/sse/index.js: -------------------------------------------------------------------------------- 1 | const kmeans = require('ml-kmeans'); 2 | const q = require('qlik-sse'); 3 | 4 | const s = q.server({ 5 | identifier: 'xxx', 6 | version: '0.1.0', 7 | allowScript: true, 8 | }); 9 | 10 | function cluster(request) { 11 | request.on('data', (bundle) => { 12 | const pairs = []; 13 | bundle.rows.forEach((row) => { 14 | pairs.push([row.duals[0].numData, row.duals[1].numData]); 15 | }); 16 | const num = Math.min(bundle.rows[0].duals[2].numData, pairs.length - 1); 17 | let rows = []; 18 | try { 19 | const k = kmeans(pairs, num); 20 | rows = k.clusters.map(c => ({ 21 | duals: [{ numData: c }], 22 | })); 23 | } catch (e) { 24 | console.error(e, num); 25 | rows = pairs.map(() => ({ 26 | duals: [{ numData: 0 }], 27 | })); 28 | } 29 | request.write({ rows }); 30 | }); 31 | } 32 | 33 | s.addFunction(cluster, { 34 | functionType: q.sse.FunctionType.TENSOR, 35 | returnType: q.sse.DataType.NUMERIC, 36 | params: [{ 37 | name: 'x', 38 | dataType: q.sse.DataType.NUMERIC, 39 | }, { 40 | name: 'y', 41 | dataType: q.sse.DataType.NUMERIC, 42 | }, { 43 | name: 'numClusters', 44 | dataType: q.sse.DataType.NUMERIC, 45 | }], 46 | }); 47 | 48 | // start the server 49 | s.start({ 50 | port: 50051, 51 | }); 52 | -------------------------------------------------------------------------------- /examples/k-means/web/scatterplot/index.js: -------------------------------------------------------------------------------- 1 | import picassojs from 'picasso.js'; 2 | import picassoQ from 'picasso-plugin-q'; 3 | 4 | import properties from './object-properties'; 5 | import data from './data'; 6 | import picSelections from './pic-selections'; 7 | import definition from './pic-definition'; 8 | 9 | export default function supernova(/* env */) { 10 | const picasso = picassojs({ 11 | renderer: { 12 | prio: ['canvas'], 13 | }, 14 | }); 15 | picasso.use(picassoQ); 16 | 17 | return { 18 | qae: { 19 | properties, 20 | data, 21 | }, 22 | component: { 23 | created() {}, 24 | mounted(element) { 25 | this.pic = picasso.chart({ 26 | element, 27 | data: [], 28 | settings: {}, 29 | }); 30 | this.picsel = picSelections({ 31 | selections: this.selections, 32 | brush: this.pic.brush('selection'), 33 | picassoQ, 34 | }); 35 | }, 36 | render({ 37 | layout, 38 | context, 39 | }) { 40 | console.log('layout', layout); 41 | this.pic.update({ 42 | data: [{ 43 | type: 'q', 44 | key: 'qHyperCube', 45 | data: layout.qHyperCube, 46 | }], 47 | settings: definition({ layout, context }), 48 | }); 49 | }, 50 | resize() {}, 51 | willUnmount() {}, 52 | destroy() {}, 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /examples/k-means/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53 | 54 | 55 |
56 |
57 |
58 | 59 | 60 | 61 |
62 |
63 |
64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /lib/functions.js: -------------------------------------------------------------------------------- 1 | const funcs = ({ sse, grpc, log }) => { 2 | const functions = []; 3 | const functionMap = {}; 4 | const functionTableDescriptionMap = {}; 5 | let expando = 1000; 6 | 7 | const f = { 8 | list() { 9 | return functions; 10 | }, 11 | add(fn, fnConfig) { 12 | const name = fnConfig.name || fn.name; 13 | functions.push({ 14 | name, 15 | functionType: fnConfig.functionType, 16 | returnType: fnConfig.returnType, 17 | params: fnConfig.params, 18 | functionId: ++expando, 19 | }); 20 | 21 | if (fnConfig.tableDescription) { 22 | functionTableDescriptionMap[expando] = fnConfig.tableDescription; 23 | } 24 | functionMap[expando] = fn; 25 | }, 26 | execute(request) { 27 | const funcionHeader = sse.FunctionRequestHeader.decode(request.metadata.get('qlik-functionrequestheader-bin')[0]); 28 | const fn = functionMap[funcionHeader.functionId]; 29 | if (fn) { 30 | const tableDescription = functionTableDescriptionMap[funcionHeader.functionId]; 31 | if (tableDescription) { 32 | const tableMeta = new grpc.Metadata(); 33 | tableMeta.set('qlik-tabledescription-bin', new sse.TableDescription(tableDescription).encodeNB()); 34 | request.sendMetadata(tableMeta); 35 | } 36 | try { 37 | fn(request); 38 | } catch (e) { 39 | log.error(e); 40 | request.call.cancelWithStatus(grpc.status.UNKNOWN, e.message); 41 | } 42 | } else { 43 | request.call.cancelWithStatus(grpc.status.UNIMPLEMENTED, 'The method is not implemented.'); 44 | return; 45 | } 46 | 47 | if (fn.constructor.name !== 'AsyncFunction') { 48 | request.on('end', () => request.end()); 49 | } 50 | }, 51 | }; 52 | 53 | return f; 54 | }; 55 | 56 | module.exports = funcs; 57 | -------------------------------------------------------------------------------- /examples/k-means/web/index.js: -------------------------------------------------------------------------------- 1 | import enigma from 'enigma.js'; 2 | import schema from 'enigma.js/schemas/3.2.json'; 3 | import nucleus from '@nebula.js/nucleus/dist/nucleus'; 4 | 5 | import scatterplot from './scatterplot'; 6 | import qscript from './mouse'; 7 | 8 | const connect = () => enigma.create({ 9 | schema, 10 | url: `ws://${window.location.hostname || 'localhost'}:9076/app/${Date.now()}`, 11 | }).open().then(qix => qix.createSessionApp().then(app => app.setScript(qscript).then(() => app.doReload().then(() => app)))); 12 | 13 | connect().then((app) => { 14 | const n = nucleus(app, { 15 | types: [{ 16 | name: 'scatter', 17 | load: () => Promise.resolve(scatterplot), 18 | }], 19 | }); 20 | 21 | n.selections().mount(document.querySelector('#selections')); 22 | 23 | n.create({ 24 | type: 'scatter', 25 | }, { 26 | element: document.querySelector('#object'), 27 | properties: { 28 | qHyperCubeDef: { 29 | qInitialDataFetch: [{ 30 | qWidth: 4, 31 | qHeight: 2000, 32 | }], 33 | qDimensions: [{ 34 | qDef: { 35 | qFieldDefs: ['sample'], 36 | }, 37 | qAttributeDimensions: [{ 38 | qDef: 'body_part', 39 | }], 40 | }], 41 | qMeasures: [ 42 | { qDef: { qDef: 'x' } }, 43 | { qDef: { qDef: 'y' } }, 44 | { qDef: { qDef: 'sse.cluster(x, y, 3)' } }, 45 | ], 46 | }, 47 | showTitles: true, 48 | subtitle: 'Shape indicates actual group the point belongs to, while color indicates the group according to k-means.', 49 | }, 50 | context: { 51 | permissions: ['passive', 'interact', 'select'], 52 | }, 53 | }).then((viz) => { 54 | const ctrl = document.querySelector('#numClusters'); 55 | ctrl.addEventListener('change', (e) => { 56 | document.querySelector('#numClustersLabel').textContent = +ctrl.value; 57 | viz.setTemporaryProperties({ 58 | qHyperCubeDef: { 59 | qMeasures: [ 60 | { qDef: { qDef: 'x' } }, 61 | { qDef: { qDef: 'y' } }, 62 | { qDef: { qDef: `sse.cluster(x, y, ${+ctrl.value})` } }, 63 | ], 64 | }, 65 | }); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | const grpc = require('grpc'); 2 | const path = require('path'); 3 | 4 | const funcs = require('./functions'); 5 | const { getEvaluateScript } = require('./script'); 6 | 7 | const { sse } = grpc.load(path.resolve(__dirname, '..', 'assets', 'SSE.proto')).qlik; 8 | 9 | const logFn = level => ({ 10 | error: level >= 1 ? console.error : () => {}, 11 | info: level >= 2 ? console.log : () => {}, 12 | }); 13 | 14 | /** 15 | * @param {object} config 16 | * @param {object|boolean} config.allowScript 17 | * @param {string} config.identifier 18 | * @param {number} [config.logLevel = 2] The log output level 19 | * @returns {server} 20 | */ 21 | const server = (config) => { 22 | const log = logFn(typeof config.logLevel === 'number' ? config.logLevel : 2); 23 | const functions = funcs({ sse, grpc, log }); 24 | 25 | const evaluateScript = getEvaluateScript({ 26 | config, sse, grpc, log, 27 | }); 28 | 29 | function getCapabilities(request, cb) { 30 | log.info(`Capabilites of plugin '${config.identifier}'`); 31 | log.info(` AllowScript: ${!!config.allowScript}`); 32 | log.info(' Functions:'); 33 | const type = { 34 | [sse.DataType.NUMERIC]: 'numeric', 35 | [sse.DataType.STRING]: 'string', 36 | }; 37 | functions.list().forEach((f) => { 38 | log.info(` ${f.name}(${f.params.map(p => type[p.dataType])}) : ${type[f.returnType]}`); 39 | }); 40 | 41 | cb(null, { 42 | allowScript: !!config.allowScript, 43 | functions: functions.list(), 44 | pluginIdentifier: config.identifier, 45 | pluginVersion: config.version, 46 | }); 47 | } 48 | 49 | let grpcServer; 50 | 51 | function start(startConfig = {}) { 52 | const port = startConfig.port || 50051; 53 | grpcServer = new grpc.Server(); 54 | grpcServer.addProtoService(sse.Connector.service, { 55 | getCapabilities, 56 | executeFunction: functions.execute, 57 | evaluateScript, 58 | }); 59 | 60 | grpcServer.bind(`0.0.0.0:${port}`, grpc.ServerCredentials.createInsecure()); 61 | 62 | grpcServer.start(); 63 | log.info(`Server listening on port ${port}`); 64 | } 65 | 66 | return { 67 | start, 68 | close() { 69 | return new Promise((resolve, reject) => { 70 | if (!grpcServer) { 71 | reject(); 72 | } 73 | grpcServer.tryShutdown(resolve); 74 | }); 75 | }, 76 | addFunction: functions.add, 77 | }; 78 | }; 79 | 80 | module.exports = { 81 | server, 82 | sse, 83 | }; 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qlik-sse 2 | 3 | `qlik-sse` is an npm package that simplifies the creation of Qlik Server Side Extensions in nodejs. 4 | 5 | Check out [Server Side Extension](https://github.com/qlik-oss/server-side-extension) for more info and how to get started from the Qlik side. 6 | 7 | --- 8 | 9 | - [Getting started](#getting-started) 10 | - [Concepts](./docs/concepts.md) 11 | - [API documentation](./docs/api.md) 12 | 13 | --- 14 | 15 | ## Getting started 16 | 17 | ### Prerequisites 18 | 19 | Before continuing, make sure you: 20 | 21 | - have Node.js >= v8.0.0 installed 22 | - can configure your Qlik installation (or dockerized Qlik Engine) 23 | 24 | ### Usage 25 | 26 | Start by installing `qlik-sse`: 27 | 28 | ```sh 29 | npm install qlik-sse 30 | ``` 31 | 32 | Next, create a file `foo.js`: 33 | ```js 34 | const q = require('qlik-sse'); 35 | 36 | // create an instance of the server 37 | const s = q.server({ 38 | identifier: 'xxx', 39 | version: '0.1.0', 40 | }); 41 | 42 | // register functions 43 | s.addFunction(/* */); 44 | 45 | // start the server 46 | s.start({ 47 | port: 50051, 48 | allowScript: true 49 | }); 50 | ``` 51 | 52 | and then run it to start the SSE plugin server: 53 | 54 | ```sh 55 | node foo.js 56 | ``` 57 | 58 | Configure the SSE in your Qlik installation by following [these instructions](https://github.com/qlik-oss/server-side-extension/blob/master/docs/configuration.md) 59 | 60 | If you're running Qlik Sense Desktop (or Qlik Engine) locally, restart it after starting the SSE server to allow Qlik Engine to get the SSE plugin's capabilities. 61 | 62 | Assuming you have named the plugin `sse`, you should now be able to use it's script functions in expressions: 63 | 64 | ```basic 65 | sse.ScriptEval('return Math.random()*args[0]', sum(Sales)); 66 | ``` 67 | 68 | You have now successfully created a Server Side Extension that can be used from within Qlik Sense or Qlik Core. 69 | 70 | Take a look at some of the [examples](./examples) on how to add functionality to the SSE. 71 | 72 | ## TODO 73 | 74 | - Documentation 75 | - [x] API 76 | - [x] Explain function types `SCALAR`, `AGGREGATION` and `TENSOR` 77 | - [x] Table load 78 | - Examples 79 | - [ ] How to use tensorflow with qix data 80 | - Real use cases 81 | - [ ] linear regression 82 | - [x] k-means 83 | - ... 84 | - Full Qlik example 85 | - [x] configuring Qlik Engine to use SSEPlugin 86 | - [x] dockerized environment 87 | - [ ] loading data 88 | - [ ] expression calls 89 | - Features 90 | - [x] Script evaluation 91 | - Error handling 92 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ## `q` 4 | 5 | The `qlik-sse` module 6 | 7 | ```js 8 | const q = require('qlik-sse'); 9 | ``` 10 | 11 | ### `q.sse` 12 | 13 | A namespace for easy access to types, all types in [SSE.proto](../assets/SSE.proto) are accessible from this namespace: 14 | 15 | ```js 16 | console.log(q.sse.FunctionType.AGGREGATION); // 1 17 | ``` 18 | 19 | ### `q.server(options)` 20 | 21 | - `options` <[Object]> 22 | - `identifier` <[string]> Identifier for this SSE plugin. 23 | - `allowScript` <[boolean]> Whether to allow script evaluation. Defaults to `false`. 24 | - returns: <[Server](#server)> 25 | 26 | Creates a new [Server](#server) instance. 27 | 28 | ```js 29 | const q = require('qlik-sse'); 30 | 31 | const server = q.server({ 32 | identifier: 'abc', 33 | allowScript: true, 34 | }); 35 | ``` 36 | 37 | ## `Server` 38 | 39 | ### `server.addFunction(fn, config)` 40 | 41 | - `fn` <[function] ([Request](#request))> 42 | - `config` <[Object]> 43 | - `functionType` <[FunctionType]> Type of function 44 | - `returnType` <[DataType]> Type of data this function is expected to return 45 | - `params` <[Array]<[Object]>> 46 | - `name` <[string]> 47 | - `dataType`: <[DataType]> 48 | - `tableDescription` <[TableDescription]> Description of the returned table when function is called from load script using the `extension` clause. 49 | 50 | Register a function which can be called from an expression. 51 | 52 | ```js 53 | const fn = (request) => {/* do stuff */}; 54 | server.addFunction(fn, { 55 | functionType: q.sse.FunctionType.TENSOR, 56 | returnType: q.sse.DataType.NUMERIC, 57 | params: [{ 58 | name: 'first', 59 | dataType: q.sse.DataType.NUMERIC 60 | }] 61 | }) 62 | ``` 63 | 64 | ### `server.start(options)` 65 | 66 | - `options` <[Object]> 67 | - `port` <[number]> Port to run the server on. Defaults to `50051`. 68 | 69 | Starts the server. 70 | 71 | ## `Request` 72 | 73 | ### `request.on(event, fn)` 74 | 75 | - `event` <[string]> Name of event to listen to. Possible values: `data`. 76 | - `fn` <[function] ([BundledRows])> 77 | 78 | ```js 79 | request.on('data', (bundle) => {/* deal with bundle*/}) 80 | ``` 81 | 82 | ### `request.write(bundle)` 83 | 84 | - `bundle` <[BundledRows]> 85 | 86 | Writes data back to Qlik Engine. 87 | 88 | ```js 89 | request.on('data', (bundle) => { 90 | const returnBundle = {/* */}; 91 | request.write(returnBundle); 92 | }); 93 | ``` 94 | 95 | 96 | [Array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array "Array" 97 | [boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean" 98 | [function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function "Function" 99 | [number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number" 100 | [Object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object "Object" 101 | [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String" 102 | -------------------------------------------------------------------------------- /examples/k-means/web/scatterplot/pic-definition.js: -------------------------------------------------------------------------------- 1 | export default function picassoDefinition({ 2 | layout, 3 | context, 4 | }) { 5 | if (!layout.qHyperCube) { 6 | throw new Error('Layout is missing a hypercube'); 7 | } 8 | return { 9 | scales: { 10 | x: { 11 | data: { field: 'qMeasureInfo/0' }, 12 | expand: 0.2, 13 | }, 14 | y: { 15 | data: { field: 'qMeasureInfo/1' }, 16 | expand: 0.2, 17 | invert: true, 18 | }, 19 | color: { 20 | data: { extract: { field: 'qMeasureInfo/2' } }, 21 | type: 'categorical-color', 22 | }, 23 | shape: { 24 | type: 'categorical-color', 25 | data: { extract: { field: 'qDimensionInfo/0/qAttrDimInfo/0' } }, 26 | range: ['circle', 'triangle', 'diamond', 'star'], 27 | }, 28 | }, 29 | components: [{ 30 | type: 'axis', 31 | dock: 'left', 32 | scale: 'y', 33 | }, { 34 | type: 'text', 35 | dock: 'left', 36 | scale: 'y', 37 | style: { 38 | text: '$label', 39 | }, 40 | show: false, 41 | }, { 42 | type: 'axis', 43 | dock: 'bottom', 44 | scale: 'x', 45 | }, { 46 | type: 'text', 47 | dock: 'bottom', 48 | scale: 'x', 49 | style: { 50 | text: '$label', 51 | }, 52 | show: false, 53 | }, { 54 | type: 'legend-cat', 55 | dock: 'right', 56 | align: 'right', 57 | scale: 'shape', 58 | settings: { 59 | item: { 60 | shape: { 61 | fill: '#444', 62 | type(d) { 63 | return d.resources.scale('shape')(d.datum.value); 64 | }, 65 | }, 66 | }, 67 | }, 68 | brush: context.permissions.indexOf('interact') !== -1 && context.permissions.indexOf('select') !== -1 ? { 69 | trigger: [{ 70 | contexts: ['selection'], 71 | }], 72 | consume: [{ 73 | context: 'selection', 74 | style: { 75 | inactive: { 76 | opacity: 0.3, 77 | }, 78 | }, 79 | }], 80 | } : {}, 81 | }, { 82 | type: 'point', 83 | data: { 84 | extract: { 85 | field: 'qDimensionInfo/0', 86 | props: { 87 | x: { field: 'qMeasureInfo/0' }, 88 | y: { field: 'qMeasureInfo/1' }, 89 | fill: { field: 'qMeasureInfo/2' }, 90 | category: { field: 'qDimensionInfo/0/qAttrDimInfo/0' }, 91 | }, 92 | }, 93 | }, 94 | settings: { 95 | x: { scale: 'x' }, 96 | y: { scale: 'y' }, 97 | fill: { scale: 'color' }, 98 | size: 0.2, 99 | shape: { scale: 'shape', ref: 'category' }, 100 | }, 101 | brush: context.permissions.indexOf('interact') !== -1 && context.permissions.indexOf('select') !== -1 ? { 102 | trigger: [{ 103 | contexts: ['selection'], 104 | }], 105 | consume: [{ 106 | context: 'selection', 107 | style: { 108 | inactive: { 109 | opacity: 0.3, 110 | }, 111 | }, 112 | }], 113 | } : {}, 114 | }], 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /examples/functions.md: -------------------------------------------------------------------------------- 1 | # Function examples 2 | 3 | Read up on the [concept of functions](../docs/concepts.md#functions) first. 4 | 5 | Functions need to be registered after the server is created, but before it's started: 6 | ```js 7 | const q = require('qlik-sse'); 8 | 9 | // create an instance of the server 10 | const s = q.server({ 11 | identifier: 'xxx', 12 | version: '0.1.0', 13 | }); 14 | 15 | /* ADD FUNCTIONS HERE BEFORE STARTING THE SERVER */ 16 | 17 | // start the server 18 | s.start({ 19 | port: 50051 20 | }); 21 | ``` 22 | 23 | ## Examples 24 | 25 | ### Sum of rows and columns 26 | 27 | ```js 28 | function sum(request) { 29 | request.on('data', (bundle) => { 30 | const rows = []; 31 | let v = 0; 32 | bundle.rows.forEach((row) => { 33 | row.duals.forEach((dual) => { 34 | if (!Number.isNaN(dual.numData)) { 35 | v += dual.numData; 36 | } 37 | }); 38 | }); 39 | rows.push({ 40 | duals: [{ numData: v }], 41 | }); 42 | request.write({ 43 | rows: [{ // return only one row, containing one cell 44 | duals: [{ numData: v }] 45 | }], 46 | }); 47 | }); 48 | } 49 | 50 | // register the function and its types 51 | s.addFunction(sum, { 52 | functionType: q.sse.FunctionType.AGGREGATION, 53 | returnType: q.sse.DataType.NUMERIC, 54 | params: [{ 55 | name: 'num', 56 | dataType: q.sse.DataType.NUMERIC, 57 | }], 58 | }); 59 | ``` 60 | 61 | ### Sum of columns 62 | 63 | ```js 64 | function sum(request) { 65 | request.on('data', (bundle) => { 66 | const rows = []; 67 | let v = 0; 68 | bundle.rows.forEach((row) => { 69 | row.duals.forEach((dual) => { 70 | if (!Number.isNaN(dual.numData)) { 71 | v += dual.numData; 72 | } 73 | }); 74 | }); 75 | rows.push({ 76 | duals: [{ numData: v }], 77 | }); 78 | request.write({ 79 | rows: [{ // return only one row, containing one cell 80 | duals: [{ numData: v }] 81 | }], 82 | }); 83 | }); 84 | } 85 | 86 | // register the function and its types 87 | s.addFunction(sum, { 88 | functionType: q.sse.FunctionType.AGGREGATION, 89 | returnType: q.sse.DataType.NUMERIC, 90 | params: [{ 91 | name: 'num', 92 | dataType: q.sse.DataType.NUMERIC, 93 | }], 94 | }); 95 | ``` 96 | 97 | ### K-means clustering 98 | 99 | ```js 100 | const kmeans = require('ml-kmeans'); // need to install this dependency 101 | 102 | function cluster(request) { 103 | request.on('data', (bundle) => { 104 | const pairs = []; 105 | bundle.rows.forEach((row) => { 106 | pairs.push([row.duals[0].numData, row.duals[1].numData]); 107 | }); 108 | const k = kmeans(pairs, 3); 109 | const rows = k.clusters.map(c => ({ 110 | duals: [{ numData: c }], 111 | })); 112 | request.write({ rows }); 113 | }); 114 | } 115 | 116 | s.addFunction(cluster, { 117 | functionType: q.sse.FunctionType.TENSOR, 118 | returnType: q.sse.DataType.NUMERIC, 119 | params: [{ 120 | name: 'x', 121 | dataType: q.sse.DataType.NUMERIC, 122 | }, { 123 | name: 'y', 124 | dataType: q.sse.DataType.NUMERIC, 125 | }, { 126 | name: 'numClusters', 127 | dataType: q.sse.DataType.NUMERIC, 128 | }], 129 | }); 130 | ``` 131 | -------------------------------------------------------------------------------- /lib/script.js: -------------------------------------------------------------------------------- 1 | function getArgType(types, sse) { 2 | if (types.every(t => t === sse.DataType.NUMERIC)) { 3 | return sse.DataType.NUMERIC; 4 | } 5 | if (types.every(t => t === sse.DataType.STRING)) { 6 | return sse.DataType.STRING; 7 | } 8 | return sse.DataType.DUAL; 9 | } 10 | 11 | function getEvaluateScript({ 12 | config, 13 | sse, 14 | grpc, 15 | log, 16 | }) { 17 | return function evaluateScript(request) { 18 | const scriptHeader = sse.ScriptRequestHeader.decode(request.metadata.get('qlik-scriptrequestheader-bin')[0]); 19 | const common = sse.CommonRequestHeader 20 | .decode(request.metadata.get('qlik-commonrequestheader-bin')[0]); 21 | 22 | let fn; 23 | try { 24 | fn = new Function('args', 'params', 'common', scriptHeader.script); // eslint-disable-line no-new-func 25 | } catch (e) { 26 | log.error(e); 27 | request.call.cancelWithStatus(grpc.status.INVALID_ARGUMENT, e.stack); 28 | return; 29 | } 30 | 31 | const { functionType, returnType, params } = scriptHeader; 32 | const types = params.map(p => p.dataType); 33 | 34 | const argType = types.length ? getArgType(types, sse) : returnType; 35 | 36 | let ff = `script${functionType === sse.FunctionType.AGGREGATION ? 'Aggr' : 'Eval'}`; 37 | ff += `${argType === sse.DataType.DUAL ? 'Ex' : ''}${returnType === sse.DataType.STRING ? 'Str' : ''}`; 38 | 39 | // allowScript: { 40 | // scriptEval: true, 41 | // scriptEvalStr: true, 42 | // scriptAggr: true, 43 | // scriptAggrStr: true, 44 | // // ---- 45 | // scriptEvalEx: true, 46 | // scriptEvalExStr: true, 47 | // scriptAggrEx: true, 48 | // scriptAggrExStr: true, 49 | // } 50 | 51 | if (typeof config.allowScript === 'object' && !config.allowScript[ff]) { 52 | request.call.cancelWithStatus(grpc.status.PERMISSION_DENIED, `'${ff}' not allowed.`); 53 | return; 54 | } 55 | 56 | const dualProp = returnType === sse.DataType.STRING ? 'strData' : 'numData'; 57 | 58 | const typesProp = types.map(t => (t === sse.DataType.STRING ? 'strData' : t === sse.DataType.NUMERIC ? 'numData' : '')); 59 | const typeMap = (d, i) => (typesProp[i] ? d[typesProp[i]] : d); 60 | 61 | const run = (args) => { 62 | let v = null; 63 | try { 64 | v = fn(args, params, common); 65 | } catch (e) { 66 | log.error(e); 67 | } 68 | 69 | return v; 70 | }; 71 | 72 | if (functionType === sse.FunctionType.AGGREGATION) { 73 | request.on('data', (bundle) => { 74 | const args = bundle.rows.map(row => row.duals.map(typeMap)); 75 | const v = run(args); 76 | 77 | request.write({ 78 | rows: [{ 79 | duals: [{ 80 | [dualProp]: returnType === sse.DataType.STRING ? String(v) : Number(v), 81 | }], 82 | }], 83 | }); 84 | }); 85 | request.on('end', () => request.end()); 86 | } else { 87 | request.on('data', (bundle) => { 88 | const rows = []; 89 | bundle.rows.forEach((row) => { 90 | const args = row.duals.map(typeMap); 91 | const v = run(args); 92 | rows.push({ 93 | duals: [{ 94 | [dualProp]: returnType === sse.DataType.STRING ? String(v) : Number(v), 95 | }], 96 | }); 97 | }); 98 | request.write({ rows }); 99 | }); 100 | request.on('end', () => request.end()); 101 | } 102 | }; 103 | } 104 | 105 | module.exports = { 106 | getArgType, 107 | getEvaluateScript, 108 | }; 109 | -------------------------------------------------------------------------------- /examples/k-means/web/scatterplot/pic-selections.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: 0 */ 2 | 3 | // --- enable keyboard accessibility --- 4 | // pressing enter (escape) key should confirm (cancel) selections 5 | const KEYS = { 6 | ENTER: 'Enter', 7 | ESCAPE: 'Escape', 8 | IE11_ESC: 'Esc', 9 | SHIFT: 'Shift', 10 | }; 11 | 12 | const instances = []; 13 | let expando = 0; 14 | const confirmOrCancelSelection = (e) => { 15 | const active = instances.filter(a => a.selections && a.selections.isActive()); 16 | if (!active.length) { 17 | return; 18 | } 19 | if (e.key === KEYS.ENTER) { 20 | active.forEach(a => a.selections.confirm()); 21 | } else if (e.key === KEYS.ESCAPE || e.key === KEYS.IE11_ESC) { 22 | active.forEach(a => a.selections.cancel()); 23 | } 24 | }; 25 | 26 | const setup = () => { 27 | document.addEventListener('keyup', confirmOrCancelSelection); 28 | }; 29 | 30 | const teardown = () => { 31 | document.removeEventListener('keyup', confirmOrCancelSelection); 32 | }; 33 | // ------------------------------------------------------ 34 | 35 | const addListeners = (emitter, listeners) => { 36 | Object.keys(listeners).forEach((type) => { 37 | emitter.on(type, listeners[type]); 38 | }); 39 | }; 40 | 41 | const removeListeners = (emitter, listeners) => { 42 | Object.keys(listeners).forEach((type) => { 43 | emitter.removeListener(type, listeners[type]); 44 | }); 45 | }; 46 | 47 | export default function ({ 48 | selections, 49 | brush, 50 | picassoQ, 51 | } = {}, { 52 | path = '/qHyperCubeDef', 53 | } = {}) { 54 | if (!selections) { 55 | return { 56 | release: () => {}, 57 | }; 58 | } 59 | const key = ++expando; 60 | 61 | let layout = null; 62 | 63 | // interceptors primary job is to ensure selections only occur on either values OR ranges 64 | const valueInterceptor = (added) => { 65 | const brushes = brush.brushes(); 66 | brushes.forEach((b) => { 67 | if (b.type === 'range') { // has range selections 68 | brush.clear([]); 69 | } else if (added[0] && added[0].key !== b.id) { // has selections in another dimension 70 | brush.clear([]); 71 | } 72 | }); 73 | return added.filter(t => t.value !== -2); // do not allow selection on null value 74 | }; 75 | 76 | const rangeInterceptor = (a) => { 77 | const v = brush.brushes().filter(b => b.type === 'value'); 78 | if (v.length) { // has dimension values selected 79 | brush.clear([]); 80 | return a; 81 | } 82 | return a; 83 | }; 84 | 85 | brush.intercept('set-ranges', rangeInterceptor); 86 | brush.intercept('toggle-ranges', rangeInterceptor); 87 | 88 | brush.intercept('toggle-values', valueInterceptor); 89 | brush.intercept('set-values', valueInterceptor); 90 | brush.intercept('add-values', valueInterceptor); 91 | 92 | brush.on('start', () => selections.begin(path)); 93 | 94 | const selectionListeners = { 95 | activate: () => { 96 | // TODO - check if we can select in the current chart, 97 | }, 98 | deactivated: () => brush.end(), 99 | cleared: () => brush.clear(), 100 | canceled: () => brush.end(), 101 | }; 102 | addListeners(selections, selectionListeners); 103 | 104 | brush.on('update', () => { 105 | const generated = picassoQ.selections(brush, {}, layout); 106 | generated.forEach(s => selections.select(s)); 107 | }); 108 | 109 | if (instances.length === 0) { 110 | setup(); 111 | } 112 | 113 | instances.push({ 114 | key, 115 | selections, 116 | }); 117 | 118 | return { 119 | layout: (lt) => { layout = lt; }, 120 | release: () => { 121 | layout = null; 122 | const idx = instances.indexOf(instances.filter(i => i.key === key)[0]); 123 | if (idx !== -1) { 124 | instances.splice(idx, 1); 125 | } 126 | if (!instances.length) { 127 | teardown(); 128 | } 129 | removeListeners(selections, selectionListeners); 130 | }, 131 | }; 132 | } 133 | -------------------------------------------------------------------------------- /assets/SSE.proto: -------------------------------------------------------------------------------- 1 | syntax="proto3"; 2 | 3 | /** 4 | * A gRPC definition for the Qlik engine extension protocol. 5 | */ 6 | package qlik.sse; 7 | 8 | /** 9 | * Enable protobuf arena allocations in C++. 10 | * Using an arena can boost performance by reducing the overhead for heap allocations. 11 | */ 12 | // option cc_enable_arenas = true; 13 | 14 | /** 15 | * Data types of the parameters and return values. 16 | */ 17 | enum DataType { 18 | STRING = 0; /// Contains only string. 19 | NUMERIC = 1; /// Contains only double. 20 | DUAL = 2; /// Contains both a string and a double. 21 | } 22 | 23 | /** 24 | * Types of functions (determined by their return values). 25 | */ 26 | enum FunctionType { 27 | SCALAR = 0; /// The return value is a scalar per row. 28 | AGGREGATION = 1; /// All rows are aggregated into a single scalar. 29 | TENSOR = 2; /// Multiple rows in, multiple rows out. 30 | } 31 | 32 | /** 33 | * An empty message used when nothing is to be passed in a call. 34 | */ 35 | message Empty {} 36 | 37 | /** 38 | * Parameter definition for functions and script calls. 39 | */ 40 | message Parameter { 41 | DataType dataType = 1; /// The data type of the parameter. 42 | string name = 2; /// The name of the parameter. 43 | } 44 | 45 | /** 46 | * Field definition for function and script calls. 47 | */ 48 | message FieldDescription { 49 | DataType dataType = 1; /// The data type of the field. 50 | string name = 2; /// The name of the field. 51 | repeated string tags = 3; /// The tags of the field. 52 | } 53 | 54 | /** 55 | * The definition of a function, which informs the Qlik engine how to use it. 56 | */ 57 | message FunctionDefinition { 58 | string name = 1; /// The name of the function. 59 | FunctionType functionType = 2; /// The type of the function. 60 | DataType returnType = 3; /// The return type of the function. 61 | repeated Parameter params = 4; /// The parameters the function takes. 62 | int32 functionId = 5; /// A unique ID number for the function, set by the plugin, to be used in calls from the Qlik engine to the plugin. 63 | } 64 | 65 | /** 66 | * A full description of the plugin, sent to the Qlik engine, listing all functions available and indicating whether script evaluation is allowed. 67 | */ 68 | message Capabilities { 69 | bool allowScript = 1; /// When true, the Qlik engine allows scripts to be sent to the plugin. 70 | repeated FunctionDefinition functions = 2; /// The definitions of all available functions. 71 | string pluginIdentifier = 3; /// The ID or name of the plugin. 72 | string pluginVersion = 4; /// The version of the plugin. 73 | } 74 | 75 | /** 76 | * The basic data type for the data stream. Can contain double, string, or both. 77 | */ 78 | message Dual { 79 | double numData = 1; /// Numeric value as double. 80 | string strData = 2; /// String. 81 | } 82 | 83 | /** 84 | * A row of duals. 85 | */ 86 | message Row { 87 | repeated Dual duals = 1; /// Row of duals. 88 | } 89 | 90 | /** 91 | * A number of rows collected in one message. The actual number will depend on the size of each row and is adjusted to optimize throughput. 92 | */ 93 | message BundledRows { 94 | repeated Row rows = 1; 95 | } 96 | 97 | /** 98 | * A header sent at the start of an EvaluateScript request under the key "qlik-scriptrequestheader-bin". 99 | */ 100 | message ScriptRequestHeader { 101 | string script = 1; /// The script to be executed. 102 | FunctionType functionType = 2; /// The function type of the script evaluation: scalar, aggregation or tensor. 103 | DataType returnType = 3; /// The return type from the script evaluation: numeric, string or both. 104 | repeated Parameter params = 4; /// The parameters names and types passed to the script. 105 | } 106 | 107 | /** 108 | * A header sent at the start of an ExecuteFunction request under the key "qlik-functionrequestheader-bin". 109 | */ 110 | message FunctionRequestHeader { 111 | int32 functionId = 1; /// The ID of the function to be executed. 112 | string version = 2; /// A dummy variable as a workaround for an issue. 113 | } 114 | 115 | /** 116 | * A header sent at the start of both an EvaluateScript request and an ExecuteFunction request under the key "qlik-commonrequestheader-bin". 117 | */ 118 | message CommonRequestHeader { 119 | string appId = 1; /// The ID of the app the request was executed in. 120 | string userId = 2; /// The ID of the user the request was executed by. 121 | int64 cardinality = 3; /// The cardinality of the parameters. 122 | } 123 | 124 | /** 125 | * A header sent before returning data to Qlik, under the key "qlik-tabledescription-bin". 126 | */ 127 | message TableDescription { 128 | repeated FieldDescription fields = 1; /// The fields of the table. 129 | string name = 2; /// The name of the table. 130 | int64 numberOfRows = 3; /// Number of rows in table. 131 | } 132 | 133 | /** 134 | * The communication service provided between the Qlik engine and the plugin. 135 | */ 136 | service Connector 137 | { 138 | /// A handshake call for the Qlik engine to retrieve the capability of the plugin. 139 | rpc GetCapabilities (Empty) returns (Capabilities) {} 140 | 141 | /// Requests a function to be executed as specified in the header. 142 | rpc ExecuteFunction (stream BundledRows) returns (stream BundledRows) {} 143 | 144 | /// Requests a script to be evaluated as specified in the header. 145 | rpc EvaluateScript (stream BundledRows) returns (stream BundledRows) {} 146 | } 147 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Server side extensions 2 | 3 | Qlik's Server Side Extension feature enables you to extend the engine with custom calculations. 4 | 5 | There are two ways you can do this; by defining custom functions, and by enabling runtime script evaluation. 6 | 7 | ## Functions 8 | 9 | [Intro](https://github.com/qlik-oss/server-side-extension/blob/master/docs/writing_a_plugin.md#function-evaluation) 10 | 11 | Functions are defined based on the input type you expect them to have and could be one of scalar, aggregation or tensor. 12 | 13 | To explain them, we're going to assume we have the following data model: 14 | 15 | Product group | Product | Sales 16 | ---|---|--- 17 | Snack|Chips|19 18 | Snack|Popcorn|11 19 | Snack|Cookies|18 20 | Dairy|Cheese|12 21 | 22 | ```basic 23 | Prods: 24 | LOAD * Inline [ 25 | Product group,Product,Sales 26 | Snack, Chips, 19 27 | Snack, Popcorn, 11 28 | Snack, Cookies, 18 29 | Dairy, Cheese, 12 30 | ]; 31 | ``` 32 | 33 | Data in SSE is received in the shape of `bundle`s: 34 | 35 | ```js 36 | const bundle = { 37 | rows: [{ 38 | duals: [{ // columns 39 | numData, // numerical value of the dual 40 | strData, // string value of the dual 41 | }, /* ... */] 42 | }, /* ... */] 43 | ]; 44 | ``` 45 | 46 | ### Tensor (and Scalar) 47 | 48 | Tensor is used when there is a one-to-one relationship between the fields in the cube and the expressions you provide to the SSE function. 49 | 50 | If we use _Product group_ as dimension and pass in _sum(Sales)_ as a parameter to the function, we would receive one bundle with one row per _Product group_: 51 | 52 | - `[48, 12]` 53 | 54 | **Example** 55 | 56 | The following example returns the value 'green' if the value of the expression _sum(Sales)_ is > 20, otherwise it returns 'red'. 57 | 58 | The expression: 59 | 60 | ```basic 61 | sse.color(sum(Sales)); 62 | ``` 63 | 64 | The SSE function: 65 | ```js 66 | function greenOrRed(request) { 67 | request.on('data', (bundle) => { 68 | const rows = []; 69 | bundle.rows.forEach((row) => { 70 | rows.push({ // for each row in the input bundle 71 | duals: [{ // we add one row to the output, containing only one dual 72 | // that dual must contain 'strData' because the return type of this function is STRING 73 | strData: row.duals[0].numData > 20 ? 'green' : 'red' // numData is expected to be valid since the dataType of the first param is NUMERIC 74 | ], 75 | }); 76 | }); 77 | request.write({ rows }); // send data back to Qlik engine 78 | } 79 | } 80 | 81 | // register the function 82 | server.addFunction(greenOrRed, { 83 | functionType: q.sse.FunctionType.TENSOR, 84 | returnType: q.sse.DataType.STRING, 85 | name: 'color', // optional, if not specified the name will be the function itself ('greenOrRed') 86 | params: [{ 87 | name: 'first', 88 | dataType: q.sse.DataType.NUMERIC 89 | }] 90 | }) 91 | ``` 92 | 93 | ### Aggregation 94 | 95 | Aggregation is used when there is a one-to-many relationship between the fields in the cube and the expressions you provide to the SSE function. Common use cases are calculating sum, avg, concatenation etc. 96 | 97 | If we use _Product group_ as dimension and pass in _Sales_ as a parameter to the function, we would receive three bundles: 98 | 99 | - For _Product group_ `'Snacks'`: `[19, 11, 18]` 100 | - For _Product group_ `'Dairy'`: `[12]` 101 | - For _total_: `[19, 11, 18, 12]` 102 | 103 | **Example** 104 | 105 | If we want to calculate the _sum_ of _Sales_ for each _Product group_, we simply aggregate the rows in each bundle: 106 | 107 | ```js 108 | (bundle) => { 109 | let v = 0; 110 | bundle.rows.forEach((row) => { 111 | row.duals.forEach((dual) => { 112 | v += dual.numData; 113 | }); 114 | }); 115 | request.write({ 116 | rows: [{ // return only one row, containing one dual 117 | duals: [{ numData: v }], 118 | }], 119 | }); 120 | } 121 | ``` 122 | 123 | ## Script evaluation 124 | 125 |

126 | Note! Enabling script evaluation is dangerous! 127 |

128 | 129 | [Intro](https://github.com/qlik-oss/server-side-extension/blob/master/docs/writing_a_plugin.md#script-evaluation) 130 | 131 | Script evaluation makes it easy to run some basic arithmetic and string manipulation. 132 | 133 | To simplify the access to values inside scripts: 134 | 135 | - bundles are first remapped into an `args` parameter. The values inside `args` are: 136 | - strings when parameter type is STRING 137 | - numbers when parameter type is NUMERIC 138 | - dual when parameter type is DUAL 139 | - the script is evaluated by wrapping it inside a function: 140 | ```js 141 | function(args, params, common) { 142 | // script evaluated here 143 | } 144 | ``` 145 | - `args` may be one of 146 | - `(string | number | dual)[][]` - when any of the `*Aggr` functions are called 147 | - `(string | number | dual)[]` 148 | - `params` <[Parameter](https://github.com/qlik-oss/server-side-extension/blob/master/docs/SSE_Protocol.md#qlik.sse.Parameter)[]> 149 | - `common` <[CommonRequestHeader](https://github.com/qlik-oss/server-side-extension/blob/master/docs/SSE_Protocol.md#qlik.sse.CommonRequestHeader)> 150 | - values are then remapped back to appropriate bundles 151 | 152 | **Example** 153 | 154 | Calculate the sum of _Sales_ per _Product Group_: 155 | 156 | ```basic 157 | HelloSSE.ScriptAggr('return args.reduce((prev, curr) => (prev + curr[0]), 0)', 'Sales') 158 | ``` 159 | 160 | In this example we're using one of the `*Aggr` functions, the value of `args` will then be: 161 | 162 | - For _Product group_ `'Snacks'`: `[[19], [11], [18]]` 163 | - For _Product group_ `'Dairy'`: `[[12]]` 164 | - For _total_: `[[19], [11], [18], [12]` 165 | 166 | **Example** 167 | 168 | Return 'green' if the value of the expression _sum(Sales)_ is > 20, otherwise return 'red'. 169 | 170 | ```basic 171 | sse.ScriptEvalExStr('N', 'return args[0] > 20 ? "green" : "red"', sum(Sales)); 172 | ``` 173 | 174 | `args` will in this case be: 175 | 176 | - For _Product group_ `'Snacks'`: `[48]` 177 | - For _Product group_ `'Dairy'`: `[12]` 178 | 179 | In this example, we use `*Ex` because we want to get the numeric value of `sum(Sales)` by specifying `N` as the first parameter. The values inside `args` will therefore be the `numData` part of the `dual`. We use `*Str` because we want a `string` as output. 180 | 181 | ## Table load 182 | 183 | [Intro](https://github.com/qlik-oss/server-side-extension/blob/master/docs/writing_a_plugin.md#tabledescription) 184 | 185 | It's possible to load entire tables and augment script data through a table load defined in the script. 186 | 187 | **Example** 188 | 189 | Let's first define a function that has a table description and returns additional columns of data: 190 | 191 | ```js 192 | const NUTRITIONAL_FACTS = { 193 | // protein, fat, carbs (in grams) 194 | Cheese: [20, 25, 4], 195 | Popcorn: [12, 4, 78], 196 | Chips: [7, 35, 53], 197 | Cookies: [6, 24, 65], 198 | }; 199 | 200 | function nutrition(request) { 201 | request.on('data', (bundle) => { 202 | const rows = []; 203 | bundle.rows.forEach((row) => { 204 | const c = NUTRITIONAL_FACTS[row.duals[0].strData]; 205 | rows.push({ 206 | duals: [ 207 | { strData: row.duals[0].strData }, // first column - product 208 | { numData: c ? c[0] : '-' }, // second column - protein 209 | { numData: c ? c[1] : '-' }, // third column - fat 210 | { numData: c ? c[2] : '-' }, // fourth column - carbs 211 | ], 212 | }); 213 | }); 214 | request.write({ rows }); 215 | }); 216 | } 217 | 218 | s.addFunction(nutrition, { 219 | functionType: q.sse.FunctionType.TENSOR, 220 | // returnType: q.sse.DataType.NUMERIC, // returnType doesn't matter when called with the 'extension' clause 221 | params: [{ 222 | name: 'does not matter', 223 | dataType: q.sse.DataType.STRING, 224 | }], 225 | tableDescription: { // describe the table this function is expected to return 226 | fields: [ 227 | { dataType: q.sse.DataType.STRING, name: 'Product'}, 228 | { dataType: q.sse.DataType.NUMERIC, name: 'protein'}, 229 | { dataType: q.sse.DataType.NUMERIC, name: 'fat'}, 230 | { dataType: q.sse.DataType.NUMERIC, name: 'carbs'} 231 | ], 232 | }, 233 | }); 234 | ``` 235 | 236 | In the load script we can then load that additional data using the `Load ... Extension` syntax: 237 | 238 | ```basic 239 | Prods: 240 | LOAD * Inline [ 241 | Product group,Product,Sales 242 | Snack, Chips, 19 243 | Snack, Popcorn, 11 244 | Snack, Cookies, 18 245 | Dairy, Cheese, 12 246 | 247 | Load * Extension sse.nutrition( Prods{Product} ); 248 | ]; 249 | ``` 250 | 251 | which will result in the following table being added to the data model: 252 | 253 | Product | fat | protein | carbs 254 | ---|---|---|--- 255 | Chips|20|25|4 256 | Popcorn|12|4|78 257 | Cookies|7|35|53 258 | Cheese|6|24|65 259 | -------------------------------------------------------------------------------- /test/e2e.spec.js: -------------------------------------------------------------------------------- 1 | const grpc = require('grpc'); 2 | const { server, sse } = require('../lib/index'); 3 | 4 | function duplicate(request) { 5 | request.on('data', (bundle) => { 6 | const rows = []; 7 | bundle.rows.forEach((row) => { 8 | row.duals.forEach((dual) => { 9 | rows.push({ 10 | duals: [{ numData: dual.numData * 2 }], 11 | }); 12 | }); 13 | }); 14 | request.write({ rows }); 15 | }); 16 | } 17 | 18 | async function later(request) { 19 | request.on('data', (bundle) => { 20 | const rows = []; 21 | setTimeout(() => { 22 | bundle.rows.forEach((row) => { 23 | row.duals.forEach((dual) => { 24 | rows.push({ 25 | duals: [{ strData: dual.strData.toUpperCase() }], 26 | }); 27 | }); 28 | }); 29 | request.write({ rows }); 30 | request.end(); 31 | }, 200); 32 | }); 33 | } 34 | 35 | function bad() { 36 | throw new Error('blabla'); 37 | } 38 | 39 | describe('e2e', () => { 40 | let s; 41 | let c; 42 | before(() => { 43 | s = server({ 44 | identifier: 'xxx', 45 | version: '0.1.0', 46 | allowScript: { 47 | scriptEval: true, 48 | scriptEvalStr: true, 49 | scriptAggr: true, 50 | scriptAggrStr: true, 51 | // ---- 52 | scriptEvalEx: true, 53 | scriptEvalExStr: false, 54 | scriptAggrEx: true, 55 | scriptAggrExStr: true, 56 | }, 57 | logLevel: 0, 58 | }); 59 | 60 | s.addFunction(duplicate, { 61 | functionType: sse.FunctionType.SCALAR, 62 | returnType: sse.DataType.NUMERIC, 63 | params: [{ 64 | name: 'first', 65 | dataType: sse.DataType.NUMERIC, 66 | }], 67 | }); 68 | 69 | s.addFunction(later, { 70 | functionType: sse.FunctionType.SCALAR, 71 | returnType: sse.DataType.STRING, 72 | params: [{ 73 | name: 'first', 74 | dataType: sse.DataType.STRING, 75 | }], 76 | }); 77 | 78 | s.addFunction(bad, { 79 | functionType: sse.FunctionType.SCALAR, 80 | returnType: sse.DataType.STRING, 81 | params: [], 82 | }); 83 | 84 | s.start({ 85 | port: 5001, 86 | }); 87 | 88 | c = new sse.Connector('0.0.0.0:5001', grpc.credentials.createInsecure()); 89 | }); 90 | 91 | after(() => { 92 | s.close(); 93 | }); 94 | 95 | describe('getCapabilities', () => { 96 | it('should return a Capabilities object', (done) => { 97 | c.getCapabilities(new sse.Empty(), (x, cap) => { 98 | const type = () => new sse.Capabilities(cap); 99 | expect(type).to.not.throw(); 100 | expect(cap).to.eql({ 101 | allowScript: true, 102 | functions: [{ 103 | name: 'duplicate', 104 | functionType: 'SCALAR', 105 | returnType: 'NUMERIC', 106 | params: [{ dataType: 'NUMERIC', name: 'first' }], 107 | functionId: 1001, 108 | }, { 109 | name: 'later', 110 | functionType: 'SCALAR', 111 | returnType: 'STRING', 112 | params: [{ dataType: 'STRING', name: 'first' }], 113 | functionId: 1002, 114 | }, { 115 | name: 'bad', 116 | functionType: 'SCALAR', 117 | returnType: 'STRING', 118 | params: [], 119 | functionId: 1003, 120 | }], 121 | pluginIdentifier: 'xxx', 122 | pluginVersion: '0.1.0', 123 | }); 124 | done(); 125 | }); 126 | }); 127 | }); 128 | 129 | describe('executeFunction', () => { 130 | it('should emit UMIMPLEMENTED error when function is not found', (done) => { 131 | const fmh = new sse.FunctionRequestHeader({ 132 | functionId: 99, 133 | }).encodeNB(); 134 | 135 | const metadata = new grpc.Metadata(); 136 | metadata.set('qlik-functionrequestheader-bin', fmh); 137 | 138 | const e = c.executeFunction(metadata); 139 | 140 | e.on('data', () => {}); 141 | e.on('error', (err) => { 142 | expect(err.code).to.equal(grpc.status.UNIMPLEMENTED); 143 | expect(err.details).to.equal('The method is not implemented.'); 144 | done(); 145 | }); 146 | e.end(); 147 | }); 148 | 149 | it('should emit UNKNOWN error when function throws error', (done) => { 150 | const fmh = new sse.FunctionRequestHeader({ 151 | functionId: 1003, 152 | }).encodeNB(); 153 | 154 | const metadata = new grpc.Metadata(); 155 | metadata.set('qlik-functionrequestheader-bin', fmh); 156 | 157 | const e = c.executeFunction(metadata); 158 | 159 | e.on('data', () => {}); 160 | e.on('error', (err) => { 161 | expect(err.code).to.equal(grpc.status.UNKNOWN); 162 | expect(err.details).to.equal('blabla'); 163 | done(); 164 | }); 165 | e.end(); 166 | }); 167 | 168 | it('should duplicate numbers', (done) => { 169 | const fmh = new sse.FunctionRequestHeader({ 170 | functionId: 1001, 171 | }).encodeNB(); 172 | 173 | const metadata = new grpc.Metadata(); 174 | metadata.set('qlik-functionrequestheader-bin', fmh); 175 | 176 | const b = new sse.BundledRows({ 177 | rows: [{ 178 | duals: [{ 179 | numData: 7, 180 | }], 181 | }], 182 | }); 183 | 184 | const e = c.executeFunction(metadata); 185 | 186 | let data = {}; 187 | const assert = () => { 188 | expect(data.rows).to.eql([{ duals: [{ numData: 14, strData: '' }] }]); 189 | done(); 190 | }; 191 | 192 | e.on('data', (d) => { data = d; }); 193 | 194 | e.on('end', assert); 195 | 196 | e.write(b); 197 | e.end(); 198 | }); 199 | 200 | it('should support async function', (done) => { 201 | const fmh = new sse.FunctionRequestHeader({ 202 | functionId: 1002, 203 | }).encodeNB(); 204 | 205 | const metadata = new grpc.Metadata(); 206 | metadata.set('qlik-functionrequestheader-bin', fmh); 207 | 208 | const b = new sse.BundledRows({ 209 | rows: [{ 210 | duals: [{ 211 | strData: 'cap me', 212 | }], 213 | }], 214 | }); 215 | 216 | const e = c.executeFunction(metadata); 217 | 218 | let data = {}; 219 | const assert = () => { 220 | expect(data.rows).to.eql([{ duals: [{ numData: 0, strData: 'CAP ME' }] }]); 221 | done(); 222 | }; 223 | 224 | e.on('data', (d) => { data = d; }); 225 | 226 | e.on('end', assert); 227 | 228 | e.write(b); 229 | e.end(); 230 | }); 231 | }); 232 | 233 | describe('evaluateScript', () => { 234 | it('should duplicate numbers', (done) => { 235 | const sh = new sse.ScriptRequestHeader({ 236 | script: 'return args[0] * 2', 237 | functionType: sse.FunctionType.SCALAR, 238 | returnType: sse.DataType.NUMERIC, 239 | params: [{ dataType: sse.DataType.NUMERIC, name: 'f' }], 240 | }).encodeNB(); 241 | 242 | const ch = new sse.CommonRequestHeader({ 243 | appId: 'aa', 244 | userId: 'uu', 245 | cardinality: 55, 246 | }).encodeNB(); 247 | 248 | const metadata = new grpc.Metadata(); 249 | metadata.set('qlik-scriptrequestheader-bin', sh); 250 | metadata.set('qlik-commonrequestheader-bin', ch); 251 | 252 | const b = new sse.BundledRows({ 253 | rows: [{ 254 | duals: [{ 255 | numData: 6, 256 | }], 257 | }], 258 | }); 259 | 260 | const e = c.evaluateScript(metadata); 261 | 262 | let data = {}; 263 | const assert = () => { 264 | expect(data.rows).to.eql([{ duals: [{ numData: 12, strData: '' }] }]); 265 | done(); 266 | }; 267 | 268 | e.on('data', (d) => { data = d; }); 269 | 270 | e.on('end', assert); 271 | 272 | e.write(b); 273 | e.end(); 274 | }); 275 | 276 | it('should duplicate numbers aggr', (done) => { 277 | const sh = new sse.ScriptRequestHeader({ 278 | script: 'return args[0] * 2', 279 | functionType: sse.FunctionType.AGGREGATION, 280 | returnType: sse.DataType.NUMERIC, 281 | params: [{ dataType: sse.DataType.NUMERIC, name: 'f' }], 282 | }).encodeNB(); 283 | 284 | const ch = new sse.CommonRequestHeader({ 285 | appId: 'aa', 286 | userId: 'uu', 287 | cardinality: 55, 288 | }).encodeNB(); 289 | 290 | const metadata = new grpc.Metadata(); 291 | metadata.set('qlik-scriptrequestheader-bin', sh); 292 | metadata.set('qlik-commonrequestheader-bin', ch); 293 | 294 | const b = new sse.BundledRows({ 295 | rows: [{ 296 | duals: [{ 297 | numData: 6, 298 | }], 299 | }], 300 | }); 301 | 302 | const e = c.evaluateScript(metadata); 303 | 304 | let data = {}; 305 | const assert = () => { 306 | expect(data.rows).to.eql([{ duals: [{ numData: 12, strData: '' }] }]); 307 | done(); 308 | }; 309 | 310 | e.on('data', (d) => { data = d; }); 311 | 312 | e.on('end', assert); 313 | 314 | e.write(b); 315 | e.end(); 316 | }); 317 | 318 | it('should catch script parsing error', (done) => { 319 | const sh = new sse.ScriptRequestHeader({ 320 | script: 'blabla invalid javascript', 321 | functionType: sse.FunctionType.SCALAR, 322 | returnType: sse.DataType.NUMERIC, 323 | params: [{ dataType: sse.DataType.NUMERIC, name: 'f' }], 324 | }).encodeNB(); 325 | 326 | const ch = new sse.CommonRequestHeader({ 327 | appId: 'aa', 328 | userId: 'uu', 329 | cardinality: 55, 330 | }).encodeNB(); 331 | 332 | const metadata = new grpc.Metadata(); 333 | metadata.set('qlik-scriptrequestheader-bin', sh); 334 | metadata.set('qlik-commonrequestheader-bin', ch); 335 | 336 | const e = c.evaluateScript(metadata); 337 | 338 | let err = {}; 339 | const assert = () => { 340 | expect(err.code).to.eql(grpc.status.INVALID_ARGUMENT); 341 | done(); 342 | }; 343 | 344 | e.on('data', () => {}); 345 | e.on('error', (er) => { err = er; assert(); }); 346 | e.end(); 347 | }); 348 | 349 | it('should not allow scriptEvalExStr', (done) => { 350 | const sh = new sse.ScriptRequestHeader({ 351 | script: 'return 0', 352 | functionType: sse.FunctionType.SCALAR, 353 | returnType: sse.DataType.STRING, 354 | params: [{ dataType: sse.DataType.DUAL, name: 'f' }], 355 | }).encodeNB(); 356 | 357 | const ch = new sse.CommonRequestHeader({ 358 | appId: 'aa', 359 | userId: 'uu', 360 | cardinality: 55, 361 | }).encodeNB(); 362 | 363 | const metadata = new grpc.Metadata(); 364 | metadata.set('qlik-scriptrequestheader-bin', sh); 365 | metadata.set('qlik-commonrequestheader-bin', ch); 366 | 367 | const e = c.evaluateScript(metadata); 368 | 369 | let err = {}; 370 | const assert = () => { 371 | expect(err.code).to.eql(grpc.status.PERMISSION_DENIED); 372 | done(); 373 | }; 374 | 375 | e.on('data', () => {}); 376 | e.on('error', (er) => { err = er; assert(); }); 377 | e.end(); 378 | }); 379 | 380 | it('should catch script execution error', (done) => { 381 | const sh = new sse.ScriptRequestHeader({ 382 | script: 'return args.foo.nope', 383 | functionType: sse.FunctionType.SCALAR, 384 | returnType: sse.DataType.NUMERIC, 385 | params: [{ dataType: sse.DataType.NUMERIC, name: 'f' }], 386 | }).encodeNB(); 387 | 388 | const ch = new sse.CommonRequestHeader({ 389 | appId: 'aa', 390 | userId: 'uu', 391 | cardinality: 55, 392 | }).encodeNB(); 393 | 394 | const metadata = new grpc.Metadata(); 395 | metadata.set('qlik-scriptrequestheader-bin', sh); 396 | metadata.set('qlik-commonrequestheader-bin', ch); 397 | 398 | const b = new sse.BundledRows({ 399 | rows: [{ 400 | duals: [{ 401 | numData: 6, 402 | }], 403 | }], 404 | }); 405 | 406 | const e = c.evaluateScript(metadata); 407 | 408 | let data = {}; 409 | const assert = () => { 410 | expect(data.rows).to.eql([{ duals: [{ numData: 0, strData: '' }] }]); 411 | done(); 412 | }; 413 | 414 | e.on('data', (d) => { data = d; }); 415 | e.on('end', assert); 416 | 417 | e.write(b); 418 | e.end(); 419 | }); 420 | }); 421 | }); 422 | -------------------------------------------------------------------------------- /examples/k-means/web/mouse.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | points: 3 | Load * inline [ 4 | x,y,body_part 5 | 0.45660137634625386,0.43280640922410835,Head 6 | 0.6113784672224188,0.5286245988894975,Head 7 | 0.45029897412145387,0.7116061205092745,Head 8 | 0.6390150501606866,0.46074398219372076,Head 9 | 0.6289567839292338,0.32346951478531516,Head 10 | 0.5662104392361843,0.289357728041304,Head 11 | 0.5676530359179894,0.29547265797376954,Head 12 | 0.43602399117606827,0.4157757274892156,Head 13 | 0.5454137939219365,0.36750178191490135,Head 14 | 0.4394528077596546,0.5478674292772121,Head 15 | 0.512200437106122,0.500864646508404,Head 16 | 0.3917587782134365,0.6103473035285875,Head 17 | 0.5218570283765972,0.5769060811333763,Head 18 | 0.7194189575585879,0.43648093863144677,Head 19 | 0.2995329322777752,0.6230627270405613,Head 20 | 0.4982241475102956,0.42839983794125985,Head 21 | 0.2740522921447801,0.4804320598631085,Head 22 | 0.38284021717487404,0.5440688862962566,Head 23 | 0.4249231989421871,0.284825727537369,Head 24 | 0.5207238207675212,0.32542733595644696,Head 25 | 0.44329557666585284,0.32697891986066585,Head 26 | 0.39882121731993425,0.44917757772100975,Head 27 | 0.5043499801956989,0.31104972990661883,Head 28 | 0.6418316378383897,0.44634230241629513,Head 29 | 0.3875139053807079,0.5835776682759641,Head 30 | 0.3939936115953249,0.4355492315286958,Head 31 | 0.5310410091841999,0.6295649641551255,Head 32 | 0.6888968092469115,0.5241631381654697,Head 33 | 0.6493962308649859,0.37410916300714764,Head 34 | 0.46558804569679024,0.3808260349477101,Head 35 | 0.6258946158877843,0.29786152832143475,Head 36 | 0.33422593969522363,0.4716084824524596,Head 37 | 0.3668006741594324,0.7098112476478706,Head 38 | 0.7416277782794454,0.5889910190948651,Head 39 | 0.38272263131699585,0.452737367638514,Head 40 | 0.56506329156323,0.4368807019054587,Head 41 | 0.3507596663956172,0.4731503428096154,Head 42 | 0.5681429924399365,0.42284600051634724,Head 43 | 0.4982887847983077,0.3891165629058462,Head 44 | 0.4364283715014795,0.692301105545165,Head 45 | 0.6037254126431655,0.46839656019105724,Head 46 | 0.5888498769597843,0.47524402811514127,Head 47 | 0.6989962241574786,0.39153609364235065,Head 48 | 0.6752421809738202,0.5665200576628142,Head 49 | 0.6455623948444207,0.4575869722287391,Head 50 | 0.4155274258102421,0.5261488358815203,Head 51 | 0.29194678962167875,0.4272334801227161,Head 52 | 0.4375613322732245,0.6446881351198951,Head 53 | 0.33345440081047545,0.4690126900207642,Head 54 | 0.5434853498540486,0.5575180098090339,Head 55 | 0.4411923952597462,0.5154128811111293,Head 56 | 0.5706964819958329,0.6262959368064891,Head 57 | 0.7065470571054172,0.3526615357856453,Head 58 | 0.4431214691109642,0.6322492132512522,Head 59 | 0.323144615700802,0.5142624100259803,Head 60 | 0.7009787144079658,0.6058330298954564,Head 61 | 0.6794405111786648,0.33386383983683265,Head 62 | 0.7159374380776726,0.5369769299381377,Head 63 | 0.47975236292326595,0.7007875282162737,Head 64 | 0.41003838698089295,0.5099887518902678,Head 65 | 0.49008612485006625,0.6294229305694207,Head 66 | 0.46603747603182555,0.6268512537813131,Head 67 | 0.32351200563321114,0.6393495801876143,Head 68 | 0.4473375193351228,0.7348324501821877,Head 69 | 0.5556571320867622,0.5715022879444677,Head 70 | 0.5707049148610202,0.33224836461163654,Head 71 | 0.6227812899148043,0.3306931377284241,Head 72 | 0.6006889225245693,0.2928147023531954,Head 73 | 0.6676542569289109,0.5752724418322568,Head 74 | 0.309970327833062,0.45265291703067895,Head 75 | 0.6127630910463642,0.5027568608385389,Head 76 | 0.32860350692925033,0.4413129371744251,Head 77 | 0.30656537501122727,0.35196144277089036,Head 78 | 0.6051765579581507,0.4821492917720065,Head 79 | 0.440146903930251,0.495593601024648,Head 80 | 0.598144530201707,0.5532603752903262,Head 81 | 0.44347199035029516,0.4622535936838886,Head 82 | 0.422174903173814,0.34103901488291244,Head 83 | 0.45550648740195465,0.41171202752594527,Head 84 | 0.4720555872309712,0.7165683667235623,Head 85 | 0.7347191848708641,0.5758536326400511,Head 86 | 0.5155381256468614,0.7245756845076422,Head 87 | 0.3273149601044746,0.6263807471208405,Head 88 | 0.4346341375571578,0.3634158158782626,Head 89 | 0.38319743027975633,0.2762100009308178,Head 90 | 0.48022262821967227,0.39871803725628563,Head 91 | 0.5379614400606375,0.6077396909730114,Head 92 | 0.31976325998106114,0.6241915643427768,Head 93 | 0.5284666588142557,0.5480244917619645,Head 94 | 0.47710199684411575,0.40228069309626585,Head 95 | 0.33331807289623916,0.46849763185945303,Head 96 | 0.6753842036454221,0.31970273813787053,Head 97 | 0.40951456007576653,0.42842739961973997,Head 98 | 0.5006618827477158,0.6265923168364407,Head 99 | 0.49827763183980106,0.5382010339612469,Head 100 | 0.5012632137589963,0.5748491652989149,Head 101 | 0.68924092353326,0.38632021585199633,Head 102 | 0.7446366283207957,0.4865794985985416,Head 103 | 0.5477306196567974,0.668460274663,Head 104 | 0.39153963750374315,0.5776294199926051,Head 105 | 0.557290307940882,0.6178822878859911,Head 106 | 0.5416042277812119,0.47991525488202486,Head 107 | 0.5137967253936864,0.6285313418967632,Head 108 | 0.4791669907632519,0.6495911839030653,Head 109 | 0.6720370220970937,0.4040964379295322,Head 110 | 0.3958227884361515,0.6588452624261568,Head 111 | 0.5787426768538191,0.6941884431469565,Head 112 | 0.6058904881765098,0.5303936494898217,Head 113 | 0.407653340954602,0.44070720087977705,Head 114 | 0.42722756534718975,0.6185064737597324,Head 115 | 0.516001237408541,0.4585304151965233,Head 116 | 0.5913356730889696,0.26544906819330893,Head 117 | 0.4862318964328004,0.4635469791701871,Head 118 | 0.4114364122202755,0.4044955392669422,Head 119 | 0.4101348255428155,0.49464707734714325,Head 120 | 0.4679994292151898,0.4128731663431996,Head 121 | 0.5819559278781021,0.687203291527696,Head 122 | 0.6793080502468297,0.38356312225629596,Head 123 | 0.38937054474971433,0.5734057037038085,Head 124 | 0.49606383258716635,0.3290899150196019,Head 125 | 0.6781048209663016,0.33292748108390435,Head 126 | 0.3496903121642816,0.4329933992146022,Head 127 | 0.28538727428959565,0.44406197703937333,Head 128 | 0.4802810307277785,0.7465574165966149,Head 129 | 0.2758683060347048,0.42493086482223386,Head 130 | 0.7165964637807317,0.4961768132521903,Head 131 | 0.6275690079206528,0.5523467224319585,Head 132 | 0.30863520005670103,0.6065060017631401,Head 133 | 0.5184350739403722,0.6255933181594546,Head 134 | 0.3252727704249059,0.518663878690165,Head 135 | 0.5364211032665399,0.3192852795617066,Head 136 | 0.6360506427122014,0.452177164199903,Head 137 | 0.6550836751096591,0.6737695414449972,Head 138 | 0.381049286997603,0.3442848645161163,Head 139 | 0.6977418201849414,0.5002854794895334,Head 140 | 0.29387974001507766,0.5660318782525734,Head 141 | 0.3950573073741327,0.30776141086320624,Head 142 | 0.5875283142332661,0.5029850439760691,Head 143 | 0.47577243578762335,0.6571162960275716,Head 144 | 0.5765913115106007,0.6073630568836432,Head 145 | 0.43120782286536896,0.4536048393809892,Head 146 | 0.4271374008377422,0.7324222423451453,Head 147 | 0.509538178207134,0.6931777163283148,Head 148 | 0.5181698331845648,0.5111409992566154,Head 149 | 0.44864558562432355,0.6811903182476917,Head 150 | 0.682341733951336,0.43078415493021127,Head 151 | 0.5309666065607501,0.2985289265625874,Head 152 | 0.600897799997153,0.6635429624194095,Head 153 | 0.46780169318472314,0.3947836158292342,Head 154 | 0.5915644139504792,0.3850652622739119,Head 155 | 0.387755003704134,0.6797111393353542,Head 156 | 0.36782453531478027,0.4372164861268705,Head 157 | 0.43639037701069394,0.4112832888808655,Head 158 | 0.626036706904056,0.4330240031551585,Head 159 | 0.4776430480397612,0.717473875201391,Head 160 | 0.5054771793503865,0.5395424282031024,Head 161 | 0.5221943178984508,0.7031808147941978,Head 162 | 0.4717768997272872,0.6064647504959523,Head 163 | 0.5050531711150169,0.6694099945514911,Head 164 | 0.650228341055716,0.5706997864367978,Head 165 | 0.3096170370177286,0.526590983167117,Head 166 | 0.6249509070898287,0.44761019842893723,Head 167 | 0.5053620701269804,0.6750690635122293,Head 168 | 0.6235230129146608,0.6421753304125937,Head 169 | 0.5106216733301224,0.41911915802469024,Head 170 | 0.29111887670561115,0.3624646302450098,Head 171 | 0.34135204212954423,0.4836897378719605,Head 172 | 0.6162899699681502,0.46263925715201104,Head 173 | 0.7068606124650494,0.46130772099304174,Head 174 | 0.518605160378907,0.37252647303440034,Head 175 | 0.6819633448943572,0.573433213976195,Head 176 | 0.5663578499358706,0.49135390567741366,Head 177 | 0.7086202064025585,0.41844781677046694,Head 178 | 0.5249692372285568,0.485610262030015,Head 179 | 0.6615332034083491,0.5495324580384817,Head 180 | 0.46567523869648914,0.5669969664558905,Head 181 | 0.6411788036353628,0.4686061791825734,Head 182 | 0.4354437851788592,0.6211545627513583,Head 183 | 0.3581252743826723,0.4777912813178678,Head 184 | 0.44937310198209257,0.4881941150533161,Head 185 | 0.36399287493228166,0.6032079773689988,Head 186 | 0.4120218915505539,0.3893285016733924,Head 187 | 0.5890047243886416,0.726746495955555,Head 188 | 0.4313267952812512,0.5750655042335979,Head 189 | 0.4927077492482129,0.38147483787457936,Head 190 | 0.5809203379333364,0.27920133592307494,Head 191 | 0.6149777466040518,0.4894293635413928,Head 192 | 0.4908517743150437,0.3637882120895225,Head 193 | 0.41264041587823297,0.41225177314572403,Head 194 | 0.3253529896251357,0.3504070739523616,Head 195 | 0.6974678628306086,0.4939221539103153,Head 196 | 0.4967336591637833,0.7059962656241111,Head 197 | 0.3685019338280463,0.5382408243577209,Head 198 | 0.3778348723181266,0.33470062886484636,Head 199 | 0.49211704225348335,0.2633016877556674,Head 200 | 0.40113010234854113,0.3249963592946038,Head 201 | 0.6886960489176914,0.6418252133877184,Head 202 | 0.44149533809771346,0.2739239915645413,Head 203 | 0.44458360726584134,0.40254557139333424,Head 204 | 0.6467627630109937,0.689444722536442,Head 205 | 0.49521829879934565,0.5429004259715249,Head 206 | 0.6197171351587627,0.6938126277047257,Head 207 | 0.2973495363920637,0.48308248414547883,Head 208 | 0.6209889659198802,0.49661943645755685,Head 209 | 0.5684222384796382,0.32004613720261177,Head 210 | 0.48084197943805607,0.38070850868584616,Head 211 | 0.5132405090658441,0.4690018473807536,Head 212 | 0.6977312894835461,0.39772196918136554,Head 213 | 0.46275715986772836,0.5465254622670109,Head 214 | 0.7311613067298663,0.47433638523777666,Head 215 | 0.4507442079334246,0.30905590754281775,Head 216 | 0.4614853335162101,0.45354716893263375,Head 217 | 0.6435018480838316,0.6463838992670885,Head 218 | 0.3300975326325553,0.61429800117587,Head 219 | 0.6028701592288027,0.6008484200713187,Head 220 | 0.4347701798526803,0.557562736730373,Head 221 | 0.6932948946988342,0.5654534594730938,Head 222 | 0.5247572213790491,0.26401207215943967,Head 223 | 0.5002917903066961,0.5418692345518223,Head 224 | 0.6522206886764839,0.6617862064183648,Head 225 | 0.2902584810634696,0.35073348437709656,Head 226 | 0.5071687450460225,0.5064850209229907,Head 227 | 0.2917686387906876,0.5758692207316308,Head 228 | 0.3755203166446687,0.6898760992517088,Head 229 | 0.6173535815217863,0.5281875766332944,Head 230 | 0.631710072257924,0.5795536341679328,Head 231 | 0.40893966208118576,0.4983293218748703,Head 232 | 0.6812524582312698,0.31644405636050704,Head 233 | 0.4407453623940024,0.5196531804037615,Head 234 | 0.37284220557890724,0.4718757302380132,Head 235 | 0.41194253633769695,0.4918369529267401,Head 236 | 0.743425497014074,0.43616922159939475,Head 237 | 0.4942279047060185,0.6133456159749187,Head 238 | 0.5935990874065795,0.41016038620431844,Head 239 | 0.5729702930852463,0.728394506497188,Head 240 | 0.6591024621232009,0.6408554187767201,Head 241 | 0.609492664166897,0.6063615320484372,Head 242 | 0.48806697616378103,0.5180426495602906,Head 243 | 0.5725696758131162,0.4762827013233272,Head 244 | 0.4355175323467955,0.5279784453659533,Head 245 | 0.6539212330228087,0.46335062554135026,Head 246 | 0.5068255999089359,0.5010647442959388,Head 247 | 0.4947431869026147,0.5597150804477188,Head 248 | 0.4974167815731843,0.40614885020574676,Head 249 | 0.5233871269725687,0.341093611151546,Head 250 | 0.46459992800618827,0.5291033620154328,Head 251 | 0.5944024530455814,0.6057524678883506,Head 252 | 0.5677375256034707,0.3454778041423724,Head 253 | 0.6120606266203569,0.3484377583462397,Head 254 | 0.27728901699058806,0.48203144582259505,Head 255 | 0.33919818777823585,0.6508366786305482,Head 256 | 0.41418250615709484,0.5820433717684208,Head 257 | 0.46731998488268855,0.57399981287557,Head 258 | 0.43897421752365273,0.39141139582977835,Head 259 | 0.4165562579890373,0.5855165706078677,Head 260 | 0.4163655211429044,0.35800944275982216,Head 261 | 0.4472267728778566,0.3239469279293801,Head 262 | 0.5529486594119973,0.6117575097364949,Head 263 | 0.3860771082109893,0.5584592017837059,Head 264 | 0.746576954842598,0.4852447791533015,Head 265 | 0.6787683190354972,0.544416694963726,Head 266 | 0.4417443374743319,0.5304243162428341,Head 267 | 0.7041355812071616,0.42907835126741445,Head 268 | 0.6025872583526563,0.6519178863077449,Head 269 | 0.4515006741729511,0.5494168152052004,Head 270 | 0.34406769499995704,0.31452273995412067,Head 271 | 0.6571664530758328,0.3395494139819467,Head 272 | 0.5391133985130727,0.4030198290550403,Head 273 | 0.5785911585761276,0.2756801334945491,Head 274 | 0.7517526656172895,0.4980941672696096,Head 275 | 0.6116232439393944,0.35471442426755717,Head 276 | 0.44394105422327373,0.5891244340193036,Head 277 | 0.5364752663717447,0.4694347928377997,Head 278 | 0.6375590914200824,0.5847092832713302,Head 279 | 0.32268111908104147,0.46658768216650875,Head 280 | 0.7093289532125356,0.636966691161416,Head 281 | 0.41670674548454156,0.40979298046661683,Head 282 | 0.40226843709696475,0.44601176413907095,Head 283 | 0.6312411617947771,0.45662127468333275,Head 284 | 0.5138729896078243,0.29507230737868534,Head 285 | 0.5630681951826686,0.6915233346477797,Head 286 | 0.3540846764255108,0.4841694760329867,Head 287 | 0.5264648654399303,0.4443834265792434,Head 288 | 0.5457131844740668,0.5895896361495174,Head 289 | 0.3455796836575118,0.451227266339269,Head 290 | 0.6507143465614921,0.63649523744681,Head 291 | 0.7425328608722306,0.5347125144436418,Head 292 | 0.6823273195034794,0.6352458367411182,Head 293 | 0.5081344429412513,0.5635050450437091,Head 294 | 0.5221079576856864,0.38781976501469645,Head 295 | 0.22251831991489018,0.7883298714240449,Ear_left 296 | 0.14596680282946534,0.7632310055814474,Ear_left 297 | 0.23749944219723773,0.7655984146207966,Ear_left 298 | 0.1980285850674685,0.7098982570929543,Ear_left 299 | 0.2616646827795952,0.6704418747075763,Ear_left 300 | 0.203041695250713,0.7347975140559748,Ear_left 301 | 0.3064904159455227,0.6902919556360958,Ear_left 302 | 0.2842777643038236,0.738365511007765,Ear_left 303 | 0.20530967314622395,0.7428170097694976,Ear_left 304 | 0.20771837372789004,0.7507873026800568,Ear_left 305 | 0.23879859471892556,0.7543122358021094,Ear_left 306 | 0.21801957755241635,0.7186034168830753,Ear_left 307 | 0.21524461099141762,0.7711917048187262,Ear_left 308 | 0.2039350838715879,0.7803596247355663,Ear_left 309 | 0.26075720217685444,0.718573999028452,Ear_left 310 | 0.293135146073639,0.714649474646771,Ear_left 311 | 0.20399614466425392,0.8184813524802571,Ear_left 312 | 0.28035680692780035,0.7161289758113678,Ear_left 313 | 0.22244794366175197,0.6727625517281877,Ear_left 314 | 0.1879572805347128,0.7862968000937579,Ear_left 315 | 0.28729744918040523,0.7945900385438784,Ear_left 316 | 0.2712753218265971,0.752179949875212,Ear_left 317 | 0.25260232004573774,0.7375688203243906,Ear_left 318 | 0.2092131026735191,0.7543484627335988,Ear_left 319 | 0.27527637261962035,0.7853181287824482,Ear_left 320 | 0.20290811368806627,0.7051821711673277,Ear_left 321 | 0.23755908729032657,0.8251065576211906,Ear_left 322 | 0.19643659936725835,0.7305112040483317,Ear_left 323 | 0.2514615416001959,0.7983455398050048,Ear_left 324 | 0.2292728118427541,0.7370013770311333,Ear_left 325 | 0.29832254625051435,0.7609297935421809,Ear_left 326 | 0.20296773383981564,0.7984580104619133,Ear_left 327 | 0.24398690269160286,0.8032512717246497,Ear_left 328 | 0.2823454381870513,0.6507218459719635,Ear_left 329 | 0.26055383370285917,0.7235272860746765,Ear_left 330 | 0.2687578250941205,0.7243688939663058,Ear_left 331 | 0.30638096553920613,0.7899582016439562,Ear_left 332 | 0.26470437697816573,0.7615201266645316,Ear_left 333 | 0.18298967448118103,0.7278944895737949,Ear_left 334 | 0.2170246393794728,0.7368279607345486,Ear_left 335 | 0.18182398704466007,0.7740302445527997,Ear_left 336 | 0.3065593255916551,0.7337959330515907,Ear_left 337 | 0.23527779023288595,0.7380422083283124,Ear_left 338 | 0.31000902581983353,0.7885794583567364,Ear_left 339 | 0.21034555806799418,0.6874932243575869,Ear_left 340 | 0.25411247060277425,0.7656493948354395,Ear_left 341 | 0.27814902664247004,0.7271079377554931,Ear_left 342 | 0.26608295583730507,0.7915967608107661,Ear_left 343 | 0.30032996652105604,0.7234573857809834,Ear_left 344 | 0.18152151643851627,0.7095338322497738,Ear_left 345 | 0.2512776247287444,0.7438694999329588,Ear_left 346 | 0.30986265676342345,0.7775360767316855,Ear_left 347 | 0.23074597881615913,0.6824100891725686,Ear_left 348 | 0.21569880839919162,0.7620046924524896,Ear_left 349 | 0.18351795509367025,0.7981412970500236,Ear_left 350 | 0.20625837001259215,0.7691560734137575,Ear_left 351 | 0.2947503737253423,0.7486847327436937,Ear_left 352 | 0.32811448779324703,0.8104480471833705,Ear_left 353 | 0.208239343383985,0.7387634028043626,Ear_left 354 | 0.23055874576730068,0.6949130653293023,Ear_left 355 | 0.24439870503634276,0.765492637542115,Ear_left 356 | 0.2866910854803799,0.7614094808432198,Ear_left 357 | 0.3014838543031903,0.7034441483006492,Ear_left 358 | 0.25282448358490983,0.7755234070996266,Ear_left 359 | 0.3220857054082107,0.7600928123854157,Ear_left 360 | 0.2557890614036615,0.749439300160098,Ear_left 361 | 0.17195783502929168,0.7368635502701839,Ear_left 362 | 0.18802792946329955,0.7244508057995779,Ear_left 363 | 0.1693845891719489,0.7154201808129301,Ear_left 364 | 0.18960171748102256,0.6820443275786829,Ear_left 365 | 0.2540901461651084,0.7916198603119513,Ear_left 366 | 0.2575684992783643,0.7434440050549689,Ear_left 367 | 0.21711019410354682,0.7293907077000309,Ear_left 368 | 0.22862637173035064,0.7667573619367998,Ear_left 369 | 0.25254817011715774,0.7898072848574761,Ear_left 370 | 0.30738378186990223,0.7015168146141113,Ear_left 371 | 0.2819275324554639,0.7689053884387266,Ear_left 372 | 0.26948071444154426,0.8373758527603316,Ear_left 373 | 0.258821822279201,0.725529628631154,Ear_left 374 | 0.24213541161565805,0.820011553112265,Ear_left 375 | 0.2629165269874901,0.7036678040195118,Ear_left 376 | 0.25468844356494813,0.7614163539866831,Ear_left 377 | 0.23745015069634573,0.7267085856031248,Ear_left 378 | 0.3040010147972316,0.8009067304298891,Ear_left 379 | 0.2579580823160971,0.7189356681450992,Ear_left 380 | 0.2663458716267545,0.7520018097620143,Ear_left 381 | 0.2986434311642774,0.7002088979934473,Ear_left 382 | 0.2592484700407562,0.7763635575221557,Ear_left 383 | 0.20599359133993891,0.7400399815310459,Ear_left 384 | 0.2178829081972778,0.7403140181632161,Ear_left 385 | 0.3179576779430139,0.749793212710529,Ear_left 386 | 0.21382856749133544,0.7943446931222934,Ear_left 387 | 0.2557675632363441,0.694139636565999,Ear_left 388 | 0.2522237138681524,0.7943940006827237,Ear_left 389 | 0.30021772312881323,0.743089979045272,Ear_left 390 | 0.1970866600117869,0.7954764320973517,Ear_left 391 | 0.31876246776325046,0.7669449786264386,Ear_left 392 | 0.2202104218437173,0.8428793976571253,Ear_left 393 | 0.35648189808486724,0.782707269261588,Ear_left 394 | 0.1809722095110372,0.7812986916049908,Ear_left 395 | 0.8377997370239522,0.7538358880610266,Ear_right 396 | 0.7294538573365863,0.779583670283009,Ear_right 397 | 0.6806521891032632,0.7177689348083449,Ear_right 398 | 0.7638809829937171,0.6572282090651652,Ear_right 399 | 0.7808853187516883,0.6592734947925624,Ear_right 400 | 0.8311746997903773,0.7262206255047086,Ear_right 401 | 0.743277821809469,0.6841029232826228,Ear_right 402 | 0.680688321346238,0.7475368639764006,Ear_right 403 | 0.8506187347587049,0.7990803699876275,Ear_right 404 | 0.7569000845699596,0.7952538516155098,Ear_right 405 | 0.7630899449816186,0.6852925401504006,Ear_right 406 | 0.7087331262670795,0.7655352223855434,Ear_right 407 | 0.7031486691547949,0.799646589897552,Ear_right 408 | 0.6956869262957491,0.7590079149145048,Ear_right 409 | 0.770220159307569,0.749528422333163,Ear_right 410 | 0.7023711448714501,0.7689249857718555,Ear_right 411 | 0.7534022960618623,0.7993679677299006,Ear_right 412 | 0.6892903823233598,0.7348762966442893,Ear_right 413 | 0.7651082962482073,0.6747331730235104,Ear_right 414 | 0.7052152644415275,0.6768256571756351,Ear_right 415 | 0.7116156172064612,0.8174867359631751,Ear_right 416 | 0.7113519692867732,0.7425940366695418,Ear_right 417 | 0.7064273764082712,0.7960264143806072,Ear_right 418 | 0.7940793434401193,0.7073379545190814,Ear_right 419 | 0.8237151064798245,0.6794732313853846,Ear_right 420 | 0.7984657574489835,0.7001463509025435,Ear_right 421 | 0.7665757626244889,0.7566684229513448,Ear_right 422 | 0.7258691046078412,0.6646061045396762,Ear_right 423 | 0.7541304825238758,0.7238597313052797,Ear_right 424 | 0.7448782161551408,0.7394726189785576,Ear_right 425 | 0.7872023861255228,0.8173254155580306,Ear_right 426 | 0.7830924890472047,0.7316558161430998,Ear_right 427 | 0.7651971076541776,0.7692942812179941,Ear_right 428 | 0.7520106617132443,0.6876440874592499,Ear_right 429 | 0.7961459853569384,0.8393688643424051,Ear_right 430 | 0.650947500402311,0.7827968494187825,Ear_right 431 | 0.8012060759032598,0.7125634511545311,Ear_right 432 | 0.8061954950809548,0.7249522038232329,Ear_right 433 | 0.7275372076425105,0.6983388762514319,Ear_right 434 | 0.7728812723027745,0.7447321392738567,Ear_right 435 | 0.7802524132257377,0.7241470373979612,Ear_right 436 | 0.7699065301953223,0.7932185854129203,Ear_right 437 | 0.8123957636975742,0.7068171361169507,Ear_right 438 | 0.7304046011555516,0.7203249916761053,Ear_right 439 | 0.7056224589841433,0.774470827074884,Ear_right 440 | 0.7539382268261435,0.6953746090340507,Ear_right 441 | 0.7361881327956482,0.8203875876829062,Ear_right 442 | 0.7114013438444344,0.7294177739107931,Ear_right 443 | 0.7283903862822285,0.7400950365943004,Ear_right 444 | 0.8199224586670886,0.6624864411844861,Ear_right 445 | 0.6932931734763774,0.7483720380641234,Ear_right 446 | 0.7887163846530041,0.6553373804573515,Ear_right 447 | 0.7587346545400881,0.7143840276080937,Ear_right 448 | 0.7436451075785734,0.741107198751839,Ear_right 449 | 0.8399763413838327,0.7397512128037355,Ear_right 450 | 0.7195566103116555,0.7577349717565741,Ear_right 451 | 0.7387065607645802,0.7134573512536048,Ear_right 452 | 0.7585718200102338,0.7415011443924274,Ear_right 453 | 0.7260165987413801,0.7199155338102927,Ear_right 454 | 0.761046704494208,0.7666157837279917,Ear_right 455 | 0.7446390882313307,0.7705456687619933,Ear_right 456 | 0.8042376941620254,0.7934272658399564,Ear_right 457 | 0.843634259355262,0.723509085172378,Ear_right 458 | 0.7615764451888559,0.7735103681098081,Ear_right 459 | 0.7447471630813102,0.812900186453634,Ear_right 460 | 0.698756677231279,0.6992834559156688,Ear_right 461 | 0.836569858595413,0.7395273438740726,Ear_right 462 | 0.7055384167486035,0.8032623540731494,Ear_right 463 | 0.7405658451005331,0.7220012904983931,Ear_right 464 | 0.7568136057549706,0.7515402362252771,Ear_right 465 | 0.6756601639542467,0.7586515066960814,Ear_right 466 | 0.70971508636843,0.7124438097520422,Ear_right 467 | 0.667733846908035,0.6982389157975166,Ear_right 468 | 0.669730607148233,0.6950262325053415,Ear_right 469 | 0.6852754907203354,0.7810821546243205,Ear_right 470 | 0.7673394640297962,0.772005715794815,Ear_right 471 | 0.8116671981458425,0.7963891674181127,Ear_right 472 | 0.7353954902397984,0.7630575124298286,Ear_right 473 | 0.7304928242746523,0.7284849570246381,Ear_right 474 | 0.735357614154059,0.8135219075858261,Ear_right 475 | 0.829462601011989,0.7767929457052348,Ear_right 476 | 0.7476746137476434,0.7198597924007893,Ear_right 477 | 0.6927327403170797,0.6700023743441031,Ear_right 478 | 0.7522365172463774,0.6604606209567222,Ear_right 479 | 0.762326391564511,0.8024849472990617,Ear_right 480 | 0.6685866005871447,0.7817694635640924,Ear_right 481 | 0.7701084808001643,0.7051847879729983,Ear_right 482 | 0.7862989275963652,0.690065279670785,Ear_right 483 | 0.6830469136648829,0.764038216680348,Ear_right 484 | 0.6446792306915289,0.7639770872738367,Ear_right 485 | 0.7431589593477718,0.7725141972075193,Ear_right 486 | 0.7286601284351713,0.6666850344927256,Ear_right 487 | 0.7067359174595279,0.7877465869835184,Ear_right 488 | 0.7394233664811464,0.7860259050967145,Ear_right 489 | 0.749466614165472,0.7328131832071315,Ear_right 490 | 0.8248418521018915,0.7134157163726719,Ear_right 491 | 0.8239586646098369,0.7675190168773441,Ear_right 492 | 0.8155149407204317,0.7135170163641819,Ear_right 493 | 0.7856914234307533,0.6819138433217659,Ear_right 494 | 0.708266829777928,0.7612342280212532,Ear_right 495 | 0.290949977617754,0.8557666871174766,Noise 496 | 0.7500676018742642,0.897027660749859,Noise 497 | 0.040554927744786196,0.5072400792452862,Noise 498 | 0.8351619113805846,0.13894038814840037,Noise 499 | 0.17474033164278335,0.3636861115238198,Noise 500 | 0.15150612475114178,0.8765856628207388,Noise 501 | 0.8603082847506063,0.6338333996208041,Noise 502 | 0.8620825903226392,0.5918053842487218,Noise 503 | 0.9160298083819269,0.523390593285425,Noise 504 | 0.42732547274373656,0.8337665738193867,Noise 505 | ]; 506 | 507 | Load 508 | *, 509 | RecNo() as sample 510 | Resident points; 511 | `; 512 | --------------------------------------------------------------------------------