From 0c96e9bbf162b7a4bca569c4a418918c6a18d2a1 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Tue, 23 Apr 2024 16:52:49 +0100 Subject: [PATCH 1/4] Include functional components. Update README.md with usage examples, acknowledgements and license info Add license Update package.json Add a demo page Rename gitignore to activate it --- gitignore => .gitignore | 0 LICENSE | 177 ++++ README.md | 82 +- demo.html | 94 ++ index.js | 2116 +++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 6 files changed, 2468 insertions(+), 3 deletions(-) rename gitignore => .gitignore (100%) create mode 100644 LICENSE create mode 100644 demo.html create mode 100644 index.js diff --git a/gitignore b/.gitignore similarity index 100% rename from gitignore rename to .gitignore diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index c780e86..94790cd 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,91 @@ A library to render Node-RED flows in a web page. ## Installation +```bash +npm install @flowfuse/flow-renderer +``` + +## development + +All client-side code is in the `index.js` file for easy inclusion in a web page. + +Run `npm run demo` to test the flow renderer in a browser. ## Usage +Add the following script tag to your HTML file: + +```html + +``` +_or wherever the script is located in your project_ + +Next, add a container element to your HTML file and call the `flowRenderer` function with the flow data and options. + +By default, the flow renderer will render the flow with `gridlines`, `images`, `labels` and `zoom` options enabled. + +To operate the zoom, use the mouse wheel + Ctrl key. +To scroll the container vertically, use the mouse wheel without the Shift key. +To scroll the container horizontally, use the mouse wheel + Shift key. + +### Basic example + +```html +
+``` + +```javascript +const container1 = document.getElementById('nr-flow-1'); +const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] +flowRenderer(flow, { container: container1 }) +``` + +### Inline Options example + +Options can be set by data attributes `scope`, `grid-lines`, `zoom`, `images`, `link-lines`, `labels` + +```html +
+``` + +```javascript +const container2 = document.getElementById('nr-flow-2'); +const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] +flowRenderer(flow, { container: container2 }) +``` + +### Full Options example + +```html +
+``` + +```javascript +const container3 = document.getElementById('nr-flow-3'); +const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] +flowRenderer(flow, { + container: container3, + scope: 'my-scope', // scope for CSS + gridlines: true, // show gridlines + images: true, // show images + linkLines: false, // show link lines + labels: true, // show labels + zoom: true, // enable zoom within the container + flowId: undefined // Id of flow to display +}) +``` + +## Acknowledgements + +This project owes a huge thanks to Gerrit Riessen for his original works on [node-red-flowviewer](https://github.com/gorenje/node-red-flowviewer-js). It was this great contribution that started the ball rolling. Gerrit kindly allowed us relicense the parts we needed to use in this project. -## Contributing +## Limitations +* Only client-side rendering is currently supported +* Mobile pinch zoom is not yet implemented +* The flow renderer does not support the full range of contributed Node-RED nodes however they will render as a generic node type complete with the node's label. +* The flow renderer does not always render the flows and nodes exactly as they appear in the Node-RED editor. This is due in part to being a client-side render with no server-side component to provide full context and partly due to the current limitations of the renderer itself. ## License -TODO \ No newline at end of file +Apache-2.0 diff --git a/demo.html b/demo.html new file mode 100644 index 0000000..4a1b7a7 --- /dev/null +++ b/demo.html @@ -0,0 +1,94 @@ + + + + + + FlowFuse Flow Renderer + + + + + + + + +

FlowFuse Flow Renderer

+
Client side demo
+
+ + +

Node-RED Flow

+ +
+ + +
+ + +

Render (grid, images, labels)

+
+ +
+
+ + +

Render (no grid, no images, labels)

+
+ +
+
+ + +

Render (no grid, images, no labels) using data-options

+
+ + + + + + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..6759064 --- /dev/null +++ b/index.js @@ -0,0 +1,2116 @@ + +; (function () { + + if (typeof exports === 'object') { + module.exports = flowRenderer; + } else if (typeof define === 'function' && define.amd) { + define(function () { return flowRenderer; }); + } else { + this.flowRenderer = flowRenderer; + } + + // #region Constants + const styleId = 'flow-renderer-css' + const PORT_WIDTH = 10 + + const portDimensions = { + width: PORT_WIDTH, + height: PORT_WIDTH + } + + const portRadius = { + rx: 3, + ry: 3 + } + + const widthHeightByType = { + "junction": { width: 10, height: 10}, + "link in": { width: 30, height: 30}, + "link out": { width: 30, height: 30}, + "subflow": { width: 40, height: 40}, + "_default": { width: 100, height: 30}, + "_default_no_label": { width: 30, height: 30}, + } + + const imgByType = { + "batch": "batch.svg", + "catch": "alert.svg", + "change": "swap.svg", + "complete": "alert.svg", + "rbe": "rbe.png", + "comment": "comment.svg", + "csv": "parser-csv.svg", + "debug": "debug.svg", + "delay": "timer.svg", + "exec": "cog.svg", + "feedparse": "parser-xml.svg", + "file": "file.svg", + "file in": "file-in.svg", + "file out": "file-out.svg", + "function": "function.svg", + "http response": "white-globe.svg", + "http in": "white-globe.svg", + "http request": "white-globe.svg", + "inject": "inject.svg", + "join": "join.svg", + "json": "parser-json.svg", + "link in": "link-out.svg", + "link out": "link-out.svg", + "link outlink": "link-out.svg", + "link outreturn": "link-return.svg", + "link call": "link-call.svg", + "xml": "parser-xml.svg", + "yaml": "parser-yaml.svg", + + "mqtt in": "bridge.svg", + "mqtt out": "bridge.svg", + "markdown": "parser-markdown.png", + + "postgresql": "db.svg", + "range": "range.svg", + "sort": "sort.svg", + "split": "split.svg", + "subflow": "subflow.svg", + "switch": "switch.svg", + "template": "template.svg", + "trigger": "trigger.svg", + + // dashboard 1.0 + ui_button: "ui_button.png", + ui_template: "ui_template.png", + ui_toast: "ui_toast.png", + ui_audio: "feed.svg", + ui_chart: "ui_chart.png", + ui_colour_picker: "ui_colour_picker.png", + ui_date_picker: "ui_date_picker.png", + ui_dropdown: "ui_dropdown.png", + ui_form: "ui_form.png", + ui_gauge: "ui_gauge.png", + ui_numeric: "ui_numeric.png", + ui_slider: "ui_slider.png", + ui_switch: "ui_switch.png", + ui_text: "ui_text.png", + ui_text_input: "ui_text.png", + + // dashboard 2.0 + "ui-button": "ui_button.png", + "ui-button-group": "ui_button.png", + "ui-chart": "ui_chart.png", + // "ui-control": "ui_control.png", + "ui-dropdown": "ui_dropdown.png", + // "ui-event": "ui_event.png", + "ui-form": "ui_form.png", + "ui-gauge": "ui_gauge.png", + "ui-markdown": "ui_text.png", // temporary + // "ui-notification": "", + // "ui-radio-group": "", + "ui-slider": "ui_slider.png", + "ui-switch": "ui_switch.png", + // "ui-table": "", + "ui-template": "ui_template.png", + "ui-text": "ui_text.png", + "ui-text-input": "ui_text.png", + + "websocket in": "white-globe.svg", + "websocket out": "white-globe.svg", + + "i2c scan": "serial.svg", + "i2c in": "serial.svg", + "i2c out": "serial.svg", + + "cronplus": "timer.svg", + + /* custom, my own private collection */ + "Thought": "alert.svg", + "Idea": "light.svg", + + // FlowFuse specific + "project link out": "ff-logo.svg", + "project link in": "ff-logo.svg", + "project link call": "ff-logo.svg" + } + + const imageNameToContent = { + "alert.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjE2Ljc0IC00MTUuMDQpIHNjYWxlKC42MjE0MykiIGZpbGw9IiNmZmYiPjxwYXRoIGQ9Ik0zNzEuMyA2ODMuOTdsMS42MS0xLjYxSDM4OWwxLjYxIDEuNjEtMy4yMTkgNDAuMjMtMy4yMTggMy4yMThoLTYuNDM3bC0zLjIxOS0zLjIxOHoiLz48cmVjdCB4PSIzNzIuOTEiIHk9IjczNS40NyIgd2lkdGg9IjE2LjA5MiIgaGVpZ2h0PSIxNi4wOTIiIHJ5PSIzLjAxNyIgY29sb3I9IiMwMDAiLz48L2c+PC9zdmc+Cg==", + "arduino.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAHYSURBVEiJ7ZTBq4xRGMZ/77kzC6XuWN26V8qNdAsbyoI/AvU5u7tB3SihZMGClIVSlhSShb4zN5uxoGYplMWNbCjlkjuUmjKjKXdm3sfCN2NY6i6keVbnvOf0e9/zPuccy/OctVRYU9oYOAb+r8DSkBzCRJZlR4EzwCzSquChmZ1NKb0BiDEacA2YByrAV+BWs9m8WK/XWwDkeY6k0O/3b+qXGpJ6kuTuS5ImJVm/3z8/2ODuDZe+F+NXkibzPB8e+VAI4TDwGdifUpput9tTwFUz2wEsuvuJEMIl4BEwV61Wpw3mJD0xs+3AjdEKL0j6Jmmm+H0MsGLt1EjlL4rYlKT3q9LuYv5ckkYrBOimlFayLDsuySV5jHErcAfoShJwJcZYAR4Dm8pwEsDdnw69GAFOxBg3hBDuApuBWWDZ3SNQMjMDFoBWt9vNJDV6vd7twtBdfwI/AOv1sw9bUkrLwCdgPoSwACwBi8A+4HS5XG6a2cFSqfQuxnhZ0h7g2W8uS3owMFDSS0mdQd8kzRQ34XoR60p67e5fCpffdjqdjcMeppQcOODu54AWsBNYV2Q9klJaSSl5COGYu98DHNhmZhVJ981sb61W+8jAybXUv/+Wx8Ax8C/0A+UBSZXw25H1AAAAAElFTkSuQmCC", + "arrow-in.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTggNXYxMkg3djI2aDExdjEybDE0LTI1eiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPgo=", + "batch.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii42MTIiPjxwYXRoIGQ9Ik0zNC4wMDEgMjcuOTg3bC00IC4wMDR2My45OTdsNC0uMDFNNDAuMDAxIDI3Ljk4N2wtNCAuMDA0djMuOTk3bDQtLjAxTTI2LjAwMSAyOS45ODdsLTctNy45ODZ2MTUuOTg2TTM0LjAwMSAxMy45ODdsLTQgLjAwNHYzLjk5N2w0LS4wMU00MC4wMDEgMTMuOTg3bC00IC4wMDR2My45OTdsNC0uMDFNMzQuMDAxIDQxLjk4OGwtNCAuMDAzdjMuOTk3bDQtLjAxTTQwLjAwMSA0MS45ODhsLTQgLjAwM3YzLjk5N2w0LS4wMU04LjAwMSAyNy45ODdsLTQgLjAwNHYzLjk5N2w0LS4wMU0xNC4wMDEgMjcuOTg3bC00IC4wMDR2My45OTdsNC0uMDFNOC4wMDEgMTkuOTg3bC00IC4wMDR2My45OTdsNC0uMDFNMTQuMDAxIDE5Ljk4N2wtNCAuMDA0djMuOTk3bDQtLjAxTTguMDAxIDM1Ljk4N2wtNCAuMDA0djMuOTk3bDQtLjAxTTE0LjAwMSAzNS45ODdsLTQgLjAwNHYzLjk5N2w0LS4wMSIvPjwvZz48L3N2Zz4K", + "bluetooth.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAuCAQAAADjedMPAAAAAXNSR0IArs4c6QAAAAJiS0dEAACqjSMyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gYEFSkDI2AawgAACLlJREFUSMeNVm1wFeUZPe/H7t7szZIbAkRirsqtCtGEBEQjesGULymKBiLCVYRq60csHWvb0Tqj/Zg60zpjp2WKf0oLgiiCtFhERSUSmPIRQhC4EQU0FZIKwcSb5JLN3bv7vk9/LNQ6g9Pun3d3Z8+zZ87zPGcOuySCi1weinjCO8grWVzC7HQ/VxnDutiH4Bd9C43r3HJrW/VTkzb4Rh4oE8VqCN7/W8BDoSoqaQ2+iM9+a091jwDixUnDBy5S4hsYjNLHs11q1CAvqt79i6tM1ZnZ7F1nDCpTef+7gAehIFpV0vIZYJnj9z5VW6Wm2Ifcb7EyVmkEX+Mhv4JZ509AiRv9QvscjABgsMzpbw6bPuNwtWDWEZ/lytGFQRECrZCBB8UNcB+QQdxw/V/6+2Pl6kCOBAAwIDbp/c2Tanzy5vBKFmMTItXa5DUG0K+Eywq4oyN+rd/F63Wxsq0Jkf2209cLi9VfWr6UwEDgkcsWWjs/6P3YP6FW6Ga6Uo8PpFEouOKPBlk12e41ovYtzn7rqGmpDYNlQ0M28CUxA8QAcBCkM2XHM2Pr1EJnAQM+4a8YEPtyM31+yHgo0p2t0h3B1mxBUIJtfp1+SxsukPQzEowAqCwARMxJ78+ftCFbLuIyzat0d/ahyCGDL1Cr3FCQGVa1fDr4rT7Iq3S51Qtt2woaAA48MHQMIAin7r13J4yxusR4A5BY5S5Q/DnU2L59WCXkDv9us+D518SU4SNlazDCas2fszVnRMBAutZPA4B0anb/qGq/S95h5ds19nNAo4gZSbPCTJobSt0PSZ9+rQExYxlrQCrWMp2IiKjl2yl7eZmXDp/8ge21jqgwk2bMaBRImklzGYsZFWb/Pk1EmjJrUqZlNoqkeb6AbplVYSbN5WX5I5o0Ka0yeyfFjGUsaSZNvoc+kyv5/GE19tFHqR9gFFuyYvUN1joATAMaYD77nLcRurPtDABjymR6/rCV/DO5h+Q8f5RuFifyh9zY4cFbp21DjGH4PRvNJ5cCFI4RDDWNR3hq5fAUwIj6d83+y8ddIiHqvLMKc5wGLLFLjZR9hT3afvcmlSEirenklgJj3wwiIk1vzG1A75rwXmc23dqAokipscRuwByHt+Wi9qA7zegMJuBKsfHoXxfofjDgsrldL+ZNQIOhUL3wavESAoH635j/hx1Ru9afZgy6Ubsth208ZSbNCnNWiSmSZirmiH013mD4t3ynJtKaQvFIBwN7J05xro40IOxCytzG+THWibg8yX/dZyEuxw78DPPT22dTP4FBlDOAAaKKAaD+trr6NHnjI1PFZc5JHpedOMZElPJ2k/qNv84abqWpB5/w4QW7zlRuLVtEkdAsGBgI1N90288//Z27i4/JbRIVg9+jF/hl5tmcgBmRNe7fCj70Rhk351iQxuTIzqGjZ8a+F7+bWWAAAAqGdsx+7EAkfyw4JbJ8CFXWWjl1KGudBC8BeVNFeVBjr3VPO91mQmzI3mG04okj+2cSAxQIxD5INrTUyCrVbXYpZo2wdvrlwVRBXkloKG0WUOk+7oi+ExSX86zNXtSIy6t+CAACDIzKnpkcKxlqFsDt1u5cuTipQxQg6qwZ/m4k5EY1TB3iyZFv9x/n49nVBStWjrwXIDACI1ZUcfulm7Ye5Muoq6god1DdbhTzjmCBypmIGctF0kyaZSJppmxHJM0GNIqzL58fm/AgIk09r5YaMcMRS+xwAZPmchEzxCTZGwyKhBxnN+Wulcf5meC49ebq4fey84NMCE0FKLhm6TWntrTTteIYSiMJltG9QVQiZjSKJXbMiBn1MUdMcRrFl+vC8VHn9j0bbmPTs9SjiUhR78vLxTSRcpJmzAiZ8EqWFh3BaDZRN2Wjxhj1q7WxexnA0Hegju8KByHa3HQb+gCO4nsWv+TbN7rdNJp1BGlRyXiXXKCA62VezBpxpf79n0beAxB0f+vMnxzRmkAEAL/8oHWazmowFKc2r3zd/gLXS2CB6pJI2TFjbukssVw4IrOWSBNpNbC9tsKc47w9N5SxZVaBMc/eN1H3EREp6l/TKGaJuaUxI2Xzd8TCSGHWFR/jn6uL7gMAPbB/2vfTJWjLFQ8BDCCoekMX3N/eslJDg8NZvOR+VxRmF0beEfLv7i38JjZSNqPlyGwAgds8Z/WRGrvJA3ImgwZnjHUGI9TqRbU/DU2mb8Pzb42UncEef6fmc3lUVykHNcVPLt/+Y9Z78JaPWjpR3m/z6Y41BHBonHFO0OMLb1gTtrN7083fxecOqlRUz+UypYG0KHWz+bx88c+n3nisa6IAOgoLVI/LDYAAGjmw8aGpKwCA0LM+9fAYK6PgdpiLFTQfJ1YBiNo76awahYdPLvWrFHC5OxkJCaUB4uyKR6auIGgAfS+tv++Md7l7J6I2sArjhOh0zuVnUqFlGuOsT3Nx9hH/B13Cz/KE969IXXn5fYyBoteGUSKz9sGlrYXD5GndG5RGRuQ/xCeFfGqWG+vQlY2rHhcASlAtAGANH/A8QaGPgwHoXzuzcZQg7/KhNuo227w+S/C6vHyRR1BsKbPDBYCEBDqCMHLEbRMMFzZyYO3kB0+TK+L6FEliNnld4jvBFs2LhKl69Z5cCOoILsBfo043H8JBLPPS9MbT9AQbYe2lvFiMQ+6QbHHf4+fAASYksqqdgPawT0hIYJEwFYFAADLrG35wgzeavW2lc/Osp4P1vFac9KOGhBVmJAsQpIBKlpAXGEyI7M1ZAQPQ+3L9A3GZFnG9112MNXpgGAbbOM5nqv+kNCbaKSE7gnYKVTiaqzc8CWReqX+AWTd6jgyoWtxJsyJNOSksWF+PeRYAJTqCkAUA3GVs9Sz64pWxjyTkXe7rdrPfiiq1SPS4UPK/Yh77Kit7IFXJLqgQ9Y7GHpUbu0eJdnuMGnS7TWaRl5Bb/Ij4xqCpREICVapKDZhpobJ/zETtZpHOAd0mQN4w6x3/DuPrsfTf18syu360MqUAAAAASUVORK5CYII=", + "bridge-dash.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTkuOTI0IDYuNjFjNC4zNzIgNS40MzMgNy4xODIgMTMuODkzIDcuMTgyIDIzLjM5NyAwIDkuNDkzLTIuODA0IDE3Ljk0Ni03LjE2NyAyMy4zNzltLTQuMjk0LTM4LjM5YzUuNjQ1IDkuNDE3IDcuMTcyIDIwLjk0NC4wMjQgMjkuOTkzbS00LjM2LTIxLjY2MWMxLjMzOCAxLjQ1OSAyLjIxNSAzLjkwNiAyLjIxNSA2LjY4IDAgMi41NzEtLjc1NSA0Ljg2My0xLjkzMSA2LjM0NiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtZGFzaGFycmF5PSIxNC4wOTYsIDMuNTI0IiBzdHJva2Utd2lkdGg9IjMuNTI0Ii8+PC9zdmc+Cg==", + "bridge.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTkuOTI0IDYuNjFjNC4zNzIgNS40MzMgNy4xODIgMTMuODkzIDcuMTgyIDIzLjM5NyAwIDkuNDkzLTIuODA0IDE3Ljk0Ni03LjE2NyAyMy4zNzltLTQuMjk0LTM4LjM5YzUuNjQ1IDkuNDE3IDcuMTcyIDIwLjk0NC4wMjQgMjkuOTkzbS00LjM2LTIxLjY2MWMxLjMzOCAxLjQ1OSAyLjIxNSAzLjkwNiAyLjIxNSA2LjY4IDAgMi41NzEtLjc1NSA0Ljg2My0xLjkzMSA2LjM0NiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjMuMjI0Ii8+PC9zdmc+", + "cog.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjAgMTJhMTggMTggMCAwIDAtMy40OTQuMzZsLTEuNDI4IDUuNzE1LTUuMDYtMy4wMzZhMTggMTggMCAwIDAtNC45NDYgNC45MTdsMy4wNDUgNS4wNzgtNS43NjUgMS40NDJBMTggMTggMCAwIDAgMiAzMGExOCAxOCAwIDAgMCAuMzQ1IDMuNDM0bDUuNzc1IDEuNDQ0LTMuMDcyIDUuMTJhMTggMTggMCAwIDAgNC44OTMgNC45MjRsNS4xMzctMy4wODMgMS40NTUgNS44MkExOCAxOCAwIDAgMCAyMCA0OGExOCAxOCAwIDAgMCAzLjQ3LS4zNTNsMS40NTItNS44MDcgNS4xMjggMy4wNzZhMTggMTggMCAwIDAgNC45MDUtNC45MTNsLTMuMDc0LTUuMTI0IDUuNzgzLTEuNDQ2QTE4IDE4IDAgMCAwIDM4IDMwYTE4IDE4IDAgMCAwLS4zNjctMy41MjlsLTUuNzUtMS40MzcgMy4wNDEtNS4wNjlhMTggMTggMCAwIDAtNC45MzctNC45MjhsLTUuMDY1IDMuMDM4LTEuNDMzLTUuNzI4QTE4IDE4IDAgMCAwIDIwIDEyem0wIDlhOSA5IDAgMCAxIDkgOSA5IDkgMCAwIDEtOSA5IDkgOSAwIDAgMS05LTkgOSA5IDAgMCAxIDktOXoiIGNvbG9yPSIjMDAwIiBmaWxsPSIjZmZmIiBvcGFjaXR5PSIuOTgiIHN0eWxlPSJpc29sYXRpb246YXV0bzttaXgtYmxlbmQtbW9kZTpub3JtYWwiLz48L3N2Zz4K", + "comment.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMzYuMTkgMjguNmMwIDYuMDg4LTcuMjg5IDExLjAyNC0xNi4yOCAxMS4wMjRhMjMuOTggMjMuOTggMCAwIDEtMi45ODItLjE4NWMtMS4yNzItLjE1OS03LjkzMyA3LjUyNi0xMy4xMTMgNi41My4xOC0yLjAwNCA4LjE4LTYuMDA0IDUuODctOC43OUM1Ljk5MyAzNS4xNiAzLjYzIDMyLjA2NiAzLjYzIDI4LjZjMC02LjA4OCA3LjI4OS0xMS4wMjQgMTYuMjgtMTEuMDI0IDguOTkxIDAgMTYuMjggNC45MzYgMTYuMjggMTEuMDI0eiIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjYmFiYWJhIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMS41IiBzdHlsZT0iaXNvbGF0aW9uOmF1dG87bWl4LWJsZW5kLW1vZGU6bm9ybWFsIi8+PC9zdmc+Cg==", + "db.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzU1IC03MDQuMzYpIj48ZWxsaXBzZSB0cmFuc2Zvcm09Im1hdHJpeCgxLjI4NjggMCAwIDEuOTI2MyAtNjQuNDQ0IC02MDcuNTYpIiBjeD0iMzQxLjI1IiBjeT0iNjg4LjYxIiByeD0iOS44NCIgcnk9IjMuMjUiIGNvbG9yPSIjMDAwIiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTM4Ny4zMiA3NTAuNDhjMCAxLjk0OS01LjY2OSA1Ljg3OS0xMi42NjIgNS44NzlzLTEyLjY2Mi0zLjkzLTEyLjY2Mi01Ljg3OXYtMjcuMDQzYzAgMS45NDkgNS42NjkgNi4yNDIgMTIuNjYyIDYuMjQyczEyLjY2Mi00LjI5MyAxMi42NjItNi4yNDJ2MjcuMDQzIiBjb2xvcj0iIzAwMCIgZmlsbD0iI2ZmZiIvPjwvZz48L3N2Zz4K", + "debug.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTAuMDA0IDE0LjQ5OWgyME0xMC4wMDQgNDYuNTAzaDIwTTEwLjAwNCAyMi41aDIwTTEwLjAwNCAzMC41MDFoMjBNMTAuMDA0IDM4LjUwMmgyMCIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuOTk5NzAwMDAwMDAwMDAwMyIvPjwvc3ZnPgo=", + "envelope.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMjAgMzIuOTZsLTE4LTE4aDM2eiIvPjxwYXRoIGQ9Ik0yIDIwLjM2bDE4IDE4IDE4LTE4djI2LjFIMnoiLz48L2c+PC9zdmc+Cg==", + "feed.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjYjg1YzVjIiBzdHJva2U9IiMwMDAiPjxwYXRoIGNvbG9yPSIjMDAwIiBkPSJNLS4wMS0uMDA0aDM5Ljk5OHY2MEgtLjAxeiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJub25lIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQ1MC4yNjYgLTU4NS4zNykiPjxyZWN0IHg9IjQ2NC4yNyIgeT0iNjI1LjM3IiB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIHJ5PSIyLjQiIGNvbG9yPSIjMDAwIiBmaWxsPSIjZmZmIiBzdHJva2U9Im5vbmUiLz48ZyBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iNCI+PHBhdGggZD0iTTQ2MS4yNyA2MTguODdsNS41LTIuNWg3bDUuNSAyLjVNNDU5LjI3IDYwOC44N2w1LjUtMi41aDExbDUuNSAyLjVNNDU3LjI3IDU5OC44N2w1LjUtMi41aDE1bDUuNSAyLjUiLz48L2c+PC9nPjwvZz48L3N2Zz4=", + "file-in.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBjb2xvcj0iIzAwMCIgZmlsbD0ibm9uZSIgZD0iTTAtLjA0aDQwdjYwSDB6Ii8+PGcgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTUgOS45NmgxNHYxNmg5djdIMTh2MTBoMTB2N0g1eiIvPjxwYXRoIGQ9Ik0yMiA5Ljk2bDEzIDEzSDIyeiIvPjxwYXRoIGQ9Ik0yOCAyNS45Nmg3djZsNSA2LTQuOTg3IDYtLjAxMyA2aC03bDEwLTEyeiIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9nPjwvc3ZnPg==", + "file-out.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBjb2xvcj0iIzAwMCIgZmlsbD0ibm9uZSIgZD0iTTAtLjA0aDQwdjYwSDB6Ii8+PGcgZmlsbD0iI2ZmZiI+PHBhdGggZD0iTTUgOS45NmgxNHYxNmgxNnYyNEg1di03aDV2N2wxMC0xMi0xMC0xMnY3SDV6Ii8+PHBhdGggZD0iTTIyIDkuOTZsMTMgMTNIMjJ6Ii8+PC9nPjxwYXRoIGQ9Ik01IDMwLjk2SDB2MTRoNXYtMkgydi0xMGgzeiIgZmlsbD0iI2ZmZiIgZmlsbC1ydWxlPSJldmVub2RkIi8+PC9zdmc+", + "file.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNNSAxMGgxNHYxNmgxNnYyNEg1eiIvPjxwYXRoIGQ9Ik0yMiAxMGwxMyAxM0gyMnoiLz48L2c+PC9zdmc+Cg==", + "function.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMzAuOTk5IDMxLjAwNXYtM2gtNi43NjJzLjgxMi0xMi4zOTcgMS4xNjItMTQgLjU5Ny0zLjM1IDIuNjI4LTMuMTAzIDEuOTcxIDMuMTAzIDEuOTcxIDMuMTAzbDQuODYyLS4wMTZzLS43ODMtMy45ODQtMi43ODMtNS45ODQtNy45NDYtMS43LTkuNjMzLjAzYy0xLjY4NyAxLjczLTIuMzAyIDUuMDY1LTIuNTk3IDYuNDIyLS41ODggNC41LS44NTQgOS4wMjctMS4yNDggMTMuNTQ3aC04LjZ2M0gxOC4xcy0uODEyIDEyLjM5OC0xLjE2MiAxNC0uNTk3IDMuMzUtMi42MjggMy4xMDMtMS45NzItMy4xMDItMS45NzItMy4xMDJsLTQuODYyLjAxNXMuNzgzIDMuOTg1IDIuNzgzIDUuOTg1YzIgMiA3Ljk0NiAxLjY5OSA5LjYzNC0uMDMxIDEuNjg3LTEuNzMgMi4zMDItNS4wNjUgMi41OTctNi40MjIuNTg3LTQuNS44NTQtOS4wMjcgMS4yNDgtMTMuNTQ3eiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPgo=", + "hash.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii44NTciPjxwYXRoIGQ9Ik0xNy45OTcgOC45OThsLTUuOTcyLjA3Mi00LjAyOCA0My45MjggNS45MTQuMDcyeiIvPjxwYXRoIGQ9Ik02IDE2Ljk5OWwtMSA2IDMxIDEgMS02eiIvPjxwYXRoIGQ9Ik0zMS45OTYgOC45OThsLTUuOTcxLjA3Mi00LjAyOSA0My45MjggNS45MTQuMDcyeiIvPjxwYXRoIGQ9Ik0zLjk5OCAzNy4wMDRsLTEgNiAzMSAxIDEtNnoiLz48L2c+PC9zdmc+Cg==", + "inject.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTggNXYxMkg3djloMTV2LTdsNiAxMS02IDEydi04SDd2OWgxMXYxMmwxNC0yNXoiIGZpbGw9IiNmZmYiLz48L3N2Zz4K", + "join.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNDAuMDAxIDM5Ljk5bC0uMDMyLTE5Ljk1NS0xMS45NjcuMDE3djE5Ljk4M2wxMi0uMDQ2TTguMDAxIDI4LjAyN2wtNCAuMDA0djMuOTk3bDQtLjAxTTE0LjAwMSAyOC4wMjdsLTQgLjAwNHYzLjk5N2w0LS4wMU0yNS4wMDEgMzAuMDI3bC03LTcuOTg2djE1Ljk4Nk04LjAwMSAyMC4wMjdsLTQgLjAwNHYzLjk5Nmw0LS4wMU0xNC4wMDEgMjAuMDI3bC00IC4wMDR2My45OTZsNC0uMDFNOC4wMDEgMzYuMDI3bC00IC4wMDR2My45OTdsNC0uMDFNMTQuMDAxIDM2LjAyN2wtNCAuMDA0djMuOTk3bDQtLjAxIiBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii42MTIiLz48L3N2Zz4K", + "leveldb.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAyCAYAAADWU2JnAAAAAXNSR0IArs4c6QAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAALEwAACxMBAJqcGAAABh9JREFUWMPtWFtsXNUVXfuO52XjJ8GxMRBI0oAhgBJCCI9IpSppRVErVKmI9wdCQiAM4qGqjUDqRyWQEI+qIPGQ+KiUFEFLAAFVixCRGqUBSoIDcV6O7Tjx+BHb4xnP3OfZqx9jx46ZO34wjoSa/Xnu3eeus886a+17gDNxJn7AIfNNOLiDF5x3ORoDDyuqz0b9qU8pJOi70t+5k3su/YkcBUTLAmYFGNnZhfVi4ed1zXiyIspk4GM4UiFpEZrAQzsARqLw1SBCFUuEzSZAsiKOczVAM0SQG+FfIXh75Ci+WH611Tuv1R/exab0AF9z80oTcE/Xbn3QGN680PJnhvQ3gc9/kqQzrnuzJ/jTWZNIWnZWHyPJ3Kj+tpx8ICkA4Lt6rzHakxvVd0lNhiY4Od5nAu0DgIFBlcUka+9ePum77C6BXrn/EzbNd2Inx3+pkvk03yH5up3h6t0fal3qgFaWylOjB3Z/pK1hpeTAET6x0NXms/pI4LFflSSVpNIYsnMXX9r/b2480cOLssOs3gSV0RS3GKOvknwotDK+p1s8m0z3c2M+o9GFAnPz+r4qnQKwQqhSCyALYANfX1HVByZzrCKnXaIJoKYR25PV8FQ51L2bvyI11rmLFnVuXIpXWr+0LEnkRvC4GnqFmSGl1KQiVIAmEkWw5IIruY0ELrqaVMLYGR5S5XsjvdjbcpmsfOMB/u3mR9Ha0orVGsDyXNkUT+IqCCMAISKygOOn9D1uZcnQ0LHpXCn+3qk5qvonz9aHZ63M/ERbJqu5EIeRopxxsuJbFmOnwxRP9OC4iESmj1kzj7abQ4oEy/95YnJekqxdij6AVigYANGnNqDNHsNHhaTygHKyuGmsX1oHj/DGvg48AeBtUO4EJB4uWmN60u7dHJebgH/0HOanCKqzsbIoUZ2sriuqwKpvera2FSVwJDpFpniVHAGwGcDm7v+yuaaRl0pEliWrcVfgM1lVLxsATvCP0xYkXjSBvBp8nU7hPRrp1IDfhHM3pPjGKPNjfH6wawrt3EKlr0Mb5vq2neG9To6dpL48vTLF7GArSfquOr7DLaMp3lgO3oyPcO3IMf4+8Dk8seWTOtNWQmcKO1URkziA2+uacLubZ8aKYB+AHc44D8Ur0dH9pfSt2iiHZ2YHnl5LlWjgAZEof2ECWUnl6mQNVlXVF05SmBbNSfRiSdQA2EDymrMaCtL2oxsAkiA500YAEBUT5yQ6QyhLWcO8FLjYRAuxnbD4rmsL5XQocGEREg7GswXZQdm/OAo8/aCAXp7HRRBuB8Yn7TT+TGUai4aH0IDqZPEzAJUlt6nlMjnhHZOlvisfTgIqZ6WcLLa/sxnVtU3WtzPJP9MOzMyxoW7eRmW3qo4GntrTrKFIzzLzmVKVaTV6JPD12e/aAcPtIJaE9cXfed2y9fi68TzJAcA5F8pbAN46uZUB173waziPb5Mfj/VzSeAyPu3vFg3LeFxErPtr+eYbGStbVACzjI904R6RQjs6S6entDP6wWgXz2pF+f6fSE2mU3wsTIGtMANLVMsttcuY3UeoMZpW5dPtH2tssIsRNYyU+qgJNOK7jJIa7dmjt3o2P5tQ3nzNUjz3vXvgiZVMthFKPWXoZFCnmmCdaorD5pvNm0qJ1PSG9xSRL5UjIX8fABA/2o4Ds2zT6QkTSLBiPZaHgBE4GX6+2CBI0BnH6yBb0/1oLwomN8I/kFjb286nFhMIQHuoC+8SsrG+2dpRognSTz1btx/v4CW+q3mWKQpsVo7189bsMH9XuBTQVbNd6sRSB/W+CfXc+vGLer6b021Tijr/ptwEmkqnuO7T17hJVXs9R7vtLBvmfKeXHdaWwMVdlfV4JpaQg0M9fL+2EUP5Meyra5I78mk0VCS4JpZA42Rj7dkYjSZQnxnEC91fyT/6DyO96jquOf9y3G1FcH3gyc7cGNsamq0vF3zbOdCpa+pb0AZirYhcQXA0GsfQQCcOnXMh4ibAgBXBEt8VJ1GF5cZnQyQqLQChBp9A8Bd3HP+prLUOlvXqFQDWgJWf27g48LnSd2GpL1ZtM+qOfcuOqgbJ1zfTEbHaz9xsn4n/q/gfqTGjd49vU6gAAAAASUVORK5CYII=", + "light.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyB0cmFuc2Zvcm09Im1hdHJpeCguNzg3MzcgMCAwIC43ODc5MyAtMjcxLjI5NCAtNTI5Ljk5KSIgc3Ryb2tlPSIjZmZmIiBmaWxsPSJub25lIj48ZWxsaXBzZSB0cmFuc2Zvcm09Im1hdHJpeCgxLjQ5MjQgMCAwIDEuNDYxNiAtNTkuNDkxIC0zNDMuNDYpIiBjeD0iMjg3Ljc1IiBjeT0iNzE1Ljg2IiByeD0iMTAuNzUiIHJ5PSIxMiIgY29sb3I9IiMwMDAiIHN0cm9rZS13aWR0aD0iNC4wNzEiLz48cGF0aCBkPSJNMzYyLjUgNzE3Ljg2djE1LjVjNi4zNzEgMi4xMjggOC43MTIgMi4wMDMgMTUgMHYtMTUuNSIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSI1LjAxIi8+PHBhdGggZD0iTTM2Ni41IDcxNy44NmwxLTEyaDVsMSAxMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjMuMDA2Ii8+PC9nPjwvc3ZnPgo=", + "link-call.svg": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLSBDcmVhdGVkIHdpdGggSW5rc2NhcGUgKGh0dHA6Ly93d3cuaW5rc2NhcGUub3JnLykgLS0+Cjxzdmcgd2lkdGg9IjEwLjU4M21tIiBoZWlnaHQ9IjE1Ljg3NW1tIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxMC41ODMgMTUuODc1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPHBhdGggZD0ibTguMjAyMSAyLjM4MTItNC44OTIyIDAuNTM2MTIgMS42MDQgMC45MjYwNC0xLjAzOTUgMS44MDA0YzAuNzM3MTktMC4zNzQwMiAxLjY0MzctMC4zODIyNyAyLjQwOTUgMC4wNTk4OTIgMC43NjUxMSAwLjQ0MTc0IDEuMjExOCAxLjIzMDEgMS4yNTc3IDIuMDU1bDEuMDM4NC0xLjc5ODYgMS42MDQgMC45MjYwNHptLTIuMzgxMyA0LjEyNDRjLTAuNzcwMTYtMC40NDQ2NS0xLjc0MDItMC4xODQ3NC0yLjE4NDggMC41ODU0Mi0wLjQ0NDY1IDAuNzcwMTYtMC4xODUgMS43NDA2IDAuNTg1MTYgMi4xODUzIDAuNzcwMTYgMC40NDQ2NSAxLjc0MjIgMC4xODUzMyAyLjE4NjktMC41ODQ4MyAwLjQ0NDY1LTAuNzcwMTYgMC4xODI5NS0xLjc0MTItMC41ODcyMS0yLjE4NTh6bS0zLjMxOTMgMS41MTU5LTEuODIxMSAzLjE1NDIgMy42NjYyIDIuMTE2NyAxLjgyLTMuMTUyNGMtMC43MzczMSAwLjM3MjY2LTEuNjQzMSAwLjM3OTYxLTIuNDA4Mi0wLjA2MjEyOS0wLjc2NTg1LTAuNDQyMTYtMS4yMTIyLTEuMjMwOS0xLjI1NjktMi4wNTYzeiIgZmlsbD0iI2ZmZiIvPgo8L3N2Zz4K", + "link-out.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNyAzOC45OHYzLjk4M2gxMXYxMmwxMy0yM0gxOWwtLjQ2My4wMTdjLTEuMjggNC4wNDgtNS4wNjYgNi45ODMtOS41MzcgNi45ODN6bTEyLTExLjAxN2gxMmwtMTMtMjN2MTJIN1YyMC45bDIgLjA2NGM0LjQ2NyAwIDguMjUgMi45MyA5LjUzNCA2Ljk3MnpNNi45NSAyNC4yMmE2IDYgMCAxIDEtLjA4MyAxMS40NTYiIGZpbGw9IiNmZmYiIHN0eWxlPSJpc29sYXRpb246YXV0bzttaXgtYmxlbmQtbW9kZTpub3JtYWwiLz48L3N2Zz4K", + "link-return.svg": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLSBDcmVhdGVkIHdpdGggSW5rc2NhcGUgKGh0dHA6Ly93d3cuaW5rc2NhcGUub3JnLykgLS0+Cjxzdmcgd2lkdGg9IjEwLjU4M21tIiBoZWlnaHQ9IjE1Ljg3NW1tIiB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAxMC41ODMgMTUuODc1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogPHBhdGggZD0ibTIuNjYyMyAxMy4yOTIgNC44OTIyLTAuNTM2MTItMS42MDQtMC45MjYwNCAxLjAzOTUtMS44MDA0Yy0wLjczNzE5IDAuMzc0MDItMS42NDM3IDAuMzgyMjctMi40MDk1LTAuMDU5ODkyLTAuNzY1MTEtMC40NDE3NC0xLjIxMTgtMS4yMzAxLTEuMjU3Ny0yLjA1NWwtMS4wMzg0IDEuNzk4Ni0xLjYwNC0wLjkyNjA0em0yLjM4MTMtNC4xMjQ0YzAuNzcwMTYgMC40NDQ2NSAxLjc0MDIgMC4xODQ3NCAyLjE4NDgtMC41ODU0MiAwLjQ0NDY1LTAuNzcwMTYgMC4xODUtMS43NDA2LTAuNTg1MTYtMi4xODUzLTAuNzcwMTYtMC40NDQ2NS0xLjc0MjItMC4xODUzMy0yLjE4NjkgMC41ODQ4My0wLjQ0NDY1IDAuNzcwMTYtMC4xODI5NSAxLjc0MTIgMC41ODcyMSAyLjE4NTh6bTMuMzE5My0xLjUxNTkgMS44MjExLTMuMTU0Mi0zLjY2NjItMi4xMTY3LTEuODIgMy4xNTI0YzAuNzM3MzEtMC4zNzI2NiAxLjY0MzEtMC4zNzk2MSAyLjQwODIgMC4wNjIxMjkgMC43NjU4NSAwLjQ0MjE2IDEuMjEyMiAxLjIzMDkgMS4yNTY5IDIuMDU2M3oiIGZpbGw9IiNmZmYiLz4KPC9zdmc+Cg==", + "mongodb.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAEbSURBVEiJ7davSwRBGMbx76rJ4Gky62EThNOmWfwL7mlm0WAw2U2mK3aDwj5gMBgEi8FmEbGIWYMGwR8gJssGhb2dmdsNIr5t5n3nMy8TZibL85wmY6hR7e+AkmYlTTUCSpoAjoEjSa1aoKRhIAfaQAc4kTRap8NtYPnbeAnYHQiUNAZslaTWJS0O0uEGMF4ynwG9JFBSBmxWbLYgqZPSYRuYrAABVlPA+QAGMJcCTkeAMyngcwT4mQI+RYCPKeAZ8BYAL6JB2y/AQQB0NFjEXkXu0PZlEmj7BjgtSd0Ca/3WhS6HLj/P6gPo2u57vpWg7VdgBbgrpnZsX1etCV6wtt+Bh2J4H6r//Y/USGTdPnAOXIUKs/+fQ+34AkBdPov8hXWQAAAAAElFTkSuQmCC", + "mouse.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB98CBBcXKYRGaWIAAAIfSURBVEjHrVY9a1RBFD33bjAhNqYTBW0kpBErawPpjIq4RWyEYG2hiIWdnY3+Aiut/GhiYyeIhb/AJGgQFAtTpFlWCYjnHIvMw3XzXvI2m1vNx50zc7/OHaClSLLtB2WMsUXSkiSRfHgYYAAA29clmeSZJt0cBdh2pwz7h/HKm9qRJ2OD2Z4vpi7vF5SJOn9lZgV0SdKk7bsAEBEXbSeALdtvIuLPoP4uIVmBzktyMdG2RdJlooH1hcFzTaDvCtgLSfNl7bgkD1gxK+lWccPzRjeQXC5gdwaVSJ4eBKxeZPt20e8OO726+ZOkrzUX/Qc4tLci6WN1SRZnV/uzth+3jT5JZOYqgJkskdkVnsxcbwvY6XRgmxExASCaKiVGzNG9S8/2WFQyUbN2VdIvAOuZ2Ss+/l1z8REAc7bnALiR86qELolsSZ9tr5REfkTytaSfA4lu2z8q4ohhQABd25sRcTIiFm1fADATEZOl7PoANgA8i4g1Sd3MXAJwIiK4y+SI6GfmhzJ91YInFw7MhzUpNh7BHijKto+OAhARYVtVPuYwU0TE5REBp2z3M1N1Tn5q+9uIreELydWmzRsl9061LLtzhRPP79nQSXq4hQ7PSR4juW37ZSNr24akqfLK7ySvNdDWFUnbRW+61U9C0lo5sCnpHslFSfclbXmnsbytY5s9fwqSzkra8L/OZJLvq59DK7A635EM29O9Xm/fc38BtSAs2DgLlw8AAAAASUVORK5CYII=", + "parser-csv.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjYuMDAxIDcuMDM0bC0yMiAuMDQzdjQ1Ljk1OGgzMnYtMzZ6bS0xNC44MzggMTRoMi44Mzh2MTUuMDAxaC0zVjI1LjczOGMtMS4wMjcuOTYtMi42MDYgMS4xMTQtNCAxLjU3NFYyNC43NmMuNzM0LS4yNCAxLjUzLS43MDYgMi4zOS0xLjM3My44NjEtLjY3MyAxLjQ1Mi0xLjQ1NyAxLjc3Mi0yLjM1MXptMTUuOTQ4IDBjMS40NDggMCAyLjgyNC4zOTIgMy42NSAxLjE3My44MjguNzggMS4yNCAxLjc1MSAxLjI0IDIuOTEyIDAgLjY2LS4xMiAxLjI5LS4zNiAxLjg5YTcuMTQzIDcuMTQzIDAgMCAxLTEuMDUgMS44NzJjLS4zNC40MzMtLjk1MiAxLjA1Ny0xLjg0IDEuODctLjg4Ni44MTUtMS42ODcgMS4zNTUtMS45MjcgMS42MjItLjIzMy4yNjctLjQyMy40MDgtLjU3LjY2Mmg1Ljc0OHYzSDIxLjk4Yy4xMDctLjk4Ny40MjctMi4xOTIuOTYtMy4wNzIuNTM0LS44ODcgMS44MjUtMi4wNiAzLjQtMy41MjIgMS4yNjctMS4xOCAxLjk3Mi0xLjk4MiAyLjI1OS0yLjQwMi4zODctLjU4LjQwMi0xLjE1NC40MDItMS43MjEgMC0uNjI3LjAwOC0xLjEwOC0uMzMyLTEuNDQxLS4zMzMtLjM0LTEuMDM1LS41MS0xLjYyOS0uNTEtLjU4NyAwLTEuMDUzLjE3OC0xLjQuNTMxLS4zNDcuMzU0LS41NDYgMS4zMTYtLjYgMi4xMzdoLTMuMDM5Yy4xNjctMS41NDguOTI4LTMuMzE1IDEuODA5LTMuOTg5Ljg4LS42NzMgMS45OC0xLjAxMSAzLjMtMS4wMTF6TTE3LjAwMiAzMy4wMzZoM3YxLjkyOGMwIC44MTQtLjA3MyAxLjQ1NS0uMjEzIDEuOTIyLS4xNC40NzMtLjQwNy44OTgtLjggMS4yNzEtLjM4Ny4zNzQtLjg4LjY2Ni0xLjQ4MS44OGwtLjU0OS0xLjE2MWMuNTY3LS4xODcuOTY5LS40NDMgMS4yMS0uNzcuMjQtLjMyNy4zNjctLjUwMy4zOC0xLjA3aC0xLjU0N3oiIGZpbGw9IiNmZmYiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuNjEyIi8+PC9zdmc+Cg==", + "parser-html.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjYuMDAxIDcuMDM0bC0yMiAuMDQzdjQ1Ljk1OGgzMnYtMzZ6bS0xMyAxM2wzIDItNiA4IDYgOC4wMDEtMyAyLTctMTB6bTEzIDBsNyAxMC03IDEwLjAwMS0zLTIgNi04LTYtOHoiIGZpbGw9IiNmZmYiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuNjEyIi8+PC9zdmc+Cg==", + "parser-json.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjYuMDAxIDcuMDM0bC0yMiAuMDQzdjQ1Ljk1OGgzMnYtMzZ6bS05LjE3IDEwLjAwMmgxLjE5N3YzLjExYy0uOTc0IDAtMi4xLjA1Ny0yLjM4MS4xN2ExLjE0NiAxLjE0NiAwIDAgMC0uNjA2LjQ5M2MtLjEzMS4yMTctLjE5Ny41OTMtLjE5NyAxLjEzMSAwIC41NDYtLjA0IDEuNTgzLS4xMTkgMy4xMS0uMDQ0Ljg1OC0uMTU4IDEuNTU2LS4zNDIgMi4wOTMtLjE4NC41My0uNDIxLjk2OC0uNzEgMS4zMTUtLjI4MS4zNDctLjcxNi43MDUtMS4zMDMgMS4wNzguNTE3LjI5NS45MzkuNjQyIDEuMjYzIDEuMDQuMzM0LjM5MS41ODguODY5Ljc2NCAxLjQzMy4xNzUuNTYzLjI5IDEuMzE4LjM0MiAyLjI2M2E4Ni45IDg2LjkgMCAwIDEgLjA5MiAyLjc1NmMwIC41NzMuMDcuOTcyLjIxIDEuMTk4LjE0LjIyNS4zNS4zOTUuNjMxLjUwNy4yOS4xMjIgMS40MDguMTgyIDIuMzU2LjE4MnYzLjEyMUgxNi44M2MtMS4xODUgMC0yLjA5My0uMDk1LTIuNzI1LS4yODVhMy42OSAzLjY5IDAgMCAxLTEuNjA0LS45MjQgMy4zIDMuMyAwIDAgMS0uODY5LTEuNjEzYy0uMTQ5LS42MzMtLjIyNC0xLjYzNC0uMjI0LTMuMDA0IDAtMS41OTYtLjA3LTIuNjMzLS4yMS0zLjExLS4xOTItLjY5My0uNDg3LTEuMTg3LS44ODItMS40ODItLjM4Ni0uMzAzLTIuNTA5LS41MzktMy4zMTYtLjU4MnYtM2MuNjQtLjAzNSAyLjY0OS0uMTkgMi45NzQtLjM0Ni4zMjQtLjE1Ni42MDUtLjQxNy44NDItLjc4MS4yMzctLjM3My4zOTgtLjgzNi40ODYtMS4zOS4wNy0uNDE3LjEwNi0xLjE0My4xMDYtMi4xNzUgMC0xLjY4Mi4wOC0yLjg1Mi4yMzgtMy41MTEuMTU4LS42NjguNDQzLTEuMjAxLjg1NS0xLjYuNDEzLS40MDggMS4wMTItLjcyOSAxLjgwMS0uOTYzLjUzNS0uMTU2IDMuNjc3LS4yMzQgMi41MjctLjIzNHptNS4xNTcgMGgxLjE5N2MxLjE1IDAgMS45OTMuMDc4IDIuNTI4LjIzNC43OS4yMzQgMS4zODguNTU1IDEuOC45NjMuNDEzLjM5OS42OTguOTMyLjg1NiAxLjYuMTU4LjY1OS4yMzggMS44My4yMzggMy41MTEgMCAxLjAzMi4wMzYgMS43NTguMTA2IDIuMTc0LjA4Ny41NTUuMjUgMS4wMTguNDg2IDEuMzkuMjM3LjM2NS41MTcuNjI2Ljg0Mi43ODIuMzI0LjE1NiAyLjMzMy4zMTEgMi45NzMuMzQ2djNjLS44MDcuMDQzLTIuOTMuMjc4LTMuMzE1LjU4Mi0uMzk1LjI5NS0uNjkuNzg5LS44ODMgMS40ODItLjE0LjQ3Ny0uMjA5IDEuNTE0LS4yMDkgMy4xMSAwIDEuMzctLjA3NSAyLjM3LS4yMjQgMy4wMDQtLjE1LjY0MS0uNDQgMS4xOC0uODcgMS42MTNhMy42OSAzLjY5IDAgMCAxLTEuNjAzLjkyNGMtLjYzMi4xOS0zLjkwOS4yODUtMi43MjUuMjg1aC0xLjE5N3YtMy4xMjFjLjk0NyAwIDIuMDY2LS4wNiAyLjM1Ni0uMTgyLjI4LS4xMTIuNDktLjI4Mi42My0uNTA4LjE0LS4yMjUuMjExLS42MjUuMjExLTEuMTk3IDAtLjM5OS4wMy0xLjMxNi4wOTItMi43NTYuMDUzLS45NDUuMTY2LTEuNy4zNDItMi4yNjQuMTc1LS41NjMuNDMtMS4wNDEuNzY0LTEuNDMxYTQuNDM0IDQuNDM0IDAgMCAxIDEuMjYzLTEuMDQxYy0uNTg3LS4zNzMtMS4wMjItLjczMS0xLjMwMi0xLjA3OC0uMjktLjM0Ny0uNTI3LS43ODYtLjcxMS0xLjMxNS0uMTg1LS41MzctLjI5OC0xLjIzNS0uMzQyLTIuMDk0LS4wOC0xLjUyNi0uMTItMi41NjMtLjEyLTMuMTA5IDAtLjUzOC0uMDY1LS45MTQtLjE5Ny0xLjEzYTEuMTQ2IDEuMTQ2IDAgMCAwLS42MDUtLjQ5NWMtLjI4LS4xMTMtMS40MDctLjE3LTIuMzgtLjE3eiIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9Ii42MTIiLz48L3N2Zz4K", + "parser-xml.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjYuMDAxIDcuMDM0bC0yMiAuMDQzdjQ1Ljk1OGgzMnYtMzZ6bS0xMyAxM2wzIDItNiA4IDYgOC4wMDEtMyAyLTctMTB6bTEzIDBsNyAxMC03IDEwLjAwMS0zLTIgNi04LTYtOHoiIGZpbGw9IiNmZmYiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIuNjEyIi8+PC9zdmc+Cg==", + "parser-yaml.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjYuMDAxIDYuOTg3bC0yMiAuMDQzdjQ1Ljk1OGgzMlYxNi45ODd6TTEwLjU2NyAxOS4zNjJoNS4zNTJjLjc2OCAwIDEuMjgyLjEwNCAxLjU0My4zMTMuMjczLjIwOC40MS41NzMuNDEgMS4wOTMgMCAuNTIxLS4xNjMuODg2LS40ODggMS4wOTQtLjMxMy4yMDktLjg5Mi4zMTMtMS43MzkuMzEzaC0uNjQ0bDQuNzI2IDguNDc3IDQuOTAzLTguNDc3aC0uODAxYy0uNzAzIDAtMS4xOTEtLjEwNC0xLjQ2NS0uMzEzLS4yNzMtLjIwOC0uNDEtLjU3My0uNDEtMS4wOTQgMC0uNTQ2LjExNy0uOTE4LjM1Mi0xLjExMy4yMzQtLjE5NS44MjYtLjI5MyAxLjc3Ny0uMjkzaDMuNTk0Yy45OSAwIDEuNjIuMTA0IDEuODk0LjMxMy4yODcuMTk1LjQzLjU2LjQzIDEuMDkzIDAgLjUyMS0uMTQzLjg4Ni0uNDMgMS4wOTQtLjI3My4yMDktLjguMzEzLTEuNTgyLjMxM2gtLjMxMmwtNi42NCAxMS4zODd2Ni4yMTFoMi44NWMxLjAwMyAwIDEuNjQxLjA5OCAxLjkxNS4yOTMuMjg2LjE4Mi40My41MzQuNDMgMS4wNTUgMCAuNTM0LS4xNDQuOTA1LS40MyAxLjExMy0uMjc0LjE5NS0uODM0LjI5My0xLjY4LjI5M2gtOS4yNzdjLS44NDcgMC0xLjQwNi0uMDk4LTEuNjgtLjI5My0uMjczLS4yMDgtLjQxLS41OC0uNDEtMS4xMTMgMC0uNTIxLjE1LS44NzMuNDUtMS4wNTUuMzEyLS4xOTUuOTUtLjI5MyAxLjkxMy0uMjkzaDIuODMyVjMzLjI5bC02LjUwNC0xMS4xMTRoLS4zMzJjLS43ODEgMC0xLjMxNS0uMTA0LTEuNjAxLS4zMTItLjI4Ny0uMjA5LS40My0uNTczLS40My0xLjA5NCAwLS40ODIuMTE3LS44MzMuMzUxLTEuMDU1LjI0OC0uMjM0LjYyNi0uMzUxIDEuMTMzLS4zNTF6IiBmaWxsPSIjZmZmIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iLjYxMiIvPjwvc3ZnPgo=", + "range.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNOC4wMDMgNDIuMDA3aDV2LTE4aC01ek04LjAwMyAyMC4wMDZoNXYtNWgtNXpNMjUgNTIuMDA3aDh2LTI5aC04ek0yNSAxNS4wMWg4di04aC04eiIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSJub25lIi8+PHBhdGggZD0iTTE1LjgxOSAxNS42MzlsNi4yOTMtMi41MTdNMTUuMTkgNDIuMzg1bDcuMjM2IDMuNzc1IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMS4yNTg2MiIvPjwvc3ZnPgo=", + "rbe.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjCAYAAAAe2bNZAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wcPFiwJnbnE1AAAAIlJREFUWMPtl7sJgEAQRGcPYwMbELQni7AJW7QYEzEYYwX1vog4L13YeWw0CwjxR0hOjKCEiC8LyZXkTLIvIWIBMiPJ1ne3xVzlsMDMrubn2RMu5UqhYUVlciMZyUhGMpKRjGQk87ZMauGuPAIaABuADsCQMzyoA6eExVRSd/eKRHrUubuxEJ9gB2tHwAJpckKMAAAAAElFTkSuQmCC", + "redis.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAJdSURBVEiJ7ZVNSFVREMd/zxQL2kSBQl+4qSgoKVxKrSoo+lgNLdUylSzaJLWpNhG0SPuwoIW0sTvRIhShTSRRELTQQEOjoiCoJLEPi1Ly3+KdZ+c9nwbiIqiBw+XMnf/vMnPmzE0lScJcWsGc0v4D58QK/xRgZkXA0rD94u7DswKa2QrgIFADlAT3TzPrAJrd/X4+XSruQzNLAduBemAH6ZK8B7qAcWA1sCWE9wAtwA13H5uEJEmCpCWSjkl6od/WLckkFSVJQmZJ2iCpTdL3EPdO0klJJUmSTKb8BigGPgOXgCvu/jRfSu7+BKgys6aQST1wCjgOzM8Ai4FeoNLdR6eraw54CDhtZmeBh8AmyD6UcuCRmTUD7e7+bSagmRUCVUAdsDHjzwAvA/uAdcA14JyZXQeuuvtADmgRcAA4BCwP7g/ATWDyUBSK3CHpoqRX0eHcldQoqU5Sq6TR4B+WdEvSPUljkpQkSRbwmaSKsC+QtFNSl6SfyrZ+SbWSFoTYckmDucC+SNAjqToSlEnaE1Zl9MHdkh5Eur4YOBi9GA/PEUnnJa2KenChpMOSnoeYiUg3mJvygKQKSSWSTkh6HYn6JfVK+hR8X0M91wTNQCbleNqsDC2wzN3PAGXALuAOsBgoBd4CTSGmIehqgjarbRpJd3w1UG1mj4HWdP96Z54e3GpmR4FtQAp4CVyAqcNhM9AA7AWKgBGgDbhNejisB44Aa4OkG2gGOt19YgowApcC+4HaqHkz9gNoB1rCvc6yvMAIPI/0GMtcrY+kr+XQdJoZgbOxv/8n9Q8CfwE7dg5XDmjPIwAAAABJRU5ErkJggg==", + "rpi.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii4xMzgiPjxwYXRoIGQ9Ik0yMC4yMSAyMC45OGMyLjA5Ni0uMDIzIDQuNjk0IDEuNTYyIDQuNjgzIDMuMDU4LS4wMSAxLjMyLTEuODI0IDIuMzktNC42NjcgMi4zNy0yLjc4NC0uMDM0LTQuNjU2LTEuMzQ0LTQuNjUtMi42MjMuMDA1LTEuMDUzIDIuMjcxLTIuODY2IDQuNjM0LTIuODA1ek0xMy4wNyAyMS44N2MuNDY5LS4wMDkuOTU0LjAyOCAxLjQ0OS4xMTEgMS40NTguMjQ1LTYuOTkgNy42MjMtNy4xMiA1Ljk3Ny0uMTE2LTMuNzU2IDIuMzktNi4wMjcgNS42NzEtNi4wODh6TTI2LjkxIDIxLjk5YzMuMjguMDYxIDUuNzg3IDIuMzMgNS42NyA2LjA4Ni0uMTMgMS42NDUtOC41NzctNS43My03LjExOS01Ljk3NWE3LjgzIDcuODMgMCAwIDEgMS40NDktLjExMXpNMjUuNzYgMjYuNTljMS41Ni4xNDMgMy4xNDMgMS4wOCA0LjE3NSAyLjY0MyAxLjY1MiAyLjUgMS4yNyA1LjY1Ni0uODU1IDcuMDQ4LTIuMTI1IDEuMzkyLTUuMTg1LjQ5Mi02LjgzNy0yLjAwOC0xLjY1Mi0yLjUtMS4yNjktNS42NTYuODU1LTcuMDQ4Ljc5Ny0uNTIyIDEuNzI1LS43MiAyLjY2Mi0uNjM1ek0xNC43IDI2Ljg0Yy45MzctLjA4NiAxLjg2NS4xMTMgMi42NjEuNjM1IDIuMTI1IDEuMzkyIDIuNTA4IDQuNTQ4Ljg1NiA3LjA0OC0xLjY1MiAyLjUtNC43MTIgMy4zOTctNi44MzcgMi4wMDUtMi4xMjQtMS4zOTItMi41MDctNC41NDUtLjg1NS03LjA0NSAxLjAzMi0xLjU2MyAyLjYxNC0yLjUgNC4xNzUtMi42NDN6TTMzLjA1IDI5LjZhLjcyOC43MjggMCAwIDEgLjIwNC4wMjZjMy44MDYgMi4xNzMgMy4xNDUgNy4wMSAxLjA5MiA4LjY2LTEuODA4LjgwNC0zLjI5LTguNjUzLTEuMjk2LTguNjg2ek02Ljk1IDI5LjczYzEuOTk0LjAzMy41MTIgOS40OS0xLjI5NiA4LjY4Ny0yLjA1My0xLjY1MS0yLjcxNC02LjQ4NiAxLjA5Mi04LjY2YS43MzguNzM4IDAgMCAxIC4yMDQtLjAyN3pNMjAuMyAzNi4yM2MyLjgzMi0uMDE0IDUuMTQxIDIuMDkzIDUuMTU2IDQuNzA0di4wNDljLjAxNSAyLjYxLTIuMjY5IDQuNzM4LTUuMSA0Ljc1Mi0yLjgzMi4wMTQtNS4xNDEtMi4wOS01LjE1Ni00LjcwMWEyLjk5NiAyLjk5NiAwIDAgMSAwLS4wNTFjLS4wMTUtMi42MTEgMi4yNjgtNC43NCA1LjEtNC43NTN6TTMxLjY2IDM4LjAyYy40LS4wMTEuNzc3LjEwMiAxLjExNS4zNjQuOTEuOTA3IDEuNDQzIDQuMzItLjE1IDYuMzY2LTIuMTk1IDMuMDQ1LTUuMTY1IDMuMTY0LTYuMjcyIDIuMzE2LTEuMTU3LTEuMDkyLS4yNzQtNC40ODIgMS4zMTMtNi4zNCAxLjM2MS0xLjUzOCAyLjc5My0yLjY3NSAzLjk5NC0yLjcwNnpNOC42NSAzOC43NGMxLjI5LjA1NSAyLjgzNiAxLjA3NiA0LjA5OSAyLjU0IDEuNDY2IDEuNzY4IDIuMTM1IDQuODcyLjkxIDUuNzg4LTEuMTU3LjY5OC0zLjk2OC40MDktNS45NjctMi40NjMtMS4zNDctMi40MDktMS4xNzUtNC44Ni0uMjI5LTUuNThhMi4wNyAyLjA3IDAgMCAxIDEuMTg3LS4yODV6TTIwLjM1IDQ3LjIxYzIuMDYtLjA5IDQuODI0LjY2MyA0LjgzIDEuNjYyLjAzMy45Ny0yLjUwOCAzLjE2My00Ljk2NyAzLjEyLTIuNTQ3LjExLTUuMDQ1LTIuMDg1LTUuMDEyLTIuODQ2LS4wMzgtMS4xMTYgMy4xMDItMS45ODcgNS4xNDktMS45MzZ6TTM1LjEzIDExLjAzYy0uNTY5IDcuMzU2LTcuNDA5IDE0LjEyOC0xMS42MjkgNi45NzEgMS4zNjMtMS41MiAzLjg1My0zLjMwNiA4LjEzMy01LjUxMy0zLjMzIDEuMTMyLTYuMzM1IDIuNjQtOC44NSA0LjcxNi01LjgzOC00LjI5NiA0LjczNC05LjY3NSAxMi4zNDYtNi4xNzR6Ii8+PHBhdGggZD0iTTUuNDIgMTEuMDNjLjU2OSA3LjM1NiA3LjQwOSAxNC4xMjggMTEuNjI5IDYuOTcxLTEuMzYzLTEuNTItMy44NTMtMy4zMDYtOC4xMzMtNS41MTMgMy4zMyAxLjEzMiA2LjMzNSAyLjY0IDguODUgNC43MTYgNS44MzgtNC4yOTYtNC43MzQtOS42NzUtMTIuMzQ2LTYuMTc0eiIvPjwvZz48L3N2Zz4=", + "serial.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNC41IDQ3LjVoNGwuMDQtMzQuOTk1TDE5LjUgMTIuNXYzNWgxMXYtMzVoNSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbGluZWNhcD0ic3F1YXJlIiBzdHJva2Utd2lkdGg9IjUiLz48L3N2Zz4K", + "sort.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjAuMDAxIDE5Ljk1NWwtNy41LTguMTc4LTcuNSA4LjE3OGg1djI4aDV2LTI4ek0zNS4wMDEgMzkuOTU1bC03LjUgOC4xNzctNy41LTguMTc3aDV2LTI4aDV2Mjh6IiBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii41MzEiLz48L3N2Zz4K", + "split.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTYuMDAxIDM5Ljk5bC0uMDMyLTE5Ljk1Ny0xMS45NjguMDE3djE5Ljk4M2wxMi0uMDQ1TTM0LjAwMSAyOC4wMjdsLTQgLjAwNHYzLjk5N2w0LS4wMU00MC4wMDEgMjguMDI3bC00IC4wMDR2My45OTdsNC0uMDFNMjcuMDAxIDMwLjAyN2wtNy03Ljk4NnYxNS45ODZNMzQuMDAxIDIwLjAyN2wtNCAuMDA0djMuOTk3bDQtLjAxTTQwLjAwMSAyMC4wMjdsLTQgLjAwNHYzLjk5N2w0LS4wMU0zNC4wMDEgMzYuMDI3bC00IC4wMDR2My45OTdsNC0uMDFNNDAuMDAxIDM2LjAyN2wtNCAuMDA0djMuOTk3bDQtLjAxIiBmaWxsPSIjZmZmIiBzdHJva2Utd2lkdGg9Ii42MTIiLz48L3N2Zz4K", + "status.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMCAzMGg5bDMuNS0xMCA1IDI1IDUuMTI1LTMwTDI3LjUgNDAgMzEgMzBoOSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2UtbWl0ZXJsaW1pdD0iNi41IiBzdHJva2Utd2lkdGg9IjQiLz48L3N2Zz4K", + "subflow.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjUgMjUuOTRoN2MuNTggMCAxLS40MiAxLTF2LTJjMC0uNTgtLjQyLTEtMS0xaC03Yy0uNTggMC0xIC40Mi0xIDF2MmMwIC41OC40MiAxIDEgMXptLTE3IDEyaDdjLjU4IDAgMS0uNDIgMS0xdi0yYzAtLjU4LS40Mi0xLTEtMUg4Yy0uNTggMC0xIC40Mi0xIDF2MmMwIC41OC40MiAxIDEgMXptLS40MTYgMTFDNS42MjQgNDguOTQgNCA0Ny4zMTUgNCA0NS4zNTZWMTQuNTIyYzAtMS45NiAxLjYyNS0zLjU4MiAzLjU4NC0zLjU4MmgyNC44MzJjMS45NiAwIDMuNTg0IDEuNjIzIDMuNTg0IDMuNTgydjMwLjgzNGMwIDEuOTYtMS42MjUgMy41ODQtMy41ODQgMy41ODR6TTMyIDM2Ljk0SDE5YzAgMi4xOS0xLjgxIDQtNCA0SDd2NC40MTZjMCAuMzUuMjM1LjU4NC41ODQuNTg0aDI0LjgzMmMuMzUgMCAuNTg0LS4yMzUuNTg0LS41ODR2LTguNDE3em0xLTJ2LTZoLThjLTIuMTkgMC00LTEuODEtNC00aC0xYy00LjMzMy0uMDAyLTguNjY3LjAwNC0xMyAwdjZoOGMyLjE5IDAgNCAxLjgxIDQgNGgxM3ptMC0xNnYtNC40MThjMC0uMzUtLjIzNS0uNTgyLS41ODQtLjU4Mkg3LjU4NGMtLjM1IDAtLjU4NC4yMzMtLjU4NC41ODJ2OC40MTdjNC4zMzMuMDAyIDguNjY3LjAwMSAxMyAuMDAxaDFjMC0yLjE5IDEuODEtNCA0LTRoOHoiIGNvbG9yPSIjMDAwIiBmaWxsPSIjZmZmIi8+PC9zdmc+Cg==", + "swap.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjZmZmIj48cGF0aCBkPSJNMTIuNzg3IDI2LjQzMkw5LjUgMTguMDAzaC00LjV2LTVoOGwyLjI4NiA2LjI4Nm01LjM1NyAxNC4yODZsMy4zNTcgOC40MjhoNHYtOGw3IDEwLjUtNyAxMC41di04aC03LjVsLTIuMzU3LTYuMjg2Ii8+PHBhdGggZD0iTTEzLjAwMSA0Ny4wMDNsMTAuODU3LTI5aDQuMTQzdjhsNy0xMC41LTctMTAuNXY4aC03LjVsLTExIDI5aC00LjV2NXoiLz48L2c+PC9zdmc+", + "switch.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNS4wMDEgMjcuMDA2djZsMTAuMTItLjAxNCAzLjAyMyA3LjcyOCAyLjM1NyA2LjI4Nmg3LjV2OGw3LTEwLjUtNy0xMC41djhoLTRsLTMuMzU3LTguNDI5Yy0uNDc1LTEuMjY3LS45My0yLjQzNS0xLjI5NS0zLjYwMWw0LjUxLTExLjk3SDI4djhsNy0xMC41LTctMTAuNXY4aC03LjVMMTUuMDk4IDI3eiIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSJub25lIi8+PC9zdmc+Cg==", + "template.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMzAuMDQxIDQ3LjkxdjMuNDg1aC0xLjVxLTYuMDI3IDAtOC4wODQtMS43OS0yLjAzMy0xLjc5Mi0yLjAzMy03LjE0VjM2LjY4cTAtMy42NTUtMS4zMDctNS4wNTgtMS4zMDYtMS40MDQtNC43NDMtMS40MDRoLTEuNDc2di0zLjQ2aDEuNDc2cTMuNDYgMCA0Ljc0My0xLjM4IDEuMzA3LTEuNDA0IDEuMzA3LTUuMDF2LTUuODA4cTAtNS4zNDkgMi4wMzMtNy4xMTYgMi4wNTctMS43OSA4LjA4My0xLjc5aDEuNTAxdjMuNDZoLTEuNjQ2cS0zLjQxMiAwLTQuNDUzIDEuMDY1LTEuMDQgMS4wNjUtMS4wNCA0LjQ3N3Y2LjAwMnEwIDMuOC0xLjExNCA1LjUxOC0xLjA4OSAxLjcxOS0zLjc1IDIuMzI0IDIuNjg1LjY1MyAzLjc3NSAyLjM3MSAxLjA4OSAxLjcxOSAxLjA4OSA1LjQ5NHY2LjAwMnEwIDMuNDEyIDEuMDQgNC40Nzd0NC40NTMgMS4wNjV6IiBmaWxsPSIjZmZmIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iLjcxOSIvPjwvc3ZnPgo=", + "timer.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBjb2xvcj0iIzAwMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU0MCAtOTg3LjM2KSI+PHBhdGggZD0iTTU2NS40MyAxMDAxLjljNi41NjIgMi4wODkgMTEuMzE2IDguMjMzIDExLjMxNiAxNS40ODggMCA4Ljk3NS03LjI3NSAxNi4yNS0xNi4yNSAxNi4yNXMtMTYuMjUtNy4yNzUtMTYuMjUtMTYuMjVjMC0yLjgwMi43MS01LjQzOCAxLjk1OC03Ljc0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHlsZT0iaXNvbGF0aW9uOmF1dG87bWl4LWJsZW5kLW1vZGU6bm9ybWFsIi8+PGNpcmNsZSBjeD0iNTYwIiBjeT0iMTAwMS40IiByPSIxLjUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjMiIHN0eWxlPSJpc29sYXRpb246YXV0bzttaXgtYmxlbmQtbW9kZTpub3JtYWwiLz48cGF0aCBkPSJNNTYwIDEwMTQuNGMtMS4yMDYgMC0xMS0xMC45OTktMTIuMzU0LTkuOTc1UzU1NyAxMDE2LjE0OCA1NTcgMTAxNy40czEuMzYgMyAzIDMgMy0xLjM2MSAzLTMtMS43OTQtMy0zLTN6IiBmaWxsPSIjZmZmIi8+PC9nPjwvc3ZnPgo=", + "trigger.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNNC41IDQ3LjQ2aDRsLjA0LTM0Ljk5NSAxMC45Ni0uMDA1djM1aDE2IiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJzcXVhcmUiIHN0cm9rZS13aWR0aD0iNSIgZmlsbD0ibm9uZSIvPjwvc3ZnPgo=", + "watch.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzU1IC03MDQuMzYpIj48Y2lyY2xlIHRyYW5zZm9ybT0idHJhbnNsYXRlKC00My4wOTEgLTQ0Ljc1Mikgc2NhbGUoLjgxODE4KSIgY3g9IjUwNS41IiBjeT0iOTQxLjg2IiByPSIxNC41IiBjb2xvcj0iIzAwMCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjQiLz48cGF0aCBkPSJNMzc3LjYyIDczNy44Nmw4Ljg3NyAxNS41IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSI2Ii8+PC9nPjwvc3ZnPgo=", + "white-globe.svg": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjAgMTJhMTggMTggMCAwIDAtMTQuMjQ2IDcuMDMzbDIuNTI3Ljg0NCAxLjk4My0xLjc5NyA0Ljg5LTMuNzE1Ljk5LjkzLTQuMDg1IDEuOTE4LS4wNjMgMi40MTYgMy4yMi0uODA1Yy45NS0uNjggMS44OTktMS4zNjQgMi44NDktMi4wNDVsLS4yNDktMi4yOWgxLjM2NGMxLjUwNi0uNTE3IDMuMDEzLTEuMDMyIDQuNTItMS41NDhsMS4zNDEgMS4zMjUtLjM1MSAxLjk1Ny0yLjA0My0uMTg2IDEuOTgyIDIuNjYyLTIuNDc3IDEuODU4di0xLjYxbC0yLjI5LjEyM2MtLjg2OC42Ni0xLjczNSAxLjMyMi0yLjYwMiAxLjk4M2wtLjM3MSAzLjM0My0zLjA5Ni40OTctMS4wNTMgMi40MTQuNDk0LjQ5NiAxLjE3OC0uMzEgMS4yMzgtMS40MjUgMi4xMDYuMzcxIDEuMTEzIDEuMjM5YzEuNTY5LjY5MSAzLjEzOSAxLjM4MiA0LjcwNyAyLjA3NEgxMS4wNjhsLTEuNjcyIDEuNDU1LS4zMSAyLjY2MiAxLjc5NyAyLjg1IDQuNTgyLjg2Ny45OSAyLjY2Mi0uMzcxIDMuMDk2IDIuMTA1IDEuNjEgMy4yODEuMzFMMjUgNDMuMTU4bC4zNy0xLjYxLjU2LTIuMDQ0Yy40OTUtLjQ5NS45ODgtLjk5MSAxLjQ4NC0xLjQ4NmwuMTI1LTEuNjctMi44NS0xLjY3Mi0xLjQ4NC0yLjE2OCAxLjU0Ny40OTYgMi40MTYgMi40MTQgMi4zNTEtMS4zLjE4Ni0yLjY2My0yLjQxNC0uMzcxLjEyMy0xLjMzMmg0LjY0NGwxLjQyNCAyLjJjLjU3OC4zMDkgMS4xNTcuNjE3IDEuNzM1LjkyN2wtLjI0OS0zLjEyN2gxLjIzOWwxLjMgMy40MzcuNDA1LTEuNDJBMTggMTggMCAwIDAgMzggMzBhMTggMTggMCAwIDAtMTgtMTh6TTIuOTU3IDMzLjgwOWwtLjUxOC4wNDNhMTggMTggMCAwIDAgMi42NzYgNi4yMzhjLjIxLS42MjguNDE4LTEuMjU2LjYyNy0xLjg4NXYtMi4yOUw1IDM0LjI0MmwtMi4wNDMtLjQzNHptMjUuNTI3IDEyLjA0OWwtNS40OTYgMS44ODlhMTggMTggMCAwIDAgNS40OTYtMS44ODl6IiBjb2xvcj0iIzAwMCIgZmlsbD0iI2ZmZiIgc3R5bGU9Imlzb2xhdGlvbjphdXRvO21peC1ibGVuZC1tb2RlOm5vcm1hbCIvPjwvc3ZnPg==", + "parser-markdown.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAoCAYAAADpE0oSAAAABmJLR0QA/wD/AP+gvaeTAAAB7UlEQVRYw+2VPWhUQRSFv/NcloTFxCRCsBItlNiksrQxtUIKC7W2tFOwEIIoapFCtFFsokmRQmzSJ4WxVgsVF1kkEUkwKqgxPyQem1kYhk3eIiEozIHHm3vveffOvDl3BjIyMjIy/nWoObB9EOiJYuuS3qQf2O4CDgEF4OBuSPoe4gKOAh1RjSVJ87RIVtget70QPXXbZxJep+37Ib4Y3nO2z0ecHtuzSa7xtGYlGu8F+iO7H7hi+4Wk92ElF4CzQFfEWwGqkV0AfVEuA7W0cFGyFYPAiO0acAK4mBT9a1RK4nuAYeA1cBI4vFPiqrTBqQGXw0qL3SwM0FtGsN0dFNwZd0tT2bb3Bf+KpNXtCv8Moqkm/i9AdzJpAbeBA8HuS2IDwBjwG5iyPbbdr/sETAdyPJnHwHq6YKAODAGngP1J/EjwDwIfJLkoOVxuAo1gbwBPgtDUgj8BzGwRE7AK3JU0U9pOkp4BN4AFYBa4lfyBmPsZGAHetQhvAk+Bh+32McAk8AC4Kqlewn0J3AO+Jf63wKikX20XlrQGXJf0vA2ugUdhSzaC+ytwTdKrrdrpB7CYqLeZcDO+PIClSO1rsdgkLdu+AxwHjgUxTu3k7aRo/xqSlhPeOeA0cEnSx127b21Xw6GSkZGRkfGf4g8tS8n281i76AAAAABJRU5ErkJggg==", + "ui_button.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAADNSURBVEiJ7ZQxCsJAEEX/hhSeQhux9gSSe3gBwcIDeDGx9CYWNvYRi8CzMAlr3M0uWYsI+d3A7Pt/ZmClSf8joABK3iqBIhXYQhp4KpC+2qcsyXV0QL7lPhawtC7aKsLAfSzgAhy6aWJT+0YZtM8usIE8JG2GAH0uRWeH26EJXQ1r4A7MfwKsm47AGTAhWCwwqy+/T05nNa9CH4ILGBwpZGyM+WDkoQeSbpIqqz4ZY3Y+wxhgJWlh1bO+5hhgLulq1U9fOilxh939TRqpXsa932DLVhSzAAAAAElFTkSuQmCC", + "ui_chart.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAADVSURBVEiJ7dQva0JhFAdg2bJYZGUywzQNs7DkBxismvwkC8Mug0XrwG6xmtYWjWZZUIMG94ftMWi4iPd6r/c2/cUD5+Ec3sOby51zIkExa1BW1kWKIe5CN0s6IfIY4z0xiKs9tTf84j4RiCI+UQ/UWjZ5ilohDOxsmycoo4IFhrhMBKKEFQaYY4QPTHEdikWAXXzhBg184x+Pkdg+EFX84CVQa+L1IBYC9rDcfWHEu9sgiBr+0I7VHAPsY4ZCVuAtHo7GdsG0OfpzOAhKl+esBzvnpLIGJQw3hKYULUMAAAAASUVORK5CYII=", + "ui_colour_picker.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AwQFgsvH/cw7AAAAhdJREFUSMftlU1LlFEYhq/zopVgMuEipA8IAyGKIpOQIDAiamO0ahFUq/oDBf2Cdi2CCIQg6C9ELVr0SQW5iAxs00bQrEgwGct0Zq42z9jrNOI70Sq6V+frvs9znvN8wH/kobbMSQVEEyBASqk1QZWUEmoHcAIYBHYAs8Bb4BkwllKq1M+u+Tz1gPpUrfo7vqg31Z2FXKIOqJNBrqn31RtquUF4Sj20lliX+ipHeqxuVFGvxQU1dTb2Z9SjjTpZEACGgf7c3hwwH36aDH8/SCmVgJNACbiubmtm3Tr1Sdz8Ta2o8+oF9ZT6Pvauxvn16lysjTQT7Ak/vVOPqGdD8If6PeeGCfWgelFdzD29t1Fwr7qkXqnHnvrS4rgcHLLQ7AIyYEvMS0BHCwkyDLSllGiLhSWgCpxXNwFbgd0tCPaFUTN1Cz8BFaATOANsB8aAWkHBTqBDbcsLfo3xa2AIOAw8LCiYRVgtCy4Ad2M8nlKaSCmVgdHV8gC4DdyLeTleU83/9L5c6PSrfeqLVX51Ud2jHo/5m8i0rDF8RuLAR3U6Uq0ZFtRBtVt9rl6KMrey0tRqtR51vEDcVdWh4Har7XlnLte1LMumgXPAhwIfsSu4MxF2vwRXVNyURoHTwOc1CnM1xylUaHvVW7mczaOsbi7cU3KtoB3YDxwDBoANwBRwB3jUzLJUsPvlXZOllCp/tZX+SXv9h/ET4N13p8IOyEcAAAAASUVORK5CYII=", + "ui_date_picker.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABmJLR0QA/wD/AP+gvaeTAAAATklEQVRIx2NgGAUjFPz///8/uWoYSTEEH2BkZGTEaSOpLsSqnhIXIutloZahNHMhE7VTCBO5gU/QR0PLy8R4cwR6eegmbNrl5VEwCogHAB0Cc6jSgYOVAAAAAElFTkSuQmCC", + "ui_dropdown.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABpSURBVEiJ7ZS7CoAwEARnRfxrK//awrU6CRYhkLNQM9UVy9wjEBh8HwHY3lNk0jJliErShXO2OIQHsHW61qvKeJRwPHbDspMBJCnqGpJUFZaBe7iFsXI/f17Z9loLthIT9n4MWY7BKzgBTP0qup60jeAAAAAASUVORK5CYII=", + "ui_form.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAIpJREFUeNpiYBgFgw4w/v//vwFI/6KSeWwgA/8DGdVUMrCVAWogVQDILBYoo4FahjJRPVao7WVwpDACAbrXgUINxAYFSC3MQEJerifCvHqivUxMcCCrIcbLcDm84YbQ/5++sTzqZaK8zIIm2YAlsTZQMy83EmFGI03zMqz4qhq0VcBorToIAUCAAQDBm54wNDH87wAAAABJRU5ErkJggg==", + "ui_gauge.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAF4SURBVEiJ7ZTPK0RRHMXPnUSywGgshpXxLxgW2LBTytLKgoUiRcmOLJSyY6dEyX8wmq2FhYU1hfzYSGpmYzTNLHws5jtczzM/rOfU69s953vO/d7Xu09qoIG64SqJQJekIUlxo54lXTjnMnXtAiSBUyAPFANPHkgBA1UnBCKStiStmFaQdC7p3lr6JA1LapGEpB1J6865j78mO7ApcsAG0B7S0wFsAu/Wu/9X2II1ZICRGl7LKJA1z3xQjAKvJk5WC/N8U+Z5ATp9YcmEVK1hnjdt3kVJihg/YfWo3kBJh4EMCXiyXeLhnh8TDQLT3rrHvI9+U87I5hoCr4B+b91i3jfp+8hZq9EqYXOS2pxzdx5d9mT9wFuroV+/hbVKWpZ0FpCSVm/8wLTVmQoDbqt0U44DfNmT/mKAbvtIC8BYyHS9wANwDTR5/Lh3GWJB05qJl0DwjqcoIeVxznqLwOqv8wARYA9IhGgx4ASYDfAJYNd+Kg008F98AoTJY9ZKCu6sAAAAAElFTkSuQmCC", + "ui_numeric.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAEUSURBVEiJ7dK9LgRhFIfxsz4SttgaEZ1CJEoNCYVoRUeLRqfYGxCJULoFoZNIFLqtRKFQuQFBrbIivn4K7zKZ3UVCoZinOjln/s97ZuaNKCgoaAa96Go3HMAtNjO9ZVzjAWcYTf0KjvCMe6zlZfO49M5m6g2mwC5mcYVamlXxiDns4QVDWeELdnLCPqw0HkQNF6nuwXCq1/GE/qxwGJ1ZYe4NZlJoI9ffTZlqq2/YUogp1HGKcm42ntlw+lshJnCHE1Qy/RFMprqcclutf/dnqBIRBxHRHRGHEbGAeqlU2o+I5YhYxWJEjKXI+ZcbYkkzN43DcIzXdKW2UfpqwR/h/WJ3/1rUjo8V4S+EHX8hKSj4b7wBmFxhytCBdoQAAAAASUVORK5CYII=", + "ui_slider.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABVSURBVEiJ7dNBCgAgCARA7f9/tlOQIiJpJOEcQ0oXA2j/Q+2QiIgVIap1mhHtSGIvy87cl2wT3O1wKZVhOvcoAL4oem1sTzJMZ4588mN6bbgSGba4CZuaJByfIWmXAAAAAElFTkSuQmCC", + "ui_switch.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAEJSURBVEiJ7ZNNSsNQFEbPc9AKLkAHgh35M3QLLYK0W7HqetyK0q7BUn8GohRxasBWhE6Og6byDHlpx5oDGeTm3pOb5AvU1PwH1E31Qh2rM3WeOGZ5z7naXM6HgmwXuAG2ga2S+30CE2CYn7eBI+AZ6IQQ3oqbPapfiY3e1dOSJ+qqH+pDvCnqpZolZFmZrCCdq/0QFcfAfmLmLoRwnBLm8yMgbES1VkX/sOLakgHQioWuMbSSWDip6Guv4WoDL7HwikUsythTuymT2gMOc8dPsbkiNlmZVO3lsblXG2XBvgZ2SAf7lcUHAOgAB8ATcPIr2JG0ofbVkTqt+PWm6q16pjZSr6Om5k/wDYhUJfwAsEtnAAAAAElFTkSuQmCC", + "ui_template.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAEaSURBVEiJ7dE/L4NRGAXw90W6EKMahEGMoh2wqUVC7d3EZjdZjGb1GXwIk6WpiVHMJEQwNUHjT+JncCXN9bZCGomkZ7r3nPOce3OeJOmhh38I7GC4W2GDeMLkb4b7MrgV3CBt5/lCBONEkiQnKEfSUpIkh2maCtoxxr/7WQl3eMRMpJ1iPZwLaAbvQruwDbzgErORNoo3jLVwBZzjFVtxWNUHahjJeGwNZxl8HvUwW20VGrhvt0HsY6+NNoUHNFrJSujkFqVoIMU1VjPCFkOPTVRisYCLuBNM4xlDkf+z8yvMZf0+7mQ5cJuoRb5y8NSRzwxrMefCkorhfoDtyFPELnIdwzLC+8Ky5n802CFwAEfo70pgDz38Ad4Br8tc7FtGcAwAAAAASUVORK5CYII=", + "ui_text.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAEWSURBVEiJ7ZMhS0RBFEbPyDMYhGXB5poFm2GNJpHFplXQ32AVYYO4xSIGQTAqGhQ0mGwmDQYRZJPYXGQRMYnwjsFZGQyy4YUN75SB716Gc+8wUFJSUgBqU/3qt3+oaIHfC9W62vaHN3UlbVTX1Xv1SB2L2ZR6oT6qp2otNVwGOsAi0Aa2kloG1IAW0AB21BHgEqgAm8A0sJ8aDKtz6rb6pOZqiDvM4wWoB+qLOhOnacS8qmap4TGwBzwD1wAhBJN6iGfvgbI/eQUYTw3f1TN1Ur1V85g3o8muOq921BN1VH1Vr9SFuMeb1HADmAXOgTsgqBNJ/RM4BB6AtRDCB7AEVON0XWCVgae3UFT/a+yXwn9KSckg8A2LRsxzeZWjFAAAAABJRU5ErkJggg==", + "ui_toast.png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAG7AAABuwBHnU4NQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAADeSURBVEiJ7ZKxCQJBFERnRQ5BUCzAxEB7sAB7EGzB6EJzE0OLsAGbMDW0AROTM9E70GcyyiknihiI3MDn/52dP8vuX6lEiT9EkCQg+4pZCFHFdfoFv1SSroYdSRNJW69PbxicnXeSZpK6tx0gBlpABAyBFZABB+d8XLmVtZF747xhBiTAHOiZ6wMLG6SOg7m+NT33JHdzyJ1+dOMSGAABaANTR9vcwJrUPdkzw8drbYAxUAdqwAhYP3sO6fW3wZq966aHUSkShxCi6otJBudGjis0e2vzE/y+YYkSf4kLDcYTu4mDJeQAAAAASUVORK5CYII=", + "ff-logo.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MCIgaGVpZ2h0PSIxMzUiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDkwIDEzNSI+PHNjcmlwdCB4bWxucz0iIi8+CiA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAwNzggMCAwIDEuMDA3OCAtNjcuMDA3IC02NS45MTQpIj4KICA8cGF0aCBkPSJtNzcuNzcgOTIuN2MtMy40OTcgMC02LjMxMSAyLjgxNi02LjMxMSA2LjMxMy0wLjAwNTIgMTEuNTUtMC4wMTMxNiAyMy4xMS0wLjAxNDk5IDM0LjY2IDcuNSAwLjA0NDQgMTUuMDEgMC4yMTcgMjIuNDktMC4zMTgzIDEwLjQzLTAuOTUxMSAxOS42MS02LjU1MiAyOS4yMi0xMC4yMyA4Ljc0Mi0zLjY5NyAxOC4xMy01Ljk0MSAyNy42Ni01Ljc0MWwtMWUtMyAtMTguMzhjNGUtMyAtMy40OTgtMi44MTUtNi4zMTMtNi4zMTEtNi4zMTN6bTczLjA1IDM3LjE5Yy0xLjA1NSAwLjAxNjktMi4xMTEgMC4wNDU3LTMuMTY2IDAuMDg4OS0xMS42NC0wLjA0NDgtMjEuOTYgNS45NzQtMzIuNDYgMTAuMTkgOC4xMTUgMy4yODMgMTUuOTUgNy40NTQgMjQuNTUgOS4zODkgMy42NzUgMC41NTc2IDcuMzcyIDAuODI5MiAxMS4wOCAwLjkwODV6bS03MS4xMyAxNi41N2MtMi43NDcgNmUtMyAtNS40OTQgMC4wMzQ2LTguMjM5IDAuMDQ4NiAwLjAwMjQgNi40MTYgMC4wMDcyIDEyLjgzIDAuMDE3NTcgMTkuMjUgMC4wMDM3IDMuNDk4IDIuODE1IDYuMzEzIDYuMzExIDYuMzEzaDY2LjczYzMuNDk3IDAgNi4zMTEtMi44MTYgNi4zMTEtNi4zMTN2LTIuNzc4Yy04LjIwMy0wLjA1MzctMTYuNC0xLjMwNC0yNC4wNC00LjM3NC0xMS44Mi00LjEyNC0yMi44NS0xMS40NS0zNS42OC0xMS45NC0zLjc5OC0wLjE3OTQtNy42MDItMC4yMTQ3LTExLjQxLTAuMjA2MnoiIGZpbGw9IiNmZmYiLz4KIDwvZz4KPHNjcmlwdCB4bWxucz0iIi8+PC9zdmc+", + } + + const clrByType = { + "base64": _hshClr("#DEBD5C"), + "batch": _hshClr("#E2D96E"), + "catch": _hshClr("#e49191"), + "change": _hshClr("#E2D96E"), + "rbe": _hshClr("#E2D96E"), + "complete": _hshClr("#C0EDC0"), + "comment": _hshClr(), + "csv": _hshClr("#DEBD5C"), + "debug": _hshClr("#87a980"), + "delay": _hshClr("#E6E0F8"), + "exec": _hshClr("darksalmon"), + "feedparse": _hshClr("#C0DEED"), + "file": _hshClr("BurlyWood"), + "file in": _hshClr("BurlyWood"), + "function": _hshClr("#fdd0a2"), + "html": _hshClr("#DEBD5C"), + "http response": _hshClr("rgb(231, 231, 174)"), + "http in": _hshClr("rgb(231, 231, 174)"), + "http request": _hshClr("rgb(231, 231, 174)"), + "inject": _hshClr("#a6bbcf"), + "join": _hshClr("#E2D96E"), + "json": _hshClr("#DEBD5C"), + "junction": _hshClr('rgb(217, 217, 217)'), + "link in": _hshClr("#ddd"), + "link out": _hshClr("#ddd"), + "link call": _hshClr("#ddd"), + "mqtt in": _hshClr("#d8bfd8"), + "mqtt out": _hshClr("#d8bfd8"), + "markdown": _hshClr("#DEBD5C"), + "postgresql": _hshClr("#5b85a7"), + "range": _hshClr("#E2D96E"), + "sort": _hshClr("#E2D96E"), + "split": _hshClr("#E2D96E"), + "subflow": _hshClr("#ddd"), + "switch": _hshClr("#E2D96E"), + "trigger": _hshClr("#E6E0F8"), + "template": _hshClr("rgb(243, 181, 103)"), + + // Dashboard 1.0 + ui_button: _hshClr("rgb(176, 223, 227)"), + ui_list: _hshClr("rgb( 63, 173, 181)"), + ui_svg_graphics: _hshClr("rgb( 63, 173, 181)"), + ui_template: _hshClr("rgb( 63, 173, 181)"), + ui_toast: _hshClr("rgb(119, 198, 204)"), + ui_upload: _hshClr("rgb( 63, 173, 181)"), + ui_audio: _hshClr("rgb(119, 198, 204)"), + ui_chart: _hshClr("rgb(119, 198, 204)"), + ui_gauge: _hshClr("rgb(119, 198, 204)"), + ui_text: _hshClr("rgb(119, 198, 204)"), + ui_date_picker: _hshClr("rgb(176, 223, 227)"), + ui_dropdown: _hshClr("rgb(176, 223, 227)"), + ui_form: _hshClr("rgb(176, 223, 227)"), + ui_numeric: _hshClr("rgb(176, 223, 227)"), + ui_slider: _hshClr("rgb(176, 223, 227)"), + ui_switch: _hshClr("rgb(176, 223, 227)"), + ui_text_input: _hshClr("rgb(176, 223, 227)"), + ui_colour_picker: _hshClr("rgb(176, 223, 227)"), + + // Dashboard 2.0 + "ui-button": _hshClr("rgb(160, 230, 236)"), + "ui-button-group": _hshClr("rgb(160, 230, 236)"), + "ui-chart": _hshClr("rgb(90, 210, 220)"), + "ui-control": _hshClr("rgb(32, 160, 170)"), + "ui-dropdown": _hshClr("rgb(160, 230, 236)"), + "ui-event": _hshClr("rgb(32, 160, 170)"), + "ui-form": _hshClr("rgb(160, 230, 236)"), + "ui-gauge": _hshClr("rgb(90, 210, 220)"), + "ui-markdown": _hshClr("rgb(39, 183, 195)"), + "ui-notification": _hshClr("rgb(90, 210, 220)"), + "ui-radio-group": _hshClr("rgb(160, 230, 236)"), + "ui-slider": _hshClr("rgb(160, 230, 236)"), + "ui-switch": _hshClr("rgb(160, 230, 236)"), + "ui-table": _hshClr("rgb(90, 210, 220)"), + "ui-template": _hshClr("rgb(39, 183, 195)"), + "ui-text": _hshClr("rgb(90, 210, 220)"), + "ui-text-input": _hshClr("rgb(160, 230, 236)"), + + "websocket in": _hshClr("rgb(215, 215, 160)"), + "websocket out": _hshClr("rgb(215, 215, 160)"), + "yaml": _hshClr("#DEBD5C"), + "xml": _hshClr("#DEBD5C"), + + /* private nodes for this instane */ + 'BlogPages': _hshClr("#ddeeff"), + 'BlogDetails': _hshClr("#ddeeff"), + 'BlogPageInfo': _hshClr("#ddeeff"), + 'BlogChanges': _hshClr("#ddeeff"), + 'PubMedium': _hshClr("#ddee44"), + + "Topic": _hshClr('#d0c9f6'), + "Observation": _hshClr('#f4adf3'), + "Question": _hshClr('#e0a4f3'), + "Thought": _hshClr('#cb9cf3'), + "Idea": _hshClr('#88baff'), + "Analogy": _hshClr('#86bfff'), + "Aphorism": _hshClr('#84c3ff'), + "Poesie": _hshClr('#83c7ff'), + "Humour": _hshClr('#81ccff'), + "Treasure": _hshClr('#7fd0ff'), + "Consequence": _hshClr('#f6c1cc'), + "Advantage": _hshClr('#efacbf'), + "Disadvantage": _hshClr('#e796b1'), + "Text": _hshClr('#c8ffb5'), + "Blog-Post": _hshClr('#d0fdc2'), + "Comment": _hshClr('#d9fcce'), + "Code-Base": _hshClr('#e1fbda'), + "Sketch": _hshClr('#e1fbda'), + "Inspiration": _hshClr('#dfdfb6'), + "Quote": _hshClr('#e5e5c0'), + "Definition": _hshClr('#eaebca'), + "Book": _hshClr('#f0f0d4'), + "Author": _hshClr('#f5f6de'), + + 'nnb-input-node': _hshClr('#ffefef'), + 'nnb-layer-node': _hshClr('#ffffef'), + 'nnb-output-node': _hshClr('#efefef'), + 'nnb-backprop': _hshClr('#e3edef'), + 'nnb-trainer': _hshClr('#e5e4ef'), + + 'Seeker': _hshClr('#e5e4ef'), + 'Sink': _hshClr('#e5e4ef'), + 'Screenshot': _hshClr('#e5e4ef'), + 'Orphans': _hshClr('#e5e4ef'), + 'IsMobile': _hshClr('#e5e4ef'), + 'Navigator': _hshClr('#e5e4ef'), + 'DrawSVG': _hshClr('#e5e4ef'), + 'GetFlows': _hshClr('#e5e4ef'), + 'SendFlow': _hshClr('#e5e4ef'), + 'TriggerImport': _hshClr('#e5e4ef'), + + "i2c scan": _hshClr('rgb(227, 82, 83)'), + "i2c in": _hshClr('rgb(227, 82, 83)'), + "i2c out": _hshClr('rgb(227, 82, 83)'), + + 'cronplus': _hshClr('#a6bbcf'), + + 'buffer-maker': _hshClr('#0090d4'), + 'buffer-parser': _hshClr('#0090d4'), + + 'FINS Read': _hshClr('#0090d4'), + 'FINS Write': _hshClr('#0090d4'), + 'FINS Read Multiple': _hshClr('#0090d4'), + 'FINS Transfer': _hshClr('#0090d4'), + 'FINS Fill': _hshClr('#0090d4'), + 'FINS Control': _hshClr('#0090d4'), + + 'MC Read': _hshClr('#0090d4'), + 'MC Write': _hshClr('#0090d4'), + + 'modbus-response': _hshClr('#E9967A'), + 'modbus-read': _hshClr('#E9967A'), + 'modbus-getter': _hshClr('#E9967A'), + 'modbus-write': _hshClr('#E9967A'), + 'modbus-flex-getter': _hshClr('#E9967A'), + 'modbus-flex-write': _hshClr('#E9967A'), + 'modbus-flex-server': _hshClr('#E9967A'), + 'modbus-flex-connector': _hshClr('#E9967A'), + 'modbus-response-filter': _hshClr('#E9967A'), + 'modbus-flex-sequencer': _hshClr('#E9967A'), + 'modbus-flex-fc': _hshClr('#E9967A'), + + 'OpcUa-Item': _hshClr('#3FADB5'), + 'OpcUa-Client': _hshClr('#3FADB5'), + 'OpcUa-Browser': _hshClr('#3FADB5'), + 'OpcUa-Server': _hshClr('#3FADB5'), + 'OpcUa-Event': _hshClr('#3FADB5'), + 'OpcUa-Method': _hshClr('#3FADB5'), + 'OpcUa-Rights': _hshClr('#3FADB5'), + 'OpcUa-Discovery': _hshClr('#3FADB5'), + + 's7 in': _hshClr('#3FADB5'), + 's7 out': _hshClr('#3FADB5'), + 's7 control': _hshClr('#3FADB5'), + + // FlowFuse specific + "project link out": _hshClr('#87D8CF'), + "project link in": _hshClr('#87D8CF'), + "project link call": _hshClr('#87D8CF'), + + "_default": _hshClr(), + } + + // #endregion + + // #region "Helper Functions" + + function getNodeImage (type) { + let imgName = imgByType[type] + if (!imgName) { + if (type.startsWith("subflow:")) { + imgName = imgByType["subflow"] + } else if (type.startsWith("ui-")) { + imgName = imgByType["ui-template"] + } else if (type.startsWith("ui_")) { + imgName = imgByType["ui_template"] + } + } + return imgName ? imageNameToContent[imgName] || "" : "" + } + + function getNodeColor (type) { + let colors = clrByType[type] + if (!colors) { + if (type.startsWith("subflow:")) { + colors = clrByType["subflow"] + } else if (type.startsWith("ui-")) { + colors = clrByType["ui-template"] + } else if (type.startsWith("ui_")) { + colors = clrByType["ui_template"] + } + } + return colors || clrByType["_default"] + } + + function _hshClr (fill, stroke) { + return { + fill: fill || "#ffffff", + stroke: stroke || "rgb(153, 153, 153)" + } + } + + function getDocument() { + if (typeof document !== "undefined") { + return document + } + if (typeof __document !== "undefined") { + return __document + } + + const args = Array.prototype.slice.call(arguments) || [this] + for (const arg of args) { + const argClassName = arg && arg.constructor && arg.constructor.name + if (argClassName === 'HTMLDocument') { + return arg + } else if (argClassName === 'HTMLDivElement' || argClassName === 'HTMLBodyElement' || argClassName === 'HTMLHtmlElement' || argClassName === 'HTMLElement') { + return arg.ownerDocument + } else if (argClassName === 'SVGElement' || argClassName === 'SVGSVGElement') { + return arg.ownerDocument + } + } + // if we reach here, return see if window is available + if (typeof window !== "undefined") { + return window.document + } + // if we reach here, return null + return null + } + + /** + * Create a SVG element with the given type and attributes + * @param {Document} doc - The document to create the SVG element in + * @param {String} type - The type of the SVG element + * @param {Object.} [attributes] - An object with the attributes of the SVG element in the form {attr: value} + * @returns + */ + function createSvgElement(type, attributes) { + const doc = getDocument(this) + const el = doc.createElementNS.apply(doc, ["http://www.w3.org/2000/svg", type]) + if (typeof attributes === "object") { + for (const attr in attributes) { + el.setAttribute(attr, attributes[attr]) + } + } + return el + } + + function clamp (val, min, max, def) { + const isNumber = (typeof val === "number") && !isNaN(val) + if (!isNumber) { + return def || min + } + return Math.min(Math.max(val, min), max) + } + + /** + * + * @param {SVGSVGElement} svg + * @param {HTMLElement} container + */ + function setupZoom(svg, container) { + let originalScale = 1 + const transform = svg.getAttribute('transform'); + if (transform) { + const scale = transform.match(/scale\(([^,]+)\)/); + if (scale) { + originalScale = clamp(parseFloat(scale[1] || 1), 0.25, 3, 1) + } + } + let modifiedScale = originalScale + // get the first child g element of the svg element + const mainSvgGroup = svg.querySelector('g') + mainSvgGroup.setAttribute('transform', `scale(${modifiedScale})`); + container.onwheel = function (e) { + if (e.ctrlKey) { + // zoom in/out + e.preventDefault() + const scale = modifiedScale - (Math.sign(e.deltaY) * 0.075) + modifiedScale = clamp(scale, 0.20, 3, 1) + mainSvgGroup.setAttribute('transform', `scale(${modifiedScale})`) + // recalculate the svg style height and width now that the scale has changed + svg.style.width = `${8000 * modifiedScale}px` + svg.style.height = `${8000 * modifiedScale}px` + } + } + } + + + function createDefaultDivContainer(container) { + const doc = getDocument(container, this) + const div = doc.createElement("div") + div.classList.add("red-ui-workspace-chart") + if (!container) { + return div + } + container.appendChild(div) + return div + } + + /** + * Generates a default SVG element with the required groups: + * + * ```html + * + * + * + * + * + * + * + * ``` + * @param {Document} doc + * @param {HTMLElement} [container] + */ + function createDefaultSVG(container) { + const doc = getDocument(container, this) + const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg") + const svgG = doc.createElementNS("http://www.w3.org/2000/svg", "g") + svgG.setAttribute("class", "outerContainer") + svg.setAttribute("style", "width:8000px; height:8000px;") + svg.appendChild(svgG) + const gs = ['flow_grid', 'flow_group_elements', 'flow_group_select', 'flow_wires', 'flow_nodes'] + for (let g of gs) { + const group = doc.createElementNS("http://www.w3.org/2000/svg", "g") + group.setAttribute("class", g) + svgG.appendChild(group) + } + // otherwise append the svg to the target + container.appendChild(svg) + return svg + } + + function getCSS(scope) { + // normalize the scope(s) + if (scope && (scope.length && typeof scope !== "string" && typeof scope[0] === "string")) { + scope = [...scope].join(" ") + } + scope = scope || ".flow-renderer" + scope = scope.split(" ").map(s => `.${s}`).join(" ").trim() + const css =` + :root { + --red-ui-primary-background: #fff; + --red-ui-secondary-text-color: #333; + --red-ui-view-grid-color: #eee; + --red-ui-view-border: 1px solid #bbbbbb; + --red-ui-node-border: #999; + --red-ui-node-background-default: #eee; + --red-ui-node-icon-background-color-fill: black; + --red-ui-node-icon-background-color-opacity: 0.1; + --red-ui-node-label-color: #333; + --red-ui-node-port-background: #d9d9d9; + --red-ui-link-color: #999; + } + ::-webkit-scrollbar { + width: 12px; + height: 12px; + } + ::-webkit-scrollbar-track { + -webkit-border-radius: 6px; + border-radius: 6px; + } + ::-webkit-scrollbar-thumb { + -webkit-border-radius: 6px; + border-radius: 6px; + -webkit-box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.5); + } + + ${scope} .red-ui-workspace-chart { + box-sizing: border-box; + border: var(--red-ui-view-border); + overflow: scroll; + height: 100%; + width: 100%; + } + ${scope} svg { + position: relative; + width: 100%; + height: 100%; + min-height: 250px; + margin: auto; + display: block; + border-radius: 2px; + } + ${scope} svg { + cursor: default; + } + ${scope} svg .group-text-label { + font-family: Helvetica Neue, Arial, Helvetica, sans-serif; + font-size: 14px; + } + ${scope} svg .node-text-label { + font-family: Helvetica Neue, Arial, Helvetica, sans-serif; + font-size: 14px; + dominant-baseline: middle; + } + ${scope} svg .subflow-node-text-label { + color: rgb(85, 85, 85); + dominant-baseline: middle; + font-family: Helvetica Neue, Arial, Helvetica, sans-serif; + font-size: 10px; + line-height: 20px; + pointer-events: none; + text-anchor: middle; + user-select: none + } + ${scope} svg .subflow-node-text-label-number { + color: rgb(85, 85, 85); + dominant-baseline: middle; + font-family: Helvetica Neue, Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 20px; + pointer-events: none; + text-anchor: middle; + user-select: none + } + + ${scope} svg .node { + fill-opacity: 1; + stroke-width: 1px; + } + ${scope} svg .link { + stroke: #999; + stroke-width: 3; + fill: none; + } + ${scope} svg .link-highlight, .node-highlight { + stroke: rgb(255, 127, 14); + } + ${scope} svg .node-highlight { + stroke-width: 3px; + } + + ${scope} svg .node-disabled { + stroke-dasharray: 8,3; + fill-opacity: 0.5; + } + ${scope} svg .group-highlight { + stroke: rgb(255, 127, 14); + stroke-width: 4px; + fill: rgb(255, 127, 14); + fill-opacity: 0.2; + } + ${scope} svg .link-disabled { + stroke-dasharray: 10,8 !important; + stroke-width: 2 !important; + stroke: rgb(204, 204, 204); + } + ${scope} svg .grid-line { + shape-rendering: crispedges; + stroke: rgb(238, 238, 238); + stroke-width: 1px; + fill: none; + } + ${scope} svg .output-deco { + stroke-width: 2px; + stroke-miterlimit: 4; + } + ${scope} svg .input-deco { + stroke-width: 2px; + stroke-miterlimit: 4; + } + ${scope} svg .flow-render-error { + background-color: rgb(54, 52, 52); + color: rgb(196, 59, 59); + width: 100%; + } + ${scope} svg text { + user-select: none; + } + ${scope} .red-ui-tabs { + display: flex; + } + ${scope} .red-ui-tab { + padding: 6px 12px; + box-sizing: border-box; + display: block; + border: 1px solid #bbbbbb; + border-right: none; + background-color: #f0f0f0; + max-width: 200px; + width: 14%; + overflow: hidden; + white-space: nowrap; + position: relative; + margin-top: -1px; + transition: 0.2s background-color; + user-select: none; + position: relative; + z-index: 1; + top: 1px; + } + ${scope} .red-ui-tab-subflow-icon { + mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAsIDAsIDQwLCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjUgMjUuOTRoN2MuNTggMCAxLS40MiAxLTF2LTJjMC0uNTgtLjQyLTEtMS0xaC03Yy0uNTggMC0xIC40Mi0xIDF2MmMwIC41OC40MiAxIDEgMXptLTE3IDEyaDdjLjU4IDAgMS0uNDIgMS0xdi0yYzAtLjU4LS40Mi0xLTEtMUg4Yy0uNTggMC0xIC40Mi0xIDF2MmMwIC41OC40MiAxIDEgMXptLS40MTYgMTFDNS42MjQgNDguOTQgNCA0Ny4zMTUgNCA0NS4zNTZWMTQuNTIyYzAtMS45NiAxLjYyNS0zLjU4MiAzLjU4NC0zLjU4MmgyNC44MzJjMS45NiAwIDMuNTg0IDEuNjIzIDMuNTg0IDMuNTgydjMwLjgzNGMwIDEuOTYtMS42MjUgMy41ODQtMy41ODQgMy41ODR6TTMyIDM2Ljk0SDE5YzAgMi4xOS0xLjgxIDQtNCA0SDd2NC40MTZjMCAuMzUuMjM1LjU4NC41ODQuNTg0aDI0LjgzMmMuMzUgMCAuNTg0LS4yMzUuNTg0LS41ODR2LTguNDE3em0xLTJ2LTZoLThjLTIuMTkgMC00LTEuODEtNC00aC0xYy00LjMzMy0uMDAyLTguNjY3LjAwNC0xMyAwdjZoOGMyLjE5IDAgNCAxLjgxIDQgNGgxM3ptMC0xNnYtNC40MThjMC0uMzUtLjIzNS0uNTgyLS41ODQtLjU4Mkg3LjU4NGMtLjM1IDAtLjU4NC4yMzMtLjU4NC41ODJ2OC40MTdjNC4zMzMuMDAyIDguNjY3LjAwMSAxMyAuMDAxaDFjMC0yLjE5IDEuODEtNCA0LTRoOHoiIGNvbG9yPSIjMDAwIiBmaWxsPSIjZmZmIi8+PC9zdmc+Cg==); + display: inline-block; + background-color: grey; + margin-left: -8px; + margin-right: 3px; + margin-top: -1px; + margin-bottom: 1px; + opacity: 1; + width: 16px; + height: 18px; + vertical-align: middle; + mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + } + ${scope} .red-ui-tab span { + font-size: 0.875rem; + font-family: Helvetica Neue, Arial, Helvetica, sans-serif; + } + ${scope} .red-ui-tab.active { + background-color: white; + border-bottom-color: white; + font-weight: bold; + } + ${scope} .red-ui-tab:hover { + cursor: pointer; + background-color: white; + } + ${scope} .red-ui-tab:last-child { + border-right: 1px solid #bbbbbb; + } + ` + return css + } + + function addStyle(doc, scope) { + const nameSafeId = styleId + (scope ? '--' + scope.toString() : '').replace(/[^a-z0-9]/gi, '-').toLowerCase() + let style = doc.getElementById(nameSafeId) + if (!style) { + style = doc.createElement('style') + style.id = nameSafeId + style.innerHTML = getCSS(scope) + doc.head.appendChild(style) + } + } + + function resetSVG (svg) { + if (svg) { + // TODO: remove all children from the SCG then rebuild it. + // Additionally, use this in the CreateDefaultSVG function + ['flow_grid', 'flow_group_elements', 'flow_group_select', 'flow_wires', 'flow_nodes'].forEach(groupClass => { + const g = svg.querySelector(`.${groupClass}`) || createSvgElement('g', { class: g }) + while (g.firstChild) { + g.removeChild(g.firstChild) + } + }) + } + } + + // #endregion + + // #region "Node-RED src code parts" + + // SRC: https://github.com/node-red/node-red/blob/29ed5b27925e51185098a7fe3180faa4c8a734d7/packages/node_modules/%40node-red/editor-client/src/js/ui/view.js#L1057-L1179 + function generateLinkPath(origX,origY, destX, destY, sc, hasStatus = false) { + // TODO: global? + const lineCurveScale = 0.75 + const node_height = 30 + const node_width = 100 + + var dy = destY-origY; + var dx = destX-origX; + var delta = Math.sqrt(dy*dy+dx*dx); + var scale = lineCurveScale; + var scaleY = 0; + if (dx*sc > 0) { + if (delta < node_width) { + scale = 0.75-0.75*((node_width-delta)/node_width); + // scale += 2*(Math.min(5*node_width,Math.abs(dx))/(5*node_width)); + // if (Math.abs(dy) < 3*node_height) { + // scaleY = ((dy>0)?0.5:-0.5)*(((3*node_height)-Math.abs(dy))/(3*node_height))*(Math.min(node_width,Math.abs(dx))/(node_width)) ; + // } + } + } else { + scale = 0.4-0.2*(Math.max(0,(node_width-Math.min(Math.abs(dx),Math.abs(dy)))/node_width)); + } + function genCP(cp) { + return ` M ${cp[0]-5} ${cp[1]} h 10 M ${cp[0]} ${cp[1]-5} v 10 ` + } + if (dx*sc > 0) { + let cp = [ + [(origX+sc*(node_width*scale)), (origY+scaleY*node_height)], + [(destX-sc*(scale)*node_width), (destY-scaleY*node_height)] + ] + return `M ${origX} ${origY} C ${cp[0][0]} ${cp[0][1]} ${cp[1][0]} ${cp[1][1]} ${destX} ${destY}` + // + ` ${genCP(cp[0])} ${genCP(cp[1])}` + } else { + let topX, topY, bottomX, bottomY + let cp + let midX = Math.floor(destX-dx/2); + let midY = Math.floor(destY-dy/2); + if (Math.abs(dy) < 10) { + bottomY = Math.max(origY, destY) + (hasStatus?35:25) + let startCurveHeight = bottomY - origY + let endCurveHeight = bottomY - destY + cp = [ + [ origX + sc*15 , origY ], + [ origX + sc*25 , origY + 5 ], + [ origX + sc*25 , origY + startCurveHeight/2 ], + + [ origX + sc*25 , origY + startCurveHeight - 5 ], + [ origX + sc*15 , origY + startCurveHeight ], + [ origX , origY + startCurveHeight ], + + [ destX - sc*15, origY + startCurveHeight ], + [ destX - sc*25, origY + startCurveHeight - 5 ], + [ destX - sc*25, destY + endCurveHeight/2 ], + + [ destX - sc*25, destY + 5 ], + [ destX - sc*15, destY ], + [ destX, destY ], + ] + + return "M "+origX+" "+origY+ + " C "+ + cp[0][0]+" "+cp[0][1]+" "+ + cp[1][0]+" "+cp[1][1]+" "+ + cp[2][0]+" "+cp[2][1]+" "+ + " C " + + cp[3][0]+" "+cp[3][1]+" "+ + cp[4][0]+" "+cp[4][1]+" "+ + cp[5][0]+" "+cp[5][1]+" "+ + " h "+dx+ + " C "+ + cp[6][0]+" "+cp[6][1]+" "+ + cp[7][0]+" "+cp[7][1]+" "+ + cp[8][0]+" "+cp[8][1]+" "+ + " C " + + cp[9][0]+" "+cp[9][1]+" "+ + cp[10][0]+" "+cp[10][1]+" "+ + cp[11][0]+" "+cp[11][1]+" " + // +genCP(cp[0])+genCP(cp[1])+genCP(cp[2])+genCP(cp[3])+genCP(cp[4]) + // +genCP(cp[5])+genCP(cp[6])+genCP(cp[7])+genCP(cp[8])+genCP(cp[9])+genCP(cp[10]) + } else { + var cp_height = node_height/2; + var y1 = (destY + midY)/2 + topX = origX + sc*node_width*scale; + topY = dy>0?Math.min(y1 - dy/2 , origY+cp_height):Math.max(y1 - dy/2 , origY-cp_height); + bottomX = destX - sc*node_width*scale; + bottomY = dy>0?Math.max(y1, destY-cp_height):Math.min(y1, destY+cp_height); + var x1 = (origX+topX)/2; + var scy = dy>0?1:-1; + cp = [ + // Orig -> Top + [x1,origY], + [topX,dy>0?Math.max(origY, topY-cp_height):Math.min(origY, topY+cp_height)], + // Top -> Mid + // [Mirror previous cp] + [x1,dy>0?Math.min(midY, topY+cp_height):Math.max(midY, topY-cp_height)], + // Mid -> Bottom + // [Mirror previous cp] + [bottomX,dy>0?Math.max(midY, bottomY-cp_height):Math.min(midY, bottomY+cp_height)], + // Bottom -> Dest + // [Mirror previous cp] + [(destX+bottomX)/2,destY] + ]; + if (cp[2][1] === topY+scy*cp_height) { + if (Math.abs(dy) < cp_height*10) { + cp[1][1] = topY-scy*cp_height/2; + cp[3][1] = bottomY-scy*cp_height/2; + } + cp[2][0] = topX; + } + return "M "+origX+" "+origY+ + " C "+ + cp[0][0]+" "+cp[0][1]+" "+ + cp[1][0]+" "+cp[1][1]+" "+ + topX+" "+topY+ + " S "+ + cp[2][0]+" "+cp[2][1]+" "+ + midX+" "+midY+ + " S "+ + cp[3][0]+" "+cp[3][1]+" "+ + bottomX+" "+bottomY+ + " S "+ + cp[4][0]+" "+cp[4][1]+" "+ + destX+" "+destY + + // +genCP(cp[0])+genCP(cp[1])+genCP(cp[2])+genCP(cp[3])+genCP(cp[4]) + } + } + } + + // SRC: https://github.com/node-red/node-red/blob/29ed5b27925e51185098a7fe3180faa4c8a734d7/packages/node_modules/%40node-red/editor-client/src/js/ui/view.js#L3009C1-L3030C6 + function convertLineBreakCharacter(str) { + var result = []; + var lines = str.split(/\\n /); + if (lines.length > 1) { + var i=0; + for (i=0;i { + return (obj.name || obj.label || obj.info || obj.text || subflowObj.name || obj.type) + }; + + var emptyLabelFunct = (obj, subflowObj, flowdata) => { + return "" + }; + + var linkCallLabelFunct = (obj, subflowObj, flowdata) => { + if (!obj.links || obj.links.length == 0) { return obj.name || obj.type } + + var lbl = undefined; + + flowdata.forEach(function (nde) { + if (lbl) return; + + if (nde.id == obj.links[0]) { + lbl = (labelByFunct[nde.type] || defaultLabelFunct)(nde, subflowObj, flowdata); + } + }); + + /* remember: link calls can reference nodes outside of the current flow, to solve that: given them names */ + return obj.name || lbl || obj.type; + }; + + var catchLabelFunct = (obj, subflowObj, flowdata) => { + var sublabel = ""; + + if (obj.uncaught) { sublabel = ": uncaught" } + if (obj.scope) { sublabel = ": " + obj.scope.length } + if (!obj.scope && !obj.uncaught) { sublabel = ": all" } + + return obj.name || (obj.type + sublabel) + }; + + var mindMapNodeLabelFunct = (obj, subflowObj, flowdata) => { + return (obj.name || obj.label || obj.info || obj.text || "").replace(/(.{40,60})([ \n\t])/g, "$1\\n$2") + + (obj.sumPass ? " ⭄" : "") + + (obj.sumPassPrio && parseInt(obj.sumPassPrio) != 0 ? " (" + obj.sumPassPrio + ")" : ""); + }; + + var blogPageInfoLabel = (obj, subflowObj, flowdata) => { + if (obj.name) { return obj.name } + + var lbl = undefined; + flowdata.forEach(function (nde) { + if (lbl) return; + + if (nde.type == "link in" && nde.name.startsWith("[blog] ") && (nde.wires[0] || []).indexOf(obj.id) > -1) { + lbl = nde.name.substring(7) + } + }); + + return lbl || obj.type; + }; + + var labelByFunct = { + "base64": undefined, + "batch": undefined, + "catch": catchLabelFunct, + "change": undefined, + "comment": undefined, + "csv": undefined, + "debug": undefined, + "exec": undefined, + "file": undefined, + "file in": undefined, + "function": undefined, + "html": undefined, + "http response": (obj, subflowObj, flowdata) => { return (obj.name || ("http" + (obj.statusCode ? " (" + obj.statusCode + ")" : ""))) }, + "http in": (obj, subflowObj, flowdata) => { return (obj.name || ("[" + obj.method + "] " + obj.url)) }, + "http request": undefined, + "inject": undefined, + "join": undefined, + "json": undefined, + "junction": undefined, + "link in": undefined, + "link out": undefined, + "link call": linkCallLabelFunct, + "markdown": undefined, + "postgresql": undefined, + "range": undefined, + "sort": undefined, + "split": undefined, + "switch": undefined, + ui_button: undefined, + ui_list: undefined, + ui_svg_graphics: undefined, + ui_template: undefined, + ui_toast: undefined, + ui_upload: undefined, + "yaml": undefined, + "xml": undefined, + + /* private nodes for this instane */ + 'BlogPages': undefined, + 'BlogDetails': undefined, + 'BlogPageInfo': blogPageInfoLabel, + 'PubMedium': undefined, + + "Topic": mindMapNodeLabelFunct, + "Observation": mindMapNodeLabelFunct, + "Question": mindMapNodeLabelFunct, + "Thought": mindMapNodeLabelFunct, + "Idea": mindMapNodeLabelFunct, + "Analogy": mindMapNodeLabelFunct, + "Aphorism": mindMapNodeLabelFunct, + "Poesie": mindMapNodeLabelFunct, + "Humour": mindMapNodeLabelFunct, + "Treasure": mindMapNodeLabelFunct, + "Consequence": mindMapNodeLabelFunct, + "Advantage": mindMapNodeLabelFunct, + "Disadvantage": mindMapNodeLabelFunct, + "Text": mindMapNodeLabelFunct, + "Blog-Post": mindMapNodeLabelFunct, + "Comment": mindMapNodeLabelFunct, + "Codebase": mindMapNodeLabelFunct, + "Sketch": mindMapNodeLabelFunct, + "Inspiration": mindMapNodeLabelFunct, + "Quote": mindMapNodeLabelFunct, + "Definition": mindMapNodeLabelFunct, + "Book": mindMapNodeLabelFunct, + "Author": mindMapNodeLabelFunct, + + 'nnb-input-node': undefined, + 'nnb-layer-node': (obj, _sub, _flow) => { return (obj.name || (obj.actfunct + ": " + obj.bias + ", " + obj.threshold)) }, + 'nnb-output-node': undefined, + 'nnb-backprop': undefined, + 'nnb-trainer': undefined, + + 'Seeker': undefined, + 'Sink': undefined, + 'Screenshot': undefined, + 'Orphans': undefined, + 'IsMobile': undefined, + 'Navigator': undefined, + 'DrawSVG': undefined, + 'GetFlows': undefined, + + "_default": defaultLabelFunct, + }; + + // #endregion + + // #region "Flow Rendering functions" + + /** + * Get an array of tabs from the flow array. + * @param {Array} flow - Node-RED flow array + * @returns {Array<{id: string, label: string, type: string}>} - Array of tabs + */ + function getFlowTabs(flow) { + const tabs = {} + let sfIndex = 10000 + let fIndex = 0 + for (node of flow) { + if (node.type === 'subflow') { + tabs[node.id] = { + id: node.id, + label: node.name, + type: 'subflow', + order: sfIndex++, + } + } else if (node.type !== "tab" && node.z) { + if (!tabs[node.z]) { + tabs[node.z] = { + id: node.z, + label: 'Flow ' + (fIndex + 1), + type: 'tab', + order: fIndex++, + } + } + } + } + + // if the flow contains real tabs, then update the tabs objects + const flowTabs = flow.filter((d) => d.type === 'tab') + let index = 0 + for (node of flowTabs) { + // Only update if tabs[d.id] exists (i.e. there are nodes to show) + if (tabs[node.id]) { + tabs[node.id].label = node.label || tabs[node.id].label + tabs[node.id].order = index++ + } + } + + // convert tabs to an array and sort by order ascending + const tabsArray = Object.keys(tabs).map((key) => tabs[key]) + tabsArray.sort((a, b) => a.order - b.order) + + return tabsArray + } + + function normaliseFlow(flow) { + if (!Array.isArray(flow)) { + flow = [] + } + for (let i = 0; i < flow.length; i++) { + if (!flow[i].id || typeof flow[i].id !== 'string') { + flow[i].id = Math.random().toString(36).substring(2, 14) + } + } + return flow + } + + function renderFlows(flows, renderOpts) { + renderOpts = renderOpts || {} + flows = normaliseFlow(flows) + + // SETUP + /** @type {Document} */ + const doc = renderOpts.document || this.document + /** @type {HTMLElement} */ + const container = renderOpts.container + + // remove all elements from the container + while (container.firstChild) { + container.removeChild(container.firstChild) + } + + // setup the tabs container + const tabContainer = doc.createElement('div') + tabContainer.classList.add('red-ui-tabs') + container.appendChild(tabContainer) + + // setup the scrollable div container for the svg element + /** @type {HTMLDivElement} */ + const div = createDefaultDivContainer(container) + + // generate the SVG element + /** @type {SVGSVGElement} */ + const svg = createDefaultSVG(div) + resetSVG(svg) + + if (!renderOpts.scope) { + const containerClasses = container.classList + if (containerClasses && containerClasses.length) { + renderOpts.scope = containerClasses + } else { + renderOpts.scope = 'flow-renderer' + container.classList.add(renderOpts.scope) + } + } else { + container.classList.add(renderOpts.scope) + } + + // tab helper functions + const openTab = function (id) { + const tabs = tabContainer.querySelectorAll('.red-ui-tab') + tabs.forEach(tab => tab.classList.remove('active')) + // get the tab element with the data-flow-id attribute equal to the id + const selectedTab = tabContainer.querySelector(`[data-flow-id="${id}"]`) + selectedTab.classList.add('active') + renderOpts.flowId = id + renderFlow(flows, renderOpts) + } + const addTab = function (tab, index) { + const thisId = `red-ui-tab-${index}` + // const name = tab.type === 'tab' ? 'Flow ' + (index + 1) : tab.label + const name = tab.label + let tabEl = container.querySelector(`.${thisId}`) + if (!tabEl) { + tabEl = doc.createElement('div') + tabContainer.appendChild(tabEl) + tabEl.classList.add('red-ui-tab') + tabEl.classList.add('red-ui-tab-' + tab.type) + // add the as a data attribute + tabEl.setAttribute('data-flow-id', tab.id) + } + if (tab.type === 'subflow') { + // + const img = doc.createElement('i') + img.classList.add('red-ui-tab-subflow-icon') + tabEl.appendChild(img) + } + tabEl.title = name + const nameSpan = doc.createElement('span') + nameSpan.textContent = name + tabEl.appendChild(nameSpan) + tabEl.onclick = function (event) { + openTab(tab.id) + } + } + + // add styles + addStyle(doc, renderOpts.scope) + + // delete any existing tabs + let tabEls = container.querySelectorAll('.red-ui-tab') + if (tabEls) { + tabEls.forEach(tab => tab.remove()) + } + + // create tabs for each flow + const tabs = getFlowTabs(flows) + tabs.forEach((tab, index) => { + addTab(tab, index, renderOpts) + }) + + // use renderOpts.flowId (or find a suitable first tab) + if (!renderOpts.flowId) { + // find first tab with .type === 'tab' + const firstTab = tabs.find(tab => tab.type === 'tab') + const firstSubflow = tabs.find(tab => tab.type === 'subflow') + renderOpts.flowId = firstTab ? firstTab.id : firstSubflow ? firstSubflow.id : null + } + if (tabs.length) { + // select the tab (causes the flow to be rendered) + openTab(renderOpts.flowId, renderOpts) + } else { + // no tabs to render + renderFlow(flows, renderOpts) + } + + if (renderOpts.zoom) { + setupZoom(svg, renderOpts.container) + } + + const result = { + svg: svg.innerHTML, + tabs: tabs, + flowId: renderOpts.flowId, + css: getCSS(renderOpts.scope) + } + + return result + } + + function renderFlow(flow, renderOpts) { + flow = normaliseFlow(flow) + /** @type {Document} */ + const doc = renderOpts.document || this.document + const container = renderOpts.container + renderOpts = renderOpts || {} + const nodes = {} + const subflows = {} + const flow_group_select = {} + const subFlowInsOutsStatusNodes = {} + const nodeIdsThatReceiveInput = {} + const flowId = renderOpts.flowId + + /** @type {SVGSVGElement} */ + let svg = container && container.querySelector('svg') + svg = svg || createDefaultSVG(container) + resetSVG(svg) + + /** @type {SVGGElement} */ + const flow_gridEl = svg.querySelector('.flow_grid'); + if (renderOpts.gridlines && flow_gridEl) { + for (var idx = 0; idx < 250; idx++) { + flow_gridEl.append(createSvgElement('line', { + x1: 0, + x2: 8000, + y1: 20 * idx, + y2: 20 * idx, + class: 'grid-line' + })); + } + + for (var idx = 0; idx < 250; idx++) { + flow_gridEl.append(createSvgElement('line', { + x1: 20 * idx, + x2: 20 * idx, + y1: 0, + y2: 8000, + class: 'grid-line' + })); + } + } + + /* this is used to define which nodes get input decoration, this is not clear from the json data so + * we make a guessimate which nodes have inputs by the wiring within the flow + */ + flow.forEach(function (obj) { + if (obj.type == "subflow") { + /* prefix subflow since this is the type of the node that uses this subflow - makes lookup simpler */ + subflows["subflow:" + obj.id] = obj; + for (var idx = 0; idx < obj.in.length; idx++) { + for (var wdx = 0; wdx < obj.in[idx].wires.length; wdx++) { + nodeIdsThatReceiveInput[obj.in[idx].wires[wdx].id] = true + } + } + } + + if (obj.wires && obj.wires.length > 0) { + obj.wires.forEach(function (aryWires) { + aryWires.forEach(function (ndeId) { nodeIdsThatReceiveInput[ndeId] = true }) + }) + } + }) + + /* + * Rendering nodes. + */ + + /** @type {SVGGElement} */ + const flow_nodesEl = svg.querySelector('.flow_nodes') + + /* + * Important lesson: never assume that (x,y) means top-left corner ... in the case of the flows.json, (x,y) is the midpoint of the node. + * For the sack of sanity, compute the top-left corner (x,y) and add it to the node. We also add the bounding-box so we have the width. + */ + flow.forEach(function (obj) { + if (obj.z == flowId || obj.id == flowId /* this is a subflow or tab */) { + + // get node dimensions + var dimensions = widthHeightByType[obj.type] || widthHeightByType["_default"]; + // get node color + var clr = getNodeColor(obj.type); + + switch (obj.type) { + + case "tab": + case "ui_spacer": + case "ui-spacer": + // ignore tabs and spacers + break; + case "group": + /* groups are handled later since we need all bounding boxes for all nodes contained in a group */ + flow_group_select[obj.id] = obj; + break; + + case "subflow": { + /* the type of the node that represents a subflow is subflow:XXXX while the subflow has type 'subflow'. So this occurs if + the flowId is that of a subflow. The obj.id == flowId and its type is 'subflow' and here we are. + */ + subFlowInsOutsStatusNodes[obj.id] = { ...obj }; + + /* input connectors */ + for (var idx = 0; idx < subFlowInsOutsStatusNodes[obj.id].in.length; idx++) { + var inObj = subFlowInsOutsStatusNodes[obj.id].in[idx]; + var grpId = "grp" + Math.random().toString().substring(2); + + const grpObj = createSvgElement('g', { id: grpId }); + flow_nodesEl.appendChild(grpObj); + + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...dimensions, + rx: 8, + ry: 8, + x: -dimensions.width / 2, + y: -dimensions.height / 2, + "stroke-width": 1, + })) + + grpObj.setAttribute("transform", `translate(${inObj.x}, ${inObj.y})`) + inObj.bbox = grpObj.getBBox(); + inObj.bbox.x = inObj.x - dimensions.width / 2 + inObj.bbox.y = inObj.y - dimensions.height / 2 + + /* add output decoration after computing the bounding box - the decoration extends the bounding box */ + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...portDimensions, + ...portRadius, + class: "output-deco", + transform: "translate(15, -5)" + })) + + const textElem = createSvgElement('text', { + y: 0, + x: -2, + class: 'subflow-node-text-label' + }); + textElem.textContent = "input"; + grpObj.appendChild(textElem); + } + + /* output connectors */ + for (var idx = 0; idx < subFlowInsOutsStatusNodes[obj.id].out.length; idx++) { + const outObj = subFlowInsOutsStatusNodes[obj.id].out[idx]; + const grpId = "grp" + Math.random().toString().substring(2); + const grpObj = createSvgElement('g', { id: grpId, }) + flow_nodesEl.appendChild(grpObj); + + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...dimensions, + rx: 8, + ry: 8, + x: -dimensions.width / 2, + y: -dimensions.height / 2, + "stroke-width": 1, + })) + + grpObj.setAttribute("transform", `translate(${outObj.x}, ${outObj.y})`) + outObj.bbox = grpObj.getBBox() + outObj.bbox.x = outObj.x - dimensions.width / 2 + outObj.bbox.y = outObj.y - dimensions.height / 2 + + /* add output decoration after computing the bounding box - the decoration extends the bounding box */ + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...portDimensions, + ...portRadius, + class: "input-deco", + transform: "translate(-25, -5)", + })) + + /* text that goes "output\n(idx+1)\n" i.e. two lines */ + const textElem1 = createSvgElement('text', { + y: -10, + x: 0, + class: 'subflow-node-text-label' + }) + textElem1.textContent = "output" + grpObj.appendChild(textElem1) + + const textElem2 = createSvgElement('text', { + y: 8, + x: 0, + class: 'subflow-node-text-label-number' + }) + textElem2.textContent = "" + (idx + 1) + grpObj.appendChild(textElem2) + } + + /* status connectors */ + if (subFlowInsOutsStatusNodes[obj.id].status) { + const outObj = subFlowInsOutsStatusNodes[obj.id].status; + const grpId = "grp" + Math.random().toString().substring(2); + const grpObj = createSvgElement('g', { id: grpId, }) + flow_nodesEl.appendChild(grpObj); + + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...dimensions, + rx: 8, + ry: 8, + x: -dimensions.width / 2, + y: -dimensions.height / 2, + "stroke-width": 1, + })) + + grpObj.setAttribute("transform", `translate(${outObj.x}, ${outObj.y})`) + outObj.bbox = grpObj.getBBox() + outObj.bbox.x = outObj.x - dimensions.width / 2 + outObj.bbox.y = outObj.y - dimensions.height / 2 + + /* add output decoration after computing the bounding box - the decoration extends the bounding box */ + grpObj.appendChild(createSvgElement('path', { + ...clr, + ...portDimensions, + ...portRadius, + class: "input-deco", + transform: "translate(-25, -5)" + })) + + const textElem = createSvgElement('text', { + y: 0, + x: 2, + class: 'subflow-node-text-label' + }); + textElem.textContent = "status"; + grpObj.appendChild(textElem) + } + + break; + } + case "junction": { + const grpId = "grp" + Math.random().toString().substring(2) + const grpObj = createSvgElement('g', { id: grpId, }) + flow_nodesEl.appendChild(grpObj) + + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...dimensions, + rx: 3, + ry: 3, + x: -5, + y: -5, + "stroke-width": 1, + })) + + grpObj.setAttribute("transform", `translate(${obj.x}, ${obj.y})`) + + obj.bbox = grpObj.getBBox() + obj.bbox.x = obj.x + obj.bbox.y = obj.y + obj.bbox.width = 0 + obj.bbox.height = 0 + + break + } + case "link in": + case "link out": { + const grpId = "grp" + Math.random().toString().substring(2) + const grpObj = createSvgElement('g', { id: grpId, }) + flow_nodesEl.appendChild(grpObj) + + grpObj.prepend(createSvgElement('rect', { + ...clr, + ...dimensions, + rx: 5, + ry: 5, + fill: obj.color || clr.fill, + class: "node " + (" node-" + obj.id) + })) + + grpObj.setAttribute("transform", `translate(${obj.x - dimensions.width / 2}, ${obj.y - dimensions.height / 2})`) + if (obj.d) { + grpObj.setAttribute("class", "node-disabled") + } + obj.bbox = grpObj.getBBox() + obj.bbox.x = obj.x - dimensions.width / 2 + obj.bbox.y = obj.y - dimensions.height / 2 + + /* add image to node */ + if (renderOpts.images) { + grpObj.appendChild(createSvgElement('image', { + "href": getNodeImage(obj.type + (obj.mode || "")), + x: (obj.type == "link in" ? 0 : (obj.mode == "return" ? 1 : 2)), + y: 0, + width: 30, + height: 30 + })) + } + + const transAndPath = { + transform: (obj.type == "link in" ? "translate(25,10)" : "translate(-4,10)"), + d: "M 0.5,9.5 9.5,9.5 9.5,0.5 0.5,0.5 Z", + } + + if (renderOpts.arrows && (obj.type == "link out")) { + transAndPath = { + transform: (obj.type == "link in" ? "translate(27,10)" : "translate(-3,10)"), + d: "M 0,10 9,5 0,0 Z", + } + } + + /* add output decoration after computing the bounding box - the decoration extends the bounding box */ + grpObj.appendChild(createSvgElement('rect', { + ...clr, + ...portDimensions, + ...portRadius, + class: (obj.type == "link in" ? "output-deco" : ("input-deco" + (renderOpts.arrows ? " input-arrows" : ""))), + "stroke-linecap": "round", + "stroke-linejoin": "round", + ...transAndPath + })) + + break + } + default: { + /* the type of the node that represents a subflow is subflow:XXXX while the subflow has type 'subflow' */ + const grpTextId = "grpTxt" + Math.random().toString().substring(2) + const lblFunct = renderOpts.labels ? (labelByFunct[obj.type] || labelByFunct["_default"]) : emptyLabelFunct + const subflowObj = subflows[obj.type] || {} + const textLabels = getLabelParts(lblFunct(obj, subflowObj, flow), "node-text-label") + + const grpText = createSvgElement('g', { id: grpTextId, "transform": "translate(38," + (textLabels.lines.length > 1 ? 18 : 16) + ")" }) + + var ypos = 0 + textLabels.lines.forEach(function (lne) { + var textElem = createSvgElement('text', { + y: ypos, + class: 'node-text-label' + }) + textElem.textContent = lne + grpText.appendChild(textElem) + ypos += 20 + }) + + const grpId = "grp" + Math.random().toString().substring(2) + const grpObj = createSvgElement('g', { id: grpId, }) + flow_nodesEl.appendChild(grpObj) + + grpObj.appendChild(grpText) + + const txtBBox = grpText.getBBox() + const txtWidth = txtBBox.width + 60 + const txtHeight = txtBBox.height + 13.5 + const rectWidth = (dimensions.width > txtWidth ? dimensions.width : txtWidth) + let rectHeight = (dimensions.height > txtHeight ? dimensions.height : txtHeight) + + if ((obj.wires || []).length > 2) { + /* if more than 2 outputs, the node "grows" but the node might already be bigger enough. The base + height of 30 supports two outputs, everything else requires growth in height. */ + rectHeight = Math.max(rectHeight, 15 * obj.wires.length) + + /* move the text block into the middle */ + if (rectHeight > txtHeight) { + const offsetHeight = (rectHeight - txtHeight) / 2 + grpText.setAttributeNS(null, "transform", "translate(38," + ((textLabels.lines.length > 1 ? 16 : 14) + offsetHeight) + ")") + } + } + + grpObj.prepend(createSvgElement('rect', { + ...clr, + rx: 5, + ry: 5, + fill: subflowObj.color || clr.fill, + width: rectWidth, + height: rectHeight, + class: "node " + (" node-" + obj.id) + })) + + grpObj.appendChild(createSvgElement('path', { + d: "M5 0 h25 v" + rectHeight + " h-25 a 5 5 0 0 1 -5 -5 v-" + (rectHeight - 10) + " a 5 5 0 0 1 5 -5", + fill: "rgb(0,0,0)", + "fill-opacity": 0.1, + "stroke": "none" + })) + + grpObj.appendChild(createSvgElement('path', { + d: "M 29.5 0.5 l 0 " + (rectHeight - 1), + fill: "none", + stroke: "rgb(0,0,0)", + "stroke-opacity": 0.1, + "stroke-width": "1px" + })) + + grpObj.setAttribute("transform", `translate(${obj.x - rectWidth / 2}, ${obj.y - rectHeight / 2})`) + obj.bbox = grpObj.getBBox() + obj.bbox.x = obj.x - rectWidth / 2 + obj.bbox.y = obj.y - rectHeight / 2 + + /* Add image - if requested - by type - some types have no image */ + if (renderOpts.images) { + const imgBaseOpts = { + x: 1, + y: Math.max(rectHeight / 2 - 15, 1), + width: 30, + height: 30 + } + const img = getNodeImage(obj.type) + if (img) { + grpObj.appendChild(createSvgElement('image', { + "href": img, + ...imgBaseOpts + })) + } else { + if (obj.type.startsWith("subflow:")) { + const hrefContent = (subflowObj.icon && + imageNameToContent[subflowObj.icon]) || imageNameToContent["subflow.svg"] + + grpObj.appendChild(createSvgElement('image', { "href": hrefContent, ...imgBaseOpts })) + } + } + } + + /* add output decoration after computing the bounding box - the decoration extends the bounding box otherwise */ + if ((subflowObj.in && subflowObj.in.length > 0) || nodeIdsThatReceiveInput[obj.id]) { + if (renderOpts.arrows) { + grpObj.appendChild(createSvgElement('path', { + ...clrByType["junction"], + transform: "translate(-3," + ((obj.bbox.height / 2) - 5) + ")", + d: (renderOpts.arrows ? "M 0,10 9,5 0,0 Z" : "M -1,9.5 8,9.5 8,0.5 -1,0.5 Z"), + class: "input-deco input-arrows", + "stroke-linecap": "round", + "stroke-linejoin": "round", + })) + } else { + grpObj.appendChild(createSvgElement('rect', { + ...clrByType["junction"], + transform: "translate(-5," + ((obj.bbox.height / 2) - 5) + ")", + ...portDimensions, + ...portRadius, + class: "input-deco" + })) + } + } + + const outDecoBaseAttrs = { + ...clrByType["junction"], + ...portDimensions, + ...portRadius, + class: "output-deco" + } + if (obj.wires && Array.isArray(obj.wires)) { + const initFactor = (obj.wires.length == 1 ? ((obj.bbox.height / 2) - 5) : ((obj.wires.length % 2 == 0) ? 5 : 8)) + for (let idx = 0; idx < obj.wires.length; idx++) { + grpObj.appendChild(createSvgElement('rect', { + transform: "translate(" + (obj.bbox.width - 4) + "," + (initFactor + (13 * idx)) + ")", + ...outDecoBaseAttrs + })) + } + } + if (obj.d) { + grpObj.setAttribute('class', 'node-disabled') + } + + break + } + }; + /* since the obj is altered, from here on end, we will be using the altered version of the node */ + nodes[obj.id] = obj + } + }); + + /* + * Rendering groups. + * + * since groups can contain other groups, we have to loop through this until all groups have + * been placed + */ + + const flow_group_selectEl = svg.querySelector('.flow_group_select') + + var doneGroups = [] + var todoGroups = [] + var biscuitBreaker = 50 /* absolute maximum number of enclosure levels */ + + for (var grpId in flow_group_select) { + todoGroups.push(grpId) + } + + // There is also another way of doing this and that is to check for the 'g' attribute on a group + // object. The 'g' attribute is set if the group is contained within another group and the 'g' is the + // id of the other group - but moving up this chain, one gets to the very top group and knows which + // groups need to be drawn to have the size of the top level group. But that is the same as this + // except all groups are drawn, i.e., if there are two groups within one group, going from the first + // group up the tree won't give us the second group contained within the enclosing group. + while (doneGroups.length != todoGroups.length && biscuitBreaker > 0) { + biscuitBreaker -= 1 + + for (var grpId in flow_group_select) { + if (doneGroups.indexOf(grpId) > -1) { continue } + + var grpObj = flow_group_select[grpId] + + /* create a very back-of-a-paper-napkin estimate of the height and width (laziness) + * Could use something like d3.js for doing this automagically, but limit the number + * of dependencies for this code - ideally no jQuery either. + ***/ + + var width = 0, height = 0, oneWasMissing = false + + grpObj.nodes.forEach(function (ndeId) { + var bbox = (nodes[ndeId] || {}).bbox + if (bbox) { + width = Math.max(width, (bbox.x - grpObj.x) + bbox.width) + height = Math.max(height, (bbox.y - grpObj.y) + bbox.height) + } else { + oneWasMissing = true + } + }) + + // Handle groups of groups of groups - i.e. if a group has been defined yet (i.e. has no + // bounding box) then continue with the next group. Each group is only created if all its + // nodes that it contains have been defined. + if (oneWasMissing) { continue } + + var grpRectId = "grpRectId" + Math.random().toString().substring(2) + var grpSvgObj = createSvgElement('g', { + id: grpRectId, + }) + + grpSvgObj.setAttribute("transform", `translate(${grpObj.x}, ${grpObj.y})`) + + grpSvgObj.appendChild(createSvgElement('rect', { + rx: 5, + ry: 5, + width: grpObj.w, + height: grpObj.h, + fill: "none", + "fill-opacity": 0, + "stroke-width": 2, + stroke: "grey", + class: "group-" + grpObj.id, + ...grpObj.style, + })) + + flow_group_selectEl.prepend(grpSvgObj) + + var obj = nodes[grpId] + obj.bbox = grpSvgObj.getBBox() + obj.bbox.x = grpObj.x + obj.bbox.y = grpObj.y + + var labelGrp = createSvgElement('g', {}) + grpSvgObj.appendChild(labelGrp) + + /* this is taken from + * https://github.com/node-red/node-red/blob/7e9042e9f713eec981adeb8ff6af226a40efb5af/packages/node_modules/%40node-red/editor-client/src/js/ui/view.js#L5555 + */ + if (grpObj.style.label && grpObj.name) { + var labelPos = grpObj.style["label-position"] || "nw" + var labels = getLabelParts(grpObj.name, "group-text-label") + + var labelX = 0 + var labelY = 0 + var labelAnchor = "start" + + if (labelPos[0] === 'n') { + labelY = 0 + 15 // Allow for font-height + } else { + labelY = obj.bbox.height - 5 - (labels.lines.length - 1) * 16 + } + if (labelPos[1] === 'w') { + labelX = 5 + labelAnchor = "start" + } else if (labelPos[1] === 'e') { + labelX = obj.bbox.width - 5 + labelAnchor = "end" + } else { + labelX = obj.bbox.width / 2 + labelAnchor = "middle" + } + + labelGrp.setAttribute("transform", `translate(${labelX}, ${labelY})`) + labelGrp.setAttribute("text-anchor", labelAnchor) + if (labels) { + let ypos = 0 + labels.lines.forEach(function (name) { + const tspan = createSvgElement("text", { + class: "group-text-label", + x: 0, + y: ypos, + fill: grpObj.style.color || 'grey', + }) + tspan.textContent = name + labelGrp.appendChild(tspan) + ypos += 16 + }) + } + } + + doneGroups.push(grpId) + } + } + + /* + * Rendering the wires between nodes + */ + const flow_wiresEl = svg.querySelector('.flow_wires') + + var linkOutNodes = [] + + /* rendering subflow? then the subflow hash will be filled */ + var subFlowIds = Object.keys(subFlowInsOutsStatusNodes); + for (var idx = 0; idx < subFlowIds.length; idx++) { + var sfObj = subFlowInsOutsStatusNodes[subFlowIds[idx]]; + + for (var jdx = 0; jdx < sfObj.in.length; jdx++) { + var inObj = sfObj.in[jdx]; + + for (var wdx = 0; wdx < inObj.wires.length; wdx++) { + var otherNode = nodes[inObj.wires[wdx].id]; + + var startX = inObj.bbox.x + inObj.bbox.width; + var startY = inObj.bbox.y + inObj.bbox.height / 2; + var endX = otherNode.bbox.x; + var endY = otherNode.bbox.y + otherNode.bbox.height / 2; + + flow_wiresEl.appendChild(createSvgElement('path', { + d: generateLinkPath(startX, startY, endX, endY, 1), + class: "link " + (otherNode.d ? "link-disabled" : "") + (" link-from-" + sfObj.id + "-to-" + otherNode.id) + })); + } + } + + if (sfObj.status) { sfObj.out.push(sfObj.status) } + for (var jdx = 0; jdx < sfObj.out.length; jdx++) { + var outObj = sfObj.out[jdx] + + for (var wdx = 0; wdx < outObj.wires.length; wdx++) { + var otherNode = nodes[outObj.wires[wdx].id]; + const initFactor = (otherNode.wires.length == 1 ? otherNode.bbox.height / 2 : ((otherNode.wires.length % 2 == 0) ? 10 : 13)); + + var startX = otherNode.bbox.x + otherNode.bbox.width; + var startY = otherNode.bbox.y + (initFactor + (13 * outObj.wires[wdx].port)); + var endX = outObj.bbox.x; + var endY = outObj.bbox.y + outObj.bbox.height / 2; + + flow_wiresEl.appendChild(createSvgElement('path', { + d: generateLinkPath(startX, startY, endX, endY, 1), + class: "link " + (otherNode.d ? "link-disabled" : "") + (" link-from-" + sfObj.id + "-to-" + otherNode.id) + })); + } + } + + } + + for (var ndeId in nodes) { + var nde = nodes[ndeId]; + if (nde.type == "link out") { linkOutNodes.push(nde) } + if ((nde.wires || []).length == 0) { continue } + + const initFactor = (nde.wires.length == 1 ? nde.bbox.height / 2 : ((nde.wires.length % 2 == 0) ? 10 : 13)); + var wireCnt = 0; + nde.wires.forEach(function (wires) { + wires.forEach(function (otherNodeId) { + var otherNode = nodes[otherNodeId]; + + if (otherNode) { + var startX = nde.bbox.x + nde.bbox.width; + var startY = nde.bbox.y + (initFactor + (13 * wireCnt)); + var endX = otherNode.bbox.x; + var endY = otherNode.bbox.y + otherNode.bbox.height / 2; + + flow_wiresEl.appendChild(createSvgElement('path', { + d: generateLinkPath(startX, startY, endX, endY, 1), + class: "link " + (otherNode.d || nde.d ? "link-disabled" : "") + (" link-from-" + nde.id + "-to-" + otherNode.id) + })); + } + }); + wireCnt++; + }); + } + + /* draw the links between link nodes, i.e. link-in and link-out nodes */ + if (renderOpts.linkLines) { + linkOutNodes.forEach(function (nde) { + nde.links.forEach(function (ndeId) { + var otherNode = nodes[ndeId]; + + if (otherNode) { + var startX = nde.bbox.x + nde.bbox.width; + var startY = nde.bbox.y + nde.bbox.height / 2; + var endX = otherNode.bbox.x; + var endY = otherNode.bbox.y + otherNode.bbox.height / 2; + + flow_wiresEl.appendChild(createSvgElement('path', { + d: generateLinkPath(startX, startY, endX, endY, 1), + "stroke-dasharray": "25,4", + class: "link " + (otherNode.d || nde.d ? "link-disabled" : "") + (" link-from-" + nde.id + "-to-" + otherNode.id) + })); + + flow_wiresEl.appendChild(createSvgElement('circle', { + cy: startY, + cx: startX + 6.5, + r: 5, + stroke: 'rgb(170, 170, 170)', + "stroke-width": 1, + stroke: 'rgb(170, 170, 170)', + "fill": 'rgb(238, 238, 238)', + class: (otherNode.d || nde.d ? "link-disabled" : "") + (" link-from-" + nde.id + "-to-" + otherNode.id) + })); + + flow_wiresEl.appendChild(createSvgElement('circle', { + cy: endY, + cx: endX - 6.5, + r: 5, + stroke: 'rgb(170, 170, 170)', + "stroke-width": 1, + stroke: 'rgb(170, 170, 170)', + "fill": 'rgb(238, 238, 238)', + class: (otherNode.d || nde.d ? "link-disabled" : "") + (" link-from-" + nde.id + "-to-" + otherNode.id) + })); + } + }); + }); + } + + /* finally remove our changes to the objects in the flowData array */ + flow.forEach(function (obj) { delete obj.bbox; }); + + const result = { + svg: svg.innerHTML, + flowId: renderOpts.flowId, + css: getCSS(renderOpts.scope) + } + + return result + } + + // #endregion + + /** + * @param {Array} flows - The flows to render. + * @param {FlowRendererOptions} options - The options for rendering the flows. + */ + function flowRenderer(flows, options) { + try { + options = Object.assign({}, flowRenderer.defaults, options) + // see if the container has and data-options and merge those + // data-xxx will enable the option + // data-xxx="true" will enable the option + // data-xxx="false" will disable the option + if (options.container) { + const containerOptions = {} + const dataOptions = ['scope', 'grid-lines', 'arrows', 'zoom', 'images', 'link-lines', 'labels'] + dataOptions.forEach(function (opt) { + if (options.container.hasAttribute("data-" + opt)) { + const optionValue = options.container.getAttribute("data-" + opt) || "true" + containerOptions[opt.replace(/-/g, '')] = optionValue === "true" + } + }) + options = Object.assign({}, options, containerOptions) + + if (typeof window === 'undefined') { + // if we are in a node.js environment, set global.document to the container ownerDocument + options.document = options.document || options.container.ownerDocument + global.__document = options.document + // polyfill getBBox for SVGElement in node env + if (options.document && options.document.createElementNS) { + // get the prototype of the SVGElement without accessing it directly + const SVGElement = options.document.createElementNS('http://www.w3.org/2000/svg', 'rect').constructor + if (!SVGElement.prototype.getBBox) { + SVGElement.prototype.getBBox = function () { + return getBBoxPolyfill(options.document, this) + } + } + } + + } + } + const renderer = renderFlows.bind(this) + return renderer(flows, options) + } catch (e) { + e.message += '\nPlease report this to @flowfuse/flow-renderer.'; + if ((options || flowRenderer.defaults).silent) { + return '

An error occured:

'
+                    + escape(e.message + '', true)
+                    + '
'; + } + throw e; + } + } + + /** + * @typedef {Object} FlowRendererOptions + * @property {boolean} [gridlines=true] - Whether to display grid lines on the flow diagram. Defaults to true. + * @property {boolean} [zoom=true] - Whether to enable zooming on the flow diagram. Defaults to true. + * @property {boolean} [images=true] - Whether to display images on the flow diagram. Defaults to true. + * @property {boolean} [linkLines=false] - Whether to display link lines on the flow diagram. Defaults to false. + * @property {boolean} [labels=true] - Whether to display labels on the flow diagram. Defaults to true. + * @property {Array} flowId - The specific flow(s) to render. If not provided, all flows will be rendered. + * @property {HTMLElement} container - The container div where the SVG diagram and controls will be rendered. + * @property {Document} document - The document object to use for creating SVG elements. Defaults to the global document object. + */ + + /** @type {FlowRendererOptions} */ + flowRenderer.defaults = { + arrows: false, + gridlines: true, + zoom: true, + images: true, + linkLines: false, + labels: true, + flowId: undefined, + container: undefined, + document: this.document + } + + flowRenderer.getStyles = getCSS; + flowRenderer.renderFlows = renderFlows; + flowRenderer.renderFlow = renderFlow; + +}).call(function () { + return this || (typeof window !== 'undefined' ? window : global); +}()); diff --git a/package.json b/package.json index dd6582a..c8895d4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Node-RED flow renderer", "main": "index.js", "scripts": { - "demo": "npx http-server", + "demo": "npx http-server -p 8080 -c-1 -o demo.html", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ From f469f59c0f7d90b6edd1f954461a0098978f4b1a Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 24 Apr 2024 14:50:23 +0100 Subject: [PATCH 2/4] support ESM --- README.md | 47 +++++++++-- index.js | 221 +++++++++++++++++++++++++++------------------------ package.json | 2 +- 3 files changed, 156 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 94790cd..e5c295c 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,12 @@ Run `npm run demo` to test the flow renderer in a browser. Add the following script tag to your HTML file: ```html - + ``` _or wherever the script is located in your project_ Next, add a container element to your HTML file and call the `flowRenderer` function with the flow data and options. +NOTE: flow-renderer is an ES Module and requires a modern browser to run. Script tags must have the `type="module"` attribute. By default, the flow renderer will render the flow with `gridlines`, `images`, `labels` and `zoom` options enabled. @@ -32,16 +33,19 @@ To operate the zoom, use the mouse wheel + Ctrl key. To scroll the container vertically, use the mouse wheel without the Shift key. To scroll the container horizontally, use the mouse wheel + Shift key. + + ### Basic example ```html -
+
``` ```javascript +const renderer = new FlowRenderer() const container1 = document.getElementById('nr-flow-1'); const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] -flowRenderer(flow, { container: container1 }) +renderer.renderFlows(flow, { container: container1 }) ``` ### Inline Options example @@ -49,25 +53,28 @@ flowRenderer(flow, { container: container1 }) Options can be set by data attributes `scope`, `grid-lines`, `zoom`, `images`, `link-lines`, `labels` ```html -
+
``` ```javascript +const renderer = new FlowRenderer() const container2 = document.getElementById('nr-flow-2'); const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] -flowRenderer(flow, { container: container2 }) +renderer.renderFlows(flow, { container: container2 }) ``` + ### Full Options example ```html -
+
``` ```javascript +const renderer = new FlowRenderer() const container3 = document.getElementById('nr-flow-3'); const flow = [{"id": "1001", "type": "inject", "x": 100, "y": 40, "wires": [["1002"]]}, {"id": "1002", "type": "debug", "x":300, "y": 40}] -flowRenderer(flow, { +renderer.renderFlows(flow, { container: container3, scope: 'my-scope', // scope for CSS gridlines: true, // show gridlines @@ -79,6 +86,26 @@ flowRenderer(flow, { }) ``` +### Vue example + +```html +
+
+
+ + +``` + ## Acknowledgements This project owes a huge thanks to Gerrit Riessen for his original works on [node-red-flowviewer](https://github.com/gorenje/node-red-flowviewer-js). It was this great contribution that started the ball rolling. Gerrit kindly allowed us relicense the parts we needed to use in this project. @@ -90,6 +117,12 @@ This project owes a huge thanks to Gerrit Riessen for his original works on [nod * The flow renderer does not support the full range of contributed Node-RED nodes however they will render as a generic node type complete with the node's label. * The flow renderer does not always render the flows and nodes exactly as they appear in the Node-RED editor. This is due in part to being a client-side render with no server-side component to provide full context and partly due to the current limitations of the renderer itself. +## Versioning + +While the API is in development, the version number of this package will remain at `0.x.y`. +`x` will be incremented for breaking changes, `y` for new features and patches. +Once the API is stable, the version number will be updated to 1.0.0 and full SemVer rules will be applied. + ## License Apache-2.0 diff --git a/index.js b/index.js index 6759064..360e9c2 100644 --- a/index.js +++ b/index.js @@ -1,18 +1,45 @@ - -; (function () { - - if (typeof exports === 'object') { - module.exports = flowRenderer; - } else if (typeof define === 'function' && define.amd) { - define(function () { return flowRenderer; }); - } else { - this.flowRenderer = flowRenderer; - } - +/** + * @typedef {Object} FlowRendererOptions + * @property {boolean} [gridlines=true] - Whether to display grid lines on the flow diagram. Defaults to true. + * @property {boolean} [zoom=true] - Whether to enable zooming on the flow diagram. Defaults to true. + * @property {boolean} [images=true] - Whether to display images on the flow diagram. Defaults to true. + * @property {boolean} [linkLines=false] - Whether to display link lines on the flow diagram. Defaults to false. + * @property {boolean} [labels=true] - Whether to display labels on the flow diagram. Defaults to true. + * @property {Array} flowId - The specific flow(s) to render. If not provided, all flows will be rendered. + * @property {HTMLElement} container - The container div where the SVG diagram and controls will be rendered. + * @property {Document} document - The document object to use for creating SVG elements. Defaults to the global document object. + */ + +/** + * @typedef {Object} FlowNode - A node in a flow. + * @property {string} id - The unique identifier for the node. + * @property {string} type - The type of node. + */ + +/** + * @typedef {Object} Flows - A collection of flows. + * @property {Array} nodes - The nodes in the flow. + */ + +const FlowRenderer = (function () { + let _this = this || {}; // #region Constants const styleId = 'flow-renderer-css' const PORT_WIDTH = 10 + /** @type {FlowRendererOptions} */ + const defaults = { + arrows: false, + gridlines: true, + zoom: true, + images: true, + linkLines: false, + labels: true, + flowId: undefined, + container: undefined, + document: undefined + } + const portDimensions = { width: PORT_WIDTH, height: PORT_WIDTH @@ -196,7 +223,7 @@ "ff-logo.svg": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI5MCIgaGVpZ2h0PSIxMzUiIHZlcnNpb249IjEuMSIgdmlld0JveD0iMCAwIDkwIDEzNSI+PHNjcmlwdCB4bWxucz0iIi8+CiA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLjAwNzggMCAwIDEuMDA3OCAtNjcuMDA3IC02NS45MTQpIj4KICA8cGF0aCBkPSJtNzcuNzcgOTIuN2MtMy40OTcgMC02LjMxMSAyLjgxNi02LjMxMSA2LjMxMy0wLjAwNTIgMTEuNTUtMC4wMTMxNiAyMy4xMS0wLjAxNDk5IDM0LjY2IDcuNSAwLjA0NDQgMTUuMDEgMC4yMTcgMjIuNDktMC4zMTgzIDEwLjQzLTAuOTUxMSAxOS42MS02LjU1MiAyOS4yMi0xMC4yMyA4Ljc0Mi0zLjY5NyAxOC4xMy01Ljk0MSAyNy42Ni01Ljc0MWwtMWUtMyAtMTguMzhjNGUtMyAtMy40OTgtMi44MTUtNi4zMTMtNi4zMTEtNi4zMTN6bTczLjA1IDM3LjE5Yy0xLjA1NSAwLjAxNjktMi4xMTEgMC4wNDU3LTMuMTY2IDAuMDg4OS0xMS42NC0wLjA0NDgtMjEuOTYgNS45NzQtMzIuNDYgMTAuMTkgOC4xMTUgMy4yODMgMTUuOTUgNy40NTQgMjQuNTUgOS4zODkgMy42NzUgMC41NTc2IDcuMzcyIDAuODI5MiAxMS4wOCAwLjkwODV6bS03MS4xMyAxNi41N2MtMi43NDcgNmUtMyAtNS40OTQgMC4wMzQ2LTguMjM5IDAuMDQ4NiAwLjAwMjQgNi40MTYgMC4wMDcyIDEyLjgzIDAuMDE3NTcgMTkuMjUgMC4wMDM3IDMuNDk4IDIuODE1IDYuMzEzIDYuMzExIDYuMzEzaDY2LjczYzMuNDk3IDAgNi4zMTEtMi44MTYgNi4zMTEtNi4zMTN2LTIuNzc4Yy04LjIwMy0wLjA1MzctMTYuNC0xLjMwNC0yNC4wNC00LjM3NC0xMS44Mi00LjEyNC0yMi44NS0xMS40NS0zNS42OC0xMS45NC0zLjc5OC0wLjE3OTQtNy42MDItMC4yMTQ3LTExLjQxLTAuMjA2MnoiIGZpbGw9IiNmZmYiLz4KIDwvZz4KPHNjcmlwdCB4bWxucz0iIi8+PC9zdmc+", } - const clrByType = { + const colorByType = { "base64": _hshClr("#DEBD5C"), "batch": _hshClr("#E2D96E"), "catch": _hshClr("#e49191"), @@ -398,17 +425,17 @@ } function getNodeColor (type) { - let colors = clrByType[type] + let colors = colorByType[type] if (!colors) { if (type.startsWith("subflow:")) { - colors = clrByType["subflow"] + colors = colorByType["subflow"] } else if (type.startsWith("ui-")) { - colors = clrByType["ui-template"] + colors = colorByType["ui-template"] } else if (type.startsWith("ui_")) { - colors = clrByType["ui_template"] + colors = colorByType["ui_template"] } } - return colors || clrByType["_default"] + return colors || colorByType["_default"] } function _hshClr (fill, stroke) { @@ -1125,7 +1152,7 @@ const tabs = {} let sfIndex = 10000 let fIndex = 0 - for (node of flow) { + for (let node of flow) { if (node.type === 'subflow') { tabs[node.id] = { id: node.id, @@ -1148,7 +1175,7 @@ // if the flow contains real tabs, then update the tabs objects const flowTabs = flow.filter((d) => d.type === 'tab') let index = 0 - for (node of flowTabs) { + for (let node of flowTabs) { // Only update if tabs[d.id] exists (i.e. there are nodes to show) if (tabs[node.id]) { tabs[node.id].label = node.label || tabs[node.id].label @@ -1163,6 +1190,11 @@ return tabsArray } + /** + * Normalise the flows array + * @param {Flows} flow - The flows to normalise + * @returns {Flows} + */ function normaliseFlow(flow) { if (!Array.isArray(flow)) { flow = [] @@ -1175,13 +1207,49 @@ return flow } + /** + * Normalise the options for rendering the flows + * NOTE: This function will also merge defaults and any data-options from the container + * NOTE: Merge occurs in this order: defaults, container data-options, provided options + * @param {FlowRendererOptions} [options] - The options for rendering the flows. + */ + function normaliseOptions(options) { + const mergeItems = [defaults] + // see if the container has and data-options and merge those + // data-xxx will enable the option + // data-xxx="true" will enable the option + // data-xxx="false" will disable the option + if (options) { + if (options.container) { + const containerOptions = {} + const dataOptions = ['scope', 'grid-lines', 'arrows', 'zoom', 'images', 'link-lines', 'labels'] + dataOptions.forEach(function (opt) { + if (options.container.hasAttribute("data-" + opt)) { + const optionValue = options.container.getAttribute("data-" + opt) || "true" + containerOptions[opt.replace(/-/g, '')] = optionValue === "true" + } + }) + mergeItems.push(containerOptions) + } + mergeItems.push(options) + } + // merge the options + const opts = Object.assign({}, ...mergeItems) + return opts + } + + /** + * Render a set of flows + * @param {Flows} flows - The flows to render + * @param {FlowRendererOptions} renderOpts - The options for rendering the flows + */ function renderFlows(flows, renderOpts) { - renderOpts = renderOpts || {} flows = normaliseFlow(flows) + renderOpts = normaliseOptions(renderOpts) // SETUP /** @type {Document} */ - const doc = renderOpts.document || this.document + const doc = getDocument(renderOpts.document, _this.document, renderOpts.container, this) /** @type {HTMLElement} */ const container = renderOpts.container @@ -1298,10 +1366,17 @@ return result } + /** + * Render a single flow tab + * @param {Flows} flow - The flow to render + * @param {FlowRendererOptions} renderOpts - The options for rendering the flows + */ function renderFlow(flow, renderOpts) { flow = normaliseFlow(flow) + renderOpts = normaliseOptions(renderOpts) + /** @type {Document} */ - const doc = renderOpts.document || this.document + const doc = getDocument(renderOpts.document, _this.document, renderOpts.container, this) const container = renderOpts.container renderOpts = renderOpts || {} const nodes = {} @@ -1310,6 +1385,7 @@ const subFlowInsOutsStatusNodes = {} const nodeIdsThatReceiveInput = {} const flowId = renderOpts.flowId + const junctionColor = getNodeColor('junction') /** @type {SVGSVGElement} */ let svg = container && container.querySelector('svg') @@ -1716,7 +1792,7 @@ if ((subflowObj.in && subflowObj.in.length > 0) || nodeIdsThatReceiveInput[obj.id]) { if (renderOpts.arrows) { grpObj.appendChild(createSvgElement('path', { - ...clrByType["junction"], + ...junctionColor, transform: "translate(-3," + ((obj.bbox.height / 2) - 5) + ")", d: (renderOpts.arrows ? "M 0,10 9,5 0,0 Z" : "M -1,9.5 8,9.5 8,0.5 -1,0.5 Z"), class: "input-deco input-arrows", @@ -1725,7 +1801,7 @@ })) } else { grpObj.appendChild(createSvgElement('rect', { - ...clrByType["junction"], + ...junctionColor, transform: "translate(-5," + ((obj.bbox.height / 2) - 5) + ")", ...portDimensions, ...portRadius, @@ -1735,7 +1811,7 @@ } const outDecoBaseAttrs = { - ...clrByType["junction"], + ...junctionColor, ...portDimensions, ...portRadius, class: "output-deco" @@ -2030,87 +2106,20 @@ // #endregion - /** - * @param {Array} flows - The flows to render. - * @param {FlowRendererOptions} options - The options for rendering the flows. - */ - function flowRenderer(flows, options) { - try { - options = Object.assign({}, flowRenderer.defaults, options) - // see if the container has and data-options and merge those - // data-xxx will enable the option - // data-xxx="true" will enable the option - // data-xxx="false" will disable the option - if (options.container) { - const containerOptions = {} - const dataOptions = ['scope', 'grid-lines', 'arrows', 'zoom', 'images', 'link-lines', 'labels'] - dataOptions.forEach(function (opt) { - if (options.container.hasAttribute("data-" + opt)) { - const optionValue = options.container.getAttribute("data-" + opt) || "true" - containerOptions[opt.replace(/-/g, '')] = optionValue === "true" - } - }) - options = Object.assign({}, options, containerOptions) - - if (typeof window === 'undefined') { - // if we are in a node.js environment, set global.document to the container ownerDocument - options.document = options.document || options.container.ownerDocument - global.__document = options.document - // polyfill getBBox for SVGElement in node env - if (options.document && options.document.createElementNS) { - // get the prototype of the SVGElement without accessing it directly - const SVGElement = options.document.createElementNS('http://www.w3.org/2000/svg', 'rect').constructor - if (!SVGElement.prototype.getBBox) { - SVGElement.prototype.getBBox = function () { - return getBBoxPolyfill(options.document, this) - } - } - } - - } - } - const renderer = renderFlows.bind(this) - return renderer(flows, options) - } catch (e) { - e.message += '\nPlease report this to @flowfuse/flow-renderer.'; - if ((options || flowRenderer.defaults).silent) { - return '

An error occured:

'
-                    + escape(e.message + '', true)
-                    + '
'; - } - throw e; - } - } - - /** - * @typedef {Object} FlowRendererOptions - * @property {boolean} [gridlines=true] - Whether to display grid lines on the flow diagram. Defaults to true. - * @property {boolean} [zoom=true] - Whether to enable zooming on the flow diagram. Defaults to true. - * @property {boolean} [images=true] - Whether to display images on the flow diagram. Defaults to true. - * @property {boolean} [linkLines=false] - Whether to display link lines on the flow diagram. Defaults to false. - * @property {boolean} [labels=true] - Whether to display labels on the flow diagram. Defaults to true. - * @property {Array} flowId - The specific flow(s) to render. If not provided, all flows will be rendered. - * @property {HTMLElement} container - The container div where the SVG diagram and controls will be rendered. - * @property {Document} document - The document object to use for creating SVG elements. Defaults to the global document object. - */ - - /** @type {FlowRendererOptions} */ - flowRenderer.defaults = { - arrows: false, - gridlines: true, - zoom: true, - images: true, - linkLines: false, - labels: true, - flowId: undefined, - container: undefined, - document: this.document + return { + renderFlows: renderFlows, + renderFlow: renderFlow, + normaliseOptions: normaliseOptions, + getStyles: getCSS, } +}) - flowRenderer.getStyles = getCSS; - flowRenderer.renderFlows = renderFlows; - flowRenderer.renderFlow = renderFlow; +if (typeof module === 'object' && module.exports) { + module.exports = FlowRenderer +} else if (typeof window === 'object') { + window.FlowRenderer = FlowRenderer +} else { + global.FlowRenderer = FlowRenderer +} -}).call(function () { - return this || (typeof window !== 'undefined' ? window : global); -}()); +export default FlowRenderer diff --git a/package.json b/package.json index c8895d4..1af0df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flowfuse/flow-renderer", - "version": "1.0.0", + "version": "0.1.0", "description": "Node-RED flow renderer", "main": "index.js", "scripts": { From 872d4f422def1fce0e9f8a4c42e96c5b551eb175 Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 24 Apr 2024 14:50:53 +0100 Subject: [PATCH 3/4] more demos (esm and vue) --- demo-esm.html | 97 ++++++++++++++++++++++++++++++++++++++++ demo-vanilla.html | 95 +++++++++++++++++++++++++++++++++++++++ demo-vue.html | 111 ++++++++++++++++++++++++++++++++++++++++++++++ demo.html | 100 ++++------------------------------------- 4 files changed, 312 insertions(+), 91 deletions(-) create mode 100644 demo-esm.html create mode 100644 demo-vanilla.html create mode 100644 demo-vue.html diff --git a/demo-esm.html b/demo-esm.html new file mode 100644 index 0000000..0b70b43 --- /dev/null +++ b/demo-esm.html @@ -0,0 +1,97 @@ + + + + + + FlowFuse Flow Renderer + + + + + + + + +

FlowFuse Flow Renderer

+
Client side demo
+
+ + +

Node-RED Flow

+ +
+ + +
+ + +

Render (grid, images, labels)

+
+ +
+
+ + +

Render (no grid, no images, labels)

+
+ +
+
+ + +

Render (no grid, images, no labels) using data-options

+
+ + + + + + \ No newline at end of file diff --git a/demo-vanilla.html b/demo-vanilla.html new file mode 100644 index 0000000..75b6280 --- /dev/null +++ b/demo-vanilla.html @@ -0,0 +1,95 @@ + + + + + + FlowFuse Flow Renderer + + + + + + + + +

FlowFuse Flow Renderer

+
Client side demo
+
+ + +

Node-RED Flow

+ +
+ + +
+ + +

Render (grid, images, labels)

+
+ +
+
+ + +

Render (no grid, no images, labels)

+
+ +
+
+ + +

Render (no grid, images, no labels) using data-options

+
+ + + + + + \ No newline at end of file diff --git a/demo-vue.html b/demo-vue.html new file mode 100644 index 0000000..034c653 --- /dev/null +++ b/demo-vue.html @@ -0,0 +1,111 @@ + + + + + + FlowFuse Flow Renderer + + + + + + + +

FlowFuse Flow Renderer

+
Client side demo
+
+ +
+ + +

Node-RED Flow

+ +
+ + +
+ + +

Render (grid, images, labels)

+
+ +
+
+ + +

Render (no grid, no images, labels)

+
+ +
+
+ + +

Render (no grid, images, no labels) using data-options

+
+ +
+ + + + + + + + \ No newline at end of file diff --git a/demo.html b/demo.html index 4a1b7a7..3b33e90 100644 --- a/demo.html +++ b/demo.html @@ -1,94 +1,12 @@ - - - - FlowFuse Flow Renderer - - - - - - - - -

FlowFuse Flow Renderer

-
Client side demo
-
- - -

Node-RED Flow

- -
- - -
- - -

Render (grid, images, labels)

-
- -
-
- - -

Render (no grid, no images, labels)

-
- -
-
- - -

Render (no grid, images, no labels) using data-options

-
- - - - - + +

FlowFuse Flow Renderer

+ + + \ No newline at end of file From c2d9cc75330a3b0c07a2c30c7b8f981cd13111ba Mon Sep 17 00:00:00 2001 From: Steve-Mcl Date: Wed, 24 Apr 2024 14:58:04 +0100 Subject: [PATCH 4/4] clarify which demo page --- demo-esm.html | 2 +- demo-vanilla.html | 2 +- demo-vue.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/demo-esm.html b/demo-esm.html index 0b70b43..7431b94 100644 --- a/demo-esm.html +++ b/demo-esm.html @@ -28,7 +28,7 @@

FlowFuse Flow Renderer

-
Client side demo
+
ESM client side demo

diff --git a/demo-vanilla.html b/demo-vanilla.html index 75b6280..b1592fa 100644 --- a/demo-vanilla.html +++ b/demo-vanilla.html @@ -28,7 +28,7 @@

FlowFuse Flow Renderer

-
Client side demo
+
Vanilla client side demo

diff --git a/demo-vue.html b/demo-vue.html index 034c653..03b7ad8 100644 --- a/demo-vue.html +++ b/demo-vue.html @@ -27,7 +27,7 @@

FlowFuse Flow Renderer

-
Client side demo
+
Vue client side demo