Repository: stan-smith/FossFLOW Branch: master Commit: 0da9ff3a22c6 Files: 369 Total size: 1.6 MB Directory structure: gitextract_4kyu4h7x/ ├── .dockerignore ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── dependabot-automerge.yml │ ├── docker.yml │ ├── e2e-tests.yml │ ├── e2e-tests.yml.backup │ ├── ethicalcheck.yml │ ├── pages.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .releaserc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── FOSSFLOW_ENCYCLOPEDIA.md ├── LICENSE ├── README.md ├── compose.dev.yml ├── compose.yml ├── docker-entrypoint.sh ├── docs/ │ ├── README.bn.md │ ├── README.cn.md │ ├── README.de.md │ ├── README.es.md │ ├── README.fr.md │ ├── README.hi.md │ ├── README.id.md │ ├── README.pt.md │ ├── README.ru.md │ └── SEMANTIC_RELEASE.md ├── e2e-tests/ │ ├── .gitignore │ ├── README.md │ ├── SETUP.md │ ├── get-docker.sh │ ├── pytest.ini │ ├── requirements.txt │ ├── run-tests.sh │ ├── test-base-paths.sh │ ├── test-diagram.json │ └── tests/ │ ├── test_base_path_routing.py │ ├── test_basic_load.py │ ├── test_connector_undo.py │ ├── test_export_svg.py │ ├── test_import_diagram.py │ ├── test_multi_node_undo.py │ ├── test_node_placement.py │ ├── test_rect_text_undo.py │ └── test_store_debug.py ├── nginx.conf ├── package.json ├── packages/ │ ├── fossflow-app/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── public/ │ │ │ ├── i18n/ │ │ │ │ └── app/ │ │ │ │ ├── bn-BD.json │ │ │ │ ├── de-DE.json │ │ │ │ ├── en-US.json │ │ │ │ ├── es-ES.json │ │ │ │ ├── fr-FR.json │ │ │ │ ├── hi-IN.json │ │ │ │ ├── id-ID.json │ │ │ │ ├── it-IT.json │ │ │ │ ├── pt-BR.json │ │ │ │ ├── ru-RU.json │ │ │ │ ├── tr-TR.json │ │ │ │ └── zh-CN.json │ │ │ ├── index.html │ │ │ ├── manifest.json │ │ │ ├── robots.txt │ │ │ └── service-worker.js │ │ ├── rsbuild.config.ts │ │ ├── src/ │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ ├── EditorPage.tsx │ │ │ ├── StorageManager.tsx │ │ │ ├── components/ │ │ │ │ ├── ChangeLanguage/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.css │ │ │ │ ├── DiagramManager.css │ │ │ │ ├── DiagramManager.tsx │ │ │ │ ├── ErrorBoundary.css │ │ │ │ └── ErrorBoundary.tsx │ │ │ ├── diagramUtils.ts │ │ │ ├── env.d.ts │ │ │ ├── i18n/ │ │ │ │ ├── bn-BD.json │ │ │ │ ├── en-US.json │ │ │ │ ├── es-ES.json │ │ │ │ ├── fr-FR.json │ │ │ │ ├── hi-IN.json │ │ │ │ ├── it-IT.json │ │ │ │ ├── pl-PL.json │ │ │ │ ├── pt-BR.json │ │ │ │ ├── ru-RU.json │ │ │ │ ├── tr-TR.json │ │ │ │ └── zh-CN.json │ │ │ ├── i18n.ts │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── minimalIcons.ts │ │ │ ├── paymentFlowExample.json │ │ │ ├── reportWebVitals.ts │ │ │ ├── serviceWorkerRegistration.ts │ │ │ ├── services/ │ │ │ │ ├── iconPackManager.ts │ │ │ │ └── storageService.ts │ │ │ └── usePersistedDiagram.ts │ │ └── tsconfig.json │ ├── fossflow-backend/ │ │ ├── package.json │ │ └── server.js │ └── fossflow-lib/ │ ├── .gitignore │ ├── LICENSE │ ├── docs/ │ │ ├── .gitignore │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── package.json │ │ ├── pages/ │ │ │ ├── _meta.json │ │ │ ├── docs/ │ │ │ │ ├── _meta.json │ │ │ │ ├── api/ │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── index.mdx │ │ │ │ │ └── initialData.mdx │ │ │ │ ├── contributing.mdx │ │ │ │ ├── index.mdx │ │ │ │ ├── installation.mdx │ │ │ │ ├── isopacks.mdx │ │ │ │ └── quickstart.mdx │ │ │ └── index.tsx │ │ ├── theme.config.tsx │ │ └── tsconfig.json │ ├── jest.config.js │ ├── jest.setup.js │ ├── package.json │ ├── rslib.config.ts │ ├── src/ │ │ ├── Isoflow.tsx │ │ ├── components/ │ │ │ ├── Circle/ │ │ │ │ └── Circle.tsx │ │ │ ├── ColorSelector/ │ │ │ │ ├── ColorPicker.tsx │ │ │ │ ├── ColorSelector.tsx │ │ │ │ ├── ColorSwatch.tsx │ │ │ │ ├── CustomColorInput.tsx │ │ │ │ └── __tests__/ │ │ │ │ ├── ColorSelector.test.tsx │ │ │ │ └── CustomColorInput.test.tsx │ │ │ ├── ConnectorEmptySpaceTooltip/ │ │ │ │ └── ConnectorEmptySpaceTooltip.tsx │ │ │ ├── ConnectorHintTooltip/ │ │ │ │ └── ConnectorHintTooltip.tsx │ │ │ ├── ConnectorRerouteTooltip/ │ │ │ │ └── ConnectorRerouteTooltip.tsx │ │ │ ├── ConnectorSettings/ │ │ │ │ └── ConnectorSettings.tsx │ │ │ ├── ContextMenu/ │ │ │ │ ├── ContextMenu.tsx │ │ │ │ └── ContextMenuManager.tsx │ │ │ ├── Cursor/ │ │ │ │ └── Cursor.tsx │ │ │ ├── DOMErrorBoundary/ │ │ │ │ ├── DOMErrorBoundary.tsx │ │ │ │ └── index.ts │ │ │ ├── DebugUtils/ │ │ │ │ ├── DebugUtils.tsx │ │ │ │ ├── LineItem.tsx │ │ │ │ ├── SizeIndicator.tsx │ │ │ │ ├── Value.tsx │ │ │ │ └── __tests__/ │ │ │ │ ├── DebugUtils.test.tsx │ │ │ │ ├── LineItem.test.tsx │ │ │ │ ├── SizeIndicator.test.tsx │ │ │ │ └── Value.test.tsx │ │ │ ├── DragAndDrop/ │ │ │ │ └── DragAndDrop.tsx │ │ │ ├── ExportImageDialog/ │ │ │ │ └── ExportImageDialog.tsx │ │ │ ├── FreehandLasso/ │ │ │ │ └── FreehandLasso.tsx │ │ │ ├── Gradient/ │ │ │ │ └── Gradient.tsx │ │ │ ├── Grid/ │ │ │ │ └── Grid.tsx │ │ │ ├── HelpDialog/ │ │ │ │ └── HelpDialog.tsx │ │ │ ├── HotkeySettings/ │ │ │ │ └── HotkeySettings.tsx │ │ │ ├── IconButton/ │ │ │ │ └── IconButton.tsx │ │ │ ├── IconPackSettings/ │ │ │ │ └── IconPackSettings.tsx │ │ │ ├── ImportHintTooltip/ │ │ │ │ └── ImportHintTooltip.tsx │ │ │ ├── IsoTileArea/ │ │ │ │ └── IsoTileArea.tsx │ │ │ ├── ItemControls/ │ │ │ │ ├── ConnectorControls/ │ │ │ │ │ └── ConnectorControls.tsx │ │ │ │ ├── IconSelectionControls/ │ │ │ │ │ ├── Icon.tsx │ │ │ │ │ ├── IconCollection.tsx │ │ │ │ │ ├── IconGrid.tsx │ │ │ │ │ ├── IconSelectionControls.tsx │ │ │ │ │ ├── Icons.tsx │ │ │ │ │ ├── Searchbox.tsx │ │ │ │ │ └── __tests__/ │ │ │ │ │ └── Icon.test.tsx │ │ │ │ ├── ItemControlsManager.tsx │ │ │ │ ├── NodeControls/ │ │ │ │ │ ├── NodeControls.tsx │ │ │ │ │ ├── NodeSettings/ │ │ │ │ │ │ └── NodeSettings.tsx │ │ │ │ │ └── QuickIconSelector.tsx │ │ │ │ ├── RectangleControls/ │ │ │ │ │ └── RectangleControls.tsx │ │ │ │ ├── TextBoxControls/ │ │ │ │ │ └── TextBoxControls.tsx │ │ │ │ └── components/ │ │ │ │ ├── ControlsContainer.tsx │ │ │ │ ├── DeleteButton.tsx │ │ │ │ ├── Header.tsx │ │ │ │ └── Section.tsx │ │ │ ├── Label/ │ │ │ │ ├── ExpandButton.tsx │ │ │ │ ├── ExpandableLabel.tsx │ │ │ │ ├── Label.tsx │ │ │ │ └── __tests__/ │ │ │ │ └── Label.test.tsx │ │ │ ├── LabelSettings/ │ │ │ │ └── LabelSettings.tsx │ │ │ ├── Lasso/ │ │ │ │ └── Lasso.tsx │ │ │ ├── LassoHintTooltip/ │ │ │ │ └── LassoHintTooltip.tsx │ │ │ ├── LazyLoadingWelcomeNotification/ │ │ │ │ └── LazyLoadingWelcomeNotification.tsx │ │ │ ├── Loader/ │ │ │ │ └── Loader.tsx │ │ │ ├── MainMenu/ │ │ │ │ ├── MainMenu.tsx │ │ │ │ └── MenuItem.tsx │ │ │ ├── PanSettings/ │ │ │ │ └── PanSettings.tsx │ │ │ ├── Renderer/ │ │ │ │ └── Renderer.tsx │ │ │ ├── RichTextEditor/ │ │ │ │ ├── RichTextEditor.tsx │ │ │ │ ├── RichTextEditorErrorBoundary.tsx │ │ │ │ └── index.ts │ │ │ ├── SceneLayer/ │ │ │ │ └── SceneLayer.tsx │ │ │ ├── SceneLayers/ │ │ │ │ ├── ConnectorLabels/ │ │ │ │ │ ├── ConnectorLabel.tsx │ │ │ │ │ └── ConnectorLabels.tsx │ │ │ │ ├── Connectors/ │ │ │ │ │ ├── Connector.tsx │ │ │ │ │ └── Connectors.tsx │ │ │ │ ├── Nodes/ │ │ │ │ │ ├── Node/ │ │ │ │ │ │ ├── IconTypes/ │ │ │ │ │ │ │ ├── IsometricIcon.tsx │ │ │ │ │ │ │ └── NonIsometricIcon.tsx │ │ │ │ │ │ └── Node.tsx │ │ │ │ │ └── Nodes.tsx │ │ │ │ ├── Rectangles/ │ │ │ │ │ ├── Rectangle.tsx │ │ │ │ │ └── Rectangles.tsx │ │ │ │ └── TextBoxes/ │ │ │ │ ├── TextBox.tsx │ │ │ │ └── TextBoxes.tsx │ │ │ ├── SettingsDialog/ │ │ │ │ └── SettingsDialog.tsx │ │ │ ├── Svg/ │ │ │ │ └── Svg.tsx │ │ │ ├── ToolMenu/ │ │ │ │ └── ToolMenu.tsx │ │ │ ├── TransformControlsManager/ │ │ │ │ ├── NodeTransformControls.tsx │ │ │ │ ├── RectangleTransformControls.tsx │ │ │ │ ├── TextBoxTransformControls.tsx │ │ │ │ ├── TransformAnchor.tsx │ │ │ │ ├── TransformControls.tsx │ │ │ │ └── TransformControlsManager.tsx │ │ │ ├── UiElement/ │ │ │ │ └── UiElement.tsx │ │ │ ├── UiOverlay/ │ │ │ │ └── UiOverlay.tsx │ │ │ ├── ZoomControls/ │ │ │ │ └── ZoomControls.tsx │ │ │ └── ZoomSettings/ │ │ │ └── ZoomSettings.tsx │ │ ├── config/ │ │ │ ├── hotkeys.ts │ │ │ ├── labelSettings.ts │ │ │ ├── panSettings.ts │ │ │ └── zoomSettings.ts │ │ ├── config.ts │ │ ├── examples/ │ │ │ ├── BasicEditor/ │ │ │ │ └── BasicEditor.tsx │ │ │ ├── DebugTools/ │ │ │ │ └── DebugTools.tsx │ │ │ ├── ReadonlyMode/ │ │ │ │ └── ReadonlyMode.tsx │ │ │ ├── index.tsx │ │ │ └── initialData.ts │ │ ├── fixtures/ │ │ │ ├── colors.ts │ │ │ ├── icons.ts │ │ │ ├── model.ts │ │ │ ├── modelItems.ts │ │ │ └── views.ts │ │ ├── global.d.ts │ │ ├── hooks/ │ │ │ ├── __tests__/ │ │ │ │ ├── useHistory.test.tsx │ │ │ │ └── useInitialDataManager.test.tsx │ │ │ ├── useColor.ts │ │ │ ├── useConnector.ts │ │ │ ├── useDiagramUtils.ts │ │ │ ├── useHistory.ts │ │ │ ├── useIcon.tsx │ │ │ ├── useIconCategories.ts │ │ │ ├── useIconFiltering.ts │ │ │ ├── useInitialDataManager.ts │ │ │ ├── useIsoProjection.ts │ │ │ ├── useModelItem.ts │ │ │ ├── useRectangle.ts │ │ │ ├── useResizeObserver.ts │ │ │ ├── useScene.ts │ │ │ ├── useTextBox.ts │ │ │ ├── useTextBoxProps.ts │ │ │ ├── useView.ts │ │ │ ├── useViewItem.ts │ │ │ └── useWindowUtils.ts │ │ ├── i18n/ │ │ │ ├── bn-BD.ts │ │ │ ├── en-US.ts │ │ │ ├── es-ES.ts │ │ │ ├── fr-FR.ts │ │ │ ├── hi-IN.ts │ │ │ ├── id-ID.ts │ │ │ ├── index.ts │ │ │ ├── it-IT.ts │ │ │ ├── pl-PL.ts │ │ │ ├── pt-BR.ts │ │ │ ├── ru-RU.ts │ │ │ ├── tr-TR.ts │ │ │ └── zh-CN.ts │ │ ├── index-docker.tsx │ │ ├── index.html │ │ ├── index.ts │ │ ├── index.tsx │ │ ├── interaction/ │ │ │ ├── modes/ │ │ │ │ ├── Connector.ts │ │ │ │ ├── Cursor.ts │ │ │ │ ├── DragItems.ts │ │ │ │ ├── FreehandLasso.ts │ │ │ │ ├── Lasso.ts │ │ │ │ ├── Pan.ts │ │ │ │ ├── PlaceIcon.ts │ │ │ │ ├── Rectangle/ │ │ │ │ │ ├── DrawRectangle.ts │ │ │ │ │ └── TransformRectangle.ts │ │ │ │ └── TextBox.ts │ │ │ ├── useInteractionManager.ts │ │ │ └── usePanHandlers.ts │ │ ├── module.d.ts │ │ ├── schemas/ │ │ │ ├── __tests__/ │ │ │ │ ├── colors.test.ts │ │ │ │ ├── connector.test.ts │ │ │ │ ├── icons.test.ts │ │ │ │ ├── modelItems.test.ts │ │ │ │ ├── rectangle.test.ts │ │ │ │ ├── textBox.test.ts │ │ │ │ ├── validation.test.ts │ │ │ │ └── views.test.ts │ │ │ ├── colors.ts │ │ │ ├── common.ts │ │ │ ├── connector.ts │ │ │ ├── icons.ts │ │ │ ├── index.ts │ │ │ ├── model.ts │ │ │ ├── modelItems.ts │ │ │ ├── rectangle.ts │ │ │ ├── textBox.ts │ │ │ ├── validation.ts │ │ │ └── views.ts │ │ ├── standaloneExports.ts │ │ ├── stores/ │ │ │ ├── localeStore.tsx │ │ │ ├── modelStore.tsx │ │ │ ├── reducers/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── connector.test.ts │ │ │ │ │ ├── modelItem.test.ts │ │ │ │ │ ├── rectangle.test.ts │ │ │ │ │ ├── textBox.test.ts │ │ │ │ │ └── viewItem.test.ts │ │ │ │ ├── connector.ts │ │ │ │ ├── index.ts │ │ │ │ ├── modelItem.ts │ │ │ │ ├── rectangle.ts │ │ │ │ ├── textBox.ts │ │ │ │ ├── types.ts │ │ │ │ ├── view.ts │ │ │ │ └── viewItem.ts │ │ │ ├── sceneStore.tsx │ │ │ └── uiStateStore.tsx │ │ ├── styles/ │ │ │ ├── GlobalStyles.tsx │ │ │ └── theme.ts │ │ ├── types/ │ │ │ ├── common.ts │ │ │ ├── dom-to-image-more.d.ts │ │ │ ├── index.ts │ │ │ ├── interactions.ts │ │ │ ├── isoflowProps.ts │ │ │ ├── model.ts │ │ │ ├── rendererProps.ts │ │ │ ├── scene.ts │ │ │ └── ui.ts │ │ └── utils/ │ │ ├── CoordsUtils.ts │ │ ├── SizeUtils.ts │ │ ├── __tests__/ │ │ │ ├── common.test.ts │ │ │ ├── immer.test.ts │ │ │ └── renderer.test.ts │ │ ├── common.ts │ │ ├── connectorLabels.ts │ │ ├── exportOptions.ts │ │ ├── findNearestUnoccupiedTile.ts │ │ ├── index.ts │ │ ├── model.ts │ │ ├── pathfinder.ts │ │ ├── pointInPolygon.ts │ │ └── renderer.ts │ ├── tsconfig.declaration.json │ ├── tsconfig.dev.json │ └── tsconfig.json ├── scripts/ │ └── update-version.js ├── test-app.html ├── test-base-paths.sh └── tsconfig.base.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ node_modules .git .devcontainer *.md .env diagrams e2e-tests ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: st_nsmith tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry polar: # Replace with a single Polar username buy_me_a_coffee: stan.smith thanks_dev: # Replace with a single thanks.dev username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug Report description: Something isn't working as expected. Please provide enough detail that I can actually reproduce it. title: "Bug: " labels: ["bug"] body: - type: markdown attributes: value: | Thanks for taking the time to report a bug. Please fill in **all** the required fields below. Issues missing required information will be closed without comment. - type: checkboxes id: existing-issues attributes: label: Pre-flight checks description: Please confirm the following before submitting. options: - label: I have searched existing issues and this hasn't been reported before required: true - label: I am running the latest version of FossFLOW required: true - label: I have read the README and checked if this is expected behaviour required: true - type: dropdown id: deployment attributes: label: Deployment method description: How are you running FossFLOW? options: - Docker (docker run) - Docker Compose - Built from source (npm run dev) - Built from source (npm run build) - Online demo default: 0 validations: required: true - type: input id: version attributes: label: FossFLOW version / Docker image tag description: "e.g. latest, v1.2.0, commit hash" placeholder: "latest" validations: required: true - type: input id: browser attributes: label: Browser and version description: "e.g. Chrome 131, Firefox 134, Safari 18.2" placeholder: "Chrome 131" validations: required: true - type: input id: os attributes: label: Operating system placeholder: "e.g. macOS 15.2, Windows 11, Ubuntu 24.04" validations: required: true - type: textarea id: description attributes: label: What happened? description: A clear description of the bug. What did you expect to happen vs what actually happened? placeholder: | Expected: ... Actual: ... validations: required: true - type: textarea id: reproduce attributes: label: Steps to reproduce description: Minimum steps to reproduce the issue. If I can't reproduce it, I can't fix it. placeholder: | 1. Open FossFLOW 2. Click on '...' 3. Observe '...' validations: required: true - type: textarea id: screenshots attributes: label: Screenshots / screen recordings description: If applicable, add screenshots or a screen recording. Drag and drop images here. validations: required: false - type: textarea id: logs attributes: label: Browser console output / Docker logs description: Open browser DevTools (F12) → Console tab, or run `docker logs `. Paste any errors here. render: shell validations: required: false - type: textarea id: diagram-json attributes: label: Diagram JSON (if relevant) description: Export your diagram and paste the JSON here if the bug is diagram-specific. Remove anything sensitive first. render: json validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 💬 General Discussion url: https://github.com/stan-smith/FossFLOW/discussions about: Got a question, idea, or just want to chat? Start a discussion instead of an issue. - name: 📖 README & Documentation url: https://github.com/stan-smith/FossFLOW#readme about: Please check the docs before opening an issue - your answer might already be there. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 💡 Feature Request description: Suggest a new feature or improvement. Please check the project scope first. title: "Feature: " labels: ["enhancement"] body: - type: markdown attributes: value: | Thanks for the suggestion! Before submitting, please understand that FossFLOW is a **simple, privacy-first diagramming tool**. The following are **out of scope** and will be closed immediately: - Authentication, RBAC, OIDC, or multi-tenancy - User accounts or team management - Cloud hosting or SaaS features - Anything that fundamentally changes what FossFLOW is If you're unsure whether your idea fits, open a [Discussion](https://github.com/stan-smith/FossFLOW/discussions) first. - type: checkboxes id: preflight attributes: label: Pre-flight checks options: - label: I have searched existing issues and feature requests for duplicates required: true - label: This feature is within the scope described above required: true - label: I have checked [Discussions](https://github.com/stan-smith/FossFLOW/discussions) for related topics required: true - type: textarea id: problem attributes: label: What problem does this solve? description: Describe the problem or limitation you're experiencing. Focus on the *problem*, not the solution. placeholder: "I'm trying to do X but currently I have to..." validations: required: true - type: textarea id: solution attributes: label: Proposed solution description: How do you think this could be solved? Be specific. validations: required: true - type: textarea id: alternatives attributes: label: Alternatives you've considered description: What other approaches have you tried or thought about? validations: required: true - type: dropdown id: contribution attributes: label: Are you willing to work on this? description: Would you be prepared to submit a PR for this feature? options: - "Yes - I'd like to implement this myself" - "Partially - I could help but would need guidance" - "No - I'm suggesting it for someone else to build" default: 2 validations: required: true - type: textarea id: context attributes: label: Additional context description: Screenshots, mockups, examples from other tools, etc. validations: required: false ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## What does this PR do? Fixes # ## Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor (no functional change) - [ ] Documentation update ## Checklist - [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md) - [ ] I have tested these changes locally and they work - [ ] I can explain every line of code in this PR if asked - [ ] This PR does not contain AI-generated code that I haven't personally reviewed, understood, and tested - [ ] I have not added any unnecessary comments, logging, or dead code - [ ] My code follows the existing style and conventions of the project - [ ] I have updated documentation if applicable ## How to test 1. 2. 3. ## Screenshots (if UI change) ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 10 commit-message: prefix: "chore(deps)" groups: minor-and-patch: update-types: - "minor" - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 commit-message: prefix: "ci(deps)" ================================================ FILE: .github/workflows/dependabot-automerge.yml ================================================ name: Dependabot Auto-Merge on: pull_request: permissions: contents: write pull-requests: write jobs: automerge: runs-on: ubuntu-latest if: github.actor == 'dependabot[bot]' steps: - name: Fetch Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Enable auto-merge for minor and patch updates if: steps.metadata.outputs.update-type != 'version-update:semver-major' run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/docker.yml ================================================ name: Build and Push Docker Image on: workflow_run: workflows: ["E2E Tests"] types: - completed branches: ["main", "master"] jobs: build-and-push: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Docker Hub uses: docker/login-action@v4 with: username: stnsmith password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v6 with: images: stnsmith/fossflow tags: | type=semver,pattern={{version}},enable=${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}},enable=${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} type=ref,event=branch,enable=${{ github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v') }} type=sha,prefix={{branch}}-,enable=${{ github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v') }} type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max progress: plain ================================================ FILE: .github/workflows/e2e-tests.yml ================================================ name: E2E Tests on: # Runs on PRs so we can gate merges on E2E results pull_request: branches: ["main", "master"] # Runs after unit tests complete successfully on push workflow_run: workflows: ["Run Tests"] types: - completed branches: ["main", "master"] jobs: e2e-tests: runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request' || github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '22.x' cache: 'npm' - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.11' cache: 'pip' cache-dependency-path: 'e2e-tests/requirements.txt' - name: Install Node dependencies run: npm ci - name: Install Python dependencies run: | cd e2e-tests pip install -r requirements.txt - name: Build FossFLOW library run: npm run build:lib - name: Build FossFLOW app run: npm run build:app - name: Install serve globally run: npm install -g serve - name: Start Selenium Chrome in background run: | docker run -d \ --name selenium-chrome \ --network host \ --shm-size=2g \ selenium/standalone-chrome:latest echo "Waiting for Selenium to be ready..." timeout 60 bash -c 'until curl -sf http://localhost:4444/status; do sleep 2; done' || { echo "Selenium failed to start" docker logs selenium-chrome exit 1 } echo "Selenium is ready" curl -s http://localhost:4444/status | jq '.' || true - name: Start FossFLOW server in background run: | cd packages/fossflow-app/build nohup serve -s . -l 3000 > /tmp/server.log 2>&1 & echo $! > /tmp/server.pid echo "Server PID: $(cat /tmp/server.pid)" echo "Waiting for server to start..." timeout 60 bash -c 'until curl -sf http://localhost:3000; do sleep 2; done' || { echo "Server failed to start" echo "Server logs:" cat /tmp/server.log kill $(cat /tmp/server.pid) 2>/dev/null || true exit 1 } echo "Server is ready" echo "Server PID saved to /tmp/server.pid" env: CI: true - name: Verify connectivity before tests run: | echo "Testing app connectivity..." curl -sf http://localhost:3000 || echo "App not accessible" echo "Testing Selenium connectivity..." curl -sf http://localhost:4444/status || echo "Selenium not accessible" - name: Run E2E tests run: | cd e2e-tests pytest -v --tb=short env: FOSSFLOW_TEST_URL: http://localhost:3000 WEBDRIVER_URL: http://localhost:4444 - name: Stop Selenium and FossFLOW server if: always() run: | echo "Stopping Selenium container..." docker stop selenium-chrome 2>/dev/null || true docker rm selenium-chrome 2>/dev/null || true if [ -f /tmp/server.pid ]; then echo "Stopping server (PID: $(cat /tmp/server.pid))" kill $(cat /tmp/server.pid) 2>/dev/null || true echo "Server logs:" cat /tmp/server.log || true fi - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: e2e-test-results path: e2e-tests/target/ if-no-files-found: ignore retention-days: 7 ================================================ FILE: .github/workflows/e2e-tests.yml.backup ================================================ name: E2E Tests on: # Runs after unit tests complete successfully workflow_run: workflows: ["Run Tests"] types: - completed branches: ["main", "master"] jobs: e2e-tests: runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' cache-dependency-path: 'e2e-tests/requirements.txt' - name: Install Node dependencies run: npm ci - name: Install Python dependencies run: | cd e2e-tests pip install -r requirements.txt - name: Build FossFLOW library run: npm run build:lib - name: Build FossFLOW app run: npm run build:app - name: Install serve globally run: npm install -g serve - name: Start Selenium Chrome in background run: | docker run -d \ --name selenium-chrome \ --network host \ --shm-size=2g \ selenium/standalone-chrome:latest echo "Waiting for Selenium to be ready..." timeout 60 bash -c 'until curl -sf http://localhost:4444/status; do sleep 2; done' || { echo "Selenium failed to start" docker logs selenium-chrome exit 1 } echo "Selenium is ready" curl -s http://localhost:4444/status | jq '.' || true - name: Start FossFLOW server in background run: | cd packages/fossflow-app/build nohup serve -s . -l 3000 > /tmp/server.log 2>&1 & echo $! > /tmp/server.pid echo "Server PID: $(cat /tmp/server.pid)" echo "Waiting for server to start..." timeout 60 bash -c 'until curl -sf http://localhost:3000; do sleep 2; done' || { echo "Server failed to start" echo "Server logs:" cat /tmp/server.log kill $(cat /tmp/server.pid) 2>/dev/null || true exit 1 } echo "Server is ready" echo "Server PID saved to /tmp/server.pid" env: CI: true - name: Verify connectivity before tests run: | echo "Testing app connectivity..." curl -sf http://localhost:3000 || echo "App not accessible" echo "Testing Selenium connectivity..." curl -sf http://localhost:4444/status || echo "Selenium not accessible" - name: Run E2E tests run: | cd e2e-tests pytest -v --tb=short env: FOSSFLOW_TEST_URL: http://localhost:3000 WEBDRIVER_URL: http://localhost:4444 - name: Stop Selenium and FossFLOW server if: always() run: | echo "Stopping Selenium container..." docker stop selenium-chrome 2>/dev/null || true docker rm selenium-chrome 2>/dev/null || true if [ -f /tmp/server.pid ]; then echo "Stopping server (PID: $(cat /tmp/server.pid))" kill $(cat /tmp/server.pid) 2>/dev/null || true echo "Server logs:" cat /tmp/server.log || true fi - name: Upload test results if: always() uses: actions/upload-artifact@v4 with: name: e2e-test-results path: e2e-tests/target/ if-no-files-found: ignore retention-days: 7 ================================================ FILE: .github/workflows/ethicalcheck.yml ================================================ # This workflow uses actions that are not certified by GitHub. # They are provided by a third-party and are governed by # separate terms of service, privacy policy, and support # documentation. # EthicalCheck addresses the critical need to continuously security test APIs in development and in production. # EthicalCheck provides the industry’s only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list. # Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities. # You develop the application and API, we bring complete and continuous security testing to you, accelerating development. # Know your API and Applications are secure with EthicalCheck – our free & automated API security testing service. # How EthicalCheck works? # EthicalCheck functions in the following simple steps. # 1. Security Testing. # Provide your OpenAPI specification or start with a public Postman collection URL. # EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing. # It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc. # 2. Reporting. # EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities. # Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging. # This is a starter workflow to help you get started with EthicalCheck Actions name: EthicalCheck-Workflow # Controls when the workflow will run on: # Triggers the workflow on push or pull request events but only for the "master" branch # Customize trigger events based on your DevSecOps processes. push: branches: [ "master" ] pull_request: branches: [ "master" ] schedule: - cron: '31 22 * * 0' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: permissions: contents: read jobs: Trigger_EthicalCheck: permissions: security-events: write # for github/codeql-action/upload-sarif to upload SARIF results actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status runs-on: ubuntu-latest steps: - name: EthicalCheck Free & Automated API Security Testing Service uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641 with: # The OpenAPI Specification URL or Swagger Path or Public Postman collection URL. oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs" # The email address to which the penetration test report will be sent. email: "security_reports@x0z.co" sarif-result-file: "ethicalcheck-results.sarif" - name: Upload sarif file to repository uses: github/codeql-action/upload-sarif@v4 with: sarif_file: ./ethicalcheck-results.sarif ================================================ FILE: .github/workflows/pages.yml ================================================ # Simple workflow for deploying static content to GitHub Pages name: Deploy static content to Pages on: # Runs after E2E tests complete successfully workflow_run: workflows: ["E2E Tests"] types: - completed branches: ["main", "master"] # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow one concurrent deployment concurrency: group: "pages" cancel-in-progress: true jobs: # Single deploy job since we're just deploying deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Pages uses: actions/configure-pages@v5 - name: Build uses: docker://node:22-alpine with: args: sh -c "npm install && npm run build:lib && npm run build:app" env: # do not report warnings as errors CI: false PUBLIC_URL: /FossFLOW/ - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: # Upload from the app's build directory in monorepo path: './packages/fossflow-app/build/' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: # Runs after Pages deployment completes successfully workflow_run: workflows: ["Deploy static content to Pages"] types: - completed branches: ["main", "master"] permissions: contents: write issues: write pull-requests: write jobs: release: name: Semantic Release runs-on: ubuntu-latest if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false - name: Setup Node.js uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci - name: Run semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release ================================================ FILE: .github/workflows/test.yml ================================================ name: Run Tests on: push: branches: ["main", "master"] pull_request: branches: ["main", "master"] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, 22.x, 24.x] steps: - name: Checkout code uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests with coverage run: npm test -- --coverage || npm test env: CI: true - name: Upload coverage reports if: success() uses: actions/upload-artifact@v7 with: name: coverage-node-${{ matrix.node-version }} path: | packages/*/coverage/ coverage/ if-no-files-found: ignore retention-days: 7 - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: test-results-node-${{ matrix.node-version }} path: | packages/*/test-results/ test-results/ if-no-files-found: ignore retention-days: 7 - name: Run NPM build to check there are no build errors run: npm run build ================================================ FILE: .gitignore ================================================ node_modules/ dist/ build/ *.log .env .env.local .DS_Store coverage/ .vscode/ .idea/ *.swp *.swo *~ .npm .eslintcache *.tsbuildinfo /e2e-tests/target *.snap parts/ prime/ stage/ overlay/ .claude ================================================ FILE: .npmignore ================================================ # Source files src/ webpack/ docs/ .circleci/ .codesandbox/ .vscode/ # Config files .eslintrc .prettierrc .nvmrc jest.config.js tsconfig.json *.config.js # Build artifacts node_modules/ *.log # Documentation *.md !README.md !LICENSE # Git .git/ .gitignore # Other Dockerfile ================================================ FILE: .nvmrc ================================================ 16.19.0 ================================================ FILE: .prettierrc ================================================ { "semi": true, "trailingComma": "none", "singleQuote": true, "printWidth": 80, "tabWidth": 2 } ================================================ FILE: .releaserc.json ================================================ { "branches": ["master", "main"], "repositoryUrl": "https://github.com/stan-smith/FossFLOW.git", "plugins": [ [ "@semantic-release/commit-analyzer", { "preset": "conventionalcommits", "releaseRules": [ { "type": "feat", "release": "minor" }, { "type": "fix", "release": "patch" }, { "type": "perf", "release": "patch" }, { "type": "revert", "release": "patch" }, { "type": "docs", "release": false }, { "type": "style", "release": false }, { "type": "chore", "release": false }, { "type": "refactor", "release": "patch" }, { "type": "test", "release": false }, { "type": "build", "release": false }, { "type": "ci", "release": false }, { "breaking": true, "release": "major" } ] } ], [ "@semantic-release/release-notes-generator", { "preset": "conventionalcommits", "presetConfig": { "types": [ { "type": "feat", "section": "Features" }, { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance" }, { "type": "revert", "section": "Reverts" }, { "type": "docs", "section": "Documentation", "hidden": false }, { "type": "style", "section": "Styles", "hidden": true }, { "type": "chore", "section": "Chores", "hidden": true }, { "type": "refactor", "section": "Code Refactoring" }, { "type": "test", "section": "Tests", "hidden": true }, { "type": "build", "section": "Build System", "hidden": true }, { "type": "ci", "section": "CI/CD", "hidden": true } ] } } ], [ "@semantic-release/changelog", { "changelogFile": "CHANGELOG.md", "changelogTitle": "# Changelog\n\nAll notable changes to FossFLOW will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)." } ], [ "@semantic-release/exec", { "prepareCmd": "npm run update-version ${nextRelease.version} && npm run build" } ], "@semantic-release/github", [ "@semantic-release/git", { "assets": [ "CHANGELOG.md", "package.json", "package-lock.json", "packages/*/package.json" ], "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" } ] ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to FossFLOW will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.10.8](https://github.com/stan-smith/FossFLOW/compare/v1.10.7...v1.10.8) (2026-03-01) ### Bug Fixes * **ui:** make settings tabs scrollable to prevent hiding ([#238](https://github.com/stan-smith/FossFLOW/issues/238)) [@0x-la1n](https://github.com/0x-la1n) ([42835fe](https://github.com/stan-smith/FossFLOW/commit/42835fe0b77458fdff7f32f884ae2ee6506efdc7)) ## [1.10.7](https://github.com/stan-smith/FossFLOW/compare/v1.10.6...v1.10.7) (2026-02-15) ### Bug Fixes * Fixed issues with history not fully working, undo/redo was hit or miss. Additionally added a huge amount of CI/CD testing using selenium so that we can simulate creating a diagram, placing nodes, connceting them, undo/redo, and rectangles/text as well, with love, Stan ([047df92](https://github.com/stan-smith/FossFLOW/commit/047df927858417ec068a749a3f6a0c6dd8741fec)) ## [1.10.6](https://github.com/stan-smith/FossFLOW/compare/v1.10.5...v1.10.6) (2026-02-14) ### Reverts * Revert "fix: replace dual-store undo/redo with unified history store" ([0c67bad](https://github.com/stan-smith/FossFLOW/commit/0c67bad5c5e433821cd9f5cd40ef9d0d0cd1f6ee)) ## [1.10.5](https://github.com/stan-smith/FossFLOW/compare/v1.10.4...v1.10.5) (2026-02-13) ### Bug Fixes * replace dual-store undo/redo with unified history store ([c3f5df2](https://github.com/stan-smith/FossFLOW/commit/c3f5df23ca451ce5d00759946eec7343d67a4332)) ## [1.10.4](https://github.com/stan-smith/FossFLOW/compare/v1.10.3...v1.10.4) (2026-02-06) ### Performance * refactored useScene and store subscriptions for performance gains ([7f97e07](https://github.com/stan-smith/FossFLOW/commit/7f97e074bb436fe237195af136bac53791608baa)) ### Documentation * removed cruft from readmes ([daa0dd3](https://github.com/stan-smith/FossFLOW/commit/daa0dd3b76162278f79f1a2c1b063df1505c8ce1)) * update contributing.md ([011f0af](https://github.com/stan-smith/FossFLOW/commit/011f0aff1d8cc38ac54eb4934a8ec775c1915b53)) ## [1.10.3](https://github.com/stan-smith/FossFLOW/compare/v1.10.2...v1.10.3) (2026-02-02) ### Bug Fixes * lasso wasnt moving nodes if there was also a text item in the selection, now it works ([f5ce168](https://github.com/stan-smith/FossFLOW/commit/f5ce1689c9c3ceaa0b180d4c165914c64f3252ec)) ## [1.10.2](https://github.com/stan-smith/FossFLOW/compare/v1.10.1...v1.10.2) (2026-01-31) ### Bug Fixes * memoized tools and other components as they were causing again more re-renders, this improves performance a touch ([e011f8c](https://github.com/stan-smith/FossFLOW/commit/e011f8cea2acd9e46efd9a9713dc3aaf94d923d5)) ## [1.10.1](https://github.com/stan-smith/FossFLOW/compare/v1.10.0...v1.10.1) (2026-01-26) ### Bug Fixes * resolve flickering issue ([#203](https://github.com/stan-smith/FossFLOW/issues/203)) ([#215](https://github.com/stan-smith/FossFLOW/issues/215)) @Abrar74774 ([dd2b782](https://github.com/stan-smith/FossFLOW/commit/dd2b782398f932597a8726906107a088a7b68b59)) ## [1.10.0](https://github.com/stan-smith/FossFLOW/compare/v1.9.2...v1.10.0) (2026-01-22) ### Features * Added SVG export, fixes [#211](https://github.com/stan-smith/FossFLOW/issues/211) ([b14832f](https://github.com/stan-smith/FossFLOW/commit/b14832f541068d41f88379a8c907648549f433b6)) ## [1.9.2](https://github.com/stan-smith/FossFLOW/compare/v1.9.1...v1.9.2) (2026-01-15) ### Documentation * add missing language cross-references to all READMEs ([806cf08](https://github.com/stan-smith/FossFLOW/commit/806cf08681a14b68a264279930c9194deb416775)) ### Code Refactoring * bumped react18 to react19 along with associated deps and changes needed, long time coming, fixes [#72](https://github.com/stan-smith/FossFLOW/issues/72), thanks [@mmastrac](https://github.com/mmastrac) for providing some of the groundwork - Stan ([2fa3a3c](https://github.com/stan-smith/FossFLOW/commit/2fa3a3c970ea5dba944bb666f42a1f6ec7725595)) ## [1.9.1](https://github.com/stan-smith/FossFLOW/compare/v1.9.0...v1.9.1) (2026-01-14) ### Bug Fixes * resolve security vulnerabilities in dependencies ([023c1e9](https://github.com/stan-smith/FossFLOW/commit/023c1e902f2cd2dd35cb5440f2d4afe6ac12c55d)) ## [1.9.0](https://github.com/stan-smith/FossFLOW/compare/v1.8.1...v1.9.0) (2026-01-14) ### Features * add German translations ([1624d16](https://github.com/stan-smith/FossFLOW/commit/1624d1662c024b1d42e6f6f6a2a97e68437d873b)) ## [1.8.1](https://github.com/stan-smith/FossFLOW/compare/v1.8.0...v1.8.1) (2026-01-14) ### Bug Fixes * make dotted line transparent to click events ([#190](https://github.com/stan-smith/FossFLOW/issues/190)) [@majiayu000](https://github.com/majiayu000) ([554325a](https://github.com/stan-smith/FossFLOW/commit/554325ad129529d8938756204f6be89e622d6f0b)), closes [#61](https://github.com/stan-smith/FossFLOW/issues/61) ## [1.8.0](https://github.com/stan-smith/FossFLOW/compare/v1.7.0...v1.8.0) (2026-01-12) ### Features * Add labels to icons indicating if not isometric (flat) ([#201](https://github.com/stan-smith/FossFLOW/issues/201)) ([a553e3c](https://github.com/stan-smith/FossFLOW/commit/a553e3c00ce8a9e776ba700e8fbdfc304c3e953e)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-11) ### Features * add indonesian language ([#186](https://github.com/stan-smith/FossFLOW/issues/186)) [@akmalsyrf](https://github.com/akmalsyrf) ([2ce342d](https://github.com/stan-smith/FossFLOW/commit/2ce342dc98278ac73841fb083d51969da811f30e)) * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * build error caused by missing property in src/i18n/es-ES.ts ([#202](https://github.com/stan-smith/FossFLOW/issues/202)) ([574b298](https://github.com/stan-smith/FossFLOW/commit/574b298e90a346d2cebd5c8b76a2bb2c80c25d6e)) * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) * resolve issue [#198](https://github.com/stan-smith/FossFLOW/issues/198) where moving sliders pan view ([#199](https://github.com/stan-smith/FossFLOW/issues/199)) ([af62f2f](https://github.com/stan-smith/FossFLOW/commit/af62f2f9b54d45f219fc442510bc7b359cc2b6d7)) ### Documentation * fix remaining CONTRIBUTING.md links in readme ([#197](https://github.com/stan-smith/FossFLOW/issues/197)) @Abrar74774 Thank you! ([cbf922d](https://github.com/stan-smith/FossFLOW/commit/cbf922d400aa9d5dc616e2269685e0700c45b91b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-10) ### Features * add indonesian language ([#186](https://github.com/stan-smith/FossFLOW/issues/186)) [@akmalsyrf](https://github.com/akmalsyrf) ([2ce342d](https://github.com/stan-smith/FossFLOW/commit/2ce342dc98278ac73841fb083d51969da811f30e)) * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * build error caused by missing property in src/i18n/es-ES.ts ([#202](https://github.com/stan-smith/FossFLOW/issues/202)) ([574b298](https://github.com/stan-smith/FossFLOW/commit/574b298e90a346d2cebd5c8b76a2bb2c80c25d6e)) * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) * resolve issue [#198](https://github.com/stan-smith/FossFLOW/issues/198) where moving sliders pan view ([#199](https://github.com/stan-smith/FossFLOW/issues/199)) ([af62f2f](https://github.com/stan-smith/FossFLOW/commit/af62f2f9b54d45f219fc442510bc7b359cc2b6d7)) ### Documentation * fix remaining CONTRIBUTING.md links in readme ([#197](https://github.com/stan-smith/FossFLOW/issues/197)) @Abrar74774 Thank you! ([cbf922d](https://github.com/stan-smith/FossFLOW/commit/cbf922d400aa9d5dc616e2269685e0700c45b91b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-10) ### Features * add indonesian language ([#186](https://github.com/stan-smith/FossFLOW/issues/186)) [@akmalsyrf](https://github.com/akmalsyrf) ([2ce342d](https://github.com/stan-smith/FossFLOW/commit/2ce342dc98278ac73841fb083d51969da811f30e)) * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * build error caused by missing property in src/i18n/es-ES.ts ([#202](https://github.com/stan-smith/FossFLOW/issues/202)) ([574b298](https://github.com/stan-smith/FossFLOW/commit/574b298e90a346d2cebd5c8b76a2bb2c80c25d6e)) * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) * resolve issue [#198](https://github.com/stan-smith/FossFLOW/issues/198) where moving sliders pan view ([#199](https://github.com/stan-smith/FossFLOW/issues/199)) ([af62f2f](https://github.com/stan-smith/FossFLOW/commit/af62f2f9b54d45f219fc442510bc7b359cc2b6d7)) ### Documentation * fix remaining CONTRIBUTING.md links in readme ([#197](https://github.com/stan-smith/FossFLOW/issues/197)) @Abrar74774 Thank you! ([cbf922d](https://github.com/stan-smith/FossFLOW/commit/cbf922d400aa9d5dc616e2269685e0700c45b91b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-08) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) * resolve issue [#198](https://github.com/stan-smith/FossFLOW/issues/198) where moving sliders pan view ([#199](https://github.com/stan-smith/FossFLOW/issues/199)) ([af62f2f](https://github.com/stan-smith/FossFLOW/commit/af62f2f9b54d45f219fc442510bc7b359cc2b6d7)) ### Documentation * fix remaining CONTRIBUTING.md links in readme ([#197](https://github.com/stan-smith/FossFLOW/issues/197)) @Abrar74774 Thank you! ([cbf922d](https://github.com/stan-smith/FossFLOW/commit/cbf922d400aa9d5dc616e2269685e0700c45b91b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-06) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) ### Documentation * fix remaining CONTRIBUTING.md links in readme ([#197](https://github.com/stan-smith/FossFLOW/issues/197)) @Abrar74774 Thank you! ([cbf922d](https://github.com/stan-smith/FossFLOW/commit/cbf922d400aa9d5dc616e2269685e0700c45b91b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-03) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ### Bug Fixes * resolve issue [#136](https://github.com/stan-smith/FossFLOW/issues/136) where "Add Node" popup has huge offset ([#195](https://github.com/stan-smith/FossFLOW/issues/195)) ([fa5478e](https://github.com/stan-smith/FossFLOW/commit/fa5478e709f187a9a5b458a967dd99c2ed9da69b)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-03) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2026-01-02) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-28) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-28) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-27) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-26) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-25) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-08) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * transparent background for exporting as png ([#180](https://github.com/stan-smith/FossFLOW/issues/180)) @F4tal1t thank you for contributing as always! ([ba1b376](https://github.com/stan-smith/FossFLOW/commit/ba1b3762db9ac34360703553e5428cf39f556534)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-06) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-12-06) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-11-28) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-11-28) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) * **ui:** enhance custom color picker and fix docs ([#169](https://github.com/stan-smith/FossFLOW/issues/169)) thank you [@non-stop-dev](https://github.com/non-stop-dev) ([f56812c](https://github.com/stan-smith/FossFLOW/commit/f56812c24e1d2eb402fce990d3607155d9f94014)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-11-20) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-11-20) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) ## [1.7.0](https://github.com/stan-smith/FossFLOW/compare/v1.6.1...v1.7.0) (2025-11-20) ### Features * read-only mode ([#168](https://github.com/stan-smith/FossFLOW/issues/168)) ([85d32e6](https://github.com/stan-smith/FossFLOW/commit/85d32e64df0f4d22bd7c2d6b3a51275c09813f72)) ## [1.6.1](https://github.com/stan-smith/FossFLOW/compare/v1.6.0...v1.6.1) (2025-11-18) ### Bug Fixes * Add error boundary to handle React-Quill DOM manipulation errors ([6c38a11](https://github.com/stan-smith/FossFLOW/commit/6c38a11f4b8fde448b18958cfb28cb6dd1862613)) ## [1.6.0](https://github.com/stan-smith/FossFLOW/compare/v1.5.2...v1.6.0) (2025-11-15) ### Features * Variable DPI images! Finally! Fixes [#70](https://github.com/stan-smith/FossFLOW/issues/70) you're welcome [@fatflyingpigs](https://github.com/fatflyingpigs) ;) ([88ab63c](https://github.com/stan-smith/FossFLOW/commit/88ab63c969fd95538f369b8b5f0e4bba2b2e3b63)) ## [1.5.2](https://github.com/stan-smith/FossFLOW/compare/v1.5.1...v1.5.2) (2025-11-15) ### Bug Fixes * Fixes [#58](https://github.com/stan-smith/FossFLOW/issues/58) now allows for CTRL+S and CTRL+O to save/load diagrams, thanks [@fatflyingpigs](https://github.com/fatflyingpigs) for bringing this to my attention ([ed944a0](https://github.com/stan-smith/FossFLOW/commit/ed944a0b61d93c97917390eabc5bbc165f78ebc1)) ## [1.5.1](https://github.com/stan-smith/FossFLOW/compare/v1.5.0...v1.5.1) (2025-10-18) ### Bug Fixes * Added lazy icon loading, users now select which icons they want loaded in, by default only the isoflow ones get loaded in, users can quickly change this, or disable this behaviour, this results in much faster loads. Fixes [#79](https://github.com/stan-smith/FossFLOW/issues/79) ([e0462f6](https://github.com/stan-smith/FossFLOW/commit/e0462f6bbd58543b98bfb395fca4fc6a10e62a50)) ## [1.5.0](https://github.com/stan-smith/FossFLOW/compare/v1.4.0...v1.5.0) (2025-10-17) ### Features * Added Portugues, French, Hindi, Bengali, and Russian support -Stan ([b299bc3](https://github.com/stan-smith/FossFLOW/commit/b299bc33018b47708d546a43c80ee46629be818f)) * Added Spanish support! added more I18n compatability -Stan ([be14d87](https://github.com/stan-smith/FossFLOW/commit/be14d8705319da406a1cad142731ee0a698bcd3c)) * Lots of language support! ([956a2af](https://github.com/stan-smith/FossFLOW/commit/956a2af52f534be02b7d417f413a0ee66dd2e17d)) ## [1.4.0](https://github.com/stan-smith/FossFLOW/compare/v1.3.0...v1.4.0) (2025-10-11) ### Features * added in esc to get ya out of menus/interactions/connectors Fixes [#154](https://github.com/stan-smith/FossFLOW/issues/154) ([5cf61c3](https://github.com/stan-smith/FossFLOW/commit/5cf61c3055c9ef1ad6a2cf5b67659e3a825a28fa)) ## [1.3.0](https://github.com/stan-smith/FossFLOW/compare/v1.2.0...v1.3.0) (2025-10-09) ### Features * **ci:** added selenium based testing procedure for integration tests ([af6dabe](https://github.com/stan-smith/FossFLOW/commit/af6dabe0fd43eb899ea0d4078ba4eb0ec195bc1d)) ## [1.2.0](https://github.com/stan-smith/FossFLOW/compare/v1.1.1...v1.2.0) (2025-10-09) ### Features * upgraded to ESlint 9, fixed some vulns ([4e2a2d1](https://github.com/stan-smith/FossFLOW/commit/4e2a2d1a11925c960c88ff737069bc48d851c105)) ## [1.1.1](https://github.com/stan-smith/FossFLOW/compare/v1.1.0...v1.1.1) (2025-10-08) ### Bug Fixes * bumped all packages, no vulns in npm audit now ([09edf76](https://github.com/stan-smith/FossFLOW/commit/09edf76ef12df55859b77fc74823f5425dbbf8b1)) ## [1.1.0](https://github.com/stan-smith/FossFLOW/compare/v1.0.5...v1.1.0) (2025-10-08) ### Features * **ci:** fixing ci stuff ([85fa0e6](https://github.com/stan-smith/FossFLOW/commit/85fa0e668129577f7ab6427946b0e6c5b2c1bccb)) ## 1.0.0 (2025-10-08) ### Features * accepts an array of textboxes as part of initialData ([aaf48bd](https://github.com/stan-smith/FossFLOW/commit/aaf48bd33e97fcf3a87a9adef68451440f15a3ed)) * Add advanced pan controls with configurable options ([83c9b3a](https://github.com/stan-smith/FossFLOW/commit/83c9b3aed21f5881cf8c0025ba37043d580de914)), closes [#25](https://github.com/stan-smith/FossFLOW/issues/25) * add click-based connector creation mode with empty space support ([#108](https://github.com/stan-smith/FossFLOW/issues/108)) ([5ff21cc](https://github.com/stan-smith/FossFLOW/commit/5ff21cc35fcf86b7e71b539ff5700039dfc3667e)), closes [#84](https://github.com/stan-smith/FossFLOW/issues/84) * add close button to item control components ([a808b83](https://github.com/stan-smith/FossFLOW/commit/a808b8376fcf912d628188d691fec98c2f619bdb)) * add comprehensive tests for connector reducer and improve CI/CD coverage reporting ([70b1f56](https://github.com/stan-smith/FossFLOW/commit/70b1f560a24fa63c57241a3974ebcf381e701e5f)) * Add configurable hotkey system for tools ([ef258df](https://github.com/stan-smith/FossFLOW/commit/ef258dff17884660c2c99e78ecef736852156cc7)), closes [#59](https://github.com/stan-smith/FossFLOW/issues/59) * Add custom icon import functionality with automatic scaling ([dd80e86](https://github.com/stan-smith/FossFLOW/commit/dd80e86de275524835084d26d52d560e3bc970f8)) * add Help dialog and shortcut key support ([d500460](https://github.com/stan-smith/FossFLOW/commit/d5004607db8bfbacb1c80a4d7ebedd6ba590d514)) * add i18n to main menu & docs: update Chinese README ([#130](https://github.com/stan-smith/FossFLOW/issues/130)) ([a001da7](https://github.com/stan-smith/FossFLOW/commit/a001da7edb0c81574a9fcbcbec30272cacf44591)) * add language detector & update Chinese README ([#127](https://github.com/stan-smith/FossFLOW/issues/127)) ([e18e51f](https://github.com/stan-smith/FossFLOW/commit/e18e51fb7fc4d5f5959c2f2e5cb31d20ee2c1b6a)) * add LLM-friendly export features and format code with Prettier ([77b304c](https://github.com/stan-smith/FossFLOW/commit/77b304c98a53cdf753172c48507ef29f4503a00f)) * Add option to toggle connector arrows ([dea6a1e](https://github.com/stan-smith/FossFLOW/commit/dea6a1e934857480dcf4dbb35801a176d182d4f9)), closes [#74](https://github.com/stan-smith/FossFLOW/issues/74) * Add server-side storage for persistent diagram management ([bf3a30f](https://github.com/stan-smith/FossFLOW/commit/bf3a30fa129932dd0c3d01ca97ed30e579c8e418)), closes [#48](https://github.com/stan-smith/FossFLOW/issues/48) * added error boundary ([#90](https://github.com/stan-smith/FossFLOW/issues/90)) ([179b512](https://github.com/stan-smith/FossFLOW/commit/179b512c7d1e17f9aab18db05e12017399890497)) * adds ability to remove a node ([2e2a98f](https://github.com/stan-smith/FossFLOW/commit/2e2a98f5e99633c51d9df19b8f16ecc394c9ab1a)) * adds basic editor example ([dc04314](https://github.com/stan-smith/FossFLOW/commit/dc04314f46892b1c11bb9c841e7ac31ed97b88e7)) * adds codesandbox config ([b23c3a9](https://github.com/stan-smith/FossFLOW/commit/b23c3a9593705f03ba26cfe9d7ea87baea3ae2a9)) * adds deleteView reducer ([80f257b](https://github.com/stan-smith/FossFLOW/commit/80f257b016987b9e873af76613b42d5785481d16)) * adds discord option to main menu ([86900a9](https://github.com/stan-smith/FossFLOW/commit/86900a97801dd6a1885f6f99c32febc653b5efce)) * adds documentation ([a279729](https://github.com/stan-smith/FossFLOW/commit/a279729dc6bc319daf1ae84d571d9742302c08ec)) * adds example for readonly mode ([db1fc8f](https://github.com/stan-smith/FossFLOW/commit/db1fc8fb36d59cf63b0fdfed47edffd944d13bee)) * adds icons ([b7ac563](https://github.com/stan-smith/FossFLOW/commit/b7ac56337d0b429275be60d31d25f6a13b3d9775)) * adds image export options to toggle grid and change bg color ([ee7a92d](https://github.com/stan-smith/FossFLOW/commit/ee7a92d1f39d141fc710e92071cc8a13c83e53da)) * adds link to Github repo in main menu ([109048c](https://github.com/stan-smith/FossFLOW/commit/109048c8d2c5398285977c6afb94d465bdd0888c)) * adds linting dependency ([bfb0295](https://github.com/stan-smith/FossFLOW/commit/bfb029584d3caa0d76acafae6d369e5d02e3f55f)) * adds standalone build and a Dockerfile ([e8d678d](https://github.com/stan-smith/FossFLOW/commit/e8d678d191c60a9dfb96e05f099d509eee7ea4a9)) * adds title to scene config ([ee3306b](https://github.com/stan-smith/FossFLOW/commit/ee3306b6914e9f7b39915cb96b76530569fa2db2)) * adds to standaloneExports ([d5084e2](https://github.com/stan-smith/FossFLOW/commit/d5084e28ea2a5abf02d019d21621a7ec6824bf91)) * adds utility methods on the window for debugging ([38c4278](https://github.com/stan-smith/FossFLOW/commit/38c4278e16c639e547aac626314f9adba8d96dc3)) * adds validation check for connectors with less than 2 anchors ([880ed5b](https://github.com/stan-smith/FossFLOW/commit/880ed5bea740bf39bdecaaaa560c59e2a8937a6b)) * adds zoom on scroll ([53641a4](https://github.com/stan-smith/FossFLOW/commit/53641a4a86bcec582ff4c26f899799b9f721ecc8)) * allows all interactions to be disabled ([0e5ca5a](https://github.com/stan-smith/FossFLOW/commit/0e5ca5a442eab1f83e0a754b0c1bd1d3c25479f3)) * allows an optional `viewId` to be passed as part of initialData ([30cd3f2](https://github.com/stan-smith/FossFLOW/commit/30cd3f28f2f0315ae9c85ebfde18709c2bd36be2)) * allows better drag and drop interaction for connector anchors ([7661bcb](https://github.com/stan-smith/FossFLOW/commit/7661bcb0693e6a9d3212f402b0ec297f54cb6bed)) * allows codesandbox to open a browser preview ([fbf48d2](https://github.com/stan-smith/FossFLOW/commit/fbf48d26cadf502dcf0bbcb3bf696a8063103129)) * allows color selection for nodes ([39afd84](https://github.com/stan-smith/FossFLOW/commit/39afd84553c5da8a1d36c0560e9b792e74277d8f)) * allows custom node labels (with example) ([55f9b37](https://github.com/stan-smith/FossFLOW/commit/55f9b37c5623127c57a47c716679cb8ed31169c2)) * allows expandable node labels ([90f6c0e](https://github.com/stan-smith/FossFLOW/commit/90f6c0ed860eaff307e2c6fafefb9663ec39c864)) * allows icons to be drag and dropped onto canvas ([07dc1d1](https://github.com/stan-smith/FossFLOW/commit/07dc1d163ccf39437ea02a18f2d33d7c2542fde8)) * allows layer order of rectangles to be changed ([56591cb](https://github.com/stan-smith/FossFLOW/commit/56591cb10296f05727af31ead306570031d6c787)) * allows loading of local scene file ([8b362ea](https://github.com/stan-smith/FossFLOW/commit/8b362ea022dfe85f10b34c7eb55b42052f447795)) * allows main menu to be customised ([46ce637](https://github.com/stan-smith/FossFLOW/commit/46ce637cd0be6ce9563c22328f5ba34a3f99fc1a)) * allows main menu to be hidden ([3b02ae1](https://github.com/stan-smith/FossFLOW/commit/3b02ae1f226f304d2f89ed1cc66d71d01ddd10eb)) * allows node icon to be changed ([fd88787](https://github.com/stan-smith/FossFLOW/commit/fd88787eb1fa6a0e52853bb84ddc29adf1480e19)) * allows node labels to be expanded ([a1783f1](https://github.com/stan-smith/FossFLOW/commit/a1783f180b4533a827f78d6b3ff2bff89704fb4f)) * allows project to be centered ([c638bf0](https://github.com/stan-smith/FossFLOW/commit/c638bf015cad7d35a7fbd2b24e08cf5ad796c8a7)) * allows saving of scene ([a1a98f2](https://github.com/stan-smith/FossFLOW/commit/a1a98f288be5383f5acb6f954b47c07bd04d9f44)) * allows textBoxes to be dragged from any point ([2f6cfa1](https://github.com/stan-smith/FossFLOW/commit/2f6cfa127462e5a70723fb79b499433e23ede8bc)) * allows translation of rectangles ([19478ab](https://github.com/stan-smith/FossFLOW/commit/19478abb78570232d42b3ec877c02a6d971bac68)) * allows width and height to be passed through as props ([f1f9c0f](https://github.com/stan-smith/FossFLOW/commit/f1f9c0f92b91ec1338e77bc2fa0488dbcde2e100)) * applies animation on zoom and scroll ([efde778](https://github.com/stan-smith/FossFLOW/commit/efde7780a025f90c8df08659673f6f8d626b1b16)) * attempts to boost performance by explicitly activating GPU rendering ([0dc27b7](https://github.com/stan-smith/FossFLOW/commit/0dc27b7be464c0bd1d64896ef86b7c4436da3b5f)) * blocks pointer-events on title ([b5a57d0](https://github.com/stan-smith/FossFLOW/commit/b5a57d067f6933b328f33e115cf573ebf2e245f8)) * bumps patch version ([94c3097](https://github.com/stan-smith/FossFLOW/commit/94c3097f39bbadef382b3bbb82c0476a871b5280)) * bumps up isopacks version ([2a9d10b](https://github.com/stan-smith/FossFLOW/commit/2a9d10bf9bebe555828275368f9fc2ad01c392d0)) * changes starting mode to PAN ([5f015c4](https://github.com/stan-smith/FossFLOW/commit/5f015c45399a3e771ad9632464e2aac5102b61a1)) * **ci:** implemented automatic versioning plus releases ([9d9fe84](https://github.com/stan-smith/FossFLOW/commit/9d9fe84eefa6a3a6b2ec158910956debb059bbc2)) * closes any open itemControls when panning mode selected ([aba2633](https://github.com/stan-smith/FossFLOW/commit/aba2633abb61fa40f2bbc18ef4a821fd0579efa7)) * closes any open itm controls if main menu is opened ([a21d01b](https://github.com/stan-smith/FossFLOW/commit/a21d01bfe35da5f4097c65af36853678e83af347)) * closes main menu BEFORE native file dialog appears ([1382c41](https://github.com/stan-smith/FossFLOW/commit/1382c4165dd61980083ad64822a19e693fd8da74)) * configures webpack build for docker image ([1a64706](https://github.com/stan-smith/FossFLOW/commit/1a647062d15addb0fe2abb2f6ec623d16db0a41c)) * debug mode size indicator now wraps around all items ([536b51d](https://github.com/stan-smith/FossFLOW/commit/536b51dc29f6eb727919b54d92f7739f361aaa54)) * disables lasso mode for now ([d017b3e](https://github.com/stan-smith/FossFLOW/commit/d017b3eaa8f3efdf61ac37c095cc3f75c6330ef7)) * disables scroll / zoom animations on drag and drop layer ([1d9324e](https://github.com/stan-smith/FossFLOW/commit/1d9324eb84cf7c28bf9268529324651d75490cc9)) * displays connector anchors only when connector is selected / active ([366b816](https://github.com/stan-smith/FossFLOW/commit/366b816521ac15aa827207441feca21f1b93195b)) * displays main manu in top left corner ([6bbef04](https://github.com/stan-smith/FossFLOW/commit/6bbef041df4658df2f1dad85192069e316aa5e01)) * displays title at bottom of view ([35b73de](https://github.com/stan-smith/FossFLOW/commit/35b73defe74b57175f0ab047b454b8b8530cc23d)) * enable panning by dragging on empty space with left mouse button ([ddb28a2](https://github.com/stan-smith/FossFLOW/commit/ddb28a2eda31b3777d73593ad46f59c91d7c23ed)) * enables dragging of connector anchors ([f808159](https://github.com/stan-smith/FossFLOW/commit/f80815976dc31db1207f466f222dcc0c75da5b75)) * enables expandable labels on nodes ([36c5c17](https://github.com/stan-smith/FossFLOW/commit/36c5c179d59c21f94076647f4b8ce8a00d9e0398)) * enhance connector functionality with multiple labels and line types ([#128](https://github.com/stan-smith/FossFLOW/issues/128)) ([d5e02ea](https://github.com/stan-smith/FossFLOW/commit/d5e02ea30346fbc2528dc0337792ebccf309d94d)), closes [#107](https://github.com/stan-smith/FossFLOW/issues/107) [#113](https://github.com/stan-smith/FossFLOW/issues/113) * enhance context menu functionality with item and empty states ([674b46f](https://github.com/stan-smith/FossFLOW/commit/674b46f6047ba837bee950d2eeceecbeaae06b00)) * ensures connectors have start and end nodes ([0bace0b](https://github.com/stan-smith/FossFLOW/commit/0bace0bc5f0e331e2302dbc1c4eb3b66e4e2c6db)) * executes entry / exit logic for interactions ([3112842](https://github.com/stan-smith/FossFLOW/commit/311284217491fb099325514f991d283bea816cdb)) * exports isoflow props as type ([75ed461](https://github.com/stan-smith/FossFLOW/commit/75ed46180f714e2b110e9f52acc3464f2126e995)) * exports Scene typings ([28122ed](https://github.com/stan-smith/FossFLOW/commit/28122ed6b7ab8f765f1371ed140f6b2aba122d83)) * exports types ([28f4db9](https://github.com/stan-smith/FossFLOW/commit/28f4db99b97ca790c6bb0c45b8219a2018aeffd5)) * exposes api to update single node and hook into scene changes ([37fd9ea](https://github.com/stan-smith/FossFLOW/commit/37fd9ea16c02e8604ab8eaa4461062d4c7b46280)) * filters textbox data on scene export ([ca087b4](https://github.com/stan-smith/FossFLOW/commit/ca087b4322e6404d0b76ff85a3e4dfeb8fa930db)) * fixes cursor position when editor not 100% of viewport ([287de5b](https://github.com/stan-smith/FossFLOW/commit/287de5bd0ea2e2f4a03dc831642045e324060e00)) * fixes UX around drag and drop onto canvas ([37bc36c](https://github.com/stan-smith/FossFLOW/commit/37bc36c563735e31befa28b63bf6da9aee33dc9a)) * fixes zIndexing of scene items ([516ab8b](https://github.com/stan-smith/FossFLOW/commit/516ab8b63347c9df1a7de56259ae1951740b3bdc)) * grid listens to window resize events ([0b97897](https://github.com/stan-smith/FossFLOW/commit/0b9789746a2f31d933b6011706b7b8cbcb4a855d)) * hides label height control when no label present ([82977bf](https://github.com/stan-smith/FossFLOW/commit/82977bfa26d4e9d747c4ff9e740c8f744fb62fd8)) * hides scene title if editor is in 'NON_INTERACTIVE' mode ([91fa85a](https://github.com/stan-smith/FossFLOW/commit/91fa85a1c5cfd7ef2fd68917c8ce1f6eb8cd5b79)) * implement comprehensive undo/redo system with keyboard shortcuts and UI integration ([b9356d3](https://github.com/stan-smith/FossFLOW/commit/b9356d3c76ce72cf1f88778b6814f1e543b23433)) * Implement quick icon selection workflow for improved UX ([8576e30](https://github.com/stan-smith/FossFLOW/commit/8576e300ece9d79a7817c814e5a1f1baa46f7457)), closes [#56](https://github.com/stan-smith/FossFLOW/issues/56) * implements 'clear canvas' menu item ([e65a782](https://github.com/stan-smith/FossFLOW/commit/e65a782feb640dba00d61a3a2a46a5dec6c3393f)) * implements adding node to scene ([99c8060](https://github.com/stan-smith/FossFLOW/commit/99c80602437fea802f6975d3132d93e7ec8c35b6)) * implements basic support for touch devices ([a841504](https://github.com/stan-smith/FossFLOW/commit/a8415041542709ea089817790cc4db920349ccb4)) * implements callback example ([57f6a00](https://github.com/stan-smith/FossFLOW/commit/57f6a0059b84cc5ec8ce8945972f6a17132becc8)) * implements connector colors ([eef4dba](https://github.com/stan-smith/FossFLOW/commit/eef4dba7901b2603ebb8342ed66d8c19967eebf1)) * implements connector controls ([198a1f4](https://github.com/stan-smith/FossFLOW/commit/198a1f4e2dc5a6a0a0fe21a53d719de5fde6eaff)) * implements connector direction icon ([eeaaad9](https://github.com/stan-smith/FossFLOW/commit/eeaaad93c5ff8f63305726aa7f5d2363208eea25)) * implements connector labels ([4ad8b22](https://github.com/stan-smith/FossFLOW/commit/4ad8b22482b0dcb9e1821fe59ac495ff1bc8dc9d)) * implements connector logic ([19324be](https://github.com/stan-smith/FossFLOW/commit/19324bed91d55136f21e962002d6774589f6e6f2)) * implements connector styles ([46de2cf](https://github.com/stan-smith/FossFLOW/commit/46de2cf19afdf23bcefa5dbb7cfcb70f4cedf784)) * implements connector width controls ([58dd3b5](https://github.com/stan-smith/FossFLOW/commit/58dd3b59da92c2010f501e19df56d462a632aba1)) * implements connectors ([43aab47](https://github.com/stan-smith/FossFLOW/commit/43aab4758ef37f6b50b0e64016f19e52e17fa5bc)) * implements group rendering (UI not implemented yet) ([d9daa68](https://github.com/stan-smith/FossFLOW/commit/d9daa68b0dcd6c6433096dc5ca33041fd1a347aa)) * implements icons searchbox ([887a916](https://github.com/stan-smith/FossFLOW/commit/887a91607c2a2a84dbfe11ea4e536768d290890a)) * implements image export ([9f11cce](https://github.com/stan-smith/FossFLOW/commit/9f11cce6e0ba2f0beefad06140768ae9c9ef6763)) * implements lasso selection (UI disabled) ([a76e5e7](https://github.com/stan-smith/FossFLOW/commit/a76e5e72899010a83f73d43d0e465dc18cd9d4f4)) * implements lastUpdated field on views ([3cff06d](https://github.com/stan-smith/FossFLOW/commit/3cff06dc5e93464b22e74de3364166ff0e255f11)) * implements multiselect ([f68daac](https://github.com/stan-smith/FossFLOW/commit/f68daacc2973aa5757075a69326aa71f191bf6a5)) * implements node delete ([b13cd66](https://github.com/stan-smith/FossFLOW/commit/b13cd66706b997b2ea290ea3a6a46fc046a6e493)) * implements node drag and drop ([b350320](https://github.com/stan-smith/FossFLOW/commit/b35032038706641989f03611a74c7137fefe2e7c)) * implements node labels ([f93f901](https://github.com/stan-smith/FossFLOW/commit/f93f901521ebf391984181456fcf09513aecd56d)) * implements node positioning via drag and drop ([a6fdbf0](https://github.com/stan-smith/FossFLOW/commit/a6fdbf0c007750721184218b3bce34a50fddb9f8)) * implements onSceneUpdated callback ([f1f77d4](https://github.com/stan-smith/FossFLOW/commit/f1f77d4ecab525a1f5e7f08efdf51dd905d1b824)) * implements readmore on node description overflow ([f9536c9](https://github.com/stan-smith/FossFLOW/commit/f9536c9cb706ff66cab91b0b3814ea2cc75e9af2)) * implements rectangle controls ([94995f0](https://github.com/stan-smith/FossFLOW/commit/94995f099c3a3105b1a88f1629b589532e2aa858)) * implements rectangle delete ([416b976](https://github.com/stan-smith/FossFLOW/commit/416b9765b797fa599ee0afabd6700c8497d6bbc7)) * implements rectangle tool basics ([bd7a118](https://github.com/stan-smith/FossFLOW/commit/bd7a11849cba439f825b31d23484fd0b3aeb81c8)) * implements text tool ([c240def](https://github.com/stan-smith/FossFLOW/commit/c240def317422521280b5264e2b2797472074f02)) * implements transform controls for rectangles ([cb36408](https://github.com/stan-smith/FossFLOW/commit/cb3640817358f5543fc6fc24bb36fd44f3dcd50f)) * imports isopacks as separate package ([0580440](https://github.com/stan-smith/FossFLOW/commit/0580440b28c42d6e70c792c9090f7a877c37413e)) * improves label handeling ([1d3e7d5](https://github.com/stan-smith/FossFLOW/commit/1d3e7d51836f2d33f9876c40b202634154eab307)) * improves mouse tracking ([77e8c02](https://github.com/stan-smith/FossFLOW/commit/77e8c02c736f4d299121691f173dcecf2895c963)) * improves panning mode UX ([2230637](https://github.com/stan-smith/FossFLOW/commit/2230637a529dd1717fcac0f9dfe9622252959cb2)) * improves panning mode UX ([4b0d3d8](https://github.com/stan-smith/FossFLOW/commit/4b0d3d86e9d2cc4b6ecba65072c2b9b461918ed3)) * improves scrolling on sidebars ([6c76790](https://github.com/stan-smith/FossFLOW/commit/6c76790800edb7493ab68d71ad35d0bbf886cb6d)) * improves textbox sizing ([20ac174](https://github.com/stan-smith/FossFLOW/commit/20ac1747044c956946f7d3aec60cd12da6a37491)) * improves UX on Tool menu ([ac4e9e8](https://github.com/stan-smith/FossFLOW/commit/ac4e9e8563092e0285c557a7d3ca03bbca634c06)) * improves UX when dragging rectangle anchor ([5feca66](https://github.com/stan-smith/FossFLOW/commit/5feca66326abda286bfe8b97cc6c48226bea31bf)) * improves UX when selecting elements ([541e469](https://github.com/stan-smith/FossFLOW/commit/541e46969740ea9f8d31002c3c80af2f466c5f4e)) * improves word wrap handling in labels ([ba30ffd](https://github.com/stan-smith/FossFLOW/commit/ba30ffd0320bde7364b3b5e93d4bafd4aff5674d)) * increases resolution of export images ([68c11e5](https://github.com/stan-smith/FossFLOW/commit/68c11e5a1d8267f232e1bab917a8ae12c7b58b2a)) * installs zustand devtools ([ad78b52](https://github.com/stan-smith/FossFLOW/commit/ad78b524b5687cae2f789873707832d33411fb65)) * introduces new TextBox tool ([23d3bda](https://github.com/stan-smith/FossFLOW/commit/23d3bdaae009e402199e163f7287adae4a4289cf)) * isoflow takes 100% height as default ([85e36bd](https://github.com/stan-smith/FossFLOW/commit/85e36bd876fdda4a10ab44e288d62a6ee1586ea2)) * keeps icons when canvas is cleared ([ccbedd7](https://github.com/stan-smith/FossFLOW/commit/ccbedd7c98b4c15f7cb30bcff4ef3e74fcdb9002)) * limits connector width ([14a72c7](https://github.com/stan-smith/FossFLOW/commit/14a72c7b2ef1f8356b47c7772bafb58936461f6e)) * makes App component default export ([8d731af](https://github.com/stan-smith/FossFLOW/commit/8d731afc44c324a7fc76c51dbd6b6a65f4c1ecc9)) * moves non-interactive check further up the tree ([7a1ab19](https://github.com/stan-smith/FossFLOW/commit/7a1ab1973aec11b65a93c65936b3a5420b303818)) * moves zoom controls to lower left ([c2f456a](https://github.com/stan-smith/FossFLOW/commit/c2f456a496710a7a288a04c32e3386218741bcf8)) * performance upgrade (solves issue with nodes not being GC'd) ([bf42411](https://github.com/stan-smith/FossFLOW/commit/bf42411d5ef055f253b9c8f4f80b321137b2a9f5)) * prevents onSceneUpdated called the first time scene is loaded ([d1bc30e](https://github.com/stan-smith/FossFLOW/commit/d1bc30e8d8476f03708d71c8703e655de487193d)) * prevents user highlighting while dragging ([7a5b996](https://github.com/stan-smith/FossFLOW/commit/7a5b99668b2b4cfcc95aa0092920344d41305f85)) * reduces height of node labels ([d551897](https://github.com/stan-smith/FossFLOW/commit/d551897bdabd1b5727810e645cc6543ba441b702)) * reduces size of icons slightly ([32f4b12](https://github.com/stan-smith/FossFLOW/commit/32f4b129b99e9433ebd073c9f3ba793a67ddcf57)) * refactors schema to accomodate model ([ad5a4e0](https://github.com/stan-smith/FossFLOW/commit/ad5a4e06f38f3da7bb5c8cb50da7ccd50991a722)) * reinstates interactions ([97d65fa](https://github.com/stan-smith/FossFLOW/commit/97d65fabf40f0ea7d9311083468f90c8af3d22e5)) * removes buggy scrollTo node when label expanded ([b0c5c9d](https://github.com/stan-smith/FossFLOW/commit/b0c5c9d4c3d832a8dc9d1dd3ba052c52f3321653)) * removes color from node schema ([ccea412](https://github.com/stan-smith/FossFLOW/commit/ccea412b8d07f67efc28b5f4db2aecc7e86233b3)) * removes custom label container for now ([194b4ed](https://github.com/stan-smith/FossFLOW/commit/194b4eda2d64ca9653e407d3d5c8b1f14fceb653)) * removes zustand dev tools ([4a4f168](https://github.com/stan-smith/FossFLOW/commit/4a4f168671d7aef944e3c91dfdd3661305dbef96)) * renders a basic node to scene ([5dbeb97](https://github.com/stan-smith/FossFLOW/commit/5dbeb973b05c2e3874cd7d137ed29aa877dd6f73)) * replace import toolbar with tooltip guidance ([a2a47b4](https://github.com/stan-smith/FossFLOW/commit/a2a47b44496f4982c43fe7f9dd9915f281160169)), closes [#123](https://github.com/stan-smith/FossFLOW/issues/123) * replaces xstate with custom state machine implementation ([6b90ad9](https://github.com/stan-smith/FossFLOW/commit/6b90ad9b7b8aedd19a8ed0175eb537fde3656657)) * resets the UI after a canvas clear ([413567e](https://github.com/stan-smith/FossFLOW/commit/413567efd39d2b30c97f22f283a39868641d9760)) * resets view after file has been successfully loaded ([9910fec](https://github.com/stan-smith/FossFLOW/commit/9910fecadabb767655ee1c3693e91c754fa728ac)) * resets window cursor when Isoflow is unmounted ([5d51aab](https://github.com/stan-smith/FossFLOW/commit/5d51aab7224e7b79f7b1cd6c85768809a28f6bbe)) * sets min width on node labels ([620970e](https://github.com/stan-smith/FossFLOW/commit/620970e99d29783dddf90e1c9e6a02d210d8fb65)) * sets overflow hidden ([9614b5e](https://github.com/stan-smith/FossFLOW/commit/9614b5ef5dfb30418e138d60bc01aa0a590c5f18)) * shows animated outline around focussed nodes ([53bd3f2](https://github.com/stan-smith/FossFLOW/commit/53bd3f2c2f8d1bf29f58f5ace0a6e7b378f0449c)) * shows both model title and view title at bottom of screen ([159f6d4](https://github.com/stan-smith/FossFLOW/commit/159f6d4c75c9c5225b1fa0ae51cf4b89706c0947)) * stores colors as part of model ([4797b22](https://github.com/stan-smith/FossFLOW/commit/4797b223047ae7eccbb7be8db1b1028e99e2d501)) * styling updates ([9270924](https://github.com/stan-smith/FossFLOW/commit/927092475634cd38877367fa30f268e3d411fcb0)) * styling updates on all ui elements ([4de4882](https://github.com/stan-smith/FossFLOW/commit/4de4882b03685e27c84e50714b446a67d45fb2e6)) * updates 'read more' button styling ([cfb60f3](https://github.com/stan-smith/FossFLOW/commit/cfb60f37cf088ffd108bfb77f742c8810f280ec0)) * updates anchor connector schema ([5d6f3d0](https://github.com/stan-smith/FossFLOW/commit/5d6f3d0aaf02e0a379102380d081748cefb7aa10)) * updates connector styling ([d980947](https://github.com/stan-smith/FossFLOW/commit/d980947e86d27a73ea4da67a9bb1203df8f3debb)) * updates connector styling ([e21ed39](https://github.com/stan-smith/FossFLOW/commit/e21ed39c782f0cc2ef66a8ae16d97daa5e398605)) * updates connector width defaults ([af5c060](https://github.com/stan-smith/FossFLOW/commit/af5c06090036ea880d810f2cb6555e3e70288a7f)) * updates connector width to more sensible values ([851ae21](https://github.com/stan-smith/FossFLOW/commit/851ae21283d547a14c04cca4538b6759bf92c0da)) * updates copy ([ffef690](https://github.com/stan-smith/FossFLOW/commit/ffef6902eef7588bd5247bd4e6ea02f0ed7de174)) * updates copy on tooltip ([d127150](https://github.com/stan-smith/FossFLOW/commit/d127150dd0cf8c76f98b7aab127c59931b9f5995)) * updates cursor styling ([73d9b52](https://github.com/stan-smith/FossFLOW/commit/73d9b522eca44c6400c698204d663c9a05abacb1)) * updates docs ([485dc4c](https://github.com/stan-smith/FossFLOW/commit/485dc4c021894372a23a80f45f108545bf9a718c)) * updates documentation ([1de5c9d](https://github.com/stan-smith/FossFLOW/commit/1de5c9d8b50c32c20d2ec24a337b47b6795b536c)) * updates documentation ([52e1fc2](https://github.com/stan-smith/FossFLOW/commit/52e1fc2802a4d8638a21cbda9cdd9e9b5406cb14)) * updates drag and drop instruction copy ([6cdff05](https://github.com/stan-smith/FossFLOW/commit/6cdff059fe0f3af1b6da5b6cc93cba4d012041b9)) * updates example ([d707b14](https://github.com/stan-smith/FossFLOW/commit/d707b14743648440343dd4302ac8566ef5b8a4ce)) * updates example content ([cbdcc76](https://github.com/stan-smith/FossFLOW/commit/cbdcc767d40735498e8e1c5b9f838b11695a051d)) * updates example data ([1c28a47](https://github.com/stan-smith/FossFLOW/commit/1c28a478803f7eba46c63cc8e9e9f03583ea9627)) * updates example data ([945ec81](https://github.com/stan-smith/FossFLOW/commit/945ec811546423dc49a5d0066dec06779a16c7f8)) * updates example data ([edf6f28](https://github.com/stan-smith/FossFLOW/commit/edf6f28b9e61970aa7db3150a3f35601792df9d1)) * updates example scene ([1c6af28](https://github.com/stan-smith/FossFLOW/commit/1c6af28ca8d84ac6bc080bb6102a944acab4e5fb)) * updates example scene ([f9cc014](https://github.com/stan-smith/FossFLOW/commit/f9cc014dc7e3d529e520f26f381c1040dc585dc8)) * updates examples and documentation ([4da997f](https://github.com/stan-smith/FossFLOW/commit/4da997f3f38091be51927f3d8d7a26979d47f744)) * updates examples to start with fitToView=true ([a129c17](https://github.com/stan-smith/FossFLOW/commit/a129c1715ada015500fd253e54d1d08a3481da6e)) * updates icon category styling ([1f67c29](https://github.com/stan-smith/FossFLOW/commit/1f67c297fbff6497e3246d0273b3e6b8c1c4fd00)) * updates icons on zoom controls ([3de56fd](https://github.com/stan-smith/FossFLOW/commit/3de56fdde8020fed98d1b790b33f1f6a5072781a)) * updates image urls in docs ([1e60907](https://github.com/stan-smith/FossFLOW/commit/1e60907b4724585aa3199aa2868bce57a0b02602)) * updates main menu options ([031a90a](https://github.com/stan-smith/FossFLOW/commit/031a90a78ac5b6057ce547a0041e96e88ad6e3a5)) * updates meta tag on example html page ([35850c4](https://github.com/stan-smith/FossFLOW/commit/35850c44b1a1c9a7679af8adecb569b390787cd7)) * updates node connector styling ([78b243c](https://github.com/stan-smith/FossFLOW/commit/78b243ca22ac221725e0e9a31d545ccc7905f930)) * updates palette ([a061f57](https://github.com/stan-smith/FossFLOW/commit/a061f573040c29d7467588a54f03cc7938507b39)) * updates readme ([f5be45b](https://github.com/stan-smith/FossFLOW/commit/f5be45bbefc12e99ae54aed3f0f6d4c84f0b21b2)) * updates rectangle styling ([e60e16e](https://github.com/stan-smith/FossFLOW/commit/e60e16e469a422d06fc12ac49fcbec688fddf5c3)) * updates roadmap on README ([8992d84](https://github.com/stan-smith/FossFLOW/commit/8992d84cc653f354b2fb7b0fa2e9c9702059d628)) * updates sidebar styling ([c83452b](https://github.com/stan-smith/FossFLOW/commit/c83452b3cb1b9143377392da2f56f228e0fc004e)) * updates styling ([a69cbf8](https://github.com/stan-smith/FossFLOW/commit/a69cbf804ccc9361559863bf1658b516ab2b0de4)) * updates styling ([fee7c2a](https://github.com/stan-smith/FossFLOW/commit/fee7c2a244621c4c9db93bffcedf420f6ea5ebbb)) * updates styling ([52ec509](https://github.com/stan-smith/FossFLOW/commit/52ec50913e8be175097ed2c40d4bdd91906518fa)) * updates styling on node labels ([d0f3cdf](https://github.com/stan-smith/FossFLOW/commit/d0f3cdf791fc1e746921a2e49a2a38002d291532)) * updates theme colours ([1faeb31](https://github.com/stan-smith/FossFLOW/commit/1faeb31d3e8e1efd756127d48fd824eb6b49ec1e)) * updates view default name ([4d4a668](https://github.com/stan-smith/FossFLOW/commit/4d4a668950720c0ba81a357d19101508f3eb2b83)) * upgrades performance on debug tools ([2f9bc47](https://github.com/stan-smith/FossFLOW/commit/2f9bc47eb59ddf791abd6da9a1589a78e768d98e)) ### Bug Fixes * add missing i18n files to public folder for GitHub Pages ([606aebc](https://github.com/stan-smith/FossFLOW/commit/606aebcf49ad3f269d19e155f4b68eef813724a8)) * adds keys to controls panel components ([c1c5bd3](https://github.com/stan-smith/FossFLOW/commit/c1c5bd33fe13019b4a15f9bea75eaa1693846d1a)) * adds keys to mapped components ([6113819](https://github.com/stan-smith/FossFLOW/commit/611381934fbe3bf2f39dd816099bb99a58bfde0d)) * adds tsc to linting script ([0e29ce3](https://github.com/stan-smith/FossFLOW/commit/0e29ce31ef8a25576c5a0674328891a49cf6b7a1)) * bug on dragging items ([5c9e3e0](https://github.com/stan-smith/FossFLOW/commit/5c9e3e0910ae0ffa444b3a2cb69a6f03332d049e)) * bug with disappearing item controls ([c3b72e3](https://github.com/stan-smith/FossFLOW/commit/c3b72e3cfd08d5e4f97a2960fd07bcea912a749f)) * bug with dragging items ([1d0351c](https://github.com/stan-smith/FossFLOW/commit/1d0351cc2d66b8dc288043ed96b0b4afde797cad)) * bug with moving rectangles ([e1515a7](https://github.com/stan-smith/FossFLOW/commit/e1515a7409917516a0b2f89397c9a89060f43dd9)) * bumps @types/react down to v17 for compatibility ([9814af2](https://github.com/stan-smith/FossFLOW/commit/9814af275666c35862fef97533e3e7a75f8baaad)) * calls function correctly ([f06acb6](https://github.com/stan-smith/FossFLOW/commit/f06acb63753ad116d6b92d34574adf1603639fc5)) * console errors ([213a5d6](https://github.com/stan-smith/FossFLOW/commit/213a5d62e9b082d6910b7e6e5a9e051caa2d4e50)) * controls container now scrolls ([f20580a](https://github.com/stan-smith/FossFLOW/commit/f20580a6f11f1af7ec9b3d7cc537eb6661466e11)) * Correct Dockerfile FROM AS casing ([a383d57](https://github.com/stan-smith/FossFLOW/commit/a383d577ce2a60bdec81fc59eace08241d29dbaf)) * correct rectangle reducer and update CI workflow with build step ([2bd1318](https://github.com/stan-smith/FossFLOW/commit/2bd131844135cb8c5a957c8cd96f2f17455a911a)) * correct typo in integration section of quickstart docs ([1256d30](https://github.com/stan-smith/FossFLOW/commit/1256d30b684cdea74502fd2c05ac432fe210cc69)) * corrects CodeSandbox setup script ([8548b00](https://github.com/stan-smith/FossFLOW/commit/8548b000715632b03f2a0ba279a15ad65f6d0a3e)) * corrects connector width issue on zoom ([555244c](https://github.com/stan-smith/FossFLOW/commit/555244c206d6c3e4682212b267dd6012522b98c0)) * corrects icon filename casing ([9161bbc](https://github.com/stan-smith/FossFLOW/commit/9161bbc9f09c85e40c7569a54f71fd1e4afeb36b)) * corrects icon reference ([7d5e116](https://github.com/stan-smith/FossFLOW/commit/7d5e11638e8873fd5dcac7a6e61f706566761dce)) * corrects textbox selection not displaying correctly ([fe7a2ed](https://github.com/stan-smith/FossFLOW/commit/fe7a2ed21130294b43f35d10dac13a7843ccad4c)) * corrects typo in error message ([4bb73e2](https://github.com/stan-smith/FossFLOW/commit/4bb73e2a3c7638a374880dc75dd3a964bb099432)) * corrects value of `disableInteractions` ([b34a2b2](https://github.com/stan-smith/FossFLOW/commit/b34a2b27d3eaedfff3b8cc6ffefafc0a37c88442)) * corrects zoom tooltips ([07011f2](https://github.com/stan-smith/FossFLOW/commit/07011f2b88c1bcb1130f6f98200ac0f6a727a35e)) * delete connectors by ID instead of index in scene ([67f0dde](https://github.com/stan-smith/FossFLOW/commit/67f0dde9321eafa8c1ede3790d853a9fff2c9727)) * delete textBox and rectangle from scene when removed ([32bcce5](https://github.com/stan-smith/FossFLOW/commit/32bcce57b7ed5c99dd855bba15ec6218fc87cb40)) * disables animations on scene layer on first render ([8d98b84](https://github.com/stan-smith/FossFLOW/commit/8d98b84213d6827ec7dacbf59a7e8422ef157f4f)) * displays icons in sidebar ([6878712](https://github.com/stan-smith/FossFLOW/commit/6878712b0bb2cde2ac535ab363a6794be45f555f)) * enables textboxes to be selected more easily ([6c3a4ce](https://github.com/stan-smith/FossFLOW/commit/6c3a4ce6c9dffe6c4f74272d2a4ab3f7615f9d6b)) * excludes `docs` folder from tsconfig ([0d21cf1](https://github.com/stan-smith/FossFLOW/commit/0d21cf16d325298f7e340cfedf910c976346cd57)) * excludes node_modules from type checking ([68fe053](https://github.com/stan-smith/FossFLOW/commit/68fe05385cd203f29a28ac6ec9063ed6a3f9f51f)) * explicitly includes tag when publishing to npm ([0b4b7aa](https://github.com/stan-smith/FossFLOW/commit/0b4b7aadfc6998e4dfbb19b474e8036b0c1fc346)) * failing test ([d4e03b0](https://github.com/stan-smith/FossFLOW/commit/d4e03b0455c51ed8565b6ff34cb6ef1a19811398)) * failing tests ([21b5579](https://github.com/stan-smith/FossFLOW/commit/21b557961f71c0754fa8cae375d0d5cfa778e5dd)) * first tile not registering when dragging item ([975ce6a](https://github.com/stan-smith/FossFLOW/commit/975ce6ae926edfe29f0d25a8f8a3da35008208db)) * fixes bug caused by missing param in function call ([421bd07](https://github.com/stan-smith/FossFLOW/commit/421bd07c932ce24ca9c736c6635fcb9b7dd2486f)) * fixes bug with 'unable to find cloneNode' on image export ([971ae23](https://github.com/stan-smith/FossFLOW/commit/971ae232c373d2bd6ea1ac94ca29242b349fc69c)) * fixes bug with rectangle resizing ([0731757](https://github.com/stan-smith/FossFLOW/commit/07317570606488776cf305a9f428b9db538655de)) * fixes color resolving mechanic ([4a67974](https://github.com/stan-smith/FossFLOW/commit/4a67974b55258794b6b84104e71369148893ac6c)) * fixes debug tools dimensions ([6cdc9eb](https://github.com/stan-smith/FossFLOW/commit/6cdc9ebb3873724655cb80bdef5596c08ab71a11)) * fixes export bug only referencing first view in the model ([ef7c314](https://github.com/stan-smith/FossFLOW/commit/ef7c314819c1ed368ac7b989754844e6a4271e69)) * fixes failing test ([569e94f](https://github.com/stan-smith/FossFLOW/commit/569e94ff547ad68fc825e505b110ffe7ecf839e5)) * fixes image export reading non-current scene ([d54b485](https://github.com/stan-smith/FossFLOW/commit/d54b4855657535b4cfe540eabfe7abe524ca1a9a)) * fixes issue loading model ([33658b3](https://github.com/stan-smith/FossFLOW/commit/33658b38b182ca3664a94f1b948f971f035cf638)) * fixes issue when clearing the canvas ([0986c52](https://github.com/stan-smith/FossFLOW/commit/0986c52d6886739339ee6ae10aaf3d94c6521897)) * fixes issue where exported image isn't positioned correctly ([b525b8a](https://github.com/stan-smith/FossFLOW/commit/b525b8a8ab18b03d7470d79f062a562d45927acb)) * fixes issue with calculating fit-to-screen dimensions ([1ad851a](https://github.com/stan-smith/FossFLOW/commit/1ad851aaca825bbfbbcd52e0a5ca9d881ea00f5d)) * fixes issue with connector path not being generated correctly ([4257445](https://github.com/stan-smith/FossFLOW/commit/425744585e433dc4e5111c4d626d944e1a79d66c)) * fixes issue with context menu not displaying correctly when zoomed out ([721e78a](https://github.com/stan-smith/FossFLOW/commit/721e78ae43d37de569f40c52eb3a6f51b9c3d60b)) * fixes issue with fitToScreen only taking dimensions of first view ([126a77f](https://github.com/stan-smith/FossFLOW/commit/126a77fdde95de69a859e3e4e509b37a573c72da)) * fixes issue with grid misalignment ([db9f60f](https://github.com/stan-smith/FossFLOW/commit/db9f60f5693c686540007fd541fc73f47ea879f5)) * fixes issue with initial view not being automatically generated ([533a54c](https://github.com/stan-smith/FossFLOW/commit/533a54c3e80b6c589898052f80b83d996d0ac4c1)) * fixes issue with reloading scene on window resize ([8f24381](https://github.com/stan-smith/FossFLOW/commit/8f24381e1bb405d04c03a9df41991d7213d5e741)) * fixes issue with textbox selection ([9903755](https://github.com/stan-smith/FossFLOW/commit/9903755052109244fb8b8558ff7b34339341a7a5)) * fixes issue with view timestamp ([53a4b61](https://github.com/stan-smith/FossFLOW/commit/53a4b61c7939e38c82bc3543e5d4778d2f776a24)) * fixes linting scripts in package.json ([693845a](https://github.com/stan-smith/FossFLOW/commit/693845af024455a3f420ca203fe6a368233181fd)) * fixes nodes not displaying correctly when being dragged onto canvas ([3686515](https://github.com/stan-smith/FossFLOW/commit/36865152ca9ec0f40fd62b1551c13c006ec5e02f)) * fixes pan mode ([e384934](https://github.com/stan-smith/FossFLOW/commit/e384934d7c678e072205a7192675a46de450056f)) * fixes zIndex ordering among nodes ([6139401](https://github.com/stan-smith/FossFLOW/commit/613940171128889c8dc92e5784264849b08ad487)) * forces case sensitive dir names through Git ([01fdfe5](https://github.com/stan-smith/FossFLOW/commit/01fdfe5da8c34a3cf1c9561e99177b5def3b0b6c)) * handle missing items gracefully in hooks and components ([ac41ed7](https://github.com/stan-smith/FossFLOW/commit/ac41ed7768679660f81a90215d5503a2d653cf52)) * handle orphaned connector references when deleting items ([#139](https://github.com/stan-smith/FossFLOW/issues/139)) ([d698a1a](https://github.com/stan-smith/FossFLOW/commit/d698a1a120f8759e13618b962baa54d3b1d8cc22)) * hides label when no content to display ([e2e2866](https://github.com/stan-smith/FossFLOW/commit/e2e2866a1c9fc08c8b41b8d9a7a7ac66bde69008)) * highlight hamburger menu icon when main menu is open ([0eb0881](https://github.com/stan-smith/FossFLOW/commit/0eb0881a60fecd882746b13d742d1bb70c785bc7)) * implements various minor fixes ([7e0c18d](https://github.com/stan-smith/FossFLOW/commit/7e0c18d8b48d14bf38009524a4cea43a81eeb262)) * improve item control handling in Cursor and DragItems modes ([02fae75](https://github.com/stan-smith/FossFLOW/commit/02fae7558c0a3260910f2e1a61797de450bb4d94)) * Increase nginx client_max_body_size to 10MB for larger diagrams ([fb3e171](https://github.com/stan-smith/FossFLOW/commit/fb3e171256b6c168bada46e43f8317e274df1447)) * issue with first group drawn not displayed ([2cb9648](https://github.com/stan-smith/FossFLOW/commit/2cb96483034564291e12ee0a3c38f1e17ca25288)) * issue with scrolling icons ([68e451a](https://github.com/stan-smith/FossFLOW/commit/68e451aa4987dfc25377217d86532d1c12130bcc)) * issue with skipping tiles while dragging nodes ([15bb5fb](https://github.com/stan-smith/FossFLOW/commit/15bb5fb6d0dee0cbf86cad38c8944c254658d178)) * issues with manipulating stale groups when dragging ([f0e6766](https://github.com/stan-smith/FossFLOW/commit/f0e6766a441fdd9681100c9f8b8f3cb85f9b4774)) * Keep connector tool selected after creating a connection ([64612a5](https://github.com/stan-smith/FossFLOW/commit/64612a592c211155ca2b97993a3daf89020b6d6f)) * label heights on nodes ([fc20387](https://github.com/stan-smith/FossFLOW/commit/fc20387e7d67fdb51cb202d97dffd970ac706cb8)) * linting errors ([04fa3cf](https://github.com/stan-smith/FossFLOW/commit/04fa3cfe7729acc0a22c15e847241da255698fec)) * makes node icons dynamic ([9c7e293](https://github.com/stan-smith/FossFLOW/commit/9c7e2932b02188e92e1b64980890bc21f44a7bfb)) * makes onSceneChange an optional prop ([bd26647](https://github.com/stan-smith/FossFLOW/commit/bd26647b607c35e0c8bc443f21e548adcc42d611)) * malformed CI config ([73c795b](https://github.com/stan-smith/FossFLOW/commit/73c795bdb72b33cb3cb993925cb8e0add6f59015)) * missing `textboxes` definitions in initialData ([e72dd84](https://github.com/stan-smith/FossFLOW/commit/e72dd842c9729672a9af5989482db6b3e409b403)) * moves zustand devtools from devDeps to deps (solves linting issue) ([cd70da3](https://github.com/stan-smith/FossFLOW/commit/cd70da3a27e8a338e2687c81207427505e9a588e)) * nodemon not watching for file updates ([dfab4c2](https://github.com/stan-smith/FossFLOW/commit/dfab4c243d9864da5f8c8e938aa417060d941da0)) * omits git tag prefix to align with npm semver requirements ([408d776](https://github.com/stan-smith/FossFLOW/commit/408d7760b2f88862675ba8f486cc4b7708a65855)) * omits git tag prefix to align with npm semver requirements ([5251bec](https://github.com/stan-smith/FossFLOW/commit/5251bec23c3bf10962dd460d1e95c7a19cad6311)) * passes full list of connector properties ([a19ea29](https://github.com/stan-smith/FossFLOW/commit/a19ea291413ac4d7767db08625b9425bb9607a6e)) * preserve connector persistence without breaking image export ([650045d](https://github.com/stan-smith/FossFLOW/commit/650045d9589d3af7e86102019666d08ab747942c)) * prevents unnecessary rerenders ([9722a62](https://github.com/stan-smith/FossFLOW/commit/9722a622fc4f6070b3d705673d3c9357c57bfb99)) * reinstates debug tools ([6bf1740](https://github.com/stan-smith/FossFLOW/commit/6bf17402b345b62e9021ed163b2150ca1337097a)) * remove duplicate downloadFile and useEffect in ExportImageDialog ([#109](https://github.com/stan-smith/FossFLOW/issues/109)) ([8db2710](https://github.com/stan-smith/FossFLOW/commit/8db2710c7a9a7e05e63cfecddd6f011339bd64c6)) * removes "fit to screen" tool ([302d4b8](https://github.com/stan-smith/FossFLOW/commit/302d4b8695eb42fd47aaf0fbdd155cab162fd031)) * removes autobind to fix failing tests ([b050d4c](https://github.com/stan-smith/FossFLOW/commit/b050d4cec4b7257992b51f27c80803bd16cb63fa)) * removes console log ([7f12fbc](https://github.com/stan-smith/FossFLOW/commit/7f12fbc052f30aa99115c705022e4947d174c1bb)) * removes console.log ([494a6a0](https://github.com/stan-smith/FossFLOW/commit/494a6a0a7cd37da05e3cc9d5afe37d8054c1522b)) * removes old logo asset ([8c63ec3](https://github.com/stan-smith/FossFLOW/commit/8c63ec3594208c0927ec98b75b5540be9ae6723b)) * removes redundant function calls ([fd5258e](https://github.com/stan-smith/FossFLOW/commit/fd5258e38cbbd6c1e6a478664311eefcf39fcc4f)) * removes redundant hook ([f13d3a6](https://github.com/stan-smith/FossFLOW/commit/f13d3a60fbc368f027915c00f3aceb82cacebfbf)) * removes redundant prop ([0b64ffd](https://github.com/stan-smith/FossFLOW/commit/0b64ffd27a97c5100a89748d363cdc2b3e201e35)) * removes references to window size, replaces with renderer size ([50c7323](https://github.com/stan-smith/FossFLOW/commit/50c73235fe5e1ed7247c4224dde977159fcdfe60)) * removes sidebar on controls panel ([6a152ee](https://github.com/stan-smith/FossFLOW/commit/6a152ee7a756a8e7e5842af44780d7f233a3ee12)) * removes touchmove for now ([6f028fa](https://github.com/stan-smith/FossFLOW/commit/6f028faa3c4ebc9d88ebfea489e00560a61c8dfd)) * removes unnecessary import ([1e95cf9](https://github.com/stan-smith/FossFLOW/commit/1e95cf9e37d25f790f85f53326e8f3f05a737008)) * removes unnecessary imports ([3dbc5fc](https://github.com/stan-smith/FossFLOW/commit/3dbc5fc4bf87e87f48f6547d7e461e84af3513fb)) * removes unnecessary param in tsconfig ([8f24b09](https://github.com/stan-smith/FossFLOW/commit/8f24b0970ce4f6158b2f0eac54d31a89d87499b7)) * removes unrecognised svg attributes ([f1b18e5](https://github.com/stan-smith/FossFLOW/commit/f1b18e56386c8f70379d7a5e961ff937731d8ee6)) * removes unrecognised svg attributes ([0e9eac1](https://github.com/stan-smith/FossFLOW/commit/0e9eac16b89b72571bfd8eaad0717e09c3304a41)) * removes unrecognised svg stroke param ([6df12e3](https://github.com/stan-smith/FossFLOW/commit/6df12e35e3141771d910ec2ae6b6f1297c8a7da5)) * removes unused package dependency ([5f63a4d](https://github.com/stan-smith/FossFLOW/commit/5f63a4d4d68e16e0da0df6d3f13b07e9c66732f9)) * removes unused prop ([d7ddb69](https://github.com/stan-smith/FossFLOW/commit/d7ddb69567f02e815b8b911c07e4026f8b36e764)) * removes unused ref ([f7532c2](https://github.com/stan-smith/FossFLOW/commit/f7532c2b85bf4a70487b8aae83f3c971259c7d19)) * resets item controls on drag ([7adb20f](https://github.com/stan-smith/FossFLOW/commit/7adb20f9bafa99953704d94b1828a8689de6c850)) * resolve connector persistence issues ([#110](https://github.com/stan-smith/FossFLOW/issues/110)) ([2733f0b](https://github.com/stan-smith/FossFLOW/commit/2733f0b7dfb2f4ba44dcf1da6963ffcb2dc76297)), closes [#103](https://github.com/stan-smith/FossFLOW/issues/103) * Resolve pan control configuration issues ([2310b85](https://github.com/stan-smith/FossFLOW/commit/2310b85995ee2f64a38a40ef57de527edfbf3560)), closes [#57](https://github.com/stan-smith/FossFLOW/issues/57) * resolve webpack and TypeScript declaration file conflicts ([2630d42](https://github.com/stan-smith/FossFLOW/commit/2630d421020f658e4e5c5451626cc28adf553b28)) * resolves paths after typese are compiled ([b2416f3](https://github.com/stan-smith/FossFLOW/commit/b2416f3e984e1e474b7ef561d82259216ea3bf7b)) * returns only first item after a click on a tile (for efficiency) ([b220c25](https://github.com/stan-smith/FossFLOW/commit/b220c25fe0a2d57a6d7b4a3bdf159b2f70d81887)) * reverts experimental mechanic to test efficiency gains ([94fbf4b](https://github.com/stan-smith/FossFLOW/commit/94fbf4bba2931744a81b4f6b3c97cd8f2758bf2a)) * scales label height according to zoom ([51f0d4f](https://github.com/stan-smith/FossFLOW/commit/51f0d4fd4b154bb93880f09bd7fefa70edf86c6d)) * sets node focus correctly ([af2d96d](https://github.com/stan-smith/FossFLOW/commit/af2d96d77b3653fb15399f390d3f3b83905b9855)) * shows grid when in edit mode ([56621f3](https://github.com/stan-smith/FossFLOW/commit/56621f3a275776e9010e745fd38fe042c62cc96f)) * strange cursor behaviour when zoomed out ([ccf267d](https://github.com/stan-smith/FossFLOW/commit/ccf267dca5eb99b6bd033f1b4057132ebb49dade)) * syncs lock file with package.json ([1c8f74a](https://github.com/stan-smith/FossFLOW/commit/1c8f74aac720ddb3114be632447103551315e2b4)) * tests not passing ([029bcf6](https://github.com/stan-smith/FossFLOW/commit/029bcf6606c32c75e3c381eeaca2026818e12de1)) * typings ([db49988](https://github.com/stan-smith/FossFLOW/commit/db499881a9e2c39b64c1e9467e46a50d6ec8a7d3)) * typo ([fe68a69](https://github.com/stan-smith/FossFLOW/commit/fe68a6920f27a41db87382cc80a353d56d37baa8)) * ui bug when creating scene elements ([2158b5e](https://github.com/stan-smith/FossFLOW/commit/2158b5e523d92bbf3b681cbdecc20d5ca39132b2)) * Update Docker run command to use absolute path for volume mount ([617b865](https://github.com/stan-smith/FossFLOW/commit/617b8654804a9d41d2dea5d61c68c2ca9feb1dca)) * updates CI config to only trigger on updated tags ([b1dd426](https://github.com/stan-smith/FossFLOW/commit/b1dd426fbe312e02d523a0ae8329fc7e9d4027ce)) * updates documentation ([d35db15](https://github.com/stan-smith/FossFLOW/commit/d35db1539d4a617cb5095120798bba9f5d1956d6)) * updates documentation link prefixes ([61e03f3](https://github.com/stan-smith/FossFLOW/commit/61e03f3367514b3c7d665796a52b14b488c3e727)) * updates package to be compatible with others ([db28d94](https://github.com/stan-smith/FossFLOW/commit/db28d949060840fc1aab90f9250cf3094140e7b2)) * upgrade to dom-to-image-more for better maintenance ([5d6cf0e](https://github.com/stan-smith/FossFLOW/commit/5d6cf0e41a7e388fbfa1998ddb154c155b686ad4)) * use relative path for i18n loading on GitHub Pages ([2091aa0](https://github.com/stan-smith/FossFLOW/commit/2091aa0cca2654ae7c4a0feeb704223b3d234f13)) * uses next/router rather than next/navigation ([a638fde](https://github.com/stan-smith/FossFLOW/commit/a638fde14f22d880190109c978baaefac4614e59)) * workaround for mobx to work correctly with modemanager ([4a9d918](https://github.com/stan-smith/FossFLOW/commit/4a9d91855572db75dbd0fe5ce48e70ed20d01afb)) ### Reverts * fix: excludes node_modules from type checking ([8b5332b](https://github.com/stan-smith/FossFLOW/commit/8b5332ba9f6b5108580c70bbf95a34bde8e965ae)) * removes explicit definition of tag used for npm ([b1bcb30](https://github.com/stan-smith/FossFLOW/commit/b1bcb302371d48e560bf7e6954d0963d63f709ce)) * Revert "Enhance ExportImageDialog performance and UX ([#100](https://github.com/stan-smith/FossFLOW/issues/100))" ([#101](https://github.com/stan-smith/FossFLOW/issues/101)) ([dbdaf02](https://github.com/stan-smith/FossFLOW/commit/dbdaf02da2a17946841c1fecd1964e6ebe837d1d)) ### Documentation * add chinese README.md ([#117](https://github.com/stan-smith/FossFLOW/issues/117)) ([556ef4a](https://github.com/stan-smith/FossFLOW/commit/556ef4a3742a21e0681ef8fcd4d7968794db4ddb)) * Add custom icon import feature to README with icon resource links ([6d53f08](https://github.com/stan-smith/FossFLOW/commit/6d53f083176f42d70a04344b681b8b706f5b04f0)) * update API documentation for initialData and renderer props ([ab7f2e6](https://github.com/stan-smith/FossFLOW/commit/ab7f2e69992a9e27177a474a28258cad997adc56)) * Update CONTRIBUTORS.md ([#89](https://github.com/stan-smith/FossFLOW/issues/89)) ([d6fab61](https://github.com/stan-smith/FossFLOW/commit/d6fab61d56e2d8f91d13ec80d45581a905e4a3c0)) * Update CONTRIBUTORS.md for monorepo structure ([526aeab](https://github.com/stan-smith/FossFLOW/commit/526aeab397dc0c8877af3ccf624d96c9ed5f7cd0)) * Update encyclopedia for monorepo structure ([94bf3c0](https://github.com/stan-smith/FossFLOW/commit/94bf3c0596eed6901edd64adc147a253535e5948)) * Update README with comprehensive monorepo information ([979c05d](https://github.com/stan-smith/FossFLOW/commit/979c05d59b194a964566467d86a0efe4a052ac4f)) ### Code Refactoring * add title to Section in TextBoxControls ([1bc1e5e](https://github.com/stan-smith/FossFLOW/commit/1bc1e5eb995a8e1a23e237113aedc492b1f917b5)) * applies origins of 0,0 to all scene layers ([ba44af5](https://github.com/stan-smith/FossFLOW/commit/ba44af5c87170c5affdea2e7c44380b46ad883e1)) * connector functionality ([b2bf329](https://github.com/stan-smith/FossFLOW/commit/b2bf329e84c51cf1070ff9144beb4182da8bd908)) * encapsulates ui menu styling in own component ([bd91210](https://github.com/stan-smith/FossFLOW/commit/bd91210e6be12fd9636ad511b20ce973623c51ed)) * implements more efficient calling of onSceneUpdate() ([0306597](https://github.com/stan-smith/FossFLOW/commit/030659709cebe3b75e482a3dec6369bdcb77471a)) * integrates the renderer with react ([773473b](https://github.com/stan-smith/FossFLOW/commit/773473b58e8602a0e0a2a83b0b0a712b8402e669)) * migrate away from paperjs [PHASE 1] ([8e6995c](https://github.com/stan-smith/FossFLOW/commit/8e6995c615d9eb5ba435d56d5b813ae5ec151123)) * migrate away from paperjs [PHASE 2] ([4da4235](https://github.com/stan-smith/FossFLOW/commit/4da4235eda972422b4247f6433f3721b8399651e)) * minor code style updates ([dba4b3b](https://github.com/stan-smith/FossFLOW/commit/dba4b3b687ece97b92de357a9a58631cb1e8a579)) * moves /tests/fixtures to /fixtures ([fd8b16a](https://github.com/stan-smith/FossFLOW/commit/fd8b16afe2c6e9db804a0114be83fc7c88e3ae3f)) * moves model types to model file ([3bf59ef](https://github.com/stan-smith/FossFLOW/commit/3bf59ef2b921617d35efb10bbb2b1b59392b9c4d)) * moves state to context ([ad34781](https://github.com/stan-smith/FossFLOW/commit/ad347817ff9ea1601547e87e7ed0c3606e576566)) * moves ui elements into own component ([ca91184](https://github.com/stan-smith/FossFLOW/commit/ca91184b6ad71c06bbc5005a77ecf7c61835f116)) * moves Ui layer styling to parent component ([d581f28](https://github.com/stan-smith/FossFLOW/commit/d581f28d01d5ffa666687067d887e8cc59deed9e)) * propagates all naming of groups to rectangles ([cdaaea4](https://github.com/stan-smith/FossFLOW/commit/cdaaea4c3e72d1eee234aca9d96febe144125e75)) * propagates renaming of rectangle tool ([514dd7a](https://github.com/stan-smith/FossFLOW/commit/514dd7a6b6471e3bba8e2431f485cafb356cbb4b)) * refactors both connector and node labels to be single component ([49c9f9b](https://github.com/stan-smith/FossFLOW/commit/49c9f9b70e43a81fb85769af04c0708f2ed06915)) * refactors connector style into a zod enum ([1e950a7](https://github.com/stan-smith/FossFLOW/commit/1e950a7b5560aa84212c0843c6f05578594c726b)) * remove redundant setMode calls in interaction modes ([fa4490f](https://github.com/stan-smith/FossFLOW/commit/fa4490fb07c77b48e432ea2f9cf6ef01a8ad5fde)) * remove unnecessary vertical divider from ToolMenu ([11a9f61](https://github.com/stan-smith/FossFLOW/commit/11a9f61d5f2221fe036a59a3d38284e18aee5ac3)) * remove unused layer ordering functionality ([#118](https://github.com/stan-smith/FossFLOW/issues/118)) ([b5b2825](https://github.com/stan-smith/FossFLOW/commit/b5b28257a56a061c64a5f3e4129eada483a66c35)) * removes non-useful hooks ([bf96554](https://github.com/stan-smith/FossFLOW/commit/bf965549483684ec3776ea3ad79a7256532d8fc2)) * removes size prop from menu props ([ce543d5](https://github.com/stan-smith/FossFLOW/commit/ce543d5a3d29375a2579c96df33f65fc3ac6b2b9)) * renames areaTool -> rectangleTool ([8a28036](https://github.com/stan-smith/FossFLOW/commit/8a280367d0e4403464e01a017d17affb111f4c03)) * renames connector name -> description ([804b4f6](https://github.com/stan-smith/FossFLOW/commit/804b4f66c7ba601be7cc7c199081d3424fa0bd9a)) * renames initialScene -> initialData ([ab5c778](https://github.com/stan-smith/FossFLOW/commit/ab5c778780838af3ff3bb454b666f04ab630fea3)) * renames interactionsEnabled > disableInteractions ([436cac1](https://github.com/stan-smith/FossFLOW/commit/436cac10a08ea0380eafa8fc76ea2cc2bc92fa45)) * renames main menu options for better handling ([84d5015](https://github.com/stan-smith/FossFLOW/commit/84d5015db336055868645ebe2605f6a54598c878)) * renames node.position to node.tile ([41e8de6](https://github.com/stan-smith/FossFLOW/commit/41e8de6cc71cb41fe7a8f6fd9420479d112b5d24)) * renames reducers to modes in interactionManager ([7275dd3](https://github.com/stan-smith/FossFLOW/commit/7275dd3a230ea84033eb7bd9874054bf7e01aedd)) * revert few changes ([44cd5f0](https://github.com/stan-smith/FossFLOW/commit/44cd5f0c6c8041a197dfc1bf136874722ef50972)) * simplifies dev by removing animations (for now) ([cbe7249](https://github.com/stan-smith/FossFLOW/commit/cbe7249c15367b1b66023e7c8464349f77a27b64)) * simplifies how zooming & scrolling is applied ([bfce0b4](https://github.com/stan-smith/FossFLOW/commit/bfce0b48e5b64123d173b8a10d814e387dbd8ca5)) * simplifies interaction manager logic ([cfd8a5a](https://github.com/stan-smith/FossFLOW/commit/cfd8a5ab5156816b423620883a1e916a995aa7e7)) * simplifies library exports and includes reducers as exports ([e7c79f0](https://github.com/stan-smith/FossFLOW/commit/e7c79f0b9ba08877e3d4639c86e66f38e7a409d2)) * simplifies logic inside of onMouseEvent ([034a849](https://github.com/stan-smith/FossFLOW/commit/034a8490e36c6df046c7164e26059f9d3f03a29d)) * simplifies renderer component ([0cb2446](https://github.com/stan-smith/FossFLOW/commit/0cb2446b9ef75a4fe217fa332ef86555541b9860)) * unifies various ui states into single enum ([6a0a398](https://github.com/stan-smith/FossFLOW/commit/6a0a3982b7a797e57c3d0abb1b39dbcf0f30cb2d)) * wraps item controls in UiElement component ([b069219](https://github.com/stan-smith/FossFLOW/commit/b06921935dfd31f294f7c2acc9f409795e886ad4)) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to FossFLOW Thank you for your interest in contributing to FossFLOW! This guide will help you get started with contributing to the project. ## Table of Contents - [Project Scope](#project-scope) - [Code of Conduct](#code-of-conduct) - [Getting Started](#getting-started) - [Development Setup](#development-setup) - [Project Structure](#project-structure) - [How to Contribute](#how-to-contribute) - [Development Workflow](#development-workflow) - [Coding Standards](#coding-standards) - [AI-Assisted Contributions](#ai-assisted-contributions) - [Testing](#testing) - [Submitting Changes](#submitting-changes) - [Community](#community) - [Recognition](#recognition) - [License](#license) ## Project Scope FossFLOW is a **simple, privacy-first, browser-based isometric diagramming tool**. It deliberately avoids enterprise complexity. The following are **out of scope** and PRs implementing them will be closed immediately: - Authentication, RBAC, OIDC, SSO, or any identity management - User accounts, teams, or multi-tenancy - Cloud hosting, SaaS features, or paid tiers - Database integrations - Anything that fundamentally changes what FossFLOW is If you're unsure whether your idea fits, open a [Discussion](https://github.com/stan-smith/FossFLOW/discussions) first. ## Code of Conduct By participating in this project, you agree to abide by our Code of Conduct: - **Be respectful**: Treat everyone with respect. No harassment, discrimination, or inappropriate behavior. - **Be collaborative**: Work together to resolve conflicts and assume good intentions. - **Be patient**: Remember that everyone has different levels of experience. - **Be welcoming**: Help new contributors feel welcome and supported. ## Getting Started ### Prerequisites - Node.js (v18 or higher) - npm (v9 or higher) - Git - A code editor (VS Code recommended) - Docker (optional, for containerized deployment) ### Quick Start 1. Fork the repository on GitHub 2. Clone your fork: ```bash git clone https://github.com/YOUR_USERNAME/FossFLOW.git cd FossFLOW ``` 3. Install dependencies: ```bash npm install ``` 4. Build the library: ```bash npm run build:lib ``` 5. Start the development server: ```bash npm run dev ``` 6. Open http://localhost:3000 in your browser ## Development Setup ### IDE Setup (VS Code) Recommended extensions: - ESLint - Prettier - TypeScript and JavaScript Language Features ### Environment Setup 1. **Install dependencies**: ```bash npm install ``` 2. **Available scripts**: ```bash npm run dev # Start app development server npm run dev:lib # Watch mode for library development npm run build # Build both library and app npm run build:lib # Build library only npm run build:app # Build app only npm test # Run tests npm run lint # Check for linting errors npm run publish:lib # Publish library to npm ``` ## Project Structure This is a monorepo containing two packages: ``` FossFLOW/ ├── packages/ │ ├── fossflow-lib/ # React component library │ │ ├── src/ │ │ │ ├── components/ # React components │ │ │ ├── stores/ # State management (Zustand) │ │ │ ├── hooks/ # Custom React hooks │ │ │ ├── interaction/ # User interaction handling │ │ │ ├── types/ # TypeScript types │ │ │ └── utils/ # Utility functions │ │ ├── rslib.config.ts # Library build config │ │ └── package.json │ │ │ └── fossflow-app/ # PWA application │ ├── src/ │ │ ├── App.tsx # Main app component │ │ ├── diagramUtils.ts # Diagram utilities │ │ └── index.tsx # App entry point │ ├── public/ # Static assets │ ├── rsbuild.config.ts # App build config │ └── package.json │ ├── Dockerfile # Docker configuration ├── compose.yml # Docker Compose config ├── package.json # Root workspace config └── tsconfig.base.json # Shared TypeScript config ``` ### Key Differences: - **fossflow-lib**: The core library, built with RSpack - **fossflow-app**: The PWA application, built with RSBuild - Both packages are managed as npm workspaces ## How to Contribute ### Finding Issues to Work On 1. Check the [Issues](https://github.com/stan-smith/FossFLOW/issues) page 2. Look for issues labeled: - `good first issue` - Great for newcomers - `help wanted` - Community help needed - `bug` - Bug fixes - `enhancement` - New features 3. Check [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md) for prioritized tasks ### Types of Contributions We welcome all types of contributions: - **Bug fixes**: Help us squash bugs - **Features**: Implement new functionality - **Documentation**: Improve docs, add examples - **Tests**: Increase test coverage - **UI/UX improvements**: Make FossFLOW better to use - **Performance**: Optimize code for better performance ## Development Workflow ### Working with the Monorepo #### Library Development (fossflow-lib) ```bash # Start library in watch mode npm run dev:lib # Build library npm run build:lib # Run library tests cd packages/fossflow-lib && npm test ``` #### App Development (fossflow-app) ```bash # Start app dev server npm run dev # Build app for production npm run build:app # The app automatically uses the local library ``` ### 1. Create a Branch ```bash git checkout -b feature/your-feature-name # or git checkout -b fix/bug-description ``` Branch naming conventions: - `feature/` - New features - `fix/` - Bug fixes - `docs/` - Documentation updates - `refactor/` - Code refactoring - `test/` - Test additions/updates ### 2. Make Your Changes - Write clean, readable code - Follow existing patterns in the codebase - Add comments for complex logic only - Update tests if needed - Update documentation if needed - Test changes in both library and app if applicable ### 3. Test Your Changes ```bash # Run all tests npm test # Run linting npm run lint # Test library changes npm run build:lib # Test app with library changes npm run dev ``` ### 4. Commit Your Changes **IMPORTANT**: We use [Conventional Commits](https://www.conventionalcommits.org/) with automated semantic versioning. Your commit messages directly control version bumps and changelog generation. #### Commit Format ``` (): [optional body] [optional footer] ``` #### Examples ```bash git commit -m "feat: add undo/redo functionality" git commit -m "fix: prevent menu from opening during drag" git commit -m "docs: update installation instructions" git commit -m "feat(connector)!: change default connector mode to click" ``` #### Commit Types **Version-bumping commits:** - `feat`: New feature (triggers MINOR version bump, e.g., 1.0.0 → 1.1.0) - `fix`: Bug fix (triggers PATCH version bump, e.g., 1.0.0 → 1.0.1) - `perf`: Performance improvement (triggers PATCH version bump) - `refactor`: Code refactoring (triggers PATCH version bump) **Non-version-bumping commits:** - `docs`: Documentation only changes (no version bump) - `style`: Code style changes - formatting, whitespace (no version bump) - `test`: Adding or updating tests (no version bump) - `chore`: Maintenance tasks, dependency updates (no version bump) - `build`: Build system changes (no version bump) - `ci`: CI/CD configuration changes (no version bump) **Breaking changes:** - Add `!` after type/scope OR add `BREAKING CHANGE:` in footer - Triggers MAJOR version bump (e.g., 1.0.0 → 2.0.0) - Example: `feat!: redesign node selection API` #### Scopes (optional but recommended) Common scopes in FossFLOW: - `connector`: Connector-related changes - `ui`: UI components and interactions - `storage`: Storage and persistence - `export`: Export/import functionality - `docker`: Docker and deployment - `i18n`: Internationalization #### Breaking Change Examples ```bash # Option 1: Using ! in type git commit -m "feat(api)!: remove deprecated exportImage function" # Option 2: Using footer git commit -m "feat: update node API BREAKING CHANGE: Node.position is now an object with x,y properties instead of array" ``` #### Release Notes Your commits will automatically generate: - Version number based on commit types - Changelog with categorized changes - GitHub release notes ## Coding Standards ### TypeScript - Use TypeScript for all new code - Avoid `any` types — if unavoidable, leave a comment explaining why - Define interfaces for component props - Use meaningful variable and function names Example: ```typescript interface NodeProps { id: string; position: { x: number; y: number }; icon: IconType; isSelected?: boolean; } const Node: React.FC = ({ id, position, icon, isSelected = false }) => { // Component implementation }; ``` ### React - Use functional components with hooks - Keep components focused and small - Use custom hooks for reusable logic - Memoize expensive computations ### State Management - Use Zustand stores appropriately: - `modelStore`: Business data - `sceneStore`: Visual state - `uiStateStore`: UI state - Keep actions pure and predictable ### Styling - Use Material-UI components when possible - Follow existing styling patterns - Use theme variables for colors - Ensure responsive design ### Comments - No unnecessary comments. The code should be self-documenting - Comments like `// This function handles the click event` or `// Main logic here` indicate a lack of understanding and will get your PR closed - Only add comments for genuinely complex logic that isn't immediately obvious ## AI-Assisted Contributions AI tools can be useful for writing code. However: - **You must understand every line of your PR.** If asked to explain a section, you should be able to - PRs that are clearly generated without human review will be closed without discussion - If your PR contains generic AI-generated comments (we can tell), it will be closed - "Vibe-coded" PRs are not welcome — if you can't debug it, don't submit it ## Testing ### Running Tests ```bash npm test # Run all tests npm test -- --watch # Watch mode npm test -- --coverage # Coverage report ``` ### Writing Tests - Write tests for new features - Update tests when changing existing code - Test edge cases and error scenarios - Use meaningful test descriptions Example: ```typescript describe('useIsoProjection', () => { it('should convert tile coordinates to screen coordinates', () => { const { tileToScreen } = useIsoProjection(); const result = tileToScreen({ x: 1, y: 1 }); expect(result).toEqual({ x: 100, y: 50 }); }); }); ``` ## Submitting Changes ### Pull Request Process 1. **Update your fork**: ```bash git remote add upstream https://github.com/stan-smith/FossFLOW.git git fetch upstream git checkout main git merge upstream/main ``` 2. **Push your branch**: ```bash git push origin feature/your-feature-name ``` 3. **Create Pull Request**: - Go to GitHub and create a PR from your branch - Fill out the PR template completely - Link related issues - Add screenshots/GIFs for UI changes - Use conventional commit format for your PR title ### PR Title Format PR titles **must** follow conventional commit format. Non-compliant PRs will be closed: ``` feat: add undo/redo functionality fix: prevent menu from opening during drag docs: update installation instructions feat(connector)!: change default connector mode ``` ### Code Review - Be open to feedback - Respond to review comments - Make requested changes promptly - Ask questions if something is unclear ## Docker Development ### Building and Running with Docker ```bash # Build multi-architecture image docker buildx build --platform linux/amd64,linux/arm64 -t fossflow:local . # Run with Docker Compose docker compose up # Or pull from Docker Hub docker run -p 80:80 stnsmith/fossflow:latest ``` ## Community ### Getting Help - **GitHub Issues**: For bugs and feature requests (use the templates) - **Discussions**: For questions and ideas - **Code Encyclopedia**: See [FOSSFLOW_ENCYCLOPEDIA.md](./FOSSFLOW_ENCYCLOPEDIA.md) - **TODO List**: See [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md) ### Communication Guidelines - Be clear and concise - Provide context and examples - Search existing issues before creating new ones - Use issue templates when available ## Recognition Contributors will be recognized in: - The project README - Release notes - Contributors list on GitHub ## License By contributing to FossFLOW, you agree that your contributions will be licensed under the project's license. --- Thank you for contributing to FossFLOW! Your efforts help make this project better for everyone. If you have any questions, don't hesitate to ask in the issues or discussions. -S ================================================ FILE: Dockerfile ================================================ # Use the official Node.js runtime as the base image FROM node:22 AS build # Set the working directory in the container WORKDIR /app # Copy package files for the monorepo COPY package*.json ./ COPY packages/fossflow-lib/package*.json ./packages/fossflow-lib/ COPY packages/fossflow-app/package*.json ./packages/fossflow-app/ #Update NPM RUN npm install -g npm@11.5.2 # Install dependencies for the entire workspace RUN npm install # Copy the entire monorepo code COPY . . # Build the library first, then the app RUN npm run build:lib && npm run build:app # Use Node with nginx for production FROM node:22-alpine # Install web server packages RUN apk add --no-cache nginx openssl # Copy backend code COPY --from=build /app/packages/fossflow-backend /app/packages/fossflow-backend # Copy the built React app to Nginx's web server directory COPY --from=build /app/packages/fossflow-app/build /usr/share/nginx/html # Copy nginx configuration COPY nginx.conf /etc/nginx/http.d/default.conf # Copy and set up entrypoint script COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh # Create data directory for persistent storage RUN mkdir -p /data/diagrams # Expose ports EXPOSE 80 3001 # Environment variables with defaults ENV ENABLE_SERVER_STORAGE=true ENV STORAGE_PATH=/data/diagrams ENV BACKEND_PORT=3001 # Start services ENTRYPOINT ["/docker-entrypoint.sh"] ================================================ FILE: FOSSFLOW_ENCYCLOPEDIA.md ================================================ # FossFLOW Codebase Encyclopedia **Last Updated**: October 2025 **Original Created**: August 14, 2025 (commit 94bf3c0) **Major Updates**: 79 commits since creation including backend storage, i18n, lasso tools, and connector enhancements --- ## Overview FossFLOW is a monorepo containing both a React component library for drawing isometric network diagrams (fossflow-lib), a Progressive Web App that uses this library (fossflow-app), and an optional backend server for persistent storage (fossflow-backend). This encyclopedia provides a comprehensive guide to navigating and understanding the codebase structure, making it easy to locate specific functionality and understand the architecture. ## Table of Contents 1. [Monorepo Structure](#monorepo-structure) 2. [Library Architecture (fossflow-lib)](#library-architecture-fossflow-lib) 3. [Application Architecture (fossflow-app)](#application-architecture-fossflow-app) 4. [Backend Architecture (fossflow-backend)](#backend-architecture-fossflow-backend) **[NEW]** 5. [State Management](#state-management) 6. [Component Organization](#component-organization) 7. [Configuration System](#configuration-system) **[NEW]** 8. [Internationalization (i18n)](#internationalization-i18n) **[NEW]** 9. [Key Technologies](#key-technologies) 10. [Build System](#build-system) 11. [Testing Structure](#testing-structure) 12. [Development Workflow](#development-workflow) ## Monorepo Structure ``` fossflow-monorepo/ ├── packages/ │ ├── fossflow-lib/ # React component library │ │ ├── src/ # Library source code │ │ │ ├── Isoflow.tsx # Main component entry │ │ │ ├── index.tsx # Development entry │ │ │ ├── config/ # Configuration (NEW) │ │ │ │ ├── hotkeys.ts # Hotkey profiles │ │ │ │ ├── panSettings.ts │ │ │ │ └── zoomSettings.ts │ │ │ ├── components/ # React components │ │ │ ├── stores/ # State management (Zustand) │ │ │ ├── hooks/ # Custom React hooks │ │ │ ├── types/ # TypeScript types │ │ │ ├── schemas/ # Zod validation │ │ │ ├── interaction/ # Interaction handling │ │ │ ├── i18n/ # Translations (NEW) │ │ │ │ ├── en-US.ts │ │ │ │ └── zh-CN.ts │ │ │ ├── utils/ # Utility functions │ │ │ ├── assets/ # Static assets │ │ │ └── styles/ # Styling │ │ ├── webpack.config.js # Webpack configuration │ │ ├── package.json # Library dependencies │ │ └── tsconfig.json # TypeScript config │ │ │ ├── fossflow-app/ # Progressive Web App │ │ ├── src/ # App source code │ │ │ ├── index.tsx # App entry point │ │ │ ├── App.tsx # Main app component │ │ │ ├── components/ # App-specific components │ │ │ ├── services/ # Services (storage) │ │ │ ├── i18n.ts # i18n configuration (NEW) │ │ │ ├── serviceWorkerRegistration.ts │ │ │ └── setupTests.ts │ │ ├── public/ # Static assets │ │ │ └── locales/ # i18n translation files (NEW) │ │ ├── rsbuild.config.ts # RSBuild configuration │ │ ├── package.json # App dependencies │ │ └── tsconfig.json # TypeScript config │ │ │ └── fossflow-backend/ # Backend server (NEW - Added ~Aug 2025) │ ├── server.js # Express server │ ├── package.json # Backend dependencies │ └── .env.example # Environment config template │ ├── package.json # Root workspace configuration ├── Dockerfile # Multi-stage Docker build ├── compose.yml # Docker Compose config ├── README.md # Project documentation ├── CONTRIBUTORS.md # Contributing guidelines └── FOSSFLOW_TODO.md # Issues and roadmap ``` ## Library Architecture (fossflow-lib) ### Entry Points - **`packages/fossflow-lib/src/index.tsx`**: Development mode entry with examples - **`packages/fossflow-lib/src/Isoflow.tsx`**: Main component exported for library usage - **`packages/fossflow-lib/src/index-docker.tsx`**: Docker-specific entry point ### Provider Hierarchy ```typescript // i18n support (NEW) // Core data model // Visual state // UI interaction state // Canvas rendering // UI controls ``` ### Data Flow 1. **Model Data** → Items, Views, Icons, Colors 2. **Scene Data** → Connector paths, Connector labels, Text box sizes 3. **UI State** → Zoom, Pan, Selection, Mode, Hotkey profile, Pan settings ## Backend Architecture (fossflow-backend) **Added**: August 2025 (commit bf3a30f) **Purpose**: Optional Express.js server for persistent diagram storage ### Overview The backend package provides server-side storage capabilities, allowing diagrams to persist across browser sessions and devices. It's particularly useful in Docker deployments. **Location**: `/packages/fossflow-backend/` ### Key Files #### Server (`server.js`) - **Technology**: Express.js with ES modules - **Port**: 3001 (configurable via `BACKEND_PORT`) - **Features**: - CORS enabled for cross-origin requests - 10MB JSON payload limit for large diagrams - Filesystem-based storage - Optional Git backup support ### API Endpoints #### Storage Status ``` GET /api/storage/status Response: { enabled: boolean, gitBackup: boolean, version: string } ``` #### List Diagrams ``` GET /api/diagrams Response: Array<{ id, name, lastModified, size }> ``` #### Get Diagram ``` GET /api/diagrams/:id Response: Diagram JSON data ``` #### Save Diagram ``` POST /api/diagrams/:id Body: Diagram JSON data Response: { success: boolean, message: string } ``` #### Delete Diagram ``` DELETE /api/diagrams/:id Response: { success: boolean } ``` ### Configuration **Environment Variables** (`.env`): - `ENABLE_SERVER_STORAGE`: Enable/disable storage endpoints (default: `true`) - `STORAGE_PATH`: Directory for diagram files (default: `/data/diagrams`) - `BACKEND_PORT`: Server port (default: `3001`) - `ENABLE_GIT_BACKUP`: Enable Git version control (default: `false`) ### Storage Format - **Directory**: `/data/diagrams/` (or `STORAGE_PATH`) - **File Format**: `{diagram-id}.json` - **Structure**: Full diagram data including icons, nodes, connectors ### Integration with App **App Service** (`packages/fossflow-app/src/services/storageService.ts`): - Detects server availability on startup - Provides unified interface for server/local storage - Handles timeouts and error states ## State Management ### 1. ModelStore (`src/stores/modelStore.tsx`) **Purpose**: Core business data **Key Data**: - `items`: Diagram elements (nodes) - `views`: Different diagram perspectives - `icons`: Available icon library - `colors`: Color palette **New Features** (since Aug 2025): - Undo/redo history tracking - Transaction system for atomic operations - Orphaned connector cleanup **Location**: `/packages/fossflow-lib/src/stores/modelStore.tsx` **Types**: `/packages/fossflow-lib/src/types/model.ts` ### 2. SceneStore (`src/stores/sceneStore.tsx`) **Purpose**: Visual/rendering state **Key Data**: - `connectors`: Path and position data - `connectorLabels`: New flexible label system (Added: commit d5e02ea) - `textBoxes`: Size information **New Features** (since Aug 2025): - Multiple labels per connector (up to 256) - Undo/redo history tracking - Label migration from legacy format **Location**: `/packages/fossflow-lib/src/stores/sceneStore.tsx` **Types**: `/packages/fossflow-lib/src/types/scene.ts` ### 3. UiStateStore (`src/stores/uiStateStore.tsx`) **Purpose**: User interface state **Key Data**: - `zoom`: Current zoom level - `scroll`: Viewport position - `mode`: Interaction mode - `editorMode`: Edit/readonly state - `hotkeyProfile`: Selected hotkey scheme (NEW) - `panSettings`: Pan control configuration (NEW) - `connectorInteractionMode`: 'click' or 'drag' (NEW) - `locale`: Current language (NEW) **New Features** (since Aug 2025): - Configurable hotkey profiles (qwerty, smnrct, none) - Advanced pan control settings - Connector creation mode toggle - i18n locale state **Location**: `/packages/fossflow-lib/src/stores/uiStateStore.tsx` **Types**: `/packages/fossflow-lib/src/types/ui.ts` ## Application Architecture (fossflow-app) ### Overview The FossFLOW application is a Progressive Web App (PWA) built with RSBuild that provides a complete diagram editor interface using the fossflow-lib library. ### Key Components #### App Entry (`packages/fossflow-app/src/index.tsx`) - Initializes the React app - Registers service worker for PWA functionality - Sets up Quill editor styles - Initializes i18n (NEW) #### Main App (`packages/fossflow-app/src/App.tsx`) - Contains the Isoflow component from fossflow-lib - Manages auto-save functionality - Handles import/export operations - Provides UI for session management - Server storage integration (NEW) - i18n language switching (NEW) **Major Updates** (since Aug 2025): - Server storage detection and UI (commit bf3a30f) - Language switcher component (commit 5d6cf0e) - Enhanced diagram loading with icon persistence (commit 4e13033) #### Service Worker - **Location**: `packages/fossflow-app/src/serviceWorkerRegistration.ts` - Enables offline functionality - Caches app resources - Provides PWA installation capability ### App Features - **Auto-Save**: Saves diagram to session storage every 5 seconds - **Import/Export**: JSON file format for diagram sharing - **PWA Support**: Installable on desktop and mobile - **Offline Mode**: Full functionality without internet - **Session Storage**: Quick save without file dialogs - **Server Storage**: Persistent backend storage (NEW) - **Multi-language**: English and Chinese support (NEW) ## Component Organization ### Core Components (Library) #### Renderer (`packages/fossflow-lib/src/components/Renderer/`) - **Purpose**: Main canvas rendering - **Key Files**: - `Renderer.tsx`: Container component - **Renders**: All visual layers including new connector labels #### UiOverlay (`src/components/UiOverlay/`) - **Purpose**: UI controls overlay - **Key Files**: - `UiOverlay.tsx`: Control panel container - **New**: Renders tooltip components (hint tooltips for various tools) #### SceneLayer (`src/components/SceneLayer/`) - **Purpose**: Transformable layer wrapper - **Uses**: GSAP for animations - **Key Files**: - `SceneLayer.tsx`: Transform container ### Scene Layers (`packages/fossflow-lib/src/components/SceneLayers/`) #### Nodes (`/Nodes/`) - **Purpose**: Render diagram nodes/icons - **Key Files**: - `Node.tsx`: Individual node component - `Nodes.tsx`: Node collection renderer - **Icon Types**: - `IsometricIcon.tsx`: 3D-style icons - `NonIsometricIcon.tsx`: Flat icons - **Updates**: Support for custom imported icons with scaling (commit dd80e86) #### Connectors (`/Connectors/`) - **Purpose**: Lines between nodes - **Key Files**: - `Connector.tsx`: Individual connector - `Connectors.tsx`: Connector collection - **Major Updates** (commits d5e02ea, 607389a): - Multiple line types (solid, dashed, dotted) - Bidirectional arrows - Click/drag creation modes #### ConnectorLabels (`/ConnectorLabels/`) **[NEW]** **Added**: August 2025 (commit d5e02ea) **Purpose**: Multiple labels along connector paths **Key Files**: - `ConnectorLabel.tsx`: Individual label component - `ConnectorLabels.tsx`: Label collection renderer **Features**: - Up to 256 labels per connector - Position anywhere along path (0-100%) - Support for line 1 and line 2 in double connectors - Backward compatible with legacy label format - Expandable labels (commit 3cbcada) **Related Utilities**: - `/src/utils/connectorLabels.ts`: Label migration and positioning logic #### Rectangles (`/Rectangles/`) - **Purpose**: Background shapes/regions - **Key Files**: - `Rectangle.tsx`: Individual rectangle - `Rectangles.tsx`: Rectangle collection - **Updates**: Fixed lasso priority issue (commit 1282320) #### TextBoxes (`/TextBoxes/`) - **Purpose**: Text annotations - **Key Files**: - `TextBox.tsx`: Individual text box - `TextBoxes.tsx`: Text box collection ### Selection Tools **[NEW]** #### Lasso (`/Lasso/`) **Added**: August 2025 (commit fec8878) **Purpose**: Rectangle-based multi-selection **Key Files**: - `Lasso.tsx`: Rectangle lasso component **Features**: - Drag to create selection rectangle - Select multiple nodes/items - Visual feedback with dashed border #### FreehandLasso (`/FreehandLasso/`) **Added**: August 2025 (commit 96047f3) **Purpose**: Freeform multi-selection **Key Files**: - `FreehandLasso.tsx`: Freehand lasso component **Features**: - Draw arbitrary selection shape - Path-based item selection - Real-time visual feedback **Interaction Modes**: - `/src/interaction/modes/Lasso.ts`: Rectangle lasso mode - `/src/interaction/modes/FreehandLasso.ts`: Freehand lasso mode ### UI Components (Library) #### MainMenu (`packages/fossflow-lib/src/components/MainMenu/`) - **Purpose**: Application menu - **Features**: Open, Export, Clear - **Updates**: i18n support (commit a001da7) #### ToolMenu (`packages/fossflow-lib/src/components/ToolMenu/`) - **Purpose**: Drawing tools palette - **Tools**: Select, Pan, Add Icon, Draw Rectangle, Add Text, Lasso (NEW), Freehand Lasso (NEW) - **Updates**: - Hotkey indicators (commit ef258df) - Visual profile badges for active hotkeys #### ItemControls (`packages/fossflow-lib/src/components/ItemControls/`) - **Purpose**: Property panels for selected items - **Subdirectories**: - `/NodeControls/`: Node properties - `QuickIconSelector.tsx`: Quick icon picker (NEW - commit 8576e30) - `/ConnectorControls/`: Connector properties - Enhanced with multiple labels support (commit d5e02ea) - Line type selection (solid, dashed, dotted) - Arrow direction controls - `/RectangleControls/`: Rectangle properties - `/TextBoxControls/`: Text properties - `/IconSelectionControls/`: Icon picker - Improved layout for small screens (commit 77231c9) - Icon scaling slider (commit 108b5e2) #### Settings Components **[NEW]** **HotkeySettings** (`/HotkeySettings/`) **Added**: August 2025 (commit ef258df) **Purpose**: Configure keyboard shortcuts **Features**: - Three profiles: QWERTY, SMNRCT, None - Visual hotkey mapping display - Per-tool hotkey customization **ConnectorSettings** (`/ConnectorSettings/`) **Added**: August 2025 (commit 5ff21cc) **Purpose**: Configure connector creation mode **Features**: - Toggle between click and drag modes - Mode descriptions and usage hints **PanSettings** (`/PanSettings/`) **Added**: August 2025 (commit 83c9b3a) **Purpose**: Configure pan controls **Features**: - Mouse pan options (middle-click, right-click, Ctrl, Alt, empty area) - Keyboard pan options (arrows, WASD, IJKL) - Pan speed adjustment #### Tooltip Components **[NEW]** **Added**: August-September 2025 (commits 9d9a0dd, a2a47b4, 5df41f9) **ConnectorHintTooltip** (`/ConnectorHintTooltip/`) - Shows when connector tool is active - Explains click vs drag creation modes **ConnectorRerouteTooltip** (`/ConnectorRerouteTooltip/`) - Shows how to reroute existing connectors - Explains drag waypoint interaction **ConnectorEmptySpaceTooltip** (`/ConnectorEmptySpaceTooltip/`) - Appears when creating connector in empty space - Guides user on connector placement **LassoHintTooltip** (`/LassoHintTooltip/`) - Shows when lasso tool is active - Explains lasso selection modes - i18n support (commit 5df41f9) **ImportHintTooltip** (`/ImportHintTooltip/`) - Replaced import toolbar - Guides users on icon import #### TransformControlsManager (`packages/fossflow-lib/src/components/TransformControlsManager/`) - **Purpose**: Selection and manipulation handles - **Key Files**: - `TransformAnchor.tsx`: Resize handles - `NodeTransformControls.tsx`: Node-specific controls #### ErrorBoundary (`/ErrorBoundary/`) **[NEW]** **Added**: August 2025 (commit 179b512) **Purpose**: Graceful error handling **Features**: - Catches React component errors - Displays user-friendly error UI - Prevents full app crashes ### Other Components - **Grid** (`/Grid/`): Isometric grid overlay - **Cursor** (`/Cursor/`): Custom cursor display - **ContextMenu** (`/ContextMenu/`): Right-click menus - **ZoomControls** (`/ZoomControls/`): Zoom in/out buttons - Updated: Zoom-to-pan conversion (commit d3fdfea) - **ColorSelector** (`/ColorSelector/`): Color picker UI - **ExportImageDialog** (`/ExportImageDialog/`): Export to PNG dialog - Updates: Window-based sizing (commit c664cfc) - Performance improvements (commits e1b0a50, c626261) ## Configuration System **Added**: August 2025 (commits ef258df, 83c9b3a) ### Overview The configuration system provides type-safe, centralized settings for hotkeys, pan controls, and zoom behavior. **Location**: `/packages/fossflow-lib/src/config/` ### Hotkey Configuration (`hotkeys.ts`) **Purpose**: Define keyboard shortcuts for tools **Types**: ```typescript type HotkeyProfile = 'qwerty' | 'smnrct' | 'none'; ``` **Profiles**: 1. **QWERTY** (Q-W-E-R-T-Y layout): - Q: Select, W: Pan, E: Add Item, R: Rectangle, T: Connector, Y: Text, L: Lasso, F: Freehand 2. **SMNRCT** (Default - S-M-N-R-C-T layout): - S: Select, M: Pan, N: Add Item, R: Rectangle, C: Connector, T: Text, L: Lasso, F: Freehand 3. **None**: All hotkeys disabled **Usage**: - Configurable via Settings → Hotkeys - Visual indicators in ToolMenu - Stored in UI state ### Pan Settings (`panSettings.ts`) **Purpose**: Configure pan/scroll controls **Settings**: - **Mouse Options**: - `middleClickPan`: Middle mouse button (default: true) - `rightClickPan`: Right mouse button - `ctrlClickPan`: Ctrl+Click - `altClickPan`: Alt+Click - `emptyAreaClickPan`: Click empty canvas area (default: true) - **Keyboard Options**: - `arrowKeysPan`: Arrow keys (default: true) - `wasdPan`: WASD keys - `ijklPan`: IJKL keys - `keyboardPanSpeed`: Pan distance (default: 20px) ### Zoom Settings (`zoomSettings.ts`) **Purpose**: Zoom behavior configuration **Settings**: - Minimum/maximum zoom levels - Zoom step increments - Zoom-to-pan conversion (added commit d3fdfea) ## Internationalization (i18n) **Added**: August 2025 (commits 2145981, 5d6cf0e, a2a47b4) ### Overview FossFLOW supports multiple languages using react-i18next with automatic language detection. ### Library i18n (`packages/fossflow-lib/src/i18n/`) **Supported Languages**: - `en-US.ts`: English (default) - `zh-CN.ts`: Simplified Chinese (added commit 556ef4a) **Translation Structure**: ```typescript { tools: { select: "Select", pan: "Pan", ... }, contextMenu: { addNode: "Add Node", ... }, settings: { hotkeys: "Hotkeys", ... }, tooltips: { connector: "Click mode: ...", ... } } ``` **Components**: - `/src/stores/localeStore.tsx`: Locale state management - `/src/components/ChangeLanguage/`: Language switcher (app-level) ### App i18n (`packages/fossflow-app/src/`) **Configuration**: `i18n.ts` - Automatic language detection - Fallback to English - Browser language preference detection **Translation Files**: `public/locales/{lang}/app.json` - App-specific translations (menus, dialogs, alerts) - Storage-related messages **Features** (commit 4d12c01): - Remaining app text fully translated - Translation enabled for all dialogs - Chinese README added ## Key Technologies ### Core Framework - **React** (^18.2.0): UI framework - **TypeScript** (^5.3.3): Type safety - **Zustand** (^4.3.3): State management - **Immer** (^10.0.2): Immutable updates ### UI Libraries - **Material-UI** (@mui/material ^5.11.10): Component library - **Emotion** (@emotion/react): CSS-in-JS styling ### Graphics & Animation - **Paper.js** (^0.12.17): Vector graphics - **GSAP** (^3.11.4): Animations - **Pathfinding** (^0.4.18): Connector routing ### Internationalization **[NEW]** - **react-i18next** (^13.0.0): Translation framework - **i18next** (^23.0.0): i18n core - **i18next-browser-languagedetector**: Auto-detect user language ### Image Export - **dom-to-image-more** (^3.7.1): Canvas to image (upgraded commit 650045d) ### Validation & Forms - **Zod** (3.22.2): Schema validation - **React Hook Form** (^7.43.2): Form handling ### Build Tools - **Webpack** (^5.76.2): Module bundler (library) - **RSBuild**: Modern bundler (app) - **Jest** (^29.5.0): Testing framework ### Backend **[NEW]** - **Express** (^4.18.2): Web server - **CORS** (^2.8.5): Cross-origin support - **dotenv** (^16.0.3): Environment configuration - **UUID** (^9.0.0): ID generation ## Build System ### Monorepo Build Architecture The project uses NPM workspaces to manage three packages: - **fossflow-lib**: Built with Webpack (CommonJS2 format) - **fossflow-app**: Built with RSBuild (modern bundler) - **fossflow-backend**: Node.js ES modules (no build step) ### Build Configurations #### Library (Webpack) - **Config**: `/packages/fossflow-lib/webpack.config.js` - **Output**: CommonJS2 module for npm publishing - **Externals**: React, React-DOM #### Application (RSBuild) - **Config**: `/packages/fossflow-app/rsbuild.config.ts` - **Features**: Hot reload, PWA support, optimized production builds - **Output**: Static files in `build/` directory #### Backend (Node.js) - **No build step**: Runs directly with Node.js - **ES Modules**: Uses `"type": "module"` in package.json ### NPM Scripts (Root Level) ```bash # Development npm run dev # Start app development server npm run dev:lib # Watch mode for library development npm run dev:backend # Start backend server (NEW) # Building npm run build # Build both library and app npm run build:lib # Build library only npm run build:app # Build app only # Testing & Quality npm test # Run tests in all workspaces npm run lint # Lint all workspaces # Publishing npm run publish:lib # Build and publish library to npm # Docker npm run docker:build # Build Docker image locally npm run docker:run # Run with Docker Compose # Clean npm run clean # Clean all build artifacts ``` ### Docker Build ```dockerfile # Multi-stage build FROM node:22 AS build WORKDIR /app # Install dependencies for monorepo RUN npm install # Build library first, then app RUN npm run build:lib && npm run build:app # Production stage with backend FROM node:22-alpine # Install backend dependencies COPY packages/fossflow-backend /app/backend # Copy built frontend COPY --from=build /app/packages/fossflow-app/build /app/frontend # Start backend server serving frontend ``` **Updates** (commit bf3a30f): - Added backend server to Docker image - Environment variable configuration - Persistent volume mounting for diagrams ## Testing Structure ### Test Files Location - Library tests: `packages/fossflow-lib/src/**/__tests__/` - App tests: `packages/fossflow-app/src/**/*.test.tsx` - Test utilities: `packages/fossflow-lib/src/fixtures/` ### Key Test Areas - `/packages/fossflow-lib/src/schemas/__tests__/`: Schema validation (completed ✅) - `/packages/fossflow-lib/src/stores/reducers/__tests__/`: State logic - Connector reducer tests (commit 70b1f56) - `/packages/fossflow-lib/src/utils/__tests__/`: Utility functions ### CI/CD Testing **Updates** (commits 70b1f56, 2bd1318): - GitHub Actions workflow with build step - Test coverage reporting - Artifact retention policies ## Development Workflow ### Monorepo Development Setup 1. **Clone and Install**: ```bash git clone https://github.com/stan-smith/FossFLOW cd FossFLOW npm install # Installs dependencies for all workspaces ``` 2. **Development Mode**: ```bash # First build the library (required for initial setup) npm run build:lib # Start app development (includes library in dev mode) npm run dev # Optional: Start backend server in separate terminal npm run dev:backend ``` 3. **Making Library Changes**: - Edit files in `packages/fossflow-lib/src/` - Changes are immediately available in the app - No need to rebuild or republish during development 4. **Making App Changes**: - Edit files in `packages/fossflow-app/src/` - Hot reload updates the browser automatically 5. **Making Backend Changes** (NEW): - Edit `packages/fossflow-backend/server.js` - Restart server or use nodemon for auto-reload ### Key Development Files #### 1. Configuration (`packages/fossflow-lib/src/config.ts`) **Key Constants**: - `TILE_SIZE`: Base tile dimensions - `DEFAULT_ZOOM`: Initial zoom level - `DEFAULT_FONT_SIZE`: Text defaults - `INITIAL_DATA`: Default model state #### 2. Hooks Directory (`packages/fossflow-lib/src/hooks/`) **Common Hooks**: - `useScene.ts`: Merged scene data - `useModelItem.ts`: Individual item access (returns `ModelItem | null`) - `useViewItem.ts`: View item access (returns `ViewItem | null`) - `useConnector.ts`: Connector management (returns `Connector | null`) - `useRectangle.ts`: Rectangle access (returns `Rectangle | null`) - `useTextBox.ts`: Text box access (returns `TextBox | null`) - `useIcon.tsx`: Icon access (returns `Icon | null`) - `useColor.ts`: Color access (returns `Color | null`) - `useIsoProjection.ts`: Coordinate conversion - `useDiagramUtils.ts`: Diagram operations - `useHistory.ts`: Undo/redo transaction system **[NEW]** **Important**: All item access hooks now return `null` instead of throwing when items don't exist, preventing React unmount errors. #### 3. Interaction System (`packages/fossflow-lib/src/interaction/`) **Main File**: `useInteractionManager.ts` **Interaction Modes** (`/modes/`): - `Cursor.ts`: Selection mode - `Pan.ts`: Canvas panning - `PlaceIcon.ts`: Icon placement - Updated: Nearest unoccupied tile placement (commit f5ebad6) - `Connector.ts`: Drawing connections - Major update: Click/drag modes (commits d78ccdb, ea0bce0, 5ff21cc) - `DragItems.ts`: Moving elements - `Rectangle/`: Rectangle tools - `TextBox.ts`: Text editing - `Lasso.ts`: Rectangle lasso selection **[NEW]** - `FreehandLasso.ts`: Freehand lasso selection **[NEW]** #### 4. Utilities (`packages/fossflow-lib/src/utils/`) **Key Utilities**: - `CoordsUtils.ts`: Coordinate calculations - `SizeUtils.ts`: Size computations - `renderer.ts`: Rendering helpers - `model.ts`: Model manipulation - `pathfinder.ts`: Connector routing - `connectorLabels.ts`: Label migration and positioning **[NEW]** - `common.ts`: Common helpers - `getItemById`: Null-safe item access (prevents errors) #### 5. Type System (`packages/fossflow-lib/src/types/`) **Core Types**: - `model.ts`: Business data types - Updated: `ConnectorLabel` interface (commit d5e02ea) - `scene.ts`: Visual state types - `ui.ts`: Interface types - Updated: Hotkey, pan, locale state - `common.ts`: Shared types - `interactions.ts`: Interaction types - `isoflowProps.ts`: Component prop types #### 6. Schema Validation (`packages/fossflow-lib/src/schemas/`) **Validation Schemas**: - `model.ts`: Model validation - `connector.ts`: Connector validation - Updated: Label array validation (commit d5e02ea) - `rectangle.ts`: Rectangle validation - `textBox.ts`: Text box validation - `views.ts`: View validation ## Undo/Redo System **Added**: August 2025 (contributor: pi22by7) **Status**: ⚠️ Implemented but has known issues under investigation ### Implementation Details The undo/redo system uses a transaction-based approach to ensure atomic operations: **Key Components**: - **Transaction System**: Groups related operations together (`useHistory.ts`) - **Dual Store Coordination**: Synchronizes model and scene stores - **History Tracking**: Maintains separate history for each store **Key File**: `/packages/fossflow-lib/src/hooks/useHistory.ts` **API**: ```typescript const { undo, redo, canUndo, canRedo, transaction } = useHistory(); // Group multiple operations transaction(() => { // Multiple state changes here // All will be undone/redone together }); ``` **Important Considerations**: - Operations that affect both model and scene (like placing icons) must use transactions - Without transactions, undo/redo can cause "Invalid item in view" errors - The system prevents partial states by grouping related changes ### Known Issues ⚠️ **Current Status**: Stan is investigating edge cases and bugs in the undo/redo system. While functional for basic operations, some complex interactions may cause issues. ### Error Handling Patterns **Problem**: Components can try to access deleted items during React unmounting **Solution**: Graceful null handling throughout the codebase **Key Changes**: 1. Added `getItemById` utility that returns `null` instead of throwing 2. Updated all hooks to return `null` when items don't exist 3. Added null checks in all components using these hooks **Affected Files**: - `/src/utils/common.ts`: Added `getItemById` function - All hooks in `/src/hooks/`: Updated to handle missing items - All components: Added null checks and early returns **Related Fixes**: - Orphaned connector cleanup (commit d698a1a) - Scene deletion synchronization (commits 32bcce5, 67f0dde) ## Navigation Quick Reference ### Need to modify... **Icons?** → `/src/components/ItemControls/IconSelectionControls/` **Custom icon import?** → `/src/components/ItemControls/IconSelectionControls/IconGrid.tsx` **Node rendering?** → `/src/components/SceneLayers/Nodes/` **Connector drawing?** → `/src/components/SceneLayers/Connectors/` **Connector labels?** → `/src/components/SceneLayers/ConnectorLabels/` **[NEW]** **Connector creation mode?** → `/src/interaction/modes/Connector.ts` + `/src/components/ConnectorSettings/` **[NEW]** **Lasso selection?** → `/src/components/Lasso/`, `/src/components/FreehandLasso/` **[NEW]** **Zoom behavior?** → `/src/stores/uiStateStore.tsx` + `/src/components/ZoomControls/` **Grid display?** → `/src/components/Grid/` **Export functionality?** → `/src/components/ExportImageDialog/` **Color picker?** → `/src/components/ColorSelector/` **Context menus?** → `/src/components/ContextMenu/` **Keyboard shortcuts?** → `/src/interaction/useInteractionManager.ts` + `/src/config/hotkeys.ts` **[NEW]** **Tool selection?** → `/src/components/ToolMenu/` **Selection handles?** → `/src/components/TransformControlsManager/` **Undo/Redo?** → `/src/hooks/useHistory.ts` **[NEW]** **i18n translations?** → `/src/i18n/en-US.ts`, `/src/i18n/zh-CN.ts` **[NEW]** **Server storage?** → `/packages/fossflow-backend/server.js` **[NEW]** **Pan settings?** → `/src/config/panSettings.ts` + `/src/components/PanSettings/` **[NEW]** **Tooltips?** → Various `/src/components/*Tooltip/` components **[NEW]** ### Want to understand... **How items are positioned?** → `/src/hooks/useIsoProjection.ts` **How connectors find paths?** → `/src/utils/pathfinder.ts` **How state updates work?** → `/src/stores/reducers/` **How validation works?** → `/src/schemas/` **Available icons?** → `/src/fixtures/icons.ts` **Default configurations?** → `/src/config.ts` + `/src/config/*` **[NEW]** **How labels are positioned?** → `/src/utils/connectorLabels.ts` **[NEW]** **How transactions work?** → `/src/hooks/useHistory.ts` **[NEW]** **How i18n works?** → `/src/i18n/`, `/src/stores/localeStore.tsx` **[NEW]** **Backend API?** → `/packages/fossflow-backend/server.js` **[NEW]** ## Key Files Reference | Purpose | File Path | Notes | |---------|-----------|-------| | Main entry | `/src/Isoflow.tsx` | | | Configuration | `/src/config.ts` | | | Hotkey config | `/src/config/hotkeys.ts` | **[NEW]** | | Pan settings | `/src/config/panSettings.ts` | **[NEW]** | | Model types | `/src/types/model.ts` | Updated with ConnectorLabel | | UI state types | `/src/types/ui.ts` | Updated with hotkeys, pan, locale | | Model store | `/src/stores/modelStore.tsx` | With undo/redo | | Scene store | `/src/stores/sceneStore.tsx` | With connector labels | | UI store | `/src/stores/uiStateStore.tsx` | With new settings | | Locale store | `/src/stores/localeStore.tsx` | **[NEW]** | | Main renderer | `/src/components/Renderer/Renderer.tsx` | | | UI overlay | `/src/components/UiOverlay/UiOverlay.tsx` | With tooltips | | Interaction manager | `/src/interaction/useInteractionManager.ts` | Updated modes | | Coordinate utils | `/src/utils/CoordsUtils.ts` | | | Connector labels util | `/src/utils/connectorLabels.ts` | **[NEW]** | | History/Undo hook | `/src/hooks/useHistory.ts` | **[NEW]** | | Public API hook | `/src/hooks/useIsoflow.ts` | | | Backend server | `/packages/fossflow-backend/server.js` | **[NEW]** | | App i18n config | `/packages/fossflow-app/src/i18n.ts` | **[NEW]** | | English translations | `/src/i18n/en-US.ts` | **[NEW]** | | Chinese translations | `/src/i18n/zh-CN.ts` | **[NEW]** | ## Recent Major Changes Summary ### August 2025 - **Backend Storage**: Express server for persistent diagrams (bf3a30f) - **i18n Support**: English + Chinese translations (2145981, 5d6cf0e) - **Hotkey System**: Configurable keyboard shortcuts (ef258df) - **Pan Controls**: Advanced pan configuration (83c9b3a) - **Connector Labels**: Multiple labels per connector (d5e02ea) - **Click Connector Mode**: Alternative to drag mode (5ff21cc, ea0bce0) - **Custom Icons**: Import with scaling slider (dd80e86, 108b5e2) - **Error Boundary**: Graceful error handling (179b512) ### September 2025 - **Lasso Tools**: Rectangle and freehand selection (fec8878, 96047f3) - **Tooltip System**: Contextual hints for all tools (9d9a0dd, a2a47b4, 5df41f9) - **Icon Panel**: Improved small screen layout (77231c9) - **Quick Icon Selector**: Faster icon selection workflow (8576e30) - **Orphaned Connectors**: Automatic cleanup (d698a1a) ### October 2025 - **Connector Label Overhaul**: Up to 256 labels, per-line support (2a53437) - **Expanded Labels**: Default expanded in exports (3cbcada) - **Zoom to Pan**: Improved zoom behavior (d3fdfea) - **Race Condition Fixes**: Diagram loading improvements (4e13033) - **Reroute Tooltips**: Connector manipulation guidance (d5db93c) --- This encyclopedia serves as a comprehensive guide to the FossFLOW codebase. Use the table of contents and quick references to efficiently navigate to the areas you need to modify or understand. **For Contributors**: See [CONTRIBUTORS.md](./CONTRIBUTORS.md) for contribution guidelines and [FOSSFLOW_TODO.md](./FOSSFLOW_TODO.md) for current issues and roadmap. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Mark Mankarious Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # FossFLOW - Isometric Diagramming Tool fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

stan-smith%2FFossFLOW | Trendshift

Hey! Stan here, if you've used FossFLOW and it's helped you, I'd really appreciate if you could donate something small :) I work full time, and finding the time to work on this project is challenging enough. If you've had a feature that I've implemented for you, or fixed a bug it'd be great if you could :) if not, that's not a problem, this software will always remain free! Also! If you haven't yet, please check out the underlying library this is built on by @markmanx I truly stand on the shoulders of a giant here 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) Buy Me A Coffee Thanks, -Stan ## Try it online

Go to --> https://stan-smith.github.io/FossFLOW/ <--

Check out my latest project: SlingShot - Dead easy video streaming over QUIC

------------------------------------------------------------------------------------------------------------------------------ FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beautiful isometric diagrams. Built with React and the Isoflow (Now forked and published to NPM as fossflow) library, it runs entirely in your browser with offline support. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTING.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTING.md)** - How to contribute to the project. ## 🐳 Quick Deploy with Docker ```bash # Using Docker Compose (recommended - includes persistent storage) docker compose up # Or run directly from Docker Hub with persistent storage docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Server storage is enabled by default in Docker. Your diagrams will be saved to `./diagrams` on the host. To disable server storage, set `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ### HTTP Basic Authentication (Optional) Protect your FossFLOW instance with HTTP Basic Auth: ```bash # With Docker Compose HTTP_AUTH_USER=admin HTTP_AUTH_PASSWORD=secret docker compose up # Or with docker run docker run -p 80:80 \ -e HTTP_AUTH_USER=admin \ -e HTTP_AUTH_PASSWORD=secret \ stnsmith/fossflow:latest ``` > **Note**: Both variables must be set to enable authentication. If either is empty, the app is accessible without login. ## Quick Start (Local Development) ```bash # Clone the repository git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Install dependencies npm install # Build the library (required first time) npm run build:lib # Start development server npm run dev ``` Open [http://localhost:3000](http://localhost:3000) in your browser. ## Monorepo Structure This is a monorepo containing two packages: - `packages/fossflow-lib` - React component library for drawing network diagrams (built with Webpack) - `packages/fossflow-app` - Progressive Web App which wraps the lib and presents it (built with RSBuild) ### Development Commands ```bash # Development npm run dev # Start app development server npm run dev:lib # Watch mode for library development # Building npm run build # Build both library and app npm run build:lib # Build library only npm run build:app # Build app only # Testing & Linting npm test # Run unit tests npm run lint # Check for linting errors # E2E Tests (Selenium) cd e2e-tests ./run-tests.sh # Run end-to-end tests (requires Docker & Python) # Publishing npm run publish:lib # Publish library to npm ``` ## How to Use ### Creating Diagrams 1. **Add Items**: - Press the "+" button on the top right menu, the library of components will appear on the left - Drag and drop components from the library onto the canvas - Or right-click on the grid and select "Add node" 2. **Connect Items**: - Select the Connector tool (press 'C' or click connector icon) - **Click mode** (default): Click first node, then click second node - **Drag mode** (optional): Click and drag from first to second node - Switch modes in Settings → Connectors tab 3. **Save Your Work**: - **Quick Save** - Saves to browser session - **Export** - Download as JSON file - **Import** - Load from JSON file ### Storage Options - **Session Storage**: Temporary saves cleared when browser closes - **Export/Import**: Permanent storage as JSON files - **Auto-Save**: Automatically saves changes every 5 seconds to session ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## Documentation - [FOSSFLOW_ENCYCLOPEDIA.md](FOSSFLOW_ENCYCLOPEDIA.md) - Comprehensive guide to the codebase - [CONTRIBUTING.md](CONTRIBUTING.md) - Contributing guidelines ## License MIT ================================================ FILE: compose.dev.yml ================================================ services: fossflow: build: . ports: - "3000:80" - "3001:3001" environment: - NODE_ENV=development - ENABLE_SERVER_STORAGE=true - STORAGE_PATH=/data/diagrams - ENABLE_GIT_BACKUP=false - HTTP_AUTH_USER=${HTTP_AUTH_USER:-} - HTTP_AUTH_PASSWORD=${HTTP_AUTH_PASSWORD:-} volumes: - ./diagrams:/data/diagrams ================================================ FILE: compose.yml ================================================ services: fossflow: image: stnsmith/fossflow:latest pull_policy: always ports: - 80:80 environment: - NODE_ENV=production - ENABLE_SERVER_STORAGE=${ENABLE_SERVER_STORAGE:-true} - STORAGE_PATH=/data/diagrams - ENABLE_GIT_BACKUP=${ENABLE_GIT_BACKUP:-false} - HTTP_AUTH_USER=${HTTP_AUTH_USER:-} - HTTP_AUTH_PASSWORD=${HTTP_AUTH_PASSWORD:-} volumes: - ./diagrams:/data/diagrams ================================================ FILE: docker-entrypoint.sh ================================================ #!/bin/sh # Start Node.js backend if server storage is enabled if [ "$ENABLE_SERVER_STORAGE" = "true" ]; then echo "Starting FossFLOW backend server..." cd /app/packages/fossflow-backend npm install --production node server.js & echo "Backend server started" else echo "Server storage disabled, backend not started" fi # Start nginx # Configure HTTP Basic Auth touch /etc/nginx/.htpasswd if [ -n "$HTTP_AUTH_USER" ] && [ -n "$HTTP_AUTH_PASSWORD" ]; then echo "Setup HTTP Basic Auth..." echo "$HTTP_AUTH_USER:$(printf '%s' "$HTTP_AUTH_PASSWORD" | openssl passwd -bcrypt -stdin)" > /etc/nginx/.htpasswd sed -i 's/AUTH_BASIC_SETTING/"Restricted"/g' /etc/nginx/http.d/default.conf else echo "No (optional) HTTP Basic Auth configured" sed -i 's/AUTH_BASIC_SETTING/off/g' /etc/nginx/http.d/default.conf fi echo "Starting nginx..." nginx -g "daemon off;" ================================================ FILE: docs/README.bn.md ================================================ # FossFLOW - আইসোমেট্রিক ডায়াগ্রাম টুল fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

হ্যালো! আমি Stan, যদি আপনি FossFLOW ব্যবহার করে থাকেন এবং এটি আপনাকে সাহায্য করেছে, আমি সত্যিই প্রশংসা করব যদি আপনি কিছু ছোট দান করতে পারেন :) আমি পূর্ণকালীন কাজ করি, এবং এই প্রকল্পে কাজ করার সময় খুঁজে পাওয়াটা যথেষ্ট চ্যালেঞ্জিং। যদি আমি আপনার জন্য একটি ফিচার বাস্তবায়ন করেছি বা একটি বাগ ঠিক করেছি তবে এটি দুর্দান্ত হবে যদি আপনি পারেন :) যদি না হয়, তাতে কোনো সমস্যা নেই, এই সফটওয়্যারটি সর্বদা বিনামূল্যে থাকবে! এছাড়াও! যদি আপনি এখনও না করে থাকেন, তবে @markmanx দ্বারা নির্মিত অন্তর্নিহিত লাইব্রেরিটি দেখুন যার উপর এটি তৈরি। আমি সত্যিই এখানে একজন দৈত্যের কাঁধে দাঁড়িয়ে আছি 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith ধন্যবাদ, -Stan ## এটি অনলাইনে চেষ্টা করুন যান --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW হল সুন্দর আইসোমেট্রিক ডায়াগ্রাম তৈরি করার জন্য একটি শক্তিশালী, ওপেন-সোর্স প্রগ্রেসিভ ওয়েব অ্যাপ (PWA)। React এবং Isoflow লাইব্রেরি দিয়ে তৈরি (এখন ফর্ক করা এবং NPM-এ fossflow হিসেবে প্রকাশিত), এটি অফলাইন সাপোর্ট সহ সম্পূর্ণরূপে আপনার ব্রাউজারে চলে। ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - প্রকল্পে কীভাবে অবদান রাখবেন। ## সাম্প্রতিক আপডেট (অক্টোবর 2025) ### বহুভাষিক সমর্থন - **8টি ভাষা সমর্থিত** - ইংরেজি, চীনা (সরলীকৃত), স্প্যানিশ, পর্তুগিজ (ব্রাজিলিয়ান), ফরাসি, হিন্দি, বাংলা এবং রাশিয়ানে সম্পূর্ণ ইন্টারফেস অনুবাদ - **ভাষা নির্বাচক** - অ্যাপ হেডারে ব্যবহার করা সহজ ভাষা সুইচার - **সম্পূর্ণ অনুবাদ** - সমস্ত মেনু, ডায়ালগ, সেটিংস, টুলটিপ এবং সাহায্য বিষয়বস্তু অনুবাদিত - **লোকেল-সচেতন** - স্বয়ংক্রিয়ভাবে আপনার ভাষা পছন্দ সনাক্ত করে এবং মনে রাখে ### উন্নত সংযোজক টুল - **ক্লিক-ভিত্তিক তৈরি** - নতুন ডিফল্ট মোড: প্রথম নোডে ক্লিক করুন, তারপর সংযোগ করতে দ্বিতীয় নোডে ক্লিক করুন - **ড্র্যাগ মোড বিকল্প** - মূল ড্র্যাগ-এন্ড-ড্রপ এখনও সেটিংসের মাধ্যমে উপলব্ধ - **মোড নির্বাচন** - সেটিংস → সংযোজক ট্যাবে ক্লিক এবং ড্র্যাগ মোডের মধ্যে স্যুইচ করুন - **উন্নত নির্ভরযোগ্যতা** - ক্লিক মোড আরও পূর্বাভাসযোগ্য সংযোগ তৈরি প্রদান করে ### কাস্টম আইকন আমদানি - **আপনার নিজস্ব আইকন আমদানি করুন** - আপনার ডায়াগ্রামে ব্যবহার করতে কাস্টম আইকন (PNG, JPG, SVG) আপলোড করুন - **স্বয়ংক্রিয় স্কেলিং** - পেশাদার চেহারার জন্য আইকনগুলি স্বয়ংক্রিয়ভাবে সামঞ্জস্যপূর্ণ আকারে স্কেল করা হয় - **আইসোমেট্রিক/ফ্ল্যাট টগল** - আমদানি করা আইকনগুলি 3D আইসোমেট্রিক বা ফ্ল্যাট 2D হিসাবে প্রদর্শিত হবে কিনা তা চয়ন করুন - **স্মার্ট অধ্যবসায়** - কাস্টম আইকনগুলি ডায়াগ্রামের সাথে সংরক্ষিত এবং সমস্ত স্টোরেজ পদ্ধতিতে কাজ করে - **আইকন সম্পদ** - বিনামূল্যে আইকন খুঁজুন: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - হাজার হাজার বিনামূল্যে SVG আইকন - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - উচ্চ মানের আইসোমেট্রিক আইকন প্যাক ### সার্ভার স্টোরেজ সমর্থন - **স্থায়ী স্টোরেজ** - সার্ভার ফাইল সিস্টেমে সংরক্ষিত ডায়াগ্রাম, ব্রাউজার সেশনে টিকে থাকে - **মাল্টি-ডিভাইস অ্যাক্সেস** - Docker ডিপ্লয়মেন্ট ব্যবহার করার সময় যেকোনো ডিভাইস থেকে আপনার ডায়াগ্রাম অ্যাক্সেস করুন - **স্বয়ংক্রিয় সনাক্তকরণ** - উপলব্ধ হলে UI স্বয়ংক্রিয়ভাবে সার্ভার স্টোরেজ দেখায় - **ওভাররাইট সুরক্ষা** - ডুপ্লিকেট নাম দিয়ে সংরক্ষণ করার সময় নিশ্চিতকরণ ডায়ালগ - **Docker একীকরণ** - Docker ডিপ্লয়মেন্টে ডিফল্টভাবে সার্ভার স্টোরেজ সক্রিয় ### উন্নত ইন্টারঅ্যাকশন বৈশিষ্ট্য - **কনফিগারযোগ্য হটকি** - ভিজ্যুয়াল সূচক সহ টুল নির্বাচনের জন্য তিনটি প্রোফাইল (QWERTY, SMNRCT, কোনোটিই নয়) - **উন্নত প্যান কন্ট্রোল** - খালি এলাকা ড্র্যাগ, মিডল/রাইট ক্লিক, মডিফায়ার কী (Ctrl/Alt) এবং কীবোর্ড নেভিগেশন (Arrow/WASD/IJKL) সহ একাধিক প্যান পদ্ধতি - **সংযোজক তীর টগল করুন** - পৃথক সংযোজকগুলিতে তীরগুলি দেখানো/লুকানোর বিকল্প - **স্থায়ী টুল নির্বাচন** - সংযোগ তৈরি করার পরে সংযোজক টুল সক্রিয় থাকে - **সেটিংস ডায়ালগ** - হটকি এবং প্যান কন্ট্রোলের জন্য কেন্দ্রীভূত কনফিগারেশন ### Docker এবং CI/CD উন্নতি - **স্বয়ংক্রিয় Docker বিল্ড** - কমিটে স্বয়ংক্রিয় Docker Hub ডিপ্লয়মেন্টের জন্য GitHub Actions ওয়ার্কফ্লো - **মাল্টি-আর্কিটেকচার সমর্থন** - `linux/amd64` এবং `linux/arm64` উভয়ের জন্য Docker ইমেজ - **প্রি-বিল্ট ইমেজ** - `stnsmith/fossflow:latest`-এ উপলব্ধ ### Monorepo আর্কিটেকচার - লাইব্রেরি এবং অ্যাপ্লিকেশন উভয়ের জন্য **একক রিপোজিটরি** - সুসংগত নির্ভরতা ব্যবস্থাপনার জন্য **NPM Workspaces** - রুটে `npm run build` দিয়ে **একীভূত বিল্ড প্রক্রিয়া** ### UI সংশোধন - Quill সম্পাদক টুলবার আইকন প্রদর্শন সমস্যা সংশোধন করা হয়েছে - প্রসঙ্গ মেনুতে React কী সতর্কতা সমাধান করা হয়েছে - markdown সম্পাদক স্টাইলিং উন্নত করা হয়েছে ## বৈশিষ্ট্য - 🎨 **আইসোমেট্রিক ডায়াগ্রামিং** - চমৎকার 3D-স্টাইল প্রযুক্তিগত ডায়াগ্রাম তৈরি করুন - 💾 **অটো-সেভ** - আপনার কাজ প্রতি 5 সেকেন্ডে স্বয়ংক্রিয়ভাবে সংরক্ষিত হয় - 📱 **PWA সমর্থন** - Mac এবং Linux-এ নেটিভ অ্যাপ হিসাবে ইনস্টল করুন - 🔒 **গোপনীয়তা-প্রথম** - সমস্ত ডেটা আপনার ব্রাউজারে স্থানীয়ভাবে সংরক্ষিত - 📤 **আমদানি/রপ্তানি** - JSON ফাইল হিসাবে ডায়াগ্রাম শেয়ার করুন - 🎯 **সেশন স্টোরেজ** - ডায়ালগ ছাড়াই দ্রুত সংরক্ষণ - 🌐 **অফলাইন সমর্থন** - ইন্টারনেট সংযোগ ছাড়াই কাজ করুন - 🗄️ **সার্ভার স্টোরেজ** - Docker ব্যবহার করার সময় ঐচ্ছিক স্থায়ী স্টোরেজ (ডিফল্টভাবে সক্রিয়) - 🌍 **বহুভাষিক** - 8টি ভাষার জন্য সম্পূর্ণ সমর্থন: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Docker দিয়ে দ্রুত ডিপ্লয় ```bash # Docker Compose ব্যবহার করা (প্রস্তাবিত - স্থায়ী স্টোরেজ অন্তর্ভুক্ত) docker compose up # অথবা স্থায়ী স্টোরেজ সহ Docker Hub থেকে সরাসরি চালান docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Docker-এ সার্ভার স্টোরেজ ডিফল্টভাবে সক্রিয়। আপনার ডায়াগ্রামগুলি হোস্টে `./diagrams`-এ সংরক্ষিত হবে। সার্ভার স্টোরেজ নিষ্ক্রিয় করতে, `ENABLE_SERVER_STORAGE=false` সেট করুন: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## দ্রুত শুরু (স্থানীয় উন্নয়ন) ```bash # রিপোজিটরি ক্লোন করুন git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # নির্ভরতা ইনস্টল করুন npm install # লাইব্রেরি তৈরি করুন (প্রথমবার প্রয়োজনীয়) npm run build:lib # উন্নয়ন সার্ভার শুরু করুন npm run dev ``` আপনার ব্রাউজারে [http://localhost:3000](http://localhost:3000) খুলুন। ## Monorepo কাঠামো এটি দুটি প্যাকেজ সম্বলিত একটি monorepo: - `packages/fossflow-lib` - নেটওয়ার্ক ডায়াগ্রাম আঁকার জন্য React কম্পোনেন্ট লাইব্রেরি (Webpack দিয়ে তৈরি) - `packages/fossflow-app` - আইসোমেট্রিক ডায়াগ্রাম তৈরির জন্য Progressive Web App (RSBuild দিয়ে তৈরি) ### উন্নয়ন কমান্ড ```bash # উন্নয়ন npm run dev # অ্যাপ উন্নয়ন সার্ভার শুরু করুন npm run dev:lib # লাইব্রেরি উন্নয়নের জন্য ওয়াচ মোড # বিল্ডিং npm run build # লাইব্রেরি এবং অ্যাপ উভয়ই তৈরি করুন npm run build:lib # শুধুমাত্র লাইব্রেরি তৈরি করুন npm run build:app # শুধুমাত্র অ্যাপ তৈরি করুন # পরীক্ষা এবং লিন্টিং npm test # ইউনিট টেস্ট চালান npm run lint # লিন্টিং ত্রুটি পরীক্ষা করুন # E2E টেস্ট (Selenium) cd e2e-tests ./run-tests.sh # এন্ড-টু-এন্ড টেস্ট চালান (Docker এবং Python প্রয়োজন) # প্রকাশনা npm run publish:lib # npm-এ লাইব্রেরি প্রকাশ করুন ``` ## কীভাবে ব্যবহার করবেন ### ডায়াগ্রাম তৈরি করা 1. **আইটেম যোগ করুন**: - উপরের ডানদিকের মেনুতে "+" বোতাম টিপুন, কম্পোনেন্ট লাইব্রেরি বাম দিকে প্রদর্শিত হবে - লাইব্রেরি থেকে ক্যানভাসে কম্পোনেন্ট ড্র্যাগ এবং ড্রপ করুন - অথবা গ্রিডে রাইট-ক্লিক করুন এবং "নোড যোগ করুন" নির্বাচন করুন 2. **আইটেম সংযুক্ত করুন**: - সংযোজক টুল নির্বাচন করুন ('C' টিপুন বা সংযোজক আইকনে ক্লিক করুন) - **ক্লিক মোড** (ডিফল্ট): প্রথম নোডে ক্লিক করুন, তারপর দ্বিতীয় নোডে ক্লিক করুন - **ড্র্যাগ মোড** (ঐচ্ছিক): প্রথম নোড থেকে দ্বিতীয় নোডে ক্লিক করুন এবং ড্র্যাগ করুন - সেটিংস → সংযোজক ট্যাবে মোড স্যুইচ করুন 3. **আপনার কাজ সংরক্ষণ করুন**: - **দ্রুত সংরক্ষণ** - ব্রাউজার সেশনে সংরক্ষণ করে - **রপ্তানি** - JSON ফাইল হিসাবে ডাউনলোড করুন - **আমদানি** - JSON ফাইল থেকে লোড করুন ### স্টোরেজ বিকল্প - **সেশন স্টোরেজ**: ব্রাউজার বন্ধ হলে অস্থায়ী সংরক্ষণগুলি মুছে যায় - **রপ্তানি/আমদানি**: JSON ফাইল হিসাবে স্থায়ী স্টোরেজ - **অটো-সেভ**: সেশনে প্রতি 5 সেকেন্ডে পরিবর্তনগুলি স্বয়ংক্রিয়ভাবে সংরক্ষণ করে ## অবদান রাখা আমরা অবদানকে স্বাগত জানাই! দয়া করে নির্দেশিকার জন্য [CONTRIBUTORS.md](../CONTRIBUTORS.md) দেখুন। ## ডকুমেন্টেশন - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - কোডবেসের জন্য ব্যাপক গাইড - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - অবদানের নির্দেশিকা ## লাইসেন্স MIT ================================================ FILE: docs/README.cn.md ================================================ # FossFLOW - 等距图表工具 fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

嗨! 我是 Stan,如果您使用过 FossFLOW 并觉得它对您有帮助,我会非常感激您能捐助一点点 :) 我全职工作,抽时间来维护这个项目已经很不容易了。 如果我为您实现了某个功能,或者修复了某个 bug,能得到您的支持将非常棒 :) 如果不能,也没关系,这个软件将永远免费! [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith 感谢, -Stan ------------------------------------------------------------------------------------------------------------------------------ FossFLOW 是一款功能强大的、开源的渐进式 Web 应用(PWA),专为创建精美的等距图表而设计。它基于 React 和 Isoflow(现已 fork 并以 fossflow 名称发布到 NPM)库构建,完全在浏览器中运行,并支持离线使用。 ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - 如何为项目做出贡献。 ## 功能 - 🎨 **等距图表** - 创建令人惊叹的 3D 风格技术图表 - 💾 **自动保存** - 您的工作每 5 秒自动保存一次 - 📱 **PWA 支持** - 在 Mac 和 Linux 上安装为原生应用 - 🔒 **隐私优先** - 所有数据都存储在您的浏览器中 - 📤 **导入/导出** - 以 JSON 文件形式分享图表 - 🎯 **会话存储** - 快速保存,无需对话框 - 🌐 **离线支持** - 无需网络连接即可工作 ## 在线试用 访问 https://stan-smith.github.io/FossFLOW/ ## 快速开始 (本地开发) ```bash # 克隆仓库 git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # 安装依赖 npm install # 启动开发服务器 npm start ``` 在浏览器中打开 [http://localhost:3000](http://localhost:3000)。 ## 使用方法 ### 创建图表 1. **添加项目**: - 按下右上角菜单的 "+" 按钮,组件库将出现在左侧。从库中拖放组件到画布上。 - 或者右键点击网格并选择 "Add node",然后点击新创建的节点并从左侧菜单自定义它。 2. **连接项目**:使用连接器显示组件之间的关系。 3. **自定义**:更改项目的颜色、标签和属性。 4. **导航**:平移和缩放以处理不同区域。 ### 保存您的工作 - **自动保存**:图表每 5 秒自动保存到浏览器存储。 - **快速保存**:点击 "Quick Save (Session)" 进行即时保存,无需弹窗。 - **另存为**:使用 "Save New" 创建具有不同名称的副本。 ### 管理图表 - **加载**:点击 "Load" 查看所有已保存的图表。 - **导入**:从他人分享的 JSON 文件加载图表。 - **导出**:将图表下载为 JSON 文件以分享或备份。 - **存储**:使用 "Storage Manager" 管理浏览器存储空间。 ### 键盘快捷键 - `Delete` - 删除选中项 - 鼠标滚轮 - 放大/缩小 - 点击并拖动 - 平移画布 - ***新增*** Ctrl+Z 撤销,Ctrl+Y 重做 ## 生产环境构建 ```bash # 创建优化后的生产环境构建 npm run build # 本地运行生产环境构建 npx serve -s build ``` `build` 文件夹包含所有部署所需的文件。 如果需要将应用部署到自定义路径(例如非根路径),请使用以下命令: ```bash # 为指定路径创建优化后的生产环境构建 PUBLIC_URL="https://mydomain.tld/path/to/app" npm run build ``` 这会将定义的 `PUBLIC_URL` 添加为所有静态文件链接的前缀。 ## 部署 ### 静态托管 将 `build` 文件夹部署到任何静态托管服务: - GitHub Pages - Netlify - Vercel - AWS S3 - 任何 Web 服务器 ### 重要说明 1. **需要 HTTPS**:PWA 功能需要 HTTPS(localhost 除外) 2. **浏览器存储**:图表保存在浏览器的 localStorage 中(约 5-10MB 限制) 3. **备份**:定期将重要图表导出为 JSON 文件 ## 浏览器支持 - Chrome/Edge(推荐)✅ - Firefox ✅ - Safari ✅ - 支持 PWA 的移动浏览器 ✅ ## 问题排查 ### 存储已满 - 使用存储管理器释放空间 - 导出并删除旧图表 - 清除浏览器数据(最后手段 - 会删除所有图表) ### 无法安装 PWA - 确保使用 HTTPS - 尝试使用 Chrome 或 Edge 浏览器 - 检查是否已安装 ### 图表丢失 - 检查浏览器的 localStorage - 查找自动保存的版本 - 始终导出重要工作 ## 技术栈 - **React** - UI 框架 - **TypeScript** - 类型安全 - **Isoflow** - 等距图表引擎 - **PWA** - 离线优先的 Web 应用 ## 贡献 欢迎贡献!请随时提交 Pull Request。 ## 许可证 Isoflow 使用 MIT 许可证发布。 FossFLOW 使用 Unlicense 许可证发布,您可以随意使用。 ## 鸣谢 基于 [Isoflow](https://github.com/markmanx/isoflow) 库构建。 x0z.co ================================================ FILE: docs/README.de.md ================================================ # FossFLOW - Isometrisches Diagramm-Werkzeug fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

Hey! Hier ist Stan. Wenn du FossFLOW benutzt hast und es dir geholfen hat, würde ich mich sehr über eine kleine Spende freuen :) Ich arbeite Vollzeit, und Zeit für dieses Projekt zu finden ist schon schwer genug. Wenn ich ein Feature für dich implementiert oder einen Bug behoben habe, wäre es toll, wenn du etwas spenden könntest :) Falls nicht, ist das kein Problem – diese Software bleibt immer kostenlos! Außerdem! Falls noch nicht geschehen, schau dir bitte die zugrunde liegende Bibliothek an, auf der dies aufbaut, von @markmanx. Ich stehe hier wirklich auf den Schultern eines Riesen 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) Buy Me A Coffee Danke, -Stan ## Online ausprobieren Gehe zu --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW ist eine leistungsstarke, quelloffene Progressive Web App (PWA) zum Erstellen schöner isometrischer Diagramme. Gebaut mit React und der Isoflow-Bibliothek (jetzt geforkt und auf NPM als fossflow veröffentlicht), läuft sie vollständig in deinem Browser mit Offline-Unterstützung. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTING.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTING.md)** - Wie du zum Projekt beitragen kannst. ## 🐳 Schnelle Bereitstellung mit Docker ```bash # Mit Docker Compose (empfohlen - beinhaltet persistenten Speicher) docker compose up # Oder direkt von Docker Hub mit persistentem Speicher ausführen docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Server-Speicher ist in Docker standardmäßig aktiviert. Deine Diagramme werden in `./diagrams` auf dem Host gespeichert. Um den Server-Speicher zu deaktivieren, setze `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Schnellstart (Lokale Entwicklung) ```bash # Repository klonen git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Abhängigkeiten installieren npm install # Bibliothek bauen (beim ersten Mal erforderlich) npm run build:lib # Entwicklungsserver starten npm run dev ``` Öffne [http://localhost:3000](http://localhost:3000) in deinem Browser. ## Monorepo-Struktur Dies ist ein Monorepo mit zwei Paketen: - `packages/fossflow-lib` - React-Komponentenbibliothek zum Zeichnen von Netzwerkdiagrammen (gebaut mit Webpack) - `packages/fossflow-app` - Progressive Web App, die die Bibliothek umhüllt und präsentiert (gebaut mit RSBuild) ### Entwicklungsbefehle ```bash # Entwicklung npm run dev # App-Entwicklungsserver starten npm run dev:lib # Watch-Modus für Bibliotheksentwicklung # Bauen npm run build # Bibliothek und App bauen npm run build:lib # Nur Bibliothek bauen npm run build:app # Nur App bauen # Testen & Linting npm test # Unit-Tests ausführen npm run lint # Auf Linting-Fehler prüfen # E2E-Tests (Selenium) cd e2e-tests ./run-tests.sh # End-to-End-Tests ausführen (erfordert Docker & Python) # Veröffentlichen npm run publish:lib # Bibliothek auf npm veröffentlichen ``` ## Verwendung ### Diagramme erstellen 1. **Elemente hinzufügen**: - Drücke die "+"-Taste im Menü oben rechts, die Komponentenbibliothek erscheint links - Ziehe Komponenten per Drag-and-Drop aus der Bibliothek auf die Leinwand - Oder klicke mit der rechten Maustaste auf das Raster und wähle "Knoten hinzufügen" 2. **Elemente verbinden**: - Wähle das Verbindungswerkzeug (drücke 'C' oder klicke auf das Verbindungssymbol) - **Klick-Modus** (Standard): Klicke auf den ersten Knoten, dann auf den zweiten - **Zieh-Modus** (optional): Klicke und ziehe vom ersten zum zweiten Knoten - Wechsle den Modus in Einstellungen → Verbindungen 3. **Arbeit speichern**: - **Schnellspeichern** - Speichert in der Browser-Sitzung - **Exportieren** - Als JSON-Datei herunterladen - **Importieren** - Aus JSON-Datei laden ### Speicheroptionen - **Sitzungsspeicher**: Temporäre Speicherungen, die beim Schließen des Browsers gelöscht werden - **Export/Import**: Permanente Speicherung als JSON-Dateien - **Automatisches Speichern**: Speichert Änderungen automatisch alle 5 Sekunden in der Sitzung ## Beitragen Wir freuen uns über Beiträge! Siehe [CONTRIBUTORS.md](../CONTRIBUTORS.md) für Richtlinien. ## Dokumentation - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Umfassender Leitfaden zur Codebase - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Beitragsrichtlinien ## Lizenz MIT ================================================ FILE: docs/README.es.md ================================================ # FossFLOW - Herramienta de Diagramas Isométricos fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

¡Hola! Soy Stan, si has usado FossFLOW y te ha ayudado, ¡realmente agradecería si pudieras donar algo pequeño :) Trabajo a tiempo completo, y encontrar tiempo para trabajar en este proyecto ya es bastante desafiante. Si he implementado una función para ti o arreglado un error, sería genial si pudieras :) si no, no hay problema, ¡este software siempre será gratuito! ¡También! Si aún no lo has hecho, por favor echa un vistazo a la biblioteca subyacente en la que esto está construido por @markmanx Realmente estoy sobre los hombros de un gigante aquí 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith Gracias, -Stan ## Pruébalo en línea Ve a --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW es una potente aplicación web progresiva (PWA) de código abierto para crear hermosos diagramas isométricos. Construido con React y la biblioteca Isoflow (Ahora bifurcada y publicada en NPM como fossflow), se ejecuta completamente en tu navegador con soporte sin conexión. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - Cómo contribuir al proyecto. ## Actualizaciones Recientes (Octubre 2025) ### Soporte Multilingüe - **8 Idiomas Soportados** - Traducción completa de la interfaz en inglés, chino (simplificado), español, portugués (brasileño), francés, hindi, bengalí y ruso - **Selector de Idioma** - Selector de idioma fácil de usar en el encabezado de la aplicación - **Traducción Completa** - Todos los menús, diálogos, configuraciones, información sobre herramientas y contenido de ayuda traducidos - **Consciente de la Localización** - Detecta y recuerda automáticamente tu preferencia de idioma ### Herramienta de Conector Mejorada - **Creación Basada en Clics** - Nuevo modo predeterminado: haz clic en el primer nodo, luego en el segundo nodo para conectar - **Opción de Modo de Arrastre** - El arrastre y colocación original sigue disponible a través de configuración - **Selección de Modo** - Cambia entre los modos de clic y arrastre en Configuración → pestaña Conectores - **Mejor Fiabilidad** - El modo de clic proporciona una creación de conexión más predecible ### Importación de Iconos Personalizados - **Importa Tus Propios Iconos** - Sube iconos personalizados (PNG, JPG, SVG) para usar en tus diagramas - **Escalado Automático** - Los iconos se escalan automáticamente a tamaños consistentes para una apariencia profesional - **Alternar Isométrico/Plano** - Elige si los iconos importados aparecen como 3D isométrico o 2D plano - **Persistencia Inteligente** - Los iconos personalizados se guardan con los diagramas y funcionan en todos los métodos de almacenamiento - **Recursos de Iconos** - Encuentra iconos gratuitos en: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - Miles de iconos SVG gratuitos - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - Paquetes de iconos isométricos de alta calidad ### Soporte de Almacenamiento en Servidor - **Almacenamiento Persistente** - Diagramas guardados en el sistema de archivos del servidor, persisten entre sesiones del navegador - **Acceso Multi-dispositivo** - Accede a tus diagramas desde cualquier dispositivo cuando uses implementación Docker - **Detección Automática** - La interfaz de usuario muestra automáticamente el almacenamiento del servidor cuando está disponible - **Protección contra Sobrescritura** - Diálogo de confirmación al guardar con nombres duplicados - **Integración Docker** - Almacenamiento en servidor habilitado por defecto en implementaciones Docker ### Funciones de Interacción Mejoradas - **Teclas de Acceso Rápido Configurables** - Tres perfiles (QWERTY, SMNRCT, Ninguno) para selección de herramientas con indicadores visuales - **Controles de Panorámica Avanzados** - Múltiples métodos de panorámica incluyendo arrastre de área vacía, clic medio/derecho, teclas modificadoras (Ctrl/Alt) y navegación por teclado (Flechas/WASD/IJKL) - **Alternar Flechas de Conector** - Opción para mostrar/ocultar flechas en conectores individuales - **Selección de Herramienta Persistente** - La herramienta de conector permanece activa después de crear conexiones - **Diálogo de Configuración** - Configuración centralizada para teclas de acceso rápido y controles de panorámica ### Mejoras de Docker y CI/CD - **Compilaciones Docker Automatizadas** - Flujo de trabajo de GitHub Actions para implementación automática de Docker Hub en commits - **Soporte Multi-arquitectura** - Imágenes Docker para `linux/amd64` y `linux/arm64` - **Imágenes Pre-construidas** - Disponibles en `stnsmith/fossflow:latest` ### Arquitectura Monorepo - **Repositorio único** para biblioteca y aplicación - **NPM Workspaces** para gestión de dependencias optimizada - **Proceso de compilación unificado** con `npm run build` en la raíz ### Correcciones de Interfaz - Se corrigió el problema de visualización de iconos de la barra de herramientas del editor Quill - Se resolvieron advertencias de clave React en menús contextuales - Se mejoró el estilo del editor de markdown ## Características - 🎨 **Diagramación Isométrica** - Crea impresionantes diagramas técnicos en estilo 3D - 💾 **Autoguardado** - Tu trabajo se guarda automáticamente cada 5 segundos - 📱 **Soporte PWA** - Instala como una aplicación nativa en Mac y Linux - 🔒 **Privacidad Primero** - Todos los datos se almacenan localmente en tu navegador - 📤 **Importar/Exportar** - Comparte diagramas como archivos JSON - 🎯 **Almacenamiento de Sesión** - Guardado rápido sin diálogos - 🌐 **Soporte Sin Conexión** - Trabaja sin conexión a internet - 🗄️ **Almacenamiento en Servidor** - Almacenamiento persistente opcional cuando se usa Docker (habilitado por defecto) - 🌍 **Multilingüe** - Soporte completo para 8 idiomas: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Implementación Rápida con Docker ```bash # Usando Docker Compose (recomendado - incluye almacenamiento persistente) docker compose up # O ejecutar directamente desde Docker Hub con almacenamiento persistente docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` El almacenamiento en servidor está habilitado por defecto en Docker. Tus diagramas se guardarán en `./diagrams` en el host. Para deshabilitar el almacenamiento en servidor, establece `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Inicio Rápido (Desarrollo Local) ```bash # Clonar el repositorio git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Instalar dependencias npm install # Compilar la biblioteca (requerido la primera vez) npm run build:lib # Iniciar servidor de desarrollo npm run dev ``` Abre [http://localhost:3000](http://localhost:3000) en tu navegador. ## Estructura del Monorepo Este es un monorepo que contiene dos paquetes: - `packages/fossflow-lib` - Biblioteca de componentes React para dibujar diagramas de red (construida con Webpack) - `packages/fossflow-app` - Aplicación Web Progresiva para crear diagramas isométricos (construida con RSBuild) ### Comandos de Desarrollo ```bash # Desarrollo npm run dev # Iniciar servidor de desarrollo de la aplicación npm run dev:lib # Modo watch para desarrollo de biblioteca # Compilación npm run build # Compilar biblioteca y aplicación npm run build:lib # Compilar solo biblioteca npm run build:app # Compilar solo aplicación # Pruebas y Linting npm test # Ejecutar pruebas unitarias npm run lint # Verificar errores de linting # Pruebas E2E (Selenium) cd e2e-tests ./run-tests.sh # Ejecutar pruebas end-to-end (requiere Docker y Python) # Publicación npm run publish:lib # Publicar biblioteca en npm ``` ## Cómo Usar ### Crear Diagramas 1. **Agregar Elementos**: - Presiona el botón "+" en el menú superior derecho, la biblioteca de componentes aparecerá a la izquierda - Arrastra y suelta componentes de la biblioteca al lienzo - O haz clic derecho en la cuadrícula y selecciona "Agregar nodo" 2. **Conectar Elementos**: - Selecciona la herramienta Conector (presiona 'C' o haz clic en el icono del conector) - **Modo de clic** (predeterminado): Haz clic en el primer nodo, luego haz clic en el segundo nodo - **Modo de arrastre** (opcional): Haz clic y arrastra desde el primer nodo al segundo - Cambia de modo en Configuración → pestaña Conectores 3. **Guardar Tu Trabajo**: - **Guardado Rápido** - Guarda en la sesión del navegador - **Exportar** - Descargar como archivo JSON - **Importar** - Cargar desde archivo JSON ### Opciones de Almacenamiento - **Almacenamiento de Sesión**: Guardados temporales eliminados cuando se cierra el navegador - **Exportar/Importar**: Almacenamiento permanente como archivos JSON - **Autoguardado**: Guarda automáticamente los cambios cada 5 segundos en la sesión ## Contribuir ¡Damos la bienvenida a las contribuciones! Por favor consulta [CONTRIBUTORS.md](../CONTRIBUTORS.md) para las pautas. ## Documentación - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Guía completa del código base - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Pautas de contribución ## Licencia MIT ================================================ FILE: docs/README.fr.md ================================================ # FossFLOW - Outil de Diagrammes Isométriques fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

Salut ! C'est Stan, si vous avez utilisé FossFLOW et qu'il vous a aidé, j'apprécierais vraiment si vous pouviez faire un petit don :) Je travaille à temps plein, et trouver le temps de travailler sur ce projet est déjà assez difficile. Si j'ai implémenté une fonctionnalité pour vous ou corrigé un bug, ce serait génial si vous pouviez :) sinon, ce n'est pas un problème, ce logiciel restera toujours gratuit ! Aussi ! Si vous ne l'avez pas encore fait, veuillez consulter la bibliothèque sous-jacente sur laquelle ceci est construit par @markmanx Je me tiens vraiment sur les épaules d'un géant ici 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith Merci, -Stan ## Essayez-le en ligne Allez sur --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW est une puissante Progressive Web App (PWA) open-source pour créer de beaux diagrammes isométriques. Construit avec React et la bibliothèque Isoflow (Maintenant forkée et publiée sur NPM comme fossflow), il fonctionne entièrement dans votre navigateur avec support hors ligne. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - Comment contribuer au projet. ## Mises à Jour Récentes (Octobre 2025) ### Support Multilingue - **8 Langues Supportées** - Traduction complète de l'interface en anglais, chinois (simplifié), espagnol, portugais (brésilien), français, hindi, bengali et russe - **Sélecteur de Langue** - Sélecteur de langue facile à utiliser dans l'en-tête de l'application - **Traduction Complète** - Tous les menus, dialogues, paramètres, info-bulles et contenu d'aide traduits - **Sensible aux Paramètres Régionaux** - Détecte et mémorise automatiquement votre préférence de langue ### Outil de Connecteur Amélioré - **Création par Clics** - Nouveau mode par défaut : cliquez sur le premier nœud, puis sur le second pour connecter - **Option Mode Glisser** - Le glisser-déposer original reste disponible via les paramètres - **Sélection de Mode** - Basculez entre les modes clic et glisser dans Paramètres → onglet Connecteurs - **Meilleure Fiabilité** - Le mode clic offre une création de connexion plus prévisible ### Importation d'Icônes Personnalisées - **Importez Vos Propres Icônes** - Téléchargez des icônes personnalisées (PNG, JPG, SVG) à utiliser dans vos diagrammes - **Mise à l'Échelle Automatique** - Les icônes sont automatiquement mises à l'échelle à des tailles cohérentes pour une apparence professionnelle - **Bascule Isométrique/Plat** - Choisissez si les icônes importées apparaissent en 3D isométrique ou 2D plat - **Persistance Intelligente** - Les icônes personnalisées sont enregistrées avec les diagrammes et fonctionnent avec toutes les méthodes de stockage - **Ressources d'Icônes** - Trouvez des icônes gratuites sur : - [Iconify Icon Sets](https://icon-sets.iconify.design/) - Des milliers d'icônes SVG gratuites - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - Packs d'icônes isométriques de haute qualité ### Support de Stockage Serveur - **Stockage Persistant** - Diagrammes enregistrés sur le système de fichiers du serveur, persistent entre les sessions du navigateur - **Accès Multi-appareils** - Accédez à vos diagrammes depuis n'importe quel appareil lors de l'utilisation du déploiement Docker - **Détection Automatique** - L'interface utilisateur affiche automatiquement le stockage serveur lorsqu'il est disponible - **Protection contre l'Écrasement** - Dialogue de confirmation lors de l'enregistrement avec des noms en double - **Intégration Docker** - Stockage serveur activé par défaut dans les déploiements Docker ### Fonctionnalités d'Interaction Améliorées - **Raccourcis Clavier Configurables** - Trois profils (QWERTY, SMNRCT, Aucun) pour la sélection d'outils avec indicateurs visuels - **Contrôles de Panoramique Avancés** - Plusieurs méthodes de panoramique incluant glisser sur zone vide, clic milieu/droit, touches modificatrices (Ctrl/Alt) et navigation au clavier (Flèches/WASD/IJKL) - **Basculer les Flèches du Connecteur** - Option pour afficher/masquer les flèches sur les connecteurs individuels - **Sélection d'Outil Persistante** - L'outil connecteur reste actif après la création de connexions - **Dialogue de Paramètres** - Configuration centralisée pour les raccourcis clavier et les contrôles de panoramique ### Améliorations Docker et CI/CD - **Builds Docker Automatisées** - Workflow GitHub Actions pour le déploiement automatique sur Docker Hub lors des commits - **Support Multi-architecture** - Images Docker pour `linux/amd64` et `linux/arm64` - **Images Pré-construites** - Disponibles sur `stnsmith/fossflow:latest` ### Architecture Monorepo - **Référentiel unique** pour la bibliothèque et l'application - **NPM Workspaces** pour une gestion rationalisée des dépendances - **Processus de build unifié** avec `npm run build` à la racine ### Corrections d'Interface - Problème d'affichage des icônes de la barre d'outils de l'éditeur Quill corrigé - Avertissements de clé React résolus dans les menus contextuels - Style de l'éditeur markdown amélioré ## Fonctionnalités - 🎨 **Diagrammes Isométriques** - Créez de superbes diagrammes techniques en style 3D - 💾 **Sauvegarde Automatique** - Votre travail est automatiquement sauvegardé toutes les 5 secondes - 📱 **Support PWA** - Installez comme une application native sur Mac et Linux - 🔒 **Confidentialité d'Abord** - Toutes les données stockées localement dans votre navigateur - 📤 **Importer/Exporter** - Partagez des diagrammes sous forme de fichiers JSON - 🎯 **Stockage de Session** - Sauvegarde rapide sans dialogues - 🌐 **Support Hors Ligne** - Travaillez sans connexion internet - 🗄️ **Stockage Serveur** - Stockage persistant optionnel lors de l'utilisation de Docker (activé par défaut) - 🌍 **Multilingue** - Support complet pour 8 langues : English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Déploiement Rapide avec Docker ```bash # Utilisation de Docker Compose (recommandé - inclut le stockage persistant) docker compose up # Ou exécuter directement depuis Docker Hub avec stockage persistant docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Le stockage serveur est activé par défaut dans Docker. Vos diagrammes seront enregistrés dans `./diagrams` sur l'hôte. Pour désactiver le stockage serveur, définissez `ENABLE_SERVER_STORAGE=false` : ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Démarrage Rapide (Développement Local) ```bash # Cloner le référentiel git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Installer les dépendances npm install # Compiler la bibliothèque (requis la première fois) npm run build:lib # Démarrer le serveur de développement npm run dev ``` Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur. ## Structure du Monorepo Ceci est un monorepo contenant deux packages : - `packages/fossflow-lib` - Bibliothèque de composants React pour dessiner des diagrammes de réseau (construit avec Webpack) - `packages/fossflow-app` - Progressive Web App pour créer des diagrammes isométriques (construit avec RSBuild) ### Commandes de Développement ```bash # Développement npm run dev # Démarrer le serveur de développement de l'application npm run dev:lib # Mode watch pour le développement de la bibliothèque # Build npm run build # Compiler la bibliothèque et l'application npm run build:lib # Compiler uniquement la bibliothèque npm run build:app # Compiler uniquement l'application # Tests et Linting npm test # Exécuter les tests unitaires npm run lint # Vérifier les erreurs de linting # Tests E2E (Selenium) cd e2e-tests ./run-tests.sh # Exécuter les tests end-to-end (nécessite Docker et Python) # Publication npm run publish:lib # Publier la bibliothèque sur npm ``` ## Comment Utiliser ### Créer des Diagrammes 1. **Ajouter des Éléments** : - Appuyez sur le bouton "+" dans le menu en haut à droite, la bibliothèque de composants apparaîtra à gauche - Glissez et déposez les composants de la bibliothèque sur le canevas - Ou cliquez avec le bouton droit sur la grille et sélectionnez "Ajouter un nœud" 2. **Connecter des Éléments** : - Sélectionnez l'outil Connecteur (appuyez sur 'C' ou cliquez sur l'icône du connecteur) - **Mode clic** (par défaut) : Cliquez sur le premier nœud, puis cliquez sur le second nœud - **Mode glisser** (optionnel) : Cliquez et glissez du premier au second nœud - Basculez entre les modes dans Paramètres → onglet Connecteurs 3. **Sauvegarder Votre Travail** : - **Sauvegarde Rapide** - Enregistre dans la session du navigateur - **Exporter** - Télécharger comme fichier JSON - **Importer** - Charger depuis un fichier JSON ### Options de Stockage - **Stockage de Session** : Sauvegardes temporaires effacées à la fermeture du navigateur - **Exporter/Importer** : Stockage permanent sous forme de fichiers JSON - **Sauvegarde Automatique** : Enregistre automatiquement les modifications toutes les 5 secondes dans la session ## Contribuer Nous accueillons les contributions ! Veuillez consulter [CONTRIBUTORS.md](../CONTRIBUTORS.md) pour les directives. ## Documentation - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Guide complet de la base de code - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Directives de contribution ## Licence MIT ================================================ FILE: docs/README.hi.md ================================================ # FossFLOW - आइसोमेट्रिक आरेख उपकरण fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

नमस्ते! मैं Stan हूं, यदि आपने FossFLOW का उपयोग किया है और इसने आपकी मदद की है, तो मैं वास्तव में सराहना करूंगा यदि आप कुछ छोटा दान कर सकें :) मैं पूर्णकालिक काम करता हूं, और इस परियोजना पर काम करने के लिए समय निकालना पर्याप्त चुनौतीपूर्ण है। यदि मैंने आपके लिए कोई सुविधा लागू की है या कोई बग ठीक किया है, तो यह बहुत अच्छा होगा यदि आप कर सकें :) यदि नहीं, तो कोई समस्या नहीं है, यह सॉफ्टवेयर हमेशा मुफ्त रहेगा! साथ ही! यदि आपने अभी तक नहीं किया है, तो कृपया @markmanx द्वारा बनाई गई अंतर्निहित लाइब्रेरी देखें, जिस पर यह बना है। मैं वास्तव में यहां एक दिग्गज के कंधों पर खड़ा हूं 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith धन्यवाद, -Stan ## इसे ऑनलाइन आज़माएं यहां जाएं --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW सुंदर आइसोमेट्रिक आरेख बनाने के लिए एक शक्तिशाली, ओपन-सोर्स प्रोग्रेसिव वेब ऐप (PWA) है। React और Isoflow लाइब्रेरी (अब फोर्क किया गया और NPM पर fossflow के रूप में प्रकाशित) के साथ बनाया गया, यह ऑफ़लाइन समर्थन के साथ पूरी तरह से आपके ब्राउज़र में चलता है। ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - परियोजना में योगदान कैसे करें। ## हाल के अपडेट (अक्टूबर 2025) ### बहुभाषी समर्थन - **8 भाषाएं समर्थित** - अंग्रेजी, चीनी (सरलीकृत), स्पेनिश, पुर्तगाली (ब्राज़ीलियाई), फ्रेंच, हिंदी, बंगाली और रूसी में पूर्ण इंटरफ़ेस अनुवाद - **भाषा चयनकर्ता** - ऐप हेडर में उपयोग में आसान भाषा स्विचर - **पूर्ण अनुवाद** - सभी मेनू, संवाद, सेटिंग्स, टूलटिप्स और सहायता सामग्री अनुवादित - **लोकेल-जागरूक** - स्वचालित रूप से आपकी भाषा प्राथमिकता का पता लगाता है और याद रखता है ### बेहतर कनेक्टर उपकरण - **क्लिक-आधारित निर्माण** - नया डिफ़ॉल्ट मोड: पहले नोड पर क्लिक करें, फिर कनेक्ट करने के लिए दूसरे नोड पर क्लिक करें - **ड्रैग मोड विकल्प** - मूल ड्रैग-एंड-ड्रॉप अभी भी सेटिंग्स के माध्यम से उपलब्ध है - **मोड चयन** - सेटिंग्स → कनेक्टर टैब में क्लिक और ड्रैग मोड के बीच स्विच करें - **बेहतर विश्वसनीयता** - क्लिक मोड अधिक अनुमानित कनेक्शन निर्माण प्रदान करता है ### कस्टम आइकन आयात - **अपने स्वयं के आइकन आयात करें** - अपने आरेखों में उपयोग करने के लिए कस्टम आइकन (PNG, JPG, SVG) अपलोड करें - **स्वचालित स्केलिंग** - पेशेवर उपस्थिति के लिए आइकन स्वचालित रूप से सुसंगत आकारों में स्केल किए जाते हैं - **आइसोमेट्रिक/फ्लैट टॉगल** - चुनें कि आयातित आइकन 3D आइसोमेट्रिक या फ्लैट 2D के रूप में दिखाई दें - **स्मार्ट दृढ़ता** - कस्टम आइकन आरेखों के साथ सहेजे जाते हैं और सभी भंडारण विधियों में काम करते हैं - **आइकन संसाधन** - मुफ्त आइकन यहां खोजें: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - हजारों मुफ्त SVG आइकन - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - उच्च गुणवत्ता वाले आइसोमेट्रिक आइकन पैक ### सर्वर स्टोरेज समर्थन - **स्थायी भंडारण** - सर्वर फ़ाइल सिस्टम में सहेजे गए आरेख, ब्राउज़र सत्रों में बने रहते हैं - **बहु-डिवाइस पहुंच** - Docker डिप्लॉयमेंट का उपयोग करते समय किसी भी डिवाइस से अपने आरेखों तक पहुंचें - **स्वचालित पहचान** - उपलब्ध होने पर UI स्वचालित रूप से सर्वर स्टोरेज दिखाता है - **अधिलेखन सुरक्षा** - डुप्लिकेट नामों से सहेजते समय पुष्टिकरण संवाद - **Docker एकीकरण** - Docker डिप्लॉयमेंट में डिफ़ॉल्ट रूप से सर्वर स्टोरेज सक्षम ### बेहतर इंटरैक्शन सुविधाएं - **कॉन्फ़िगर करने योग्य हॉटकी** - विजुअल संकेतकों के साथ उपकरण चयन के लिए तीन प्रोफाइल (QWERTY, SMNRCT, कोई नहीं) - **उन्नत पैन नियंत्रण** - रिक्त क्षेत्र ड्रैग, मध्य/दाएं क्लिक, संशोधक कुंजी (Ctrl/Alt) और कीबोर्ड नेविगेशन (Arrow/WASD/IJKL) सहित कई पैन विधियां - **कनेक्टर तीर टॉगल करें** - व्यक्तिगत कनेक्टरों पर तीर दिखाने/छिपाने का विकल्प - **स्थायी उपकरण चयन** - कनेक्शन बनाने के बाद कनेक्टर उपकरण सक्रिय रहता है - **सेटिंग्स संवाद** - हॉटकी और पैन नियंत्रण के लिए केंद्रीकृत कॉन्फ़िगरेशन ### Docker और CI/CD सुधार - **स्वचालित Docker बिल्ड** - कमिट्स पर स्वचालित Docker Hub डिप्लॉयमेंट के लिए GitHub Actions वर्कफ़्लो - **बहु-आर्किटेक्चर समर्थन** - `linux/amd64` और `linux/arm64` दोनों के लिए Docker छवियां - **पूर्व-निर्मित छवियां** - `stnsmith/fossflow:latest` पर उपलब्ध ### Monorepo आर्किटेक्चर - लाइब्रेरी और एप्लिकेशन दोनों के लिए **एकल रिपॉजिटरी** - सुव्यवस्थित निर्भरता प्रबंधन के लिए **NPM Workspaces** - रूट पर `npm run build` के साथ **एकीकृत बिल्ड प्रक्रिया** ### UI सुधार - Quill संपादक टूलबार आइकन प्रदर्शन समस्या ठीक की गई - संदर्भ मेनू में React कुंजी चेतावनियां हल की गईं - markdown संपादक स्टाइलिंग में सुधार किया गया ## विशेषताएं - 🎨 **आइसोमेट्रिक आरेख** - आश्चर्यजनक 3D-शैली तकनीकी आरेख बनाएं - 💾 **ऑटो-सेव** - आपका काम हर 5 सेकंड में स्वचालित रूप से सहेजा जाता है - 📱 **PWA समर्थन** - Mac और Linux पर मूल ऐप के रूप में इंस्टॉल करें - 🔒 **गोपनीयता-प्रथम** - सभी डेटा आपके ब्राउज़र में स्थानीय रूप से संग्रहीत - 📤 **आयात/निर्यात** - JSON फ़ाइलों के रूप में आरेख साझा करें - 🎯 **सत्र भंडारण** - संवाद के बिना त्वरित सहेजें - 🌐 **ऑफ़लाइन समर्थन** - इंटरनेट कनेक्शन के बिना काम करें - 🗄️ **सर्वर स्टोरेज** - Docker उपयोग करते समय वैकल्पिक स्थायी भंडारण (डिफ़ॉल्ट रूप से सक्षम) - 🌍 **बहुभाषी** - 8 भाषाओं के लिए पूर्ण समर्थन: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Docker के साथ त्वरित डिप्लॉय ```bash # Docker Compose का उपयोग करना (अनुशंसित - स्थायी भंडारण शामिल) docker compose up # या स्थायी भंडारण के साथ Docker Hub से सीधे चलाएं docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Docker में सर्वर स्टोरेज डिफ़ॉल्ट रूप से सक्षम है। आपके आरेख होस्ट पर `./diagrams` में सहेजे जाएंगे। सर्वर स्टोरेज अक्षम करने के लिए, `ENABLE_SERVER_STORAGE=false` सेट करें: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## त्वरित प्रारंभ (स्थानीय विकास) ```bash # रिपॉजिटरी क्लोन करें git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # निर्भरताएं इंस्टॉल करें npm install # लाइब्रेरी बनाएं (पहली बार आवश्यक) npm run build:lib # विकास सर्वर प्रारंभ करें npm run dev ``` अपने ब्राउज़र में [http://localhost:3000](http://localhost:3000) खोलें। ## Monorepo संरचना यह दो पैकेज वाला एक monorepo है: - `packages/fossflow-lib` - नेटवर्क आरेख बनाने के लिए React घटक लाइब्रेरी (Webpack के साथ निर्मित) - `packages/fossflow-app` - आइसोमेट्रिक आरेख बनाने के लिए Progressive Web App (RSBuild के साथ निर्मित) ### विकास आदेश ```bash # विकास npm run dev # ऐप विकास सर्वर शुरू करें npm run dev:lib # लाइब्रेरी विकास के लिए वॉच मोड # बिल्डिंग npm run build # लाइब्रेरी और ऐप दोनों बनाएं npm run build:lib # केवल लाइब्रेरी बनाएं npm run build:app # केवल ऐप बनाएं # परीक्षण और लिंटिंग npm test # यूनिट टेस्ट चलाएं npm run lint # लिंटिंग त्रुटियों की जांच करें # E2E टेस्ट (Selenium) cd e2e-tests ./run-tests.sh # एंड-टू-एंड टेस्ट चलाएं (Docker और Python आवश्यक) # प्रकाशन npm run publish:lib # npm पर लाइब्रेरी प्रकाशित करें ``` ## उपयोग कैसे करें ### आरेख बनाना 1. **आइटम जोड़ें**: - शीर्ष दाईं ओर मेनू पर "+" बटन दबाएं, घटक लाइब्रेरी बाईं ओर दिखाई देगी - लाइब्रेरी से घटकों को कैनवास पर ड्रैग और ड्रॉप करें - या ग्रिड पर राइट-क्लिक करें और "नोड जोड़ें" चुनें 2. **आइटम कनेक्ट करें**: - कनेक्टर उपकरण चुनें ('C' दबाएं या कनेक्टर आइकन पर क्लिक करें) - **क्लिक मोड** (डिफ़ॉल्ट): पहले नोड पर क्लिक करें, फिर दूसरे नोड पर क्लिक करें - **ड्रैग मोड** (वैकल्पिक): पहले से दूसरे नोड तक क्लिक करें और ड्रैग करें - सेटिंग्स → कनेक्टर टैब में मोड स्विच करें 3. **अपना काम सहेजें**: - **त्वरित सहेजें** - ब्राउज़र सत्र में सहेजता है - **निर्यात** - JSON फ़ाइल के रूप में डाउनलोड करें - **आयात** - JSON फ़ाइल से लोड करें ### भंडारण विकल्प - **सत्र भंडारण**: ब्राउज़र बंद होने पर अस्थायी सहेजें साफ़ हो जाते हैं - **निर्यात/आयात**: JSON फ़ाइलों के रूप में स्थायी भंडारण - **ऑटो-सेव**: सत्र में हर 5 सेकंड में परिवर्तन स्वचालित रूप से सहेजता है ## योगदान देना हम योगदान का स्वागत करते हैं! कृपया दिशानिर्देशों के लिए [CONTRIBUTORS.md](../CONTRIBUTORS.md) देखें। ## प्रलेखन - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - कोडबेस के लिए व्यापक गाइड - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - योगदान दिशानिर्देश ## लाइसेंस MIT ================================================ FILE: docs/README.id.md ================================================ # FossFLOW - Alat Diagram Isometrik fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

Halo! Saya Stan, jika Anda telah menggunakan FossFLOW dan ini membantu Anda, saya akan sangat menghargai jika Anda bisa menyumbang sesuatu yang kecil :) Saya bekerja penuh waktu, dan menemukan waktu untuk mengerjakan proyek ini sudah cukup menantang. Jika saya telah mengimplementasikan fitur untuk Anda atau memperbaiki bug, akan sangat bagus jika Anda bisa menyumbang :) jika tidak, tidak masalah, software ini akan selalu tetap gratis! Juga! Jika Anda belum melakukannya, silakan lihat library dasar yang digunakan untuk membangun ini oleh @markmanx Saya benar-benar berdiri di atas bahu raksasa di sini 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith Terima kasih, -Stan ## Coba Secara Online Kunjungi --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW adalah aplikasi web progresif (PWA) open-source yang powerful untuk membuat diagram isometrik yang indah. Dibangun dengan React dan library Isoflow (Sekarang di-fork dan dipublikasikan ke NPM sebagai fossflow), berjalan sepenuhnya di browser Anda dengan dukungan offline. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - Cara berkontribusi pada proyek. ## Pembaruan Terbaru (Oktober 2025) ### Impor Ikon Kustom - **Impor Ikon Anda Sendiri** - Unggah ikon kustom (PNG, JPG, SVG) untuk digunakan dalam diagram Anda - **Penskalaan Otomatis** - Ikon secara otomatis diskalakan ke ukuran yang konsisten untuk tampilan profesional - **Toggle Isometrik/Datar** - Pilih apakah ikon yang diimpor muncul sebagai 3D isometrik atau 2D datar - **Persistence Cerdas** - Ikon kustom disimpan dengan diagram dan bekerja di semua metode penyimpanan - **Sumber Daya Ikon** - Temukan ikon gratis di: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - Ribuan ikon SVG gratis - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - Paket ikon isometrik berkualitas tinggi ### Dukungan Penyimpanan Server - **Penyimpanan Persisten** - Diagram disimpan ke filesystem server, bertahan di seluruh sesi browser - **Akses Multi-perangkat** - Akses diagram Anda dari perangkat apa pun saat menggunakan deployment Docker - **Deteksi Otomatis** - UI secara otomatis menampilkan penyimpanan server saat tersedia - **Perlindungan Penimpaan** - Dialog konfirmasi saat menyimpan dengan nama duplikat - **Integrasi Docker** - Penyimpanan server diaktifkan secara default dalam deployment Docker ### Fitur Interaksi yang Ditingkatkan - **Hotkey yang Dapat Dikonfigurasi** - Tiga profil (QWERTY, SMNRCT, None) untuk pemilihan alat dengan indikator visual - **Kontrol Pan Lanjutan** - Beberapa metode pan termasuk seret area kosong, klik tengah/kanan, tombol modifier (Ctrl/Alt), dan navigasi keyboard (Arrow/WASD/IJKL) - **Toggle Panah Konektor** - Opsi untuk menampilkan/menyembunyikan panah pada konektor individual - **Pemilihan Alat Persisten** - Alat konektor tetap aktif setelah membuat koneksi - **Dialog Pengaturan** - Konfigurasi terpusat untuk hotkey dan kontrol pan ### Peningkatan Docker & CI/CD - **Build Docker Otomatis** - Workflow GitHub Actions untuk deployment Docker Hub otomatis pada commit - **Dukungan Multi-arsitektur** - Image Docker untuk `linux/amd64` dan `linux/arm64` - **Image Pra-dibangun** - Tersedia di `stnsmith/fossflow:latest` ### Arsitektur Monorepo - **Repositori tunggal** untuk library dan aplikasi - **NPM Workspaces** untuk manajemen dependensi yang efisien - **Proses build terpadu** dengan `npm run build` di root ### Perbaikan UI - Memperbaiki masalah tampilan ikon toolbar editor Quill - Menyelesaikan peringatan key React di menu konteks - Meningkatkan styling editor markdown ## Fitur - 🎨 **Diagram Isometrik** - Buat diagram teknis bergaya 3D yang menakjubkan - 💾 **Auto-Save** - Pekerjaan Anda secara otomatis disimpan setiap 5 detik - 📱 **Dukungan PWA** - Instal sebagai aplikasi native di Mac dan Linux - 🔒 **Privasi Pertama** - Semua data disimpan secara lokal di browser Anda - 📤 **Impor/Ekspor** - Bagikan diagram sebagai file JSON - 🎯 **Penyimpanan Sesi** - Simpan cepat tanpa dialog - 🌐 **Dukungan Offline** - Bekerja tanpa koneksi internet - 🗄️ **Penyimpanan Server** - Penyimpanan persisten opsional saat menggunakan Docker (diaktifkan secara default) - 🌍 **Multibahasa** - Dukungan lengkap untuk 9 bahasa: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский, Bahasa Indonesia ## 🐳 Deploy Cepat dengan Docker ```bash # Menggunakan Docker Compose (disarankan - termasuk penyimpanan persisten) docker compose up # Atau jalankan langsung dari Docker Hub dengan penyimpanan persisten docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Penyimpanan server diaktifkan secara default di Docker. Diagram Anda akan disimpan ke `./diagrams` di host. Untuk menonaktifkan penyimpanan server, set `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Mulai Cepat (Pengembangan Lokal) ```bash # Clone repositori git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Install dependensi npm install # Build library (diperlukan pertama kali) npm run build:lib # Mulai development server npm run dev ``` Buka [http://localhost:3000](http://localhost:3000) di browser Anda. ## Struktur Monorepo Ini adalah monorepo yang berisi dua paket: - `packages/fossflow-lib` - Library komponen React untuk menggambar diagram jaringan (dibangun dengan Webpack) - `packages/fossflow-app` - Progressive Web App untuk membuat diagram isometrik (dibangun dengan RSBuild) ### Perintah Pengembangan ```bash # Pengembangan npm run dev # Mulai development server aplikasi npm run dev:lib # Mode watch untuk pengembangan library # Build npm run build # Build library dan aplikasi npm run build:lib # Build library saja npm run build:app # Build aplikasi saja # Testing & Linting npm test # Jalankan unit test npm run lint # Periksa error linting # E2E Tests (Selenium) cd e2e-tests ./run-tests.sh # Jalankan end-to-end tests (memerlukan Docker & Python) # Publishing npm run publish:lib # Publish library ke npm ``` ## Cara Menggunakan ### Membuat Diagram 1. **Tambahkan Item**: - Tekan tombol "+" di menu kanan atas, library komponen akan muncul di kiri - Seret dan lepas komponen dari library ke kanvas - Atau klik kanan pada grid dan pilih "Add node" 2. **Hubungkan Item**: - Pilih alat Konektor (tekan 'C' atau klik ikon konektor) - **Mode klik** (default): Klik node pertama, lalu klik node kedua - **Mode seret** (opsional): Klik dan seret dari node pertama ke node kedua - Beralih mode di Pengaturan → tab Konektor 3. **Simpan Pekerjaan Anda**: - **Simpan Cepat** - Menyimpan ke sesi browser - **Ekspor** - Unduh sebagai file JSON - **Impor** - Muat dari file JSON ### Opsi Penyimpanan - **Penyimpanan Sesi**: Simpan sementara yang dihapus saat browser ditutup - **Ekspor/Impor**: Penyimpanan permanen sebagai file JSON - **Auto-Save**: Secara otomatis menyimpan perubahan setiap 5 detik ke sesi ## Berkontribusi Kami menyambut kontribusi! Silakan lihat [CONTRIBUTORS.md](../CONTRIBUTORS.md) untuk panduan. ## Dokumentasi - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Panduan lengkap untuk codebase - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Panduan kontribusi ## Lisensi MIT ================================================ FILE: docs/README.pt.md ================================================ # FossFLOW - Ferramenta de Diagramas Isométricos fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

Olá! Aqui é o Stan, se você usou o FossFLOW e ele te ajudou, eu realmente agradeceria se você pudesse doar algo pequeno :) Eu trabalho em tempo integral, e encontrar tempo para trabalhar neste projeto já é desafiador o suficiente. Se eu implementei um recurso para você ou corrigi um bug, seria ótimo se você pudesse :) se não, não há problema, este software sempre será gratuito! Também! Se você ainda não o fez, por favor confira a biblioteca subjacente na qual isso é construído por @markmanx Eu realmente estou sobre os ombros de um gigante aqui 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith Obrigado, -Stan ## Experimente online Vá para --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW é um poderoso Progressive Web App (PWA) de código aberto para criar belos diagramas isométricos. Construído com React e a biblioteca Isoflow (Agora bifurcada e publicada no NPM como fossflow), ele roda inteiramente no seu navegador com suporte offline. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - Como contribuir para o projeto. ## Atualizações Recentes (Outubro 2025) ### Suporte Multilíngue - **8 Idiomas Suportados** - Tradução completa da interface em inglês, chinês (simplificado), espanhol, português (brasileiro), francês, hindi, bengali e russo - **Seletor de Idioma** - Seletor de idioma fácil de usar no cabeçalho do aplicativo - **Tradução Completa** - Todos os menus, diálogos, configurações, dicas de ferramentas e conteúdo de ajuda traduzidos - **Consciente de Localidade** - Detecta e lembra automaticamente sua preferência de idioma ### Ferramenta de Conector Aprimorada - **Criação Baseada em Cliques** - Novo modo padrão: clique no primeiro nó, depois no segundo nó para conectar - **Opção de Modo de Arrastar** - O arrastar e soltar original ainda está disponível através das configurações - **Seleção de Modo** - Alterne entre os modos de clique e arrastar em Configurações → aba Conectores - **Melhor Confiabilidade** - O modo de clique fornece criação de conexão mais previsível ### Importação de Ícones Personalizados - **Importe Seus Próprios Ícones** - Carregue ícones personalizados (PNG, JPG, SVG) para usar em seus diagramas - **Dimensionamento Automático** - Os ícones são dimensionados automaticamente para tamanhos consistentes para aparência profissional - **Alternar Isométrico/Plano** - Escolha se os ícones importados aparecem como 3D isométrico ou 2D plano - **Persistência Inteligente** - Ícones personalizados são salvos com diagramas e funcionam em todos os métodos de armazenamento - **Recursos de Ícones** - Encontre ícones gratuitos em: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - Milhares de ícones SVG gratuitos - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - Pacotes de ícones isométricos de alta qualidade ### Suporte de Armazenamento no Servidor - **Armazenamento Persistente** - Diagramas salvos no sistema de arquivos do servidor, persistem entre sessões do navegador - **Acesso Multi-dispositivo** - Acesse seus diagramas de qualquer dispositivo ao usar implantação Docker - **Detecção Automática** - A interface do usuário mostra automaticamente o armazenamento do servidor quando disponível - **Proteção contra Sobrescrita** - Diálogo de confirmação ao salvar com nomes duplicados - **Integração Docker** - Armazenamento no servidor habilitado por padrão em implantações Docker ### Recursos de Interação Aprimorados - **Teclas de Atalho Configuráveis** - Três perfis (QWERTY, SMNRCT, Nenhum) para seleção de ferramentas com indicadores visuais - **Controles de Panorâmica Avançados** - Múltiplos métodos de panorâmica incluindo arrastar área vazia, clique do meio/direito, teclas modificadoras (Ctrl/Alt) e navegação por teclado (Setas/WASD/IJKL) - **Alternar Setas do Conector** - Opção para mostrar/ocultar setas em conectores individuais - **Seleção de Ferramenta Persistente** - A ferramenta de conector permanece ativa após criar conexões - **Diálogo de Configurações** - Configuração centralizada para teclas de atalho e controles de panorâmica ### Melhorias de Docker e CI/CD - **Builds Docker Automatizadas** - Fluxo de trabalho do GitHub Actions para implantação automática do Docker Hub em commits - **Suporte Multi-arquitetura** - Imagens Docker para `linux/amd64` e `linux/arm64` - **Imagens Pré-construídas** - Disponíveis em `stnsmith/fossflow:latest` ### Arquitetura Monorepo - **Repositório único** para biblioteca e aplicação - **NPM Workspaces** para gerenciamento de dependências simplificado - **Processo de build unificado** com `npm run build` na raiz ### Correções de Interface - Corrigido problema de exibição de ícones da barra de ferramentas do editor Quill - Resolvidos avisos de chave React em menus de contexto - Melhorado estilo do editor de markdown ## Características - 🎨 **Diagramação Isométrica** - Crie impressionantes diagramas técnicos em estilo 3D - 💾 **Salvamento Automático** - Seu trabalho é salvo automaticamente a cada 5 segundos - 📱 **Suporte PWA** - Instale como um aplicativo nativo no Mac e Linux - 🔒 **Privacidade em Primeiro Lugar** - Todos os dados armazenados localmente no seu navegador - 📤 **Importar/Exportar** - Compartilhe diagramas como arquivos JSON - 🎯 **Armazenamento de Sessão** - Salvamento rápido sem diálogos - 🌐 **Suporte Offline** - Trabalhe sem conexão à internet - 🗄️ **Armazenamento no Servidor** - Armazenamento persistente opcional ao usar Docker (habilitado por padrão) - 🌍 **Multilíngue** - Suporte completo para 8 idiomas: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Implantação Rápida com Docker ```bash # Usando Docker Compose (recomendado - inclui armazenamento persistente) docker compose up # Ou execute diretamente do Docker Hub com armazenamento persistente docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` O armazenamento no servidor está habilitado por padrão no Docker. Seus diagramas serão salvos em `./diagrams` no host. Para desabilitar o armazenamento no servidor, defina `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Início Rápido (Desenvolvimento Local) ```bash # Clonar o repositório git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Instalar dependências npm install # Compilar a biblioteca (necessário na primeira vez) npm run build:lib # Iniciar servidor de desenvolvimento npm run dev ``` Abra [http://localhost:3000](http://localhost:3000) no seu navegador. ## Estrutura do Monorepo Este é um monorepo contendo dois pacotes: - `packages/fossflow-lib` - Biblioteca de componentes React para desenhar diagramas de rede (construída com Webpack) - `packages/fossflow-app` - Progressive Web App para criar diagramas isométricos (construído com RSBuild) ### Comandos de Desenvolvimento ```bash # Desenvolvimento npm run dev # Iniciar servidor de desenvolvimento do aplicativo npm run dev:lib # Modo watch para desenvolvimento da biblioteca # Build npm run build # Compilar biblioteca e aplicativo npm run build:lib # Compilar apenas biblioteca npm run build:app # Compilar apenas aplicativo # Testes e Linting npm test # Executar testes unitários npm run lint # Verificar erros de linting # Testes E2E (Selenium) cd e2e-tests ./run-tests.sh # Executar testes end-to-end (requer Docker e Python) # Publicação npm run publish:lib # Publicar biblioteca no npm ``` ## Como Usar ### Criar Diagramas 1. **Adicionar Itens**: - Pressione o botão "+" no menu superior direito, a biblioteca de componentes aparecerá à esquerda - Arraste e solte componentes da biblioteca na tela - Ou clique com o botão direito na grade e selecione "Adicionar nó" 2. **Conectar Itens**: - Selecione a ferramenta Conector (pressione 'C' ou clique no ícone do conector) - **Modo de clique** (padrão): Clique no primeiro nó, depois clique no segundo nó - **Modo de arrastar** (opcional): Clique e arraste do primeiro nó para o segundo - Alterne os modos em Configurações → aba Conectores 3. **Salvar Seu Trabalho**: - **Salvamento Rápido** - Salva na sessão do navegador - **Exportar** - Baixar como arquivo JSON - **Importar** - Carregar de arquivo JSON ### Opções de Armazenamento - **Armazenamento de Sessão**: Salvamentos temporários apagados quando o navegador fecha - **Exportar/Importar**: Armazenamento permanente como arquivos JSON - **Salvamento Automático**: Salva automaticamente as alterações a cada 5 segundos na sessão ## Contribuindo Damos as boas-vindas a contribuições! Por favor veja [CONTRIBUTORS.md](../CONTRIBUTORS.md) para diretrizes. ## Documentação - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Guia abrangente para a base de código - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Diretrizes de contribuição ## Licença MIT ================================================ FILE: docs/README.ru.md ================================================ # FossFLOW - Инструмент для изометрических диаграмм fossflow

English | 简体中文 | Español | Português | Français | हिन्दी | বাংলা | Русский | Bahasa Indonesia | Deutsch

Привет! Я Stan, если вы использовали FossFLOW и это помогло вам, я буду очень признателен, если вы сможете сделать небольшое пожертвование :) Я работаю полный рабочий день, и найти время для работы над этим проектом достаточно сложно. Если я реализовал для вас функцию или исправил ошибку, было бы здорово, если бы вы могли :) если нет, то это не проблема, это программное обеспечение всегда останется бесплатным! Также! Если вы еще не сделали этого, пожалуйста, ознакомьтесь с базовой библиотекой, на которой это построено, от @markmanx Я действительно стою здесь на плечах гиганта 🫡 [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/P5P61KBXA3) image https://buymeacoffee.com/stan.smith Спасибо, -Stan ## Попробуйте онлайн Перейдите на --> https://stan-smith.github.io/FossFLOW/ <-- ------------------------------------------------------------------------------------------------------------------------------ FossFLOW - это мощное прогрессивное веб-приложение (PWA) с открытым исходным кодом для создания красивых изометрических диаграмм. Созданное с помощью React и библиотеки Isoflow (Теперь форкнуто и опубликовано в NPM как fossflow), оно полностью работает в вашем браузере с поддержкой офлайн-режима. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/FossFLOW/blob/master/CONTRIBUTORS.md)** - Как внести вклад в проект. ## Недавние обновления (Октябрь 2025) ### Многоязычная поддержка - **Поддержка 8 языков** - Полный перевод интерфейса на английский, китайский (упрощенный), испанский, португальский (бразильский), французский, хинди, бенгальский и русский - **Переключатель языка** - Простой в использовании переключатель языка в заголовке приложения - **Полный перевод** - Все меню, диалоги, настройки, подсказки и справочный контент переведены - **Учет локали** - Автоматически определяет и запоминает ваш языковой предпочтение ### Улучшенный инструмент соединителя - **Создание на основе кликов** - Новый режим по умолчанию: щелкните первый узел, затем второй узел для соединения - **Опция режима перетаскивания** - Исходное перетаскивание по-прежнему доступно через настройки - **Выбор режима** - Переключайтесь между режимами клика и перетаскивания в Настройки → вкладка Соединители - **Лучшая надежность** - Режим клика обеспечивает более предсказуемое создание соединений ### Импорт пользовательских иконок - **Импортируйте свои собственные иконки** - Загружайте пользовательские иконки (PNG, JPG, SVG) для использования в ваших диаграммах - **Автоматическое масштабирование** - Иконки автоматически масштабируются до согласованных размеров для профессионального внешнего вида - **Переключатель изометрия/плоскость** - Выберите, будут ли импортированные иконки отображаться как 3D изометрические или плоские 2D - **Умная постоянство** - Пользовательские иконки сохраняются с диаграммами и работают со всеми методами хранения - **Ресурсы иконок** - Найдите бесплатные иконки на: - [Iconify Icon Sets](https://icon-sets.iconify.design/) - Тысячи бесплатных SVG иконок - [Flaticon Isometric Icons](https://www.flaticon.com/free-icons/isometric) - Высококачественные наборы изометрических иконок ### Поддержка хранилища сервера - **Постоянное хранилище** - Диаграммы сохраняются в файловой системе сервера, сохраняются между сеансами браузера - **Многоустройственный доступ** - Получайте доступ к вашим диаграммам с любого устройства при использовании развертывания Docker - **Автоматическое обнаружение** - UI автоматически показывает хранилище сервера, когда оно доступно - **Защита от перезаписи** - Диалог подтверждения при сохранении с дублирующими именами - **Интеграция Docker** - Хранилище сервера включено по умолчанию в развертываниях Docker ### Расширенные функции взаимодействия - **Настраиваемые горячие клавиши** - Три профиля (QWERTY, SMNRCT, Нет) для выбора инструментов с визуальными индикаторами - **Расширенные элементы управления панорамированием** - Несколько методов панорамирования, включая перетаскивание пустой области, средний/правый щелчок, клавиши модификаторы (Ctrl/Alt) и навигацию с клавиатуры (Стрелки/WASD/IJKL) - **Переключить стрелки соединителя** - Опция для отображения/скрытия стрелок на отдельных соединителях - **Постоянный выбор инструмента** - Инструмент соединителя остается активным после создания соединений - **Диалог настроек** - Централизованная конфигурация для горячих клавиш и элементов управления панорамированием ### Улучшения Docker и CI/CD - **Автоматизированные сборки Docker** - Рабочий процесс GitHub Actions для автоматического развертывания Docker Hub при коммитах - **Поддержка мультиархитектуры** - Образы Docker для `linux/amd64` и `linux/arm64` - **Предварительно собранные образы** - Доступны на `stnsmith/fossflow:latest` ### Архитектура Monorepo - **Единый репозиторий** для библиотеки и приложения - **NPM Workspaces** для упрощенного управления зависимостями - **Единый процесс сборки** с `npm run build` в корне ### Исправления UI - Исправлена проблема отображения иконок панели инструментов редактора Quill - Решены предупреждения ключей React в контекстных меню - Улучшен стиль редактора markdown ## Возможности - 🎨 **Изометрическое диаграммирование** - Создавайте потрясающие технические диаграммы в стиле 3D - 💾 **Автосохранение** - Ваша работа автоматически сохраняется каждые 5 секунд - 📱 **Поддержка PWA** - Установите как нативное приложение на Mac и Linux - 🔒 **Приоритет конфиденциальности** - Все данные хранятся локально в вашем браузере - 📤 **Импорт/Экспорт** - Делитесь диаграммами как JSON файлами - 🎯 **Хранилище сеанса** - Быстрое сохранение без диалогов - 🌐 **Поддержка офлайн** - Работайте без подключения к интернету - 🗄️ **Хранилище сервера** - Дополнительное постоянное хранилище при использовании Docker (включено по умолчанию) - 🌍 **Многоязычный** - Полная поддержка 8 языков: English, 简体中文, Español, Português, Français, हिन्दी, বাংলা, Русский ## 🐳 Быстрое развертывание с Docker ```bash # Использование Docker Compose (рекомендуется - включает постоянное хранилище) docker compose up # Или запустите напрямую из Docker Hub с постоянным хранилищем docker run -p 80:80 -v $(pwd)/diagrams:/data/diagrams stnsmith/fossflow:latest ``` Хранилище сервера включено по умолчанию в Docker. Ваши диаграммы будут сохранены в `./diagrams` на хосте. Чтобы отключить хранилище сервера, установите `ENABLE_SERVER_STORAGE=false`: ```bash docker run -p 80:80 -e ENABLE_SERVER_STORAGE=false stnsmith/fossflow:latest ``` ## Быстрый старт (Локальная разработка) ```bash # Клонировать репозиторий git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Установить зависимости npm install # Собрать библиотеку (требуется в первый раз) npm run build:lib # Запустить сервер разработки npm run dev ``` Откройте [http://localhost:3000](http://localhost:3000) в вашем браузере. ## Структура Monorepo Это monorepo, содержащий два пакета: - `packages/fossflow-lib` - Библиотека компонентов React для рисования сетевых диаграмм (собрана с Webpack) - `packages/fossflow-app` - Прогрессивное веб-приложение для создания изометрических диаграмм (собрано с RSBuild) ### Команды разработки ```bash # Разработка npm run dev # Запустить сервер разработки приложения npm run dev:lib # Режим наблюдения для разработки библиотеки # Сборка npm run build # Собрать библиотеку и приложение npm run build:lib # Собрать только библиотеку npm run build:app # Собрать только приложение # Тестирование и линтинг npm test # Запустить модульные тесты npm run lint # Проверить ошибки линтинга # E2E тесты (Selenium) cd e2e-tests ./run-tests.sh # Запустить сквозные тесты (требуется Docker и Python) # Публикация npm run publish:lib # Опубликовать библиотеку в npm ``` ## Как использовать ### Создание диаграмм 1. **Добавить элементы**: - Нажмите кнопку "+" в правом верхнем меню, библиотека компонентов появится слева - Перетащите компоненты из библиотеки на холст - Или щелкните правой кнопкой мыши на сетке и выберите "Добавить узел" 2. **Соединить элементы**: - Выберите инструмент Соединитель (нажмите 'C' или щелкните значок соединителя) - **Режим клика** (по умолчанию): Щелкните первый узел, затем щелкните второй узел - **Режим перетаскивания** (опционально): Щелкните и перетащите от первого узла ко второму - Переключайте режимы в Настройки → вкладка Соединители 3. **Сохранить вашу работу**: - **Быстрое сохранение** - Сохраняет в сеанс браузера - **Экспорт** - Скачать как JSON файл - **Импорт** - Загрузить из JSON файла ### Варианты хранения - **Хранилище сеанса**: Временные сохранения удаляются при закрытии браузера - **Экспорт/Импорт**: Постоянное хранилище в виде JSON файлов - **Автосохранение**: Автоматически сохраняет изменения каждые 5 секунд в сеанс ## Внесение вклада Мы приветствуем вклад! Пожалуйста, смотрите [CONTRIBUTORS.md](../CONTRIBUTORS.md) для руководства. ## Документация - [FOSSFLOW_ENCYCLOPEDIA.md](../FOSSFLOW_ENCYCLOPEDIA.md) - Всестороннее руководство по кодовой базе - [CONTRIBUTORS.md](../CONTRIBUTORS.md) - Руководство по внесению вклада ## Лицензия MIT ================================================ FILE: docs/SEMANTIC_RELEASE.md ================================================ # Semantic Release Setup This document explains how FossFLOW uses automated semantic versioning and releases. ## Overview FossFLOW uses [semantic-release](https://github.com/semantic-release/semantic-release) to automate: - Version number calculation based on commit messages - CHANGELOG.md generation - GitHub release creation - Git tag creation - Docker image tagging with version numbers ## How It Works ### 1. Commit Messages Drive Versioning When you commit code using conventional commits, the commit type determines the version bump: | Commit Type | Version Bump | Example | |-------------|--------------|---------| | `feat:` | Minor (1.0.0 → 1.1.0) | New features | | `fix:` | Patch (1.0.0 → 1.0.1) | Bug fixes | | `perf:` | Patch (1.0.0 → 1.0.1) | Performance improvements | | `refactor:` | Patch (1.0.0 → 1.0.1) | Code refactoring | | `feat!:` or `BREAKING CHANGE:` | Major (1.0.0 → 2.0.0) | Breaking changes | | `docs:`, `style:`, `test:`, `chore:` | No bump | Non-code changes | ### 2. Automated Workflow When you push to `master` branch: 1. **Tests run** (via `.github/workflows/test.yml`) 2. **If tests pass**, semantic-release workflow triggers (`.github/workflows/release.yml`) 3. **Semantic-release analyzes** commits since last release 4. **If version bump needed**: - Calculates new version number - Updates `package.json` files in all workspace packages - Generates CHANGELOG.md - Creates git tag (e.g., `v1.2.0`) - Commits changes with `[skip ci]` - Pushes tag to GitHub - Creates GitHub release with notes 5. **Docker workflow triggers** on new tag (`.github/workflows/docker.yml`) 6. **Docker images are tagged** with: - `latest` - `1.2.0` (full version) - `1.2` (major.minor) - `1` (major only) ### 3. Multiple Package Versioning FossFLOW is a monorepo with multiple packages. All packages are versioned together: - Root `package.json` - `packages/fossflow-lib/package.json` - `packages/fossflow-app/package.json` - `packages/fossflow-backend/package.json` The `scripts/update-version.js` script syncs version numbers across all packages. ## Configuration Files ### `.releaserc.json` Main semantic-release configuration: - Defines which branches trigger releases (`master`, `main`) - Configures commit analysis rules - Sets up changelog generation - Defines which files to commit ### `.github/workflows/release.yml` GitHub Actions workflow that: - Runs after tests pass - Executes semantic-release - Uses `GITHUB_TOKEN` for GitHub API access - Uses `NPM_TOKEN` for npm publishing (optional) ### `scripts/update-version.js` Node.js script that updates version numbers in all package.json files simultaneously. ## Example Release Flow ### Scenario: Adding a New Feature ```bash # Make your changes git add . git commit -m "feat(connector): add multi-point connector routing" git push origin master ``` **Result:** - Tests run and pass - Semantic-release detects `feat:` commit - Version bumps from 1.0.5 → 1.1.0 - CHANGELOG.md updated with new entry - Git tag `v1.1.0` created - GitHub release created - Docker images tagged: `1.1.0`, `1.1`, `1`, `latest` ### Scenario: Fixing a Bug ```bash git commit -m "fix(export): resolve image export quality issue" git push origin master ``` **Result:** - Version bumps from 1.1.0 → 1.1.1 - Patch release created ### Scenario: Breaking Change ```bash git commit -m "feat(api)!: redesign node creation API BREAKING CHANGE: createNode() now requires nodeType parameter" git push origin master ``` **Result:** - Version bumps from 1.1.1 → 2.0.0 - Major release created with breaking change highlighted ### Scenario: Documentation Update ```bash git commit -m "docs: update installation instructions" git push origin master ``` **Result:** - No version bump - No release created - Changes still merged to master ## Manual Testing Locally You can test semantic-release locally without publishing: ```bash # Dry run (no changes made) npx semantic-release --dry-run # See what version would be released npx semantic-release --dry-run --no-ci ``` ## Troubleshooting ### No Release Created Check if: - Commits follow conventional commit format - Commits include version-bumping types (`feat`, `fix`, etc.) - Tests passed successfully - You're on the `master` or `main` branch ### Version Not Updated Ensure: - `scripts/update-version.js` has execute permissions - Script is referenced in `.releaserc.json` under `@semantic-release/exec` ### Docker Not Tagged Verify: - Git tag was created successfully - Docker workflow has permission to run ## Additional Resources - [Conventional Commits](https://www.conventionalcommits.org/) - [Semantic Versioning](https://semver.org/) - [Semantic Release Documentation](https://semantic-release.gitbook.io/semantic-release/) - [Keep a Changelog](https://keepachangelog.com/) ## Maintaining This System ### Updating Semantic Release ```bash npm update semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/exec ``` ### Adding New Commit Types Edit `.releaserc.json` under `releaseRules` to add custom commit type behaviors. ### Changing Release Branch Edit `.releaserc.json` and `.github/workflows/release.yml` to target different branches. ================================================ FILE: e2e-tests/.gitignore ================================================ __pycache__/ *.pyc .pytest_cache/ htmlcov/ .coverage *.log venv/ env/ screenshots/ ================================================ FILE: e2e-tests/README.md ================================================ # FossFLOW E2E Tests End-to-end tests for FossFLOW using Selenium WebDriver with Python and pytest. ## Prerequisites 1. **Python 3.11+** - Install from https://www.python.org/ 2. **Docker** - For running Selenium Grid 3. **Chrome/Chromium** browser (provided by Selenium Docker image) ## Running Tests Locally ### Quick Start (Recommended) Use the provided test runner script: ```bash cd e2e-tests ./run-tests.sh ``` The script will: - Check for required dependencies (Docker, Python) - Start Selenium container automatically - Create a Python virtual environment - Install test dependencies - Prompt you to start the FossFLOW app if not running - Run the tests - Clean up Selenium container ### Manual Setup 1. Start Selenium server with Chrome: ```bash docker run -d --name fossflow-selenium -p 4444:4444 -p 7900:7900 --shm-size="2g" selenium/standalone-chrome:latest ``` 2. Start the FossFLOW dev server: ```bash cd .. # Go to project root npm run dev ``` 3. Install Python dependencies: ```bash cd e2e-tests python3 -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate pip install -r requirements.txt ``` 4. Run the tests: ```bash pytest -v ``` ## Environment Variables - `FOSSFLOW_TEST_URL` - Base URL of the app (default: `http://localhost:3000`) - `WEBDRIVER_URL` - WebDriver endpoint (default: `http://localhost:4444`) Example: ```bash FOSSFLOW_TEST_URL=http://localhost:8080 pytest -v ``` ## Available Tests - `test_homepage_loads` - Verifies the homepage loads and has basic React elements - `test_page_has_canvas` - Checks for the canvas element used for diagram drawing - `test_page_renders_without_crash` - Verifies the page fully renders with all key elements visible ## CI/CD Tests run automatically in GitHub Actions on: - Push to `master` or `main` branches - Pull requests to `master` or `main` branches The CI workflow: 1. Builds the app 2. Starts the app server in background 3. Starts Selenium standalone Chrome 4. Installs Python dependencies 5. Runs all E2E tests with pytest ## Test Structure ``` e2e-tests/ ├── tests/ │ └── test_basic_load.py # Main test suite ├── requirements.txt # Python dependencies ├── pytest.ini # Pytest configuration ├── run-tests.sh # Test runner script └── README.md # This file ``` ## Adding New Tests 1. Create a new test file in `tests/` directory (must start with `test_`) 2. Import required modules: ```python import pytest from selenium import webdriver from selenium.webdriver.common.by import By ``` 3. Use the `driver` fixture: ```python def test_my_feature(driver): driver.get("http://localhost:3000") element = driver.find_element(By.ID, "my-element") assert element.is_displayed() ``` 4. Run your test: ```bash pytest tests/test_my_feature.py -v ``` ## Debugging ### Running with Visible Browser To see the browser during tests, modify the driver fixture in `test_basic_load.py`: ```python # Comment out headless mode # chrome_options.add_argument("--headless") ``` ### Using VNC to Watch Tests When using the Selenium Docker image, you can watch tests in real-time: 1. Connect to VNC viewer at `http://localhost:7900` (password: `secret`) 2. Remove `--headless` from Chrome options 3. Run tests and watch in VNC viewer ### Verbose Output Run tests with more verbose output: ```bash pytest -vv --tb=long ``` ### Running Specific Tests ```bash # Run a single test pytest tests/test_basic_load.py::test_homepage_loads -v # Run tests matching a pattern pytest -k "canvas" -v ``` ## Troubleshooting ### Connection refused errors - Ensure Selenium is running: `docker ps | grep selenium` - Check Selenium status: `curl http://localhost:4444/status` - Ensure FossFLOW app is running: `curl http://localhost:3000` ### Element not found errors - Increase wait times in tests - Check if the app URL is correct - Verify the app loaded successfully in browser ### Import errors - Activate virtual environment: `source venv/bin/activate` - Install dependencies: `pip install -r requirements.txt` ### Docker container conflicts - Remove existing container: `docker rm -f fossflow-selenium` - Check for port conflicts: `lsof -i :4444` ## Dependencies - **selenium** (4.27.1) - WebDriver automation library - **pytest** (8.3.4) - Testing framework - **pytest-xdist** (3.6.1) - Parallel test execution support ================================================ FILE: e2e-tests/SETUP.md ================================================ # E2E Testing Setup Summary ## What Was Added A complete Selenium-based end-to-end testing framework using Python and pytest with the Selenium WebDriver library. ### File Structure ``` e2e-tests/ ├── requirements.txt # Python dependencies (selenium, pytest) ├── pytest.ini # Pytest configuration ├── .gitignore # Ignore __pycache__, .pytest_cache, venv ├── README.md # Comprehensive testing documentation ├── SETUP.md # This file ├── run-tests.sh # Helper script for local testing └── tests/ └── test_basic_load.py # Initial test suite ``` ### Tests Included Three basic tests to verify the application loads correctly: 1. **test_homepage_loads** - Verifies: - Page loads successfully - Title contains "FossFLOW" or "isometric" - Body element exists - React root element exists 2. **test_page_has_canvas** - Verifies: - Canvas element exists (for isometric drawing) 3. **test_page_renders_without_crash** - Verifies: - Page fully renders without errors - All key elements are visible (body, root, canvas) - Page source is substantial (not blank/error page) ### CI/CD Integration Updated `.github/workflows/e2e-tests.yml` to: - Run on push/PR to master/main branches - Set up Python 3.11 with pip caching - Spin up Selenium standalone Chrome in Docker - Build the FossFLOW app - Serve the built app with nohup for persistence - Install Python test dependencies - Run all E2E tests with pytest - Upload test artifacts ### Dependencies **Python packages:** - `selenium` v4.27.1 - WebDriver automation library - `pytest` v8.3.4 - Testing framework - `pytest-xdist` v3.6.1 - Parallel test execution support **External services:** - Selenium Server (via Docker) - Running FossFLOW instance ## Quick Start ### Local Development ```bash # Easiest: Use the helper script cd e2e-tests ./run-tests.sh # The script will: # - Start Selenium container # - Create Python venv # - Install dependencies # - Prompt you to start the app # - Run tests # - Clean up ``` ### Manual Setup ```bash # 1. Start Selenium (in Docker) docker run -d -p 4444:4444 -p 7900:7900 --shm-size=2g selenium/standalone-chrome # 2. Start FossFLOW dev server (in another terminal) npm run dev # 3. Set up Python environment cd e2e-tests python3 -m venv venv source venv/bin/activate pip install -r requirements.txt # 4. Run tests pytest -v ``` ### CI/CD Tests run automatically on GitHub Actions. See workflow at `.github/workflows/e2e-tests.yml`. ## Next Steps You can now expand the test suite to cover: 1. **Drawing Features** - Add nodes to canvas - Connect nodes - Edit node properties - Delete nodes 2. **UI Interactions** - Menu navigation - Settings dialogs - Tool selection - Hotkeys 3. **Data Operations** - Save diagrams - Load diagrams - Export to JSON - Import from JSON 4. **Advanced Features** - Undo/redo - Custom icons - Multi-select - Zoom/pan ## Example: Adding a New Test Create `tests/test_diagram_creation.py`: ```python import pytest from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def test_can_add_node(driver): """Test that users can add a node to the canvas.""" driver.get("http://localhost:3000") # Wait for app to load wait = WebDriverWait(driver, 10) # Click the add node button add_button = wait.until( EC.element_to_be_clickable((By.CSS_SELECTOR, "button[aria-label='Add Node']")) ) add_button.click() # Verify node library appears library = wait.until( EC.visibility_of_element_located((By.CLASS_NAME, "node-library")) ) assert library.is_displayed() ``` Run: `pytest tests/test_diagram_creation.py::test_can_add_node -v` ## Pytest Features ### Running Tests ```bash # Run all tests pytest -v # Run specific test file pytest tests/test_basic_load.py -v # Run specific test pytest tests/test_basic_load.py::test_homepage_loads -v # Run tests matching pattern pytest -k "canvas" -v # Run with more verbose output pytest -vv --tb=long ``` ### Test Fixtures The `driver` fixture is automatically available to all tests: ```python def test_example(driver): driver.get("http://localhost:3000") # driver is automatically created and cleaned up ``` ## Debugging ### Watch Tests with VNC Connect to `http://localhost:7900` (password: `secret`) to watch tests run in real-time. ### Run Non-Headless Edit `test_basic_load.py` and comment out: ```python # chrome_options.add_argument("--headless") ``` ### Add Screenshots on Failure Add to your test: ```python def test_example(driver): try: # Your test code assert something except AssertionError: driver.save_screenshot("failure.png") raise ``` ## Troubleshooting See `README.md` for detailed troubleshooting steps including: - Connection refused errors - Element not found errors - Import errors - Docker container conflicts ## Resources - [Selenium Python documentation](https://selenium-python.readthedocs.io/) - [pytest documentation](https://docs.pytest.org/) - [Selenium WebDriver docs](https://www.selenium.dev/documentation/webdriver/) - [WebDriver spec](https://w3c.github.io/webdriver/) ## Migration Notes This test suite was migrated from Rust (thirtyfour) to Python (selenium + pytest) for: - Simpler syntax and easier maintenance - Better debugging tools - Wider community support - Faster test development - More reliable WebDriver connections ================================================ FILE: e2e-tests/get-docker.sh ================================================ #!/bin/sh set -e # Docker Engine for Linux installation script. # # This script is intended as a convenient way to configure docker's package # repositories and to install Docker Engine, This script is not recommended # for production environments. Before running this script, make yourself familiar # with potential risks and limitations, and refer to the installation manual # at https://docs.docker.com/engine/install/ for alternative installation methods. # # The script: # # - Requires `root` or `sudo` privileges to run. # - Attempts to detect your Linux distribution and version and configure your # package management system for you. # - Doesn't allow you to customize most installation parameters. # - Installs dependencies and recommendations without asking for confirmation. # - Installs the latest stable release (by default) of Docker CLI, Docker Engine, # Docker Buildx, Docker Compose, containerd, and runc. When using this script # to provision a machine, this may result in unexpected major version upgrades # of these packages. Always test upgrades in a test environment before # deploying to your production systems. # - Isn't designed to upgrade an existing Docker installation. When using the # script to update an existing installation, dependencies may not be updated # to the expected version, resulting in outdated versions. # # Source code is available at https://github.com/docker/docker-install/ # # Usage # ============================================================================== # # To install the latest stable versions of Docker CLI, Docker Engine, and their # dependencies: # # 1. download the script # # $ curl -fsSL https://get.docker.com -o install-docker.sh # # 2. verify the script's content # # $ cat install-docker.sh # # 3. run the script with --dry-run to verify the steps it executes # # $ sh install-docker.sh --dry-run # # 4. run the script either as root, or using sudo to perform the installation. # # $ sudo sh install-docker.sh # # Command-line options # ============================================================================== # # --version # Use the --version option to install a specific version, for example: # # $ sudo sh install-docker.sh --version 23.0 # # --channel # # Use the --channel option to install from an alternative installation channel. # The following example installs the latest versions from the "test" channel, # which includes pre-releases (alpha, beta, rc): # # $ sudo sh install-docker.sh --channel test # # Alternatively, use the script at https://test.docker.com, which uses the test # channel as default. # # --mirror # # Use the --mirror option to install from a mirror supported by this script. # Available mirrors are "Aliyun" (https://mirrors.aliyun.com/docker-ce), and # "AzureChinaCloud" (https://mirror.azure.cn/docker-ce), for example: # # $ sudo sh install-docker.sh --mirror AzureChinaCloud # # --setup-repo # # Use the --setup-repo option to configure Docker's package repositories without # installing Docker packages. This is useful when you want to add the repository # but install packages separately: # # $ sudo sh install-docker.sh --setup-repo # # ============================================================================== # Git commit from https://github.com/docker/docker-install when # the script was uploaded (Should only be modified by upload job): SCRIPT_COMMIT_SHA="c7e4dd90efd707a8e67302b967c4ef4a29d3cb6b" # strip "v" prefix if present VERSION="${VERSION#v}" # The channel to install from: # * stable # * test DEFAULT_CHANNEL_VALUE="stable" if [ -z "$CHANNEL" ]; then CHANNEL=$DEFAULT_CHANNEL_VALUE fi DEFAULT_DOWNLOAD_URL="https://download.docker.com" if [ -z "$DOWNLOAD_URL" ]; then DOWNLOAD_URL=$DEFAULT_DOWNLOAD_URL fi DEFAULT_REPO_FILE="docker-ce.repo" if [ -z "$REPO_FILE" ]; then REPO_FILE="$DEFAULT_REPO_FILE" # Automatically default to a staging repo fora # a staging download url (download-stage.docker.com) case "$DOWNLOAD_URL" in *-stage*) REPO_FILE="docker-ce-staging.repo";; esac fi mirror='' DRY_RUN=${DRY_RUN:-} REPO_ONLY=${REPO_ONLY:-0} while [ $# -gt 0 ]; do case "$1" in --channel) CHANNEL="$2" shift ;; --dry-run) DRY_RUN=1 ;; --mirror) mirror="$2" shift ;; --version) VERSION="${2#v}" shift ;; --setup-repo) REPO_ONLY=1 shift ;; --*) echo "Illegal option $1" ;; esac shift $(( $# > 0 ? 1 : 0 )) done case "$mirror" in Aliyun) DOWNLOAD_URL="https://mirrors.aliyun.com/docker-ce" ;; AzureChinaCloud) DOWNLOAD_URL="https://mirror.azure.cn/docker-ce" ;; "") ;; *) >&2 echo "unknown mirror '$mirror': use either 'Aliyun', or 'AzureChinaCloud'." exit 1 ;; esac case "$CHANNEL" in stable|test) ;; *) >&2 echo "unknown CHANNEL '$CHANNEL': use either stable or test." exit 1 ;; esac command_exists() { command -v "$@" > /dev/null 2>&1 } # version_gte checks if the version specified in $VERSION is at least the given # SemVer (Maj.Minor[.Patch]), or CalVer (YY.MM) version.It returns 0 (success) # if $VERSION is either unset (=latest) or newer or equal than the specified # version, or returns 1 (fail) otherwise. # # examples: # # VERSION=23.0 # version_gte 23.0 // 0 (success) # version_gte 20.10 // 0 (success) # version_gte 19.03 // 0 (success) # version_gte 26.1 // 1 (fail) version_gte() { if [ -z "$VERSION" ]; then return 0 fi version_compare "$VERSION" "$1" } # version_compare compares two version strings (either SemVer (Major.Minor.Path), # or CalVer (YY.MM) version strings. It returns 0 (success) if version A is newer # or equal than version B, or 1 (fail) otherwise. Patch releases and pre-release # (-alpha/-beta) are not taken into account # # examples: # # version_compare 23.0.0 20.10 // 0 (success) # version_compare 23.0 20.10 // 0 (success) # version_compare 20.10 19.03 // 0 (success) # version_compare 20.10 20.10 // 0 (success) # version_compare 19.03 20.10 // 1 (fail) version_compare() ( set +x yy_a="$(echo "$1" | cut -d'.' -f1)" yy_b="$(echo "$2" | cut -d'.' -f1)" if [ "$yy_a" -lt "$yy_b" ]; then return 1 fi if [ "$yy_a" -gt "$yy_b" ]; then return 0 fi mm_a="$(echo "$1" | cut -d'.' -f2)" mm_b="$(echo "$2" | cut -d'.' -f2)" # trim leading zeros to accommodate CalVer mm_a="${mm_a#0}" mm_b="${mm_b#0}" if [ "${mm_a:-0}" -lt "${mm_b:-0}" ]; then return 1 fi return 0 ) is_dry_run() { if [ -z "$DRY_RUN" ]; then return 1 else return 0 fi } is_wsl() { case "$(uname -r)" in *microsoft* ) true ;; # WSL 2 *Microsoft* ) true ;; # WSL 1 * ) false;; esac } is_darwin() { case "$(uname -s)" in *darwin* ) true ;; *Darwin* ) true ;; * ) false;; esac } deprecation_notice() { distro=$1 distro_version=$2 echo printf "\033[91;1mDEPRECATION WARNING\033[0m\n" printf " This Linux distribution (\033[1m%s %s\033[0m) reached end-of-life and is no longer supported by this script.\n" "$distro" "$distro_version" echo " No updates or security fixes will be released for this distribution, and users are recommended" echo " to upgrade to a currently maintained version of $distro." echo printf "Press \033[1mCtrl+C\033[0m now to abort this script, or wait for the installation to continue." echo sleep 10 } get_distribution() { lsb_dist="" # Every system that we officially support has /etc/os-release if [ -r /etc/os-release ]; then lsb_dist="$(. /etc/os-release && echo "$ID")" fi # Returning an empty string here should be alright since the # case statements don't act unless you provide an actual value echo "$lsb_dist" } echo_docker_as_nonroot() { if is_dry_run; then return fi if command_exists docker && [ -e /var/run/docker.sock ]; then ( set -x $sh_c 'docker version' ) || true fi # intentionally mixed spaces and tabs here -- tabs are stripped by "<<-EOF", spaces are kept in the output echo echo "================================================================================" echo if version_gte "20.10"; then echo "To run Docker as a non-privileged user, consider setting up the" echo "Docker daemon in rootless mode for your user:" echo echo " dockerd-rootless-setuptool.sh install" echo echo "Visit https://docs.docker.com/go/rootless/ to learn about rootless mode." echo fi echo echo "To run the Docker daemon as a fully privileged service, but granting non-root" echo "users access, refer to https://docs.docker.com/go/daemon-access/" echo echo "WARNING: Access to the remote API on a privileged Docker daemon is equivalent" echo " to root access on the host. Refer to the 'Docker daemon attack surface'" echo " documentation for details: https://docs.docker.com/go/attack-surface/" echo echo "================================================================================" echo } # Check if this is a forked Linux distro check_forked() { # Check for lsb_release command existence, it usually exists in forked distros if command_exists lsb_release; then # Check if the `-u` option is supported set +e lsb_release -a -u > /dev/null 2>&1 lsb_release_exit_code=$? set -e # Check if the command has exited successfully, it means we're in a forked distro if [ "$lsb_release_exit_code" = "0" ]; then # Print info about current distro cat <<-EOF You're using '$lsb_dist' version '$dist_version'. EOF # Get the upstream release info lsb_dist=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'id' | cut -d ':' -f 2 | tr -d '[:space:]') dist_version=$(lsb_release -a -u 2>&1 | tr '[:upper:]' '[:lower:]' | grep -E 'codename' | cut -d ':' -f 2 | tr -d '[:space:]') # Print info about upstream distro cat <<-EOF Upstream release is '$lsb_dist' version '$dist_version'. EOF else if [ -r /etc/debian_version ] && [ "$lsb_dist" != "ubuntu" ] && [ "$lsb_dist" != "raspbian" ]; then if [ "$lsb_dist" = "osmc" ]; then # OSMC runs Raspbian lsb_dist=raspbian else # We're Debian and don't even know it! lsb_dist=debian fi dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" case "$dist_version" in 13) dist_version="trixie" ;; 12) dist_version="bookworm" ;; 11) dist_version="bullseye" ;; 10) dist_version="buster" ;; 9) dist_version="stretch" ;; 8) dist_version="jessie" ;; esac fi fi fi } do_install() { echo "# Executing docker install script, commit: $SCRIPT_COMMIT_SHA" if command_exists docker; then cat >&2 <<-'EOF' Warning: the "docker" command appears to already exist on this system. If you already have Docker installed, this script can cause trouble, which is why we're displaying this warning and provide the opportunity to cancel the installation. If you installed the current Docker package using this script and are using it again to update Docker, you can ignore this message, but be aware that the script resets any custom changes in the deb and rpm repo configuration files to match the parameters passed to the script. You may press Ctrl+C now to abort this script. EOF ( set -x; sleep 20 ) fi user="$(id -un 2>/dev/null || true)" sh_c='sh -c' if [ "$user" != 'root' ]; then if command_exists sudo; then sh_c='sudo -E sh -c' elif command_exists su; then sh_c='su -c' else cat >&2 <<-'EOF' Error: this installer needs the ability to run commands as root. We are unable to find either "sudo" or "su" available to make this happen. EOF exit 1 fi fi if is_dry_run; then sh_c="echo" fi # perform some very rudimentary platform detection lsb_dist=$( get_distribution ) lsb_dist="$(echo "$lsb_dist" | tr '[:upper:]' '[:lower:]')" if is_wsl; then echo echo "WSL DETECTED: We recommend using Docker Desktop for Windows." echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop/" echo cat >&2 <<-'EOF' You may press Ctrl+C now to abort this script. EOF ( set -x; sleep 20 ) fi case "$lsb_dist" in ubuntu) if command_exists lsb_release; then dist_version="$(lsb_release --codename | cut -f2)" fi if [ -z "$dist_version" ] && [ -r /etc/lsb-release ]; then dist_version="$(. /etc/lsb-release && echo "$DISTRIB_CODENAME")" fi ;; debian|raspbian) dist_version="$(sed 's/\/.*//' /etc/debian_version | sed 's/\..*//')" case "$dist_version" in 13) dist_version="trixie" ;; 12) dist_version="bookworm" ;; 11) dist_version="bullseye" ;; 10) dist_version="buster" ;; 9) dist_version="stretch" ;; 8) dist_version="jessie" ;; esac ;; centos|rhel) if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then dist_version="$(. /etc/os-release && echo "$VERSION_ID")" fi ;; *) if command_exists lsb_release; then dist_version="$(lsb_release --release | cut -f2)" fi if [ -z "$dist_version" ] && [ -r /etc/os-release ]; then dist_version="$(. /etc/os-release && echo "$VERSION_ID")" fi ;; esac # Check if this is a forked Linux distro check_forked # Print deprecation warnings for distro versions that recently reached EOL, # but may still be commonly used (especially LTS versions). case "$lsb_dist.$dist_version" in centos.8|centos.7|rhel.7) deprecation_notice "$lsb_dist" "$dist_version" ;; debian.buster|debian.stretch|debian.jessie) deprecation_notice "$lsb_dist" "$dist_version" ;; raspbian.buster|raspbian.stretch|raspbian.jessie) deprecation_notice "$lsb_dist" "$dist_version" ;; ubuntu.focal|ubuntu.bionic|ubuntu.xenial|ubuntu.trusty) deprecation_notice "$lsb_dist" "$dist_version" ;; ubuntu.oracular|ubuntu.mantic|ubuntu.lunar|ubuntu.kinetic|ubuntu.impish|ubuntu.hirsute|ubuntu.groovy|ubuntu.eoan|ubuntu.disco|ubuntu.cosmic) deprecation_notice "$lsb_dist" "$dist_version" ;; fedora.*) if [ "$dist_version" -lt 41 ]; then deprecation_notice "$lsb_dist" "$dist_version" fi ;; esac # Run setup for each distro accordingly case "$lsb_dist" in ubuntu|debian|raspbian) pre_reqs="ca-certificates curl" apt_repo="deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] $DOWNLOAD_URL/linux/$lsb_dist $dist_version $CHANNEL" ( if ! is_dry_run; then set -x fi $sh_c 'apt-get -qq update >/dev/null' $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pre_reqs >/dev/null" $sh_c 'install -m 0755 -d /etc/apt/keyrings' $sh_c "curl -fsSL \"$DOWNLOAD_URL/linux/$lsb_dist/gpg\" -o /etc/apt/keyrings/docker.asc" $sh_c "chmod a+r /etc/apt/keyrings/docker.asc" $sh_c "echo \"$apt_repo\" > /etc/apt/sources.list.d/docker.list" $sh_c 'apt-get -qq update >/dev/null' ) if [ "$REPO_ONLY" = "1" ]; then exit 0 fi pkg_version="" if [ -n "$VERSION" ]; then if is_dry_run; then echo "# WARNING: VERSION pinning is not supported in DRY_RUN" else # Will work for incomplete versions IE (17.12), but may not actually grab the "latest" if in the test channel pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/~ce~.*/g' | sed 's/-/.*/g')" search_command="apt-cache madison docker-ce | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" pkg_version="$($sh_c "$search_command")" echo "INFO: Searching repository for VERSION '$VERSION'" echo "INFO: $search_command" if [ -z "$pkg_version" ]; then echo echo "ERROR: '$VERSION' not found amongst apt-cache madison results" echo exit 1 fi if version_gte "18.09"; then search_command="apt-cache madison docker-ce-cli | grep '$pkg_pattern' | head -1 | awk '{\$1=\$1};1' | cut -d' ' -f 3" echo "INFO: $search_command" cli_pkg_version="=$($sh_c "$search_command")" fi pkg_version="=$pkg_version" fi fi ( pkgs="docker-ce${pkg_version%=}" if version_gte "18.09"; then # older versions didn't ship the cli and containerd as separate packages pkgs="$pkgs docker-ce-cli${cli_pkg_version%=} containerd.io" fi if version_gte "20.10"; then pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" fi if version_gte "23.0"; then pkgs="$pkgs docker-buildx-plugin" fi if version_gte "28.2"; then pkgs="$pkgs docker-model-plugin" fi if ! is_dry_run; then set -x fi $sh_c "DEBIAN_FRONTEND=noninteractive apt-get -y -qq install $pkgs >/dev/null" ) echo_docker_as_nonroot exit 0 ;; centos|fedora|rhel) if [ "$(uname -m)" = "s390x" ]; then echo "Effective v27.5, please consult RHEL distro statement for s390x support." exit 1 fi repo_file_url="$DOWNLOAD_URL/linux/$lsb_dist/$REPO_FILE" ( if ! is_dry_run; then set -x fi if command_exists dnf5; then $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" $sh_c "dnf5 config-manager addrepo --overwrite --save-filename=docker-ce.repo --from-repofile='$repo_file_url'" if [ "$CHANNEL" != "stable" ]; then $sh_c "dnf5 config-manager setopt \"docker-ce-*.enabled=0\"" $sh_c "dnf5 config-manager setopt \"docker-ce-$CHANNEL.enabled=1\"" fi $sh_c "dnf makecache" elif command_exists dnf; then $sh_c "dnf -y -q --setopt=install_weak_deps=False install dnf-plugins-core" $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" $sh_c "dnf config-manager --add-repo $repo_file_url" if [ "$CHANNEL" != "stable" ]; then $sh_c "dnf config-manager --set-disabled \"docker-ce-*\"" $sh_c "dnf config-manager --set-enabled \"docker-ce-$CHANNEL\"" fi $sh_c "dnf makecache" else $sh_c "yum -y -q install yum-utils" $sh_c "rm -f /etc/yum.repos.d/docker-ce.repo /etc/yum.repos.d/docker-ce-staging.repo" $sh_c "yum-config-manager --add-repo $repo_file_url" if [ "$CHANNEL" != "stable" ]; then $sh_c "yum-config-manager --disable \"docker-ce-*\"" $sh_c "yum-config-manager --enable \"docker-ce-$CHANNEL\"" fi $sh_c "yum makecache" fi ) if [ "$REPO_ONLY" = "1" ]; then exit 0 fi pkg_version="" if command_exists dnf; then pkg_manager="dnf" pkg_manager_flags="-y -q --best" else pkg_manager="yum" pkg_manager_flags="-y -q" fi if [ -n "$VERSION" ]; then if is_dry_run; then echo "# WARNING: VERSION pinning is not supported in DRY_RUN" else if [ "$lsb_dist" = "fedora" ]; then pkg_suffix="fc$dist_version" else pkg_suffix="el" fi pkg_pattern="$(echo "$VERSION" | sed 's/-ce-/\\\\.ce.*/g' | sed 's/-/.*/g').*$pkg_suffix" search_command="$pkg_manager list --showduplicates docker-ce | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" pkg_version="$($sh_c "$search_command")" echo "INFO: Searching repository for VERSION '$VERSION'" echo "INFO: $search_command" if [ -z "$pkg_version" ]; then echo echo "ERROR: '$VERSION' not found amongst $pkg_manager list results" echo exit 1 fi if version_gte "18.09"; then # older versions don't support a cli package search_command="$pkg_manager list --showduplicates docker-ce-cli | grep '$pkg_pattern' | tail -1 | awk '{print \$2}'" cli_pkg_version="$($sh_c "$search_command" | cut -d':' -f 2)" fi # Cut out the epoch and prefix with a '-' pkg_version="-$(echo "$pkg_version" | cut -d':' -f 2)" fi fi ( pkgs="docker-ce$pkg_version" if version_gte "18.09"; then # older versions didn't ship the cli and containerd as separate packages if [ -n "$cli_pkg_version" ]; then pkgs="$pkgs docker-ce-cli-$cli_pkg_version containerd.io" else pkgs="$pkgs docker-ce-cli containerd.io" fi fi if version_gte "20.10"; then pkgs="$pkgs docker-compose-plugin docker-ce-rootless-extras$pkg_version" fi if version_gte "23.0"; then pkgs="$pkgs docker-buildx-plugin docker-model-plugin" fi if ! is_dry_run; then set -x fi $sh_c "$pkg_manager $pkg_manager_flags install $pkgs" ) echo_docker_as_nonroot exit 0 ;; sles) echo "Effective v27.5, please consult SLES distro statement for s390x support." exit 1 ;; *) if [ -z "$lsb_dist" ]; then if is_darwin; then echo echo "ERROR: Unsupported operating system 'macOS'" echo "Please get Docker Desktop from https://www.docker.com/products/docker-desktop" echo exit 1 fi fi echo echo "ERROR: Unsupported distribution '$lsb_dist'" echo exit 1 ;; esac exit 1 } # wrapped up in a function so that we have some protection against only getting # half the file during "curl | sh" do_install ================================================ FILE: e2e-tests/pytest.ini ================================================ [pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v --tb=short ================================================ FILE: e2e-tests/requirements.txt ================================================ selenium==4.27.1 pytest==8.3.4 pytest-xdist==3.6.1 ================================================ FILE: e2e-tests/run-tests.sh ================================================ #!/bin/bash # Helper script to run E2E tests locally set -e SELENIUM_CONTAINER="fossflow-selenium" APP_PORT=3000 SELENIUM_PORT=4444 echo "FossFLOW E2E Test Runner" # Check if Docker is available if ! command -v docker &> /dev/null; then echo "❌ Docker is required but not installed." echo "Please install Docker from https://docs.docker.com/get-docker/" exit 1 fi # Check if Python is available if ! command -v python3 &> /dev/null; then echo "❌ Python 3 is required but not installed." echo "Please install Python 3 from https://www.python.org/" exit 1 fi # Check if pip is available if ! command -v pip3 &> /dev/null; then echo "❌ pip3 is required but not installed." echo "Please install pip3" exit 1 fi # Start Selenium container if not running if [ ! "$(docker ps -q -f name=$SELENIUM_CONTAINER)" ]; then echo "Starting Selenium Chrome container..." docker run -d --rm \ --name $SELENIUM_CONTAINER \ -p $SELENIUM_PORT:4444 \ -p 7900:7900 \ --shm-size="2g" \ selenium/standalone-chrome:latest echo "Waiting for Selenium to be ready..." timeout 60 bash -c "until curl -sf http://localhost:$SELENIUM_PORT/status > /dev/null; do sleep 2; done" || { echo "❌ Selenium failed to start" docker logs $SELENIUM_CONTAINER docker stop $SELENIUM_CONTAINER exit 1 } echo "Selenium is ready" else echo "Selenium container is already running" fi # Check if FossFLOW is running if ! curl -sf http://localhost:$APP_PORT > /dev/null; then echo "⚠️ FossFLOW app is not running on port $APP_PORT" echo "Please start it with: npm run dev" echo "" read -p "Start the app now in another terminal and press Enter to continue..." fi # Verify app is accessible if ! curl -sf http://localhost:$APP_PORT > /dev/null; then echo "❌ FossFLOW app is still not accessible on http://localhost:$APP_PORT" docker stop $SELENIUM_CONTAINER 2>/dev/null || true exit 1 fi echo "✅ FossFLOW app is accessible" echo "" # Install Python dependencies if needed if [ ! -d "venv" ]; then echo "Creating Python virtual environment..." python3 -m venv venv source venv/bin/activate pip install -r requirements.txt else source venv/bin/activate fi # Run tests echo "Running E2E tests..." echo "" FOSSFLOW_TEST_URL="http://localhost:$APP_PORT" \ WEBDRIVER_URL="http://localhost:$SELENIUM_PORT" \ pytest -v --tb=short "$@" TEST_RESULT=$? # Deactivate venv deactivate # Cleanup echo "" echo "Cleaning up..." docker stop $SELENIUM_CONTAINER 2>/dev/null || true if [ $TEST_RESULT -eq 0 ]; then echo "" echo "✅ All tests passed!" else echo "" echo "❌ Some tests failed" exit $TEST_RESULT fi ================================================ FILE: e2e-tests/test-base-paths.sh ================================================ #!/bin/bash # Test FossFLOW deployment at different base paths # This simulates how the app will be served on GitHub Pages or other platforms with subpaths set -e echo "Testing FossFLOW at multiple base paths..." # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Base paths to test BASE_PATHS=("/" "/fossflow" "/apps/fossflow" "/my-org/projects/fossflow") # Function to cleanup cleanup() { echo -e "\n${YELLOW}Cleaning up...${NC}" # Stop any running containers docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true docker stop selenium-test 2>/dev/null || true docker rm selenium-test 2>/dev/null || true # Kill any local servers if [ -f /tmp/server.pid ]; then kill $(cat /tmp/server.pid) 2>/dev/null || true rm /tmp/server.pid fi } # Set trap to cleanup on exit trap cleanup EXIT # Function to test a specific base path test_base_path() { local BASE_PATH=$1 echo -e "\n${YELLOW}Testing base path: ${BASE_PATH}${NC}" # Clean up any previous test docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true # Build the app with the specific PUBLIC_URL echo "Building app with PUBLIC_URL=${BASE_PATH}..." (cd .. && PUBLIC_URL="${BASE_PATH}" npm run build:app) # Create nginx config for this base path if [ "$BASE_PATH" = "/" ]; then LOCATION_PATH="/" ALIAS_PATH="/usr/share/nginx/html/" else LOCATION_PATH="${BASE_PATH%/}/" ALIAS_PATH="/usr/share/nginx/html/" fi cat > /tmp/nginx.conf < /dev/null; then echo -e "${GREEN}✓ App accessible at http://localhost:3001${BASE_PATH}${NC}" else echo -e "${RED}✗ App NOT accessible at http://localhost:3001${BASE_PATH}${NC}" echo "Nginx logs:" docker logs nginx-test return 1 fi # Run E2E tests if Selenium is available if docker ps | grep selenium-test > /dev/null; then echo "Running E2E tests..." FOSSFLOW_TEST_URL="http://localhost:3001${BASE_PATH}" \ FOSSFLOW_BASE_PATH="${BASE_PATH}" \ WEBDRIVER_URL="http://localhost:4444" \ pytest tests/test_base_path_routing.py -v --tb=short || { echo -e "${RED}✗ E2E tests failed for base path: ${BASE_PATH}${NC}" return 1 } echo -e "${GREEN}✓ E2E tests passed for base path: ${BASE_PATH}${NC}" else echo -e "${YELLOW}Selenium not running, skipping E2E tests${NC}" echo "To run E2E tests, start Selenium first:" echo " docker run -d --name selenium-test --network host selenium/standalone-chrome:latest" fi # Clean up this test's nginx docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true return 0 } # Main execution echo "Setting up test environment..." # Ensure we're in the e2e-tests directory cd "$(dirname "$0")" # Check if Selenium is running, offer to start it if ! docker ps | grep selenium > /dev/null; then echo -e "${YELLOW}Selenium is not running. Would you like to start it for E2E tests? (y/n)${NC}" read -r response if [[ "$response" == "y" ]]; then echo "Starting Selenium..." docker run -d \ --name selenium-test \ --network host \ --shm-size=2g \ selenium/standalone-chrome:latest echo "Waiting for Selenium to be ready..." timeout 30 bash -c 'until curl -sf http://localhost:4444/status > /dev/null 2>&1; do sleep 2; done' || { echo -e "${RED}Selenium failed to start${NC}" exit 1 } echo -e "${GREEN}✓ Selenium is ready${NC}" fi fi # Test each base path FAILED_PATHS=() for BASE_PATH in "${BASE_PATHS[@]}"; do if ! test_base_path "$BASE_PATH"; then FAILED_PATHS+=("$BASE_PATH") fi done # Summary echo -e "\n=========================================" echo "Test Summary:" echo "=========================================" if [ ${#FAILED_PATHS[@]} -eq 0 ]; then echo -e "${GREEN}✓ All base paths tested successfully!${NC}" echo "Tested paths: ${BASE_PATHS[*]}" else echo -e "${RED}✗ Some base paths failed:${NC}" for path in "${FAILED_PATHS[@]}"; do echo " - $path" done echo -e "\n${YELLOW}This indicates the app may not work correctly when deployed to GitHub Pages or other subpath deployments.${NC}" exit 1 fi ================================================ FILE: e2e-tests/test-diagram.json ================================================ { "title": "Untitled Diagram", "icons": [ { "id": "block", "name": "block", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB3aWR0aD0iNTUxLjU5OTk4cHgiIGhlaWdodD0iMzQzLjc5OTk5cHgiIHZpZXdCb3g9IjAgMCA1NTEuNTk5OTggMzQzLjc5OTk5IgoJIHN0eWxlPSJlbmFibGUtYmFja2dyb3VuZDpuZXcgMCAwIDU1MS41OTk5OCAzNDMuNzk5OTk7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7b3BhY2l0eTowLjQ7ZW5hYmxlLWJhY2tncm91bmQ6bmV3ICAgIDt9Cgkuc3Qxe2ZpbGw6I0NERDlFRTt9Cgkuc3Qye2ZpbGw6I0I1QzVEQzt9Cgkuc3Qze2ZpbGw6I0ZGRkZGRjt9Cgkuc3Q0e2ZpbGw6IzY4ODVBOTt9Cgkuc3Q1e2ZpbGw6IzIzMUYyMDt9Cjwvc3R5bGU+CjxnPgoJPHBvbHlnb24gY2xhc3M9InN0MCIgcG9pbnRzPSIzMDkuMjk5OTksMzIyLjg5OTk5IDI3NC42MDAwMSwzMTYuMTAwMDEgMjc0LDE2LjIgNTUxLjU5OTk4LDE4MCAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3QxIiBwb2ludHM9IjI3NC42MDAwMSwyMjguOCA5Mi4yLDExOS4yIDI3NCw5LjcgNDU3LDExOS4yIAkiLz4KCTxwb2x5Z29uIGNsYXNzPSJzdDIiIHBvaW50cz0iOTAuMiwxOTUgMjc0LjYwMDAxLDMwNS43OTk5OSAyNzQuNjAwMDEsMzA1Ljc5OTk5IDI3NC42MDAwMSwyMzEuMyA5MC4yLDEyMC40IAkiLz4KCTxwb2x5Z29uIGNsYXNzPSJzdDMiIHBvaW50cz0iMjg4LjI5OTk5LDIyMC42MDAwMSAxMzUuNywxMjcuNyAzMDMsMjcgMjc0LDkuNyA5Mi4yLDExOS4yIDI3NC42MDAwMSwyMjguOCAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3Q0IiBwb2ludHM9IjQ1OSwxOTUgMjc0LjYwMDAxLDMwNS43OTk5OSAyNzQuNjAwMDEsMzA1Ljc5OTk5IDI3NC42MDAwMSwyMzEuMyA0NTksMTIwLjQgCSIvPgoJPHBhdGggY2xhc3M9InN0NSIgZD0iTTQ2Ny4yMDAwMSwxMTUuNkw0NjcuMjAwMDEsMTE1LjZMMjc0LDBMODMuMDAwMDIsMTE1LjFsLTEsMC42djgzLjdsMTkyLjU5OTk4LDExNS44MDAwMkw0NjYuMjAwMDEsMjAwCgkJbDEtMC42MDAwMVYxMTUuNnogTTI3NCw5LjdsMTgzLDEwOS41TDI3NC42MDAwMSwyMjguOEw5Mi4yMDAwMSwxMTkuMkwyNzQsOS43eiBNNDU3LDEyNC4xdjY5LjY5OTk5TDI3Ni43MDAwMSwzMDIuMjAwMDFWMjMyLjUKCQlMNDU3LDEyNC4xeiBNMjcyLjYwMDAxLDIzMi4zOTk5OXY2OS42OTk5OEw5Mi4zMDAwMSwxOTMuOHYtNjkuN0wyNzIuNjAwMDEsMjMyLjM5OTk5eiIvPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "cache", "name": "cache", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NS4xcHgiIGhlaWdodD0iNTcuM3B4IiB2aWV3Qm94PSIwIDAgNTUuMSA1Ny4zIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1NS4xIDU3LjMiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMyI+CjwvZz4KPGcgaWQ9IkxheWVyXzQiPgo8L2c+CjxnIGlkPSJMYXllcl8xMCI+Cgk8ZyBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiPgoJCTxwb2x5Z29uIHBvaW50cz0iMzUuNiw0NiAzOC4zLDQ4LjEgMjUuNiw1Mi4xIDIxLjIsNTMgMzIsMzguOSA0MS40LDQ0LjggCQkiLz4KCTwvZz4KPC9nPgo8ZyBpZD0iTGF5ZXJfNyI+CjwvZz4KPGcgaWQ9IkxheWVyXzYiPgoJPHBvbHlnb24gZmlsbD0iI0YyQzI1NiIgcG9pbnRzPSIzMS42LDE5IDE4LjQsMTEuMSAxOC40LDMzLjkgMjEuMiwzNS41IDIxLjIsNTIuMSAzMS42LDMxLjUgMjYsMjguMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZFNEFFIiBwb2ludHM9IjE4LjQsMzMuOSAxOC41LDMzLjkgMjUuNiwxNS40IDE4LjQsMTEuMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSJub25lIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC41IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzEuNiwxOSAKCQkxOC40LDExLjEgMTguNCwzMy45IDIxLjIsMzUuNSAyMS4yLDUyLjEgMzEuNiwzMS41IDI2LDI4LjEgCSIvPgo8L2c+CjxnIGlkPSJMYXllcl81Ij4KCTxwb2x5Z29uIGZpbGw9IiNGN0M1NkIiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxOC40LDExLjEgCgkJMjQuNSw3LjQgMzcuNiwxNS4zIDMyLDI0LjMgMzcuNiwyNy43IDI3LjIsNDguMyAyMS4yLDUyLjEgMzEuNiwzMS41IDI2LDI4LjEgMzEuNiwxOSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNzU0RjBDIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC40IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzcuNiwxNS4zIDMxLjYsMTkgMjYsMjguMSAzMS42LDMxLjUgCgkJMjEuMiw1Mi4xIDI3LjIsNDguMyAzNy42LDI3LjcgMzIsMjQuMyAJIi8+Cgk8ZyBpZD0iTGF5ZXJfMTIiPgoJPC9nPgoJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjE4LjQsMTEuMSAKCQkyNC41LDcuNCAzNy42LDE1LjMgMzIsMjQuMyAzNy42LDI3LjcgMjcuMiw0OC4zIDIxLjIsNTIuMSAzMS42LDMxLjUgMjYsMjguMSAzMS42LDE5IAkiLz4KPC9nPgo8ZyBpZD0iTGF5ZXJfOCI+Cgk8cG9seWdvbiBmaWxsPSIjQUY3QTJFIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC40IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzEuNiwzMS41IAoJCTM3LjYsMjcuNyAzMiwyNC4zIDI2LDI4LjEgCSIvPgo8L2c+CjxnIGlkPSJMYXllcl8xMSI+Cgk8cG9seWxpbmUgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjM3LjYsMTUuMyAKCQkzMS42LDE5IDI2LDI4LjEgMzIsMjQuMyAzNy42LDE1LjMgCSIvPgo8L2c+CjxnIGlkPSJMYXllcl85Ij4KCTxnPgoJCTxwYXRoIGZpbGw9IiMwMTAyMDIiIGQ9Ik0yNC41LDcuNGwxMy4yLDcuOWwtNS43LDlsNS42LDMuNEwyNy4yLDQ4LjNsLTYsMy43VjM1LjVsLTIuNy0xLjZWMTEuMUwyNC41LDcuNCBNMjQuNSw2LjYKCQkJYy0wLjEsMC0wLjMsMC0wLjQsMC4xbC02LDMuN2MtMC4yLDAuMS0wLjQsMC40LTAuNCwwLjd2MjIuOGMwLDAuMywwLjEsMC41LDAuNCwwLjdsMi4zLDEuNHYxNi4xYzAsMC4zLDAuMiwwLjYsMC40LDAuNwoJCQljMC4xLDAuMSwwLjMsMC4xLDAuNCwwLjFzMC4zLDAsMC40LTAuMWw2LTMuN2MwLjEtMC4xLDAuMi0wLjIsMC4zLTAuM2wxMC40LTIwLjZjMC4yLTAuNCwwLjEtMC44LTAuMy0xbC00LjktM2w1LjItOC40CgkJCWMwLjEtMC4yLDAuMS0wLjQsMC4xLTAuNmMtMC4xLTAuMi0wLjItMC40LTAuNC0wLjVsLTEzLjEtOEMyNC43LDYuNiwyNC42LDYuNiwyNC41LDYuNkwyNC41LDYuNnoiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "cardterminal", "name": "cardterminal", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1MjIuMXB4IiBoZWlnaHQ9IjYxNnB4IiB2aWV3Qm94PSIwIDAgNTIyLjEgNjE2IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MjIuMSA2MTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8cGF0aCBvcGFjaXR5PSIwLjE1IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgZD0iTTM2MSw1ODMuMmMyLDIzLjgtNDIuOSwzOC43LTEwNC45LDE5LjNzLTExNy41LTY1LjYtMTE5LjUtODkuMwoJYy0yLTIzLjgsNDUtMzguNCwxMDctMTkuMUMzMDUuNSw1MTMuNCwzNTksNTU5LjQsMzYxLDU4My4yeiIvPgo8ZyBpZD0iTGF5ZXJfMV8xXyIgZGlzcGxheT0ibm9uZSI+Cgk8cG9seWdvbiBkaXNwbGF5PSJpbmxpbmUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI0E3QTlBQyIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNDc2LjUsNDMxLjMgMjYxLjEsNTU1LjcgCgkJNDUuNiw0MzEuMyA0NS42LDE4Mi42IDI2MS4xLDU4LjIgNDc2LjUsMTgyLjYgCSIvPgo8L2c+CjxnIGlkPSJMYXllcl8yXzFfIj4KCTxnPgoJCTxnPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiM0QjZFOTgiIGQ9Ik0zNzYsMTQ2LjZ2Mjk0LjVjMCwzLjEtMC41LDUuNy0xLjQsNy44Yy0wLjksMi4xLTIuMiwzLjctMy43LDQuN2wwLDBjLTAuMiwwLjEtMC40LDAuMy0wLjYsMC40CgkJCQkJTDMzMiw0NzYuMWMzLjQtMS45LDUuNS02LjQsNS41LTEyLjdWMTY4LjljMC0xMS42LTctMjUtMTUuNy0zMEwxNjAuNSw0NS44Yy0zLjMtMS45LTYuNC0yLjMtOC45LTEuNGwzNi4zLTIxLjEKCQkJCQljMi45LTIuMiw2LjgtMi4zLDExLjIsMC4ybDE2MS4yLDkzLjFjNS43LDMuMywxMC44LDEwLjMsMTMuNSwxOEMzNzUuMiwxMzguNiwzNzYsMTQyLjcsMzc2LDE0Ni42eiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBvbHlnb24gZmlsbD0iIzAwNzBEOSIgcG9pbnRzPSIxNzQsMzkwIDE3NCw0NzIgMzA2LjEsNTQ4LjMgMzA2LjEsNDY2LjMgCQkJCSIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBvbHlnb24gZmlsbD0iI0ZGQzYwMCIgcG9pbnRzPSIyMzEuMiw0MzUuNiAxODQuNCw0MDguNiAxODQuNCw0MzYuNiAxODQuNCw0MzcuNiAxODQuNCw0NjUuNyAyMzEuMiw0OTIuNyAyMzEuMiw0NjMuNiAKCQkJCQkyMzEuMiw0NjMuNiAJCQkJIi8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMzM3LjQsMTgyLjd2LTEzLjhjMC0xMS42LTctMjUtMTUuNy0zMEwxNjAuNSw0NS44Yy0zLjMtMS45LTYuNC0yLjMtOC45LTEuNGwzNi4zLTIxLjEKCQkJCQljMi45LTIuMiw2LjgtMi4zLDExLjIsMC4ybDE2MS4yLDkzLjFjNS43LDMuMywxMC44LDEwLjMsMTMuNSwxOEMzNjMuMSwxNTEuNywzNTAuOSwxNjcuOCwzMzcuNCwxODIuN3oiLz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxwb2x5Z29uIGZpbGw9IiMwQkIyRjMiIHBvaW50cz0iMjY1LjUsNDgzLjIgMjU1LDQ3Ny4xIDI1NSw1MDIuNyAyNjUuNSw1MDguOCAyNjUuNSw0OTUuOCAyNjUuNSw0OTUuOCAJCQkJIi8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjMEJCMkYzIiBwb2ludHM9IjI2NS41LDQ0NS42IDI1NSw0MzkuNSAyNTUsNDY1LjEgMjY1LjUsNDcxLjIgMjY1LjUsNDU4LjIgMjY1LjUsNDU4LjIgCQkJCSIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0NERDlFRSIgZD0iTTMzOC4yLDQ2My40YzAsMTEuNi03LDE2LjktMTUuNywxMS45bC0xNjEuMi05My4xYy04LjctNS0xNS43LTE4LjUtMTUuNy0zMFY1Ny43CgkJCQkJYzAtMTEuNiw3LTE2LjksMTUuNy0xMS45bDE2MS4yLDkzLjFjOC43LDUsMTUuNywxOC41LDE1LjcsMzBWNDYzLjR6Ii8+CgkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzI4LjgsNDc5LjFjLTIuMywwLTQuOC0wLjctNy4yLTIuMmwtMTYxLjItOTMuMWMtOS40LTUuNC0xNi43LTE5LjQtMTYuNy0zMS44VjU3LjcKCQkJCQljMC05LjYsNC41LTE1LjgsMTEuNS0xNS44YzIuMywwLDQuOCwwLjcsNy4yLDIuMmwxNjEuMiw5My4xYzkuNCw1LjQsMTYuNywxOS40LDE2LjcsMzEuOHYyOTQuNQoJCQkJCUMzNDAuMiw0NzMsMzM1LjcsNDc5LjEsMzI4LjgsNDc5LjF6IE0xNTUuMSw0NS45Yy00LjUsMC03LjUsNC42LTcuNSwxMS44djI5NC41YzAsMTAuOSw2LjYsMjMuNiwxNC43LDI4LjNsMTYxLjIsOTMuMQoJCQkJCWMxLjksMS4xLDMuNiwxLjYsNS4yLDEuNmM0LjUsMCw3LjUtNC42LDcuNS0xMS44VjE2OC45YzAtMTAuOS02LjYtMjMuNi0xNC43LTI4LjNsLTE2MS4yLTkzQzE1OC41LDQ2LjUsMTU2LjcsNDUuOSwxNTUuMSw0NS45egoJCQkJCSIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggZmlsbD0iI0U2RTdFOCIgZD0iTTMwNy43LDIyOC4yYy0xLjIsMC0yLjUtMC40LTMuNy0xLjFMMTc1LjcsMTUzYy00LjQtMi41LTcuOC05LTcuOC0xNC44Vjg5LjZjMC01LjUsMy4xLTgsNi4xLTgKCQkJCQkJYzEuMiwwLDIuNSwwLjQsMy43LDEuMWwxMjguMyw3NGMwLjYsMC4zLDEuMSwwLjcsMS42LDEuMWMzLjcsMy4xLDYuMiw4LjcsNi4yLDEzLjd2NDguN0MzMTMuOCwyMjUuNywzMTAuOCwyMjguMiwzMDcuNywyMjguMnoKCQkJCQkJIi8+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTc0LDgzLjZjMC44LDAsMS43LDAuMywyLjcsMC44bDY1LjEsMzcuNmw0OS44LDI4LjdsMTMuNCw3LjdjMC40LDAuMywwLjksMC42LDEuMywwLjkKCQkJCQkJYzMuMSwyLjYsNS41LDcuNyw1LjUsMTIuMXY0OC43YzAsMy43LTEuNyw2LTQuMSw2Yy0wLjgsMC0xLjgtMC4zLTIuNy0wLjhsLTM0LjUtMTkuOWwtMjEuNy0xMi41bC0xNC44LTguNmwtNDkuOC0yOC43CgkJCQkJCWwtNy41LTQuM2MtMy44LTIuMi02LjgtOC02LjgtMTMuMVY4OS42QzE2OS45LDg1LjgsMTcxLjYsODMuNiwxNzQsODMuNiBNMTc0LDc5LjZMMTc0LDc5LjZjLTMuOSwwLTguMSwzLjEtOC4xLDEwdjQ4LjcKCQkJCQkJYzAsNi41LDMuOCwxMy42LDguOCwxNi41bDcuNSw0LjNsNDkuOCwyOC43bDE0LjgsOC42bDIxLjcsMTIuNWwzNC41LDE5LjljMS42LDAuOSwzLjEsMS40LDQuNywxLjRjMy45LDAsOC4xLTMuMSw4LjEtMTB2LTQ4LjcKCQkJCQkJYzAtNS41LTIuOS0xMS44LTctMTUuMmMtMC42LTAuNS0xLjItMC45LTEuOS0xLjNsLTEzLjQtNy43bC00OS44LTI4LjdMMTc4LjYsODFDMTc3LjIsODAsMTc1LjYsNzkuNiwxNzQsNzkuNkwxNzQsNzkuNnoiLz4KCQkJCTwvZz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zMTEuOCwxNzEuNXY5LjhsLTQxLjQsMjQuMWwtMjEuNy0xMi41bDU3LjUtMzMuNUMzMDkuNSwxNjIsMzExLjgsMTY3LjEsMzExLjgsMTcxLjV6Ii8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9IjI5MS42LDE1MC43IDIzMy45LDE4NC4zIDE4NC4yLDE1NS42IDI0MS44LDEyMiAJCQkJIi8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjAyLjMsMjI4Yy0xLjMsMC0yLjUtMC40LTMuOC0xLjFsLTE5LjgtMTEuNGMtNC42LTIuNi04LjItOS40LTguMi0xNS40di0xMS4yCgkJCQkJCQkJYzAtNS43LDMuMi04LjMsNi4zLTguM2MxLjMsMCwyLjUsMC40LDMuOCwxLjFsMTkuOCwxMS40YzQuNiwyLjYsOC4yLDkuNCw4LjIsMTUuNHYxMS4yQzIwOC42LDIyNS41LDIwNS41LDIyOCwyMDIuMywyMjh6Ii8+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTc2LjgsMTgyLjZjMC45LDAsMS44LDAuMywyLjgsMC45bDE5LjgsMTEuNGM0LDIuMyw3LjIsOC40LDcuMiwxMy43djExLjJjMCwzLjktMS44LDYuMy00LjMsNi4zCgkJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlsLTE5LjgtMTEuNGMtNC0yLjMtNy4yLTguNC03LjItMTMuN3YtMTEuMkMxNzIuNSwxODQuOSwxNzQuMywxODIuNiwxNzYuOCwxODIuNiBNMTc2LjgsMTc4LjYKCQkJCQkJCQlMMTc2LjgsMTc4LjZjLTQsMC04LjMsMy4yLTguMywxMC4zdjExLjJjMCw2LjgsMy45LDE0LjEsOS4yLDE3LjFsMTkuOCwxMS40YzEuNiwwLjksMy4yLDEuNCw0LjgsMS40YzQsMCw4LjMtMy4yLDguMy0xMC4zCgkJCQkJCQkJdi0xMS4yYzAtNi44LTMuOS0xNC4xLTkuMi0xNy4xTDE4MS43LDE4MEMxODAuMSwxNzkuMSwxNzguNCwxNzguNiwxNzYuOCwxNzguNkwxNzYuOCwxNzguNnoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iI0JDQkVDMCIgZD0iTTIwMi45LDE5OGMwLjMsMS4yLDAuNSwyLjQsMC41LDMuNnYxMS4yYzAsNS4zLTMuMiw3LjctNy4yLDUuNGwtMTkuOC0xMS40Yy0xLjItMC43LTIuNC0xLjgtMy40LTMuMQoJCQkJCQkJYzEsNC4yLDMuNiw4LjMsNi43LDEwLjFsMTkuOCwxMS40YzQsMi4zLDcuMi0wLjEsNy4yLTUuNHYtMTEuMkMyMDYuNiwyMDQuOSwyMDUuMSwyMDAuOSwyMDIuOSwxOTh6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTI1MS4yLDI1Ni4zYy0xLjMsMC0yLjUtMC40LTMuOC0xLjFsLTE5LjgtMTEuNGMtNC42LTIuNi04LjItOS40LTguMi0xNS40di0xMS4yCgkJCQkJCQkJYzAtNS43LDMuMi04LjMsNi4zLTguM2MxLjMsMCwyLjUsMC40LDMuOCwxLjFsMTkuOCwxMS40YzQuNiwyLjYsOC4yLDkuNCw4LjIsMTUuNFYyNDhDMjU3LjUsMjUzLjcsMjU0LjMsMjU2LjMsMjUxLjIsMjU2LjN6CgkJCQkJCQkJIi8+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjI1LjcsMjEwLjhjMC45LDAsMS44LDAuMywyLjgsMC45bDE5LjgsMTEuNGM0LDIuMyw3LjIsOC40LDcuMiwxMy43VjI0OGMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQljLTAuOSwwLTEuOC0wLjMtMi44LTAuOUwyMjguNSwyNDJjLTQtMi4zLTcuMi04LjQtNy4yLTEzLjd2LTExLjJDMjIxLjQsMjEzLjEsMjIzLjEsMjEwLjgsMjI1LjcsMjEwLjggTTIyNS43LDIwNi44CgkJCQkJCQkJTDIyNS43LDIwNi44Yy00LDAtOC4zLDMuMi04LjMsMTAuM3YxMS4yYzAsNi44LDMuOSwxNC4xLDkuMiwxNy4xbDE5LjgsMTEuNGMxLjYsMC45LDMuMiwxLjQsNC44LDEuNGM0LDAsOC4zLTMuMiw4LjMtMTAuMwoJCQkJCQkJCXYtMTEuMmMwLTYuOC0zLjktMTQuMS05LjItMTcuMWwtMTkuOC0xMS40QzIyOC45LDIwNy4zLDIyNy4zLDIwNi44LDIyNS43LDIwNi44TDIyNS43LDIwNi44eiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjQkNCRUMwIiBkPSJNMjUxLjcsMjI2LjJjMC4zLDEuMiwwLjUsMi40LDAuNSwzLjZWMjQxYzAsNS4zLTMuMiw3LjctNy4yLDUuNEwyMjUuMiwyMzVjLTEuMi0wLjctMi40LTEuOC0zLjQtMy4xCgkJCQkJCQljMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40di0xMS4yQzI1NS41LDIzMy4xLDI1NCwyMjkuMSwyNTEuNywyMjYuMnoiLz4KCQkJCQk8L2c+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMzAwLDI4NC41Yy0xLjMsMC0yLjUtMC40LTMuOC0xLjFMMjc2LjQsMjcyYy00LjYtMi42LTguMi05LjQtOC4yLTE1LjR2LTExLjJjMC01LjcsMy4yLTguMyw2LjMtOC4zCgkJCQkJCQkJYzEuMywwLDIuNSwwLjQsMy44LDEuMWwxOS44LDExLjRjNC42LDIuNiw4LjIsOS40LDguMiwxNS40djExLjJDMzA2LjMsMjgxLjksMzAzLjIsMjg0LjUsMzAwLDI4NC41eiIvPgoJCQkJCQk8L2c+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTI3NC41LDIzOWMwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjd2MTEuMmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQljLTAuOSwwLTEuOC0wLjMtMi44LTAuOWwtMTkuOC0xMS40Yy00LTIuMy03LjItOC40LTcuMi0xMy43di0xMS4yQzI3MC4yLDI0MS4zLDI3MiwyMzksMjc0LjUsMjM5IE0yNzQuNSwyMzVMMjc0LjUsMjM1CgkJCQkJCQkJYy00LDAtOC4zLDMuMi04LjMsMTAuM3YxMS4yYzAsNi44LDMuOSwxNC4xLDkuMiwxNy4xbDE5LjgsMTEuNGMxLjYsMC45LDMuMiwxLjQsNC44LDEuNGM0LDAsOC4zLTMuMiw4LjMtMTAuM1YyNjUKCQkJCQkJCQljMC02LjgtMy45LTE0LjEtOS4yLTE3LjFsLTE5LjgtMTEuNEMyNzcuOCwyMzUuNSwyNzYuMSwyMzUsMjc0LjUsMjM1TDI3NC41LDIzNXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iI0JDQkVDMCIgZD0iTTMwMC42LDI1NC40YzAuMywxLjIsMC41LDIuNCwwLjUsMy42djExLjJjMCw1LjMtMy4yLDcuNy03LjIsNS40bC0xOS44LTExLjQKCQkJCQkJCWMtMS4yLTAuNy0yLjQtMS44LTMuNC0zLjFjMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40VjI2NUMzMDQuMywyNjEuMywzMDIuOCwyNTcuMywzMDAuNiwyNTQuNHoKCQkJCQkJCSIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8Zz4KCQkJCQkJCTxnPgoJCQkJCQkJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0yMDIuMywyNzQuM2MtMS4zLDAtMi41LTAuNC0zLjgtMS4xbC0xOS44LTExLjRjLTQuNi0yLjYtOC4yLTkuNC04LjItMTUuNHYtMTEuMgoJCQkJCQkJCQljMC01LjcsMy4yLTguMyw2LjMtOC4zYzEuMywwLDIuNSwwLjQsMy44LDEuMWwxOS44LDExLjRjNC42LDIuNiw4LjIsOS40LDguMiwxNS40VjI2NkMyMDguNiwyNzEuOCwyMDUuNSwyNzQuMywyMDIuMywyNzQuMwoJCQkJCQkJCQl6Ii8+CgkJCQkJCQk8L2c+CgkJCQkJCQk8Zz4KCQkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTc2LjgsMjI4LjljMC45LDAsMS44LDAuMywyLjgsMC45bDE5LjgsMTEuNGM0LDIuMyw3LjIsOC40LDcuMiwxMy43VjI2NmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlMMTc5LjcsMjYwYy00LTIuMy03LjItOC40LTcuMi0xMy43di0xMS4yQzE3Mi41LDIzMS4yLDE3NC4zLDIyOC45LDE3Ni44LDIyOC45IE0xNzYuOCwyMjQuOQoJCQkJCQkJCQlMMTc2LjgsMjI0LjljLTQsMC04LjMsMy4yLTguMywxMC4zdjExLjJjMCw2LjgsMy45LDE0LjEsOS4yLDE3LjFsMTkuOCwxMS40YzEuNiwwLjksMy4yLDEuNCw0LjgsMS40YzQsMCw4LjMtMy4yLDguMy0xMC4zCgkJCQkJCQkJCXYtMTEuMmMwLTYuOC0zLjktMTQuMS05LjItMTcuMWwtMTkuOC0xMS40QzE4MC4xLDIyNS4zLDE3OC40LDIyNC45LDE3Ni44LDIyNC45TDE3Ni44LDIyNC45eiIvPgoJCQkJCQkJPC9nPgoJCQkJCQk8L2c+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iI0JDQkVDMCIgZD0iTTIwMi45LDI0NC4zYzAuMywxLjIsMC41LDIuNCwwLjUsMy42djExLjJjMCw1LjMtMy4yLDcuNy03LjIsNS40bC0xOS44LTExLjQKCQkJCQkJCQljLTEuMi0wLjctMi40LTEuOC0zLjQtMy4xYzEsNC4yLDMuNiw4LjMsNi43LDEwLjFsMTkuOCwxMS40YzQsMi4zLDcuMi0wLjEsNy4yLTUuNHYtMTEuMgoJCQkJCQkJCUMyMDYuNiwyNTEuMiwyMDUuMSwyNDcuMiwyMDIuOSwyNDQuM3oiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8Zz4KCQkJCQkJCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjUxLjIsMzAyLjVjLTEuMywwLTIuNS0wLjQtMy44LTEuMUwyMjcuNSwyOTBjLTQuNi0yLjYtOC4yLTkuNC04LjItMTUuNHYtMTEuMgoJCQkJCQkJCQljMC0yLjMsMC41LTQuMywxLjYtNS44YzEuMS0xLjYsMi44LTIuNSw0LjctMi41YzEuMywwLDIuNSwwLjQsMy44LDEuMWwxOS44LDExLjRjNC42LDIuNiw4LjIsOS40LDguMiwxNS40djExLjIKCQkJCQkJCQkJQzI1Ny41LDMwMCwyNTQuMywzMDIuNSwyNTEuMiwzMDIuNXoiLz4KCQkJCQkJCTwvZz4KCQkJCQkJCTxnPgoJCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMjUuNywyNTcuMWMwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjd2MTEuMmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlsLTE5LjgtMTEuNGMtNC0yLjMtNy4yLTguNC03LjItMTMuN3YtMTEuMkMyMjEuNCwyNTkuNCwyMjMuMSwyNTcuMSwyMjUuNywyNTcuMSBNMjI1LjcsMjUzLjEKCQkJCQkJCQkJTDIyNS43LDI1My4xYy0yLjUsMC00LjksMS4yLTYuNCwzLjRjLTEuMywxLjgtMS45LDQuMi0xLjksNi45djExLjJjMCw2LjgsMy45LDE0LjEsOS4yLDE3LjFsMTkuOCwxMS40CgkJCQkJCQkJCWMxLjYsMC45LDMuMiwxLjQsNC44LDEuNGM0LDAsOC4zLTMuMiw4LjMtMTAuM1YyODNjMC02LjgtMy45LTE0LjEtOS4yLTE3LjFsLTE5LjgtMTEuNAoJCQkJCQkJCQlDMjI4LjksMjUzLjUsMjI3LjMsMjUzLjEsMjI1LjcsMjUzLjFMMjI1LjcsMjUzLjF6Ii8+CgkJCQkJCQk8L2c+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjQkNCRUMwIiBkPSJNMjUxLjcsMjcyLjVjMC4zLDEuMiwwLjUsMi40LDAuNSwzLjZ2MTEuMmMwLDUuMy0zLjIsNy43LTcuMiw1LjRsLTE5LjgtMTEuNAoJCQkJCQkJCWMtMS4yLTAuNy0yLjQtMS44LTMuNC0zLjFjMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40VjI4M0MyNTUuNSwyNzkuNCwyNTQsMjc1LjQsMjUxLjcsMjcyLjV6IgoJCQkJCQkJCS8+CgkJCQkJCTwvZz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxnPgoJCQkJCQkJPGc+CgkJCQkJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTMwMCwzMzAuN2MtMS4zLDAtMi41LTAuNC0zLjgtMS4xbC0xOS44LTExLjRjLTQuNi0yLjYtOC4yLTkuNC04LjItMTUuNHYtMTEuMgoJCQkJCQkJCQljMC01LjcsMy4yLTguMyw2LjMtOC4zYzEuMywwLDIuNSwwLjQsMy44LDEuMWwxOS44LDExLjRjNC42LDIuNiw4LjIsOS40LDguMiwxNS40djExLjJDMzA2LjMsMzI4LjIsMzAzLjIsMzMwLjcsMzAwLDMzMC43egoJCQkJCQkJCQkiLz4KCQkJCQkJCTwvZz4KCQkJCQkJCTxnPgoJCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNzQuNSwyODUuM2MwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjd2MTEuMmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlsLTE5LjgtMTEuNGMtNC0yLjMtNy4yLTguNC03LjItMTMuN3YtMTEuMkMyNzAuMiwyODcuNiwyNzIsMjg1LjMsMjc0LjUsMjg1LjMgTTI3NC41LDI4MS4zCgkJCQkJCQkJCUwyNzQuNSwyODEuM2MtNCwwLTguMywzLjItOC4zLDEwLjN2MTEuMmMwLDYuOCwzLjksMTQuMSw5LjIsMTcuMWwxOS44LDExLjRjMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjMKCQkJCQkJCQkJdi0xMS4yYzAtNi44LTMuOS0xNC4xLTkuMi0xNy4xbC0xOS44LTExLjRDMjc3LjgsMjgxLjcsMjc2LjEsMjgxLjMsMjc0LjUsMjgxLjNMMjc0LjUsMjgxLjN6Ii8+CgkJCQkJCQk8L2c+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjQkNCRUMwIiBkPSJNMzAwLjYsMzAwLjdjMC4zLDEuMiwwLjUsMi40LDAuNSwzLjZ2MTEuMmMwLDUuMy0zLjIsNy43LTcuMiw1LjRsLTE5LjgtMTEuNAoJCQkJCQkJCWMtMS4yLTAuNy0yLjQtMS44LTMuNC0zLjFjMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40di0xMS4yCgkJCQkJCQkJQzMwNC4zLDMwNy42LDMwMi44LDMwMy42LDMwMC42LDMwMC43eiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxnPgoJCQkJCQkJPGc+CgkJCQkJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTIwMi4zLDMyMC42Yy0xLjMsMC0yLjUtMC40LTMuOC0xLjFMMTc4LjcsMzA4Yy00LjYtMi42LTguMi05LjQtOC4yLTE1LjR2LTExLjIKCQkJCQkJCQkJYzAtNS43LDMuMi04LjMsNi4zLTguM2MxLjMsMCwyLjUsMC40LDMuOCwxLjFsMTkuOCwxMS40YzQuNiwyLjYsOC4yLDkuNCw4LjIsMTUuNHYxMS4yQzIwOC42LDMxOCwyMDUuNSwzMjAuNiwyMDIuMywzMjAuNnoKCQkJCQkJCQkJIi8+CgkJCQkJCQk8L2c+CgkJCQkJCQk8Zz4KCQkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTc2LjgsMjc1LjFjMC45LDAsMS44LDAuMywyLjgsMC45bDE5LjgsMTEuNGM0LDIuMyw3LjIsOC40LDcuMiwxMy43djExLjJjMCwzLjktMS44LDYuMy00LjMsNi4zCgkJCQkJCQkJCWMtMC45LDAtMS44LTAuMy0yLjgtMC45bC0xOS44LTExLjRjLTQtMi4zLTcuMi04LjQtNy4yLTEzLjd2LTExLjJDMTcyLjUsMjc3LjUsMTc0LjMsMjc1LjEsMTc2LjgsMjc1LjEgTTE3Ni44LDI3MS4xCgkJCQkJCQkJCUwxNzYuOCwyNzEuMWMtNCwwLTguMywzLjItOC4zLDEwLjN2MTEuMmMwLDYuOCwzLjksMTQuMSw5LjIsMTcuMWwxOS44LDExLjRjMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjMKCQkJCQkJCQkJVjMwMWMwLTYuOC0zLjktMTQuMS05LjItMTcuMWwtMTkuOC0xMS40QzE4MC4xLDI3MS42LDE3OC40LDI3MS4xLDE3Ni44LDI3MS4xTDE3Ni44LDI3MS4xeiIvPgoJCQkJCQkJPC9nPgoJCQkJCQk8L2c+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iI0JDQkVDMCIgZD0iTTIwMi45LDI5MC41YzAuMywxLjIsMC41LDIuNCwwLjUsMy42djExLjJjMCw1LjMtMy4yLDcuNy03LjIsNS40bC0xOS44LTExLjQKCQkJCQkJCQljLTEuMi0wLjctMi40LTEuOC0zLjQtMy4xYzEsNC4yLDMuNiw4LjMsNi43LDEwLjFsMTkuOCwxMS40YzQsMi4zLDcuMi0wLjEsNy4yLTUuNHYtMTEuMgoJCQkJCQkJCUMyMDYuNiwyOTcuNSwyMDUuMSwyOTMuNCwyMDIuOSwyOTAuNXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8Zz4KCQkJCQkJCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjUxLjIsMzQ4LjhjLTEuMywwLTIuNS0wLjQtMy44LTEuMWwtMTkuOC0xMS40Yy00LjYtMi42LTguMi05LjQtOC4yLTE1LjR2LTExLjIKCQkJCQkJCQkJYzAtNS43LDMuMi04LjMsNi4zLTguM2MxLjMsMCwyLjUsMC40LDMuOCwxLjFsMTkuOCwxMS40YzQuNiwyLjYsOC4yLDkuNCw4LjIsMTUuNHYxMS4yCgkJCQkJCQkJCUMyNTcuNSwzNDYuMiwyNTQuMywzNDguOCwyNTEuMiwzNDguOHoiLz4KCQkJCQkJCTwvZz4KCQkJCQkJCTxnPgoJCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMjUuNywzMDMuM2MwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjd2MTEuMmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlsLTE5LjgtMTEuNGMtNC0yLjMtNy4yLTguNC03LjItMTMuN3YtMTEuMkMyMjEuNCwzMDUuNywyMjMuMSwzMDMuMywyMjUuNywzMDMuMyBNMjI1LjcsMjk5LjMKCQkJCQkJCQkJTDIyNS43LDI5OS4zYy00LDAtOC4zLDMuMi04LjMsMTAuM3YxMS4yYzAsNi44LDMuOSwxNC4xLDkuMiwxNy4xbDE5LjgsMTEuNGMxLjYsMC45LDMuMiwxLjQsNC44LDEuNGM0LDAsOC4zLTMuMiw4LjMtMTAuMwoJCQkJCQkJCQl2LTExLjJjMC02LjgtMy45LTE0LjEtOS4yLTE3LjFsLTE5LjgtMTEuNEMyMjguOSwyOTkuOCwyMjcuMywyOTkuMywyMjUuNywyOTkuM0wyMjUuNywyOTkuM3oiLz4KCQkJCQkJCTwvZz4KCQkJCQkJPC9nPgoJCQkJCQk8Zz4KCQkJCQkJCTxwYXRoIGZpbGw9IiNCQ0JFQzAiIGQ9Ik0yNTEuNywzMTguN2MwLjMsMS4yLDAuNSwyLjQsMC41LDMuNnYxMS4yYzAsNS4zLTMuMiw3LjctNy4yLDUuNGwtMTkuOC0xMS40CgkJCQkJCQkJYy0xLjItMC43LTIuNC0xLjgtMy40LTMuMWMxLDQuMiwzLjYsOC4zLDYuNywxMC4xbDE5LjgsMTEuNGM0LDIuMyw3LjItMC4xLDcuMi01LjR2LTExLjJDMjU1LjUsMzI1LjcsMjU0LDMyMS42LDI1MS43LDMxOC43CgkJCQkJCQkJeiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8Zz4KCQkJCQkJCTxnPgoJCQkJCQkJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zMDAsMzc3Yy0xLjMsMC0yLjUtMC40LTMuOC0xLjFsLTE5LjgtMTEuNGMtNC42LTIuNi04LjItOS40LTguMi0xNS40di0xMS4yYzAtMi4zLDAuNS00LjMsMS42LTUuOAoJCQkJCQkJCQljMS4xLTEuNiwyLjgtMi41LDQuNy0yLjVjMS4zLDAsMi41LDAuNCwzLjgsMS4xbDE5LjgsMTEuNGM0LjYsMi42LDguMiw5LjQsOC4yLDE1LjR2MTEuMkMzMDYuMywzNzQuNCwzMDMuMiwzNzcsMzAwLDM3N3oiCgkJCQkJCQkJCS8+CgkJCQkJCQk8L2c+CgkJCQkJCQk8Zz4KCQkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjc0LjUsMzMxLjVjMC45LDAsMS44LDAuMywyLjgsMC45bDE5LjgsMTEuNGM0LDIuMyw3LjIsOC40LDcuMiwxMy43djExLjJjMCwzLjktMS44LDYuMy00LjMsNi4zCgkJCQkJCQkJCWMtMC45LDAtMS44LTAuMy0yLjgtMC45bC0xOS44LTExLjRjLTQtMi4zLTcuMi04LjQtNy4yLTEzLjd2LTExLjJDMjcwLjIsMzMzLjksMjcyLDMzMS41LDI3NC41LDMzMS41IE0yNzQuNSwzMjcuNQoJCQkJCQkJCQlMMjc0LjUsMzI3LjVjLTIuNSwwLTQuOSwxLjItNi40LDMuNGMtMS4zLDEuOC0xLjksNC4yLTEuOSw2LjlWMzQ5YzAsNi44LDMuOSwxNC4xLDkuMiwxNy4xbDE5LjgsMTEuNAoJCQkJCQkJCQljMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjN2LTExLjJjMC02LjgtMy45LTE0LjEtOS4yLTE3LjFsLTE5LjgtMTEuNAoJCQkJCQkJCQlDMjc3LjgsMzI4LDI3Ni4xLDMyNy41LDI3NC41LDMyNy41TDI3NC41LDMyNy41eiIvPgoJCQkJCQkJPC9nPgoJCQkJCQk8L2c+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iI0JDQkVDMCIgZD0iTTMwMC42LDM0Ni45YzAuMywxLjIsMC41LDIuNCwwLjUsMy42djExLjJjMCw1LjMtMy4yLDcuNy03LjIsNS40bC0xOS44LTExLjQKCQkJCQkJCQljLTEuMi0wLjctMi40LTEuOC0zLjQtMy4xYzEsNC4yLDMuNiw4LjMsNi43LDEwLjFsMTkuOCwxMS40YzQsMi4zLDcuMi0wLjEsNy4yLTUuNHYtMTEuMgoJCQkJCQkJCUMzMDQuMywzNTMuOSwzMDIuOCwzNDkuOCwzMDAuNiwzNDYuOXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjOERDNjNGIiBkPSJNMjAyLjMsMzY2LjhjLTEuMywwLTIuNS0wLjQtMy44LTEuMWwtMTkuOC0xMS40Yy00LjYtMi42LTguMi05LjQtOC4yLTE1LjR2LTExLjIKCQkJCQkJCWMwLTUuNywzLjItOC4zLDYuMy04LjNjMS4zLDAsMi41LDAuNCwzLjgsMS4xbDE5LjgsMTEuNGM0LjYsMi42LDguMiw5LjQsOC4yLDE1LjR2MTEuMkMyMDguNiwzNjQuMywyMDUuNSwzNjYuOCwyMDIuMywzNjYuOHoKCQkJCQkJCSIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTE3Ni44LDMyMS40YzAuOSwwLDEuOCwwLjMsMi44LDAuOWwxOS44LDExLjRjNCwyLjMsNy4yLDguNCw3LjIsMTMuN3YxMS4yYzAsMy45LTEuOCw2LjMtNC4zLDYuMwoJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlsLTE5LjgtMTEuNGMtNC0yLjMtNy4yLTguNC03LjItMTMuN3YtMTEuMkMxNzIuNSwzMjMuNywxNzQuMywzMjEuNCwxNzYuOCwzMjEuNCBNMTc2LjgsMzE3LjQKCQkJCQkJCUwxNzYuOCwzMTcuNGMtNCwwLTguMywzLjItOC4zLDEwLjN2MTEuMmMwLDYuOCwzLjksMTQuMSw5LjIsMTcuMWwxOS44LDExLjRjMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjMKCQkJCQkJCXYtMTEuMmMwLTYuOC0zLjktMTQuMS05LjItMTcuMWwtMTkuOC0xMS40QzE4MC4xLDMxNy45LDE3OC40LDMxNy40LDE3Ni44LDMxNy40TDE3Ni44LDMxNy40eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxwYXRoIGZpbGw9IiM3QkE1MzgiIGQ9Ik0yMDIuOSwzMzYuOGMwLjMsMS4yLDAuNSwyLjQsMC41LDMuNnYxMS4yYzAsNS4zLTMuMiw3LjctNy4yLDUuNGwtMTkuOC0xMS40Yy0xLjItMC43LTIuNC0xLjgtMy40LTMuMQoJCQkJCQljMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40di0xMS4yQzIwNi42LDM0My43LDIwNS4xLDMzOS43LDIwMi45LDMzNi44eiIvPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTI1MS4yLDM5NS4xYy0xLjMsMC0yLjUtMC40LTMuOC0xLjFsLTE5LjgtMTEuNGMtNC42LTIuNi04LjItOS40LTguMi0xNS40VjM1NgoJCQkJCQkJCWMwLTUuNywzLjItOC4zLDYuMy04LjNjMS4zLDAsMi41LDAuNCwzLjgsMS4xbDE5LjgsMTEuNGM0LjYsMi42LDguMiw5LjQsOC4yLDE1LjR2MTEuMkMyNTcuNSwzOTIuNSwyNTQuMywzOTUuMSwyNTEuMiwzOTUuMQoJCQkJCQkJCXoiLz4KCQkJCQkJPC9nPgoJCQkJCQk8Zz4KCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMjUuNywzNDkuNmMwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjd2MTEuMmMwLDMuOS0xLjgsNi4zLTQuMyw2LjMKCQkJCQkJCQljLTAuOSwwLTEuOC0wLjMtMi44LTAuOWwtMTkuOC0xMS40Yy00LTIuMy03LjItOC40LTcuMi0xMy43di0xMS4yQzIyMS40LDM1MS45LDIyMy4xLDM0OS42LDIyNS43LDM0OS42IE0yMjUuNywzNDUuNgoJCQkJCQkJCUwyMjUuNywzNDUuNmMtNCwwLTguMywzLjItOC4zLDEwLjN2MTEuMmMwLDYuOCwzLjksMTQuMSw5LjIsMTcuMWwxOS44LDExLjRjMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjMKCQkJCQkJCQl2LTExLjJjMC02LjgtMy45LTE0LjEtOS4yLTE3LjFMMjMwLjUsMzQ3QzIyOC45LDM0Ni4xLDIyNy4zLDM0NS42LDIyNS43LDM0NS42TDIyNS43LDM0NS42eiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjQkNCRUMwIiBkPSJNMjUxLjcsMzY1YzAuMywxLjIsMC41LDIuNCwwLjUsMy42djExLjJjMCw1LjMtMy4yLDcuNy03LjIsNS40bC0xOS44LTExLjRjLTEuMi0wLjctMi40LTEuOC0zLjQtMy4xCgkJCQkJCQljMSw0LjIsMy42LDguMyw2LjcsMTAuMWwxOS44LDExLjRjNCwyLjMsNy4yLTAuMSw3LjItNS40di0xMS4yQzI1NS41LDM3MS45LDI1NCwzNjcuOSwyNTEuNywzNjV6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiNENjQ1NTAiIGQ9Ik0zMDAsNDIzLjNjLTEuMywwLTIuNS0wLjQtMy44LTEuMWwtMTkuOC0xMS40Yy00LjYtMi42LTguMi05LjQtOC4yLTE1LjR2LTExLjJjMC01LjcsMy4yLTguMyw2LjMtOC4zCgkJCQkJCQljMS4zLDAsMi41LDAuNCwzLjgsMS4xbDE5LjgsMTEuNGM0LjYsMi42LDguMiw5LjQsOC4yLDE1LjRWNDE1QzMwNi4zLDQyMC43LDMwMy4yLDQyMy4zLDMwMCw0MjMuM3oiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNzQuNSwzNzcuOGMwLjksMCwxLjgsMC4zLDIuOCwwLjlsMTkuOCwxMS40YzQsMi4zLDcuMiw4LjQsNy4yLDEzLjdWNDE1YzAsMy45LTEuOCw2LjMtNC4zLDYuMwoJCQkJCQkJYy0wLjksMC0xLjgtMC4zLTIuOC0wLjlMMjc3LjQsNDA5Yy00LTIuMy03LjItOC40LTcuMi0xMy43di0xMS4yQzI3MC4yLDM4MC4xLDI3MiwzNzcuOCwyNzQuNSwzNzcuOCBNMjc0LjUsMzczLjgKCQkJCQkJCUwyNzQuNSwzNzMuOGMtNCwwLTguMywzLjItOC4zLDEwLjN2MTEuMmMwLDYuOCwzLjksMTQuMSw5LjIsMTcuMWwxOS44LDExLjRjMS42LDAuOSwzLjIsMS40LDQuOCwxLjRjNCwwLDguMy0zLjIsOC4zLTEwLjMKCQkJCQkJCXYtMTEuMmMwLTYuOC0zLjktMTQuMS05LjItMTcuMWwtMTkuOC0xMS40QzI3Ny44LDM3NC4zLDI3Ni4xLDM3My44LDI3NC41LDM3My44TDI3NC41LDM3My44eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxwYXRoIGZpbGw9IiM5MzJFM0QiIGQ9Ik0zMDAuNiwzOTMuMmMwLjMsMS4yLDAuNSwyLjQsMC41LDMuNlY0MDhjMCw1LjMtMy4yLDcuNy03LjIsNS40TDI3NC4xLDQwMmMtMS4yLTAuNy0yLjQtMS44LTMuNC0zLjEKCQkJCQkJYzEsNC4yLDMuNiw4LjMsNi43LDEwLjFsMTkuOCwxMS40YzQsMi4zLDcuMi0wLjEsNy4yLTUuNHYtMTEuMkMzMDQuMyw0MDAuMSwzMDIuOCwzOTYuMSwzMDAuNiwzOTMuMnoiLz4KCQkJCTwvZz4KCQkJPC9nPgoJCTwvZz4KCQk8Zz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTkzLjEsMjEuOWMxLjksMCw0LDAuNiw2LjIsMS45bDE2MS4yLDkzLjFjNS43LDMuMywxMC44LDEwLjMsMTMuNSwxOGMxLjQsMy45LDIuMiw4LjEsMi4yLDEydjI5NC41CgkJCQkJYzAsMy4xLTAuNSw1LjctMS40LDcuOGMtMC45LDIuMS0yLjIsMy43LTMuNyw0LjdsMCwwYy0wLjIsMC4xLTAuNCwwLjMtMC42LDAuNEwzMzUsNDc0LjdjLTEuNiwxLjctMy43LDIuNy02LDIuNwoJCQkJCWMtMS45LDAtNC0wLjYtNi4yLTEuOWwtMTYuNC05LjV2ODJsLTEzMi4xLTc2LjN2LTgybC0xMi43LTcuM2MtOC43LTUtMTUuNy0xOC41LTE1LjctMzBWNTcuOWMwLTguNCwzLjctMTMuNSw5LjEtMTMuNwoJCQkJCWMtMC4xLDAtMC4zLDAtMC40LDBjLTEsMC0xLjksMC4xLTIuNywwLjRsMzYuMy0yMS4xQzE4OS42LDIyLjUsMTkxLjMsMjEuOSwxOTMuMSwyMS45IE0xOTMuMSwxMi45Yy0zLjYsMC03LDEuMS05LjksMy4yCgkJCQkJbC0zNS45LDIwLjhsMC4xLDAuM2MtNS45LDMuMS0xMC42LDkuOS0xMC42LDIwLjl2Mjk0LjVjMCwxNC45LDguNywzMS4yLDIwLjIsMzcuOGw4LjIsNC43djc2Ljh2NS4ybDQuNSwyLjZMMzAxLjgsNTU2bDEzLjUsNy44CgkJCQkJVjU0OHYtNjYuNGwyLjksMS43YzMuNiwyLjEsNy4yLDMuMSwxMC43LDMuMWM0LjMsMCw4LjMtMS42LDExLjUtNC40bDMzLjEtMTkuMWgwLjJsMi4yLTEuNGMzLjEtMiw1LjYtNS4xLDcuMS04LjgKCQkJCQljMS40LTMuMiwyLjEtNywyLjEtMTEuMlYxNDYuOGMwLTQuOC0wLjktOS45LTIuNy0xNWMtMy42LTEwLTEwLjEtMTguNi0xNy41LTIyLjhsLTE2MS05M0MyMDAuMywxMy45LDE5Ni43LDEyLjksMTkzLjEsMTIuOQoJCQkJCUwxOTMuMSwxMi45eiIvPgoJCQk8L2c+CgkJPC9nPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "cloud", "name": "cloud", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI0NzEuN3B4IiBoZWlnaHQ9IjUwOC40cHgiIHZpZXdCb3g9IjAgMCA0NzEuNyA1MDguNCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNDcxLjcgNTA4LjQiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMV8xXyIgZGlzcGxheT0ibm9uZSI+Cgk8cG9seWdvbiBkaXNwbGF5PSJpbmxpbmUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzIzMUYyMCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjQzMy4xLDM3MyAyMjcuMyw0OTEuOCAyMS41LDM3MyAKCQkyMS41LDEzNS40IDIyNy4zLDE2LjYgNDMzLjEsMTM1LjQgCSIvPgo8L2c+CjxwYXRoIG9wYWNpdHk9IjAuMjUiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNMzgxLjUsNDI5LjRjNC42LDMwLjktNTUuMyw1NC44LTE0MS4xLDM1LjZTNzUuMywzOTAuOSw3MC43LDM1OS45CgljLTQuNi0zMC45LDU4LjEtNTQuNywxNDMuOS0zNS41UzM3Ni45LDM5OC41LDM4MS41LDQyOS40eiIvPgo8ZyBpZD0iTGF5ZXJfMl8xXyI+Cgk8Zz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iI0E3QTlBQyIgZD0iTTM0OS42LDI0OS4yTDI4NCwyODdjLTEuNS03LjUtMy44LTE1LTYuNy0yMi4zYy05LjMtMjMuNS0yNC45LTQ0LjUtNDIuNi01NC43bC0wLjItMC4xCgkJCQljLTYuNS0zLjgtMTIuNy01LjctMTguNC02LjFsNjUuNS0zNy44YzUuNywwLjQsMTEuOSwyLjQsMTguNCw2LjFsMC4yLDAuMWM4LjIsNC43LDE2LDExLjgsMjIuOSwyMC4zCgkJCQlDMzM1LjcsMjA4LjEsMzQ1LjQsMjI4LjYsMzQ5LjYsMjQ5LjJ6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjQTdBOUFDIiBkPSJNMjgxLjYsMTY2bC02NS41LDM3LjhjLTAuOS05LjUtMy0xOS4xLTUuOS0yOC42Yy0xMC4xLTMyLjQtMzAuNy02Mi42LTU0LjYtNzYuNAoJCQkJYy04LjYtNS0xNi44LTcuMy0yNC4zLTcuM2MtNS43LDAtMTAuOSwxLjQtMTUuNiw0bDY1LjgtMzhsMC4xLDAuMWMxMC43LTUuOSwyNC40LTUuMiwzOS41LDMuNWMxNCw4LjEsMjYuOCwyMS43LDM3LjEsMzguMgoJCQkJQzI3MC43LDExOS4xLDI3OS40LDE0MywyODEuNiwxNjZ6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjQkNCRUMwIiBkPSJNMzE3LjcsNDIzLjZDMzE3LjgsNDIzLjYsMzE3LjgsNDIzLjYsMzE3LjcsNDIzLjZMMzE3LjcsNDIzLjZMMzE3LjcsNDIzLjZ6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRDFEM0Q0IiBkPSJNNDAwLjksMzQ1LjNjMCwxOS42LTYuNCwzMy42LTE2LjUsMzkuOWwwLDBsMCwwYy0wLjQsMC4zLTAuOSwwLjUtMS4zLDAuOGwtNjQuNiwzNy4yCgkJCQljMTAuNC02LjIsMTctMjAuMiwxNy00MC4yYzAtMzYuNy0yMi4zLTc5LjQtNDkuOS05NS4zTDI4NCwyODdsNjUuNS0zNy44bDEuNSwwLjhDMzc4LjYsMjY1LjksNDAwLjksMzA4LjYsNDAwLjksMzQ1LjN6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjQTdBOUFDIiBkPSJNMzM0LjgsMzczLjNjMC4zLDMuMywwLjUsNi41LDAuNSw5LjdjMCwxOS45LTYuNiwzNC0xNyw0MC4yTDM4MywzODZjMC41LTAuMiwwLjktMC41LDEuMy0wLjhoMC4xbDAsMAoJCQkJYzEwLjItNi4zLDE2LjUtMjAuMywxNi41LTM5LjljMC0xNC4zLTMuNC0yOS41LTkuMi00My43QzM3Ny45LDMyOS4zLDM1OC41LDM1My44LDMzNC44LDM3My4zeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iI0QxRDNENCIgZD0iTTMyMy4xLDE5Mi41Yy03LjQsMjguNy0yMy43LDUzLjgtNDUuOCw3Mi4yYy05LjMtMjMuNS0yNC45LTQ0LjUtNDIuNi01NC43bC0wLjItMC4xCgkJCQljLTYuNS0zLjgtMTIuNy01LjctMTguNC02LjFsNjUuNS0zNy44YzUuNywwLjQsMTEuOSwyLjQsMTguNCw2LjFsMC4yLDAuMUMzMDguNSwxNzYuOSwzMTYuMiwxODQsMzIzLjEsMTkyLjV6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRDFEM0Q0IiBkPSJNMjU4LjMsOTkuMmMtMTAsMjktMjYuNyw1NC45LTQ4LjEsNzZjLTEwLjEtMzIuNC0zMC43LTYyLjYtNTQuNi03Ni40Yy04LjYtNS0xNi44LTcuMy0yNC4zLTcuMwoJCQkJYy01LjcsMC0xMC45LDEuNC0xNS42LDRsNjUuOC0zOGwwLjEsMC4xYzEwLjctNS45LDI0LjQtNS4yLDM5LjUsMy41QzIzNS4xLDY5LjEsMjQ4LDgyLjcsMjU4LjMsOTkuMnoiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xOTYuOSw1My43YzcuNCwwLDE1LjYsMi40LDI0LjMsNy4zYzE0LDguMSwyNi44LDIxLjcsMzcuMSwzOC4yYzEyLjQsMTkuOSwyMS4xLDQzLjgsMjMuNCw2Ni44CgkJCQljNS43LDAuNCwxMS45LDIuNCwxOC40LDYuMWwwLjIsMC4xYzguMiw0LjcsMTYsMTEuOCwyMi45LDIwLjNjMTIuNiwxNS42LDIyLjMsMzYuMSwyNi40LDU2LjdsMS41LDAuOAoJCQkJYzE5LjUsMTEuMiwzNi4zLDM1LjksNDQuNSw2Mi4zYzAsMCwwLDAsMCwwLjFjMC4zLDAuOSwwLjUsMS43LDAuOCwyLjZjMCwwLjEsMC4xLDAuMywwLjEsMC40YzAuNywyLjUsMS4zLDQuOSwxLjksNy40CgkJCQljMC4xLDAuMywwLjEsMC41LDAuMiwwLjhjMC4xLDAuNiwwLjMsMS4zLDAuNCwxLjljMC4xLDAuNCwwLjIsMC45LDAuMiwxLjNjMC4xLDAuNiwwLjIsMS4yLDAuMywxLjhjMC4xLDAuNSwwLjIsMSwwLjIsMS42CgkJCQljMC4xLDAuNSwwLjIsMSwwLjIsMS41YzAuMSwxLDAuMywyLDAuNCwyLjljMCwwLjQsMC4xLDAuOCwwLjEsMS4yYzAuMSwwLjcsMC4xLDEuMywwLjIsMmMwLDAuNCwwLjEsMC45LDAuMSwxLjMKCQkJCWMwLDAuNywwLjEsMS4zLDAuMSwyYzAsMC40LDAsMC44LDAuMSwxLjJjMCwwLjksMCwxLjgsMC4xLDIuOGMwLDAuMSwwLDAuMiwwLDAuM2MwLDEyLjMtMi41LDIyLjMtNi44LDI5LjcKCQkJCWMtMi42LDQuNC01LjksNy45LTkuNywxMC4ybDAsMGwwLDBsMCwwYy0wLjIsMC4xLTAuNCwwLjItMC42LDAuM2MtMC4zLDAuMi0wLjUsMC4zLTAuOCwwLjRsLTY0LjYsMzcuMmMtMC4yLDAuMS0wLjQsMC4yLTAuNiwwLjQKCQkJCWwwLDBoLTAuMWwwLDBjLTMuNywyLjEtNy45LDMuMi0xMi41LDMuMmMtNi4xLDAtMTIuOC0xLjktMTkuOC02bC0xODItMTA1Yy0yNy41LTE1LjktNDkuOS01OC41LTQ5LjktOTUuMwoJCQkJYzAtMjcuMywxMi40LTQzLjcsMzAuMS00My43YzQuOSwwLDEwLjIsMS4zLDE1LjgsMy45Yy0zLjMtMTEuOS01LjEtMjQtNS4xLTM1LjdjMC0yNC44LDguMy00Mi4yLDIxLjQtNDkuNmgtMC4xbDY1LjktMzgKCQkJCUMxODYuMiw1NSwxOTEuMyw1My43LDE5Ni45LDUzLjcgTTE5Ni45LDQ0LjdjLTcuMSwwLTEzLjcsMS43LTE5LjYsNWgtMC4xaC0wLjFsLTY1LjksMzhsMCwwQzk0LjgsOTcsODUuNCwxMTcuOSw4NS40LDE0NQoJCQkJYzAsNy40LDAuNywxNS4xLDIsMjNjLTEuMy0wLjEtMi41LTAuMi0zLjctMC4yYy0xMS43LDAtMjIsNS41LTI5LDE1LjRjLTYuNiw5LjMtMTAuMSwyMi4yLTEwLjEsMzcuM2MwLDE5LjIsNS41LDQwLjQsMTUuNiw1OS42CgkJCQljMTAuMiwxOS41LDI0LDM0LjksMzguNyw0My40bDE4MiwxMDUuMWM4LjIsNC44LDE2LjQsNy4yLDI0LjMsNy4yYzYuMSwwLDExLjctMS40LDE2LjgtNC4zbDAsMGgwLjFsLTAuMi0wLjNsMC4yLDAuMwoJCQkJYzAuMi0wLjEsMC41LTAuMywwLjctMC40bDY0LjQtMzcuMWMwLjItMC4xLDAuNS0wLjMsMC43LTAuNGwwLjItMC4xaDAuMWMwLjEtMC4xLDAuMy0wLjIsMC40LTAuMmw0LjYtMi41VjM5MAoJCQkJYzMuMi0yLjgsNi4xLTYuMiw4LjQtMTAuMmM1LjMtOSw4LjEtMjAuOCw4LjEtMzQuM2MwLTAuMiwwLTAuMywwLTAuNWMwLTAuOSwwLTEuOS0wLjEtMi45YzAtMC4zLDAtMC42LDAtMXYtMC4zCgkJCQljMC0wLjctMC4xLTEuNC0wLjEtMi4yYzAtMC41LTAuMS0xLTAuMS0xLjRjLTAuMS0wLjctMC4xLTEuNC0wLjItMi4ydi0wLjRjMC0wLjMtMC4xLTAuNi0wLjEtMC45Yy0wLjEtMS4yLTAuMy0yLjItMC40LTMuMgoJCQkJYy0wLjEtMC40LTAuMS0wLjgtMC4yLTEuMmwtMC4xLTAuNGMtMC4xLTAuNi0wLjItMS4xLTAuMy0xLjdjLTAuMS0wLjctMC4yLTEuMy0wLjMtMS45Yy0wLjEtMC40LTAuMi0wLjktMC4zLTEuMwoJCQkJYy0wLjEtMC43LTAuMy0xLjQtMC40LTIuMWMwLTAuMi0wLjEtMC40LTAuMS0wLjZsLTAuMS0wLjJjLTAuNi0yLjYtMS4yLTUuMy0yLTcuOWwtMC4xLTAuMmwtMC4xLTAuMmMtMC4zLTEtMC41LTEuOS0wLjgtMi44CgkJCQlsLTAuMS0wLjJsMCwwYy04LjktMjguNi0yNi42LTUzLjYtNDYuNi02Ni4xYy00LjgtMjAuNC0xNC43LTQwLjgtMjcuNC01Ni42Yy03LjgtOS43LTE2LjMtMTcuMi0yNS4zLTIyLjRsLTAuMS0wLjFoLTAuMWgtMC4xCgkJCQljLTUtMi45LTkuOS00LjktMTQuOC02LjFjLTMuMi0yMS44LTExLjYtNDQuMS0yMy45LTYzLjdDMjU0LDc2LjQsMjQwLDYyLjEsMjI1LjIsNTMuNkMyMTUuOCw0Ny41LDIwNi4xLDQ0LjcsMTk2LjksNDQuNwoJCQkJTDE5Ni45LDQ0Ljd6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjg1LjUsMjg3LjhMMjg0LDI4N2MtNi41LTMxLjgtMjYuMS02My42LTQ5LjMtNzdsLTAuMi0wLjFjLTYuNS0zLjctMTIuNy01LjctMTguNC02LjEKCQkJCWMtNC4yLTQyLTI5LjctODcuMi02MC41LTEwNUMxMjEuOCw3OS4zLDk0LjQsMTAwLDk0LjQsMTQ1YzAsMTEuNiwxLjgsMjMuOCw1LjEsMzUuN2MtMjUuNy0xMi4xLTQ1LjksNC44LTQ1LjksMzkuOAoJCQkJYzAsMzYuNywyMi4zLDc5LjQsNDkuOSw5NS4zbDE4MiwxMDUuMWMyNy41LDE1LjksNDkuOS0xLDQ5LjktMzcuN1MzMTMsMzAzLjcsMjg1LjUsMjg3Ljh6Ii8+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zMDUuMyw0MjguN0wzMDUuMyw0MjguN2MtNi42LDAtMTMuNi0yLjEtMjAuOC02LjJsLTE4Mi0xMDUuMWMtMjgtMTYuMi01MC45LTU5LjctNTAuOS05NwoJCQkJYzAtMjcuNywxMi42LTQ1LjcsMzIuMS00NS43YzQuMSwwLDguNCwwLjgsMTIuOSwyLjVjLTIuOC0xMS4xLTQuMi0yMS45LTQuMi0zMi4zYzAtMzMuNywxNS4zLTU1LjYsMzguOS01NS42CgkJCQljOCwwLDE2LjUsMi42LDI1LjMsNy42YzMwLjQsMTcuNSw1Ni42LDYyLjQsNjEuMywxMDQuOWM1LjcsMC43LDExLjYsMi44LDE3LjYsNi4ybDAuMiwwLjFjMjMsMTMuMyw0My4xLDQ0LjMsNTAuMSw3Ny40bDAuNywwLjQKCQkJCWMyOCwxNi4yLDUwLjksNTkuNyw1MC45LDk3YzAsMTMuNi0zLDI1LjEtOC44LDMzLjJDMzIyLjgsNDI0LjQsMzE0LjgsNDI4LjcsMzA1LjMsNDI4Ljd6IE04My43LDE3OC44Yy0xNywwLTI4LjEsMTYuNC0yOC4xLDQxLjcKCQkJCWMwLDM2LDIxLjksNzgsNDguOSw5My41bDE4MiwxMDUuMWM2LjYsMy44LDEyLjksNS43LDE4LjgsNS43bDAsMGM4LjEsMCwxNS4xLTMuNywyMC0xMC43YzUuMy03LjQsOC0xOC4xLDgtMzAuOQoJCQkJYzAtMzYtMjEuOS03OC00OC45LTkzLjVsLTIuMi0xLjNsLTAuMi0wLjljLTYuNS0zMS45LTI2LjQtNjMtNDguMy03NS43bC0wLjItMC4xYy02LjEtMy41LTEyLTUuNS0xNy41LTUuOGwtMS43LTAuMWwtMC4yLTEuNwoJCQkJYy00LjItNDEuOC0yOS44LTg2LjItNTkuNS0xMDMuNGMtOC4xLTQuNy0xNi03LjEtMjMuMy03LjFjLTIxLjIsMC0zNC45LDIwLjItMzQuOSw1MS42YzAsMTEuMiwxLjcsMjMsNS4xLDM1LjFsMS4yLDQuMmwtMy45LTEuOQoJCQkJQzkzLjUsMTgwLDg4LjUsMTc4LjgsODMuNywxNzguOHoiLz4KCQk8L2c+CgkJPGc+CgkJCTxsaW5lIGZpbGw9Im5vbmUiIHgxPSIyODEuNiIgeTE9IjE2NiIgeDI9IjIxNi4xIiB5Mj0iMjAzLjciLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTIxNi4xLDIwNS43Yy0wLjcsMC0xLjQtMC40LTEuNy0xYy0wLjYtMS0wLjItMi4yLDAuNy0yLjdsNjUuNi0zNy44YzEtMC42LDIuMi0wLjIsMi43LDAuNwoJCQkJYzAuNiwxLDAuMiwyLjItMC43LDIuN2wtNjUuNiwzNy44QzIxNi44LDIwNS43LDIxNi40LDIwNS43LDIxNi4xLDIwNS43eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPGxpbmUgZmlsbD0ibm9uZSIgeDE9IjI4NCIgeTE9IjI4NyIgeDI9IjM0OS42IiB5Mj0iMjQ5LjIiLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTI4NCwyODljLTAuNywwLTEuNC0wLjQtMS43LTFjLTAuNi0xLTAuMi0yLjIsMC43LTIuN2w2NS42LTM3LjhjMS0wLjYsMi4yLTAuMiwyLjcsMC43CgkJCQljMC42LDEsMC4yLDIuMi0wLjcsMi43TDI4NSwyODguN0MyODQuNywyODguOSwyODQuNCwyODksMjg0LDI4OXoiLz4KCQk8L2c+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "cronjob", "name": "cronjob", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI0NDYuNnB4IiBoZWlnaHQ9IjQ3MC43cHgiIHZpZXdCb3g9IjAgMCA0NDYuNiA0NzAuNyIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNDQ2LjYgNDcwLjciIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMl8xXyI+CjwvZz4KPGcgaWQ9IkxheWVyXzFfMl8iPgoJPGcgaWQ9IkxheWVyXzFfMV8iIGRpc3BsYXk9Im5vbmUiPgoJCTxwb2x5Z29uIGRpc3BsYXk9ImlubGluZSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQTdBOUFDIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI0MjkuOSwzNjIuMSAKCQkJMjE0LjUsNDg2LjUgLTAuOSwzNjIuMSAtMC45LDExMy4zIDIxNC41LC0xMSA0MjkuOSwxMTMuMyAJCSIvPgoJPC9nPgoJPHBhdGggZmlsbD0iI0YwRjdGRiIgZD0iTTIxOS4xLDM2MC41YzkuNiwwLDE5LjMsMi40LDI2LjcsNi43YzEuNCwwLjgsMi42LDEuNiwzLjgsMi41YzMuNS0yLjYsNS4zLTUuNyw1LjItOS4yCgkJYy0wLjEtNC44LTQtOS42LTEwLjUtMTMuNGMtMS40LTAuOC0yLjgtMS41LTQuNC0yLjJjLTYuMi0yLjYtMTMuNi00LjEtMjAuOC00LjFjLTcuNSwwLTE0LjMsMS42LTE5LjIsNC40CgkJYy00LjQsMi41LTYuOSw1LjgtNy4yLDkuNHMxLjYsNy40LDUuMywxMC44YzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4yYzIuNy0xLjUsNS44LTIuNyw5LjQtMy42QzIxMS4zLDM2MC45LDIxNS4xLDM2MC41LDIxOS4xLDM2MC41CgkJeiIvPgoJPHBhdGggZmlsbD0iI0YwRjdGRiIgZD0iTTMwMy41LDI2Ny40YzUuNywwLDExLjUsMS41LDE1LjksNGMwLjcsMC40LDEuMywwLjgsMS45LDEuMmMxLjYtMS4zLDIuNS0yLjksMi40LTQuNgoJCWMtMC4xLTIuNi0yLjItNS4yLTUuOC03LjNjLTAuOC0wLjUtMS42LTAuOS0yLjUtMS4yYy0zLjUtMS41LTcuOC0yLjMtMTEuOS0yLjNjLTQuMywwLTguMSwwLjktMTAuOSwyLjVjLTIuNCwxLjQtMy43LDMuMS0zLjksNQoJCWMtMC4xLDEuOCwwLjgsMy43LDIuNiw1LjVjMS42LTAuOSwzLjQtMS42LDUuNC0yQzI5OC44LDI2Ny43LDMwMS4xLDI2Ny40LDMwMy41LDI2Ny40eiIvPgoJPGcgaWQ9IkxheWVyXzJfMl8iPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTgxLjksMTcuN2M4LjMsMCwxNiwxLjksMjIuOSw1LjRsMCwwYzEuNCwwLjcsMi44LDEuNSw0LjEsMi40bDI0LjIsMTQuMWwwLDBsMCwwbDAsMAoJCQkJYzIyLjMsMTAuNywzNi41LDM4LjgsMzYuNSw3OS43YzAsNTguNS0yOS4zLDEyNC43LTY5LjQsMTYzLjVsMzEsMS4zbDE1LjgsMjMuMWMzLjIsMC43LDYuMywxLjUsOS40LDIuNGwtNy4zLTQuMnYtMTMuMmwwLDBsMCwwCgkJCQlsMy4yLTQuMmwwLDBsMCwwbC0yMS4zLTVsLTEuNC0xMS4xdi0xMy4ybDAsMGwwLDBsMTcuOC0yLjdsMCwwbC00LjktNS4xdi0xMy4ybDAsMGwwLDBsMTIuNi03LjNsMjIuMyw3LjEKCQkJCWMyLjYtMC43LDUuNC0xLjMsOC4yLTEuN2w2LTEyLjlsMTkuMiwwLjhsOS4zLDEzLjVjMywwLjcsNS45LDEuNSw4LjcsMi40bDIwLjgtNS4zbDE0LjYsOC40bDAsMGwwLDB2MTMuMmwtMy4yLDQuMmwyMS41LDQuOQoJCQkJbDEuNCwxMS4xdjEzLjJsLTE3LjgsMi43bDQuOSw1LjF2MTMuMmwtMTIuNiw3LjNsMCwwbDAsMGwwLDBsLTIyLjMtNy4xYy0yLjYsMC43LTUuNCwxLjMtOC4yLDEuN2wtNS4zLDExLjR2MTUuNWwtNS42LDcuMgoJCQkJbDM2LjgsOC40bDIuNCwxOXYyMi43bC0zMC40LDQuN2w4LjMsOC43bDAsMGwwLDB2MjIuN0wzMTIuNSw0NDVsMCwwbDAsMGwwLDBsLTM4LjItMTIuMmMtNC41LDEuMi05LjIsMi4yLTE0LDIuOWwtMTAuMiwyMgoJCQkJbC0zMi45LTEuNGwtMTUuOC0yMy4xYy01LjEtMS4xLTEwLTIuNS0xNC45LTQuMWwtMzUuNSw5LjFsMCwwbDAsMGwtMjQuOS0xNC40di0yMi43bDAsMGwwLDBsNS42LTcuMmwwLDBsMCwwbC0zNi44LTguNGwtMi40LTE5CgkJCQlWMzQ0djAuMVYzNDRsMzAuNC00LjdsMCwwbDAsMGwtOC4zLTguN3YtMTkuOGMtMi0wLjktMy45LTEuOS01LjctMy4xTDgzLDI5Mi42Yy0xLjUtMC44LTMtMS42LTQuNC0yLjZsMCwwbDAsMAoJCQkJYy0xOS0xMi41LTMxLTM5LjItMzEtNzYuNWMwLTQ5LDIwLjYtMTAzLjQsNTAuOC0xNDIuNmMxMS44LTE1LjMsMjUuMi0yOC4zLDM5LjMtMzcuN2MyLjEtMS40LDQuMi0yLjcsNi4zLTMuOQoJCQkJQzE1Ny4yLDIxLjQsMTcwLjIsMTcuNywxODEuOSwxNy43IE0xODEuOSw5LjdjLTEzLjUsMC0yNy43LDQuMy00Mi4zLDEyLjZjLTIuMywxLjMtNC41LDIuNy02LjcsNC4yYy0xNC42LDkuNy0yOC41LDIzLTQxLjIsMzkuNQoJCQkJYy0zMi4zLDQxLjktNTIuNCw5OC40LTUyLjQsMTQ3LjVjMCwxOS4zLDMsMzYuMyw5LDUwLjZjNS45LDE0LjMsMTQuNSwyNS4yLDI1LjQsMzIuNGMwLjIsMC4xLDAuNCwwLjIsMC41LDAuNAoJCQkJYzEuNSwxLDMuMSwxLjksNC43LDIuN2wyNS43LDE1YzAuNiwwLjQsMS4yLDAuOCwxLjksMS4xdjE0LjljMCwxLjEsMC4yLDIuMSwwLjYsMy4xbC0xNS43LDIuNGMtMy45LDAuNS03LDMuOS03LDcuOXYyMi43CgkJCQljMCwwLjMsMCwwLjcsMC4xLDFsMi40LDE5YzAuNCwzLjMsMi45LDYuMSw2LjIsNi44bDI1LjMsNS44Yy0wLjIsMC43LTAuMywxLjMtMC4zLDJWNDI0YzAsMi45LDEuNSw1LjUsNCw2LjlsMjQuOSwxNC40CgkJCQljMS4yLDAuNywyLjYsMS4xLDQsMS4xYzAuMiwwLDAuNCwwLDAuNiwwYzAuNSwwLDAuOS0wLjEsMS40LTAuMmwzMy4zLTguNmMzLjQsMSw2LjgsMiwxMC4zLDIuOGwxNC4xLDIwLjYKCQkJCWMxLjQsMi4xLDMuNywzLjQsNi4zLDMuNWwzMi45LDEuNGMwLjEsMCwwLjIsMCwwLjMsMGMzLjEsMCw1LjktMS44LDcuMy00LjZsOC40LTE4LjJjMi44LTAuNSw1LjYtMS4xLDguMy0xLjhsMzUuOCwxMS40CgkJCQljMC44LDAuMywxLjcsMC40LDIuNiwwLjRjMC4yLDAsMC40LDAsMC42LDBjMS4yLTAuMSwyLjMtMC40LDMuNC0xbDIxLjYtMTIuNWMyLjUtMS40LDQtNC4xLDQtNi45VjQxMGMwLTEuMS0wLjItMi4xLTAuNi0zLjEKCQkJCWwxNS45LTIuNWMzLjktMC42LDYuOC00LDYuOC03Ljl2LTIyLjdjMC0wLjMsMC0wLjctMC4xLTFsLTIuNC0xOWMtMC40LTMuMy0yLjktNi4xLTYuMi02LjhsLTI1LjMtNS44YzAuMi0wLjcsMC4zLTEuMywwLjMtMgoJCQkJdi0xMy44bDIuOC02YzAuOC0wLjIsMS42LTAuMywyLjQtMC41bDIwLDYuNGMwLjgsMC4zLDEuNywwLjQsMi42LDAuNGMwLjIsMCwwLjUsMCwwLjcsMGwwLDBsMCwwYzEuMS0wLjEsMi4zLTAuNSwzLjMtMWwxMi42LTcuMwoJCQkJYzIuNS0xLjQsNC00LjEsNC02Ljl2LTEyLjJsNi4xLTAuOWMzLjktMC42LDYuOC00LDYuOC03Ljl2LTEzLjJjMC0wLjMsMC0wLjctMC4xLTFsLTEuNC0xMS4xYy0wLjQtMy4zLTIuOS02LjEtNi4yLTYuOAoJCQkJbC0xMi4xLTIuOHYtMTEuOGMwLTIuOS0xLjYtNS42LTQuMS03bC0xNC41LTguNGMtMS4yLTAuNy0yLjYtMS4xLTQtMS4xYy0wLjcsMC0xLjMsMC4xLTIsMC4zbC0xOC42LDQuOAoJCQkJYy0xLjMtMC40LTIuNy0wLjgtNC4xLTEuMWwtNy41LTExYy0xLjQtMi4xLTMuNy0zLjQtNi4zLTMuNWwtMTkuMi0wLjhjLTAuMSwwLTAuMiwwLTAuMywwYy0zLjEsMC01LjksMS44LTcuMyw0LjZsLTQuMiw5LjEKCQkJCWMtMC44LDAuMi0xLjYsMC4zLTIuNSwwLjVsLTIwLjItNi40Yy0wLjgtMC4zLTEuNi0wLjQtMi40LTAuNGMtMSwwLTIsMC4yLTIuOSwwLjZjMS44LTMuNywzLjUtNy41LDUuMi0xMS4zCgkJCQljMTMuMy0zMC45LDIwLjMtNjIuOCwyMC4zLTkyLjNjMC00Mi41LTE0LjgtNzQtNDAuNy04Ni43bC0yMy43LTEzLjljLTEuNS0wLjktMy0xLjgtNC41LTIuNmMtMC4xLDAtMC4xLTAuMS0wLjItMC4xCgkJCQlDMjAwLjQsMTEuOCwxOTEuNCw5LjcsMTgxLjksOS43eiBNMjE4LDI3NS41YzEuMi0xLjQsMi40LTIuOCwzLjYtNC4zdjAuN2MwLDAuMywwLDAuNywwLjEsMWwwLjMsMi44TDIxOCwyNzUuNUwyMTgsMjc1LjV6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiNDREQ5RUUiIGQ9Ik0yMzIuNiwzOC45Yy0xNi41LTcuOS0zNy4zLTYuMy01OS45LDYuOGMtMi4xLDEuMi00LjEsMi41LTYuMiwzLjlDMTUyLjMsNTksMTM5LDcyLDEyNy4xLDg3LjMKCQkJCQljLTIzLjUsMzAuNC00MS4xLDY5LjktNDcuOSwxMDljLTIsMTEuMy0zLDIyLjYtMywzMy42YzAsMTguOSwzLjEsMzUuMSw4LjYsNDguMXMxMy41LDIyLjgsMjMuMiwyOC45bC0yNi0xNS4xCgkJCQkJYy0xLjUtMC44LTMtMS42LTQuNC0yLjZsMCwwYy05LjUtNi4yLTE3LjMtMTYtMjIuNi0yOWMtNS40LTEyLjktOC40LTI4LjktOC40LTQ3LjZjMC0xMi4zLDEuMy0yNC45LDMuNy0zNy41CgkJCQkJQzU3LjUsMTM3LjMsNzQuNyw5OS40LDk3LjQsNzBjMTEuOS0xNS4zLDI1LjItMjguMywzOS4zLTM3LjdjMi4xLTEuNCw0LjItMi43LDYuMy0zLjljMTMuNi03LjksMjYuNS0xMS42LDM4LjMtMTEuNgoJCQkJCWM4LjMsMCwxNiwxLjksMjIuOSw1LjRsMCwwYzEuNCwwLjcsMi44LDEuNSw0LjEsMi40TDIzMi42LDM4Ljl6Ii8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjRjBGN0ZGIiBkPSJNMjMyLjYsMzguOWMtMTYuNS03LjktMzcuMy02LjMtNTkuOSw2LjhjLTEuNywxLTMuNCwyLTUsMy4xYy0wLjQsMC4yLTAuOCwwLjUtMS4yLDAuOAoJCQkJCUMxNTIuMyw1OSwxMzksNzIsMTI3LjEsODcuM2MtMjMuNSwzMC40LTQxLjEsNjkuOS00Ny45LDEwOWMtMTAuMy02LTIwLTEzLjEtMjguOS0yMWM3LjItMzcuOCwyNC40LTc1LjcsNDcuMS0xMDUuMQoJCQkJCWMxMS45LTE1LjMsMjUuMi0yOC4zLDM5LjMtMzcuN2MwLjktMC42LDEuNy0xLjEsMi42LTEuN2MxLjItMC44LDIuNS0xLjUsMy43LTIuMmMxMy42LTcuOSwyNi41LTExLjYsMzguMy0xMS42CgkJCQkJYzguMywwLDE2LDEuOSwyMi45LDUuNGwwLDBjMS40LDAuNywyLjgsMS41LDQuMSwyLjRMMjMyLjYsMzguOXoiLz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiNDREQ5RUUiIGQ9Ik0yMzIuNiwzOC45Yy0xNi41LTcuOS0zNy4zLTYuMy01OS45LDYuOGMtMS43LDEtMy40LDItNSwzLjFjLTEwLTUuMi0xOS40LTExLjItMjguMi0xOAoJCQkJCWMxLjItMC44LDIuNS0xLjUsMy43LTIuMmMxMy42LTcuOSwyNi41LTExLjYsMzguMy0xMS42YzguMywwLDE2LDEuOSwyMi45LDUuNGwwLDBjMS40LDAuNywyLjgsMS41LDQuMSwyLjRMMjMyLjYsMzguOXoiLz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxnPgoJCQkJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik03Ni4zLDIyOS45YzAsNzEsNDMuMiwxMDMuNiw5Ni40LDcyLjkKCQkJCQkJczk2LjQtMTEzLjIsOTYuNC0xODQuMlMyMjUuOSwxNSwxNzIuNyw0NS43Uzc2LjMsMTU4LjksNzYuMywyMjkuOXoiLz4KCQkJCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTM0LjQsMzE1LjhMMTM0LjQsMzE1LjgKCQkJCQkJYy0zNi4yLDAtNTkuNi0zMy43LTU5LjYtODZjMC03MS40LDQzLjYtMTU0LjYsOTcuMi0xODUuNWMxMy41LTcuOCwyNi43LTExLjgsMzktMTEuOGMxNy42LDAsMzIuNSw4LDQzLjEsMjMKCQkJCQkJYzEwLjgsMTUuMywxNi41LDM3LDE2LjUsNjIuOWMwLDcxLjQtNDMuNiwxNTQuNi05Ny4yLDE4NS41QzE1OS45LDMxMS45LDE0Ni44LDMxNS44LDEzNC40LDMxNS44eiIvPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPHBhdGggZmlsbD0iIzNFNjM4NyIgZD0iTTI1NS43LDEyNi4zYzAsNjEuMi0zNy4yLDEzMi4yLTgzLDE1OC43Yy0xMy40LDcuNy0yNiwxMC44LTM3LjEsOS44Yy0yNy4yLTIuNS00NS45LTI5LjItNDUuOS03Mi42CgkJCQkJCWMwLTYxLjEsMzcuMi0xMzIuMiw4My0xNTguN2MxMC4xLTUuOCwxOS43LTksMjguNy05LjhDMjMzLjEsNTEsMjU1LjcsNzguNiwyNTUuNywxMjYuM3oiLz4KCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTM5LjcsMjk2LjRjLTEuNCwwLTIuOS0wLjEtNC4zLTAuMmMtMTQtMS4zLTI1LjktOC44LTM0LjMtMjEuNmMtOC41LTEzLjEtMTMtMzEuMi0xMy01Mi40CgkJCQkJCWMwLTYxLjUsMzcuNi0xMzMuMyw4My44LTE2MGMxMC4xLTUuOCwxOS45LTkuMiwyOS4zLTEwYzEuNS0wLjEsMy0wLjIsNC40LTAuMmMzMS4zLDAsNTEuNiwyOS4xLDUxLjYsNzQuMgoJCQkJCQljMCw2MS41LTM3LjYsMTMzLjMtODMuOCwxNjBDMTYxLjgsMjkzLDE1MC40LDI5Ni40LDEzOS43LDI5Ni40eiBNMjA1LjcsNTVjLTEuNCwwLTIuOCwwLjEtNC4xLDAuMmMtOC45LDAuOC0xOC40LDQtMjguMSw5LjYKCQkJCQkJQzEyOC4xLDkxLDkxLjIsMTYxLjYsOTEuMiwyMjIuMmMwLDQxLjMsMTcuMSw2OC42LDQ0LjUsNzEuMWMxLjMsMC4xLDIuNywwLjIsNCwwLjJjMTAuMiwwLDIxLTMuMywzMi4yLTkuOAoJCQkJCQljNDUuNC0yNi4yLDgyLjMtOTYuOCw4Mi4zLTE1Ny40QzI1NC4yLDgzLDIzNS4yLDU1LDIwNS43LDU1eiIvPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiNFNkU3RTgiIGQ9Ik0yNDcuMywxMjYuM2MwLDYxLjItMzcuMiwxMzIuMi04MywxNTguN2MtMTAuMSw1LjgtMTkuNyw5LTI4LjcsOS44Yy0yNy4yLTIuNS00NS45LTI5LjItNDUuOS03Mi42CgkJCQkJCQljMC02MS4xLDM3LjItMTMyLjIsODMtMTU4LjdjMTAuMS01LjgsMTkuNy05LDI4LjctOS44QzIyOC42LDU2LjIsMjQ3LjMsODIuOSwyNDcuMywxMjYuM3oiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xMzUuNiwyOTYuMmgtMC4xYy0xNC0xLjMtMjUuOS04LjgtMzQuMy0yMS42Yy04LjUtMTMuMS0xMy0zMS4yLTEzLTUyLjRjMC02MS41LDM3LjYtMTMzLjMsODMuOC0xNjAKCQkJCQkJCWMxMC4xLTUuOCwxOS45LTkuMiwyOS4zLTEwaDAuMWgwLjFjMTQsMS4zLDI1LjksOC44LDM0LjMsMjEuNmM4LjUsMTMuMSwxMywzMS4yLDEzLDUyLjRjMCw2MS41LTM3LjYsMTMzLjMtODMuOCwxNjAKCQkJCQkJCWMtMTAuMSw1LjgtMTkuOSw5LjItMjkuMywxMEgxMzUuNnogTTIwMS40LDU1LjJjLTguOSwwLjgtMTguMyw0LTI3LjksOS42QzEyOC4xLDkxLDkxLjIsMTYxLjYsOTEuMiwyMjIuMgoJCQkJCQkJYzAsNDEuMywxNyw2OC41LDQ0LjQsNzEuMWM4LjktMC44LDE4LjMtNCwyNy45LTkuNmM0NS40LTI2LjIsODIuMy05Ni44LDgyLjMtMTU3LjRDMjQ1LjgsODUsMjI4LjgsNTcuOCwyMDEuNCw1NS4yeiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cG9seWdvbiBmaWxsPSIjMjMxRjIwIiBwb2ludHM9IjE3OCwxNzMuMSAxODYuOCwxNjggMTg2LjgsNzMuNCAxNzgsNzguNSAJCQkJCQkiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xNzYuNSwxNzUuN1Y3Ny42bDExLjgtNi44djk4LjFMMTc2LjUsMTc1Ljd6IE0xNzkuNSw3OS40djkxLjFsNS44LTMuNFY3NkwxNzkuNSw3OS40eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cG9seWdvbiBmaWxsPSIjMjMxRjIwIiBwb2ludHM9IjE4Mi41LDE2MC4yIDE4NC41LDE2Ny45IDEzMiwyMjAuNCAxMzAsMjEyLjcgCQkJCQkJIi8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTMxLjMsMjIzLjNsLTMtMTEuMWw1NS01NWwzLDExLjFMMTMxLjMsMjIzLjN6IE0xMzEuNiwyMTMuMWwxLjIsNC40bDUwLjEtNTAuMWwtMS4yLTQuNEwxMzEuNiwyMTMuMQoJCQkJCQkJeiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xMzMuOCwzMTUuOEwxMzMuOCwzMTUuOGMtOS45LDAtMTkuMS0yLjYtMjcuMS03LjZsLTI1LjktMTUuMWMtMS41LTAuNy0zLTEuNi00LjQtMi42bC0wLjgtMC40VjI5MAoJCQkJCWMtMTkuNy0xMy40LTMxLTQxLjUtMzEtNzcuM2MwLTQ3LjcsMTkuNS0xMDIuNyw1MS0xNDMuNWMxMi4zLTE2LDI1LjctMjguOCwzOS43LTM4LjFjMi4xLTEuNCw0LjItMi43LDYuNC00CgkJCQkJYzEzLjUtNy44LDI2LjctMTEuOCwzOS0xMS44YzguNSwwLDE2LjQsMS45LDIzLjYsNS42bDAuMSwwLjFjMS40LDAuNywyLjgsMS42LDQuMiwyLjRsMjQuMSwxNC4xYzIzLjcsMTEuNSwzNy4zLDQxLDM3LjMsODEKCQkJCQljMCw3MS40LTQzLjYsMTU0LjYtOTcuMiwxODUuNUMxNTkuMywzMTEuOSwxNDYuMSwzMTUuOCwxMzMuOCwzMTUuOHogTTc3LjksMjg4TDc3LjksMjg4YzEuNCwwLjksMi44LDEuNyw0LjMsMi41bDI2LDE1LjIKCQkJCQljNy42LDQuOCwxNi4yLDcuMiwyNS42LDcuMmwwLDBjMTEuOCwwLDI0LjUtMy44LDM3LjUtMTEuNEMyMjQsMjcxLDI2Ni45LDE4OSwyNjYuOSwxMTguNmMwLTM4LjktMTMtNjcuNC0zNS43LTc4LjNsMC40LTAuOAoJCQkJCWwtMC40LDAuN0wyMDcsMjYuMWMtMS40LTAuOS0yLjctMS43LTQuMS0yLjRsLTAuMS0wLjFjLTYuNy0zLjUtMTQuMi01LjItMjIuMS01LjJjLTExLjgsMC0yNC41LDMuOC0zNy41LDExLjQKCQkJCQljLTIuMSwxLjItNC4yLDIuNS02LjIsMy45Yy0xMy43LDkuMS0yNi45LDIxLjctMzksMzcuNGMtMzEuMSw0MC4zLTUwLjUsOTQuNi01MC41LDE0MS43QzQ3LjYsMjQ3LjksNTguNiwyNzUuMyw3Ny45LDI4OAoJCQkJCUw3Ny45LDI4OHoiLz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjQ0REOUVFIiBkPSJNMzYzLjYsMjQxLjlsLTE0LjYtOC40bC0yMC44LDUuM2MtMi44LTAuOS01LjgtMS43LTguNy0yLjRsLTkuMy0xMy41bC0xOS4yLTAuOGwtNiwxMi45CgkJCQkJCQljLTIuOCwwLjQtNS42LDEtOC4yLDEuN2wtMjIuMy03LjFsLTEyLjYsNy4zbDEyLjMsMTIuOWMtMS4yLDEuNS0yLjIsMy4xLTIuOSw0LjdsLTIyLjMsMy40bDEuNCwxMS4xbDIzLjQsNS4zCgkJCQkJCQljMS4xLDEuNywyLjUsMy40LDQuMSw1bC05LjIsMTJsMTQuNiw4LjRsMjAuOC01LjNjMi44LDAuOSw1LjgsMS43LDguNywyLjRsOS4zLDEzLjVsMTkuMiwwLjhsNi0xMi45YzIuOC0wLjQsNS42LTEsOC4yLTEuNwoJCQkJCQkJbDIyLjMsNy4xbDEyLjYtNy4zbC0xMi4zLTEyLjljMS4yLTEuNSwyLjItMy4xLDIuOS00LjdsMjIuMy0zLjVsLTEuNC0xMS4xbC0yMy40LTUuM2MtMS4xLTEuNy0yLjUtMy40LTQuMS01TDM2My42LDI0MS45egoJCQkJCQkJIE0zMjAuNSwyNzQuOWMtNi45LDQtMTguOSwzLjUtMjYuOC0xLjFzLTguOC0xMS41LTEuOS0xNS41czE4LjktMy41LDI2LjgsMS4xQzMyNi41LDI2NC4xLDMyNy40LDI3MSwzMjAuNSwyNzQuOXoiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zMjIuMiwzMTIuN2wtMjEtMC45bC05LjMtMTMuNmMtMi43LTAuNi01LjMtMS4zLTcuOS0yLjFsLTIwLjksNS40bC0xNi42LTkuNmw5LjUtMTIuMwoJCQkJCQkJYy0xLjEtMS4yLTIuMi0yLjUtMy4xLTMuOGwtMjMuOC01LjRsLTEuNy0xMy42bDIyLjktMy41YzAuNS0xLjEsMS4yLTIuMiwyLTMuM2wtMTIuOC0xMy4zbDE0LjgtOC42bDIyLjUsNy4yCgkJCQkJCQljMi4zLTAuNiw0LjctMS4xLDcuMi0xLjVsNi4xLTEzLjFsMjEsMC45bDkuMywxMy42YzIuNywwLjYsNS4zLDEuMyw3LjksMi4xbDIwLjktNS40bDE2LjYsOS42bC05LjUsMTIuMwoJCQkJCQkJYzEuMSwxLjIsMi4yLDIuNSwzLDMuOGwyMy45LDUuNGwxLjcsMTMuNmwtMjMsMy42Yy0wLjUsMS4xLTEuMiwyLjItMiwzLjNsMTIuOCwxMy40bC0xNC44LDguNmwtMjIuNS03LjIKCQkJCQkJCWMtMi4zLDAuNi00LjcsMS4xLTcuMSwxLjVMMzIyLjIsMzEyLjd6IE0zMDIuOSwzMDguOWwxNy41LDAuN2w1LjktMTIuN2wwLjgtMC4xYzIuOC0wLjQsNS41LTEsOC0xLjZsMC40LTAuMWwyMi4xLDdsMTAuNC02CgkJCQkJCQlsLTExLjktMTIuNWwwLjgtMWMxLjEtMS40LDItMi45LDIuNy00LjRsMC4zLTAuOGwyMS43LTMuNGwtMS4xLTguNmwtMjMtNS4ybC0wLjMtMC41Yy0xLjEtMS42LTIuNC0zLjMtMy45LTQuOGwtMC45LTAuOQoJCQkJCQkJbDktMTEuN2wtMTIuNS03LjJsLTIwLjYsNS4zbC0wLjQtMC4xYy0yLjgtMC45LTUuNy0xLjctOC42LTIuM2wtMC42LTAuMWwtOS4yLTEzLjRsLTE3LjUtMC43bC01LjksMTIuN2wtMC44LDAuMQoJCQkJCQkJYy0yLjgsMC40LTUuNSwxLTguMSwxLjZsLTAuNCwwLjFsLTIyLjEtN2wtMTAuNCw2bDExLjksMTIuNGwtMC44LDFjLTEuMSwxLjQtMiwyLjktMi43LDQuNGwtMC4zLDAuOGwtMjEuNywzLjRsMS4xLDguNgoJCQkJCQkJbDIzLDUuMmwwLjMsMC41YzEuMSwxLjYsMi40LDMuMywzLjksNC44bDAuOSwwLjlsLTksMTEuN2wxMi41LDcuMmwyMC42LTUuM2wwLjQsMC4xYzIuOCwwLjksNS43LDEuNyw4LjYsMi4zbDAuNiwwLjEKCQkJCQkJCUwzMDIuOSwzMDguOXogTTMwOC45LDI3OS4xYy01LjcsMC0xMS41LTEuNS0xNS45LTRjLTQuNi0yLjctNy4zLTYuMy03LjMtMTAuMWMwLTMuMiwxLjktNiw1LjQtOGMzLjItMS45LDcuNi0yLjksMTIuNC0yLjkKCQkJCQkJCWM1LjcsMCwxMS41LDEuNSwxNS45LDRjNC42LDIuNyw3LjMsNi4zLDcuMywxMGMwLDMuMi0xLjksNi01LjQsOEMzMTguMSwyNzguMSwzMTMuNywyNzkuMSwzMDguOSwyNzkuMXogTTMwMy41LDI1Ny4yCgkJCQkJCQljLTQuMywwLTguMSwwLjktMTAuOSwyLjVjLTIuNSwxLjUtMy45LDMuMy0zLjksNS40YzAsMi42LDIuMSw1LjMsNS44LDcuNWMzLjksMi4zLDkuMiwzLjYsMTQuNCwzLjZjNC4zLDAsOC4xLTAuOSwxMC45LTIuNQoJCQkJCQkJYzIuNS0xLjUsMy45LTMuMywzLjktNS40YzAtMi42LTIuMS01LjMtNS44LTcuNUMzMTMuOSwyNTguNSwzMDguNywyNTcuMiwzMDMuNSwyNTcuMnoiLz4KCQkJCQk8L2c+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjNEI2RTk4IiBkPSJNMjUxLjQsMjU0LjVjMC43LTEuNiwxLjctMy4yLDIuOS00LjdMMjQyLDIzNi45djEzLjJsNC45LDUuMUwyNTEuNCwyNTQuNXoiLz4KCQkJCQkJPC9nPgoJCQkJCQk8Zz4KCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNDYuMywyNTYuOGwtNS44LTYuMXYtMTcuNmwxNS44LDE2LjVsLTAuOCwxYy0xLjEsMS40LTIsMi45LTIuNyw0LjRsLTAuMywwLjhMMjQ2LjMsMjU2Ljh6CgkJCQkJCQkJIE0yNDMuNSwyNDkuNWwzLjksNC4xbDMtMC41YzAuNS0xLjEsMS4yLTIuMiwyLTMuM2wtOC45LTkuM1YyNDkuNXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjNEI2RTk4IiBkPSJNMzU2LjYsMjUxLjFsLTIuMiwyLjhjMS42LDEuNiwzLDMuMyw0LjEsNWwxLjksMC40bDMuMi00LjJ2LTEzLjJMMzU2LjYsMjUxLjFMMzU2LjYsMjUxLjF6Ii8+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzYxLDI2MWwtMy40LTAuOGwtMC4zLTAuNWMtMS4xLTEuNi0yLjQtMy4zLTMuOS00LjhsLTAuOS0wLjlsMTIuNy0xNi41djE4LjJMMzYxLDI2MXogTTM1OS40LDI1Ny42CgkJCQkJCQkJbDAuNCwwLjFsMi4zLTN2LTguM2wtNS44LDcuNUMzNTcuNSwyNTUsMzU4LjUsMjU2LjMsMzU5LjQsMjU3LjZ6Ii8+CgkJCQkJCTwvZz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTMyMS4yLDI3NC41Yy0wLjgtMC42LTEuNi0xLjItMi42LTEuOGMtNi4yLTMuNi0xNC44LTQuNi0yMS41LTMuMWM0LjktNS42LDExLjQtOS42LDE4LjktMTEuNQoJCQkJCQkJCWMwLjksMC40LDEuOCwwLjgsMi43LDEuM0MzMjYuMywyNjMuOSwzMjcuMywyNzAuNSwzMjEuMiwyNzQuNXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSJub25lIiBkPSJNMzIxLjIsMjc0LjVjLTAuOC0wLjYtMS42LTEuMi0yLjYtMS44Yy02LjItMy42LTE0LjgtNC42LTIxLjUtMy4xYy0xLjksMC40LTMuNywxLjEtNS4yLDIKCQkJCQkJCQljLTAuMiwwLjEtMC41LDAuMy0wLjcsMC40Yy01LjQtNC40LTUuMy0xMC4yLDAuNy0xMy43YzYuMS0zLjUsMTYuNC0zLjUsMjQuMS0wLjJjMC45LDAuNCwxLjgsMC44LDIuNywxLjMKCQkJCQkJCQlDMzI2LjMsMjYzLjksMzI3LjMsMjcwLjUsMzIxLjIsMjc0LjV6Ii8+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzIxLjEsMjc2LjRsLTAuOS0wLjdjLTAuNy0wLjYtMS41LTEuMS0yLjQtMS43Yy0zLjktMi4zLTkuMi0zLjYtMTQuNC0zLjZjLTIuMSwwLTQuMiwwLjItNi4xLDAuNgoJCQkJCQkJCWMtMS44LDAuNC0zLjUsMS00LjgsMS44Yy0wLjIsMC4xLTAuNCwwLjMtMC42LDAuNGwtMC45LDAuNmwtMC45LTAuN2MtMy4xLTIuNi00LjctNS43LTQuNS04LjdjMC4yLTIuOSwyLjEtNS41LDUuNC03LjQKCQkJCQkJCQljMy4yLTEuOSw3LjYtMi45LDEyLjQtMi45YzQuNSwwLDkuMiwwLjksMTMuMSwyLjZjMSwwLjQsMS45LDAuOSwyLjgsMS40YzQuNSwyLjYsNy4yLDYuMiw3LjMsOS44YzAuMSwzLTEuNSw1LjgtNC42LDcuOAoJCQkJCQkJCUwzMjEuMSwyNzYuNHogTTMwMy41LDI2Ny40YzUuNywwLDExLjUsMS41LDE1LjksNGMwLjcsMC40LDEuMywwLjgsMS45LDEuMmMxLjYtMS4zLDIuNS0yLjksMi40LTQuNgoJCQkJCQkJCWMtMC4xLTIuNi0yLjItNS4yLTUuOC03LjNjLTAuOC0wLjUtMS42LTAuOS0yLjUtMS4yYy0zLjUtMS41LTcuOC0yLjMtMTEuOS0yLjNjLTQuMywwLTguMSwwLjktMTAuOSwyLjUKCQkJCQkJCQljLTIuNCwxLjQtMy43LDMuMS0zLjksNWMtMC4xLDEuOCwwLjgsMy43LDIuNiw1LjVjMS42LTAuOSwzLjQtMS42LDUuNC0yQzI5OC44LDI2Ny43LDMwMS4xLDI2Ny40LDMwMy41LDI2Ny40eiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8Zz4KCQkJCQkJCTxwYXRoIGZpbGw9IiM0QjZFOTgiIGQ9Ik0zNjEsMjc4LjhjLTAuNywxLjYtMS43LDMuMi0yLjksNC43bDcuNSw3LjhsMTcuOC0yLjd2LTEzLjJMMzYxLDI3OC44eiIvPgoJCQkJCQk8L2c+CgkJCQkJCTxnPgoJCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTM2NSwyOTNsLTguOS05LjNsMC44LTFjMS4xLTEuNCwyLTIuOSwyLjctNC40bDAuMy0wLjhsMjQuOS0zLjhWMjkwTDM2NSwyOTN6IE0zNjAsMjgzLjRsNiw2LjMKCQkJCQkJCQlsMTUuNy0yLjR2LTEwLjJsLTE5LjgsMy4xQzM2MS40LDI4MS4zLDM2MC44LDI4Mi40LDM2MCwyODMuNHoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMzYyLjIsMzAxLjJsLTQuNSwyLjZsLTIyLjMtNy4xYy0yLjYsMC43LTUuNCwxLjMtOC4yLDEuN2wtNiwxMi45bC0xOS4yLTAuOGwtOS4zLTEzLjUKCQkJCQkJCQljLTMtMC43LTUuOS0xLjQtOC43LTIuNGwtMjAuOCw1LjNsLTcuNS00LjNsMCwwbC03LjEtNC4xdjEzLjJsMTQuNiw4LjRsMjAuOC01LjNjMi44LDAuOSw1LjgsMS43LDguNywyLjRsOS4zLDEzLjVsMTkuMiwwLjgKCQkJCQkJCQlsNi0xMi45YzIuOC0wLjQsNS42LTEsOC4yLTEuN2wyMi4zLDcuMWwxMi42LTcuM3YtMTMuMkwzNjIuMiwzMDEuMkwzNjIuMiwzMDEuMnoiLz4KCQkJCQkJPC9nPgoJCQkJCQk8Zz4KCQkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zMjIuMiwzMjUuOWwtMjEtMC45bC05LjMtMTMuNmMtMi43LTAuNi01LjMtMS4zLTcuOS0yLjFsLTIwLjksNS40bC0xNS44LTkuMXYtMTYuN2wxNi4zLDkuNAoJCQkJCQkJCWwyMC42LTUuM2wwLjQsMC4xYzIuOCwwLjksNS43LDEuNyw4LjYsMi4zbDAuNiwwLjFsOS4yLDEzLjRsMTcuNSwwLjdsNS45LTEyLjdsMC44LTAuMWMyLjgtMC40LDUuNS0xLDgtMS42bDAuNC0wLjFsMjIuMSw3CgkJCQkJCQkJbDE0LjMtOC4ydjE2LjdsLTEzLjksOGwtMjIuNS03LjJjLTIuMywwLjYtNC43LDEuMS03LjEsMS41TDMyMi4yLDMyNS45eiBNMzAyLjksMzIyLjFsMTcuNSwwLjdsNS45LTEyLjdsMC44LTAuMQoJCQkJCQkJCWMyLjgtMC40LDUuNS0xLDgtMS42bDAuNC0wLjFsMjIuMSw3bDExLjMtNi41VjI5OWwtMTAuOSw2LjNsLTIyLjUtNy4yYy0yLjMsMC42LTQuNywxLjEtNy4xLDEuNWwtNi4xLDEzLjFsLTIxLTAuOQoJCQkJCQkJCWwtOS4zLTEzLjZjLTIuNy0wLjYtNS4zLTEuMy03LjktMi4xbC0yMC45LDUuNGwtMTIuOC03LjR2OS44bDEzLjMsNy43bDIwLjYtNS4zbDAuNCwwLjFjMi44LDAuOSw1LjcsMS43LDguNiwyLjNsMC42LDAuMQoJCQkJCQkJCUwzMDIuOSwzMjIuMXoiLz4KCQkJCQkJPC9nPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjNEI2RTk4IiBkPSJNMjU4LDI3OS40Yy0xLjYtMS42LTMtMy4zLTQuMS01bC0yMy40LTUuM2wtMS40LTExLjF2MTMuMmwxLjQsMTEuMWwyMS41LDQuOUwyNTgsMjc5LjR6Ii8+CgkJCQkJCTwvZz4KCQkJCQkJPGc+CgkJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjUyLjYsMjg4LjlsLTIzLjUtNS40bC0xLjUtMTIuM1YyNThsMy0wLjJsMS4yLDEwbDIzLDUuMmwwLjMsMC41YzEuMSwxLjYsMi40LDMuMywzLjksNC44bDAuOSwwLjkKCQkJCQkJCQlMMjUyLjYsMjg4Ljl6IE0yMzEuOCwyODFsMTkuNiw0LjVsNC42LTZjLTEuMS0xLjItMi4yLTIuNS0zLjEtMy44bC0yMi40LTUuMXYwLjVMMjMxLjgsMjgxeiIvPgoJCQkJCQk8L2c+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMjQ4LjgsMjkxLjQgMjQ4LjgsMzA0LjYgMjYzLjMsMzEzIDI2My4zLDI5OS44IAkJCQkJCSIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTI2NC44LDMxNS42bC0xNy42LTEwLjF2LTE2LjdsMTcuNiwxMC4xVjMxNS42eiBNMjUwLjMsMzAzLjhsMTEuNiw2Ljd2LTkuOGwtMTEuNi02LjdMMjUwLjMsMzAzLjgKCQkJCQkJCUwyNTAuMywzMDMuOHoiLz4KCQkJCQk8L2c+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIzNTcuOCwzMDMuNyAzNTcuOCwzMTcgMzcwLjQsMzA5LjcgMzcwLjQsMjk2LjQgCQkJCQkJIi8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzU2LjMsMzE5LjZ2LTE2LjdsMTUuNi05djE2LjdMMzU2LjMsMzE5LjZ6IE0zNTkuMywzMDQuNnY5LjhsOS42LTUuNVYyOTlMMzU5LjMsMzA0LjZ6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iMzYzLjYsMjQxLjkgMzYzLjYsMjU1LjEgMzYwLjQsMjU5LjQgMzU4LjUsMjU4LjkgMzU0LjQsMjUzLjkgCQkJCQkJIi8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzYxLDI2MWwtMy4zLTAuOGwtNS4yLTYuNGwxMi42LTE2LjR2MTguMkwzNjEsMjYxeiBNMzU5LjMsMjU3LjZsMC40LDAuMWwyLjMtM3YtOC4zbC01LjgsNy42CgkJCQkJCQlMMzU5LjMsMjU3LjZ6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iMjQyLDIzNi45IDI0MiwyNTAuMSAyNDYuOCwyNTUuMiAyNTEuNCwyNTQuNSAyNTQuMywyNDkuNyAJCQkJCQkiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNDYuMywyNTYuOGwtNS44LTYuMXYtMTcuNmwxNS43LDE2LjRsLTMuOSw2LjNMMjQ2LjMsMjU2Ljh6IE0yNDMuNSwyNDkuNWwzLjksNC4xbDMuMS0wLjVsMS45LTMuMgoJCQkJCQkJbC04LjktOS4zVjI0OS41TDI0My41LDI0OS41eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjIyOS4xLDI1Ny45IDIyOS4xLDI3MS4yIDIzMC41LDI4Mi4zIDI1MiwyODcuMiAyNTgsMjc5LjQgMjUzLjksMjc0LjQgMjMwLjUsMjY5IAkJCQkJCSIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTI1Mi42LDI4OC45bC0yMy41LTUuNGwtMS41LTEyLjNWMjU4bDMtMC4ybDEuMiwxMGwyMi45LDUuMmw1LjIsNi40TDI1Mi42LDI4OC45eiBNMjMxLjgsMjgxCgkJCQkJCQlsMTkuNiw0LjVsNC43LTYuMWwtMy0zLjdsLTIyLjQtNS4xdjAuNUwyMzEuOCwyODF6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iMzgzLjMsMjc1LjQgMzgzLjMsMjg4LjYgMzY1LjUsMjkxLjQgMzU4LjEsMjgzLjYgMzYxLDI3OC44IAkJCQkJCSIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTM2NSwyOTNsLTguOC05LjJsMy45LTYuM2wyNC43LTMuOFYyOTBMMzY1LDI5M3ogTTM1OS45LDI4My40bDYuMSw2LjRsMTUuNy0yLjR2LTEwLjJsLTE5LjksMy4xCgkJCQkJCQlMMzU5LjksMjgzLjR6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQk8L2c+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiNDREQ5RUUiIGQ9Ik0zMjIsMzE1LjhsLTI0LjktMTQuNGwtMzUuNSw5LjFjLTQuOS0xLjYtOS45LTIuOS0xNC45LTQuMWwtMTUuOC0yMy4xbC0zMi45LTEuNGwtMTAuMiwyMgoJCQkJCQkJYy00LjgsMC43LTkuNSwxLjctMTQuMSwyLjlsLTM4LjItMTIuMmwtMjEuNiwxMi41bDIxLjEsMjJjLTIuMSwyLjYtMy43LDUuMy01LDguMWwtMzguMiw1LjlsMi40LDE5bDQwLDkuMQoJCQkJCQkJYzEuOSwyLjksNC4zLDUuOCw3LDguNmwtMTUuOCwyMC41bDI0LjksMTQuNGwzNS41LTkuMWM0LjksMS42LDkuOSwyLjksMTQuOSw0LjFsMTUuOCwyMy4xbDMyLjksMS40bDEwLjItMjIKCQkJCQkJCWM0LjgtMC43LDkuNS0xLjcsMTQtMi45bDM4LjIsMTIuMmwyMS42LTEyLjVsLTIwLjktMjJjMi4xLTIuNiwzLjctNS4zLDUtOC4xbDM4LjItNS45bC0yLjQtMTlsLTQwLjEtOS4xCgkJCQkJCQljLTEuOS0yLjktNC4zLTUuOC03LTguNkwzMjIsMzE1Ljh6IE0yNDguMywzNzIuM2MtMTEuOCw2LjgtMzIuMyw1LjktNDUuOC0xLjljLTEzLjYtNy44LTE1LTE5LjctMy4zLTI2LjVzMzIuMy01LjksNDUuOCwxLjkKCQkJCQkJCUMyNTguNiwzNTMuNywyNjAsMzY1LjUsMjQ4LjMsMzcyLjN6Ii8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjUwLjUsNDM1LjhsLTM0LjYtMS40TDIwMCw0MTEuMWMtNC44LTEuMS05LjUtMi40LTE0LjEtMy44bC0zNS43LDkuMmwtMjYuOS0xNS42bDE2LjEtMjAuOQoJCQkJCQkJYy0yLjMtMi40LTQuMy00LjktNi03LjRsLTQwLjUtOS4ybC0yLjctMjEuNWwzOC44LTZjMS4xLTIuMywyLjQtNC41LDQtNi43bC0yMS41LTIyLjVsMjMuOC0xMy43bDM4LjQsMTIuMgoJCQkJCQkJYzQuMi0xLjEsOC41LTIsMTMtMi43bDEwLjMtMjIuMmwzNC42LDEuNGwxNS45LDIzLjJjNC44LDEuMSw5LjUsMi40LDE0LjEsMy44bDM1LjctOS4ybDI2LjksMTUuNkwzMDguMSwzMzYKCQkJCQkJCWMyLjMsMi40LDQuMyw0LjksNiw3LjRsNDAuNSw5LjJsMi43LDIxLjVsLTM4LjgsNmMtMS4xLDIuMy0yLjQsNC41LTQsNi42bDIxLjUsMjIuNWwtMjMuOCwxMy43bC0zOC40LTEyLjIKCQkJCQkJCWMtNC4yLDEuMS04LjUsMi0xMywyLjdMMjUwLjUsNDM1Ljh6IE0yMTcuNSw0MzEuNWwzMS4xLDEuM2wxMC4xLTIxLjlsMC44LTAuMWM0LjgtMC43LDkuNC0xLjcsMTMuOS0yLjhsMC40LTAuMWwwLjQsMC4xCgkJCQkJCQlsMzcuNiwxMmwxOS40LTExLjJsLTIwLjctMjEuNmwwLjgtMWMyLTIuNSwzLjYtNS4xLDQuOC03LjhsMC4zLTAuOGwzNy42LTUuOGwtMi4xLTE2LjVsLTM5LjYtOWwtMC4zLTAuNQoJCQkJCQkJYy0xLjktMi44LTQuMi01LjctNi45LTguNGwtMC45LTAuOWwxNS41LTIwLjJMMjk2LjgsMzAzbC0zNS40LDkuMUwyNjEsMzEyYy00LjgtMS42LTkuOC0yLjktMTQuOC00bC0wLjYtMC4xbC0xNS43LTIzCgkJCQkJCQlsLTMxLjEtMS4zbC0xMC4xLDIxLjlsLTAuOCwwLjFjLTQuOCwwLjctOS41LDEuNy0xMy45LDIuOGwtMC40LDAuMWwtMzgtMTIuMWwtMTkuNCwxMS4ybDIwLjcsMjEuNmwtMC44LDEKCQkJCQkJCWMtMiwyLjUtMy42LDUuMS00LjgsNy44bC0wLjMsMC44bC0zNy41LDUuOGwyLjEsMTYuNWwzOS42LDlsMC4zLDAuNWMxLjksMi44LDQuMiw1LjcsNi45LDguNGwwLjksMC45TDEyNy43LDQwMGwyMi45LDEzLjIKCQkJCQkJCWwzNS40LTkuMWwwLjQsMC4xYzQuOCwxLjYsOS44LDIuOSwxNC44LDRsMC42LDAuMUwyMTcuNSw0MzEuNXogTTIyOC40LDM3OC40Yy05LjYsMC0xOS4zLTIuNC0yNi43LTYuNwoJCQkJCQkJYy03LjYtNC40LTEyLTEwLjMtMTItMTYuM2MwLTUsMy4xLTkuNiw4LjctMTIuOGM1LjMtMy4xLDEyLjctNC44LDIwLjctNC44YzkuNiwwLDE5LjMsMi40LDI2LjcsNi43YzcuNiw0LjQsMTIsMTAuMywxMiwxNi4zCgkJCQkJCQljMCw1LTMuMSw5LjYtOC43LDEyLjhDMjQzLjcsMzc2LjcsMjM2LjMsMzc4LjQsMjI4LjQsMzc4LjR6IE0yMTksMzQwLjljLTcuNSwwLTE0LjMsMS42LTE5LjIsNC40Yy00LjYsMi43LTcuMiw2LjMtNy4yLDEwLjIKCQkJCQkJCWMwLDQuOCwzLjgsOS44LDEwLjUsMTMuN2M2LjksNCwxNi4xLDYuMywyNS4yLDYuM2M3LjUsMCwxNC4zLTEuNiwxOS4yLTQuNGM0LjYtMi43LDcuMi02LjMsNy4yLTEwLjJjMC00LjgtMy44LTkuOC0xMC41LTEzLjcKCQkJCQkJCUMyMzcuMywzNDMuMSwyMjguMSwzNDAuOSwyMTksMzQwLjl6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik0xMjkuOSwzMzcuM2MxLjItMi44LDIuOS01LjUsNS04LjFsLTIxLjEtMjJ2MjIuN2w4LjMsOC43TDEyOS45LDMzNy4zeiIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTEyMS42LDM0MC4xbC05LjMtOS43di0yN2wyNC42LDI1LjdsLTAuOCwxYy0yLDIuNS0zLjYsNS4xLTQuOCw3LjhsLTAuMywwLjhMMTIxLjYsMzQwLjF6CgkJCQkJCQkgTTExNS4zLDMyOS4ybDcuNCw3LjdsNi4yLTFjMS4xLTIuMywyLjQtNC41LDQtNi43bC0xNy42LTE4LjRDMTE1LjMsMzEwLjgsMTE1LjMsMzI5LjIsMTE1LjMsMzI5LjJ6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiM0QjZFOTgiIGQ9Ik0zMDkuOSwzMzEuNWwtMy43LDQuOGMyLjcsMi44LDUuMSw1LjcsNyw4LjZsMy4yLDAuN2w1LjYtNy4ydi0yMi43TDMwOS45LDMzMS41TDMwOS45LDMzMS41eiIvPgoJCQkJCTwvZz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTMxNywzNDcuM2wtNC43LTEuMWwtMC4zLTAuNWMtMS45LTIuOC00LjItNS43LTYuOS04LjRsLTAuOS0wLjlsMTkuMy0yNVYzMzlMMzE3LDM0Ny4zeiBNMzE0LjEsMzQzLjYKCQkJCQkJCWwxLjcsMC40bDQuNy02di0xNy43bC0xMi4zLDE2QzMxMC40LDMzOC42LDMxMi40LDM0MS4xLDMxNC4xLDM0My42eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMjQ5LjUsMzcxLjZjLTEuMy0xLjEtMi44LTIuMS00LjUtMy4xYy0xMC41LTYuMS0yNS4zLTgtMzYuOS01LjNjOC40LTkuNSwxOS42LTE2LjUsMzIuMy0xOS42CgkJCQkJCQljMS42LDAuNywzLjEsMS40LDQuNiwyLjNDMjU4LjEsMzUzLjQsMjU5LjksMzY0LjcsMjQ5LjUsMzcxLjZ6Ii8+CgkJCQkJPC9nPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0yNDkuNSwzNzEuNmMtMS4zLTEuMS0yLjgtMi4xLTQuNS0zLjFjLTEwLjUtNi4xLTI1LjMtOC0zNi45LTUuM2MtMy4zLDAuOC02LjQsMS45LTksMy40CgkJCQkJCQljLTAuNCwwLjItMC44LDAuNS0xLjIsMC43Yy05LjItNy42LTkuMS0xNy40LDEuMi0yMy40YzEwLjUtNi4xLDI4LTYsNDEuMy0wLjRjMS42LDAuNywzLjEsMS40LDQuNiwyLjMKCQkJCQkJCUMyNTguMSwzNTMuNCwyNTkuOSwzNjQuNywyNDkuNSwzNzEuNnoiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNDkuNCwzNzMuNGwtMC45LTAuN2MtMS4yLTEtMi43LTItNC4zLTIuOWMtNi45LTQtMTYuMS02LjMtMjUuMi02LjNjLTMuNywwLTcuMywwLjQtMTAuNiwxLjEKCQkJCQkJCXMtNi4xLDEuOC04LjYsMy4yYy0wLjQsMC4yLTAuOCwwLjUtMS4xLDAuN2wtMC45LDAuNmwtMC45LTAuN2MtNS4xLTQuMi03LjctOS4yLTcuMi0xNC4xYzAuNC00LjYsMy41LTguOCw4LjYtMTEuOAoJCQkJCQkJYzUuMy0zLjEsMTIuNy00LjgsMjAuNy00LjhjNy42LDAsMTUuNCwxLjUsMjIsNC4zYzEuNywwLjcsMy4zLDEuNSw0LjcsMi4zYzcuNCw0LjMsMTEuOCwxMC4xLDEyLDE1LjkKCQkJCQkJCWMwLjEsNC43LTIuNSw5LjEtNy40LDEyLjRMMjQ5LjQsMzczLjR6IE0yMTkuMSwzNjAuNWM5LjYsMCwxOS4zLDIuNCwyNi43LDYuN2MxLjQsMC44LDIuNiwxLjYsMy44LDIuNQoJCQkJCQkJYzMuNS0yLjYsNS4zLTUuNyw1LjItOS4yYy0wLjEtNC44LTQtOS42LTEwLjUtMTMuNGMtMS40LTAuOC0yLjgtMS41LTQuNC0yLjJjLTYuMi0yLjYtMTMuNi00LjEtMjAuOC00LjEKCQkJCQkJCWMtNy41LDAtMTQuMywxLjYtMTkuMiw0LjRjLTQuNCwyLjUtNi45LDUuOC03LjIsOS40czEuNiw3LjQsNS4zLDEwLjhjMC4xLTAuMSwwLjItMC4xLDAuMy0wLjJjMi43LTEuNSw1LjgtMi43LDkuNC0zLjYKCQkJCQkJCUMyMTEuMywzNjAuOSwyMTUuMSwzNjAuNSwyMTkuMSwzNjAuNXoiLz4KCQkJCQk8L2c+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzRCNkU5OCIgZD0iTTMxNy40LDM3OC45Yy0xLjIsMi44LTIuOSw1LjUtNSw4LjFsMTIuOCwxMy4zbDMwLjQtNC43VjM3M0wzMTcuNCwzNzguOXoiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zMjQuNyw0MDJsLTE0LjItMTQuOWwwLjgtMWMyLTIuNSwzLjYtNS4xLDQuOC03LjhsMC4zLTAuOGw0MC43LTYuM1YzOTdMMzI0LjcsNDAyeiBNMzE0LjUsMzg2LjkKCQkJCQkJCWwxMS4zLDExLjhsMjguMy00LjR2LTE5LjZsLTM1LjcsNS41QzMxNy40LDM4Mi42LDMxNi4xLDM4NC44LDMxNC41LDM4Ni45eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMzE5LjYsNDE3LjJsLTcuNiw0LjRsLTM4LjItMTIuMmMtNC41LDEuMi05LjIsMi4yLTE0LDIuOWwtMTAuMiwyMmwtMzIuOS0xLjRsLTE1LjgtMjMuMQoJCQkJCQkJYy01LjEtMS4xLTEwLTIuNS0xNC45LTQuMWwtMzUuNSw5LjFsLTEyLjgtNy40bDAsMGwtMTIuMS03djIyLjdsMjQuOSwxNC40bDM1LjUtOS4xYzQuOSwxLjYsOS45LDIuOSwxNC45LDQuMWwxNS44LDIzLjEKCQkJCQkJCWwzMi45LDEuNGwxMC4yLTIyYzQuOC0wLjcsOS41LTEuNywxNC0yLjlsMzguMiwxMi4ybDIxLjYtMTIuNXYtMjIuN0wzMTkuNiw0MTcuMkwzMTkuNiw0MTcuMnoiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNTAuNSw0NTguNWwtMzQuNi0xLjRMMjAwLDQzMy44Yy00LjgtMS4xLTkuNS0yLjQtMTQuMS0zLjhsLTM1LjcsOS4yTDEyMy45LDQyNHYtMjYuMWwyNi42LDE1LjQKCQkJCQkJCWwzNS40LTkuMWwwLjQsMC4xYzQuOCwxLjYsOS44LDIuOSwxNC44LDRsMC42LDAuMWwxNS44LDIzbDMxLjEsMS4zbDEwLjEtMjEuOWwwLjgtMC4xYzQuOC0wLjcsOS40LTEuNywxMy45LTIuOGwwLjQtMC4xCgkJCQkJCQlsMzgsMTIuMWwyMy4zLTEzLjR2MjYuMWwtMjIuOSwxMy4ybC0zOC40LTEyLjJjLTQuMiwxLjEtOC41LDItMTMsMi43TDI1MC41LDQ1OC41eiBNMjE3LjUsNDU0LjFsMzEuMSwxLjNsMTAuMS0yMS45bDAuOC0wLjEKCQkJCQkJCWM0LjgtMC43LDkuNC0xLjcsMTMuOS0yLjhsMC40LTAuMWwwLjQsMC4xbDM3LjYsMTJsMjAuMy0xMS43di0xOS4ybC0xOS45LDExLjVMMjczLjgsNDExYy00LjIsMS4xLTguNSwyLTEzLDIuN2wtMTAuMywyMi4yCgkJCQkJCQlsLTM0LjYtMS40TDIwMCw0MTEuMmMtNC44LTEuMS05LjUtMi40LTE0LjEtMy44bC0zNS43LDkuMkwxMjcsNDAzLjJ2MTkuMmwyMy42LDEzLjZsMzUuNC05LjFsMC40LDAuMWM0LjgsMS42LDkuNywyLjksMTQuOCw0CgkJCQkJCQlsMC42LDAuMUwyMTcuNSw0NTQuMXoiLz4KCQkJCQk8L2c+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPHBhdGggZmlsbD0iIzRCNkU5OCIgZD0iTTE0MS4yLDM4MGMtMi43LTIuOC01LjEtNS43LTctOC42bC00MC05LjFsLTIuNC0xOVYzNjZsMi40LDE5bDM2LjgsOC40TDE0MS4yLDM4MHoiLz4KCQkJCQk8L2c+CgkJCQkJPGc+CgkJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xMzEuNiwzOTQuOUw5Mi44LDM4NmwtMi41LTIwLjJ2LTIyLjdsMy0wLjJsMi4yLDE3LjlsMzkuNiw5bDAuMywwLjVjMS45LDIuOCw0LjIsNS43LDYuOSw4LjQKCQkJCQkJCWwwLjksMC45TDEzMS42LDM5NC45eiBNOTUuNSwzODMuNmwzNC45LDhsOC45LTExLjVjLTIuMy0yLjQtNC4zLTQuOS02LTcuNGwtNDAtOS4xdjIuMkw5NS41LDM4My42eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMTI1LjQsNDAwLjUgMTI1LjQsNDIzLjEgMTUwLjMsNDM3LjUgMTUwLjMsNDE0LjkgCQkJCQkiLz4KCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTUxLjgsNDQwLjFMMTIzLjksNDI0di0yNi4xbDI3LjksMTYuMVY0NDAuMXogTTEyNi45LDQyMi4zbDIxLjksMTIuN3YtMTkuMmwtMjEuOS0xMi43VjQyMi4zeiIvPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIzMTIsNDIxLjYgMzEyLDQ0NC4yIDMzMy42LDQzMS44IDMzMy42LDQwOS4xIAkJCQkJIi8+CgkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTMxMC41LDQ0Ni44di0yNi4xbDI0LjYtMTQuMnYyNi4xTDMxMC41LDQ0Ni44eiBNMzEzLjUsNDIyLjR2MTkuMmwxOC42LTEwLjd2LTE5LjJMMzEzLjUsNDIyLjR6Ii8+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjMyMiwzMTUuOCAzMjIsMzM4LjQgMzE2LjQsMzQ1LjYgMzEzLjIsMzQ0LjkgMzA2LjIsMzM2LjMgCQkJCQkiLz4KCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzE3LDM0Ny4zbC00LjctMS4xbC04LjEtOS45bDE5LjItMjV2MjcuNkwzMTcsMzQ3LjN6IE0zMTQsMzQzLjZsMS44LDAuNGw0LjctNnYtMTcuN2wtMTIuNCwxNi4xCgkJCQkJCUwzMTQsMzQzLjZ6Ii8+CgkJCQk8L2c+CgkJCQk8Zz4KCQkJCQk8Zz4KCQkJCQkJPHBvbHlnb24gZmlsbD0iIzY4ODVBOSIgcG9pbnRzPSI5MS44LDM0My4yIDkxLjgsMzY1LjkgOTQuMSwzODQuOSAxMzEsMzkzLjMgMTQxLjIsMzgwIDEzNC4yLDM3MS4zIDk0LjEsMzYyLjIgCQkJCQkJIi8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTMxLjYsMzk0LjlMOTIuOCwzODZsLTIuNS0yMC4ydi0yMi43bDMtMC4ybDIuMiwxNy45bDM5LjUsOWw4LjEsOS45TDEzMS42LDM5NC45eiBNOTUuNSwzODMuNgoJCQkJCQkJbDM0LjksOGw4LjktMTEuNmwtNi03LjNsLTQwLjEtOS4xdjIuMkw5NS41LDM4My42eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxnPgoJCQkJCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjM1NS42LDM3MyAzNTUuNiwzOTUuNyAzMjUuMiw0MDAuNCAzMTIuNSwzODcgMzE3LjQsMzc4LjkgCQkJCQkJIi8+CgkJCQkJPC9nPgoJCQkJCTxnPgoJCQkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzI0LjcsNDAybC0xNC4xLTE0LjdsNS45LTkuN2w0MC42LTYuM1YzOTdMMzI0LjcsNDAyeiBNMzE0LjQsMzg2LjhsMTEuNCwxMS45bDI4LjMtNC40di0xOS42CgkJCQkJCQlsLTM1LjgsNS41TDMxNC40LDM4Ni44eiIvPgoJCQkJCTwvZz4KCQkJCTwvZz4KCQkJPC9nPgoJCTwvZz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "cube", "name": "cube", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB3aWR0aD0iMTA1LjRweCIgaGVpZ2h0PSI5My45cHgiIHZpZXdCb3g9IjAgMCAxMDUuNCA5My45IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxMDUuNCA5My45IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGcgaWQ9IkxheWVyXzMiPgo8L2c+CjxnIGlkPSJMYXllcl80Ij4KCTxwb2x5Z29uIGZpbGw9IiM0RjY1ODciIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjgwLjQsMjYuMiA1MC41LDQ0LjMgNTAuNSw4MS4yIAoJCTgwLjQsNjMuMyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIyMC43LDI2LjIgNTAuNSw0NC4zIDUwLjUsODEuMiAKCQkyMC43LDYzLjMgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NFRDhFQiIgcG9pbnRzPSI1MC41LDQ0LjIgMjAuNywyNi4yIDUwLjMsOC41IDgwLjQsMjYuMiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9IjI5LjksMjYuMiA1NC45LDExLjIgNTAuMyw4LjUgMjAuNywyNi4yIDUwLjUsNDQuMiA1NS4xLDQxLjUgCSIvPgoJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNTAuNSw0NC4yIDIwLjcsMjYuMiA1MC4zLDguNSAKCQk4MC40LDI2LjIgCSIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI4MC40LDU1LjEgODAuNCw2My4zIDU2LjMsNzcuOCA1MC41LDgzLjIgNjYuNCw4My4yIDk4LjUsNjMuOCAJIi8+CjwvZz4KPGcgaWQ9IkxheWVyXzUiPgoJPGc+CgkJPHBhdGggZD0iTTUwLjMsOC41bDMwLjEsMTcuOHYzNy4xTDUwLjUsODEuMkwyMC43LDYzLjNWMjYuMkw1MC4zLDguNSBNNTAuMyw2LjVjLTAuNCwwLTAuNywwLjEtMSwwLjNMMTkuNiwyNC41CgkJCWMtMC42LDAuNC0xLDEtMSwxLjd2MzcuMWMwLDAuNywwLjQsMS40LDEsMS43bDI5LjgsMTcuOWMwLjMsMC4yLDAuNywwLjMsMSwwLjNzMC43LTAuMSwxLTAuM2wzMC0xNy45YzAuNi0wLjQsMS0xLDEtMS43VjI2LjIKCQkJYzAtMC43LTAuNC0xLjQtMS0xLjdMNTEuMyw2LjdDNTEsNi42LDUwLjYsNi41LDUwLjMsNi41TDUwLjMsNi41eiIvPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "desktop", "name": "desktop", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NzMuNXB4IiBoZWlnaHQ9IjU3MC40cHgiIHZpZXdCb3g9IjAgMCA1NzMuNSA1NzAuNCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNTczLjUgNTcwLjQiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMV8xXyIgZGlzcGxheT0ibm9uZSI+Cgk8cG9seWdvbiBkaXNwbGF5PSJpbmxpbmUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI0E3QTlBQyIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNDgyLjksNDA2LjYgMjY3LjUsNTMwLjkgCgkJNTIuMSw0MDYuNiA1Mi4xLDE1Ny44IDI2Ny41LDMzLjQgNDgyLjksMTU3LjggCSIvPgo8L2c+CjxnIGlkPSJMYXllcl8yXzFfIj4KCTxnPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjNEI2RTk4IiBkPSJNMzQ0LjYsNDA1Yy0yMCwwLTQwLjItNS4xLTU1LjQtMTMuOWMtMTUuNi05LTI0LjYtMjEuMS0yNC42LTMzLjJ2LTEwaDRjMCwxMC42LDguMiwyMS40LDIyLjYsMjkuNwoJCQkJYzE0LjcsOC41LDM0LjEsMTMuMyw1My40LDEzLjNsMCwwYzE1LjksMCwzMC40LTMuMyw0MC44LTkuM2MxMC01LjgsMTUuNS0xMy42LDE1LjYtMjIuMXYtMy4ybDQsMnYxLjJ2OS44CgkJCQljMCwxMC4xLTYuMywxOS4yLTE3LjYsMjUuOEMzNzYuNCw0MDEuNSwzNjEuMiw0MDUsMzQ0LjYsNDA1eiIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjY2LjcsMzQ3LjljMCwxMC45LDgsMjIuNCwyMy42LDMxLjRzMzUuNSwxMy42LDU0LjQsMTMuNmMxNS43LDAsMzAuNi0zLjIsNDEuOC05LjYKCQkJCWMxMS02LjQsMTYuNS0xNC45LDE2LjYtMjMuOGwwLDB2OS43YzAsMCwwLDAsMCwwLjF2MC4xbDAsMGMwLDktNS41LDE3LjYtMTYuNiwyNGMtMTEuMiw2LjUtMjYuMSw5LjYtNDEuOCw5LjYKCQkJCWMtMTguOSwwLTM4LjktNC42LTU0LjQtMTMuNmMtMTUuNi05LTIzLjYtMjAuNS0yMy42LTMxLjRWMzQ3LjkgTTI3MC43LDM0Ny45aC04djEwYzAsMTIuOCw5LjMsMjUuNSwyNS42LDM0LjkKCQkJCWMxNS42LDksMzYuMSwxNC4xLDU2LjQsMTQuMWMxNi45LDAsMzIuNS0zLjYsNDMuOC0xMC4xYzEwLjUtNi4xLDE2LjktMTQuMywxOC4zLTIzLjVoMC4zdi00di0wLjF2LTAuMXYtOS43di0yLjVsLTIuMi0xLjEKCQkJCWwtNS43LTIuOWwtMC4xLDYuNGMtMC4xLDcuOC01LjMsMTUtMTQuNiwyMC40Yy0xMC4xLDUuOS0yNC4zLDkuMS0zOS44LDkuMWMtMTguOSwwLTM4LTQuOC01Mi40LTEzLjEKCQkJCUMyNzguNSwzNjgsMjcwLjcsMzU3LjgsMjcwLjcsMzQ3LjlMMjcwLjcsMzQ3Ljl6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjQjFDQUVDIiBkPSJNMzQ0LjYsMzk1Yy0yMCwwLTQwLjItNS4xLTU1LjQtMTMuOWMtMTUuNi05LTI0LjYtMjEuMS0yNC42LTMzLjJjMC0xMC4xLDYuMy0xOS4zLDE3LjYtMjUuOQoJCQkJYzExLTYuNCwyNi4yLTkuOSw0Mi44LTkuOWMyLjYsMCw1LjMsMC4xLDcuOSwwLjNoMC41bDcwLjksNDFsMC4yLDAuOWMyLjMsMTEuOS0zLjksMjMuMS0xNy4yLDMwLjcKCQkJCUMzNzYuNCwzOTEuNCwzNjEuMiwzOTUsMzQ0LjYsMzk1TDM0NC42LDM5NXoiLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTMyNS4xLDMxNC4yYzIuNiwwLDUuMiwwLjEsNy44LDAuM2w2OS44LDQwLjNjMi4xLDEwLjYtMy4yLDIxLjEtMTYuMiwyOC42Yy0xMS4yLDYuNS0yNi4xLDkuNi00MS44LDkuNgoJCQkJYy0xOC45LDAtMzguOS00LjYtNTQuNC0xMy42Yy0yOC41LTE2LjQtMzEuNi00MS4zLTYuOS01NS42QzI5NC41LDMxNy40LDMwOS40LDMxNC4yLDMyNS4xLDMxNC4yIE0zMjUuMSwzMTAuMgoJCQkJYy0xNi45LDAtMzIuNSwzLjYtNDMuOCwxMC4xYy0xMiw2LjktMTguNiwxNi43LTE4LjYsMjcuNmMwLDEyLjgsOS4zLDI1LjUsMjUuNiwzNC45YzE1LjYsOSwzNi4xLDE0LjEsNTYuNCwxNC4xCgkJCQljMTYuOSwwLDMyLjUtMy42LDQzLjgtMTAuMWMxNC04LjEsMjAuNi0yMC4xLDE4LjEtMzIuOWwtMC40LTEuOGwtMS42LTAuOUwzMzQuOSwzMTFsLTAuOC0wLjVsLTAuOS0wLjEKCQkJCUMzMzAuNSwzMTAuMywzMjcuNywzMTAuMiwzMjUuMSwzMTAuMkwzMjUuMSwzMTAuMnoiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiM0QjZFOTgiIGQ9Ik0zNjIsMzYyLjVjLTAuMywwLTAuNy0wLjEtMS0wLjNjLTAuNi0wLjQtMS0xLTEtMS43di0yOS4yYzAtMC43LDAuNC0xLjQsMS0xLjdjMC4zLTAuMiwwLjctMC4zLDEtMC4zCgkJCQlzMC43LDAuMSwxLDAuM2wxMCw1LjhjMC42LDAuNCwxLDEsMSwxLjd2MTcuN2MwLDAuNy0wLjQsMS40LTEsMS43bC0xMCw1LjhDMzYyLjcsMzYyLjQsMzYyLjQsMzYyLjUsMzYyLDM2Mi41eiIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzYyLDMzMS4zbDEwLDUuOHYxNy43bC0xMCw1LjhWMzMxLjMgTTM2MiwzMjcuM2MtMC43LDAtMS40LDAuMi0yLDAuNWMtMS4yLDAuNy0yLDItMiwzLjV2MjkuMgoJCQkJYzAsMS40LDAuOCwyLjgsMiwzLjVjMC42LDAuNCwxLjMsMC41LDIsMC41czEuNC0wLjIsMi0wLjVsMTAtNS44YzEuMi0wLjcsMi0yLDItMy41VjMzN2MwLTEuNC0wLjgtMi43LTItMy41bC0xMC01LjgKCQkJCUMzNjMuNCwzMjcuNCwzNjIuNywzMjcuMywzNjIsMzI3LjNMMzYyLDMyNy4zeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTM2MiwzNjIuNWMtMC4zLDAtMC43LTAuMS0xLTAuM2wtNTAuMS0yOC45Yy0wLjYtMC40LTEtMS0xLTEuN3YtMjkuMmMwLTAuNywwLjQtMS40LDEtMS43CgkJCQljMC4zLTAuMiwwLjctMC4zLDEtMC4zczAuNywwLjEsMSwwLjNsNTAuMSwyOC45YzAuNiwwLjQsMSwxLDEsMS43djI5LjJjMCwwLjctMC40LDEuNC0xLDEuN0MzNjIuNywzNjIuNCwzNjIuNCwzNjIuNSwzNjIsMzYyLjV6CgkJCQkiLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTMxMS45LDMwMi40bDUwLjEsMjguOXYyOS4ybC01MC4xLTI4LjlWMzAyLjQgTTMxMS45LDI5OC40Yy0wLjcsMC0xLjQsMC4yLTIsMC41Yy0xLjIsMC43LTIsMi0yLDMuNQoJCQkJdjI5LjJjMCwxLjQsMC44LDIuOCwyLDMuNUwzNjAsMzY0YzAuNiwwLjQsMS4zLDAuNSwyLDAuNXMxLjQtMC4yLDItMC41YzEuMi0wLjcsMi0yLDItMy41di0yOS4yYzAtMS40LTAuOC0yLjgtMi0zLjVsLTUwLjEtMjguOQoJCQkJQzMxMy4zLDI5OC41LDMxMi42LDI5OC40LDMxMS45LDI5OC40TDMxMS45LDI5OC40eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjQjFDQUVDIiBwb2ludHM9IjQ3My42LDE4MiAxOTUuOCwyMS40IDIyMC45LDYuOSA0OTguNiwxNjcuNSA0OTguNiwzODYuNyA0NzMuNiw0MDEuMiAJCQkJIi8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBvbHlnb24gZmlsbD0iIzRCNkU5OCIgcG9pbnRzPSI0NzMuNiwxNzkuNyA0OTguNiwxNjUuMiA0OTguNiwzODYuNyA0NzMuNiw0MDEuMiAJCQkJIi8+CgkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNNDk2LjYsMTY4Ljd2MjE2LjlsLTIxLjEsMTIuMlYxODAuOUw0OTYuNiwxNjguNyBNNTAwLjYsMTYxLjdsLTYsMy41bC0yMS4xLDEyLjJsLTIsMS4ydjIuM3YyMTYuOXY2LjkKCQkJCQlsNi0zLjVsMjEuMS0xMi4ybDItMS4ydi0yLjNWMTY4LjdWMTYxLjdMNTAwLjYsMTYxLjd6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iMTk3LjgsMjM5LjUgMTk3LjgsMTcuOSA0NzQsMTc5LjIgNDc0LDQwMC43IAkJCSIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTk5LjgsMjEuNGwyNzIuMiwxNTl2MjE2LjlsLTI3Mi4yLTE1OUwxOTkuOCwyMS40IE0xOTUuOCwxNC41djYuOXYyMTYuOXYyLjNsMiwxLjJsMjcyLjIsMTU5bDYtMi41CgkJCQl2LTAuOVYxODAuNVYxNzhsLTItMS4ybC0yNzIuMi0xNTlMMTk1LjgsMTQuNUwxOTUuOCwxNC41eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSI0MzYuNiwxNTkuNyAzMDAuMiwyOTUuNSAxOTkuOSwyMzguMyAxOTkuOSwyMzIuOSAzMzcuNCwxMDEuOCAJCQkiLz4KCQk8L2c+CgkJPGc+CgkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iNDYxLDE3My45IDQ3MiwxODAuNCA0NzIsMjQwLjggMzc0LjUsMzM4LjQgMzI0LjgsMzA5LjggCQkJIi8+CgkJPC9nPgoJCTxnPgoJCQk8Zz4KCQkJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iNDEsNDIwLjggNDEsMzk3LjQgMjU4LjksNTIzLjMgMzY0LDQ2Mi42IDM2NCw0ODYgMjU4LjksNTQ2LjcgCQkJCSIvPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTQzLDQwMC45bDIxNiwxMjQuN2wxMDMtNTkuNXYxOC44bC0xMDMsNTkuNUw0Myw0MTkuN1Y0MDAuOSBNMzksMzk0djYuOXYxOC44djIuM2wyLDEuMmwyMTYsMTI0LjcKCQkJCQlsMiwxLjJsMi0xLjJsMTAzLTU5LjVsMi0xLjJ2LTIuM3YtMTguOHYtNi45bC02LDMuNUwyNTksNTIxTDQ1LDM5Ny40TDM5LDM5NEwzOSwzOTR6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxwb2x5Z29uIGZpbGw9IiM0QjZFOTgiIHN0cm9rZT0iIzIzMUYyMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzYyLDQ2Ni4xIDM2Miw0ODQuOSAyNTguOSw1NDQuMyAKCQkJCTI1OC45LDUyNS42IAkJCSIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0IxQ0FFQyIgcG9pbnRzPSIzOSw0MDAuOSAxNDYsMzM5LjEgMzY2LDQ2Ni4xIDI1OC45LDUyNy45IAkJCSIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTQ2LDM0MS40bDIxNiwxMjQuN2wtMTAzLDU5LjVMNDMsNDAwLjlMMTQ2LDM0MS40IE0xNDYsMzM2LjhsLTIsMS4yTDQxLDM5Ny40bC02LDMuNWw2LDMuNWwyMTYsMTI0LjcKCQkJCWwyLDEuMmwyLTEuMmwxMDMtNTkuNWw2LTMuNWwtNi0zLjVMMTQ4LDMzNy45TDE0NiwzMzYuOEwxNDYsMzM2Ljh6Ii8+CgkJPC9nPgoJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHN0cm9rZT0iIzIzMUYyMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNzcsNDAyLjYgCgkJCTI1Ni4xLDUwNS4yIDMyOS42LDQ2Mi42IDE1MS4xLDM1OSAJCSIvPgoJPC9nPgoJPGc+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMjEsOS4ybDI3MS43LDE1Ny4zdjIxOS45bC0yMS4xLDEyLjJMNDA0LjgsMzU5YzAuNiw0LjQsMCw1LjctMS44LDkuOHYwLjZjMCw5LTUuNSwxNy42LTE2LjcsMjQKCQkJCWMtMTEuMiw2LjUtMjYuMSw5LjYtNDEuOCw5LjZjLTE4LjksMC0zOC45LTQuNi01NC40LTEzLjZjLTE1LjYtOS0yMy42LTIwLjUtMjMuNi0zMS40di0wLjJjLTEuMy0zLjItMi02LjYtMi05LjkKCQkJCWMwLTEwLjEsNi4zLTE5LjMsMTcuNi0yNS45YzcuNi00LjQsMTcuMS03LjQsMjcuNi04Ljl2LTEwLjljMC0wLjQsMC4xLTAuNy0yNC44LTE0LjhsLTg1LjMtNDkuMlYyMS40TDIyMSw5LjIgTTIyMSwwbC00LDIuMwoJCQkJbC0yMS4xLDEyLjJsLTQsMi4zdjQuNnYyMTYuOXY0LjZsNCwyLjNMMzAyLDMwNS44djAuN2MtOC45LDEuOS0xNyw0LjgtMjMuNiw4LjdjLTE0LDguMS0yMS42LDE5LjctMjEuNiwzMi44CgkJCQljMCwzLjgsMC43LDcuNiwyLDExLjRjMC42LDEzLjgsMTAuNiwyNy4yLDI3LjUsMzdjMTYuMiw5LjMsMzcuNSwxNC43LDU4LjQsMTQuN2MxNy42LDAsMzMuOS0zLjgsNDUuOC0xMC43CgkJCQljMTIuOS03LjUsMjAuMi0xOC4xLDIwLjYtMzBjMC4yLTAuNCwwLjMtMC44LDAuNC0xLjJsNTYsMzMuNGw0LDIuM2w0LTIuM2wyMS4xLTEyLjJsNC0yLjN2LTQuNnYtMjE3di00LjZsLTQtMi4zTDIyNSwyLjNMMjIxLDAKCQkJCUwyMjEsMHoiLz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTE0NiwzNDEuNGwyMTYsMTI0Ljd2MTguOGwtMTAzLDU5LjVMNDMsNDE5Ljd2LTE4LjhMMTQ2LDM0MS40IE0xNDYsMzMyLjJsLTQsMi4zTDM5LDM5NGwtNCwyLjN2NC42CgkJCQkJdjE4Ljh2NC42bDQsMi4zbDIxNiwxMjQuN2w0LDIuM2w0LTIuM2wxMDMtNTkuNWw0LTIuM3YtNC42di0xOC44di00LjZsLTQtMi4zTDE1MCwzMzQuNUwxNDYsMzMyLjJMMTQ2LDMzMi4yeiIvPgoJCQk8L2c+CgkJPC9nPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "diamond", "name": "diamond", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB3aWR0aD0iMTEzLjlweCIgaGVpZ2h0PSIxMDUuOHB4IiB2aWV3Qm94PSIwIDAgMTEzLjkgMTA1LjgiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDExMy45IDEwNS44IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPGcgaWQ9IkxheWVyXzhfY29weSI+Cgk8ZyBpZD0iTGF5ZXJfMyI+Cgk8L2c+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxNC4xLDUwIDU4LjEsNy41IDEwMi4xLDUwIAoJCQk1OC4xLDc2LjUgCQkiLz4KCTwvZz4KCTxwb2x5Z29uIGZpbGw9IiNDRUQ4RUIiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjE0LjEsNTAgNTguMSw3Ni41IDU4LjEsNy41IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iNTUuNCwyNCAxOS41LDQ5LjggMTguNSw0OS4yIDU1LjQsMTMuNyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNDY1RTdDIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxNC4xLDUwIDU4LjEsOTYuNiAxMDIuMSw1MCAKCQk1OC4xLDc2LjUgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0EzQjRDQyIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMTQuMSw1MCA1OC4xLDk2LjYgNTguMSw3Ni41IAkKCQkiLz4KPC9nPgo8ZyBpZD0iTGF5ZXJfNCI+Cgk8cG9seWdvbiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjU4LjEsNy41IDE0LjEsNTAgCgkJNTguMSw5Ni42IDEwMi4xLDUwIAkiLz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iNTguMSw5Ny41IDEwOC4zLDgxLjcgODUuOSw2Ny4zIAkiLz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "dns", "name": "dns", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzhfY29weSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IgoJIHk9IjBweCIgd2lkdGg9IjEzNHB4IiBoZWlnaHQ9IjEyMi40cHgiIHZpZXdCb3g9IjAgMCAxMzQgMTIyLjQiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEzNCAxMjIuNCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl83X2NvcHkiPgo8L2c+CjxwYXRoIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik03Ni4xLDY1LjRjLTIuOCwwLTUuNiwwLjEtOC4yLDAuM3Y0OS4xYzIuNywwLjEsNS40LDAuMyw4LjIsMC4zCgljMzIsMCw1Ny45LTExLjEsNTcuOS0yNC44QzEzNCw3Ni41LDEwOC4xLDY1LjQsNzYuMSw2NS40eiIvPgo8Y2lyY2xlIGZpbGw9IiNEQUUxRUQiIGN4PSI2Ni42IiBjeT0iNjEuMiIgcj0iNTMuMSIvPgo8cGF0aCBmaWxsPSIjNzE4NEEyIiBkPSJNMTE2LDU5LjJjMC0yNy44LTIyLTUwLjMtNDkuNi01MS4xYy0yOCwwLjgtNTAuNiwyMi42LTUyLjgsNTAuMmMwLDAuMywwLDAuNiwwLDAuOQoJYzAsMjguMywyMi45LDUxLjIsNTEuMiw1MS4yUzExNiw4Ny41LDExNiw1OS4yeiIvPgo8cGF0aCBmaWxsPSIjQjlDNEQ3IiBkPSJNMTA0LjcsNDguNmMwLTExLjYtNC4yLTIyLjMtMTEuMi0zMC41Yy04LTYtMTcuOC05LjctMjguNS0xMEM1MS40LDguNSwzOS4xLDE0LDI5LjksMjIuOGwwLDAKCWMtOS4yLDguOC0xNS4yLDIwLjktMTYuMywzNC4zbDAsMGMwLDAuMywwLDAuNiwwLDAuOGMwLDMuNSwwLjQsNi44LDEsMTAuMUMyMiw4NC40LDM4LjQsOTUuNyw1Ny41LDk1LjcKCUM4My42LDk1LjcsMTA0LjcsNzQuNiwxMDQuNyw0OC42eiIvPgo8cGF0aCBmaWxsPSIjRDBEOEU5IiBkPSJNMjkuOSwyMi44TDI5LjksMjIuOEMyMi43LDI5LjYsMTcuNSwzOC40LDE1LDQ4LjNjNywxMi4xLDIwLjEsMjAuMSwzNSwyMC4xYzIyLjQsMCw0MC41LTE4LjEsNDAuNS00MC41CgljMC00LjctMC44LTkuMS0yLjMtMTMuM0M4MS40LDEwLjYsNzMuNSw4LjIsNjUsOEM1MS40LDguNSwzOS4xLDE0LDI5LjksMjIuOHoiLz4KPGNpcmNsZSBpZD0iT3V0bGluZSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY3g9IjY2LjYiIGN5PSI2MS4yIiByPSI1My4xIi8+CjxlbGxpcHNlIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzNDNTA2RCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGN4PSI2Ni41IiBjeT0iNDYuMSIgcng9IjUwLjQiIHJ5PSIyMi4yIi8+CjxlbGxpcHNlIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzNDNTA2RCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGN4PSI2Ni41IiBjeT0iNzYuMyIgcng9IjUwLjQiIHJ5PSIyMi4yIi8+CjxlbGxpcHNlIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzNDNTA2RCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGN4PSI2Ni41IiBjeT0iNjEuMiIgcng9IjIzLjUiIHJ5PSI1My4yIi8+CjxwYXRoIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik05MS43LDg1LjhINDFjLTcuMiwwLTEzLTUuOC0xMy0xM3YtNS45YzAtNy4yLDUuOC0xMywxMy0xM2g1MC43CgljNy4yLDAsMTMsNS44LDEzLDEzdjUuOUMxMDQuNyw4MCw5OC45LDg1LjgsOTEuNyw4NS44eiIvPgo8Zz4KCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik05Ni44LDc5LjRjLTIwLjIsMi4yLTQwLjYsMi4yLTYwLjksMGMtNy4yLTAuOS0xMy03LjgtMTMtMTQuOGMwLTEuOSwwLTMuOSwwLTUuOGMwLTcsNS44LTEzLjgsMTMtMTQuOAoJCWMyMC4yLTIuMiw0MC42LTIuMiw2MC45LDBjNy4yLDAuOSwxMyw3LjgsMTMsMTQuOGMwLDEuOSwwLDMuOSwwLDUuOEMxMDkuNyw3MS43LDEwMy45LDc4LjUsOTYuOCw3OS40eiIvPgoJPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTk2LjgsNzkuNGMtMjAuMiwyLjItNDAuNiwyLjItNjAuOSwwCgkJYy03LjItMC45LTEzLTcuOC0xMy0xNC44YzAtMS45LDAtMy45LDAtNS44YzAtNyw1LjgtMTMuOCwxMy0xNC44YzIwLjItMi4yLDQwLjYtMi4yLDYwLjksMGM3LjIsMC45LDEzLDcuOCwxMywxNC44CgkJYzAsMS45LDAsMy45LDAsNS44QzEwOS43LDcxLjcsMTAzLjksNzguNSw5Ni44LDc5LjR6Ii8+CjwvZz4KPGc+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNMzMsNzNjMC03LjQsMC0xNC45LDAtMjIuM2MyLjctMC40LDUuNC0wLjgsOC4xLTEuMWMyLjItMC4zLDQuMiwwLDYsMC45czMuMiwyLjMsNC4yLDQuMnMxLjUsNC4xLDEuNSw2LjUKCQkJYzAsMC40LDAsMC44LDAsMS4yYzAsMi40LTAuNSw0LjYtMS41LDYuNXMtMi40LDMuMy00LjEsNC4yYy0xLjgsMC45LTMuOCwxLjItNiwxQzM4LjUsNzMuOCwzNS43LDczLjQsMzMsNzN6IE0zOSw1NC4zCgkJCWMwLDUsMCwxMC4xLDAsMTUuMWMwLjcsMC4xLDEuNCwwLjEsMi4xLDAuMmMxLjcsMC4xLDMuMS0wLjQsNC0xLjZzMS40LTMuMSwxLjQtNS42YzAtMC40LDAtMC43LDAtMS4xYzAtMi41LTAuNS00LjMtMS40LTUuNQoJCQlzLTIuMy0xLjctNC4xLTEuNkM0MC40LDU0LjIsMzkuNyw1NC4yLDM5LDU0LjN6Ii8+CgkJPHBhdGggZmlsbD0iIzAwMDAwMCIgZD0iTTc3LjIsNzUuM2MtMiwwLjEtNC4xLDAuMi02LjEsMC4yYy0zLTUuNi02LTExLjQtOS0xN2MwLDUuNywwLDExLjQsMCwxN2MtMiwwLTQuMS0wLjEtNi4xLTAuMmMwLTksMC0xOCwwLTI3CgkJCWMyLTAuMSw0LjEtMC4yLDYuMS0wLjJjMyw1LjYsNiwxMS40LDksMTdjMC01LjcsMC0xMS40LDAtMTdjMiwwLDQuMSwwLjEsNi4xLDAuMkM3Ny4yLDU3LjMsNzcuMiw2Ni4zLDc3LjIsNzUuM3oiLz4KCQk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNOTQuMiw2Ny41YzAtMC44LTAuMy0xLjUtMC45LTEuOWMtMC42LTAuNS0xLjctMC45LTMuMy0xLjRjLTEuNi0wLjUtMi45LTEtMy45LTEuNWMtMy4zLTEuNi00LjktNC00LjktNi45CgkJCWMwLTEuNSwwLjQtMi44LDEuMi0zLjhjMC44LTEuMSwyLTEuOSwzLjUtMi4zYzEuNS0wLjUsMy4yLTAuNiw1LTAuNGMxLjgsMC4yLDMuNCwwLjcsNC45LDEuNWMxLjQsMC44LDIuNiwxLjgsMy4zLDIuOQoJCQljMC44LDEuMiwxLjIsMi40LDEuMiwzLjhjLTItMC4xLTQtMC4yLTYuMS0wLjNjMC0xLTAuMy0xLjgtMC45LTIuM2MtMC42LTAuNi0xLjUtMC45LTIuNi0xcy0xLjksMC4xLTIuNiwwLjUKCQkJYy0wLjYsMC40LTAuOSwxLTAuOSwxLjhjMCwwLjcsMC4zLDEuMywxLDEuOGMwLjcsMC42LDEuOSwxLjEsMy43LDEuN2MxLjcsMC42LDMuMiwxLjIsNC4zLDEuOGMyLjcsMS41LDQuMSwzLjMsNC4xLDUuOAoJCQljMCwxLjktMC44LDMuNi0yLjUsNC45Yy0xLjcsMS40LTMuOSwyLjMtNi44LDIuNmMtMiwwLjItMy45LDAuMS01LjYtMC42Yy0xLjctMC42LTIuOS0xLjYtMy44LTIuOWMtMC44LTEuMy0xLjMtMi44LTEuMy00LjYKCQkJYzIsMCw0LjEtMC4xLDYuMS0wLjJjMCwxLjQsMC40LDIuNCwxLjEsM3MxLjgsMC45LDMuNCwwLjdjMS0wLjEsMS44LTAuMywyLjQtMC44QzkzLjksNjguOCw5NC4yLDY4LjIsOTQuMiw2Ny41eiIvPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "document", "name": "document", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI0NTEuMjAwMDFweCIgaGVpZ2h0PSI1NDEuNzAwMDFweCIgdmlld0JveD0iMCAwIDQ1MS4yMDAwMSA1NDEuNzAwMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDQ1MS4yMDAwMSA1NDEuNzAwMDEiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8yXzFfIiBkaXNwbGF5PSJub25lIj4KPC9nPgo8ZyBpZD0iTGF5ZXJfMyI+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjMzMS4yOTk5OSw0ODcuMjk5OTkgMjc4LjEwMDAxLDQ5My42MDAwMSAyNTQuNywzNTguNzAwMDEgCgkJMjc2LjEwMDAxLDM1OS43OTk5OSA0MTMuNSw0NDAuODk5OTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzIzMUYyMCIgcG9pbnRzPSIyOTkuODk5OTksMjU3LjYwMDAxIDI5OS43MDAwMSw0ODIuMjk5OTkgMjc4LjEwMDAxLDQ5My42MDAwMSA4NS41LDM3Ny43OTk5OSA4NS41LDgwLjcgCgkJMTA2LjgsNjYuNiAyMzcuNywxNDUgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NERDlFRSIgcG9pbnRzPSI5NS43LDg0LjIgMjEzLjgsMTU1LjM5OTk5IDIyNiwxNDguMzk5OTkgMTA3LjIsNzcuMyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0REOUVFIiBwb2ludHM9IjI3Ni4xMDAwMSwyNTIuMyAyNzYuMTAwMDEsNDgwLjUgOTUuOCwzNzIuMjAwMDEgOTUuOCw4OS4xIDIxMi44LDE1OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjI4MS4yMDAwMSwyNjkuMjk5OTkgMjgwLjIwMDAxLDQ4MC42MDAwMSAyOTIuMzk5OTksNDc0LjUgMjkxLjI5OTk5LDI2My4yMDAwMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9Ijk1LjcsODQuMiAxMzMuMywxMDYuOCAxNTYuNSwxMDYuOSAxMTguNiw4NC4zIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iMTQzLjYwMDAxLDExMyAxNTkuNjAwMDEsMTIyLjQgMTgyLjcsMTIyLjUgMTY2LjYwMDAxLDExMyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjOEFBNEMxIiBwb2ludHM9IjIxOS44OTk5OSwxNTkuNyAyNzkuNzAwMDEsMjY1LjM5OTk5IDI5MC42MDAwMSwyNTkgMjMxLjMsMTUzLjEwMDAxIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMyOTQwNTkiIHBvaW50cz0iMjUzLjgsMzM3LjI5OTk5IDExOCwyNTUuODk5OTkgMTE4LDIzNy4zIDI1My44LDMxOC43MDAwMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMjk0MDU5IiBwb2ludHM9IjI1My44LDM3MS42MDAwMSAxMTgsMjkwLjIwMDAxIDExOCwyNzEuNjAwMDEgMjUzLjgsMzUzIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMyOTQwNTkiIHBvaW50cz0iMjUzLjgsNDA0LjM5OTk5IDExOCwzMjMgMTE4LDMwNC4zOTk5OSAyNTMuOCwzODUuODk5OTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzI5NDA1OSIgcG9pbnRzPSIyMTguODk5OTksNDE3LjI5OTk5IDExOCwzNTcuMzk5OTkgMTE4LDMzOC43OTk5OSAyMTguODk5OTksMzk4LjcwMDAxIAkiLz4KPC9nPgo8ZyBpZD0iTGF5ZXJfNiI+Cgk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjE5Ljg5OTk5LDE1OS43TDIxMS4yLDE1Ny4zdjc1LjU5OTk5YzAsMi44OTk5OSwxLjYwMDAxLDUuNjAwMDEsNC4xMDAwMSw3bDYwLjgsMzQuODk5OTlsMy42MDAwMS05LjM5OTk5CgkJTDIxOS44OTk5OSwxNTkuN3oiLz4KCTxwYXRoIGZpbGw9IiNDREQ5RUUiIGQ9Ik0yNzMuNSwyNjUuMzk5OTlMMjIyLjgsMjM2Yy0yLjItMS4zLTMuNjAwMDEtMy43LTMuNjAwMDEtNi4zbC0wLjMtNTkuOTAwMDFMMjczLjUsMjY1LjM5OTk5eiIvPgoJPGcgaWQ9IkxheWVyXzQiPgoJPC9nPgo8L2c+CjxnIGlkPSJMYXllcl8xXzFfIiBkaXNwbGF5PSJub25lIj4KCTxnIGRpc3BsYXk9ImlubGluZSI+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTg0Ni45MDAwMiwzMDguMjk5OTlDODQ3LDMwOS4xOTk5OCw4NDcsMzEwLjA5OTk4LDg0NywzMTFMODQ2LjkwMDAyLDMwOC4yOTk5OUw4NDYuOTAwMDIsMzA4LjI5OTk5eiIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik04NDksMzExaC0zLjc5OTk5YzAtMC43OTk5OSwwLTEuNjAwMDEtMC4wOTk5OC0yLjVsLTAuMjk5OTktMi4xMDAwMWg0LjA5OTk4TDg0OSwzMTFMODQ5LDMxMXoiLz4KCTwvZz4KCTxnIGRpc3BsYXk9ImlubGluZSI+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTg0Ni45MDAwMiwyMTIuN0M4NDcsMjEzLjU5OTk5LDg0NywyMTQuNSw4NDcsMjE1LjM5OTk5TDg0Ni45MDAwMiwyMTIuN0w4NDYuOTAwMDIsMjEyLjd6Ii8+CgkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTg0OSwyMTUuMzk5OTloLTMuNzk5OTljMC0wLjgsMC0xLjYwMDAxLTAuMDk5OTgtMi41bC0wLjI5OTk5LTIuMTAwMDFoNC4wOTk5OEw4NDksMjE1LjM5OTk5CgkJCUw4NDksMjE1LjM5OTk5eiIvPgoJPC9nPgoJPHBhdGggZGlzcGxheT0iaW5saW5lIiBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSIKCQlNODQ2LjkwMDAyLDMwOC4yOTk5OUM4NDcsMzA5LjE5OTk4LDg0NywzMTAuMDk5OTgsODQ3LDMxMUw4NDYuOTAwMDIsMzA4LjI5OTk5TDg0Ni45MDAwMiwzMDguMjk5OTl6Ii8+Cgk8cGF0aCBkaXNwbGF5PSJpbmxpbmUiIGZpbGw9IiNCMkNCRUQiIHN0cm9rZT0iIzIzMUYyMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9IgoJCU04NDYuOTAwMDIsMjEyLjdDODQ3LDIxMy41OTk5OSw4NDcsMjE0LjUsODQ3LDIxNS4zOTk5OUw4NDYuOTAwMDIsMjEyLjdMODQ2LjkwMDAyLDIxMi43eiIvPgoJPGcgZGlzcGxheT0iaW5saW5lIj4KCQk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjI3NCw1MjUuMjk5OTkgMjM5LjMsNTE4LjUgMjM4LjcsMjE4LjYwMDAxIDUxNi4yOTk5OSwzODIuMzk5OTkgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjU1LjYsNDk4LjIwMDAxIDU0LjksNDk4LjIwMDAxIDU0LjksNDk4LjcwMDAxIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iIzIzMUYyMCIgcG9pbnRzPSI1NC40LDQ5OS42MDAwMSA1NC40LDQ5Ny43MDAwMSA1Ny41LDQ5Ny43MDAwMSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNDQ0Q4RUUiIHBvaW50cz0iNTUuNiw0MTAuMjk5OTkgNTQuOSw0MTAuMjk5OTkgNTQuOSw0MTAuNzAwMDEgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjMjMxRjIwIiBwb2ludHM9IjU0LjQsNDExLjYwMDAxIDU0LjQsNDA5Ljc5OTk5IDU3LjUsNDA5Ljc5OTk5IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0NERDlFRSIgcG9pbnRzPSIyMzkuMyw0MzEuMjAwMDEgNTYuOSwzMjEuNjAwMDEgMjM4LjcsMjEyLjEwMDAxIDQyMS43MDAwMSwzMjEuNjAwMDEgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjU1LjYsMzIyLjM5OTk5IDU0LjksMzIyLjM5OTk5IDU0LjksMzIyLjc5OTk5IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0I1QzVEQyIgcG9pbnRzPSI1NC45LDM5Ny4zOTk5OSAyMzkuMyw1MDguMjAwMDEgMjM5LjMsNTA4LjIwMDAxIDIzOS4zLDQzMy43MDAwMSA1NC45LDMyMi43OTk5OSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iMjY3LDQ5MS42MDAwMSAyMzkuMyw1MDguMjAwMDEgMjM5LjMsNTA4LjIwMDAxIDIzOS4zLDQzMy43MDAwMSAyNjcsNDE3IAkJIi8+CgkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTQ2LjcsMzE4LjEwMDAxdjgzdjAuNzAwMDFMMjM5LjMsNTE3LjYwMDA0bDM3Ljk5OTk4LTIyLjc5OTk5VjQxMUw4NC4xLDI5NS4zOTk5OUw0Ni43LDMxOC4xMDAwMXoKCQkJIE0yMzcuMyw1MDQuNUw1NywzOTYuMjAwMDFWMzI2LjVsMTgwLjMsMTA4LjI5OTk5VjUwNC41eiBNNTYuOSwzMjEuNjAwMDFMODIuNCwzMDZsMTgzLDEwOS41bC0yNi4xMDAwMSwxNS43MDAwMUw1Ni45LDMyMS42MDAwMXoKCQkJIE0yNjcuMzk5OTksNDg5LjI5OTk5bC0yNi4xMDAwMSwxNS4yOTk5OXYtNjkuNzAwMDFsMjYuMTAwMDEtMTUuMjk5OTlWNDg5LjI5OTk5eiIvPgoJPC9nPgo8L2c+CjxnIGlkPSJMYXllcl81Ij4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "firewall", "name": "firewall", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NzguNXB4IiBoZWlnaHQ9IjU0OS4zcHgiIHZpZXdCb3g9IjAgMCA1NzguNSA1NDkuMyIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNTc4LjUgNTQ5LjMiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iMzk1LjYsNTE5LjkgMzMxLjksNTI1LjYgMjYxLjQsMjM1LjggNTYyLjksNDE3IAkiLz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iODYuMSwzNzguNSA4Ni40LDYzLjEgMTg1LjMsNS44IDQzMC42LDE0OCA0MzAuNiw0NjMuMSAzMzEuNCw1MjAuNiAJCSIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0xODUuMywxMS42TDQyNS42LDE1MXYzMDkuM2wtOTQuMiw1NC42TDkxLjEsMzc1LjdMOTEuNCw2NkwxODUuMywxMS42IE0xODUuMywwbC01LDIuOWwtOTQsNTQuNGwtNSwyLjkKCQkJVjY2TDgxLDM3NS43djUuOGw1LDIuOWwyNDAuMywxMzkuMmw1LDIuOWw1LTIuOWw5NC4yLTU0LjZsNS0yLjl2LTUuOFYxNTAuOXYtNS44bC01LTIuOUwxOTAuMywyLjlMMTg1LjMsMEwxODUuMywweiIvPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIxMzcuNSwyNDYuNCA5OS4yLDIyNC4zIDkxLjQsMjI4LjggMTI5LjYsMjUwLjkgMTI5LjYsMzE3LjIgMTM3LjUsMzEyLjcgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjN0YxQTBBIiBwb2ludHM9IjE1MC4yLDI1My43IDE0Mi40LDI1OC4yIDIzMC44LDMwOS4zIDIzMC44LDM3NS42IDIzOC42LDM3MS4xIDIzOC42LDMwNC44IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIyNTEuNCwzMTIuMSAyNDMuNSwzMTYuNiAzMzEuOSwzNjcuNyAzMzEuOSw0MzQgMzM5LjgsNDI5LjUgMzM5LjgsMzYzLjIgCQkiLz4KCTwvZz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iOTguOSwxNDIuMiA5MS4xLDE0Ni43IDE3OS41LDE5Ny44IDE3OS41LDI2NC4xIDE4Ny4zLDI1OS42IDE4Ny4zLDE5My4zIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMjAwLjksMjAxLjEgMTkzLjEsMjA1LjYgMjgxLjUsMjU2LjcgMjgxLjUsMzIzIDI4OS4zLDMxOC41IDI4OS4zLDI1Mi4yIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMzAzLjUsMjYwLjQgMjk1LjcsMjY0LjkgMzMwLjksMjg2LjMgMzMwLjksMzUyLjYgMzM4LjgsMzQ4LjEgMzM4LjgsMjgxLjggCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSI5OC45LDMwNC45IDkxLjEsMzA5LjQgMTc5LjUsMzYwLjQgMTc5LjUsNDI2LjcgMTg3LjMsNDIyLjIgMTg3LjMsMzU2IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMjAwLjksMzYzLjggMTkzLjEsMzY4LjMgMjgxLjUsNDE5LjMgMjgxLjUsNDg1LjYgMjg5LjMsNDgxLjEgMjg5LjMsNDE0LjggCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIzMDMuNSw0MjMgMjk1LjcsNDI3LjUgMzMwLjksNDQ3LjIgMzMwLjksNTEzLjUgMzM4LjgsNTA5IDMzOC44LDQ0Mi43IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMTM3LjUsODMuOCA5OS4yLDYxLjcgOTEuNCw2Ni4yIDEyOS42LDg4LjMgMTI5LjYsMTU0LjUgMTM3LjUsMTUwLjEgCSIvPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIxMjkuNiwxNTQuNSA5MS40LDEzMi41IDkxLjQsNjYuMiAxMjkuNiw4OC4zIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIxNzkuNSwyNjQuMSA5MS4xLDIxMyA5MS4xLDE0Ni43IDE3OS41LDE5Ny44IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIyODEuNSwzMjMgMTkzLjEsMjcxLjkgMTkzLjEsMjA1LjYgMjgxLjUsMjU2LjcgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjRjM0MTM5IiBwb2ludHM9IjMzMC45LDM1Mi42IDI5NS43LDMzMS4yIDI5NS43LDI2NC45IDMzMC45LDI4Ni4zIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIxNzkuNSw0MjYuNyA5MS4xLDM3NS43IDkxLjEsMzA5LjQgMTc5LjUsMzYwLjQgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjRjM0MTM5IiBwb2ludHM9IjI4MS41LDQ4NS42IDE5My4xLDQzNC42IDE5My4xLDM2OC4zIDI4MS41LDQxOS4zIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIzMzAuOSw1MTMuNSAyOTUuNyw0OTMuOCAyOTUuNyw0MjcuNSAzMzAuOSw0NDcuMiAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNGMzQxMzkiIHBvaW50cz0iMzMxLjksMjcxLjQgMjQzLjUsMjIwLjMgMjQzLjUsMTU0IDMzMS45LDIwNS4xIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIxMjkuNiwzMTcuMiA5MS40LDI5NS4xIDkxLjQsMjI4LjggMTI5LjYsMjUwLjkgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjRjM0MTM5IiBwb2ludHM9IjIzMC44LDM3NS42IDE0Mi40LDMyNC41IDE0Mi40LDI1OC4yIDIzMC44LDMwOS4zIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0YzNDEzOSIgcG9pbnRzPSIzMzEuOSw0MzQgMjQzLjUsMzgyLjkgMjQzLjUsMzE2LjYgMzMxLjksMzY3LjcgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjRjM0MTM5IiBwb2ludHM9IjIzMC44LDIxMi45IDE0Mi40LDE2MS45IDE0Mi40LDk1LjYgMjMwLjgsMTQ2LjcgCQkiLz4KCTwvZz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMTUwLjIsOTEuMSAxNDIuNCw5NS42IDIzMC44LDE0Ni43IDIzMC44LDIxMi45IDIzOC42LDIwOC41IDIzOC42LDE0Mi4yIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMjUxLjQsMTQ5LjUgMjQzLjUsMTU0IDMzMS45LDIwNS4xIDMzMS45LDI3MS40IDMzOS44LDI2Ni45IDMzOS44LDIwMC42IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGNzdGN0YiIHBvaW50cz0iMTI5LjYsODguMyAyMjMuOCwzMy42IDE4NS4zLDExLjYgOTEuNCw2NiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRjc3RjdGIiBwb2ludHM9IjE0Mi40LDk1LjYgMjM2LjYsNDEgMzI1LDkyIDIzMC44LDE0Ni43IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGNzdGN0YiIHBvaW50cz0iMjQzLjUsMTU0IDMzNy43LDk5LjQgNDI2LjEsMTUwLjQgMzMxLjksMjA1LjEgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIzMzEuNCwyNzAuOSA0MjUuNiwyMTYuMyA0MjUuNiwxNTAgMzMxLjQsMjA0LjYgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIzMzEuNCwzNTEuMyA0MjUuNiwyOTYuNiA0MjUuNiwyMzAuMyAzMzEuNCwyODUgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIzMzEuNCw0MzMuOCA0MjUuNiwzNzkuMSA0MjUuNiwzMTIuOCAzMzEuNCwzNjcuNSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjN0YxQTBBIiBwb2ludHM9IjMzMS40LDUxMy45IDQyNS42LDQ1OS4zIDQyNS42LDM5MyAzMzEuNCw0NDcuNiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjN0YxQTBBIiBwb2ludHM9IjQyNi4xLDE1MC40IDQyNi4xLDE1OC4xIDMzMS45LDIxMi43IDMzMS45LDIwNS4xIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3RjFBMEEiIHBvaW50cz0iMjIzLjcsMzMuNyAyMjMuNyw0MSAxMjkuNiw5NS42IDEyOS42LDg4LjMgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGMUEwQSIgcG9pbnRzPSIyMzAuOCwxNDYuNyAyMzAuOCwxNTQgMzI1LDk5LjQgMzI1LDkyLjEgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzZCMEMwMyIgcG9pbnRzPSI0MjUuNiwyMjQuNSAzMjYuNCwyODEuNyAzMzEuNCwyODUgNDI1LjYsMjMwLjMgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzZCMEMwMyIgcG9pbnRzPSI0MjUuNiwzMDcuMyAzMjYuNCwzNjQuNSAzMzEuNCwzNjcuOCA0MjUuNiwzMTMuMiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNkIwQzAzIiBwb2ludHM9IjQyNS42LDM4Ny4xIDMyNi40LDQ0NC4zIDMzMS40LDQ0Ny42IDQyNS42LDM5MyAJIi8+Cgk8cGF0aCBvcGFjaXR5PSIwLjU5IiBmaWxsPSIjMjMxRjIwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgZD0iTTIxMy43LDUxMmw1MS44LDMuMmwyNy44LTE2LjFjMC0zNi42LDAtNjAuMywwLTk2LjkKCQljMC03LjksMC0xOC45LTUuNy0yOC41Yy02LjUtMTEtOS4zLTExLjctMTYuOC0xNi4xYy0yOC42LTE2LjctNzUuMi00My40LTEwMy43LTYwYy0yLjksNS01LjgsNS4xLTguNywxMC4xTDIxMy43LDUxMnoiLz4KCTxnIGlkPSJMYXllcl8yXzFfIj4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iI0ZGOTYwMCIgZD0iTTI1Ny41LDM3OC40djk2LjhjMCw0LjktMS42LDguNC00LjIsMTBsLTM2LjQsMjFjMi41LTEuNiw0LjEtNS4xLDQuMS0xMHYtOTYuOGMwLTkuMi01LjYtMjAtMTIuNS0yNAoJCQkJbC04Mi40LTQ3LjZjLTIuNy0xLjYtNS4zLTEuOS03LjMtMS4xbDAsMGwzNC44LTIwLjFjMi4zLTEuOCw1LjUtMiw5LDAuMWw4Mi40LDQ3LjZjMy4zLDEuOSw2LjIsNS4zLDguNSw5LjMKCQkJCUMyNTUuOSwzNjguMiwyNTcuNSwzNzMuNSwyNTcuNSwzNzguNHoiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiNGRkQ0NEQiIGQ9Ik0yMjEsNDQ2di00Ni42YzAtOS4yLTUuNi0yMC0xMi41LTI0bC04Mi40LTQ3LjZjLTIuNy0xLjYtNS4zLTEuOS03LjMtMS4xbDAsMGwzNC44LTIwLjEKCQkJCWMyLjMtMS44LDUuNS0yLDksMC4xbDgyLjQsNDcuNmMzLjMsMS45LDYuMiw1LjMsOC41LDkuM0MyNTEuOCwzOTUsMjM5LjksNDIzLjUsMjIxLDQ0NnoiLz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTIyMy40LDM3NC42Yy0wLjIsMC0wLjQsMC0wLjYsMGgtMC4xaC0wLjlsMCwwYy0zLjUtMC4yLTYuOS0xLjItOS42LTIuOGMtMy43LTIuMS01LjYtNS4xLTUtOHYtMzYuMwoJCQkJCWMwLTIwLjItMTIuMy00My42LTI3LjQtNTIuNGMtMy43LTIuMS03LjItMy4yLTEwLjYtMy4yYy0wLjIsMC0wLjUsMC0wLjcsMGMtMi43LDQuMS00LjEsOS45LTQuMSwxNi44djM2LjQKCQkJCQljMC4yLDIuNC0xLjEsNC41LTMuNyw2Yy0wLjQsMC4zLTAuOSwwLjUtMS40LDAuN2MtMC4zLDAuMS0wLjcsMC4zLTEsMC40cy0wLjcsMC4yLTEuMSwwLjNjLTAuNSwwLjEtMS4xLDAuMy0xLjcsMC40aC0wLjEKCQkJCQljLTAuMywwLTAuNiwwLjEtMC45LDAuMWgtMC4yYy0wLjMsMC0wLjYsMC4xLTAuOCwwLjFoLTAuMmMtMC4zLDAtMC41LDAtMC44LDBIMTUyYy0wLjIsMC0wLjQsMC0wLjYsMHYtMC45djAuOWgtMC4xaC0wLjlsMCwwCgkJCQkJYy0zLjUtMC4yLTYuOS0xLjItOS42LTIuOGMtMy42LTIuMS01LjUtNS01LjEtNy45di0zNy41YzAtMTAuNSwyLjEtMTkuNyw2LjEtMjYuNWMwLjEtMC4yLDEuNi0zLjUsNS4xLTcuM2wwLjMtMC4zCgkJCQkJYzAuMy0wLjMsMC42LTAuNiwwLjktMC45YzAuMy0wLjMsMC42LTAuNiwwLjktMC45YzAuMy0wLjMsMC42LTAuNiwxLTAuOWMwLjMtMC4zLDAuNy0wLjYsMS0wLjljMC4yLTAuMSwwLjMtMC4zLDAuNS0wLjQKCQkJCQljMCwwLDAuMi0wLjEsMC4yLTAuMnYtMC41aDAuNmMwLjEtMC4xLDAuNC0wLjMsMC40LTAuM2MwLjItMC4xLDAuMy0wLjIsMC41LTAuNGMwLjItMC4xLDAuNC0wLjMsMC42LTAuNAoJCQkJCWMwLjItMC4xLDAuNC0wLjMsMC42LTAuNGMwLDAsMC41LTAuMywwLjctMC40bDAuMy0wLjJjMC41LTAuMywxLTAuNiwxLjUtMC44YzAuMS0wLjEsMC4zLTAuMiwwLjQtMC4yYzAuMS0wLjEsMC4yLTAuMSwwLjQtMC4yCgkJCQkJYzAuMy0wLjEsMC42LTAuMywwLjktMC40YzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4yYzAuMSwwLDAuMi0wLjEsMC4zLTAuMWMwLjEtMC4xLDAuMy0wLjEsMC41LTAuMmMwLjItMC4xLDAuNS0wLjIsMC43LTAuMwoJCQkJCXMwLjUtMC4yLDAuNy0wLjNoMC4xYzAuMy0wLjEsMC41LTAuMiwwLjctMC4zYzAuMy0wLjEsMC41LTAuMiwwLjgtMC4zaDAuMWMwLjItMC4xLDAuNS0wLjEsMC43LTAuMmMwLjItMC4xLDAuNS0wLjEsMC43LTAuMgoJCQkJCWwwLjMtMC4xYzAuMi0wLjEsMC40LTAuMSwwLjYtMC4yYzAuMi0wLjEsMC41LTAuMSwwLjctMC4yYzAsMCwwLjEsMCwwLjIsMGMwLjMtMC4xLDAuNS0wLjEsMC44LTAuMmMwLjMtMC4xLDAuNi0wLjEsMC45LTAuMQoJCQkJCWMxLjktMC41LDMuOC0wLjcsNS44LTAuN2MxLjMsMCwyLjUsMC4xLDMuOCwwLjNjNC41LDAuNiw5LjIsMi4zLDE0LDUuMWMyNC4zLDE0LjEsNDQuMiw1MS45LDQ0LjIsODQuM3YzOS40bC0wLjEtMC4xCgkJCQkJYy0wLjQsMS43LTEuNiwzLjItMy42LDQuNGMtMC40LDAuMy0wLjksMC41LTEuNCwwLjdjLTAuNCwwLjEtMC43LDAuMy0xLDAuNHMtMC43LDAuMi0xLjEsMC4zYy0wLjUsMC4xLTEuMSwwLjMtMS43LDAuNGgtMC4xCgkJCQkJYy0wLjMsMC0wLjYsMC4xLTAuOSwwLjFoLTAuMmMtMC4zLDAtMC42LDAuMS0wLjgsMC4xaC0wLjJjLTAuMywwLTAuNiwwLTAuOSwwTDIyMy40LDM3NC42eiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTE3My45LDI0MC4yYzEuMiwwLDIuNCwwLjEsMy43LDAuM2M0LjMsMC42LDguOSwyLjIsMTMuNiw1YzI0LjEsMTMuOSw0My43LDUxLjMsNDMuNyw4My41djM3LjdsMCwwCgkJCQkJYzAuMiwxLjktMC45LDMuOC0zLjMsNS4yYy0wLjQsMC4yLTAuOSwwLjUtMS4zLDAuNmMtMC4zLDAuMS0wLjYsMC4yLTEsMC40Yy0wLjMsMC4xLTAuNywwLjItMSwwLjNjLTAuNSwwLjEtMSwwLjItMS42LDAuM2wwLDAKCQkJCQljLTAuMywwLjEtMC42LDAuMS0xLDAuMWMtMC4xLDAtMC4xLDAtMC4yLDBjLTAuMywwLTAuNSwwLjEtMC44LDAuMWMtMC4xLDAtMC4xLDAtMC4yLDBjLTAuMywwLTAuNSwwLTAuOCwwYy0wLjEsMC0wLjEsMC0wLjIsMAoJCQkJCWMtMC4xLDAtMC4yLDAtMC4zLDBjLTAuMiwwLTAuNCwwLTAuNiwwbDAsMGwwLDBsMCwwbDAsMGMtMy41LTAuMS03LjItMS0xMC0yLjdjLTMuNS0yLTUuMS00LjctNC42LTcuMXYtMzYuMwoJCQkJCWMwLTIwLjUtMTIuNS00NC4zLTI3LjgtNTMuMmMtMy45LTIuMy03LjctMy4zLTExLTMuM2MtMC40LDAtMC44LDAtMS4yLDBsMCwwbDAsMGMtMi45LDQuMi00LjUsMTAuMi00LjUsMTcuN3YzNi41CgkJCQkJYzAuMiwxLjktMC45LDMuOC0zLjMsNS4xYy0wLjQsMC4yLTAuOSwwLjUtMS4zLDAuNmMtMC4zLDAuMS0wLjYsMC4yLTEsMC40Yy0wLjMsMC4xLTAuNywwLjItMSwwLjNjLTAuNSwwLjEtMSwwLjItMS42LDAuM2wwLDAKCQkJCQljLTAuMywwLjEtMC42LDAuMS0xLDAuMWMtMC4xLDAtMC4xLDAtMC4yLDBjLTAuMywwLTAuNSwwLjEtMC44LDAuMWMtMC4xLDAtMC4yLDAtMC4yLDBjLTAuMywwLTAuNSwwLTAuOCwwYy0wLjEsMC0wLjIsMC0wLjMsMAoJCQkJCXMtMC4xLDAtMC4yLDBjLTAuMiwwLTAuNCwwLTAuNiwwbDAsMGwwLDBjMCwwLDAsMC0wLjEsMGwwLDBjLTMuNS0wLjEtNy4xLTEtMTAtMi43Yy0zLjQtMi01LTQuNi00LjYtN3YtMzcuNgoJCQkJCWMwLTEwLjgsMi4yLTE5LjYsNi0yNi4xbDAsMGMwLDAsMS41LTMuMyw1LTdjMC4xLTAuMSwwLjItMC4yLDAuMy0wLjNjMC4zLTAuMywwLjYtMC42LDAuOS0wLjljMC4zLTAuMywwLjYtMC42LDAuOS0wLjgKCQkJCQljMC4zLTAuMywwLjYtMC42LDAuOS0wLjhjMC4zLTAuMywwLjctMC42LDEtMC44YzAuMi0wLjEsMC40LTAuMywwLjUtMC40YzAuMi0wLjEsMC40LTAuMywwLjUtMC40bDAsMGMwLjItMC4xLDAuMy0wLjIsMC41LTAuNAoJCQkJCWwwLjEtMC4xYzAuMi0wLjEsMC4zLTAuMiwwLjUtMC40YzAuMi0wLjEsMC40LTAuMywwLjYtMC40YzAuMi0wLjEsMC40LTAuMywwLjYtMC40YzAuMi0wLjEsMC40LTAuMywwLjYtMC40CgkJCQkJYzAuMSwwLDAuMi0wLjEsMC4zLTAuMWMwLjUtMC4zLDEtMC42LDEuNS0wLjhjMC4xLTAuMSwwLjMtMC4xLDAuNC0wLjJjMC4xLTAuMSwwLjItMC4xLDAuMy0wLjJjMC4zLTAuMSwwLjYtMC4zLDAuOS0wLjQKCQkJCQljMC4yLTAuMSwwLjMtMC4xLDAuNS0wLjJjMC4xLDAsMC4yLTAuMSwwLjMtMC4xYzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4yYzAuMi0wLjEsMC41LTAuMiwwLjctMC4zczAuNS0wLjIsMC43LTAuM2wwLDAKCQkJCQljMC4yLTAuMSwwLjUtMC4yLDAuNy0wLjNsMCwwYzAuMi0wLjEsMC41LTAuMiwwLjgtMC4yYzAsMCwwLDAsMC4xLDBjMC4yLTAuMSwwLjUtMC4xLDAuNy0wLjJjMC4yLTAuMSwwLjUtMC4xLDAuNy0wLjIKCQkJCQljMC4xLDAsMC4yLDAsMC4zLTAuMWMwLjItMC4xLDAuNC0wLjEsMC42LTAuMWMwLjItMC4xLDAuNS0wLjEsMC43LTAuMmMwLjEsMCwwLjEsMCwwLjIsMGMwLjMtMC4xLDAuNS0wLjEsMC44LTAuMgoJCQkJCWMwLjMtMC4xLDAuNi0wLjEsMC45LTAuMUMxNzAuMSwyNDAuNCwxNzIsMjQwLjIsMTczLjksMjQwLjIgTTE3My45LDIzOC4zTDE3My45LDIzOC4zYy0yLjEsMC00LjEsMC4yLTYsMC43CgkJCQkJYy0wLjMsMC0wLjYsMC4xLTAuOCwwLjFjLTAuMywwLTAuNSwwLjEtMC44LDAuMmMtMC4xLDAtMC4xLDAtMC4yLDBjLTAuMiwwLjEtMC41LDAuMS0wLjcsMC4yYy0wLjIsMC4xLTAuNCwwLjEtMC43LDAuMgoJCQkJCWwtMC4yLDAuMWgtMC4xYy0wLjMsMC4xLTAuNSwwLjEtMC44LDAuMmMtMC4yLDAuMS0wLjUsMC4xLTAuNywwLjJoLTAuMWwwLDBsMCwwYy0wLjMsMC4xLTAuNSwwLjItMC44LDAuM2wwLDBsMCwwCgkJCQkJYy0wLjIsMC4xLTAuNSwwLjItMC43LDAuM2gtMC4xYy0wLjIsMC4xLTAuNSwwLjItMC43LDAuM2MtMC4zLDAuMS0wLjUsMC4yLTAuOCwwLjNjLTAuMiwwLjEtMC4zLDAuMS0wLjUsMC4yCgkJCQkJYy0wLjEsMC0wLjIsMC4xLTAuMywwLjFjLTAuMiwwLjEtMC4zLDAuMS0wLjUsMC4yYy0wLjMsMC4xLTAuNiwwLjMtMC45LDAuNGMtMC4xLDAuMS0wLjIsMC4xLTAuNCwwLjJjLTAuMiwwLjEtMC4zLDAuMi0wLjUsMC4yCgkJCQkJYy0wLjUsMC4zLTEuMSwwLjYtMS42LDAuOWwtMC4xLDAuMWwtMC4xLDAuMWwwLDBsMCwwYy0wLjIsMC4xLTAuNCwwLjItMC42LDAuM2gtMC4xbDAsMGwwLDBjLTAuMiwwLjEtMC40LDAuMy0wLjYsMC40CgkJCQkJYy0wLjIsMC4xLTAuNCwwLjMtMC42LDAuNGMtMC4yLDAuMS0wLjQsMC4yLTAuNSwwLjRsLTAuMSwwLjFsMCwwbDAsMEgxNTF2MWMtMC4xLDAuMS0wLjMsMC4yLTAuNCwwLjNjLTAuNCwwLjMtMC43LDAuNi0xLjEsMC45CgkJCQkJYy0wLjQsMC4zLTAuNywwLjYtMSwwLjljLTAuMywwLjMtMC42LDAuNi0wLjksMC45Yy0wLjMsMC4zLTAuNiwwLjYtMC45LDFjLTAuMSwwLjEtMC4yLDAuMi0wLjMsMC40Yy0zLjQsMy43LTUsNy01LjMsNy41CgkJCQkJYy00LjEsNi45LTYuMiwxNi4yLTYuMiwyNi45djM3LjRjLTAuNSwzLjIsMS42LDYuNSw1LjUsOC44YzIuNiwxLjUsNS44LDIuNSw5LjEsMi44djAuMWwxLjgsMC4xaDAuMWwwLDBsMCwwYzAuMiwwLDAuNCwwLDAuNiwwCgkJCQkJaDAuMmMwLjEsMCwwLjIsMCwwLjMsMGMwLjMsMCwwLjYsMCwwLjgsMGgwLjFjMC4xLDAsMC4xLDAsMC4yLDBjMC4zLDAsMC42LDAsMC45LTAuMWgwLjFoMC4xYzAuMywwLDAuNi0wLjEsMC45LTAuMWwwLDBoMC4yCgkJCQkJYzAuNi0wLjEsMS4yLTAuMiwxLjgtMC40YzAuNC0wLjEsMC44LTAuMiwxLjEtMC4zYzAuNC0wLjEsMC43LTAuMywxLjEtMC40YzAuNi0wLjIsMS4xLTAuNSwxLjUtMC44YzIuOS0xLjcsNC40LTQuMiw0LjItNi45CgkJCQkJVjI4OWMwLTYuNCwxLjMtMTEuOSwzLjctMTUuOGMwLjEsMCwwLjEsMCwwLjIsMGMzLjIsMCw2LjUsMSwxMC4xLDMuMWMxNC44LDguNiwyNi45LDMxLjcsMjYuOSw1MS41VjM2NAoJCQkJCWMtMC42LDMuMywxLjUsNi42LDUuNSw4LjljMi42LDEuNSw1LjgsMi41LDkuMSwyLjh2MC4xbDEuOCwwLjFoMC4xbDAsMGMwLjIsMCwwLjQsMCwwLjYsMGMwLjEsMCwwLjIsMCwwLjMsMHMwLjEsMCwwLjIsMAoJCQkJCWMwLjMsMCwwLjYsMCwwLjksMGgwLjFoMC4xYzAuMywwLDAuNiwwLDAuOS0wLjFoMC4xaDAuMWMwLjMsMCwwLjYtMC4xLDAuOS0wLjFsMCwwbDAsMGwwLDBoMC4xYzAuNi0wLjEsMS4yLTAuMiwxLjgtMC40CgkJCQkJYzAuNC0wLjEsMC43LTAuMiwxLjEtMC4zYzAuNC0wLjEsMC43LTAuMiwxLjEtMC40YzAuNi0wLjIsMS4xLTAuNSwxLjUtMC44YzEuNy0xLDMtMi4zLDMuNi0zLjhsMC42LDAuNFYzNjd2LTM3LjcKCQkJCQljMC0xNS45LTQuNi0zMy40LTEzLTQ5LjRjLTguNC0xNi4xLTE5LjYtMjguNy0zMS42LTM1LjdjLTQuOS0yLjgtOS43LTQuNi0xNC4zLTUuMkMxNzYuNSwyMzguNCwxNzUuMiwyMzguMywxNzMuOSwyMzguMwoJCQkJCUwxNzMuOSwyMzguM3oiLz4KCQkJPC9nPgoJCTwvZz4KCQk8Zz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjRDFEM0Q0IiBkPSJNMTY4LDI3MS4xTDE2OCwyNzEuMUwxNjgsMjcxLjFjLTIuOSw0LjItNC41LDEwLjItNC41LDE3Ljd2MzYuNWMwLjIsMS45LTAuOSwzLjgtMy4zLDUuMQoJCQkJCWMtMC40LDAuMi0wLjksMC41LTEuMywwLjZjLTAuMywwLjEtMC42LDAuMi0xLDAuNGMtMC4zLDAuMS0wLjcsMC4yLTEsMC4zYy0wLjUsMC4xLTEsMC4yLTEuNiwwLjNsMCwwYy0xLjMsMC4yLTIuNywwLjMtNC4xLDAuMwoJCQkJCXYtMzQuOGMwLTcuNSwxLjctMTMuNSw0LjUtMTcuN0MxNTcuNSwyNzYuNiwxNjMuNCwyNzEuNywxNjgsMjcxLjFDMTY3LjksMjcxLjEsMTY3LjksMjcxLjEsMTY4LDI3MS4xeiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0QxRDNENCIgZD0iTTIzNC45LDMyOC45YzAtMzIuMi0xOS42LTY5LjUtNDMuNy04My41Yy00LjgtMi43LTkuMy00LjQtMTMuNi01Yy0zLjMtMC41LTYuNC0wLjMtOS4zLDAuNAoJCQkJCWMtMTAsMS42LTE2LjcsNi43LTIwLjcsMTEuMWMyLjUtMS4xLDUuMy0xLjksOC41LTIuNGMyLjktMC43LDYtMC45LDkuMy0wLjRjNC4zLDAuNiw4LjksMi4yLDEzLjYsNQoJCQkJCWMyNC4xLDEzLjksNDMuNyw1MS4zLDQzLjcsODMuNXYzNi4xYzMuMywwLjEsNi42LTAuNSw5LTEuOXMzLjQtMy4yLDMuMy01LjJsMCwwTDIzNC45LDMyOC45TDIzNC45LDMyOC45eiIvPgoJCQk8L2c+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xNzQuMSwyNDAuMgoJCQkJYzEuMiwwLDIuNCwwLjEsMy43LDAuM2MwLjcsMC4xLDEuNCwwLjIsMi4xLDAuNGMyLjksMC42LDUuOCwxLjcsOC45LDMuMmMwLjksMC40LDEuOCwwLjksMi43LDEuNGMxNSw4LjYsMjguMSwyNi4zLDM2LDQ2LjEKCQkJCWMwLjIsMC42LDAuNSwxLjIsMC43LDEuOGMwLjEsMC4yLDAuMSwwLjMsMC4yLDAuNWM0LjMsMTEuNSw2LjgsMjMuNiw2LjgsMzVsMCwwdjE5LjdsMTAsNS44YzMuMywxLjksNi4yLDUuMyw4LjUsOS4zCgkJCQljMi41LDQuNSw0LjEsOS44LDQuMSwxNC43djk2LjhjMCw0LjktMS42LDguNC00LjIsMTBsLTM2LjQsMjFjLTEsMC43LTIuMiwxLTMuNSwxYy0xLjUsMC0zLjItMC41LTUtMS41bC04Mi40LTQ3LjYKCQkJCWMtNi45LTQtMTIuNS0xNC43LTEyLjUtMjR2LTcuOHYtODkuMWMwLTUuNiwyLjEtOS4zLDUuMi0xMC42bDAsMGwwLDBsMTcuOC0xMC4zdi0zMS41YzAtMTAuOCwyLjItMTkuNiw2LTI2LjFsMCwwCgkJCQljMCwwLDEuNS0zLjMsNS03YzAuMS0wLjEsMC4yLTAuMiwwLjMtMC4zczAuMi0wLjIsMC4yLTAuMmMwLjItMC4yLDAuMy0wLjQsMC41LTAuNWMwLjEtMC4xLDAuMS0wLjEsMC4xLTAuMgoJCQkJYzAuMS0wLjEsMC4xLTAuMSwwLjItMC4yYzAuMS0wLjEsMC4zLTAuMywwLjUtMC40YzAuMS0wLjEsMC4yLTAuMiwwLjItMC4yYzAuMS0wLjEsMC4zLTAuMywwLjQtMC40bDAuMS0wLjEKCQkJCWMwLjEtMC4xLDAuMy0wLjIsMC40LTAuNGMwLjEtMC4xLDAuMS0wLjEsMC4yLTAuMmMwLjItMC4yLDAuNC0wLjMsMC42LTAuNWMwLjEtMC4xLDAuMS0wLjEsMC4yLTAuMmMwLjEtMC4xLDAuMi0wLjIsMC40LTAuMwoJCQkJYzAuMS0wLjEsMC4yLTAuMiwwLjMtMC4zczAuMi0wLjIsMC40LTAuM2wwLDBjMC4yLTAuMSwwLjMtMC4yLDAuNS0wLjRsMCwwYzAsMCwwLjEsMCwwLjEtMC4xbDAsMGMwLjItMC4xLDAuMy0wLjIsMC41LTAuNAoJCQkJYzAuMi0wLjEsMC4zLTAuMiwwLjUtMC4zYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWMwLjItMC4xLDAuMy0wLjIsMC41LTAuM3MwLjQtMC4yLDAuNi0wLjNsMC4xLTAuMWMwLjEsMCwwLjEtMC4xLDAuMi0wLjEKCQkJCWMwLjMtMC4yLDAuNi0wLjQsMC45LTAuNWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMXMwLjItMC4xLDAuMy0wLjFjMC4xLTAuMSwwLjMtMC4xLDAuNC0wLjJjMC4xLTAuMSwwLjItMC4xLDAuMy0wLjJoMC4xCgkJCQljMC4xLDAsMC4yLTAuMSwwLjItMC4xYzAuMi0wLjEsMC40LTAuMiwwLjYtMC4zYzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4yYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWgwLjFjMC4yLTAuMSwwLjMtMC4xLDAuNS0wLjIKCQkJCWMwLjItMC4xLDAuNS0wLjIsMC43LTAuM2MwLjEsMCwwLjItMC4xLDAuMi0wLjFzMC4xLDAsMC4xLTAuMWMwLjEsMCwwLjItMC4xLDAuMy0wLjFsMCwwYzAuMi0wLjEsMC41LTAuMiwwLjctMC4zbDAsMAoJCQkJYzAuMSwwLDAuMi0wLjEsMC4zLTAuMWgwLjFjMC4xLDAsMC4yLTAuMSwwLjMtMC4xYzAsMCwwLDAsMC4xLDBjMC4yLTAuMSwwLjUtMC4xLDAuNy0wLjJjMC4yLTAuMSwwLjQtMC4xLDAuNi0wLjJsMCwwCgkJCQljMC4xLDAsMC4xLDAsMC4yLDBjMC4xLDAsMC4yLDAsMC4zLTAuMWMwLjItMC4xLDAuNC0wLjEsMC42LTAuMWMwLjItMC4xLDAuNS0wLjEsMC43LTAuMmMwLjEsMCwwLjEsMCwwLjIsMAoJCQkJYzAuMy0wLjEsMC41LTAuMSwwLjgtMC4yYzAuMy0wLjEsMC42LTAuMSwwLjktMC4xQzE3MC4zLDI0MC40LDE3Mi4xLDI0MC4yLDE3NC4xLDI0MC4yIE0xNjguMiwyNzEuMUwxNjguMiwyNzEuMUwxNjguMiwyNzEuMQoJCQkJYy0yLjksNC4yLTQuNSwxMC4yLTQuNSwxNy43djE4LjZsNDQuNywyNS44di01LjZjMC0yMC41LTEyLjUtNDQuMy0yNy44LTUzLjJjLTMuOS0yLjMtNy43LTMuMy0xMS0zLjMKCQkJCUMxNjksMjcxLDE2OC42LDI3MSwxNjguMiwyNzEuMSBNMTc0LjEsMjM1LjRjLTIuMywwLTQuNSwwLjMtNi42LDAuOGMtMC4zLDAtMC41LDAuMS0wLjgsMC4xYy0wLjMsMC4xLTAuNiwwLjEtMC44LDAuMgoJCQkJYy0wLjEsMC0wLjIsMC0wLjMsMC4xYy0wLjMsMC4xLTAuNSwwLjEtMC44LDAuMmMtMC4yLDAuMS0wLjUsMC4xLTAuNywwLjJsLTAuMywwLjFjMCwwLDAsMC0wLjEsMGgtMC4xbDAsMGwwLDBoLTAuMQoJCQkJYy0wLjIsMC0wLjQsMC4xLTAuNSwwLjFjLTAuMiwwLjEtMC41LDAuMS0wLjgsMC4yaC0wLjFjLTAuMSwwLTAuMiwwLjEtMC4zLDAuMWgtMC4xbC0wLjIsMC4xaC0wLjFjLTAuMSwwLTAuMiwwLjEtMC4zLDAuMWwwLDAKCQkJCWMtMC4zLDAuMS0wLjUsMC4yLTAuOCwwLjNjMCwwLTAuMSwwLTAuMSwwLjFjLTAuMSwwLTAuMiwwLjEtMC4yLDAuMWgtMC4xbC0wLjIsMC4xbDAsMGMtMC4xLDAtMC4yLDAuMS0wLjIsMC4xCgkJCQljLTAuMywwLjEtMC41LDAuMi0wLjgsMC4zYy0wLjIsMC4xLTAuNCwwLjEtMC41LDAuMmwwLDBsMCwwYy0wLjEsMC0wLjIsMC4xLTAuMywwLjFjLTAuMiwwLjEtMC4zLDAuMi0wLjUsMC4yCgkJCQljLTAuMiwwLjEtMC4zLDAuMi0wLjUsMC4ybC0wLjEsMC4xaC0wLjFjLTAuMSwwLTAuMSwwLjEtMC4yLDAuMWMwLDAtMC4xLDAtMC4xLDAuMWMtMC4xLDAuMS0wLjMsMC4xLTAuNCwwLjIKCQkJCWMtMC4yLDAuMS0wLjMsMC4yLTAuNSwwLjJjLTAuMSwwLTAuMiwwLjEtMC4yLDAuMWgtMC4xaC0wLjFjLTAuMSwwLTAuMiwwLjEtMC4zLDAuMWMtMC40LDAuMi0wLjgsMC40LTEuMSwwLjYKCQkJCWMtMC4xLDAtMC4xLDAuMS0wLjIsMC4xaC0wLjFsLTAuMSwwLjFsMCwwYy0wLjIsMC4xLTAuNCwwLjItMC42LDAuNGMwLDAsMCwwLTAuMSwwYy0wLjIsMC4xLTAuNCwwLjItMC42LDAuNGwtMC4yLDAuMgoJCQkJYy0wLjIsMC4xLTAuNCwwLjItMC41LDAuNGMtMC4yLDAuMS0wLjQsMC4zLTAuNiwwLjRsMCwwYzAsMCwwLDAtMC4xLDBjMCwwLDAsMC0wLjEsMGwwLDBsMCwwYy0wLjIsMC4xLTAuMywwLjItMC41LDAuNGwwLDAKCQkJCWMtMC4xLDAuMS0wLjIsMC4yLTAuMywwLjJsLTAuMSwwLjFjLTAuMSwwLjEtMC4zLDAuMi0wLjQsMC4zaC0wLjFjLTAuMSwwLjEtMC4yLDAuMi0wLjQsMC4zYy0wLjEsMC4xLTAuMSwwLjEtMC4yLDAuMmwwLDBsMCwwCgkJCQljLTAuMiwwLjItMC40LDAuNC0wLjcsMC42bDAsMGMtMC4xLDAuMS0wLjIsMC4xLTAuMiwwLjJjLTAuMSwwLjEtMC4zLDAuMy0wLjQsMC40bDAsMGgtMC4xbDAsMGMtMC4yLDAuMS0wLjMsMC4zLTAuNSwwLjRsMCwwCgkJCQljLTAuMSwwLjEtMC4xLDAuMS0wLjIsMC4ybC0wLjEsMC4xYy0wLjIsMC4yLTAuMywwLjMtMC41LDAuNWwwLDBjLTAuMSwwLjEtMC4xLDAuMS0wLjIsMC4yYzAsMC0wLjEsMC4xLTAuMiwwLjJsMCwwbDAsMAoJCQkJYy0wLjIsMC4yLTAuNCwwLjQtMC42LDAuNmwwLDBjLTAuMSwwLjEtMC4xLDAuMS0wLjIsMC4ybDAsMGwwLDBjLTAuMSwwLjEtMC4yLDAuMi0wLjMsMC4zYy0zLjUsMy44LTUuMiw3LjEtNS43LDgKCQkJCWMtNC4zLDcuNC02LjYsMTcuMS02LjYsMjguM3YyOC43bC0xNS4yLDguOGMtNC45LDIuMS03LjgsNy42LTcuOCwxNC44VjQyNnY3LjhjMCwxMC45LDYuNiwyMy4yLDE0LjksMjguMWw4Mi40LDQ3LjYKCQkJCWMyLjUsMS40LDQuOSwyLjEsNy4zLDIuMWMyLjEsMCw0LjItMC42LDYtMS43bDM2LjMtMjFsMC4xLTAuMWM0LjEtMi42LDYuNC03LjYsNi40LTE0LjF2LTk2LjhjMC01LjUtMS43LTExLjYtNC43LTE3CgkJCQljLTIuNy00LjktNi40LTguOS0xMC4zLTExLjFsLTcuNy00LjR2LTE3YzAtMTEuNi0yLjQtMjQuMy03LjEtMzYuN2wtMC4xLTAuMmMwLTAuMS0wLjEtMC4zLTAuMS0wLjRjLTAuMi0wLjYtMC41LTEuMi0wLjctMS45CgkJCQljLTQuMS0xMC4zLTkuNi0yMC4xLTE2LTI4LjRjLTYuNy04LjctMTQuMy0xNS42LTIyLTIwLjFjLTEtMC42LTItMS4xLTIuOS0xLjZjLTMuNC0xLjctNi43LTIuOS0xMC0zLjZjLTAuOC0wLjItMS42LTAuMy0yLjQtMC40CgkJCQlDMTc3LDIzNS41LDE3NS41LDIzNS40LDE3NC4xLDIzNS40TDE3NC4xLDIzNS40eiBNMTY4LjQsMzA0LjZ2LTE1LjhjMC01LjEsMC45LTkuNSwyLjUtMTIuOWMyLjIsMC4zLDQuNywxLjEsNy4yLDIuNgoJCQkJYzEzLjEsNy42LDI0LjMsMjguMywyNS40LDQ2LjRMMTY4LjQsMzA0LjZMMTY4LjQsMzA0LjZ6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRkZCQzAwIiBkPSJNMjIxLDM5OS40djk2LjhjMCw5LjItNS42LDEzLjUtMTIuNSw5LjVMMTY1LjcsNDgxbC0yNi41LTE1LjNsLTEyLjMtNy4xbC0wLjgtMC41CgkJCQljLTYuOS00LTEyLjUtMTQuNy0xMi41LTI0di05Ni44YzAtOS4yLDUuNi0xMy41LDEyLjUtOS41bDI1LjUsMTQuN2w0NC41LDI1LjdsMTIuMyw3LjFsMCwwQzIxNS40LDM3OS41LDIyMSwzOTAuMiwyMjEsMzk5LjR6Ii8+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiNGRkQ0NEQiIGQ9Ik0xOTYuMSwzNjguM2wtNjkuMiw5MC4zbC0wLjgtMC41Yy02LjktNC0xMi41LTE0LjctMTIuNS0yNHYtNDEuOWwzOC4xLTQ5LjZMMTk2LjEsMzY4LjN6Ii8+CgkJPHBhdGggZmlsbD0iI0ZGRDQ0RCIgZD0iTTIyMSwzOTkuNHY5LjVMMTY1LjcsNDgxbC0yNi41LTE1LjNsNjkuMi05MC4zQzIxNS40LDM3OS41LDIyMSwzOTAuMiwyMjEsMzk5LjR6Ii8+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMTMuNSw1MDguMmMtMS43LDAtMy42LTAuNS01LjUtMS42TDEyNS42LDQ1OWMtNy4yLTQuMS0xMy0xNS4zLTEzLTI0Ljh2LTk2LjhjMC03LjIsMy4zLTExLjksOC41LTExLjkKCQkJCWMxLjcsMCwzLjYsMC41LDUuNSwxLjZsODIuNCw0Ny42YzcuMiw0LjEsMTMsMTUuMywxMywyNC44djk2LjhDMjIyLDUwMy41LDIxOC42LDUwOC4yLDIxMy41LDUwOC4yeiBNMTIxLjEsMzI3LjMKCQkJCWMtNCwwLTYuNiwzLjktNi42LDEwdjk2LjhjMCw4LjksNS40LDE5LjMsMTIuMSwyMy4xbDgyLjQsNDcuNmMxLjYsMC45LDMuMSwxLjQsNC41LDEuNGM0LDAsNi42LTMuOSw2LjYtMTB2LTk2LjgKCQkJCWMwLTguOS01LjQtMTkuMy0xMi4xLTIzLjFsLTgyLjQtNDcuNkMxMjQsMzI3LjgsMTIyLjUsMzI3LjMsMTIxLjEsMzI3LjN6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiNGRjk2MDAiIGQ9Ik0xODQsNDA0LjhjMCw3LjctMywxMi44LTcuNiwxNHYyNS4zYzAsMS40LTAuMiwyLjYtMC41LDMuN2MtMS4yLDMuNy00LjQsNS04LjEsMi44CgkJCQkJYy00LjgtMi43LTguNi0xMC4xLTguNi0xNi41di0yNS4zYy00LjYtNi41LTcuNi0xNS03LjYtMjIuOGMwLTExLjgsNy4xLTE3LjQsMTYtMTIuNGMwLjEsMCwwLjIsMC4xLDAuMywwLjEKCQkJCQlDMTc2LjcsMzc5LDE4NCwzOTIuOSwxODQsNDA0Ljh6Ii8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTcxLjIsNDUyLjZjLTEuMywwLTIuNi0wLjQtMy45LTEuMmMtNS0yLjktOS4xLTEwLjYtOS4xLTE3LjN2LTI1Yy00LjgtNi45LTcuNi0xNS41LTcuNi0yMy4xCgkJCQkJYzAtNC41LDEtOC4zLDIuOS0xMXM0LjYtNC4yLDcuOC00LjJjMi4xLDAsNC40LDAuNiw2LjcsMS45YzAuMSwwLDAuMiwwLjEsMC4zLDAuMmM5LjIsNS4zLDE2LjcsMTkuNiwxNi43LDMxLjkKCQkJCQljMCw3LjYtMi44LDEzLTcuNiwxNC43djI0LjZjMCwxLjUtMC4yLDIuOC0wLjYsNEMxNzUuOCw0NTAuOSwxNzMuOCw0NTIuNiwxNzEuMiw0NTIuNnogTTE2MS4zLDM3Mi44Yy0yLjUsMC00LjcsMS4yLTYuMywzLjQKCQkJCQljLTEuNywyLjQtMi42LDUuOC0yLjYsOS45YzAsNy4zLDIuOCwxNS42LDcuNSwyMi4ybDAuMiwwLjJ2MjUuNmMwLDYsMy42LDEzLDguMSwxNS42YzEsMC42LDIsMC45LDIuOSwwLjkKCQkJCQljMS43LDAsMy4xLTEuMSwzLjgtMy4yYzAuMy0xLDAuNS0yLjEsMC41LTMuNHYtMjZsMC43LTAuMmM0LjMtMS4xLDYuOS02LDYuOS0xM2MwLTExLjYtNy4xLTI1LjItMTUuOC0zMC4yCgkJCQkJYy0wLjEsMC0wLjItMC4xLTAuMi0wLjFDMTY1LDM3My40LDE2My4xLDM3Mi44LDE2MS4zLDM3Mi44eiIvPgoJCQk8L2c+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTg0LDQwNC44YzAsNy43LTMsMTIuOC03LjYsMTR2MjUuM2MwLDEuNC0wLjIsMi42LTAuNSwzLjdjLTMuMS0zLjUtNS4zLTguOS01LjMtMTMuNnYtMjUuMwoJCQkJYy00LjYtNi41LTcuNi0xNS03LjYtMjIuOGMwLTUuOSwxLjctMTAuMiw0LjYtMTIuNGMwLjEsMCwwLjIsMC4xLDAuMywwLjFDMTc2LjcsMzc5LDE4NCwzOTIuOSwxODQsNDA0Ljh6Ii8+CgkJPC9nPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "function-module", "name": "function-module", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIxODMuMXB4IiBoZWlnaHQ9IjEzNi42cHgiIHZpZXdCb3g9IjAgMCAxODMuMSAxMzYuNiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgMTgzLjEgMTM2LjYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfOCI+Cgk8ZyBpZD0iTGF5ZXJfMV8xXyI+CgkJPGcgaWQ9IkxheWVyXzMiPgoJCTwvZz4KCQk8ZyBpZD0iTGF5ZXJfNiI+CgkJPC9nPgoJCTxnIGlkPSJMYXllcl83Ij4KCQkJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIxMTkuNCw3NS43IDEyNC44LDc1LjcgMTI4LjMsNjguNCAxNDMuNSw2Ni45IDE0Ni44LDU2LjUgMTM4LjYsMzkuOCAKCQkJCTg4LjIsNDAuNSAJCQkiLz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMTYuMiw0My4zYy00LjEtMi4xLTEwLjEtMi0xMy40LDAuMgoJCQljLTIuNiwxLjctMi44LDQuMi0wLjcsNi4yYzMuMy0yLjEsOS4zLTIuMiwxMy4zLTAuMWMwLjksMC40LDEuNiwwLjksMi4xLDEuNWwwLjEtMC4xQzEyMC45LDQ4LjgsMTIwLjMsNDUuMywxMTYuMiw0My4zeiIvPgoJCTxnPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiM5MEE2QkYiIGQ9Ik0xMzkuMiw2Mi45bC0yLjEsMS40bC0xMS40LTIuOWMtMS4zLDAuNC0yLjcsMC44LTQsMS4xTDExOSw2OWwtOS42LDAuMWwtNS02LjVjLTEuNS0wLjItMy0wLjYtNC40LTAuOQoJCQkJCUw4OS43LDY1bC0zLjktMS45bDAsMGwtMy43LTEuOGwwLjQsNi42bDcuNSwzLjhsMTAuMi0zLjNjMS41LDAuNCwyLjksMC43LDQuNCwwLjlsNSw2LjVsOS42LTAuMWwyLjYtNi42YzEuNC0wLjMsMi43LTAuNyw0LTEuMQoJCQkJCWwxMS40LDIuOWw2LjEtNGwtMC40LTYuNkwxMzkuMiw2Mi45TDEzOS4yLDYyLjl6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iIzZEODRBNSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMDAsNjEuOEMxMDAsNjEuOCw5OS45LDYxLjgsMTAwLDYxLjhMODkuNyw2NWgtMC4xbDAuNCw2LjZoMC4xCgkJCWwxMC4yLTMuM2wwLDBMMTAwLDYxLjh6Ii8+CgkJPGc+CgkJCTxnPgoJCQkJPHBvbHlnb24gZmlsbD0iIzZEODRBNSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNzEuNCw0NS4xIDcxLjcsNTEuNyAKCQkJCQk3Mi43LDU3LjIgODMuNyw1OS4xIDg2LjQsNTUgODQuMiw1Mi42IDcyLjQsNTAuNiAJCQkJIi8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzZEODRBNSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik04Mi40LDQyLjdjMC4zLTAuOCwwLjgtMS43LDEuMy0yLjUKCQkJCQlsLTYuNS02LjFsMC40LDYuNmwyLjYsMi40TDgyLjQsNDIuN3oiLz4KCQkJPC9nPgoJCTwvZz4KCQk8Zz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxNDksNDkuMyAxNDkuMyw1NiAxNDAuNSw1Ny45IDEzNi42LDU0LjIgMTM3LjksNTEuNyAKCQkJCQkJCQkJIi8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzUyNkU5NCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMzQuOSwzOGwtMSwxLjUKCQkJCQljMC44LDAuOCwxLjYsMS42LDIuMiwyLjRMMTM3LDQybDEuNS0yLjJsLTAuNC02LjZMMTM0LjksMzhMMTM0LjksMzh6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMzguMiwzMy4ybC03LjUtMy44bC0xMC4yLDMuMwoJCQkJCWMtMS41LTAuNC0yLjktMC43LTQuNC0wLjlsLTUtNi41bC05LjYsMC4xTDk4LjgsMzJjLTEuNCwwLjMtMi43LDAuNy00LjEsMS4xbC0xMS40LTIuOWwtNi4xLDRsNi41LDYuMWMtMC42LDAuOC0xLDEuNi0xLjMsMi41CgkJCQkJbC0xMSwyLjJsMSw1LjVsMTEuOCwyYzAuNiwwLjgsMS40LDEuNiwyLjIsMi40bC00LjMsNi4zbDcuNSwzLjhsMTAuMi0zLjNjMS41LDAuNCwyLjksMC43LDQuNCwwLjlsNSw2LjVsOS42LTAuMWwyLjYtNi42CgkJCQkJYzEuNC0wLjMsMi43LTAuNyw0LTEuMWwxMS40LDIuOWw2LjEtNGwtNi41LTYuMWMwLjYtMC44LDEtMS42LDEuMy0yLjVsMTEuMS0yLjRsLTEtNS41bC0xMS45LTJjLTAuNi0wLjgtMS40LTEuNi0yLjItMi40CgkJCQkJTDEzOC4yLDMzLjJ6IE0xMTcuNiw1MC45Yy0zLjMsMi4yLTkuMywyLjMtMTMuNCwwLjJzLTQuNy01LjUtMS40LTcuN3M5LjMtMi4zLDEzLjQtMC4yQzEyMC4zLDQ1LjMsMTIwLjksNDguOCwxMTcuNiw1MC45eiIvPgoJCQk8L2c+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiNGNEY0RjQiIGQ9Ik0xMTYsMzEuN2wtNS02LjVsLTUuNCwwLjFsLTE5LjMsMzhsMy40LDEuN2wxMC4yLTMuM2MxLjUsMC40LDIuOSwwLjcsNC40LDAuOWwwLjIsMC4ybDUuMi0xMC4zCgkJCWMtMi0wLjEtMy45LTAuNi01LjYtMS40Yy00LjEtMi4xLTQuNy01LjUtMS40LTcuN2MyLjktMS45LDguMS0yLjIsMTItMC44bDUuMi0xMC4xQzExOC42LDMyLjIsMTE3LjMsMzIsMTE2LDMxLjd6Ii8+CgkJPHBhdGggZmlsbD0iIzZEODRBNSIgZD0iTTExNC44LDQyLjdMMTE0LjgsNDIuN2MtMy41LDEuMS02LjYsMi45LTguOCw1LjZjMy4xLTAuNSw2LjYtMC4xLDkuMywxLjNjMC45LDAuNCwxLjYsMC45LDIuMSwxLjUKCQkJbDAuMS0wLjFjMS40LTAuOSwyLjEtMi4xLDIuMS0zLjN2LTAuMWMwLTAuMy0wLjEtMC42LTAuMi0wLjhjMC0wLjEtMC4xLTAuMi0wLjEtMC4zczAtMC4xLTAuMS0wLjJjMC0wLjEtMC4xLTAuMi0wLjItMC4zCgkJCWMwLTAuMS0wLjEtMC4xLTAuMS0wLjJjLTAuMS0wLjEtMC4xLTAuMi0wLjItMC4zcy0wLjEtMC4xLTAuMi0wLjJjLTAuMS0wLjEtMC4xLTAuMi0wLjItMC4yYy0wLjEtMC4xLTAuMi0wLjItMC4yLTAuMgoJCQljLTAuMS0wLjEtMC4xLTAuMS0wLjItMC4yYy0wLjEtMC4xLTAuMi0wLjItMC4zLTAuMmMtMC4xLTAuMS0wLjItMC4xLTAuMi0wLjJjLTAuMS0wLjEtMC4zLTAuMi0wLjQtMC4zYy0wLjEsMC0wLjEtMC4xLTAuMi0wLjEKCQkJYy0wLjItMC4xLTAuNS0wLjMtMC43LTAuNGMtMC4yLTAuMS0wLjQtMC4yLTAuNy0wLjNDMTE1LjMsNDIuOSwxMTUsNDIuOCwxMTQuOCw0Mi43eiIvPgoJCTxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTE2LjIsNDMuM2MtNC4xLTIuMS0xMC4xLTItMTMuNCwwLjIKCQkJYy0yLjYsMS43LTIuOCw0LjItMC43LDYuMmMzLjMtMi4xLDkuMy0yLjIsMTMuMy0wLjFjMC45LDAuNCwxLjYsMC45LDIuMSwxLjVsMC4xLTAuMUMxMjAuOSw0OC44LDEyMC4zLDQ1LjMsMTE2LjIsNDMuM3oiLz4KCQk8Zz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTEzOS4yLDYyLjlsLTIuMSwxLjRsLTExLjQtMi45CgkJCQkJYy0xLjMsMC40LTIuNywwLjgtNCwxLjFMMTE5LDY5bC05LjYsMC4xbC01LTYuNWMtMS41LTAuMi0zLTAuNi00LjQtMC45TDg5LjcsNjVsLTMuOS0xLjlsMCwwbC0zLjctMS44bDAuNCw2LjZsNy41LDMuOAoJCQkJCWwxMC4yLTMuM2MxLjUsMC40LDIuOSwwLjcsNC40LDAuOWw1LDYuNWw5LjYtMC4xbDIuNi02LjZjMS40LTAuMywyLjctMC43LDQtMS4xbDExLjQsMi45bDYuMS00bC0wLjQtNi42TDEzOS4yLDYyLjlMMTM5LjIsNjIuOQoJCQkJCXoiLz4KCQkJPC9nPgoJCTwvZz4KCQk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxMDQuNCw2Mi43IDEwNC43LDY5LjMgCgkJCTEwOS44LDc1LjggMTA5LjQsNjkuMiAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiM2RDg0QTUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjExOSw2OSAxMTkuNCw3NS43IDEyMiw2OS4xIAoJCQkxMjEuNiw2Mi40IAkJIi8+CgkJPHBhdGggZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMjUuNyw2MS40aC0wLjJsMC40LDYuNmgwLjFsMTEuNCwyLjkKCQkJbC0wLjQtNi42TDEyNS43LDYxLjR6Ii8+CgkJPHBvbHlnb24gZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNzIuOCw1Ny4yIDgzLjcsNTkuMSA4NC41LDU3LjggCgkJCTg0LjIsNTIuNiA3Mi40LDUwLjYgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI4NS45LDYzLjEgODIuMiw2MS4yIDgyLjYsNjcuOSAKCQkJOTAuMSw3MS42IDg5LjcsNjUgCQkiLz4KCQk8cGF0aCBmaWxsPSIjRThFQkVGIiBkPSJNMTI4LjksMzBsLTcuNCwyLjRMMTE2LDQzLjNoMC4xYzQuMSwyLjEsNC43LDUuNSwxLjQsNy43Yy0xLjYsMS4xLTMuOSwxLjYtNi4yLDEuN2wtNS44LDExLjZsMy44LDQuOQoJCQlMMTI4LjksMzB6Ii8+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMzguMiwzMy4ybC03LjUtMy44bC0xMC4yLDMuMwoJCQkJCWMtMS41LTAuNC0yLjktMC43LTQuNC0wLjlsLTUtNi41bC05LjYsMC4xTDk4LjgsMzJjLTEuNCwwLjMtMi43LDAuNy00LjEsMS4xbC0xMS40LTIuOWwtNi4xLDRsNi41LDYuMWMtMC42LDAuOC0xLDEuNi0xLjMsMi41CgkJCQkJbC0xMSwyLjJsMSw1LjVsMTEuOCwyYzAuNiwwLjgsMS40LDEuNiwyLjIsMi40bC00LjMsNi4zbDcuNSwzLjhsMTAuMi0zLjNjMS41LDAuNCwyLjksMC43LDQuNCwwLjlsNSw2LjVsOS42LTAuMWwyLjYtNi42CgkJCQkJYzEuNC0wLjMsMi43LTAuNyw0LTEuMWwxMS40LDIuOWw2LjEtNGwtNi41LTYuMWMwLjYtMC44LDEtMS42LDEuMy0yLjVsMTEuMS0yLjRsLTEtNS41bC0xMS45LTJjLTAuNi0wLjgtMS40LTEuNi0yLjItMi40CgkJCQkJTDEzOC4yLDMzLjJ6IE0xMTcuNiw1MC45Yy0zLjMsMi4yLTkuMywyLjMtMTMuNCwwLjJzLTQuNy01LjUtMS40LTcuN3M5LjMtMi4zLDEzLjQtMC4yQzEyMC4zLDQ1LjMsMTIwLjksNDguOCwxMTcuNiw1MC45eiIvPgoJCQk8L2c+CgkJPC9nPgoJPC9nPgoJPGc+CgkJPHBhdGggZD0iTTExMSwyNS4ybDUsNi41bDQuNCwwLjlsMTAuMi0zLjNsNy41LDMuOGwwLjQsNi42TDEzNyw0MmwxMC45LDEuOGwxLDUuNWwwLjQsNi42bC04LjgsMS45bDIuNSwyLjRsMC40LDYuN2wtNi4xLDRMMTI2LDY4CgkJCWwtNCwxLjFsLTIuNiw2LjZsLTkuNiwwLjFsLTUtNi41bC00LjQtMC45bC0xMC4yLDMuM2wtNy41LTMuOGwtMC40LTYuN2wxLjUtMi4xbC0xMC45LTEuOGwtMS01LjVMNzEuNCw0NWw4LjgtMS44bC0yLjYtMi40CgkJCWwtMC40LTYuN2w2LjEtNEw5NC43LDMzbDQuMS0xLjFsMi42LTYuNkwxMTEsMjUuMiBNMTEyLDIzLjJoLTFsLTkuNiwwLjFoLTEuM2wtMC41LDEuMmwtMi4yLDUuN0w5NC43LDMxbC0xMC45LTIuOEw4MywyOAoJCQlsLTAuNywwLjVsLTYuMSw0bC0xLDAuNmwwLjEsMS4xbDAuNCw2Ljd2MC44TDc2LDQybC01LDFsLTEuNywwLjRsMC4xLDEuN2wwLjQsNi43djAuMVY1MmwxLDUuNWwwLjMsMS40bDEuNCwwLjJsNy43LDEuM2wtMC4xLDAuMQoJCQl2MC43bDAuNCw2LjdsMC4xLDEuMWwxLDAuNWw3LjUsMy44bDAuNywwLjRsMC44LTAuMmw5LjctMy4xbDMuMiwwLjdsNC42LDUuOWwwLjYsMC44aDFsOS42LTAuMWgxLjNsMC41LTEuMmwyLjItNS43bDIuNi0wLjcKCQkJbDEwLjksMi44bDAuOSwwLjJsMC43LTAuNWw2LjEtNGwxLTAuNmwtMC4xLTEuMmwtMC40LTYuN2wtMC4xLTAuOGwtMC4zLTAuM2w1LjEtMS4xbDEuNy0wLjRsLTAuMS0xLjdsLTAuNC02LjZ2LTAuMVY0OWwtMS01LjUKCQkJbC0wLjMtMS40bC0xLjQtMC4ybC03LjgtMS4zbDAuMS0wLjJ2LTAuN2wtMC40LTYuNmwtMC4xLTEuMmwtMS0wLjVsLTcuNS0zLjhsLTAuNy0wLjRsLTAuOCwwLjJsLTkuNywzLjFsLTMuMi0wLjdsLTQuNi01LjkKCQkJTDExMiwyMy4yTDExMiwyMy4yeiIvPgoJPC9nPgo8L2c+CjxnIGlkPSJMYXllcl84X2NvcHkiPgoJPGcgaWQ9IkxheWVyXzFfY29weSI+CgkJPGcgaWQ9IkxheWVyXzNfY29weSI+CgkJPC9nPgoJCTxnIGlkPSJMYXllcl82X2NvcHkiPgoJCTwvZz4KCQk8ZyBpZD0iTGF5ZXJfN19jb3B5Ij4KCQkJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI4NS40LDEzMy4xIDkyLDEzMS4xIDk3LjEsMTIwLjUgMTIxLjMsMTE5LjQgMTI0LjEsMTAzLjIgMTEyLDc4LjggCgkJCQkzOC41LDc5LjkgCQkJIi8+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiNDRUQ4RUIiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNNzkuNCw4My44Yy01LjktMy0xNC44LTIuOS0xOS42LDAuMwoJCQljLTMuOCwyLjUtNCw2LjItMS4xLDkuMWM0LjktMy4xLDEzLjUtMy4xLDE5LjQtMC4yYzEuMywwLjYsMi4zLDEuNCwzLjEsMi4yYzAuMSwwLDAuMS0wLjEsMC4yLTAuMUM4Ni4zLDkxLjksODUuNCw4Ni45LDc5LjQsODMuOAoJCQl6Ii8+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzkwQTZCRiIgZD0iTTExMi45LDExMi41bC0zLjEsMi4xbC0xNi42LTQuM2MtMS45LDAuNi0zLjksMS4yLTUuOSwxLjZsLTMuOCw5LjZsLTE0LjEsMC4ybC03LjMtOS41CgkJCQkJYy0yLjItMC4zLTQuMy0wLjgtNi41LTEuNGwtMTQuOSw0LjhsLTUuNi0yLjhsMCwwbC01LjMtMi43bDAuNiw5LjdsMTEsNS41bDE0LjktNC44YzIuMSwwLjYsNC4zLDEsNi41LDEuNGw3LjMsOS41bDE0LjEtMC4yCgkJCQkJbDMuOC05LjZjMi0wLjQsNC0xLDUuOS0xLjZsMTYuNiw0LjNsOC45LTUuOWwtMC42LTkuN0wxMTIuOSwxMTIuNUwxMTIuOSwxMTIuNXoiLz4KCQkJPC9nPgoJCTwvZz4KCQk8cGF0aCBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTU1LjcsMTEwLjhMNTUuNywxMTAuOGwtMTQuOSw0LjdsLTAuMS0wLjFsMC42LDkuN2wwLjEsMC4xCgkJCWwxNC45LTQuOGwwLDBMNTUuNywxMTAuOHoiLz4KCQk8Zz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxNCw4Ni41IDE0LjUsOTYuMiAxNiwxMDQuMiAKCQkJCQkzMS45LDEwNi45IDM1LjksMTAxIDMyLjcsOTcuNCAxNS40LDk0LjUgCQkJCSIvPgoJCQk8L2c+CgkJPC9nPgoJCTxnPgoJCQk8Zz4KCQkJCTxwYXRoIGZpbGw9IiM2RDg0QTUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMzAuMSw4M2MwLjQtMS4yLDEuMS0yLjQsMS45LTMuNgoJCQkJCWwtOS41LTguOWwwLjYsOS43bDMuOCwzLjVMMzAuMSw4M3oiLz4KCQkJPC9nPgoJCTwvZz4KCQk8Zz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxMjcuMiw5Mi43IDEyNy44LDEwMi40IDExNC45LDEwNS4xIDEwOS4yLDk5LjcgCgkJCQkJMTExLjEsOTYuMiAJCQkJIi8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzUyNkU5NCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMDYuNyw3Ni4xbC0xLjUsMi4xCgkJCQkJYzEuMiwxLjEsMi4zLDIuMywzLjIsMy41bDEuNCwwLjJsMi4yLTMuMmwtMC42LTkuN0wxMDYuNyw3Ni4xTDEwNi43LDc2LjF6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xMTEuNSw2OS4xbC0xMS01LjVsLTE0LjksNC44CgkJCQkJYy0yLjEtMC42LTQuMy0xLTYuNS0xLjRsLTcuMy05LjVsLTE0LjEsMC4ybC0zLjgsOS42Yy0yLDAuNC00LDEtNS45LDEuNmwtMTYuNi00LjNsLTguOSw1LjlsOS41LDguOWMtMC44LDEuMi0xLjQsMi40LTEuOSwzLjYKCQkJCQlMMTQsODYuNGwxLjUsOGwxNy4zLDIuOWMwLjksMS4yLDIsMi40LDMuMiwzLjVsLTYuMiw5LjFsMTEsNS41bDE0LjktNC44YzIuMSwwLjYsNC4zLDEsNi41LDEuNGw3LjMsOS41bDE0LjEtMC4ybDMuOC05LjYKCQkJCQljMi0wLjQsNC0xLDUuOS0xLjZsMTYuNiw0LjNsOC45LTUuOWwtOS41LTguOWMwLjgtMS4yLDEuNC0yLjQsMS45LTMuNmwxNi4xLTMuNWwtMS41LThsLTE3LjMtMi45Yy0wLjktMS4yLTItMi40LTMuMi0zLjUKCQkJCQlMMTExLjUsNjkuMXogTTgxLjQsOTVjLTQuOSwzLjItMTMuNiwzLjMtMTkuNiwwLjNzLTYuOS04LTIuMS0xMS4yczEzLjYtMy4zLDE5LjYtMC4zQzg1LjQsODYuOSw4Ni4zLDkxLjksODEuNCw5NXoiLz4KCQkJPC9nPgoJCTwvZz4KCQk8cGF0aCBmaWxsPSIjRjRGNEY0IiBkPSJNNzkuMSw2N2wtNy4zLTkuNWwtNy45LDAuMUwzNS43LDExM2w0LjksMi41bDE0LjktNC44YzIuMSwwLjYsNC4zLDEsNi41LDEuNGwwLjMsMC4zTDcwLDk3LjUKCQkJYy0yLjktMC4yLTUuNy0wLjktOC4xLTIuMWMtNi0zLTYuOS04LTIuMS0xMS4yYzQuMy0yLjgsMTEuOC0zLjIsMTcuNi0xLjJsNy41LTE0LjhDODMsNjcuNyw4MS4xLDY3LjMsNzkuMSw2N3oiLz4KCQk8cGF0aCBmaWxsPSIjNkQ4NEE1IiBkPSJNNzcuNCw4M0w3Ny40LDgzYy01LjEsMS42LTkuNiw0LjItMTIuOSw4LjJDNjksOTAuNCw3NC4yLDkxLDc4LjEsOTNjMS4zLDAuNiwyLjMsMS40LDMuMSwyLjIKCQkJYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWMyLjEtMS40LDMuMS0zLjEsMy4xLTQuOVY5MGMwLTAuNC0wLjEtMC44LTAuMi0xLjJjLTAuMS0wLjItMC4xLTAuMy0wLjItMC41YzAtMC4xLTAuMS0wLjItMC4xLTAuMgoJCQljLTAuMS0wLjEtMC4xLTAuMy0wLjItMC40cy0wLjEtMC4yLTAuMi0wLjNjLTAuMS0wLjEtMC4yLTAuMy0wLjMtMC40Yy0wLjEtMC4xLTAuMi0wLjItMC4zLTAuM2MtMC4xLTAuMS0wLjItMC4yLTAuMy0wLjMKCQkJYy0wLjEtMC4xLTAuMi0wLjItMC4zLTAuM2MtMC4xLTAuMS0wLjItMC4yLTAuMy0wLjNjLTAuMS0wLjEtMC4zLTAuMi0wLjQtMC40Yy0wLjEtMC4xLTAuMi0wLjItMC4zLTAuM2MtMC4yLTAuMS0wLjQtMC4zLTAuNi0wLjQKCQkJYy0wLjEtMC4xLTAuMi0wLjEtMC4zLTAuMmMtMC4zLTAuMi0wLjctMC40LTEtMC42cy0wLjctMC4zLTEtMC41Qzc4LDgzLjIsNzcuNyw4My4xLDc3LjQsODN6Ii8+CgkJPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik03OS40LDgzLjhjLTUuOS0zLTE0LjgtMi45LTE5LjYsMC4zCgkJCWMtMy44LDIuNS00LDYuMi0xLjEsOS4xYzQuOS0zLjEsMTMuNS0zLjEsMTkuNC0wLjJjMS4zLDAuNiwyLjMsMS40LDMuMSwyLjJjMC4xLDAsMC4xLTAuMSwwLjItMC4xQzg2LjMsOTEuOSw4NS40LDg2LjksNzkuNCw4My44CgkJCXoiLz4KCQk8Zz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTExMi45LDExMi41bC0zLjEsMi4xbC0xNi42LTQuMwoJCQkJCWMtMS45LDAuNi0zLjksMS4yLTUuOSwxLjZsLTMuOCw5LjZsLTE0LjEsMC4ybC03LjMtOS41Yy0yLjItMC4zLTQuMy0wLjgtNi41LTEuNGwtMTQuOSw0LjhsLTUuNi0yLjhsMCwwbC01LjMtMi43bDAuNiw5LjcKCQkJCQlsMTEsNS41bDE0LjktNC44YzIuMSwwLjYsNC4zLDEsNi41LDEuNGw3LjMsOS41bDE0LjEtMC4ybDMuOC05LjZjMi0wLjQsNC0xLDUuOS0xLjZsMTYuNiw0LjNsOC45LTUuOWwtMC42LTkuN0wxMTIuOSwxMTIuNQoJCQkJCUwxMTIuOSwxMTIuNXoiLz4KCQkJPC9nPgoJCTwvZz4KCQk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI2Mi4xLDExMi4yIDYyLjcsMTIxLjkgCgkJCTcwLDEzMS40IDY5LjQsMTIxLjcgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjNkQ4NEE1IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI4My41LDEyMS41IDg0LjEsMTMxLjEgCgkJCTg3LjksMTIxLjUgODcuMywxMTEuOCAJCSIvPgoJCTxwYXRoIGZpbGw9IiNDRUQ4RUIiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNOTMuMywxMTAuM0g5M2wwLjYsOS43aDAuMWwxNi42LDQuMwoJCQlsLTAuNi05LjdMOTMuMywxMTAuM3oiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxNiwxMDQuMiAzMS45LDEwNi45IDMzLjIsMTA1IAoJCQkzMi43LDk3LjQgMTUuNSw5NC41IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzUuMSwxMTIuNyAyOS44LDExMCAKCQkJMzAuMywxMTkuNyA0MS4zLDEyNS4zIDQwLjgsMTE1LjYgCQkiLz4KCQk8cGF0aCBmaWxsPSIjRThFQkVGIiBkPSJNOTgsNjQuNGwtMTAuOCwzLjRsLTgsMTUuOWMwLDAsMC4xLDAsMC4xLDAuMWM2LDMsNi45LDgsMi4xLDExLjJjLTIuNCwxLjYtNS43LDIuNC05LjEsMi41bC04LjUsMTcKCQkJbDUuNSw3LjFMOTgsNjQuNHoiLz4KCQk8Zz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTExMS41LDY5LjFsLTExLTUuNWwtMTQuOSw0LjgKCQkJCQljLTIuMS0wLjYtNC4zLTEtNi41LTEuNGwtNy4zLTkuNWwtMTQuMSwwLjJsLTMuOCw5LjZjLTIsMC40LTQsMS01LjksMS42bC0xNi42LTQuM2wtOC45LDUuOWw5LjUsOC45Yy0wLjgsMS4yLTEuNCwyLjQtMS45LDMuNgoJCQkJCUwxNCw4Ni40bDEuNSw4bDE3LjMsMi45YzAuOSwxLjIsMiwyLjQsMy4yLDMuNWwtNi4yLDkuMWwxMSw1LjVsMTQuOS00LjhjMi4xLDAuNiw0LjMsMSw2LjUsMS40bDcuMyw5LjVsMTQuMS0wLjJsMy44LTkuNgoJCQkJCWMyLTAuNCw0LTEsNS45LTEuNmwxNi42LDQuM2w4LjktNS45bC05LjUtOC45YzAuOC0xLjIsMS40LTIuNCwxLjktMy42bDE2LjEtMy41bC0xLjUtOGwtMTcuMy0yLjljLTAuOS0xLjItMi0yLjQtMy4yLTMuNQoJCQkJCUwxMTEuNSw2OS4xeiBNODEuNCw5NWMtNC45LDMuMi0xMy42LDMuMy0xOS42LDAuM3MtNi45LTgtMi4xLTExLjJzMTMuNi0zLjMsMTkuNi0wLjNDODUuNCw4Ni45LDg2LjMsOTEuOSw4MS40LDk1eiIvPgoJCQk8L2c+CgkJPC9nPgoJPC9nPgoJPGc+CgkJPHBhdGggZD0iTTcxLjgsNTcuNWw3LjMsOS41bDYuNSwxLjRsMTQuOS00LjhsMTEsNS41bDAuNiw5LjZsLTIuMiwzLjJsMTUuOSwyLjdsMS41LDhsMC41LDkuN2wtMTIuOSwyLjhsMy43LDMuNWwwLjYsOS43bC04LjksNS45CgkJCWwtMTYuNi00LjNsLTUuOSwxLjZsLTMuOCw5LjZsLTE0LDAuM2wtNy4zLTkuNWwtNi41LTEuNGwtMTQuOSw0LjhsLTExLTUuNWwtMC42LTkuN2wyLjItMy4xTDE2LDEwNC4ybC0xLjUtOC4xTDE0LDg2LjRsMTIuOC0yLjcKCQkJTDIzLDgwLjNsLTAuNS05LjdsOC45LTUuOUw0OCw2OC45bDUuOS0xLjZsMy44LTkuNkw3MS44LDU3LjUgTTcyLjgsNTUuNWgtMWwtMTQuMSwwLjJoLTEuM0w1NS45LDU3bC0zLjQsOC43TDQ4LDY2LjlsLTE2LjEtNC4xCgkJCUwzMSw2Mi41TDMwLjMsNjNsLTguOSw1LjlsLTEsMC42bDAuMSwxLjFsMC41LDkuN3YwLjhsMC42LDAuNmwwLjksMC45bC05LDEuOWwtMS43LDAuNGwwLjEsMS43bDAuNiw5Ljd2MC4xdjAuMWwxLjUsOC4xbDAuMywxLjQKCQkJbDEuNCwwLjJsMTIuOCwyLjFsLTAuNCwwLjVsLTAuNCwwLjZ2MC43bDAuNiw5LjdsMC4xLDEuMWwxLDAuNWwxMSw1LjVsMC43LDAuNGwwLjgtMC4ybDE0LjQtNC42bDUuMiwxLjFsNi45LDguOWwwLjYsMC44aDEKCQkJbDE0LjEtMC4yaDEuM2wwLjUtMS4ybDMuNC04LjdsNC40LTEuMmwxNi4xLDQuMWwwLjksMC4ybDAuNy0wLjVsOC45LTUuOWwxLTAuNmwtMC4xLTEuMmwtMC42LTkuN2wtMC4xLTAuOGwtMC42LTAuNWwtMC45LTAuOQoJCQlsOS4xLTEuOWwxLjctMC40bC0wLjEtMS43bC0wLjUtOS43di0wLjF2LTAuMWwtMS41LThsLTAuMy0xLjRsLTEuNC0wLjJsLTEyLjgtMi4ybDAuNC0wLjZsMC40LTAuNnYtMC43bC0wLjYtOS42bC0wLjEtMS4ybC0xLTAuNQoJCQlsLTExLTUuNWwtMC43LTAuNGwtMC44LDAuMmwtMTQuNCw0LjZsLTUuMi0xLjFsLTYuOS04LjlMNzIuOCw1NS41TDcyLjgsNTUuNXoiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "image", "name": "image", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI0NTEuMjAwMDFweCIgaGVpZ2h0PSI1NDEuNzAwMDFweCIgdmlld0JveD0iMCAwIDQ1MS4yMDAwMSA1NDEuNzAwMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDQ1MS4yMDAwMSA1NDEuNzAwMDEiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8yXzFfIiBkaXNwbGF5PSJub25lIj4KPC9nPgo8ZyBpZD0iTGF5ZXJfMyI+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjMzMS4yOTk5OSw0ODcuMjk5OTkgMjc4LjEwMDAxLDQ5My42MDAwMSAyNTQuNywzNTguNzAwMDEgCgkJMjc2LjEwMDAxLDM1OS43OTk5OSA0MTMuNSw0NDAuODk5OTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzIzMUYyMCIgcG9pbnRzPSIyOTkuODk5OTksMjU3LjYwMDAxIDI5OS43MDAwMSw0ODIuMjk5OTkgMjc4LjEwMDAxLDQ5My42MDAwMSA4NS41LDM3Ny43OTk5OSA4NS41LDgwLjcgCgkJMTA2LjgsNjYuNiAyMzcuNywxNDUgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NERDlFRSIgcG9pbnRzPSI5NS43LDg0LjIgMjEzLjgsMTU1LjM5OTk5IDIyNiwxNDguMzk5OTkgMTA3LjIsNzcuMyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0REOUVFIiBwb2ludHM9IjI3Ni4xMDAwMSwyNTIuMyAyNzYuMTAwMDEsNDgwLjUgOTUuOCwzNzIuMjAwMDEgOTUuOCw4OS4xIDIxMi44LDE1OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjI4MS4yMDAwMSwyNjkuMjk5OTkgMjgwLjIwMDAxLDQ4MC42MDAwMSAyOTIuMzk5OTksNDc0LjUgMjkxLjI5OTk5LDI2My4yMDAwMSAJIi8+Cgk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNMjU0LjgsNDMxLjIwMDAxYzAsOC4zOTk5OS01LjMsMTItMTEuODk5OTksOC4xMDAwMUwxMjQsMzY4LjM5OTk5QzExNy41LDM2NC41LDExMi4xLDM1NC41LDExMi4xLDM0Ni4xMDAwMUwxMTIsMjMwLjUKCQljMC04LjM5OTk5LDUuMy0xMiwxMS45LTguMTAwMDFsMTE4LjksNzAuODk5OTljNi41LDMuODk5OTksMTEuODk5OTksMTMuODk5OTksMTEuODk5OTksMjIuMjk5OTlMMjU0LjgsNDMxLjIwMDAxeiBNMTIzLjksMjM0LjUKCQljLTEuMy0wLjgtMi40LDAtMi40LDEuNjAwMDFsMC4yLDExNS42MDAwMWMwLDEuNjAwMDEsMS4xLDMuNzAwMDEsMi40LDQuNUwyNDMsNDI3LjEwMDAxYzEuMywwLjc5OTk5LDIuMzk5OTksMCwyLjM5OTk5LTEuNjAwMDEKCQlsLTAuMi0xMTUuNjAwMDFjMC0xLjYwMDAxLTEuMTAwMDEtMy43MDAwMS0yLjM5OTk5LTQuNUMyNDIuNywzMDUuMzk5OTksMTIzLjksMjM0LjUsMTIzLjksMjM0LjV6IE0xNDUuMywyOTYKCQljLTcuODk5OTktNC43MDAwMS0xNC4zLTE2LjcwMDAxLTE0LjMtMjYuNzk5OTlzNi4zOTk5OS0xNC4zOTk5OSwxNC4yLTkuNzk5OTljNy44OTk5OSw0LjcwMDAxLDE0LjMsMTYuNzAwMDEsMTQuMywyNi43OTk5OQoJCUMxNTkuNjAwMDEsMjk2LjI5OTk5LDE1My4yLDMwMC43MDAwMSwxNDUuMywyOTZ6IE0yMzUuNyw0MTAuNzAwMDFsLTEwNC41OTk5OS02Mi4zOTk5OXYtMTguMjk5OTlsMjMuNy0xNi4yMDAwMUwxNjYuNywzMzYuMTAwMDEKCQlsMzgtMjZsMzEsNThWNDEwLjcwMDAxeiIvPgoJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSI5NS43LDg0LjIgMTMzLjMsMTA2LjggMTU2LjUsMTA2LjkgMTE4LjYsODQuMyAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9IjE0My42MDAwMSwxMTMgMTU5LjYwMDAxLDEyMi40IDE4Mi43LDEyMi41IDE2Ni42MDAwMSwxMTMgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzhBQTRDMSIgcG9pbnRzPSIyMTkuODk5OTksMTU5LjcgMjc5LjcwMDAxLDI2NS4zOTk5OSAyOTAuNjAwMDEsMjU5IDIzMS4zLDE1My4xMDAwMSAJIi8+CjwvZz4KPGcgaWQ9IkxheWVyXzYiPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTIxOS44OTk5OSwxNTkuN0wyMTEuMiwxNTcuM3Y3NS41OTk5OWMwLDIuODk5OTksMS42MDAwMSw1LjYwMDAxLDQuMTAwMDEsN2w2MC44LDM0Ljg5OTk5bDMuNjAwMDEtOS4zOTk5OQoJCUwyMTkuODk5OTksMTU5Ljd6Ii8+Cgk8cGF0aCBmaWxsPSIjQ0REOUVFIiBkPSJNMjczLjUsMjY1LjM5OTk5TDIyMi44LDIzNmMtMi4yLTEuMy0zLjYwMDAxLTMuNy0zLjYwMDAxLTYuM2wtMC4zLTU5LjkwMDAxTDI3My41LDI2NS4zOTk5OXoiLz4KCTxnIGlkPSJMYXllcl80Ij4KCTwvZz4KPC9nPgo8ZyBpZD0iTGF5ZXJfMV8xXyIgZGlzcGxheT0ibm9uZSI+Cgk8ZyBkaXNwbGF5PSJpbmxpbmUiPgoJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04NDYuOTAwMDIsMzA4LjI5OTk5Qzg0NywzMDkuMTk5OTgsODQ3LDMxMC4wOTk5OCw4NDcsMzExTDg0Ni45MDAwMiwzMDguMjk5OTlMODQ2LjkwMDAyLDMwOC4yOTk5OXoiLz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNODQ5LDMxMWgtMy43OTk5OWMwLTAuNzk5OTksMC0xLjYwMDAxLTAuMDk5OTgtMi41bC0wLjI5OTk5LTIuMTAwMDFoNC4wOTk5OEw4NDksMzExTDg0OSwzMTF6Ii8+Cgk8L2c+Cgk8ZyBkaXNwbGF5PSJpbmxpbmUiPgoJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04NDYuOTAwMDIsMjEyLjdDODQ3LDIxMy41OTk5OSw4NDcsMjE0LjUsODQ3LDIxNS4zOTk5OUw4NDYuOTAwMDIsMjEyLjdMODQ2LjkwMDAyLDIxMi43eiIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik04NDksMjE1LjM5OTk5aC0zLjc5OTk5YzAtMC44LDAtMS42MDAwMS0wLjA5OTk4LTIuNWwtMC4yOTk5OS0yLjEwMDAxaDQuMDk5OThMODQ5LDIxNS4zOTk5OQoJCQlMODQ5LDIxNS4zOTk5OXoiLz4KCTwvZz4KCTxwYXRoIGRpc3BsYXk9ImlubGluZSIgZmlsbD0iI0IyQ0JFRCIgc3Ryb2tlPSIjMjMxRjIwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iCgkJTTg0Ni45MDAwMiwzMDguMjk5OTlDODQ3LDMwOS4xOTk5OCw4NDcsMzEwLjA5OTk4LDg0NywzMTFMODQ2LjkwMDAyLDMwOC4yOTk5OUw4NDYuOTAwMDIsMzA4LjI5OTk5eiIvPgoJPHBhdGggZGlzcGxheT0iaW5saW5lIiBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSIKCQlNODQ2LjkwMDAyLDIxMi43Qzg0NywyMTMuNTk5OTksODQ3LDIxNC41LDg0NywyMTUuMzk5OTlMODQ2LjkwMDAyLDIxMi43TDg0Ni45MDAwMiwyMTIuN3oiLz4KCTxnIGRpc3BsYXk9ImlubGluZSI+CgkJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIyNzQsNTI1LjI5OTk5IDIzOS4zLDUxOC41IDIzOC43LDIxOC42MDAwMSA1MTYuMjk5OTksMzgyLjM5OTk5IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0NDRDhFRSIgcG9pbnRzPSI1NS42LDQ5OC4yMDAwMSA1NC45LDQ5OC4yMDAwMSA1NC45LDQ5OC43MDAwMSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiMyMzFGMjAiIHBvaW50cz0iNTQuNCw0OTkuNjAwMDEgNTQuNCw0OTcuNzAwMDEgNTcuNSw0OTcuNzAwMDEgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjU1LjYsNDEwLjI5OTk5IDU0LjksNDEwLjI5OTk5IDU0LjksNDEwLjcwMDAxIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iIzIzMUYyMCIgcG9pbnRzPSI1NC40LDQxMS42MDAwMSA1NC40LDQwOS43OTk5OSA1Ny41LDQwOS43OTk5OSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iMjM5LjMsNDMxLjIwMDAxIDU2LjksMzIxLjYwMDAxIDIzOC43LDIxMi4xMDAwMSA0MjEuNzAwMDEsMzIxLjYwMDAxIAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0NDRDhFRSIgcG9pbnRzPSI1NS42LDMyMi4zOTk5OSA1NC45LDMyMi4zOTk5OSA1NC45LDMyMi43OTk5OSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNCNUM1REMiIHBvaW50cz0iNTQuOSwzOTcuMzk5OTkgMjM5LjMsNTA4LjIwMDAxIDIzOS4zLDUwOC4yMDAwMSAyMzkuMyw0MzMuNzAwMDEgNTQuOSwzMjIuNzk5OTkgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjI2Nyw0OTEuNjAwMDEgMjM5LjMsNTA4LjIwMDAxIDIzOS4zLDUwOC4yMDAwMSAyMzkuMyw0MzMuNzAwMDEgMjY3LDQxNyAJCSIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik00Ni43LDMxOC4xMDAwMXY4M3YwLjcwMDAxTDIzOS4zLDUxNy42MDAwNGwzNy45OTk5OC0yMi43OTk5OVY0MTFMODQuMSwyOTUuMzk5OTlMNDYuNywzMTguMTAwMDF6CgkJCSBNMjM3LjMsNTA0LjVMNTcsMzk2LjIwMDAxVjMyNi41bDE4MC4zLDEwOC4yOTk5OVY1MDQuNXogTTU2LjksMzIxLjYwMDAxTDgyLjQsMzA2bDE4MywxMDkuNWwtMjYuMTAwMDEsMTUuNzAwMDFMNTYuOSwzMjEuNjAwMDF6CgkJCSBNMjY3LjM5OTk5LDQ4OS4yOTk5OWwtMjYuMTAwMDEsMTUuMjk5OTl2LTY5LjcwMDAxbDI2LjEwMDAxLTE1LjI5OTk5VjQ4OS4yOTk5OXoiLz4KCTwvZz4KPC9nPgo8ZyBpZD0iTGF5ZXJfNSI+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "laptop", "name": "laptop", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NDEuMXB4IiBoZWlnaHQ9IjUxMy4xcHgiIHZpZXdCb3g9IjAgMCA1NDEuMSA1MTMuMSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNTQxLjEgNTEzLjEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik0yNzQuNiw0NzEuNWwxNy4yLDkuNWMxLjgsMSwzLjksMSw1LjcsMEw1MzguMSwzNDNjMy45LTIuMiwzLjktNy44LDAtMTAuMQoJCUwzNDQuNiwyMjAuOGMtMy4zLTEuOS03LjYtMC4yLTguNSwzLjVsLTY0LjMsMjQwLjVDMjcxLjEsNDY3LjQsMjcyLjIsNDcwLjIsMjc0LjYsNDcxLjV6Ii8+CjwvZz4KPGc+Cgk8Zz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0NERDlFRSIgcG9pbnRzPSIxOTguMiwyMTguMSAzOC44LDMxMC4xIDI3MC40LDQ0My4zIDQyOS4zLDM1MS41IAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQ0REOUVFIiBwb2ludHM9IjE5OS41LDE4LjMgMTk5LjUsMjE4LjEgNDI5LjMsMzUxLjUgNDI5LjUsMTUxLjggCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiM0QjZFOTgiIHBvaW50cz0iNDQ1LjIsMTc3LjEgNDQ1LjMsMTQyLjcgNDI5LjUsMTUxLjggNDI5LjMsMzI3LjcgNDI5LjMsMzM1LjMgNDI5LjQsMzM1LjMgNDI5LjMsMzUxLjUgCgkJCQk0MjkuMywzNTEuNSA0MjcuNSwzNTIuNiAyNzAuNCw0NDMuMyAyNzAuNCw0NjEuNSA0MjcsMzcxIDQyNywzNzEgNDQ1LDM2MC42IDQ0NS4xLDM0NC41IDQ0NS4xLDM0NC41IDQ0NS4zLDE3Ny4xIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjEyMS41LDM0NS43IDE5Ny43LDM4OS43IDI0My4xLDM2My41IDE2Ni45LDMxOS41IAkJCSIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTk3LjcsMzkybC04MC4yLTQ2LjNsNDkuNC0yOC41bDgwLjIsNDYuM0wxOTcuNywzOTJ6IE0xMjUuNSwzNDUuN2w3Mi4yLDQxLjdsNDEuNC0yMy45bC03Mi4yLTQxLjcKCQkJCUwxMjUuNSwzNDUuN3oiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSI0MjYuOCwxODYuOCA0MjYuOCwxODYuOCA0MjYuOCwxNTQuMyAyNjAuOCwyNTEgMjkyLjgsMjY5LjUgNDI2LjgsMTkxLjQgCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiNCNUM1REMiIHBvaW50cz0iMzguOCwzMjcuOCAzOC44LDMxMC4xIDI3MC40LDQ0My4zIDI3MC40LDQ2MS41IAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQjFDQUVDIiBwb2ludHM9IjE5OS41LDE4LjMgNDI5LjUsMTUxLjggNDQ1LjMsMTQyLjcgMjE1LjIsOS4yIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjM5NC4zLDM1MC40IDI2NywyNzcgMjY2LjksMjc3LjEgMjAwLjEsMjM4LjUgMTI0LjMsMjgyLjIgMjUxLjUsMzU1LjcgMjUxLjYsMzU1LjYgMzE4LjUsMzk0LjIgCgkJCQkJCQkiLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTMxOC41LDM5Ni41bC02Ni44LTM4LjZsLTAuMSwwLjFsLTEtMC42bC0xMzAuMi03NS4ybDc5LjgtNDYuMWw2Ni44LDM4LjZsMC4xLTAuMWwxLDAuNmwxMzAuMiw3NS4yCgkJCQlMMzE4LjUsMzk2LjV6IE0yNTEuNiwzNTMuM2w2Ni44LDM4LjZsNzEuOC00MS41TDI2NywyNzkuM2wtMC4xLDAuMWwtNjYuOC0zOC42bC03MS44LDQxLjVsMTIzLjIsNzEuMUwyNTEuNiwzNTMuM3oiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIzMzIuMyw5OC43IDIwMS41LDE3NC45IDIwMS41LDIxNi44IDIzOC45LDIzOC40IDQwNS42LDE0MS4yIAkJCSIvPgoJCQk8Zz4KCQkJCTxwb2x5Z29uIGZpbGw9IiMyMzFGMjAiIHBvaW50cz0iNDMxLjksMzUyLjcgNDMxLjksMzUyLjYgNDMxLjYsMzUyLjggCQkJCSIvPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTIxNS42LDBsLTIzLjcsMTMuN3YxOTkuOGwtMTYwLjYsOTJ2MjYuOWwyMzguNSwxMzcuN2wxLDAuNmwxODIuOS0xMDUuNFYxMzguMUwyMTUuNiwweiBNNDMuMiwzMTAuMQoJCQkJCWwxNTUuNC04OS43bDIyNy4xLDEzMS4xbC0xNTUsODkuNUw0My4yLDMxMC4xeiBNMjY4LjcsNDQ0LjVWNDU4TDQxLjIsMzI2LjZ2LTEzTDI2OC43LDQ0NC41eiBNNDI3LjksMTUyLjlsLTAuMiwxOTUuMQoJCQkJCUwyMDEuOCwyMTdWMjEuOEw0MjcuOSwxNTIuOXogTTIwMy44LDE4LjNsMTEuNy02LjhsMjI2LjEsMTMxLjJsLTcuOSw0LjZsLTMuOCwyLjJMMjAzLjgsMTguM3ogTTI3Mi43LDQ0NC41bDE1Ny4xLTkwLjZsMi4xLTEuMwoJCQkJCXYtMi4xVjE1Mi45bDEwLjItNS45bDEuNS0wLjlsLTAuMiwyMTMuM2wtMTQuNSw4LjVsLTE1Ni4yLDkwTDI3Mi43LDQ0NC41TDI3Mi43LDQ0NC41eiIvPgoJCQk8L2c+CgkJPC9nPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "loadbalancer", "name": "loadbalancer", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI2MzQuNHB4IiBoZWlnaHQ9IjU0OC4xcHgiIHZpZXdCb3g9IjAgMCA2MzQuNCA1NDguMSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNjM0LjQgNTQ4LjEiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMl8xXyI+Cgk8cG9seWxpbmUgb3BhY2l0eT0iMC40IiBmaWxsPSIjMjMxRjIwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIyNzkuNyw0OTIuMSAzMzAuOCw0ODYuNiA0MDEuOSw0NDkuNiA0MzUuNSw0NjguMiAKCQk1NzEuOCwzODMuMyA0NzIuMywzMjguMyAyNzkuNyw0OTIuMSAJIi8+CjwvZz4KPGcgaWQ9IkxheWVyXzFfMl8iPgoJPGcgaWQ9IkxheWVyXzFfMV8iIGRpc3BsYXk9Im5vbmUiPgoJCTxwb2x5Z29uIGRpc3BsYXk9ImlubGluZSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQTdBOUFDIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI1MzMuNywzOTEuOSAKCQkJMzE4LjMsNTE2LjMgMTAyLjgsMzkxLjkgMTAyLjgsMTQzLjEgMzE4LjMsMTguOCA1MzMuNywxNDMuMSAJCSIvPgoJPC9nPgoJPGcgaWQ9IkxheWVyXzJfMl8iPgoJCTxnPgoJCQk8Zz4KCQkJCTxnPgoJCQkJCTxwYXRoIGZpbGw9IiNDREQ5RUUiIGQ9Ik0yNzkuNyw0ODIuNWMtMC4zLDAtMC43LTAuMS0xLTAuM2wtMTEwLjEtNjMuNWMtMC44LTAuNS0xLjItMS40LTAuOS0yLjNsNTUtMTg0LjIKCQkJCQkJYzAuMy0wLjksMS0xLjQsMS45LTEuNGMwLDAsMCwwLDAuMSwwYzAuOSwwLDEuNywwLjcsMS45LDEuNmw1NSwyNDcuN2MwLjIsMC44LTAuMSwxLjYtMC44LDIKCQkJCQkJQzI4MC41LDQ4Mi40LDI4MC4xLDQ4Mi41LDI3OS43LDQ4Mi41eiIvPgoJCQkJPC9nPgoJCQkJPGc+CgkJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTIyNC43LDIzMi44bDU1LDI0Ny43bC0xMTAtNjMuNUwyMjQuNywyMzIuOCBNMTY5LjcsNDE3bDExMC4xLDYzLjVMMTY5LjcsNDE3IE0yMjQuNywyMjguOAoJCQkJCQljLTEuOCwwLTMuMywxLjItMy44LDIuOWwtNTUsMTg0LjFjLTAuMiwwLjUtMC4yLDEtMC4yLDEuNWMwLDAuMywwLjEsMC42LDAuMiwwLjlsMCwwbDAsMGMwLjMsMC45LDAuOSwxLjcsMS44LDIuMmwwLDBsMCwwbDAsMAoJCQkJCQlsMCwwbDAsMGwwLDBsMCwwbDExMCw2My42bDAsMGMwLjYsMC40LDEuMywwLjUsMiwwLjVjMC4zLDAsMC41LDAsMC44LTAuMWMwLjIsMCwwLjUtMC4xLDAuNy0wLjJjMC44LTAuMywxLjQtMC44LDEuOS0xLjYKCQkJCQkJYzAuMS0wLjEsMC4xLTAuMiwwLjItMC4zYzAuNS0wLjksMC42LTEuOSwwLjQtMi44bC01NS0yNDcuN2MtMC40LTEuOC0xLjktMy4xLTMuOC0zLjFDMjI0LjgsMjI4LjgsMjI0LjcsMjI4LjgsMjI0LjcsMjI4LjgKCQkJCQkJTDIyNC43LDIyOC44eiIvPgoJCQkJPC9nPgoJCQk8L2c+CgkJCTxnPgoJCQkJPGc+CgkJCQkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTI3OS43LDQ4Mi41Yy0wLjMsMC0wLjUtMC4xLTAuOC0wLjJjLTAuNi0wLjMtMS0wLjgtMS4xLTEuNGwtNTUtMjQ3LjdjLTAuMi0wLjksMC4yLTEuNywxLTIuMgoJCQkJCQlsMTUxLjktODcuNmMwLjMtMC4yLDAuNy0wLjMsMS0wLjNzMC41LDAuMSwwLjgsMC4yYzAuNiwwLjMsMSwwLjgsMS4xLDEuNGw1NSwyNDcuN2MwLjIsMC45LTAuMiwxLjctMSwyLjJsLTE1MS44LDg3LjYKCQkJCQkJQzI4MC40LDQ4Mi40LDI4MCw0ODIuNSwyNzkuNyw0ODIuNXoiLz4KCQkJCTwvZz4KCQkJCTxnPgoJCQkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zNzYuNSwxNDUuMmw1NSwyNDcuN2wtNzEuNCw0MS4ybC04MC40LDQ2LjRsLTM2LjItMTYzbC0xOC44LTg0LjdMMzc2LjUsMTQ1LjIgTTM3Ni41LDE0MS4yCgkJCQkJCWMtMC43LDAtMS40LDAuMi0yLDAuNWwtMTUxLjksODcuNmMtMS41LDAuOS0yLjMsMi42LTEuOSw0LjNsMTguOCw4NC43bDM2LjIsMTYzYzAuMywxLjIsMS4xLDIuMywyLjMsMi44CgkJCQkJCWMwLjUsMC4yLDEuMSwwLjMsMS42LDAuM2MwLjcsMCwxLjQtMC4yLDItMC41bDgwLjQtNDYuNGw3MS40LTQxLjJjMS41LTAuOSwyLjMtMi42LDEuOS00LjNsLTU1LTI0Ny43CgkJCQkJCWMtMC4zLTEuMi0xLjEtMi4zLTIuMy0yLjhDMzc3LjYsMTQxLjMsMzc3LjEsMTQxLjIsMzc2LjUsMTQxLjJMMzc2LjUsMTQxLjJ6Ii8+CgkJCQk8L2c+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cG9seWdvbiBmaWxsPSIjNDE2Nzg3IiBwb2ludHM9IjQzMS41LDM5Mi45IDM2MC4xLDQzNC4xIDI0My41LDMxNy41IDIyNC43LDIzMi44IDM3Ni41LDE0NS4yIAkJCQkiLz4KCQkJPC9nPgoJCQk8Zz4KCQkJCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iNzMuOSwxNDUgNDA2LDMzNi43IDU2Mi43LDI0Ni4zIDIzMC42LDU0LjUgCQkJCSIvPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTQwNiwzMzkuMWwtMS0wLjZMNjkuOSwxNDVsMTYwLjctOTIuOGwxLDAuNmwzMzUuMSwxOTMuNUw0MDYsMzM5LjF6IE03Ny45LDE0NUw0MDYsMzM0LjRsMTUyLjctODguMgoJCQkJCUwyMzAuNiw1Ni44TDc3LjksMTQ1eiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBvbHlnb24gZmlsbD0iIzY4ODVBOSIgcG9pbnRzPSI0MDYsMzM2LjcgNzMuOSwxNDUgNzMuOSwxNzMuMyA0MDYsMzY1LjEgNTYyLjcsMjc0LjYgNTYyLjcsMjQ2LjMgCQkJCSIvPgoJCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTQwNiwzNjcuNGwtMS0wLjZMNzEuOSwxNzQuNXYtMzNMNDA2LDMzNC40bDE1OC43LTkxLjZ2MzIuOWwtMSwwLjZMNDA2LDM2Ny40eiBNNzUuOSwxNzIuMkw0MDYsMzYyLjgKCQkJCQlsMTU0LjctODkuM3YtMjMuN0w0MDYsMzM5LjFsLTEtMC42bC0zMjkuMS0xOTBDNzUuOSwxNDguNSw3NS45LDE3Mi4yLDc1LjksMTcyLjJ6Ii8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjMwLjYsNTQuNWwzMzIuMSwxOTEuN3YyOC4zTDQyMy4yLDM1NWw4LjQsMzcuOEwzNjAuMiw0MzRsLTgwLjQsNDYuNEwxNjkuNyw0MTdsNDgtMTYwLjZsLTE0My44LTgzCgkJCQkJVjE0NUwyMzAuNiw1NC41IE0yMzAuNiw0M2wtNSwyLjlMNjguOSwxMzYuM2wtNSwyLjl2NS44djI4LjN2NS44bDUsMi45bDEzNi45LDc5bC00NS43LDE1My4xbC0yLjMsNy42bDYuOCw0bDExMC4xLDYzLjVsNSwyLjkKCQkJCQlsNS0yLjlsODAuNC00Ni40bDcxLjQtNDEuMmw2LjQtMy43bC0xLjYtNy4ybC02LjgtMzAuNmwxMzMuMi03Ni45bDUtMi45di01Ljh2LTI4LjN2LTUuOGwtNS0yLjlMMjM1LjYsNDUuOUwyMzAuNiw0M0wyMzAuNiw0M3oKCQkJCQkiLz4KCQkJPC9nPgoJCTwvZz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "lock", "name": "lock", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyMDMuOXB4IiBoZWlnaHQ9IjIxMHB4IiB2aWV3Qm94PSIwIDAgMjAzLjkgMjEwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDMuOSAyMTAiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMV8xXyI+Cgk8cGF0aCBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNMTE1LjYsMTkwLjZsNDAtMS44bDE4LjctMTFjMy0xLjgsNC42LTQuOCwzLjYtNy4yYy0xLjMtMy04LjktOC0xMi41LTEwLjUKCQljMC44LTIuOS00LjUtMTEuNi0yMS4yLTE4LjZjLTQwLjMtMjMuNC0zNCwyMy42LTM0LDIzLjZMMTE1LjYsMTkwLjZ6Ii8+Cgk8ZyBpZD0iTGF5ZXJfMyI+Cgk8L2c+Cgk8ZyBpZD0iTGF5ZXJfMl8xXyI+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiNGMDk5MzgiIGQ9Ik0xNDUuMSwxMDMuNnY2Mi41YzAsMy4yLTEsNS40LTIuNyw2LjVsLTIzLjUsMTMuNWMxLjYtMSwyLjYtMy4zLDIuNi02LjV2LTYyLjVjMC01LjktMy42LTEyLjktOC4xLTE1LjUKCQkJCUw2MC4zLDcxYy0xLjctMS0zLjQtMS4yLTQuNy0wLjdsMCwwbDIyLjUtMTNjMS41LTEuMiwzLjUtMS4zLDUuOCwwLjFsNTMuMiwzMC43YzIuMSwxLjIsNCwzLjQsNS41LDYKCQkJCUMxNDQuMSw5Ny4xLDE0NS4xLDEwMC41LDE0NS4xLDEwMy42eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iI0Y4RDQ2NiIgZD0iTTEyMS42LDE0Ny4zdi0zMC4xYzAtNS45LTMuNi0xMi45LTguMS0xNS41TDYwLjMsNzFjLTEuNy0xLTMuNC0xLjItNC43LTAuN2wwLDBsMjIuNS0xMwoJCQkJYzEuNS0xLjIsMy41LTEuMyw1LjgsMC4xbDUzLjIsMzAuN2MyLjEsMS4yLDQsMy40LDUuNSw2QzE0MS41LDExNC40LDEzMy44LDEzMi43LDEyMS42LDE0Ny4zeiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iIzIyMUYyMCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNOTEuMywxNC41CgkJCQljMC44LDAsMS41LDAuMSwyLjQsMC4yYzAuNSwwLjEsMC45LDAuMSwxLjQsMC4zYzEuOSwwLjQsMy43LDEuMSw1LjcsMi4xYzAuNiwwLjMsMS4yLDAuNiwxLjcsMC45YzkuNyw1LjUsMTguMSwxNywyMy4yLDI5LjcKCQkJCWMwLjEsMC40LDAuMywwLjgsMC41LDEuMmMwLjEsMC4xLDAuMSwwLjIsMC4xLDAuM2MyLjgsNy40LDQuNCwxNS4yLDQuNCwyMi42bDAsMHYxMi43bDYuNSwzLjdjMi4xLDEuMiw0LDMuNCw1LjUsNgoJCQkJYzEuNiwyLjksMi42LDYuMywyLjYsOS41djYyLjVjMCwzLjItMSw1LjQtMi43LDYuNWwtMjMuNSwxMy41Yy0wLjYsMC41LTEuNCwwLjYtMi4zLDAuNmMtMSwwLTIuMS0wLjMtMy4yLTFsLTUzLjItMzAuNwoJCQkJYy00LjUtMi42LTguMS05LjUtOC4xLTE1LjV2LTVWNzcuMWMwLTMuNiwxLjQtNiwzLjQtNi44bDAsMGwwLDBsMTEuNS02LjZWNDMuM2MwLTcsMS40LTEyLjYsMy45LTE2LjhsMCwwYzAsMCwxLTIuMSwzLjItNC41CgkJCQljMC4xLTAuMSwwLjEtMC4xLDAuMi0wLjJzMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4yLTAuMywwLjMtMC4zYzAuMS0wLjEsMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4xLTAuMSwwLjEtMC4xCgkJCQljMC4xLTAuMSwwLjItMC4yLDAuMy0wLjNjMC4xLTAuMSwwLjEtMC4xLDAuMS0wLjFjMC4xLTAuMSwwLjItMC4yLDAuMy0wLjNsMC4xLTAuMWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMwoJCQkJYzAuMS0wLjEsMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4zLTAuMiwwLjQtMC4zYzAuMS0wLjEsMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4xLTAuMSwwLjMtMC4yYzAuMS0wLjEsMC4xLTAuMSwwLjItMC4yCgkJCQlzMC4xLTAuMSwwLjMtMC4ybDAsMGMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuM2wwLDBjMCwwLDAuMSwwLDAuMS0wLjFsMCwwYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4zYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4yCgkJCQljMC4xLDAsMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4yYzAuMS0wLjEsMC4zLTAuMSwwLjQtMC4ybDAuMS0wLjFjMC4xLDAsMC4xLTAuMSwwLjEtMC4xYzAuMi0wLjEsMC40LTAuMywwLjYtMC4zCgkJCQljMC4xLTAuMSwwLjEtMC4xLDAuMi0wLjFzMC4xLTAuMSwwLjItMC4xYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4xYzAuMS0wLjEsMC4xLTAuMSwwLjItMC4xaDAuMWMwLjEsMCwwLjEtMC4xLDAuMS0wLjEKCQkJCWMwLjEtMC4xLDAuMy0wLjEsMC40LTAuMnMwLjItMC4xLDAuMy0wLjFjMC4xLDAsMC4xLTAuMSwwLjEtMC4xaDAuMWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMWMwLjEtMC4xLDAuMy0wLjEsMC41LTAuMgoJCQkJYzAuMSwwLDAuMS0wLjEsMC4xLTAuMXMwLjEsMCwwLjEtMC4xYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWwwLDBjMC4xLTAuMSwwLjMtMC4xLDAuNS0wLjJsMCwwYzAuMSwwLDAuMS0wLjEsMC4yLTAuMUg4NAoJCQkJYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWMwLDAsMCwwLDAuMSwwYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjQtMC4xbDAsMGMwLjEsMCwwLjEsMCwwLjEsMAoJCQkJYzAuMSwwLDAuMSwwLDAuMi0wLjFzMC4zLTAuMSwwLjQtMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMSwwLDAuMSwwLDAuMSwwYzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4xCgkJCQljMC4yLTAuMSwwLjQtMC4xLDAuNi0wLjFDODguOSwxNC42LDkwLDE0LjUsOTEuMywxNC41IE04Ny41LDM0LjRMODcuNSwzNC40TDg3LjUsMzQuNGMtMS45LDIuNy0yLjksNi42LTIuOSwxMS40djEybDI4LjgsMTYuNgoJCQkJdi0zLjZjMC0xMy4yLTguMS0yOC42LTE3LjktMzQuM2MtMi41LTEuNS01LTIuMS03LjEtMi4xQzg4LDM0LjQsODcuOCwzNC40LDg3LjUsMzQuNCBNOTEuMywxMS40Yy0xLjUsMC0yLjksMC4yLTQuMywwLjUKCQkJCWMtMC4yLDAtMC4zLDAuMS0wLjUsMC4xYy0wLjEsMC0wLjMsMC0wLjUsMC4xYy0wLjEsMC0wLjEsMC0wLjIsMC4xYy0wLjIsMC4xLTAuMywwLjEtMC41LDAuMWMtMC4xLDAuMS0wLjMsMC4xLTAuNSwwLjFsLTAuMiwwLjEKCQkJCWMwLDAsMCwwLTAuMSwwaC0wLjFsMCwwbDAsMGgtMC4xYy0wLjEsMC0wLjMsMC4xLTAuMywwLjFjLTAuMSwwLjEtMC4zLDAuMS0wLjUsMC4xaC0wLjFjLTAuMSwwLTAuMSwwLjEtMC4yLDAuMWgtMC4xTDgzLDEyLjkKCQkJCWgtMC4xYy0wLjEsMC0wLjEsMC4xLTAuMiwwLjFsMCwwYy0wLjIsMC4xLTAuMywwLjEtMC41LDAuMmMwLDAtMC4xLDAtMC4xLDAuMWMtMC4xLDAtMC4xLDAuMS0wLjEsMC4xaC0wLjFsMC4xLTAuMWwwLDAKCQkJCWMtMC4xLDAtMC4xLDAuMS0wLjEsMC4xYy0wLjIsMC4xLTAuMywwLjEtMC41LDAuMmMtMC4xLDAuMS0wLjMsMC4xLTAuMywwLjFsMCwwbDAsMGMtMC4xLDAtMC4xLDAuMS0wLjIsMC4xCgkJCQljLTAuMSwwLjEtMC4yLDAuMS0wLjMsMC4xQzgwLjUsMTQsODAuNCwxNCw4MC4zLDE0bC0wLjEsMGgtMC4xQzgwLDE0LDgwLDE0LjEsODAsMTQuMXMtMC4xLDAtMC4xLDAuMWMtMC4xLDAuMS0wLjIsMC4xLTAuMywwLjEKCQkJCWMtMC4xLDAuMS0wLjIsMC4xLTAuMywwLjFjLTAuMSwwLTAuMSwwLjEtMC4xLDAuMWgtMC4xSDc5Yy0wLjEsMC0wLjEsMC4xLTAuMiwwLjFjLTAuMywwLjEtMC41LDAuMy0wLjcsMC40CgkJCQlDNzgsMTUsNzgsMTUuMSw3OCwxNS4xaDBsLTAuMSwwLjFsMCwwYy0wLjEsMC4xLTAuMywwLjEtMC40LDAuM2MwLDAsMCwwLTAuMSwwYy0wLjEsMC4xLTAuMywwLjEtMC40LDAuM2wtMC4xLDAuMQoJCQkJYy0wLjEsMC4xLTAuMywwLjEtMC4zLDAuM2MtMC4xLDAuMS0wLjMsMC4yLTAuNCwwLjNsMCwwYzAsMCwwLDAtMC4xLDBjMCwwLDAsMC0wLjEsMGwwLDBsMCwwYy0wLjEsMC4xLTAuMiwwLjEtMC4zLDAuM2wwLDAKCQkJCWMtMC4xLDAuMS0wLjEsMC4xLTAuMiwwLjFMNzUuNCwxN2MtMC4xLDAuMS0wLjIsMC4xLTAuMywwLjJINzVjLTAuMSwwLjEtMC4xLDAuMS0wLjMsMC4yYy0wLjEsMC4xLTAuMSwwLjEtMC4xLDAuMWwwLDBsMCwwCgkJCQljLTAuMSwwLjEtMC4zLDAuMy0wLjUsMC40bDAsMEM3NCwxOCw3NCwxOCw3NCwxOGMwLjItMC4yLDAtMC4xLDAsMGwwLDBoLTAuMWwwLDBjLTAuMSwwLjEtMC4yLDAuMi0wLjMsMC4zbDAsMAoJCQkJYy0wLjEsMC4xLTAuMSwwLjEtMC4xLDAuMWwtMC4xLDAuMWMtMC4xLDAuMS0wLjIsMC4yLTAuMywwLjNsMCwwQzczLDE4LjksNzMsMTguOSw3MywxOC45TDcyLjksMTlsMCwwbDAsMAoJCQkJYy0wLjEsMC4xLTAuMywwLjMtMC40LDAuNGwwLDBjLTAuMSwwLjEtMC4xLDAuMS0wLjEsMC4xbDAsMGwwLDBjLTAuMSwwLjEtMC4xLDAuMS0wLjIsMC4yYy0yLjMsMi41LTMuNCw0LjYtMy43LDUuMgoJCQkJYy0yLjgsNC44LTQuMywxMS00LjMsMTguM3YxOC41bC05LjgsNS43Yy0zLjIsMS40LTUsNC45LTUsOS41djU3LjV2NWMwLDcsNC4zLDE1LDkuNiwxOC4xbDUzLjIsMzAuN2MxLjYsMC45LDMuMiwxLjQsNC43LDEuNAoJCQkJYzEuNCwwLDIuNy0wLjQsMy45LTEuMWwyMy40LTEzLjVsMC4xLTAuMWMyLjYtMS43LDQuMS00LjksNC4xLTkuMXYtNjIuNWMwLTMuNS0xLjEtNy41LTMtMTFjLTEuNy0zLjItNC4xLTUuNy02LjYtNy4ybC01LTIuOAoJCQkJdi0xMWMwLTcuNS0xLjUtMTUuNy00LjYtMjMuN2wtMC4xLTAuMWMwLTAuMS0wLjEtMC4yLTAuMS0wLjNjLTAuMS0wLjQtMC4zLTAuOC0wLjUtMS4yYy0yLjYtNi42LTYuMi0xMy0xMC4zLTE4LjMKCQkJCWMtNC4zLTUuNi05LjItMTAuMS0xNC4yLTEzYy0wLjYtMC40LTEuMy0wLjctMS45LTFjLTIuMi0xLjEtNC4zLTEuOS02LjUtMi4zYy0wLjUtMC4xLTEtMC4yLTEuNS0wLjMKCQkJCUM5My4yLDExLjQsOTIuMiwxMS40LDkxLjMsMTEuNEw5MS4zLDExLjR6IE04Ny42LDU2VjQ1LjhjMC0zLjMsMC42LTYuMSwxLjYtOC4zYzEuNCwwLjIsMywwLjcsNC42LDEuNwoJCQkJYzguNSw0LjksMTUuNywxOC4zLDE2LjQsMjkuOUw4Ny42LDU2TDg3LjYsNTZ6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRjVCRDQxIiBkPSJNMTIxLjYsMTE3LjJ2NjIuNWMwLDUuOS0zLjYsOC43LTguMSw2LjFsLTI3LjYtMTUuOUw2OC44LDE2MGwtNy45LTQuNmwtMC41LTAuMwoJCQkJYy00LjUtMi42LTguMS05LjUtOC4xLTE1LjVWNzcuMWMwLTUuOSwzLjYtOC43LDguMS02LjFsMTYuNSw5LjVsMjguNywxNi42bDcuOSw0LjZsMCwwQzExOCwxMDQuNCwxMjEuNiwxMTEuMywxMjEuNiwxMTcuMnoiLz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iI0Y4RDQ2NiIgZD0iTTEwNS41LDk3LjFsLTQ0LjYsNTguM2wtMC41LTAuM2MtNC41LTIuNi04LjEtOS41LTguMS0xNS41di0yN2wyNC42LTMyTDEwNS41LDk3LjF6Ii8+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTEyMy4xLDEwMS4yYy0wLjEsMC0wLjMsMC0wLjQsMGgtMC4xSDEyMmwwLDBjLTIuMy0wLjEtNC41LTAuOC02LjItMS44Yy0yLjQtMS40LTMuNi0zLjMtMy4yLTUuMlY3MC44CgkJCQkJYzAtMTMtNy45LTI4LjEtMTcuNy0zMy44Yy0yLjQtMS40LTQuNi0yLjEtNi44LTIuMWMtMC4xLDAtMC4zLDAtMC41LDBjLTEuNywyLjYtMi42LDYuNC0yLjYsMTAuOHYyMy41YzAuMSwxLjUtMC43LDIuOS0yLjQsMy45CgkJCQkJYy0wLjMsMC4yLTAuNiwwLjMtMC45LDAuNWMtMC4yLDAuMS0wLjUsMC4yLTAuNiwwLjNjLTAuMiwwLjEtMC41LDAuMS0wLjcsMC4yYy0wLjMsMC4xLTAuNywwLjItMS4xLDAuM2gtMC4xCgkJCQkJYy0wLjIsMC0wLjQsMC4xLTAuNiwwLjFoLTAuMWMtMC4yLDAtMC40LDAuMS0wLjUsMC4xaC0wLjFjLTAuMiwwLTAuMywwLTAuNSwwaC0wLjNjLTAuMSwwLTAuMywwLTAuNCwwVjc0djAuNmgtMC4xSDc2bDAsMAoJCQkJCWMtMi4zLTAuMS00LjUtMC44LTYuMi0xLjhjLTIuMy0xLjQtMy41LTMuMi0zLjMtNS4xVjQzLjNjMC02LjgsMS40LTEyLjcsMy45LTE3LjFjMC4xLTAuMSwxLTIuMywzLjMtNC43bDAuMi0wLjIKCQkJCQljMC4yLTAuMiwwLjQtMC40LDAuNi0wLjZzMC40LTAuNCwwLjYtMC42czAuNC0wLjQsMC42LTAuNnMwLjUtMC40LDAuNi0wLjZjMC4xLTAuMSwwLjItMC4yLDAuMy0wLjNsMC4xLTAuMXYtMC4zaDAuNAoJCQkJCWMwLjEtMC4xLDAuMy0wLjIsMC4zLTAuMmMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuM2MwLjEtMC4xLDAuMy0wLjIsMC40LTAuM2MwLjEtMC4xLDAuMy0wLjIsMC40LTAuM2MwLDAsMC4zLTAuMiwwLjUtMC4zCgkJCQkJbDAuMi0wLjFjMC4zLTAuMiwwLjYtMC40LDEtMC41YzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4xYzAuMS0wLjEsMC4xLTAuMSwwLjMtMC4xYzAuMi0wLjEsMC40LTAuMiwwLjYtMC4zCgkJCQkJYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4xYzAuMSwwLDAuMS0wLjEsMC4yLTAuMWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMWMwLjEtMC4xLDAuMy0wLjEsMC41LTAuMmMwLjEtMC4xLDAuMy0wLjEsMC41LTAuMmgwLjEKCQkJCQljMC4yLTAuMSwwLjMtMC4xLDAuNS0wLjJjMC4yLTAuMSwwLjMtMC4xLDAuNS0wLjJoMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xbDAuMi0wLjEKCQkJCQljMC4xLTAuMSwwLjMtMC4xLDAuNC0wLjFjMC4xLTAuMSwwLjMtMC4xLDAuNS0wLjFoMC4xYzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMi0wLjEsMC40LTAuMSwwLjYtMC4xCgkJCQkJYzEuMi0wLjMsMi41LTAuNSwzLjctMC41YzAuOCwwLDEuNiwwLjEsMi41LDAuMmMyLjksMC40LDUuOSwxLjUsOSwzLjNjMTUuNyw5LjEsMjguNSwzMy41LDI4LjUsNTQuNHYyNS40bC0wLjEtMC4xCgkJCQkJYy0wLjMsMS4xLTEsMi4xLTIuMywyLjhjLTAuMywwLjItMC42LDAuMy0wLjksMC41Yy0wLjMsMC4xLTAuNSwwLjItMC42LDAuM2MtMC4yLDAuMS0wLjUsMC4xLTAuNywwLjJjLTAuMywwLjEtMC43LDAuMi0xLjEsMC4zCgkJCQkJaC0wLjFjLTAuMiwwLTAuNCwwLjEtMC42LDAuMWgtMC4xYy0wLjIsMC0wLjQsMC4xLTAuNSwwLjFoLTAuMWMtMC4yLDAtMC40LDAtMC42LDBMMTIzLjEsMTAxLjJ6Ii8+CgkJCTwvZz4KCQkJPGc+CgkJCQk8cGF0aCBmaWxsPSIjMjIxRjIwIiBkPSJNOTEuMiwxNC41YzAuOCwwLDEuNSwwLjEsMi40LDAuMmMyLjgsMC40LDUuNywxLjQsOC44LDMuMmMxNS41LDksMjguMiwzMy4xLDI4LjIsNTMuOXYyNC4zbDAsMAoJCQkJCWMwLjEsMS4yLTAuNiwyLjUtMi4xLDMuNGMtMC4zLDAuMS0wLjYsMC4zLTAuOCwwLjRjLTAuMiwwLjEtMC40LDAuMS0wLjYsMC4zYy0wLjIsMC4xLTAuNSwwLjEtMC42LDAuMmMtMC4zLDAuMS0wLjYsMC4xLTEsMC4yCgkJCQkJbDAsMGMtMC4yLDAuMS0wLjQsMC4xLTAuNiwwLjFjLTAuMSwwLTAuMSwwLTAuMSwwYy0wLjIsMC0wLjMsMC4xLTAuNSwwLjFjLTAuMSwwLTAuMSwwLTAuMSwwYy0wLjIsMC0wLjMsMC0wLjUsMAoJCQkJCWMtMC4xLDAtMC4xLDAtMC4xLDBzLTAuMSwwLTAuMiwwcy0wLjMsMC0wLjQsMGwwLDBsMCwwbDAsMGwwLDBjLTIuMy0wLjEtNC42LTAuNi02LjUtMS43Yy0yLjMtMS4zLTMuMy0zLTMtNC42VjcwLjkKCQkJCQljMC0xMy4yLTguMS0yOC42LTE3LjktMzQuM2MtMi41LTEuNS01LTIuMS03LjEtMi4xYy0wLjMsMC0wLjUsMC0wLjgsMGwwLDBsMCwwYy0xLjksMi43LTIuOSw2LjYtMi45LDExLjR2MjMuNgoJCQkJCWMwLjEsMS4yLTAuNiwyLjUtMi4xLDMuM2MtMC4zLDAuMS0wLjYsMC4zLTAuOCwwLjRjLTAuMiwwLjEtMC40LDAuMS0wLjYsMC4zYy0wLjIsMC4xLTAuNSwwLjEtMC42LDAuMmMtMC4zLDAuMS0wLjYsMC4xLTEsMC4yCgkJCQkJbDAsMEM3OS41LDc0LDc5LjMsNzQsNzkuMSw3NEM3OSw3NCw3OSw3NCw3OSw3NGMtMC4yLDAtMC4zLDAuMS0wLjUsMC4xYy0wLjEsMC0wLjEsMC0wLjEsMGMtMC4yLDAtMC4zLDAtMC41LDAKCQkJCQljLTAuMSwwLTAuMSwwLTAuMiwwcy0wLjEsMC0wLjEsMGMtMC4xLDAtMC4zLDAtMC40LDBsMCwwbDAsMGMwLDAsMCwwLTAuMSwwbDAsMGMtMi4zLTAuMS00LjYtMC42LTYuNS0xLjdjLTIuMi0xLjMtMy4yLTMtMy00LjUKCQkJCQlWNDMuM2MwLTcsMS40LTEyLjYsMy45LTE2LjhsMCwwYzAsMCwxLTIuMSwzLjItNC41YzAuMS0wLjEsMC4xLTAuMSwwLjItMC4yYzAuMi0wLjIsMC40LTAuNCwwLjYtMC42czAuNC0wLjQsMC42LTAuNQoJCQkJCWMwLjItMC4yLDAuNC0wLjQsMC42LTAuNWMwLjItMC4yLDAuNS0wLjQsMC42LTAuNXMwLjMtMC4yLDAuMy0wLjNjMC4xLTAuMSwwLjMtMC4yLDAuMy0wLjNsMCwwYzAuMS0wLjEsMC4yLTAuMSwwLjMtMC4zCgkJCQkJbDAuMS0wLjFjMC4xLTAuMSwwLjItMC4xLDAuMy0wLjNjMC4xLTAuMSwwLjMtMC4yLDAuNC0wLjNjMC4xLTAuMSwwLjMtMC4yLDAuNC0wLjNjMC4xLTAuMSwwLjMtMC4yLDAuNC0wLjMKCQkJCQljMC4xLDAsMC4xLTAuMSwwLjItMC4xYzAuMy0wLjIsMC42LTAuNCwxLTAuNWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMWMwLjEtMC4xLDAuMS0wLjEsMC4yLTAuMWMwLjItMC4xLDAuNC0wLjIsMC42LTAuMwoJCQkJCWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMWMwLjEsMCwwLjEtMC4xLDAuMi0wLjFjMC4xLTAuMSwwLjItMC4xLDAuMy0wLjFjMC4xLTAuMSwwLjMtMC4xLDAuNS0wLjJjMC4xLTAuMSwwLjMtMC4xLDAuNS0wLjJsMCwwCgkJCQkJYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4ybDAsMGMwLjEtMC4xLDAuMy0wLjEsMC41LTAuMWMwLDAsMCwwLDAuMSwwYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xCgkJCQkJYzAuMSwwLDAuMSwwLDAuMi0wLjFzMC4zLTAuMSwwLjQtMC4xYzAuMS0wLjEsMC4zLTAuMSwwLjUtMC4xYzAuMSwwLDAuMSwwLDAuMSwwYzAuMi0wLjEsMC4zLTAuMSwwLjUtMC4xCgkJCQkJYzAuMi0wLjEsMC40LTAuMSwwLjYtMC4xQzg4LjcsMTQuNiw5MCwxNC41LDkxLjIsMTQuNSBNOTEuMiwxMy4zTDkxLjIsMTMuM2MtMS40LDAtMi42LDAuMS0zLjksMC41Yy0wLjIsMC0wLjQsMC4xLTAuNSwwLjEKCQkJCQljLTAuMiwwLTAuMywwLjEtMC41LDAuMWMtMC4xLDAtMC4xLDAtMC4xLDBjLTAuMiwwLTAuNCwwLTAuNSwwYy0wLjEsMC4xLTAuMywwLjEtMC41LDAuMWwtMC4xLDAuMUg4NWMtMC4yLDAuMS0wLjMsMC4xLTAuNSwwLjEKCQkJCQljLTAuMSwwLjEtMC4zLDAuMS0wLjUsMC4xbDAsMGwwLDBsMCwwYy0wLjIsMC4xLTAuMywwLjEtMC41LDAuMmwwLDBsMCwwYy0wLjEsMC4xLTAuMywwLjEtMC41LDAuMmwwLDAKCQkJCQljLTAuMSwwLjEtMC4zLDAuMS0wLjUsMC4yYy0wLjIsMC4xLTAuMywwLjEtMC41LDAuMmMtMC4xLDAuMS0wLjIsMC4xLTAuMywwLjFjLTAuMSwwLTAuMSwwLjEtMC4yLDAuMWMtMC4xLDAuMS0wLjIsMC4xLTAuMywwLjEKCQkJCQljLTAuMiwwLjEtMC40LDAuMi0wLjYsMC4zYy0wLjEsMC4xLTAuMSwwLjEtMC4zLDAuMUM4MC4yLDE2LDgwLjEsMTYsODAsMTZjLTAuMywwLjItMC43LDAuNC0xLDAuNmwtMC4xLDAuMWwtMC4xLDAuMWwwLDBsMCwwCgkJCQkJYy0wLjEsMC4xLTAuMywwLjEtMC40LDAuMmgtMC4xbDAsMGwwLDBjLTAuMSwwLjEtMC4zLDAuMi0wLjQsMC4zYy0wLjEsMC4xLTAuMywwLjItMC40LDAuM2MtMC4xLDAuMS0wLjMsMC4xLTAuMywwLjNMNzcuMSwxOAoJCQkJCWwwLDBsMCwwaC0wLjh2MC42Yy0wLjEsMC4xLTAuMiwwLjEtMC4zLDAuMmMtMC4zLDAuMi0wLjUsMC40LTAuNywwLjZjLTAuMywwLjItMC41LDAuNC0wLjYsMC42Yy0wLjIsMC4yLTAuNCwwLjQtMC42LDAuNgoJCQkJCXMtMC40LDAuNC0wLjYsMC42Yy0wLjEsMC4xLTAuMSwwLjEtMC4yLDAuM2MtMi4yLDIuNC0zLjIsNC41LTMuNCw0LjhjLTIuNiw0LjUtNCwxMC41LTQsMTcuNHYyNC4xYy0wLjMsMi4xLDEsNC4yLDMuNSw1LjcKCQkJCQljMS43LDEsMy43LDEuNiw1LjksMS44djAuMWwxLjIsMC4xaDAuMWwwLDBsMCwwYzAuMSwwLDAuMywwLDAuNCwwaDAuMWMwLjEsMCwwLjEsMCwwLjIsMGMwLjIsMCwwLjQsMCwwLjUsMEg3OAoJCQkJCWMwLjEsMCwwLjEsMCwwLjEsMGMwLjIsMCwwLjQsMCwwLjYtMC4xaDAuMWgwLjFjMC4yLDAsMC40LTAuMSwwLjYtMC4xbDAsMGgwLjFjMC40LTAuMSwwLjgtMC4xLDEuMi0wLjMKCQkJCQljMC4zLTAuMSwwLjUtMC4xLDAuNy0wLjJjMC4zLTAuMSwwLjUtMC4yLDAuNy0wLjNjMC40LTAuMSwwLjctMC4zLDEtMC41YzEuOS0xLjEsMi44LTIuNywyLjctNC41VjQ2YzAtNC4xLDAuOC03LjcsMi40LTEwLjIKCQkJCQljMC4xLDAsMC4xLDAsMC4xLDBjMi4xLDAsNC4yLDAuNiw2LjUsMmM5LjUsNS41LDE3LjQsMjAuNSwxNy40LDMzLjJ2MjMuNGMtMC40LDIuMSwxLDQuMywzLjUsNS43YzEuNywxLDMuNywxLjYsNS45LDEuOHYwLjEKCQkJCQlsMS4yLDAuMWgwLjFsMCwwYzAuMSwwLDAuMywwLDAuNCwwczAuMSwwLDAuMiwwczAuMSwwLDAuMSwwYzAuMiwwLDAuNCwwLDAuNiwwaDAuMWgwLjFjMC4yLDAsMC40LDAsMC42LTAuMWgwLjFoMC4xCgkJCQkJYzAuMiwwLDAuNC0wLjEsMC42LTAuMWwwLDBsMCwwbDAsMGgwLjFjMC40LTAuMSwwLjgtMC4xLDEuMi0wLjNjMC4zLTAuMSwwLjUtMC4xLDAuNy0wLjJjMC4zLTAuMSwwLjUtMC4xLDAuNy0wLjMKCQkJCQljMC40LTAuMSwwLjctMC4zLDEtMC41YzEuMS0wLjYsMS45LTEuNSwyLjMtMi41bDAuNCwwLjN2LTIuMlY3MmMwLTEwLjMtMy0yMS41LTguNC0zMS45cy0xMi42LTE4LjUtMjAuNC0yMwoJCQkJCWMtMy4yLTEuOC02LjMtMy05LjItMy40QzkyLjksMTMuMyw5MiwxMy4zLDkxLjIsMTMuM0w5MS4yLDEzLjN6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iI0Y4RDQ2NiIgZD0iTTEyMS42LDExNy4ydjYuMWwtMzUuNyw0Ni41TDY4LjgsMTYwbDQ0LjYtNTguM0MxMTgsMTA0LjQsMTIxLjYsMTExLjMsMTIxLjYsMTE3LjJ6Ii8+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMjFGMjAiIGQ9Ik0xMTYuNywxODcuNGMtMS4xLDAtMi4zLTAuMy0zLjUtMUw2MCwxNTUuNmMtNC42LTIuNi04LjQtOS45LTguNC0xNlY3Ny4yYzAtNC42LDIuMS03LjcsNS41LTcuNwoJCQkJYzEuMSwwLDIuMywwLjMsMy41LDFsNTMuMiwzMC43YzQuNiwyLjYsOC40LDkuOSw4LjQsMTZ2NjIuNUMxMjIuMiwxODQuNCwxMjAsMTg3LjQsMTE2LjcsMTg3LjR6IE01Ny4xLDcwLjcKCQkJCWMtMi42LDAtNC4zLDIuNS00LjMsNi41djYyLjVjMCw1LjcsMy41LDEyLjUsNy44LDE0LjlsNTMuMiwzMC43YzEsMC42LDIsMC45LDIuOSwwLjljMi42LDAsNC4zLTIuNSw0LjMtNi41di02Mi41CgkJCQljMC01LjctMy41LTEyLjUtNy44LTE0LjlMNjAsNzEuNkM1OSw3MSw1OCw3MC43LDU3LjEsNzAuN3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0YwOTkzOCIgZD0iTTk3LjcsMTIwLjdjMCw1LTEuOSw4LjMtNC45LDlWMTQ2YzAsMC45LTAuMSwxLjctMC4zLDIuNGMtMC44LDIuNC0yLjgsMy4yLTUuMiwxLjgKCQkJCQljLTMuMS0xLjctNS41LTYuNS01LjUtMTAuNnYtMTYuM2MtMy00LjItNC45LTkuNy00LjktMTQuN2MwLTcuNiw0LjYtMTEuMiwxMC4zLThjMC4xLDAsMC4xLDAuMSwwLjIsMC4xCgkJCQkJQzkzLDEwNCw5Ny43LDExMyw5Ny43LDEyMC43eiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iIzIyMUYyMCIgZD0iTTg5LjQsMTUxLjVjLTAuOCwwLTEuNy0wLjMtMi41LTAuOGMtMy4yLTEuOS01LjktNi44LTUuOS0xMS4ydi0xNi4xYy0zLjEtNC41LTQuOS0xMC00LjktMTQuOQoJCQkJCWMwLTIuOSwwLjYtNS40LDEuOS03LjFjMS4yLTEuNywzLTIuNyw1LTIuN2MxLjQsMCwyLjgsMC40LDQuMywxLjJjMC4xLDAsMC4xLDAuMSwwLjIsMC4xYzUuOSwzLjQsMTAuOCwxMi42LDEwLjgsMjAuNgoJCQkJCWMwLDQuOS0xLjgsOC40LTQuOSw5LjVWMTQ2YzAsMS0wLjEsMS44LTAuNCwyLjZDOTIuNCwxNTAuNCw5MS4xLDE1MS41LDg5LjQsMTUxLjV6IE04My4xLDEwMGMtMS42LDAtMywwLjgtNC4xLDIuMgoJCQkJCWMtMS4xLDEuNS0xLjcsMy43LTEuNyw2LjRjMCw0LjcsMS44LDEwLjEsNC44LDE0LjNsMC4xLDAuMXYxNi41YzAsMy45LDIuMyw4LjQsNS4yLDEwLjFjMC42LDAuNCwxLjMsMC42LDEuOSwwLjYKCQkJCQljMS4xLDAsMi0wLjcsMi41LTIuMWMwLjItMC42LDAuMy0xLjQsMC4zLTIuMnYtMTYuOGwwLjUtMC4xYzIuOC0wLjcsNC41LTMuOSw0LjUtOC40YzAtNy41LTQuNi0xNi4zLTEwLjItMTkuNQoJCQkJCWMtMC4xLDAtMC4xLTAuMS0wLjEtMC4xQzg1LjQsMTAwLjQsODQuMiwxMDAsODMuMSwxMDB6Ii8+CgkJCTwvZz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMjFGMjAiIGQ9Ik05Ny43LDEyMC43YzAsNS0xLjksOC4zLTQuOSw5VjE0NmMwLDAuOS0wLjEsMS43LTAuMywyLjRjLTItMi4zLTMuNC01LjctMy40LTguOHYtMTYuMwoJCQkJYy0zLTQuMi00LjktOS43LTQuOS0xNC43YzAtMy44LDEuMS02LjYsMy04YzAuMSwwLDAuMSwwLjEsMC4yLDAuMUM5MywxMDQsOTcuNywxMTMsOTcuNywxMjAuN3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0QwRDJEMyIgZD0iTTEzMC41LDcxLjdjMC0yMC44LTEyLjYtNDQuOC0yOC4yLTUzLjljLTMuMS0xLjctNi0yLjgtOC44LTMuMmMtMi4xLTAuMy00LjEtMC4yLTYsMC4zCgkJCQkJYy02LjUsMS0xMC44LDQuMy0xMy40LDcuMmMxLjYtMC43LDMuNC0xLjIsNS41LTEuNWMxLjktMC41LDMuOS0wLjYsNi0wLjNjMi44LDAuNCw1LjcsMS40LDguOCwzLjJjMTUuNSw5LDI4LjIsMzMuMSwyOC4yLDUzLjkKCQkJCQl2MjMuM2MyLjEsMC4xLDQuMy0wLjMsNS44LTEuMmMxLjUtMC45LDIuMi0yLjEsMi4xLTMuNGwwLDBMMTMwLjUsNzEuN0wxMzAuNSw3MS43eiIvPgoJCQk8L2c+CgkJCTxnPgoJCQkJPHBhdGggZmlsbD0iI0QwRDJEMyIgZD0iTTg3LjQsMzQuNEw4Ny40LDM0LjRMODcuNCwzNC40Yy0xLjksMi43LTIuOSw2LjYtMi45LDExLjR2MjMuNmMwLjEsMS4yLTAuNiwyLjUtMi4xLDMuMwoJCQkJCWMtMC4zLDAuMS0wLjYsMC4zLTAuOCwwLjRjLTAuMiwwLjEtMC40LDAuMS0wLjYsMC4zYy0wLjIsMC4xLTAuNSwwLjEtMC42LDAuMmMtMC4zLDAuMS0wLjYsMC4xLTEsMC4ybDAsMAoJCQkJCWMtMC44LDAuMS0xLjcsMC4yLTIuNiwwLjJWNTEuNGMwLTQuOCwxLjEtOC43LDIuOS0xMS40QzgwLjYsMzgsODQuNCwzNC44LDg3LjQsMzQuNEM4Ny4zLDM0LjQsODcuMywzNC40LDg3LjQsMzQuNHoiLz4KCQkJPC9nPgoJCTwvZz4KCTwvZz4KPC9nPgo8ZyBpZD0iTGF5ZXJfMl8yXyI+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "mail", "name": "mail", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgd2lkdGg9Ijc5MC40cHgiIGhlaWdodD0iNjIyLjlweCIgdmlld0JveD0iMCAwIDc5MC40IDYyMi45IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA3OTAuNCA2MjIuOSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iI0ZGQjIxQSIgcG9pbnRzPSI1NDYuNywyMDAuNCA1NDYuNyw0ODQuMiA1NDYuNCw0ODQuNCA1MTguMSw1MDAuNyA1MTguMSwyMTYuNiA1MTguMywyMTYuNSA1NDYuNCwyMDAuMyAJCSIvPgoJCTxnPgoJCQk8cG9seWdvbiBmaWxsPSIjRkZCMjFBIiBwb2ludHM9IjUxOC4zLDIxNi42IDUxOC4zLDUwMC43IDE5MS42LDMxMi4xIDE5MS42LDI4IDE5Mi45LDI4LjggNTE4LjEsMjE2LjUgCQkJIi8+CgkJCQoJCQkJPHBhdGggZmlsbD0iI0ZGQzkzRSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSIKCQkJCU01MTcsNTAwLjRMMTkxLjgsMzEyLjdMMzEyLDI0Ny4xbDIxLjYsMzkuNmM3LDEyLjksMjMuMiwxNy43LDM2LjEsMTAuNmwyOC4xLTE1LjNMNTE3LDUwMC40eiIvPgoJCQk8cGF0aCBkPSJNNTE5LjEsMjE2LjZjLTE3Ny4zLDExNy42LTE1MC4xLDE1Mi4xLTIxMiwzMi43QzI4NC4zLDIwNS4yLDIxNC4yLDcyLDE5MS42LDI4QzI2My43LDczLjcsNDYzLDIxNCw1MTkuMSwyMTYuNnoiLz4KCQkJCgkJCQk8cGF0aCBmaWxsPSIjRkZDOTNFIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9IgoJCQkJTTUxOC4xLDIxNi41bC0xMjAuMiw2NS42bC0yNC4xLDEzLjJjLTE1LjEsOC4yLTM0LjEsMi43LTQyLjMtMTIuNGwtNC44LTguOWwtMTIuNS0yMi44bC0yLjItNGwtNi41LTExLjlMMjcyLDE3My44bC03OS4xLTE0NQoJCQkJbDE2Ny4yLDk2LjZsNzAuOSw0MC45bDE4LjMsMTAuNWwyNi4zLDE1LjJMNTE4LjEsMjE2LjV6Ii8+CgkJCQoJCQkJPHBvbHlnb24gZmlsbD0iI0ZGRDY3QiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IgoJCQkJMTkxLjYsMjguMSA1MTguMSwyMTYuNiA1NDYuNCwyMDAuMyAyMjAsMTEuOCAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIyMDAsMzAuMyAyNDEuMyw1NC4xIDI2MS44LDQzLjEgMjE5LjgsMTguOSAJCQkiLz4KCQkJPHBvbHlsaW5lIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMjg0LjUsNTYuNiAyNjQuOCw2OCAyNTEuOCw2MC41IDI3MS42LDQ5LjEgCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMjU2LjEsNjggMjExLjcsNTguNSAyMTEuNywyOTkuMyAyNDEuMywzMzMuNyAyMDAuOCwzMTAuNCAxOTcuNiwzMDcgMTk3LjcsNDIuMyAxOTguOCwzNC45IAkJCQoJCQkJIi8+CgkJCTxwYXRoIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzIzMUYyMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik03MjAuMiwzNzIuMmwtMTYzLjctOTguNnYyMTYuN2wxNjMuNy05OC42CgkJCQlDNzI3LjUsMzg3LjIsNzI3LjUsMzc2LjYsNzIwLjIsMzcyLjJ6Ii8+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yMjAuOCwwLjVMMjE5LjksMGwtMzguNSwyMi4ybC0wLjEsMjk0Ljh2MWwzMzYsMTkzLjlsMC45LDAuNWwzOC4zLTIyLjFsMC4zLTI5NC43bDAtMUwyMjAuOCwwLjV6CgkJCQkgTTMwOSwyNDYuMkwxOTcuNiwzMDdsMC4xLTI2NC43TDMwOSwyNDYuMnogTTE5OC44LDM0LjlsMzE0LjYsMTgxLjZsLTE0Ni4xLDc5LjdjLTExLjEsNi0yNC45LDItMzAuOS05LjFsLTIzLjQtNDNsMCwwCgkJCQlMMTk4LjgsMzQuOXogTTMxMS4xLDI1MC4ybDIwLjYsMzcuN2M3LjYsMTQsMjUuMSwxOS4xLDM5LDExLjVsMjYuMi0xNC4zbDExMC4yLDIwMi4xTDIwMC44LDMxMC40TDMxMS4xLDI1MC4yeiBNNDAwLjksMjgzCgkJCQlsMTE0LjktNjIuN3YyNzEuOWwtMS4yLTAuN0w0MDAuOSwyODN6IE0yMDAsMzAuM2wxOS44LTExLjRMNTM4LDIwMi42TDUxOC4yLDIxNEwyMDAsMzAuM3ogTTUyMC4zLDIxNy45bDIuMS0xLjJsMC4yLTAuMWwwLDAKCQkJCWwxNy43LTEwLjJsLTAuMiwyNzQuNWwtMTkuOCwxMS40TDUyMC4zLDIxNy45TDUyMC4zLDIxNy45eiIvPgoJCTwvZz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "mailmultiple", "name": "mailmultiple", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhcGFfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiCgkgd2lkdGg9Ijc5MC40cHgiIGhlaWdodD0iNjIyLjlweCIgdmlld0JveD0iMCAwIDc5MC40IDYyMi45IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA3OTAuNCA2MjIuOSIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iI0ZGQjIxQSIgcG9pbnRzPSI1ODcuOSwyMDcuOSA1ODcuOSw0NzUuNCA1ODcuNiw0NzUuNiA1NjAuOSw0OTEgNTYwLjksMjIzLjIgNTYxLjEsMjIzLjEgNTg3LjYsMjA3LjggCQkiLz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0ZGQjIxQSIgcG9pbnRzPSI1NjEuMSwyMjMuMiA1NjEuMSw0OTEgMjUzLjIsMzEzLjIgMjUzLjIsNDUuNCAyNTQuNCw0Ni4xIDU2MC45LDIyMy4xIAkJCSIvPgoJCQkKCQkJCTxwYXRoIGZpbGw9IiNGRkM5M0UiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iCgkJCQlNNTU5LjksNDkwLjdsLTMwNi41LTE3N0wzNjYuNywyNTJsMjAuNCwzNy40YzYuNiwxMi4yLDIxLjksMTYuNywzNC4xLDEwbDI2LjUtMTQuNEw1NTkuOSw0OTAuN3oiLz4KCQkJPHBhdGggZD0iTTU2MS45LDIyMy4yQzM5NC44LDMzNCw0MjAuNCwzNjYuNSwzNjIuMSwyNTRjLTIxLjUtNDEuNS04Ny42LTE2Ny0xMDguOS0yMDguNkMzMjEuMiw4OC41LDUwOS4xLDIyMC43LDU2MS45LDIyMy4yeiIvPgoJCQkKCQkJCTxwYXRoIGZpbGw9IiNGRkM5M0UiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iCgkJCQlNNTYwLjksMjIzLjFsLTExMy4zLDYxLjhsLTIyLjcsMTIuNGMtMTQuMyw3LjgtMzIuMSwyLjUtMzkuOS0xMS43bC00LjYtOC40bC0xMS43LTIxLjVsLTItMy43bC02LjEtMTEuMmwtMzEuNi01OEwyNTQuNCw0Ni4xCgkJCQlsMTU3LjYsOTFsNjYuOCwzOC42bDE3LjIsOS45bDI0LjgsMTQuM0w1NjAuOSwyMjMuMXoiLz4KCQkJCgkJCQk8cG9seWdvbiBmaWxsPSIjRkZENjdCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iCgkJCQkyNTMuMiw0NS41IDU2MC45LDIyMy4yIDU4Ny42LDIwNy44IDI3OS45LDMwLjEgCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMjYxLjEsNDcuNiAzMDAsNzAgMzE5LjMsNTkuNiAyNzkuOCwzNi44IAkJCSIvPgoJCQk8cG9seWxpbmUgZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIzNDAuNyw3Mi40IDMyMi4yLDgzLjEgMzA5LjksNzYgMzI4LjYsNjUuMiAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0YwRjdGRiIgcG9pbnRzPSIzMTQsODMuMSAyNzIuMSw3NC4yIDI3Mi4xLDMwMS4xIDMwMCwzMzMuNiAyNjEuOCwzMTEuNSAyNTguOCwzMDguNCAyNTguOSw1OC44IDI2MCw1MS45IAkJCSIvPgoJCQk8cGF0aCBvcGFjaXR5PSIwLjQiIGZpbGw9IiMyMzFGMjAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNNzUxLjQsMzY5LjhsLTE1NC4zLTkzdjIwNC4zbDE1NC4zLTkzCgkJCQlDNzU4LjMsMzg0LDc1OC4zLDM3NCw3NTEuNCwzNjkuOHoiLz4KCQkJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTI4MC43LDE5LjVsLTAuOC0wLjVsLTM2LjMsMjAuOWwtMC4xLDI3Ny45djFsMzE2LjcsMTgyLjhsMC44LDAuNWwzNi4xLTIwLjlsMC4yLTI3Ny44di0xTDI4MC43LDE5LjV6CgkJCQkgTTM2My44LDI1MS4xbC0xMDUsNTcuM2wwLjEtMjQ5LjVMMzYzLjgsMjUxLjF6IE0yNjAsNTEuOWwyOTYuNiwxNzEuMmwtMTM3LjgsNzUuMWMtMTAuNCw1LjctMjMuNSwxLjgtMjkuMi04LjZsLTIyLjEtNDAuNWwwLDAKCQkJCUwyNjAsNTEuOXogTTM2NS44LDI1NC44bDE5LjQsMzUuNmM3LjIsMTMuMiwyMy43LDE4LDM2LjgsMTAuOGwyNC43LTEzLjVsMTAzLjksMTkwLjVMMjYxLjgsMzExLjVMMzY1LjgsMjU0Ljh6IE00NTAuNSwyODUuNwoJCQkJbDEwOC40LTU5LjF2MjU2LjNsLTEuMi0wLjdMNDUwLjUsMjg1Ljd6IE0yNjEuMSw0Ny42bDE4LjctMTAuOGwyOTkuOSwxNzMuMUw1NjEsMjIwLjdMMjYxLjEsNDcuNnogTTU2MywyMjQuNGwyLTEuMWwwLjItMC4xbDAsMAoJCQkJbDE2LjctOS43bC0wLjIsMjU4LjdMNTYzLDQ4M1YyMjQuNEw1NjMsMjI0LjR6Ii8+CgkJPC9nPgoJPC9nPgo8L2c+CjxnPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iI0ZGQjIxQSIgcG9pbnRzPSI1MzEuMywyMzYuMiA1MzEuMyw1MDMuNyA1MzEuMSw1MDMuOCA1MDQuNCw1MTkuMiA1MDQuNCwyNTEuNSA1MDQuNSwyNTEuNCA1MzEuMSwyMzYgCQkiLz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0ZGQjIxQSIgcG9pbnRzPSI1MDQuNSwyNTEuNSA1MDQuNSw1MTkuMiAxOTYuNiwzNDEuNSAxOTYuNiw3My43IDE5Ny45LDc0LjQgNTA0LjQsMjUxLjQgCQkJIi8+CgkJCQoJCQkJPHBhdGggZmlsbD0iI0ZGQzkzRSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSIKCQkJCU01MDMuMyw1MTlMMTk2LjgsMzQybDExMy4zLTYxLjhsMjAuNCwzNy40YzYuNiwxMi4yLDIxLjksMTYuNywzNC4xLDEwbDI2LjUtMTQuNEw1MDMuMyw1MTl6Ii8+CgkJCTxwYXRoIGQ9Ik01MDUuMywyNTEuNWMtMTY3LjEsMTEwLjgtMTQxLjUsMTQzLjMtMTk5LjgsMzAuOGMtMjEuNS00MS41LTg3LjYtMTY3LTEwOC45LTIwOC42QzI2NC42LDExNi44LDQ1Mi41LDI0OSw1MDUuMywyNTEuNXoiCgkJCQkvPgoJCQkKCQkJCTxwYXRoIGZpbGw9IiNGRkM5M0UiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iCgkJCQlNNTA0LjQsMjUxLjRsLTExMy4zLDYxLjhsLTIyLjcsMTIuNGMtMTQuMyw3LjgtMzIuMSwyLjUtMzkuOS0xMS43bC00LjYtOC40TDMxMi4yLDI4NGwtMi0zLjdMMzA0LDI2OWwtMzEuNi01OEwxOTcuOSw3NC40CgkJCQlsMTU3LjYsOTFsNjYuOCwzOC42bDE3LjIsOS45bDI0LjgsMTQuM0w1MDQuNCwyNTEuNHoiLz4KCQkJCgkJCQk8cG9seWdvbiBmaWxsPSIjRkZENjdCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iCgkJCQkxOTYuNiw3My44IDUwNC40LDI1MS41IDUzMS4xLDIzNiAyMjMuNCw1OC40IAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjRjBGN0ZGIiBwb2ludHM9IjIwNC42LDc1LjggMjQzLjUsOTguMyAyNjIuOCw4Ny45IDIyMy4yLDY1LjEgCQkJIi8+CgkJCTxwb2x5bGluZSBmaWxsPSIjRjBGN0ZGIiBwb2ludHM9IjI4NC4yLDEwMC42IDI2NS42LDExMS4zIDI1My4zLDEwNC4zIDI3Miw5My41IAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjRjBGN0ZGIiBwb2ludHM9IjI1Ny40LDExMS4zIDIxNS41LDEwMi40IDIxNS41LDMyOS40IDI0My41LDM2MS45IDIwNS4zLDMzOS44IDIwMi4zLDMzNi42IDIwMi40LDg3LjEgCgkJCQkyMDMuNCw4MC4yIAkJCSIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMjI0LjEsNDcuOGwtMC44LTAuNUwxODcsNjguMkwxODYuOSwzNDZ2MWwzMTYuNywxODIuOGwwLjgsMC41bDM2LjEtMjAuOWwwLjItMjc3Ljh2LTFMMjI0LjEsNDcuOHoKCQkJCSBNMzA3LjMsMjc5LjRsLTEwNSw1Ny4zbDAuMS0yNDkuNUwzMDcuMywyNzkuNHogTTIwMy40LDgwLjJMNTAwLDI1MS4zbC0xMzcuOCw3NS4xYy0xMC40LDUuNy0yMy41LDEuOC0yOS4yLTguNmwtMjItNDAuNGwwLDAKCQkJCUwyMDMuNCw4MC4yeiBNMzA5LjMsMjgzLjFsMTkuNCwzNS42YzcuMiwxMy4yLDIzLjcsMTgsMzYuOCwxMC44bDI0LjctMTMuNWwxMDMuOSwxOTAuNUwyMDUuMywzMzkuOEwzMDkuMywyODMuMXogTTM5My45LDMxNAoJCQkJbDEwOC40LTU5LjF2MjU2LjNsLTEuMi0wLjdMMzkzLjksMzE0eiBNMjA0LjYsNzUuOEwyMjMuMyw2NWwyOTkuOSwxNzMuMUw1MDQuNSwyNDlMMjA0LjYsNzUuOHogTTUwNi41LDI1Mi43bDItMS4xbDAuMi0wLjFsMCwwCgkJCQlsMTYuNy05LjdsLTAuMiwyNTguN2wtMTguNywxMC44VjI1Mi43TDUwNi41LDI1Mi43eiIvPgoJCTwvZz4KCTwvZz4KPC9nPgo8Zz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiNGRkIyMUEiIHBvaW50cz0iNDc0LjcsMjczLjkgNDc0LjcsNTQxLjQgNDc0LjUsNTQxLjUgNDQ3LjgsNTU3IDQ0Ny44LDI4OS4yIDQ0OCwyODkuMSA0NzQuNSwyNzMuNyAJCSIvPgoJCTxnPgoJCQk8cG9seWdvbiBmaWxsPSIjRkZCMjFBIiBwb2ludHM9IjQ0OCwyODkuMiA0NDgsNTU3IDE0MCwzNzkuMiAxNDAsMTExLjQgMTQxLjMsMTEyLjEgNDQ3LjgsMjg5LjEgCQkJIi8+CgkJCQoJCQkJPHBhdGggZmlsbD0iI0ZGQzkzRSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSIKCQkJCU00NDYuOCw1NTYuN2wtMzA2LjUtMTc3bDExMy4zLTYxLjhsMjAuNCwzNy40YzYuNiwxMi4yLDIxLjksMTYuNywzNC4xLDEwbDI2LjUtMTQuNEw0NDYuOCw1NTYuN3oiLz4KCQkJPHBhdGggZD0iTTQ0OC44LDI4OS4yQzI4MS43LDQwMCwzMDcuMyw0MzIuNSwyNDksMzE5LjljLTIxLjUtNDEuNS04Ny42LTE2Ny0xMDguOS0yMDguNkMyMDguMSwxNTQuNSwzOTUuOSwyODYuNyw0NDguOCwyODkuMnoiLz4KCQkJCgkJCQk8cGF0aCBmaWxsPSIjRkZDOTNFIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9IgoJCQkJTTQ0Ny44LDI4OS4xbC0xMTMuMyw2MS44bC0yMi43LDEyLjRjLTE0LjMsNy44LTMyLjEsMi41LTM5LjktMTEuN2wtNC42LTguNGwtMTEuNy0yMS41bC0yLTMuN2wtNi4xLTExLjJsLTMxLjYtNThsLTc0LjUtMTM2LjcKCQkJCWwxNTcuNiw5MWw2Ni44LDM4LjZsMTcuMiw5LjlsMjQuOCwxNC4zTDQ0Ny44LDI4OS4xeiIvPgoJCQkKCQkJCTxwb2x5Z29uIGZpbGw9IiNGRkQ2N0IiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIKCQkJCTE0MCwxMTEuNSA0NDcuOCwyODkuMiA0NzQuNSwyNzMuNyAxNjYuOCw5Ni4xIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjRjBGN0ZGIiBwb2ludHM9IjE0OCwxMTMuNSAxODYuOSwxMzYgMjA2LjIsMTI1LjYgMTY2LjcsMTAyLjggCQkJIi8+CgkJCTxwb2x5bGluZSBmaWxsPSIjRjBGN0ZGIiBwb2ludHM9IjIyNy42LDEzOC40IDIwOSwxNDkuMSAxOTYuOCwxNDIgMjE1LjQsMTMxLjIgCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiNGMEY3RkYiIHBvaW50cz0iMjAwLjksMTQ5LjEgMTU5LDE0MC4xIDE1OSwzNjcuMSAxODYuOSwzOTkuNiAxNDguNywzNzcuNSAxNDUuNywzNzQuMyAxNDUuOCwxMjQuOCAxNDYuOCwxMTcuOSAKCQkJCQkJCSIvPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMTY3LjUsODUuNWwtMC44LTAuNWwtMzYuMywyMC45bC0wLjEsMjc3Ljl2MUw0NDcsNTY3LjVsMC44LDAuNWwzNi4xLTIwLjlsMC4yLTI3Ny44di0xTDE2Ny41LDg1LjV6CgkJCQkgTTI1MC43LDMxNy4xbC0xMDUsNTcuM2wwLjEtMjQ5LjVMMjUwLjcsMzE3LjF6IE0xNDYuOCwxMTcuOWwyOTYuNiwxNzEuMmwtMTM3LjgsNzUuMWMtMTAuNCw1LjctMjMuNSwxLjgtMjkuMi04LjZsLTIyLjEtNDAuNQoJCQkJbDAsMEwxNDYuOCwxMTcuOXogTTI1Mi43LDMyMC44bDE5LjQsMzUuNmM3LjIsMTMuMiwyMy43LDE4LDM2LjgsMTAuOGwyNC43LTEzLjVsMTAzLjksMTkwLjVMMTQ4LjcsMzc3LjVMMjUyLjcsMzIwLjh6CgkJCQkgTTMzNy40LDM1MS43bDEwOC40LTU5LjF2MjU2LjNsLTEuMi0wLjdMMzM3LjQsMzUxLjd6IE0xNDgsMTEzLjVsMTguNy0xMC44bDI5OS45LDE3My4xTDQ0OCwyODYuNkwxNDgsMTEzLjV6IE00NDkuOSwyOTAuNAoJCQkJbDItMS4xbDAuMi0wLjFsMCwwbDE2LjctOS43bC0wLjIsMjU4LjdMNDQ5LjksNTQ5VjI5MC40TDQ0OS45LDI5MC40eiIvPgoJCTwvZz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "mobiledevice", "name": "mobiledevice", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI4MjQuN3B4IiBoZWlnaHQ9Ijg1MC45cHgiIHZpZXdCb3g9IjAgMCA4MjQuNyA4NTAuOSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgODI0LjcgODUwLjkiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIGZpbGw9IiNEQ0U5RjkiIGQ9Ik0yNTEuMyw1OTYuM3YtNTYuNkwyMTYsNTE5LjR2NTMuNmMwLDIwLjYsMTYuNyw0Ni45LDM3LjMsNTguOGwxNC41LDguMwoJCUMyNTcuNyw2MjYuMiwyNTEuMyw2MTAuMiwyNTEuMyw1OTYuM3oiLz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iNTAzLjYsMjQ0LjUgNTAzLjYsNjg1LjQgMjc2LjUsNTU0LjMgMjQ0LjIsNTM1LjcgMjIyLjksNTIzLjMgMjE2LDUxOS4zIDIxNiw3OC40IDQzNy45LDIwNi41IAoJCQk1MDIuNCwyNDMuOCAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNFNkYwRkYiIHBvaW50cz0iMzE0LjEsNTc0LjcgMjgxLjksNTU2LjEgMjYwLjUsNTQzLjcgMjUzLjYsNTM5LjggMjUzLjYsMTAwLjEgMjE2LDc4LjQgMjE2LDUxOS4zIDIyMi45LDUyMy4zIAoJCQkyNDQuMiw1MzUuNyAyNzYuNSw1NTQuMyA1MDMuNiw2ODUuNCA1MDMuNiw2ODQuMSAJCSIvPgoJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik01MzguNSwxOTcuMnY1MjEuN2MwLDkuMS0zLjMsMTUuNS04LjYsMTguOWwtMC45LDAuNWwtMzQuNSwxOS45djBjNS43LTMuMyw5LjItOS44LDkuMi0xOS4yVjIxNy4zCgkJCWMwLTIwLjYtMTYuNy00Ni45LTM3LjMtNTguOEwzMDAuNCw2Mi43bC0xOS42LTExLjNsLTI3LjUtMTUuOWMtMC40LTAuMi0wLjgtMC41LTEuMi0wLjdjLTAuNC0wLjItMC44LTAuNC0xLjItMC42CgkJCWMtMTAuMi01LjItMTkuMi01LjctMjUuNi0yLjJ2MGwzNC4xLTE5LjZsMC4zLTAuMmMyLjgtMS43LDYuMS0yLjYsOS45LTIuNmM1LjUsMCwxMS44LDEuOSwxOC42LDUuOGwyNC42LDE0LjJsMTg4LjQsMTA4LjgKCQkJQzUyMS44LDE1MC4yLDUzOC41LDE3Ni42LDUzOC41LDE5Ny4yeiIvPgoJCTxwYXRoIGZpbGw9IiNCNEMwRDMiIGQ9Ik01MDMuNiwyNDQuNXYtMjcuMmMwLTIwLjYtMTYuNy00Ni45LTM3LjMtNTguOGwtMjEzLjEtMTIzQzIzMi43LDIzLjYsMjE2LDMwLjYsMjE2LDUxLjJ2MjcuMkw1MDMuNiwyNDQuNQoJCQl6Ii8+CgkJPHBhdGggZmlsbD0iI0RDRTlGOSIgZD0iTTI1MS4zLDgwLjdjMC0yMC4zLDExLjctMzEuMSwyOC43LTI5LjhsLTI2LjgtMTUuNUMyMzIuNywyMy42LDIxNiwzMC42LDIxNiw1MS4ydjI3LjJsMzUuNCwyMC40VjgwLjcKCQkJTDI1MS4zLDgwLjd6Ii8+CgkJPHBhdGggZmlsbD0iI0I0QzBEMyIgZD0iTTIxNiw1MTkuNHY1My42YzAsMjAuNiwxNi43LDQ2LjksMzcuMyw1OC44bDIxMy4xLDEyM2MyMC42LDExLjksMzcuMyw0LjgsMzcuMy0xNS44di01My42TDIxNiw1MTkuNHoiLz4KCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMzY3LjQsNjQ5LjljMCwxNi4xLTEzLjEsMjEuNi0yOS4yLDEyLjNjLTE2LjEtOS4zLTI5LjItMjkuOS0yOS4yLTQ2YzAtMTYuMSwxMy4xLTIxLjYsMjkuMi0xMi4zCgkJCUMzNTQuNCw2MTMuMiwzNjcuNCw2MzMuOCwzNjcuNCw2NDkuOXoiLz4KCQk8cGF0aCBmaWxsPSIjQjBDQUUyIiBkPSJNMzM4LjIsNjAzLjljLTMtMS43LTUuOS0zLTguNy0zLjdjMTQuOSw5LjksMjYuNSwyOS4xLDI2LjUsNDQuM2MwLDEzLjEtOC42LDE5LjItMjAuNSwxNgoJCQljMC45LDAuNiwxLjgsMS4yLDIuNywxLjdjMTYuMSw5LjMsMjkuMiwzLjgsMjkuMi0xMi4zQzM2Ny40LDYzMy44LDM1NC40LDYxMy4yLDMzOC4yLDYwMy45eiIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0zNTIuOCw2NjguOWMtNC44LDAtMTAuMi0xLjctMTUuNy00LjhjLTE2LjctOS42LTMwLjMtMzEuMS0zMC4zLTQ3LjljMC0xMS42LDYuNi0xOS4xLDE2LjgtMTkuMQoJCQljNC44LDAsMTAuMiwxLjcsMTUuNyw0LjhjMTYuNyw5LjYsMzAuMywzMS4xLDMwLjMsNDcuOUMzNjkuNiw2NjEuNSwzNjMsNjY4LjksMzUyLjgsNjY4Ljl6IE0zMjMuNyw2MDEuNQoJCQljLTcuOCwwLTEyLjQsNS41LTEyLjQsMTQuN2MwLDE1LjQsMTIuNiwzNS4yLDI4LjEsNDQuMWM0LjgsMi44LDkuNCw0LjIsMTMuNSw0LjJjNy44LDAsMTIuNC01LjUsMTIuNC0xNC43CgkJCWMwLTE1LjQtMTIuNi0zNS4yLTI4LjEtNDQuMUMzMzIuMyw2MDMsMzI3LjcsNjAxLjUsMzIzLjcsNjAxLjV6Ii8+CgkJPHBhdGggZmlsbD0iI0RDRTlGOSIgZD0iTTMxMi44LDI5LjZsLTIuMiw1LjdsLTI3LjQsMTQuNmwtMi4zLDEuNGwtMjcuNS0xNS45Yy0wLjQtMC4yLTAuOC0wLjUtMS4yLTAuN2MtMC40LTAuMi0wLjgtMC40LTEuMi0wLjYKCQkJYy0xMC4yLTUuMi0xOS4yLTUuNy0yNS42LTIuMnYwbDM0LjEtMTkuNmwwLjMtMC4yYzIuOC0xLjcsNi4xLTIuNiw5LjktMi42YzUuNSwwLDExLjgsMS45LDE4LjYsNS44TDMxMi44LDI5LjZ6Ii8+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTMyNS4xLDExMi4xYzAsNS42LTQuNiw3LjUtMTAuMiw0LjNjLTUuNi0zLjItMTAuMi0xMC40LTEwLjItMTZjMC01LjYsNC42LTcuNSwxMC4yLTQuMwoJCQlDMzIwLjYsOTkuMywzMjUuMSwxMDYuNSwzMjUuMSwxMTIuMXoiLz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzIwLjEsMTIwLjRjLTIsMC00LjEtMC42LTYuMy0xLjljLTYuNC0zLjctMTEuNC0xMS42LTExLjQtMTguMWMwLTQuOSwzLTguMyw3LjUtOC4zYzIsMCw0LjEsMC42LDYuMywxLjkKCQkJYzYuNCwzLjcsMTEuNCwxMS42LDExLjQsMTguMUMzMjcuNSwxMTcuMSwzMjQuNSwxMjAuNCwzMjAuMSwxMjAuNHogTTMwOS45LDk2LjljLTEuOCwwLTIuNywxLjItMi43LDMuNWMwLDQuOCw0LjEsMTEuMiw5LDE0CgkJCWMxLjQsMC44LDIuOCwxLjMsMy45LDEuM2MxLjgsMCwyLjctMS4yLDIuNy0zLjVjMC00LjgtNC4xLTExLjItOS0xNEMzMTIuNCw5Ny4zLDMxMSw5Ni45LDMwOS45LDk2Ljl6Ii8+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTM5Ny40LDE1NC4zYzAsMy43LTMsNS02LjgsMi45bC00Ni0yNi42Yy0zLjgtMi4yLTYuOC03LTYuOC0xMC43bDAsMGMwLTMuOCwzLTUsNi44LTIuOWw0NiwyNi42CgkJCUMzOTQuNCwxNDUuNywzOTcuNCwxNTAuNSwzOTcuNCwxNTQuM0wzOTcuNCwxNTQuM3oiLz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzk0LDE2MC42Yy0xLjUsMC0zLTAuNS00LjYtMS40bC00Ni0yNi42Yy00LjUtMi42LTgtOC4yLTgtMTIuOGMwLTMuOCwyLjMtNi4zLDUuOC02LjMKCQkJYzEuNSwwLDMsMC41LDQuNiwxLjRsNDYsMjYuNmM0LjUsMi42LDgsOC4yLDgsMTIuOEMzOTkuOCwxNTguMSwzOTcuNSwxNjAuNiwzOTQsMTYwLjZ6IE0zNDEuMiwxMTguM2MtMC40LDAtMSwwLTEsMS41CgkJCWMwLDIuOSwyLjYsNi45LDUuNiw4LjZsNDYsMjYuNmMwLjgsMC41LDEuNiwwLjcsMi4yLDAuN2MwLjQsMCwxLDAsMS0xLjVjMC0yLjktMi42LTYuOS01LjYtOC42bC00Ni0yNi42CgkJCUMzNDIuNiwxMTguNiwzNDEuOCwxMTguMywzNDEuMiwxMTguM3oiLz4KCQk8cGF0aCBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNNzQ2LjIsNTY3LjdjLTMyLTE3LjYtMTk4LjMtMTAwLTE5OC4zLTEwMHYyNTEuMWMwLDguNy0yLjUsMTUuOS03LjIsMjEKCQkJYy0xLjIsMS40LTIuNSwyLjYtNC4xLDMuNmwtMC43LDAuNGMwLDAsMCwwLDAsMGMtMC4xLDAtMC4xLDAuMS0wLjIsMC4xbC0xMC42LDYuMWMwLDAsMTkzLjgtMTAyLjMsMjI2LjctMTIxLjcKCQkJQzc4NC44LDYwOC45LDc4OS42LDU5MS41LDc0Ni4yLDU2Ny43eiIvPgoJCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iNTAxLjIsMjQ2LjEgMjMxLjEsNTI1LjIgMjI1LjQsNTIyIDIyNS40LDQyOS42IDQzOC40LDIwOS43IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSI1MDEsMjc3LjEgNTAxLjEsMzI4LjggMjgyLjcsNTU0LjggMjUxLjMsNTM2LjcgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjRENFOUY5IiBwb2ludHM9IjI5Ni44LDU3LjggMzIzLjMsNDIuNyAzMzUuNCw0OS43IDMwOS45LDY1LjQgCQkiLz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNNTA5LjYsMTMwLjlMNTA5LjYsMTMwLjlMMjk2LDcuNUMyOTUsNi44LDI4NC4yLDAsMjcxLjcsMGMtMy45LDAtNy42LDAuNy0xMSwybC0zNS44LDIwLjMKCQkJYy0yLjEsMC45LTE3LDcuOC0xNi4zLDI4LjlsMCw1MjEuOGMwLDAuNiwwLjQsMTQuOCw1LjgsMjUuMmMwLjUsMC45LDEsMS45LDEuNCwyLjljNC45LDkuOSwxMS4xLDIyLjMsMjksMzUuMmwwLjEsMC4xTDQ2My43LDc2MwoJCQljMSwwLjYsMTEuMyw3LjEsMjMuNSw3LjFjMCwwLDAsMCwwLDBjNi4yLDAsMTEuOC0xLjcsMTYuNy01TDUzNyw3NDZjMC41LTAuMywxMi4xLTYuOCwxMy4xLTIzLjRsMC4yLTE5LjNWMjAyCgkJCUM1NTAuNiwxOTguOSw1NTMuNiwxNjEuMiw1MDkuNiwxMzAuOXogTTUzMy40LDcxOC45YzAsNC4zLTAuOSwxMC01LjEsMTIuN2wtMjIuOCwxMy4xaDBjMC0wLjEsMC0wLjMsMC4xLTAuNAoJCQljMC4xLTAuNCwwLjEtMC43LDAuMi0xLjFjMC0wLjMsMC4xLTAuNSwwLjEtMC44YzAtMC4zLDAuMS0wLjUsMC4xLTAuOGMwLTAuNCwwLjEtMC45LDAuMS0xLjNjMC0wLjQsMC0wLjksMC0xLjNWMjE3LjMKCQkJYzAtMjEuMy0xNy4zLTQ4LjYtMzguNS02MC45bC0yMTMuMS0xMjNjLTAuNC0wLjItMC44LTAuNS0xLjItMC43Yy0wLjItMC4xLTAuMy0wLjItMC41LTAuM2MtMC4xLTAuMS0wLjMtMC4xLTAuNC0wLjIKCQkJYy0wLjEtMC4xLTAuMy0wLjEtMC40LTAuMmMtMC42LTAuMy0xLjItMC42LTEuOC0wLjljLTAuNS0wLjItMS0wLjQtMS41LTAuNmMtMC42LTAuMy0xLjMtMC41LTEuOS0wLjhjLTAuMSwwLTAuMi0wLjEtMC4zLTAuMQoJCQljLTAuMSwwLTAuMi0wLjEtMC4zLTAuMWwwLDBsMTkuMy0xMS4xYzAsMCwwLjEsMCwwLjEtMC4xYzEuNy0xLDMuNy0xLjUsNi4yLTEuNWM0LjQsMCw5LjUsMS43LDE1LDQuOGwyMTMuMSwxMjMKCQkJYzE4LjIsMTAuNSwzMy43LDM0LjYsMzMuNyw1Mi42VjcxOC45TDUzMy40LDcxOC45eiBNMjI1LjMsNTIyVjg2LjZsMjc1LjgsMTU5LjJ2NDM1LjRMMjI1LjMsNTIyeiBNNTAxLjEsNjg2LjhWNzM5CgkJCWMwLDAuNiwwLDEuMSwwLDEuN2MwLDAuNCwwLDAuNy0wLjEsMS4xYzAsMC4xLDAsMC4yLDAsMC4zYy0wLjEsMC45LTAuMiwxLjctMC40LDIuNWMwLDAuMSwwLDAuMSwwLDAuMmMtMC4yLDEuMi0wLjUsMi4zLTAuOSwzLjMKCQkJbDAsMGwtNi4xLDMuNWMtMS43LDEtNCwxLjUtNi42LDEuNWMtNC42LDAtOS43LTEuNi0xNC45LTQuNkwyNTksNjI1LjVjLTE4LjMtMTAuNS0zMy43LTM0LjYtMzMuNy01Mi42di00NS40TDUwMS4xLDY4Ni44egoJCQkgTTIyNS4zLDgxLjFWNTEuMmMwLTQuMSwwLjgtOS40LDQuNS0xMi4zYzAsMCwwLDAsMC4xLTAuMWMwLjItMC4xLDAuNC0wLjMsMC42LTAuNGMwLDAsMC4xLTAuMSwwLjEtMC4xYzAuMS0wLjEsMC4yLTAuMSwwLjQtMC4yCgkJCWMwLjEtMC4xLDAuMi0wLjEsMC4zLTAuMmwwLjItMC4xYzAuNC0wLjIsMC43LTAuNCwxLjEtMC43YzMuNS0xLjgsOS45LTQuMiwxNi4xLTEuNWMwLjQsMC4yLDAuOCwwLjQsMS4zLDAuNmwwLjMsMC4xCgkJCWMwLjIsMC4xLDAuNCwwLjIsMC42LDAuM2MwLjEsMC4xLDAuMywwLjEsMC40LDAuMmMwLjIsMC4xLDAuNSwwLjMsMC43LDAuNGwyMTMuMSwxMjNjMS45LDEuMSwzLjcsMi4zLDUuNSwzLjYKCQkJYzE3LjMsMTIuNiwzMC42LDM1LjIsMzAuNiw1My4xdjIzLjFMMjI1LjMsODEuMXoiLz4KCTwvZz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "office", "name": "office", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1MS4ycHgiIGhlaWdodD0iNzUuNnB4IiB2aWV3Qm94PSIwIDAgNTEuMiA3NS42IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA1MS4yIDc1LjYiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8cG9seWdvbiBpZD0iU2hhZG93IiBmaWxsPSIjNzY3Nzc2IiBwb2ludHM9IjMxLjMsNzEuOCAyNC42LDcxLjggMjUsNDQuMiA1MS4yLDU5LjggIi8+CjxwYXRoIGZpbGw9IiM0QzkzNEUiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0yNi41LDcwLjdsMTQuNi04LjhWMTQuN0wyNC41LDQuOEw4LDE0Ljd2MzQuNAoJYy0wLjYtMC45LTEuMy0xLjQtMS44LTEuNGMtMC40LDAtMC44LDAuMy0xLjIsMC43bDAsMGMtMC4xLDAuMS0wLjIsMC4yLTAuMywwLjRsMCwwYy0wLjEsMC4xLTAuMiwwLjMtMC4zLDAuNHYwLjEKCWMtMC4xLDAuMS0wLjIsMC4zLTAuMywwLjRjMCwwLDAsMC4xLTAuMSwwLjFjLTAuMSwwLjEtMC4xLDAuMy0wLjIsMC40YzAsMC4xLTAuMSwwLjEtMC4xLDAuMmMtMC4xLDAuMS0wLjEsMC4zLTAuMiwwLjQKCWMwLDAuMS0wLjEsMC4yLTAuMSwwLjNjLTAuMSwwLjEtMC4xLDAuMy0wLjIsMC40YzAsMC4xLTAuMSwwLjItMC4xLDAuM1MzLDUyLDMsNTIuMmMwLDAuMS0wLjEsMC4zLTAuMSwwLjRjMCwwLjEtMC4xLDAuMi0wLjEsMC4zCgljMCwwLjItMC4xLDAuMy0wLjEsMC41YzAsMC4xLDAsMC4yLTAuMSwwLjNjMCwwLjItMC4xLDAuNC0wLjEsMC41czAsMC4yLDAsMC4zYzAsMC4yLDAsMC40LTAuMSwwLjZjMCwwLjEsMCwwLjIsMCwwLjMKCWMwLDAuMywwLDAuNiwwLDAuOWMwLDAuMywwLDAuNSwwLDAuOHYwLjFjMCwwLjEsMCwwLjEsMCwwLjJjMCwwLjIsMCwwLjMsMC4xLDAuNGMwLDAuMSwwLDAuMSwwLDAuMnYwLjFjMCwwLjEsMC4xLDAuMiwwLjEsMC4zCglzMCwwLjEsMC4xLDAuMmMwLDAuMSwwLjEsMC4zLDAuMiwwLjR2MC4xYzAsMCwwLDAuMSwwLjEsMC4xdjAuMWMwLDAuMSwwLjEsMC4yLDAuMSwwLjJzMCwwLDAsMC4xdjAuMWMwLDAsMCwwLjEsMC4xLDAuMQoJYzAsMC4xLDAuMSwwLjEsMC4xLDAuMmwwLjEsMC4xYzAuMSwwLjEsMC4xLDAuMiwwLjIsMC4ybDAuMSwwLjFsMC4xLDAuMWwwLDBsMCwwbDAuMSwwLjFjMC4xLDAsMC4xLDAuMSwwLjIsMC4xbDAsMGMwLDAsMCwwLDAuMSwwCglsMCwwYzAuMSwwLjEsMC4yLDAuMSwwLjMsMC4yYzAsMCwwLjEsMCwwLjEsMC4xYzAuMSwwLDAuMSwwLjEsMC4yLDAuMWwwLDBoMC4xbDAsMGwwLDB2MS43QzUuMiw2My42LDUuNSw2NCw2LDY0aDAuNGgwLjEKCWMwLjQsMCwwLjgtMC40LDAuOC0wLjh2LTEuOEM3LjUsNjEuMyw3LjgsNjEuMiw4LDYxdjAuOWwxMi40LDcuNGMwLDAuMSwwLjEsMC4yLDAuMSwwLjJjMC40LDEsMS4yLDEuOSwyLjMsMi4zbDAsMHYxLjcKCWMwLDAuNCwwLjQsMC44LDAuOCwwLjhIMjRoMC4xYzAuNCwwLDAuOC0wLjQsMC44LTAuOHYtMS44TDI2LjUsNzAuNyIvPgo8ZyBpZD0iU3RydWN0dXJlIj4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMjQuNiwyNC43IDgsMTQuNyAyNC41LDQuOCAKCQkJNDEuMSwxNC43IAkJIi8+Cgk8L2c+Cgk8cG9seWdvbiBmaWxsPSIjRTJFMkUxIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjgsMTQuNyA4LDYxLjkgMjQuNiw3MS44IDI0LjYsMjQuNyAJCgkJIi8+Cgk8cG9seWdvbiBmaWxsPSIjOTU5NTk2IiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjQxLjEsMTQuNyA0MS4xLDYxLjkgMjQuNiw3MS44IAoJCTI0LjYsMjQuNyAJIi8+CjwvZz4KPGcgaWQ9IldpbmRvd3MiPgoJPGcgaWQ9IldpbmRvdyI+CgkJPHBvbHlnb24gZmlsbD0iIzdFOTVBQyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI4LDE2LjcgOCwyMS45IDI0LjYsMzEuOCAyNC42LDI2LjcgCgkJCQkJIi8+CgkJPHBvbHlsaW5lIGZpbGw9IiM0QTY1OEIiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMjQuNiwyNi43IDQxLjEsMTYuNyA0MS4xLDIxLjkgCgkJCTI0LjYsMzEuOCAJCSIvPgoJPC9nPgoJPGcgaWQ9IldpbmRvd18xXyI+CgkJPHBvbHlnb24gZmlsbD0iIzdFOTVBQyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI4LDI0LjMgOCwyOS40IDI0LjYsMzkuNCAyNC42LDM0LjIgCgkJCQkJIi8+CgkJPHBvbHlsaW5lIGZpbGw9IiM0QTY1OEIiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMjQuNiwzNC4yIDQxLjEsMjQuMyA0MS4xLDI5LjQgCgkJCTI0LjYsMzkuNCAJCSIvPgoJPC9nPgoJPGcgaWQ9IldpbmRvd18yXyI+CgkJPHBvbHlnb24gZmlsbD0iIzdFOTVBQyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI4LDMxLjggOCwzNyAyNC42LDQ2LjkgMjQuNiw0MS44IAkJCgkJCSIvPgoJCTxwb2x5bGluZSBmaWxsPSIjNEE2NThCIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjI0LjYsNDEuOCA0MS4xLDMxLjggNDEuMSwzNyAKCQkJMjQuNiw0Ni45IAkJIi8+Cgk8L2c+Cgk8ZyBpZD0iV2luZG93XzNfIj4KCQk8cG9seWdvbiBmaWxsPSIjN0U5NUFDIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjgsMzkuNCA4LDQ0LjUgMjQuNiw1NC41IDI0LjYsNDkuMyAKCQkJCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjNEE2NThCIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjI0LjYsNDkuMyA0MS4xLDM5LjQgNDEuMSw0NC41IAoJCQkyNC42LDU0LjUgCQkiLz4KCTwvZz4KCTxnIGlkPSJXaW5kb3dfNF8iPgoJCTxwb2x5Z29uIGZpbGw9IiM3RTk1QUMiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iOCw0Ni45IDgsNTIuMSAyNC42LDYyIDI0LjYsNTYuOSAJCQoJCQkiLz4KCQk8cG9seWxpbmUgZmlsbD0iIzRBNjU4QiIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2Utd2lkdGg9IjAuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIyNC42LDU2LjkgNDEuMSw0Ni45IDQxLjEsNTIuMSAKCQkJMjQuNiw2MiAJCSIvPgoJPC9nPgoJPGcgaWQ9IlJlZmxlY3Rpb25zIj4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSIxNy44LDI3LjcgMTguNCwyMi45IDEwLjksMTguNCAxMC45LDE4LjQgMTAuMywyMy4yIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQTJDMkRDIiBwb2ludHM9IjE2LjksMzQuOCAxNy41LDMwIDE3LjUsMzAgMTAsMjUuNSAxMCwyNS41IDkuNCwzMC4zIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQTJDMkRDIiBwb2ludHM9IjE2LDQxLjggMTYuNiwzNyAxNi42LDM3IDkuMSwzMi41IDkuMSwzMi41IDguNSwzNy4zIAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQTJDMkRDIiBwb2ludHM9IjE1LjIsNDguOCAxNS44LDQ0IDE1LjgsNDQgOC4zLDM5LjUgOC4zLDM5LjUgOCw0MS40IDgsNDQuNSAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSI4LDUyLjEgMTQuMyw1NS44IDE0LjMsNTUuOCAxNC45LDUxIDE0LjksNTEgOCw0Ni45IAkJCSIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSIyMy41LDI2IDIwLDIzLjkgMTkuNCwyOC43IDIyLjksMzAuOCAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSIyMi42LDMzIDE5LjEsMzAuOSAxOC41LDM1LjcgMjIsMzcuOCAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSIyMS43LDQwIDE4LjIsMzcuOSAxNy42LDQyLjcgMjEsNDQuOCAJCQkiLz4KCQkJPHBvbHlnb24gZmlsbD0iI0EyQzJEQyIgcG9pbnRzPSIyMC44LDQ3IDE3LjMsNDQuOSAxNi43LDQ5LjcgMjAuMSw1MS44IAkJCSIvPgoJCQk8cG9seWdvbiBmaWxsPSIjQTJDMkRDIiBwb2ludHM9IjE5LjksNTQgMTYuNCw1MiAxNS44LDU2LjcgMTkuMiw1OC44IAkJCSIvPgoJCTwvZz4KCTwvZz4KPC9nPgo8ZyBpZD0iVHJlZXMiPgoJPGcgaWQ9IlRyZWUiPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjNkM2MDU0IiBkPSJNMjMuNiw3NC41Yy0wLjUsMC0wLjktMC40LTAuOS0wLjl2LTEuOWwwLjIsMC4xYzAuNiwwLjIsMSwwLjIsMSwwLjJzMC40LDAsMS0wLjJsMC4yLTAuMXYxLjkKCQkJCWMwLDAuNS0wLjQsMC45LTAuOSwwLjlIMjMuNnoiLz4KCQkJPHBhdGggZmlsbD0iIzAxMDIwMiIgZD0iTTI0LjksNzEuOXYxLjhjMCwwLjQtMC40LDAuOC0wLjgsMC44aC0wLjVjLTAuNCwwLTAuOC0wLjQtMC44LTAuOHYtMS44YzAuNiwwLjIsMS4xLDAuMywxLjEsMC4zCgkJCQlTMjQuMyw3Mi4xLDI0LjksNzEuOSBNMjIuNSw3MS41djAuNHYxLjhjMCwwLjYsMC41LDEsMSwxSDI0YzAuNiwwLDEtMC41LDEtMXYtMS44di0wLjRsLTAuMywwLjFjLTAuNSwwLjItMSwwLjItMSwwLjIKCQkJCXMtMC40LDAtMS0wLjJMMjIuNSw3MS41TDIyLjUsNzEuNXoiLz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iI0EzOTk4RSIgZD0iTTI0LjMsNzJjLTAuMywwLjEtMC41LDAuMS0wLjUsMC4xcy0wLjQsMC0wLjgtMC4yYy0wLjEsMC0wLjEsMC0wLjIsMHYxLjdjMCwwLjQsMC40LDAuOCwwLjgsMC44SDI0CgkJCWMwLjItMC4yLDAuNC0wLjQsMC40LTAuN1Y3MkgyNC4zeiIvPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjM0QzI5IiBkPSJNMjMuOCw3Mi4yYzAsMC0zLjktMC4yLTMuOS01LjRjMC00LjgsMi40LTguNywzLjktOC43czMuOSwzLjksMy45LDguN0MyNy44LDcyLjEsMjMuOSw3Mi4yLDIzLjgsNzIuMgoJCQkJTDIzLjgsNzIuMkwyMy44LDcyLjJ6Ii8+CgkJCTxwYXRoIGZpbGw9IiMwMTAyMDIiIGQ9Ik0yMy44LDU4LjJjMS40LDAsMy44LDMuOCwzLjgsOC42YzAsNS4yLTMuOCw1LjMtMy44LDUuM1MyMCw3MiwyMCw2Ni44QzIwLDYyLDIyLjQsNTguMiwyMy44LDU4LjIKCQkJCSBNMjMuOCw1Ny45Yy0xLjYsMC00LjEsNC00LjEsOC45YzAsNS40LDQsNS42LDQsNS42YzAuMSwwLDQuMS0wLjIsNC4xLTUuNkMyNy45LDYxLjksMjUuNCw1Ny45LDIzLjgsNTcuOUwyMy44LDU3Ljl6Ii8+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiMzRTdEM0UiIGQ9Ik0yMCw2Ni44YzAsNC44LDMuMiw1LjMsMy43LDUuM2MxLjItMC41LDIuOS0xLjksMi45LTUuNGMwLTMuOC0xLjQtNy0yLjctOC41YzAsMCwwLDAtMC4xLDAKCQkJQzIyLjQsNTguMiwyMCw2MiwyMCw2Ni44eiIvPgoJCTxwYXRoIGZpbGw9IiM0QzkzNEUiIGQ9Ik0yMy4zLDcyYzEtMS4yLDEuOS0zLjEsMS45LTYuMmMwLTIuOS0wLjUtNS41LTEuMy03LjZDMjIuNCw1OC4zLDIwLDYyLDIwLDY2LjhDMjAsNzAuNywyMi4yLDcxLjcsMjMuMyw3MgoJCQl6Ii8+Cgk8L2c+Cgk8ZyBpZD0iVHJlZV8yXyI+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiM2QzYwNTQiIGQ9Ik02LDY0LjFjLTAuNSwwLTAuOS0wLjQtMC45LTAuOXYtMS45bDAuMiwwLjFjMC42LDAuMiwxLDAuMiwxLDAuMnMwLjQsMCwxLTAuMmwwLjItMC4xdjEuOQoJCQkJYzAsMC41LTAuNCwwLjktMC45LDAuOUg2eiIvPgoJCQk8cGF0aCBmaWxsPSIjMDEwMjAyIiBkPSJNNy4zLDYxLjR2MS44YzAsMC40LTAuNCwwLjgtMC44LDAuOEg2Yy0wLjQsMC0wLjgtMC40LTAuOC0wLjh2LTEuOGMwLjYsMC4yLDEuMSwwLjMsMS4xLDAuMwoJCQkJUzYuNyw2MS42LDcuMyw2MS40IE00LjksNjF2MC40djEuOGMwLDAuNiwwLjUsMSwxLDFoMC41YzAuNiwwLDEtMC41LDEtMXYtMS44VjYxbC0wLjMsMC4xYy0wLjUsMC4yLTEsMC4yLTEsMC4ycy0wLjQsMC0xLTAuMgoJCQkJTDQuOSw2MUw0LjksNjF6Ii8+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiNBMzk5OEUiIGQ9Ik02LjcsNjEuNmMtMC4zLDAuMS0wLjUsMC4xLTAuNSwwLjFzLTAuNCwwLTAuOC0wLjJjLTAuMSwwLTAuMSwwLTAuMiwwdjEuN0M1LjIsNjMuNiw1LjUsNjQsNiw2NGgwLjQKCQkJYzAuMi0wLjIsMC40LTAuNCwwLjQtMC43di0xLjdINi43eiIvPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjM0QzI5IiBkPSJNNi4yLDYxLjhjMCwwLTMuOS0wLjItMy45LTUuNGMwLTQuOCwyLjQtOC43LDMuOS04LjdzMy45LDMuOSwzLjksOC43QzEwLjIsNjEuNiw2LjMsNjEuOCw2LjIsNjEuOAoJCQkJTDYuMiw2MS44TDYuMiw2MS44eiIvPgoJCQk8cGF0aCBmaWxsPSIjMDEwMjAyIiBkPSJNNi4yLDQ3LjdjMS40LDAsMy44LDMuOCwzLjgsOC42YzAsNS4yLTMuOCw1LjMtMy44LDUuM3MtMy44LTAuMS0zLjgtNS4zQzIuNCw1MS41LDQuOCw0Ny43LDYuMiw0Ny43CgkJCQkgTTYuMiw0Ny41Yy0xLjYsMC00LjEsNC00LjEsOC45YzAsNS40LDQsNS42LDQsNS42YzAuMSwwLDQuMS0wLjIsNC4xLTUuNkMxMC4zLDUxLjUsNy44LDQ3LjUsNi4yLDQ3LjVMNi4yLDQ3LjV6Ii8+CgkJPC9nPgoJCTxwYXRoIGZpbGw9IiMzRTdEM0UiIGQ9Ik0yLjQsNTYuM2MwLDQuOCwzLjIsNS4zLDMuNyw1LjNDNy40LDYxLjEsOSw1OS44LDksNTYuMmMwLTMuOC0xLjQtNy0yLjctOC41YzAsMCwwLDAtMC4xLDAKCQkJQzQuOCw0Ny43LDIuNCw1MS41LDIuNCw1Ni4zeiIvPgoJCTxwYXRoIGZpbGw9IiM0QzkzNEUiIGQ9Ik01LjcsNjEuNmMxLTEuMiwxLjktMy4xLDEuOS02LjJjMC0yLjktMC41LTUuNS0xLjMtNy42Yy0xLjQsMC4xLTMuOCwzLjgtMy44LDguNgoJCQlDMi40LDYwLjMsNC42LDYxLjMsNS43LDYxLjZ6Ii8+Cgk8L2c+CjwvZz4KPGcgaWQ9IkRvb3J3YXkiPgoJPHBvbHlnb24gaWQ9Ik91dGxpbmVfM18iIGRpc3BsYXk9Im5vbmUiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iCgkJMTcuNyw2MS4yIDE3LjcsNTkuNiAxMi40LDU2LjQgOS4xLDU5LjUgOS4xLDYwIDE0LjUsNjMuMiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNEE2NThCIiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS13aWR0aD0iMC4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjEyLjQsNTggMTIuNCw2NC40IDE3LjcsNjcuNiAKCQkxNy43LDYxLjIgCSIvPgoJPHBvbHlnb24gZGlzcGxheT0ibm9uZSIgZmlsbD0iIzZDNkM2QyIgcG9pbnRzPSIxNy43LDU5LjYgMTcuNyw2MS4yIDE0LjUsNjMuMiAxNC41LDYyLjcgCSIvPgoJPHBvbHlnb24gZGlzcGxheT0ibm9uZSIgZmlsbD0iIzlFOUU5RSIgcG9pbnRzPSI5LjEsNTkuNSA5LjEsNjAgMTQuNSw2My4yIDE0LjUsNjIuNyAJIi8+Cgk8cG9seWdvbiBpZD0iX3gzQ19QYXRoX3gzRV8iIGRpc3BsYXk9Im5vbmUiIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iMTQuNSw2Mi43IDkuMSw1OS41IDEyLjQsNTYuNCAxNy43LDU5LjYgCSIvPgo8L2c+CjxnIGlkPSJSb29mdG9wIj4KCTxwb2x5Z29uIGZpbGw9IiNFQUVBRUEiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMjQuNiwyMiAxMi41LDE0LjcgMjQuNSw3LjUgCgkJMzYuNywxNC43IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM5NTk1OTYiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMTIuNSwxNC43IDEyLjUsMTQuNyAxNCwxNS42IAoJCTI0LjYsOS4yIDI0LjYsNy41IDI0LjYsNy41IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNFMkUyRTEiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLXdpZHRoPSIwLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzYuNywxNC43IDM2LjcsMTQuNyAzNS4yLDE1LjYgCgkJMjQuNiw5LjIgMjQuNiw3LjUgMjQuNiw3LjUgCSIvPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "package-module", "name": "package-module", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyMDEuMTAwMDFweCIgaGVpZ2h0PSIyMDAuOHB4IiB2aWV3Qm94PSIwIDAgMjAxLjEwMDAxIDIwMC44IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDEuMTAwMDEgMjAwLjgiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8xXzFfIj4KCTxnIGlkPSJMYXllcl8zIj4KCQk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjEwMC41LDE4Ni41IDEyOC41LDE4Ni41IDIwMS4xMDAwMSwxNDQuMyAxNjkuMTAwMDEsMTI2LjcgCQkiLz4KCTwvZz4KCTxwb2x5Z29uIGZpbGw9IiNGQUQ5QTMiIHBvaW50cz0iMTAwLjUsOTcuNyAzMiw1Ni41IDEwMC4zLDE1LjQgMTY5LjEwMDAxLDU2LjUgCSIvPgoJPGcgaWQ9IldpbmRvdyI+CgkJPHBvbHlnb24gZmlsbD0iI0UyQkY4MCIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzIsNTYuNSAzMiwxNDEuODk5OTkgMTAwLjUsMTgzIDEwMC41LDk3LjcgCQkiLz4KCTwvZz4KCTxnIGlkPSJXaW5kb3dfMV8iPgoJCTxwb2x5Z29uIGZpbGw9IiNCNjhDNjkiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjE2OS4xMDAwMSw1Ni41IDE2OS4xMDAwMSwxNDEuODk5OTkgMTAwLjUsMTgzIAoJCQkxMDAuNSw5Ny43IAkJIi8+Cgk8L2c+Cgk8ZyBpZD0iV2luZG93XzNfIj4KCQk8cGF0aCBmaWxsPSIjQ0VBNzZBIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTcxLjEsMTQ0Ljh2MTEuNWMwLDEuMTAwMDEsMC42LDIuMTAwMDEsMS41LDIuNjAwMDFsMTguOCwxMS4zCgkJCWMwLjksMC42MDAwMSwyLjEtMC4xMDAwMSwyLjEtMS4ydi0xMmMwLTAuODk5OTktMC41LTEuNy0xLjItMi4ybC0xOC44LTExLjNDNzIuNCwxNDIuODk5OTksNzEuMSwxNDMuNjAwMDEsNzEuMSwxNDQuOHoiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGlkPSJPdXRsaW5lIiBmaWxsPSIjMDAwMDAwIiBkPSJNMTAwLjMsMTUuNGw2OC44LDQxLjF2ODUuMzk5OTlMMTAwLjUsMTgzTDMyLDE0MS44OTk5OVY1Ni41TDEwMC4zLDE1LjQgTTEwMC4zLDExLjlsLTEuNSwwLjlMMzAuNSw1My45CgkJCUwyOSw1NC44djEuN3Y4NS4zOTk5OXYxLjdsMS41LDAuODk5OTlMOTksMTg1LjYwMDAxbDEuNSwwLjg5OTk5bDEuNS0wLjg5OTk5bDY4LjUtNDEuMmwxLjUtMC44OTk5OXYtMS43VjU2LjV2LTEuN2wtMS41LTAuOQoJCQlsLTY4LjgtNDEuMUwxMDAuMywxMS45TDEwMC4zLDExLjl6Ii8+Cgk8L2c+Cgk8Zz4KCQk8ZyBpZD0iVGFwZSI+CgkJCTxwb2x5Z29uIGZpbGw9IiNERUI2OEIiIHBvaW50cz0iMTIzLjksMjkuMSA1NS41LDcwLjMgNTUuNSwxMDQgNzEuNSwxMTMuMiA3MS41LDc5LjkgMTQwLjEwMDAxLDM4LjcgCQkJIi8+CgkJCTxwb2x5Z29uIGZpbGw9IiNCNjhDNjkiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIwLjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI3MS41LDExMy4yIDcxLjUsNzkuOSA1NS41LDcwLjMgCgkJCQk1NS41LDEwNCAJCQkiLz4KCQk8L2c+CgkJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMTIzLjksMjkuMSA1NS41LDcwLjMgNTUuNSwxMDQgNzEuNSwxMTMuMiA3MS41LDc5LjkgCgkJCTE0MC4xMDAwMSwzOC43IAkJIi8+Cgk8L2c+CjwvZz4KPGcgaWQ9IkxheWVyXzJfMV8iPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "paymentcard", "name": "paymentcard", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI2NTkuNzAwMDFweCIgaGVpZ2h0PSI0NDEuNXB4IiB2aWV3Qm94PSIwIDAgNjU5LjcwMDAxIDQ0MS41IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA2NTkuNzAwMDEgNDQxLjUiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8xXzFfIiBkaXNwbGF5PSJub25lIj4KCTxwb2x5Z29uIGRpc3BsYXk9ImlubGluZSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQTdBOUFDIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI1NTEuNSwzNjQgMzM2LDQ4OC4zOTk5OSAKCQkxMjAuNiwzNjQgMTIwLjYsMTE1LjIgMzM2LC05LjEgNTUxLjUsMTE1LjIgCSIvPgo8L2c+CjxnIGlkPSJMYXllcl8yXzFfIj4KCTxnPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMDU0ODZEIiBkPSJNNjI3LjUsMTk0Ljh2MjcuMTAwMDFsMCwwYzAsMi0xLjIwMDAxLDMuOC0zLjU5OTk4LDUuMkwyOTEuNjAwMDEsNDE5CgkJCQljLTEuMTAwMDEsMC42MDAwMS0yLjI5OTk5LDEuMTAwMDEtMy42MDAwMSwxLjM5OTk5Yy01LjM5OTk5LDEuMzk5OTktMTIuNSwwLjYwMDAxLTE3LjYwMDAxLTIuMjk5OTlMNDkuNywyOTAuNzAwMDEKCQkJCWMtMy41LTItNS4zLTQuNzAwMDEtNS4yLTcuMTAwMDFWMjU3LjVsMCwwYzAuMSwyLjM5OTk5LDEuOCw0Ljc5OTk5LDUuMiw2Ljc5OTk5bDIyMC43LDEyNy4zOTk5OQoJCQkJYzYuMjk5OTksMy42MDAwMSwxNS43MDAwMSw0LDIxLjIwMDAxLDAuODk5OTlsMTMuMTAwMDEtNy42MDAwMWwzMTkuMjAwMDEtMTg0LjNDNjI2LjU5OTk4LDE5OS4xMDAwMSw2MjcuNzk5OTksMTk3LDYyNy41LDE5NC44CgkJCQlMNjI3LjUsMTk0Ljh6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMzFBOEY3IiBkPSJNMjcwLjM5OTk5LDM5MS43MDAwMWMzLjI5OTk5LDMuNjAwMDEsNy4zOTk5OSwyMCwwLDI2LjVsLTIyMC43LTEyNy41Yy0zLjUtMi01LjMtNC43MDAwMS01LjItNy4xMDAwMQoJCQkJVjI1Ny41bDAsMGMwLjEsMi4zOTk5OSwxLjgsNC43OTk5OSw1LjIsNi43OTk5OUwyNzAuMzk5OTksMzkxLjcwMDAxIi8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjU4MkNFIiBkPSJNNjIzLjkwMDAyLDIwMC43TDYyMC4zMDAwNSwyMDIuOEw1NTAsMjQzLjNsLTMwLDE3LjNsLTE0OS44OTk5OSw4Ni41TDI5MS41LDM5Mi41CgkJCQljLTUuMzk5OTksMy4xMDAwMS0xNC44OTk5OSwyLjcwMDAxLTIxLjEwMDAxLTAuODk5OTlMNzcuNiwyODAuMzk5OTlsLTI3LjktMTYuMTAwMDFjLTYuMy0zLjYwMDAxLTYuOS05LjEwMDAxLTEuNS0xMi4yCgkJCQlMMTcxLjIsMTgxbDMwLTE3LjNsNzAuMi00MC42bDEwOS02Mi45YzUuMzk5OTktMy4xLDE0Ljg5OTk5LTIuNywyMS4yMDAwMSwwLjlsMjIwLjY5OTk4LDEyNy40CgkJCQlDNjI4LjU5OTk4LDE5Mi4xMDAwMSw2MjkuMjk5OTksMTk3LjYwMDAxLDYyMy45MDAwMiwyMDAuN3oiLz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iIzMxQThGNyIgZD0iTTUyMCwyNjAuNzAwMDFsLTE0OS44OTk5OSw4Ni41TDc3LjYsMjgwLjM5OTk5bC0yNy45LTE2LjEwMDAxYy02LjMtMy42MDAwMS02LjktOS4xMDAwMS0xLjUtMTIuMgoJCQlMMTcxLjIsMTgxTDUyMCwyNjAuNzAwMDF6Ii8+CgkJPHBvbHlnb24gZmlsbD0iIzMxQThGNyIgcG9pbnRzPSI2MjAuMjk5OTksMjAyLjggNTUwLDI0My4zIDIwMS4zLDE2My43IDI3MS41LDEyMy4xIAkJIi8+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yODIuMzk5OTksMzk2LjcwMDAxYy00LjcwMDAxLDAtOS4zOTk5OS0xLjIwMDAxLTEzLTMuMjk5OTlMNDguNywyNjYKCQkJCWMtMy45LTIuMjk5OTktNi4yLTUuMzk5OTktNi4yLTguNjAwMDFjMC0yLjgsMS43LTUuMyw0LjctN0wzNzkuNSw1OC40YzIuNzAwMDEtMS41LDYuMjk5OTktMi40LDEwLjIwMDAxLTIuNAoJCQkJYzQuNzAwMDEsMCw5LjM5OTk5LDEuMiwxMywzLjNMNjIzLjQwMDAyLDE4Ni43YzMuOTAwMDIsMi4zLDYuMjAwMDEsNS4zOTk5OSw2LjIwMDAxLDguNjAwMDFjMCwyLjgtMS43MDAwMSw1LjMtNC43MDAwMSw3CgkJCQlMMjkyLjYwMDAxLDM5NC4yOTk5OUMyODkuODk5OTksMzk1Ljc5OTk5LDI4Ni4yOTk5OSwzOTYuNzAwMDEsMjgyLjM5OTk5LDM5Ni43MDAwMXogTTM4OS43MDAwMSw2MC4xCgkJCQljLTMuMjAwMDEsMC02LjEwMDAxLDAuNy04LjIwMDAxLDEuOEw0OS4yLDI1My44Yy0xLjIsMC43LTIuNywxLjg5OTk5LTIuNywzLjU5OTk5YzAsMS43MDAwMSwxLjYsMy43MDAwMSw0LjIsNS4yMDAwMQoJCQkJTDI3MS4zOTk5OSwzOTBjMywxLjcwMDAxLDcsMi43MDAwMSwxMSwyLjcwMDAxYzMuMjAwMDEsMCw2LjEwMDAxLTAuNzAwMDEsOC4yMDAwMS0xLjc5OTk5TDYyMi45MDAwMiwxOTkKCQkJCWMxLjIwMDAxLTAuNywyLjcwMDAxLTEuODk5OTksMi43MDAwMS0zLjYwMDAxYzAtMS43LTEuNTk5OTgtMy43LTQuMjAwMDEtNS4yTDQwMC42MDAwMSw2Mi44CgkJCQlDMzk3LjcwMDAxLDYxLjEsMzkzLjcwMDAxLDYwLjEsMzg5LjcwMDAxLDYwLjF6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cG9seWdvbiBmaWxsPSIjRkY5NjAwIiBwb2ludHM9IjMwOC43MDAwMSwyNDguODk5OTkgMjA2LDMwOC4xMDAwMSAxMzUuMTAwMDEsMjY3LjIwMDAxIDExNy4xLDI1Ni43OTk5OSAyMTkuOCwxOTcuNjAwMDEgCgkJCQkyNjguNzAwMDEsMjI1LjggCQkJIi8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRkZFNDJBIiBkPSJNMzgzLjM5OTk5LDExOS4zYy0xOCwxMC4zOTk5OS0xOS44OTk5OSwyNi4xMDAwMS00LjM5OTk5LDM1YzE1LjYwMDAxLDksNDIuNzAwMDEsNy44OTk5OSw2MC43MDAwMS0yLjUKCQkJCXMxOS44OTk5OS0yNi4xLDQuMzk5OTktMzVDNDI4LjUsMTA3LjgsNDAxLjI5OTk5LDEwOC45LDM4My4zOTk5OSwxMTkuM3oiLz4KCQk8L2c+CgkJPGc+CgkJCTxwb2x5Z29uIGZpbGw9IiMwMDU3OUUiIHBvaW50cz0iNDY4LjEwMDAxLDI1Ni4yOTk5OSAyNzEuMTAwMDEsMzcwIDI1My44LDM2MCA0NTAuNzk5OTksMjQ2LjMgCQkJIi8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRkZDNjAwIiBkPSJNMjY4LjcwMDAxLDIyNS44Yy0yNi4yLDI3LjM5OTk5LTYzLjEwMDAxLDQ0LjQwMDAxLTEwNCw0NC40MDAwMWMtMTAuMTAwMDEsMC0yMC0xLTI5LjUtM2wtMTgtMTAuMzk5OTkKCQkJCWwxMDIuNjAwMDEtNTkuM0wyNjguNzAwMDEsMjI1Ljh6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjRkZFNDJBIiBkPSJNMTgyLjYwMDAxLDIzNi4zOTk5OWMtMTgsMTAuMzk5OTktMTkuODk5OTksMjYuMTAwMDEtNC4zOTk5OSwzNXM0Mi43LDcuODk5OTksNjAuNy0yLjUKCQkJCXMxOS45MDAwMS0yNi4xMDAwMSw0LjM5OTk5LTM1QzIyNy43LDIyNC44OTk5OSwyMDAuNjAwMDEsMjI2LDE4Mi42MDAwMSwyMzYuMzk5OTl6Ii8+CgkJPC9nPgoJCTxnPgoJCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNMzg5LjcwMDAxLDU4LjFjNC4yMDAwMSwwLDguNSwxLDEyLDNsMjIwLjcwMDAxLDEyNy40YzMuMjAwMDEsMS44LDQuOTAwMDIsNC4xMDAwMSw1LjA5OTk4LDYuM2wwLDAKCQkJCXYyNy4xMDAwMWwwLDBjMCwyLTEuMjAwMDEsMy44LTMuNTk5OTgsNS4yTDI5MS42MDAwMSw0MTljLTEuMTAwMDEsMC42MDAwMS0yLjI5OTk5LDEuMTAwMDEtMy42MDAwMSwxLjM5OTk5CgkJCQljLTEuNzAwMDEsMC41LTMuNjAwMDEsMC43MDAwMS01LjYwMDAxLDAuNzAwMDFjLTQuMjAwMDEsMC04LjUtMS0xMi0zTDQ5LjcsMjkwLjcwMDAxYy0zLjUtMi01LjMtNC43MDAwMS01LjItNy4xMDAwMVYyNTcuNWwwLDAKCQkJCWwwLDBjLTAuMS0yLDEuMS00LDMuNi01LjVMMzgwLjUsNjAuMkMzODIuODk5OTksNTguOCwzODYuMjAwMDEsNTguMSwzODkuNzAwMDEsNTguMSBNMzg5LjcwMDAxLDQ4LjFMMzg5LjcwMDAxLDQ4LjEKCQkJCWMtNS4zOTk5OSwwLTEwLjI5OTk5LDEuMi0xNC4yMDAwMSwzLjVMNDMuMiwyNDMuMzk5OTljLTUuMiwzLTguNCw4LTguNiwxMy4zOTk5OWMwLDAuMjAwMDEsMCwwLjUsMCwwLjc5OTk5djI2CgkJCQljLTAuMiw2LjI5OTk5LDMuNSwxMi4xMDAwMSwxMC4yLDE1Ljg5OTk5bDIyMC43LDEyNy4zOTk5OWM0Ljc5OTk5LDIuNzk5OTksMTAuODk5OTksNC4yOTk5OSwxNyw0LjI5OTk5CgkJCQljMi43OTk5OSwwLDUuNjAwMDEtMC4yOTk5OSw4LjEwMDAxLTFjMi4yMDAwMS0wLjYwMDAxLDQuMjAwMDEtMS4zOTk5OSw2LTIuMzk5OTlsMCwwbDMzMi4zMDAwMi0xOTEuODk5OTkKCQkJCWM1LjA5OTk4LTMsOC4yMDAwMS03LjYwMDAxLDguNTk5OTgtMTIuOGMwLTAuMzk5OTksMC4wOTk5OC0wLjcsMC4wOTk5OC0xLjEwMDAxdi0yNy4xMDAwMQoJCQkJYzAtMC44OTk5OS0wLjA5OTk4LTEuOC0wLjI5OTk5LTIuNjAwMDFjLTEuMDk5OTgtNC44OTk5OS00LjUtOS4zLTkuNzk5OTktMTIuMzk5OTlMNDA2LjYwMDAxLDUyLjQKCQkJCUM0MDEuNzk5OTksNDkuNiwzOTUuNzk5OTksNDguMSwzODkuNzAwMDEsNDguMUwzODkuNzAwMDEsNDguMXoiLz4KCQk8L2c+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiNGRkU0MkEiIGQ9Ik02MjUuNSwxOTkuNWwwLjA5OTk4LTAuMTAwMDFDNjI1LjU5OTk4LDE5OS41LDYyNS41LDE5OS41LDYyNS41LDE5OS41eiIvPgoJCTwvZz4KCQk8Zz4KCQkJPHBhdGggZmlsbD0iIzAwNTc5RSIgZD0iTTI5OC44OTk5OSwzMDEuMTAwMDFjNC4zOTk5OSwyLjUsNC43OTk5OSw2LjI5OTk5LDEuMTAwMDEsOC41bC01MywzMC42MDAwMQoJCQkJYy0zLjgsMi4yMDAwMS0xMC4zOTk5OSwxLjg5OTk5LTE0LjctMC42MDAwMWwtOS4zLTUuMjk5OTljLTQuMzk5OTktMi41LTQuOC02LjI5OTk5LTEuMTAwMDEtOC41bDUzLTMwLjYwMDAxCgkJCQljMy43OTk5OS0yLjIwMDAxLDEwLjM5OTk5LTEuODk5OTksMTQuNzAwMDEsMC42MDAwMUwyOTguODk5OTksMzAxLjEwMDAxeiIvPgoJCQk8cGF0aCBmaWxsPSIjMDA1NzlFIiBkPSJNMzc3Ljg5OTk5LDI1NS41YzQuMzk5OTksMi41LDQuNzk5OTksNi4yOTk5OSwxLjEwMDAxLDguNWwtNTMsMzAuNjAwMDEKCQkJCUMzMjIuMjAwMDEsMjk2LjgwMDAyLDMxNS42MDAwMSwyOTYuNSwzMTEuMjk5OTksMjk0TDMwMiwyODguNjAwMDFjLTQuMzk5OTktMi41LTQuNzk5OTktNi4yOTk5OS0xLjEwMDAxLTguNWw1My0zMC42MDAwMQoJCQkJYzMuNzk5OTktMi4yLDEwLjM5OTk5LTEuODk5OTksMTQuNzAwMDEsMC42MDAwMUwzNzcuODk5OTksMjU1LjV6Ii8+CgkJCTxwYXRoIGZpbGw9IiMwMDU3OUUiIGQ9Ik00NTYuODk5OTksMjA5Ljg5OTk5YzQuMzk5OTksMi41LDQuNzk5OTksNi4zLDEuMTAwMDEsOC41TDQwNSwyNDkKCQkJCWMtMy43OTk5OSwyLjItMTAuMzk5OTksMS44OTk5OS0xNC43MDAwMS0wLjYwMDAxTDM4MSwyNDNjLTQuMzk5OTktMi41LTQuNzk5OTktNi4zLTEuMTAwMDEtOC41bDUzLTMwLjYwMDAxCgkJCQljMy43OTk5OS0yLjIsMTAuMzk5OTktMS44OTk5OSwxNC43MDAwMSwwLjYwMDAxTDQ1Ni44OTk5OSwyMDkuODk5OTl6Ii8+CgkJCTxwYXRoIGZpbGw9IiMwMDU3OUUiIGQ9Ik01MzUuOTAwMDIsMTY0LjNjNC40MDAwMiwyLjUsNC43OTk5OSw2LjMsMS4wOTk5OCw4LjVsLTUzLDMwLjYwMDAxCgkJCQljLTMuNzk5OTksMi4yLTEwLjM5OTk5LDEuODk5OTktMTQuNzAwMDEtMC42MDAwMUw0NjAsMTk3LjQwMDAxYy00LjM5OTk5LTIuNS00Ljc5OTk5LTYuMy0xLjEwMDAxLTguNWw1My0zMC42MDAwMQoJCQkJYzMuODAwMDItMi4yLDEwLjM5OTk5LTEuODk5OTksMTQuNjk5OTgsMC42MDAwMUw1MzUuOTAwMDIsMTY0LjN6Ii8+CgkJPC9nPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "plane", "name": "plane", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMjQ4LjQ0IDI1NC43MyI+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwcGF0aCI+PHBhdGggaWQ9IkZ1c2VsYWdlLTIiIGQ9Ik0xMTQuMjQsNzQuNzdjLTcuNTQtMi45My00MC4wOS0xMy41NS01MS45NS0xNi45NHMtOS42NCwxLjUzLTQuNTIsNy4zNGMwLDAsMTAuNTIsMTQuNzEsMTguMjUsMjMuNDcsNy43Myw4Ljc2LDE4LjMyLDIwLjcyLDI0LjY3LDI2LjIyczk3Ljg3LDU3LjkyLDk3Ljg3LDU3LjkyYzAsMCwzNy44NCwxMi40Miw0NS43NCw2Ljc4LDcuOTEtNS42NS0yNy43Ni00My41NS0yNy43Ni00My41NWwtMTAyLjI5LTYxLjI1WiIgZmlsbD0ibm9uZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwcGF0aC0xIj48ZWxsaXBzZSBpZD0iSW50YWtlLTIiIGN4PSIyMDUuMzIiIGN5PSIxMTMuNzQiIHJ4PSIxMi4yNiIgcnk9IjguMTciIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMxLjc1IDI3MS43NCkgcm90YXRlKC03MS4zKSIgZmlsbD0ibm9uZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwcGF0aC0yIj48ZWxsaXBzZSBpZD0iSW50YWtlLTYiIGN4PSIxNDAuNCIgY3k9IjE2My4yMSIgcng9IjEyLjI2IiByeT0iOC4xNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU5LjIxIDI0My44NSkgcm90YXRlKC03MS4zKSIgZmlsbD0ibm9uZSIvPjwvY2xpcFBhdGg+PGNsaXBQYXRoIGlkPSJjbGlwcGF0aC0zIj48cGF0aCBpZD0iUmlnaHRXaW5nLTMiIGQ9Ik0xMjMuMTQsMTIxLjkzTDI1LjUsMTU1LjUyczE0Ljk1LDQuNzUsMTguMyw0LjQxLDEyNy4yOC0xMC42MSwxMjcuMjgtMTAuNjFsLTQ3LjkzLTI3LjM5WiIgZmlsbD0ibm9uZSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxwYXRoIGlkPSJTaGFkb3ciIGQ9Ik02NC42NiwxMTkuNjFsMTMuMDctMTIuMjZzNC41OSwxLjU3LDMuOTcsNC41OWwtNi4yMywyNS41NywxLDEuNjZjMTEuNjUsMy43NywyMy4zMSw3LjcsMjcuNDUsOS4zbDQyLjUyLDI1LjQ2LDU2LjMtNTcuNTZzNC41Nyw0LjMsNC41Nyw4LjMyLTIzLjEzLDY0LjMtMjUuNDcsNzAuNDNsMjQuMzksMTQuNnMzNS42NywzNy45LDI3Ljc2LDQzLjU1Yy03LjkxLDUuNjUtNDUuNzQtNi43OC00NS43NC02Ljc4LDAsMC0xOC4yNS0xMC40Ni0zOS4wMi0yMi41Mi0zMC44NCwyLjU2LTExMy4wMiw5LjM3LTExNS43NCw5LjY1LTMuMzUsLjM1LTE4LjMtNC40MS0xOC4zLTQuNDFsOTAuMjMtMzEuMDRjLTcuOTUtNC44MS0xMy42My04LjM4LTE1LjA1LTkuNjEtNi4zNS01LjUxLTE2Ljk0LTE3LjQ2LTI0LjY3LTI2LjIyLTMuNjMtNC4xMS03Ljg3LTkuNTMtMTEuMzUtMTQuMTMtMTcuMjYsMi4xNC00NC42Miw1LjU0LTQ1LjcsNS42OC0xLjU4LC4yLTguNjUtMi41LTguNjUtMi41bDQzLjYtMTguMDMsMjEuMDctMTMuNzRaIiBmaWxsPSIjMDEwMTAxIiBpc29sYXRpb249Imlzb2xhdGUiIG9wYWNpdHk9Ii40Ii8+PGcgaWQ9Ik91dGxpbmVMb3dlciI+PHBhdGggZD0iTTEyMC45NiwxNDQuMTRjNS4xNiwwLDEwLjg4LDEuOTQsMTIuOTQsMi41LDMuOTQsMS4wOCwxMSw1LjE1LDExLDUuMTUtLjA3LS4wMi0uMTMtLjA0LS4yLS4wNS0uMTItLjA1LS4yNS0uMS0uMzgtLjE0LC4xLC4wMywuMTksLjA4LC4yOCwuMTIsLjAzLDAsLjA2LC4wMiwuMSwuMDIsLjkxLC4zOCwxLjY5LDEsMi4zMywxLjc5LDEuMjcsMS41OCwxLjk5LDMuODYsMi4wNSw2LjQ1LC4wNCwxLjg1LS4yNiwzLjg1LS45NCw1Ljg1LTEuODcsNS41My02LjAyLDkuMjktOS44Nyw5LjI5LS42MSwwLTEuMjItLjEtMS44MS0uMjloMGMtMy41OS0xLjI1LTIyLjkzLTguNTEtMjQuOS0yMC4yMy0yLjI0LTIuMjEtMy4yLTQuODQtMi4xOS02Ljc3LC43OC0xLjUsMi41OS0yLjI3LDQuODEtMi4yNywuMiwwLC40MSwwLC42MiwuMDIsMS43LTEuMDUsMy44Ny0xLjQzLDYuMTYtMS40M20wLTNoMGMtMi43LDAtNS4wMSwuNDctNi44OSwxLjQxLTMuNDEsLjAzLTYuMDksMS40NC03LjM2LDMuODgtMS41LDIuODctLjcsNi40OSwyLjA4LDkuNiwxLjI4LDUuNDgsNS42NiwxMC41NywxMy4wMiwxNS4xNSw1Ljc5LDMuNiwxMS44Myw1Ljg0LDEzLjYxLDYuNDYsLjAyLDAsLjA1LC4wMiwuMDcsLjAzLC44OSwuMywxLjgyLC40NSwyLjc3LC40NSw1LjIyLDAsMTAuNDUtNC42NiwxMi43MS0xMS4zMiwuNzctMi4yNiwxLjE0LTQuNjQsMS4xLTYuODgtLjA3LTMuMjUtMS4wMy02LjE5LTIuNzEtOC4yNy0uNzgtLjk3LTEuNjktMS43NC0yLjcyLTIuMy0uMDgtLjA2LS4xNi0uMTEtLjI0LS4xNi0uNzYtLjQ0LTcuNTItNC4zLTExLjcxLTUuNDQtLjI4LS4wOC0uNjQtLjE4LTEuMDUtLjMtMi43NC0uODEtNy44My0yLjMtMTIuNjgtMi4zaDBaIiBmaWxsPSIjMWQxZDFiIi8+PHBhdGggZD0iTTE4Ni4yNCw5NC41MmM1LjE2LDAsMTAuODgsMS45NCwxMi45NCwyLjUsMy45NCwxLjA4LDExLDUuMTUsMTEsNS4xNS0uMDctLjAyLS4xMy0uMDQtLjItLjA1LS4xMi0uMDUtLjI1LS4xLS4zOC0uMTQsLjEsLjAzLC4xOSwuMDgsLjI4LC4xMiwuMDMsMCwuMDYsLjAyLC4xLC4wMiwuOTEsLjM4LDEuNjksMSwyLjMzLDEuNzksMS4yNywxLjU4LDEuOTksMy44NiwyLjA1LDYuNDUsLjA0LDEuODUtLjI2LDMuODUtLjk0LDUuODUtMS44Nyw1LjUzLTYuMDIsOS4yOS05Ljg3LDkuMjktLjYxLDAtMS4yMi0uMS0xLjgxLS4yOWgwYy0zLjU5LTEuMjUtMjIuOTMtOC41MS0yNC45LTIwLjIzLTIuMjQtMi4yMS0zLjItNC44NC0yLjE5LTYuNzcsLjc4LTEuNSwyLjU5LTIuMjcsNC44MS0yLjI3LC4yLDAsLjQxLDAsLjYyLC4wMiwxLjctMS4wNSwzLjg3LTEuNDMsNi4xNi0xLjQzbTAtM2gwYy0yLjcsMC01LjAxLC40Ny02Ljg5LDEuNDEtMy40MSwuMDMtNi4wOSwxLjQ0LTcuMzYsMy44OC0xLjUsMi44Ny0uNyw2LjQ5LDIuMDgsOS42LDEuMjgsNS40OCw1LjY2LDEwLjU3LDEzLjAyLDE1LjE1LDUuNzksMy42LDExLjgzLDUuODQsMTMuNjEsNi40NiwuMDIsMCwuMDUsLjAyLC4wNywuMDMsLjg5LC4zLDEuODIsLjQ1LDIuNzcsLjQ1LDUuMjIsMCwxMC40NS00LjY2LDEyLjcxLTExLjMyLC43Ny0yLjI2LDEuMTQtNC42NCwxLjEtNi44OC0uMDctMy4yNS0xLjAzLTYuMTktMi43MS04LjI3LS43OC0uOTctMS42OS0xLjc0LTIuNzItMi4zLS4wOC0uMDYtLjE2LS4xMS0uMjQtLjE2LS43Ni0uNDQtNy41Mi00LjMtMTEuNzEtNS40NC0uMjgtLjA4LS42NC0uMTgtMS4wNS0uMy0yLjc0LS44MS03LjgzLTIuMy0xMi42OC0yLjNoMFoiIGZpbGw9IiMxZDFkMWIiLz48L2c+PGcgaWQ9IlBsYW5lIj48ZyBpZD0iRnVzZWxhZ2UiPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwcGF0aCkiPjxwYXRoIGlkPSJNaWR0b25lIiBkPSJNMTE0LjI0LDc0Ljc3Yy03LjU0LTIuOTMtNDAuMDktMTMuNTUtNTEuOTUtMTYuOTRzLTkuNjQsMS41My00LjUyLDcuMzRjMCwwLDEwLjUyLDE0LjcxLDE4LjI1LDIzLjQ3LDcuNzMsOC43NiwxOC4zMiwyMC43MiwyNC42NywyNi4yMnM5Ny44Nyw1Ny45Miw5Ny44Nyw1Ny45MmMwLDAsMzcuODQsMTIuNDIsNDUuNzQsNi43OCw3LjkxLTUuNjUtMjcuNzYtNDMuNTUtMjcuNzYtNDMuNTVsLTEwMi4yOS02MS4yNVoiIGZpbGw9IiNlZmVmZWYiLz48cGF0aCBpZD0iU2hhZG93LTIiIGQ9Ik0xOTguNzEsMTcyLjg0Yy4wNSwuMDIsLjEzLC4wNCwuMjIsLjA3bC4xLC4wM2MuMTEsLjA0LC4yNSwuMDgsLjQxLC4xM2wuMTMsLjA0Yy4xNiwuMDUsLjM1LC4xMSwuNTUsLjE3bC4yNiwuMDhjLjE2LC4wNSwuMzIsLjEsLjUsLjE1LC4xMSwuMDMsLjIyLC4wNywuMzQsLjEsLjIxLC4wNiwuNDMsLjEzLC42NiwuMiwuMTUsLjA0LC4zLC4wOSwuNDUsLjE0LC4yNSwuMDgsLjUyLC4xNiwuNzksLjI0LC4xNSwuMDUsLjMyLC4wOSwuNDgsLjE0LC4yMiwuMDcsLjQ1LC4xMywuNjgsLjIsLjE4LC4wNSwuMzUsLjEsLjU0LC4xNiwuMjYsLjA4LC41NCwuMTYsLjgxLC4yNCwuMjYsLjA4LC41MywuMTUsLjgsLjIzLC4zLC4wOSwuNiwuMTcsLjkxLC4yNiwuMjIsLjA2LC40NiwuMTMsLjY5LC4xOSwuMjUsLjA3LC41LC4xNCwuNzUsLjIxLC4yNSwuMDcsLjQ5LC4xNCwuNzQsLjIsLjI4LC4wOCwuNTYsLjE1LC44NSwuMjMsLjM2LC4xLC43MywuMiwxLjEsLjI5LC4zMywuMDksLjY2LC4xOCwxLC4yNiwuMjcsLjA3LC41NCwuMTQsLjgxLC4yMSwuMjgsLjA3LC41NSwuMTQsLjgzLC4yMSwuMjgsLjA3LC41NSwuMTQsLjgzLC4yMSwuMzEsLjA4LC42MiwuMTUsLjk0LC4yMywuMzksLjA5LC43OCwuMTksMS4xNywuMjgsLjM3LC4wOSwuNzQsLjE4LDEuMTIsLjI2LC4yOCwuMDYsLjU2LC4xMywuODMsLjE5LC4zLC4wNywuNjEsLjE0LC45MiwuMiwuMjksLjA2LC41NywuMTMsLjg2LC4xOSwuMzMsLjA3LC42NiwuMTQsLjk5LC4yMSwuMzcsLjA4LC43NCwuMTUsMS4xMSwuMjMsLjQxLC4wOCwuODIsLjE2LDEuMjMsLjI0LC4yNywuMDUsLjU0LC4xLC44MSwuMTUsLjMxLC4wNiwuNjMsLjEyLC45NCwuMTcsLjI3LC4wNSwuNTUsLjEsLjgyLC4xNCwuMzQsLjA2LC42OSwuMTEsMS4wMywuMTcsLjMsLjA1LC42LC4xLC45LC4xNCwuNDUsLjA3LC44OSwuMTMsMS4zMywuMTksLjI0LC4wMywuNDgsLjA2LC43MiwuMDksLjMyLC4wNCwuNjMsLjA4LC45NSwuMTIsLjI0LC4wMywuNDgsLjA1LC43MiwuMDgsLjM0LC4wNCwuNjgsLjA3LDEuMDIsLjEsLjE5LC4wMiwuMzgsLjA0LC41NiwuMDVMNzkuNiw5Mi43YzcuMTgsOC4wOSwxNS42NywxNy40NywyMS4wOSwyMi4xNyw0LjAzLDMuNDksNDIuMywyNS44NSw2OS43Nyw0MS43NCwxNi43Nyw5LjY3LDI4LjEsMTYuMTksMjguMSwxNi4xOWwuMDYsLjAyLC4wOSwuMDNaIiBmaWxsPSIjYzdjNmM2Ii8+PHBhdGggaWQ9IkhpZ2hsaWdodCIgZD0iTTExNC4yNCw3NC43N2MtNi4yMy0yLjQyLTI5LjU1LTEwLjEtNDQuMTUtMTQuNmwxNjAuMSw5MS41MWMtNi45NS04LjU0LTEzLjY2LTE1LjY3LTEzLjY2LTE1LjY3bC0xMDIuMjktNjEuMjVaIiBmaWxsPSIjZmFmYWZhIi8+PGcgaWQ9IldpbmRvd3MiPjxwYXRoIGQ9Ik0xMjcuODgsMTA3LjcybC0uMDcsNy4wM2MwLC44MS0uOSwxLjMtMS41OSwuODhsLTQuODctMi45OGMtLjMxLS4xOS0uNS0uNTMtLjUtLjlsLjA0LTcuNTJjMC0uOCwuODctMS4zLDEuNTYtLjlsNC4zNiwyLjUyYy42NywuMzksMS4wNywxLjEsMS4wNywxLjg3WiIgZmlsbD0iIzk1OTY5NyIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTM5LjE1LDExNC40OGwtLjA3LDcuMDNjMCwuODEtLjksMS4zLTEuNTksLjg4bC00Ljg3LTIuOThjLS4zMS0uMTktLjUtLjUzLS41LS45bC4wNC03LjUyYzAtLjgsLjg3LTEuMywxLjU2LS45bDQuMzYsMi41MmMuNjcsLjM5LDEuMDcsMS4xLDEuMDcsMS44N1oiIGZpbGw9IiM5NTk2OTciIHN0cm9rZT0iIzFkMWQxYiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE1MC42OSwxMjEuMTVsLS4wNyw3LjAzYzAsLjgxLS45LDEuMy0xLjU5LC44OGwtNC44Ny0yLjk4Yy0uMzEtLjE5LS41LS41My0uNS0uOWwuMDQtNy41MmMwLS44LC44Ny0xLjMsMS41Ni0uOWw0LjM2LDIuNTJjLjY3LC4zOSwxLjA3LDEuMSwxLjA3LDEuODdaIiBmaWxsPSIjOTU5Njk3IiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjxwYXRoIGQ9Ik0xNjIuMDQsMTI3LjgybC0uMDcsNy4wM2MwLC44MS0uOSwxLjMtMS41OSwuODhsLTQuODctMi45OGMtLjMxLS4xOS0uNS0uNTMtLjUtLjlsLjA0LTcuNTJjMC0uOCwuODctMS4zLDEuNTYtLjlsNC4zNiwyLjUyYy42NywuMzksMS4wNywxLjEsMS4wNywxLjg3WiIgZmlsbD0iIzk1OTY5NyIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTczLjQsMTM0LjEybC0uMDcsNy4wM2MwLC44MS0uOSwxLjMtMS41OSwuODhsLTQuODctMi45OGMtLjMxLS4xOS0uNS0uNTMtLjUtLjlsLjA0LTcuNTJjMC0uOCwuODctMS4zLDEuNTYtLjlsNC4zNiwyLjUyYy42NywuMzksMS4wNywxLjEsMS4wNywxLjg3WiIgZmlsbD0iIzk1OTY5NyIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMTE2LjY4LDEwMS40MmwtLjA3LDcuMDNjMCwuODEtLjksMS4zLTEuNTksLjg4bC00Ljg3LTIuOThjLS4zMS0uMTktLjUtLjUzLS41LS45bC4wNC03LjUyYzAtLjgsLjg3LTEuMywxLjU2LS45bDQuMzYsMi41MmMuNjcsLjM5LDEuMDcsMS4xLDEuMDcsMS44N1oiIGZpbGw9IiM5NTk2OTciIHN0cm9rZT0iIzFkMWQxYiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE4NC45NCwxNDAuNzlsLS4wNyw3LjAzYzAsLjgxLS45LDEuMy0xLjU5LC44OGwtNC44Ny0yLjk4Yy0uMzEtLjE5LS41LS41My0uNS0uOWwuMDQtNy41MmMwLS44LC44Ny0xLjMsMS41Ni0uOWw0LjM2LDIuNTJjLjY3LC4zOSwxLjA3LDEuMSwxLjA3LDEuODdaIiBmaWxsPSIjOTU5Njk3IiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvZz48cGF0aCBpZD0iVGFpbFNoYWRvdyIgZD0iTTczLjczLDg1Ljk5YzQuNzMtMy4wNSwxMS41OS01LjcsMjEuNTMtNi41MS0xMi4zOS03LjEyLTM5LjQ0LTIyLjYzLTM5LjQ0LTIyLjYzbC0uNDUtLjA2Yy0zLjQ2LC41LTEuMjgsNC4yMiwyLjQsOC4zOSwwLDAsOC42LDEyLjAzLDE1Ljk2LDIwLjgxWiIgZmlsbD0iI2M3YzZjNiIvPjxwYXRoIGlkPSJXaW5kc2NyZWVuIiBkPSJNMjAxLjg4LDE0NC4yOHM1LjgxLDcuMjYsOC4yOCwxNC4zN2M0LjQ5LDIsMjAuMDIsNi4wOSwyNi4zOCwzLjg0LDEuOTgtMi44MiwxLjQxLTYuNjQsMS4xMy04LjE5cy04LjcyLTEwLjkxLTguNzItMTAuOTFjMCwwLS42NSw0LjI3LTEuOTIsNC45OHMtMTUuMTEtLjg0LTI1LjE0LTQuMDhaIiBmaWxsPSIjMjIyMzIzIi8+PC9nPjwvZz48ZyBpZD0iV2luZ3MiPjxnIGlkPSJMZWZ0V2luZyI+PGcgaWQ9IkVuZ2luZSI+PGcgaWQ9IkVuZ2luZS0yIj48ZyBpZD0iQm9keSI+PGVsbGlwc2UgY3g9IjE4Mi40OCIgY3k9IjEwMi42MSIgcng9IjUuNiIgcnk9IjkuMjMiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDcuMDkgMjE2LjkzKSByb3RhdGUoLTYyLjQ0KSIgZmlsbD0iIzk2OTY5NiIvPjxwYXRoIGQ9Ik0xOTcuNTgsMTExLjExYzIuMTctNi40MSw3LjQtMTAuNDQsMTEuNjctOC45OSwuMSwuMDMsLjE5LC4wOCwuMjgsLjEyLC4xLC4wMywuMiwuMDQsLjMsLjA4LDAsMC03LjA3LTQuMDctMTEtNS4xNS0zLjk0LTEuMDgtMjEuNC03LjI0LTIyLjQ3LDUtMS4xNywxMy40NSwyMS4xNCwyMS44MiwyNS4wMywyMy4xNy00LjI3LTEuNDUtNS45Ny03LjgyLTMuOC0xNC4yM1oiIGZpbGw9IiNlMmUyZTEiLz48L2c+PGcgaWQ9IkludGFrZSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXBwYXRoLTEpIj48ZWxsaXBzZSBpZD0iSW50YWtlLTMiIGN4PSIyMDUuMzIiIGN5PSIxMTMuNzQiIHJ4PSIxMi4yNiIgcnk9IjguMTciIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMxLjc1IDI3MS43NCkgcm90YXRlKC03MS4zKSIgZmlsbD0iIzdjN2M3YyIvPjxlbGxpcHNlIGlkPSJJbnRha2UtNCIgY3g9IjE5OC4zNCIgY3k9IjExMC44IiByeD0iMTIuMjYiIHJ5PSI4LjE3IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyOS43OSAyNjMuMTQpIHJvdGF0ZSgtNzEuMykiIGZpbGw9IiNlMmUyZTEiLz48cGF0aCBkPSJNMjA5LjI1LDEwMi4xMmMtNC4yOC0xLjQ1LTkuNSwyLjU4LTExLjY3LDguOTktMi4xNyw2LjQxLS40NywxMi43OSwzLjgxLDE0LjIzczkuNS0yLjU4LDExLjY3LTguOTljMi4xNy02LjQxLC40Ny0xMi43OS0zLjgxLTE0LjIzWm0yLjc2LDEzLjg4Yy0xLjg4LDUuNTQtNi40OCw5LjYzLTEwLjE4LDguMzgtMy42OS0xLjI1LTUuMDgtNy4zNi0zLjItMTIuOSwxLjg4LTUuNTQsNi43NC05LjQ3LDEwLjQzLTguMjIsMy42OSwxLjI1LDQuODMsNy4yMSwyLjk1LDEyLjc1WiIgZmlsbD0iI2VmZWZlZiIvPjxlbGxpcHNlIGN4PSIyMDEuNDEiIGN5PSIxMTEuOCIgcng9IjIuNjEiIHJ5PSIxLjY5IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMi42OCAyNTcuMTMpIHJvdGF0ZSgtNjguMTUpIiBmaWxsPSIjN2M3YzdjIi8+PC9nPjwvZz48L2c+PC9nPjxwYXRoIGlkPSJMZWZ0V2luZy0yIiBkPSJNMTkxLjk2LDEyMS45M3MyNS42Ny02Ni45MSwyNS42Ny03MC45NC00LjU3LTguMzItNC41Ny04LjMybC01Ni43Miw1OCwzNS42MiwyMS4yNloiIGZpbGw9IiNmYWZhZmEiLz48L2c+PGcgaWQ9IlJpZ2h0V2luZyI+PGcgaWQ9IkVuZ2luZS0zIj48ZyBpZD0iQm9keS0yIj48ZWxsaXBzZSBjeD0iMTE3LjU2IiBjeT0iMTUyLjA4IiByeD0iNS42IiByeT0iOS4yMyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTcxLjY2IDE4NS45NSkgcm90YXRlKC02Mi40NCkiIGZpbGw9IiM5Njk2OTYiLz48cGF0aCBkPSJNMTMyLjY1LDE2MC41OGMyLjE3LTYuNDEsNy40LTEwLjQ0LDExLjY3LTguOTksLjEsLjAzLC4xOSwuMDgsLjI4LC4xMiwuMSwuMDMsLjIsLjA0LC4zLC4wOCwwLDAtNy4wNy00LjA3LTExLTUuMTUtMy45NC0xLjA4LTIxLjQtNy4yNC0yMi40Nyw1LTEuMTcsMTMuNDUsMjEuMTQsMjEuODIsMjUuMDMsMjMuMTctNC4yNy0xLjQ1LTUuOTctNy44Mi0zLjgtMTQuMjNaIiBmaWxsPSIjZTJlMmUxIi8+PC9nPjxnIGlkPSJJbnRha2UtNSI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXBwYXRoLTIpIj48ZWxsaXBzZSBpZD0iSW50YWtlLTciIGN4PSIxNDAuNCIgY3k9IjE2My4yMSIgcng9IjEyLjI2IiByeT0iOC4xNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU5LjIxIDI0My44NSkgcm90YXRlKC03MS4zKSIgZmlsbD0iIzdjN2M3YyIvPjxlbGxpcHNlIGlkPSJJbnRha2UtOCIgY3g9IjEzMy40MiIgY3k9IjE2MC4yNyIgcng9IjEyLjI2IiByeT0iOC4xNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTYxLjE3IDIzNS4yNSkgcm90YXRlKC03MS4zKSIgZmlsbD0iI2UyZTJlMSIvPjxwYXRoIGQ9Ik0xNDQuMzMsMTUxLjU5Yy00LjI4LTEuNDUtOS41LDIuNTgtMTEuNjcsOC45OS0yLjE3LDYuNDEtLjQ3LDEyLjc5LDMuODEsMTQuMjNzOS41LTIuNTgsMTEuNjctOC45OWMyLjE3LTYuNDEsLjQ3LTEyLjc5LTMuODEtMTQuMjNabTIuNzYsMTMuODhjLTEuODgsNS41NC02LjQ4LDkuNjMtMTAuMTgsOC4zOC0zLjY5LTEuMjUtNS4wOC03LjM2LTMuMi0xMi45LDEuODgtNS41NCw2Ljc0LTkuNDcsMTAuNDMtOC4yMiwzLjY5LDEuMjUsNC44Myw3LjIxLDIuOTUsMTIuNzVaIiBmaWxsPSIjZWZlZmVmIi8+PGVsbGlwc2UgY3g9IjEzNi40OSIgY3k9IjE2MS4yNyIgcng9IjIuNjEiIHJ5PSIxLjY5IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjMuOTkgMjI3LjkzKSByb3RhdGUoLTY4LjE1KSIgZmlsbD0iIzdjN2M3YyIvPjwvZz48L2c+PC9nPjxnIGlkPSJSaWdodFdpbmctMiI+PGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXBwYXRoLTMpIj48cGF0aCBpZD0iUmlnaHRXaW5nLTQiIGQ9Ik0xMjMuMTQsMTIxLjkzTDI1LjUsMTU1LjUyczE0Ljk1LDQuNzUsMTguMyw0LjQxLDEyNy4yOC0xMC42MSwxMjcuMjgtMTAuNjFsLTQ3LjkzLTI3LjM5WiIgZmlsbD0iI2VmZWZlZiIvPjxwYXRoIGlkPSJTaGFkb3ctMyIgZD0iTTEwMS44MSwxMjkuMjdsMzkuMDYsMjIuNTVjMTcuNDQtMS40NSwzMC4yLTIuNSwzMC4yLTIuNWwtNDcuOTMtMjcuMzktMjEuMzQsNy4zNFoiIGZpbGw9IiNjN2M2YzYiLz48L2c+PC9nPjwvZz48L2c+PGcgaWQ9IlRhaWwiPjxwYXRoIGlkPSJUYWlsTGVmdCIgZD0iTTg1Ljc0LDY0LjAzbDYuMjgtMjUuNzhjLjYzLTMuMDMtMy45Ny00LjU5LTMuOTctNC41OWwtMTMuMTMsMTIuMzMsMTAuODIsMTguMDVaIiBmaWxsPSIjZmFmYWZhIi8+PHBhdGggaWQ9IlRhaWxSaWdodCIgZD0iTTU2LjQ2LDU4LjYxTDEwLjMyLDc3LjY5czcuMDYsMi43LDguNjUsMi41bDU5LjU3LTcuNC0yMi4wOC0xNC4xOFoiIGZpbGw9IiNjN2M2YzYiLz48cG9seWdvbiBpZD0iVGFpbENlbnRlciIgcG9pbnRzPSI5NS4yNyA3OS41MSA1Mi4zNCA4LjQzIDQzLjE2IDMgNTUuODIgNTYuODQgOTUuMjcgNzkuNTEiIGZpbGw9IiNkNjA3NTYiLz48L2c+PC9nPjxnIGlkPSJPdXRsaW5lVXBwZXIiPjxnIGlkPSJNYWluIj48cGF0aCBkPSJNNDMuMTYsM2w5LjE4LDUuNDMsMjIuNjQsMzcuNDksMTMuMDctMTIuMjZzNC41OSwxLjU3LDMuOTcsNC41OWwtNi4yMywyNS41NywxLDEuNjZjMTEuNjUsMy43NywyMy4zMSw3LjcsMjcuNDUsOS4zbDQyLjUyLDI1LjQ2LDU2LjMtNTcuNTZzNC41Nyw0LjMsNC41Nyw4LjMyLTIzLjEzLDY0LjMtMjUuNDcsNzAuNDNsMjQuMzksMTQuNnMzNS42NywzNy45LDI3Ljc2LDQzLjU1Yy0xLjQ2LDEuMDUtMy45NSwxLjQ3LTcuMDQsMS40Ny0xMy41OCwwLTM4LjctOC4yNS0zOC43LTguMjUsMCwwLTE4LjI1LTEwLjQ2LTM5LjAyLTIyLjUyLTMwLjg0LDIuNTYtMTEzLjAyLDkuMzctMTE1Ljc0LDkuNjUtLjEyLC4wMS0uMjUsLjAyLS4zOSwuMDItMy45OSwwLTE3LjktNC40Mi0xNy45LTQuNDJsOTAuMjMtMzEuMDRjLTcuOTUtNC44MS0xMy42My04LjM4LTE1LjA1LTkuNjEtNi4zNS01LjUxLTE2Ljk0LTE3LjQ2LTI0LjY3LTI2LjIyLTMuNjMtNC4xMS03Ljg3LTkuNTMtMTEuMzUtMTQuMTMtMTcuMjYsMi4xNC00NC42Miw1LjU0LTQ1LjcsNS42OC0uMDYsMC0uMTIsLjAxLS4xOSwuMDEtMS44OCwwLTguNDYtMi41MS04LjQ2LTIuNTFsNDMuNi0xOC4wM2MtLjY1LTEuNjYtLjI0LTIuNzgsMS44OC0yLjkyTDQzLjE2LDNtMC0zYy0uNjMsMC0xLjI1LC4yLTEuNzgsLjU4LS45NywuNzEtMS40MiwxLjkzLTEuMTQsMy4xbDEyLjA0LDUxLjIxYy0uNDMsLjM0LS43NCwuNzItLjk2LDEuMDYtLjI4LC40NC0uNTUsMS4wNC0uNjQsMS43OUw5LjE3LDc0LjkyYy0xLjE0LC40Ny0xLjg3LDEuNTgtMS44NSwyLjgxLC4wMiwxLjIzLC43OCwyLjMyLDEuOTMsMi43NiwyLjEzLC44MSw3LjMzLDIuNzEsOS41MywyLjcxLC4yLDAsLjM4LS4wMSwuNTYtLjAzbDQyLjc4LTUuMzEsMS4yMS0uMTVjNC4wOCw1LjM1LDcuNTksOS42OSwxMC40NCwxMi45Miw3LjI4LDguMjYsMTguMjksMjAuNzMsMjQuOTYsMjYuNTEsLjc3LC42NiwyLjY4LDIuMDcsMTAsNi41OGwtODQuMiwyOC45NmMtMS4yMiwuNDItMi4wNCwxLjU4LTIuMDIsMi44NywuMDIsMS4yOSwuODYsMi40MywyLjA5LDIuODIsMi40LC43NiwxNC41Nyw0LjU2LDE4LjgxLDQuNTYsLjI1LDAsLjQ4LS4wMSwuNy0uMDMsMi43LS4yOCw4Ny4yNS03LjI5LDExNC43NC05LjU3LDIwLjIxLDExLjczLDM4LjA0LDIxLjk1LDM4LjIyLDIyLjA1LC4xOCwuMSwuMzYsLjE4LC41NiwuMjUsMS4wNCwuMzQsMjUuNzMsOC40LDM5LjY0LDguNCw0LDAsNi44Ny0uNjYsOC43OC0yLjAzLC45NS0uNjgsMi4xNC0xLjk3LDIuMzYtNC4yNiwuMzEtMy4xMy0uODktOS4zLTE0LjczLTI2LjU0LTcuMzMtOS4xMy0xNC44OC0xNy4xNy0xNC45NS0xNy4yNS0uMTktLjItLjQxLS4zOC0uNjQtLjUybC0yMi4yMi0xMy4zMWMyLjI1LTUuODksNy41OS0xOS45MiwxMi43My0zMy43NSwxMi4wNC0zMi40NCwxMi4wNC0zNC4zNiwxMi4wNC0zNS4zOSwwLTUuMTctNC45NS05Ljk4LTUuNTEtMTAuNTEtLjU4LS41NC0xLjMyLS44MS0yLjA2LS44MS0uNzgsMC0xLjU2LC4zLTIuMTUsLjlsLTU0LjY0LDU1Ljg3LTQwLjQ5LTI0LjI0Yy0uMTUtLjA5LS4zLS4xNi0uNDYtLjIyLTMuNzctMS40Ny0xNC00Ljk0LTI2LjI2LTguOTJsNS44Ny0yNC4xcy4wMi0uMDcsLjAyLS4xYy44LTMuODgtMi40LTYuODMtNS45My04LjA0LS4zMi0uMTEtLjY0LS4xNi0uOTctLjE2LS43NSwwLTEuNDksLjI4LTIuMDUsLjgxbC0xMC4zNiw5LjczTDU0LjkxLDYuODhjLS4yNi0uNDItLjYxLS43OC0xLjA0LTEuMDNMNDQuNjksLjQyYy0uNDctLjI4LTEtLjQyLTEuNTMtLjQyaDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9nPjxnIGlkPSJSaWdodFdpbmctNSI+PHBhdGggZD0iTTEyMy4xNCwxMjEuOTNsNDcuOTMsMjcuMzlzLTEyMy45MywxMC4yNi0xMjcuMjgsMTAuNjFjLS4xMiwuMDEtLjI1LC4wMi0uMzksLjAyLTMuOTksMC0xNy45LTQuNDItMTcuOS00LjQybDk3LjY0LTMzLjU5bTAtMmMtLjIyLDAtLjQ0LC4wNC0uNjUsLjExTDI0Ljg1LDE1My42M2MtLjgyLC4yOC0xLjM2LDEuMDUtMS4zNSwxLjkxLC4wMSwuODYsLjU3LDEuNjIsMS4zOSwxLjg4LDEuNDUsLjQ2LDE0LjMzLDQuNTIsMTguNTEsNC41MiwuMjIsMCwuNDItLjAxLC42LS4wMywzLjI5LS4zNCwxMjYtMTAuNSwxMjcuMjQtMTAuNiwuODctLjA3LDEuNi0uNywxLjc5LTEuNTYsLjE5LS44Ni0uMi0xLjczLS45Ni0yLjE3bC00Ny45My0yNy4zOWMtLjMxLS4xNy0uNjUtLjI2LS45OS0uMjZoMFoiIGZpbGw9IiMxZDFkMWIiLz48L2c+PGcgaWQ9IlRhaWxDZW50ZXItMiI+PHBhdGggZD0iTTQzLjE2LDNsOS4xOCw1LjQzLDQyLjkyLDcxLjA4LTM5LjQ0LTIyLjY2TDQzLjE2LDNtMC0yYy0uNDIsMC0uODMsLjEzLTEuMTgsLjM5LS42NSwuNDctLjk1LDEuMjktLjc2LDIuMDdsMTIuNjYsNTMuODRjLjEzLC41NCwuNDcsMSwuOTUsMS4yOGwzOS40NCwyMi42NmMuMzEsLjE4LC42NSwuMjcsMSwuMjcsLjUyLDAsMS4wNC0uMjEsMS40My0uNiwuNjQtLjY1LC43NS0xLjY1LC4yOC0yLjQzTDU0LjA2LDcuMzljLS4xNy0uMjgtLjQxLS41Mi0uNjktLjY5TDQ0LjE4LDEuMjhjLS4zMS0uMTktLjY3LS4yOC0xLjAyLS4yOGgwWiIgZmlsbD0iIzFkMWQxYiIvPjwvZz48ZyBpZD0iVGFpbFJpZ2h0LTIiPjxwYXRoIGQ9Ik01Ni40Niw1OC42MWwyMi4wOCwxNC4xOHMtNTcuOTksNy4yLTU5LjU3LDcuNGMtLjA2LDAtLjEyLC4wMS0uMTksLjAxLTEuODgsMC04LjQ2LTIuNTEtOC40Ni0yLjUxbDQ2LjE0LTE5LjA4bTAtMmMtLjI2LDAtLjUyLC4wNS0uNzYsLjE1TDkuNTYsNzUuODRjLS43NiwuMzEtMS4yNSwxLjA2LTEuMjQsMS44OCwuMDEsLjgyLC41MiwxLjU1LDEuMjksMS44NCwxLjYyLC42Miw3LjA4LDIuNjQsOS4xNywyLjY0LC4xNiwwLC4zLDAsLjQzLS4wM2w1OS41Ny03LjRjLjg0LS4xLDEuNTItLjcyLDEuNy0xLjU0LC4xOS0uODItLjE2LTEuNjctLjg3LTIuMTNsLTIyLjA4LTE0LjE4Yy0uMzMtLjIxLS43LS4zMi0xLjA4LS4zMmgwWiIgZmlsbD0iIzFkMWQxYiIvPjwvZz48ZyBpZD0iTGVmdFdpbmctMyI+PHBhdGggZD0iTTE1Ni41Myw5OS4wNGw1Ni40OS01Ny43NywuNzEsLjY3Yy4yLC4xOSw0Ljg4LDQuNjQsNC44OCw5LjA1LDAsNC4wOS0yMi4zLDYyLjMzLTI0Ljg0LDY4Ljk2bC0uNDMsMS4xMS0zNi44Mi0yMi4wM1oiIGZpbGw9IiNmYWZhZmEiLz48cGF0aCBkPSJNMjEzLjA2LDQyLjY3czQuNTcsNC4zLDQuNTcsOC4zMi0yNC43OCw2OC42LTI0Ljc4LDY4LjZsLTM0LjcxLTIwLjc3LDU0LjkyLTU2LjE2bS0uMDYtMi44bC0xLjM3LDEuNC01NC45Miw1Ni4xNi0xLjc3LDEuODEsMi4xOCwxLjMsMzQuNzEsMjAuNzcsMi4wNCwxLjIyLC44NS0yLjIyYzUuODQtMTUuMjMsMjQuOTEtNjUuMjQsMjQuOTEtNjkuMzIsMC00Ljc1LTQuNjYtOS4yOC01LjItOS43OGwtMS40My0xLjM0aDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9nPjwvZz48L3N2Zz4=", "isIsometric": true, "collection": "isoflow" }, { "id": "printer", "name": "printer", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIxNjguMzk5OTlweCIgaGVpZ2h0PSIxNTQuOHB4IiB2aWV3Qm94PSIwIDAgMTY4LjM5OTk5IDE1NC44IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxNjguMzk5OTkgMTU0LjgiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8xXzFfIj4KCTxnIGlkPSJMYXllcl8zIj4KCTwvZz4KCTxnIGlkPSJMYXllcl82Ij4KCTwvZz4KCTxwYXRoIGZpbGw9IiNEMkQzRDUiIGQ9Ik0xNDQuNSw4NGMtMC4yLDIuMi0xLjM5OTk5LDQuMy0zLjUsNS41bC0yOS4yLDE3LjZjLTUuOCwzLjUtMTMsMy41LTE4LjgsMEwyNy44LDY4Yy0yLjMtMS40LTMuNC0zLjYtMy42LTYKCQl2LTAuMXYzNC42Yy0wLjEsMi42LDEsNS4yLDMuNiw2LjdsNjUuMDk5OTksMzkuMDk5OTljNS44LDMuNSwxMywzLjUsMTguOCwwbDI5LjItMTcuNmMyLjYwMDAxLTEuNSwzLjgtNC4zLDMuNS02LjlWODRIMTQ0LjV6Ii8+Cgk8cGF0aCBmaWxsPSIjNUQ2MzY2IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNNTguMiwzN0wyNy44LDU1LjNjLTQuOCwyLjktNC44LDkuOCwwLDEyLjcKCQlsNjUuMDk5OTksMzkuMWM1LjgsMy41LDEzLDMuNSwxOC44LDBsMjkuMi0xNy42YzQuOC0yLjksNC44LTkuOCwwLTEyLjdMNzQuNiwzN0M2OS42LDMzLjksNjMuMywzNCw1OC4yLDM3eiIvPgoJPHBhdGggZmlsbD0iI0U5RTlFQSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjAuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTgzLjcsNDIuNUw3MS4yLDUwYy00LjYsMi44LTQuNiw5LjUsMCwxMi4zCgkJTDEwMSw4MC4yYzUuNywzLjQsMTIuOCwzLjQsMTguNSwwTDEzMyw3Mkw4My43LDQyLjV6Ii8+Cgk8Zz4KCQk8cmVjdCB4PSIxMjUuMzg2NCIgeT0iNjIuMzk1ODMiIGZpbGw9IiM3MTc2NzciIHdpZHRoPSIwIiBoZWlnaHQ9IjEwLjEwMDAxIi8+CgkJPHBhdGggZmlsbD0iIzcxNzY3NyIgZD0iTTEwMSw4MC4yTDk0LjEsNzZMNjIuOCw4OWw4LjcsNS4ybDMxLjUtMTNDMTAyLjMsODAuOSwxMDEuNiw4MC41LDEwMSw4MC4yeiIvPgoJPC9nPgoJPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTU4LjIsMzdMMjcuOCw1NS4zYy00LjgsMi45LTQuOCw5LjgsMCwxMi43CgkJbDY1LjA5OTk5LDM5LjFjNS44LDMuNSwxMywzLjUsMTguOCwwbDI5LjItMTcuNmM0LjgtMi45LDQuOC05LjgsMC0xMi43TDc0LjYsMzdDNjkuNiwzMy45LDYzLjMsMzQsNTguMiwzN3oiLz4KCTxwYXRoIGZpbGw9IiMxRTIyMjYiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMzYuNywxMDguN0w4NiwxMzguM3YtMTcuMWMwLTMuOS0yLTcuNS01LjQtOS41TDQxLjEsODgKCQljLTEuOS0xLjItNC40LDAuMi00LjQsMi41QzM2LjcsOTAuNSwzNi43LDEwOC43LDM2LjcsMTA4Ljd6Ii8+Cgk8cGF0aCBmaWxsPSIjMzQzQTNEIiBkPSJNMTMzLjEwMDAxLDcyLjFsMTMtMjYuNGMwLjUtMi45LTAuOC01LjctMy4zLTcuMmwtNDAuOS0yNC42Yy0yLjEtMS4zLTQuOSwwLTUuMywyLjRsLTEzLDI2LjEKCQlMMTMzLjEwMDAxLDcyLjF6Ii8+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjODc4Nzg4IiBkPSJNMjAuOSwxMTYuOEwyMC45LDExNi44djAuMUMyMC45LDExNi45LDIwLjksMTE2LjksMjAuOSwxMTYuOHoiLz4KCQk8cGF0aCBmaWxsPSIjODc4Nzg4IiBkPSJNODYsMTMzLjEwMDAxbC0xMy40LDguMTAwMDFjLTUuNywzLjM5OTk5LTEyLjgsMy4zOTk5OS0xOC41LDBsLTI5LjgtMTcuOWMtMi4yLTEuMy0zLjQtMy42LTMuNS01Ljl2NWwwLDAKCQkJYzAsMi40LDEuMiw0LjcsMy41LDYuMTAwMDFsMjkuOCwxNy44OTk5OWMyLjgsMS43LDYsMi41LDkuMSwyLjYwMDAxbDAsMGwwLDBjMy4yLDAsNi41LTAuOCw5LjQtMi42MDAwMUw4NiwxMzguM2wwLDBsMCwwCgkJCVYxMzMuMTAwMDF6Ii8+Cgk8L2c+Cgk8cGF0aCBmaWxsPSIjNUY2NTY4IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTgwLjcsMTExLjdMNTIsOTQuNWwtMTUuMiw5LjFoLTAuMWwtMTIuNSw3LjYKCQljLTQuNiwyLjgtNC42LDkuNSwwLDEyLjNMNTQsMTQxLjM5OTk5YzUuNywzLjM5OTk5LDEyLjgsMy4zOTk5OSwxOC41LDBsOS4xLTUuNWwwLDBsNC4zLTIuNXYtMTJDODYsMTE3LjMsODQsMTEzLjcsODAuNywxMTEuN3oiLz4KCTxwYXRoIGZpbGw9IiNFOUU5RUEiIGQ9Ik04NiwxMjEuMmMwLTMuOS0yLTcuNS01LjQtOS41TDU4LjQsOTguNGwtMzEuOCwxOS4xbDM1LjYsMjEuMzk5OTlMODYsMTI0LjZWMTIxLjJ6Ii8+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNMTA4LjYsMTFsMzMuOSwyMC40bC0yLjYwMDAxLDUuM2wyLjg5OTk5LDEuOGMyLjUsMS41LDMuOCw0LjQsMy4zLDcuMmwtMTMsMjYuNGwtMy43LTIuMmwzLjcsMi4ybDAsMGw3LjgsNC43CgkJCWMyLjM5OTk5LDEuNCwzLjUsMy44LDMuNjAwMDEsNi4ybDAsMGwwLDBsMCwwdi0wLjF2MC4xdjAuMXY0LjdjMCwwLjIsMCwwLjUsMCwwLjd2MjkuMmMwLjIsMi42LTEsNS40LTMuNSw2LjlsLTI5LjIsMTcuNgoJCQljLTIuOSwxLjctNi4xLDIuNjAwMDEtOS40LDIuNjAwMDFzLTYuNS0wLjg5OTk5LTkuNC0yLjYwMDAxbC02LjktNC4xMDAwMXYwLjEwMDAxbDAsMGwwLDBsLTEzLjQsOC4xMDAwMQoJCQljLTIuOSwxLjctNi4xLDIuNjAwMDEtOS4zLDIuNjAwMDFoLTAuMWwwLDBsMCwwYy0zLjIsMC02LjMtMC44OTk5OS05LjEtMi42MDAwMWwtMjkuOC0xNy44OTk5OWMtMi4zLTEuNC0zLjUtMy44LTMuNS02LjFsMCwwdi01CgkJCWMwLTAuMiwwLTAuNCwwLTAuNnYtMC4xbDAsMGMwLDAsMCwwLDAsMC4xYzAsMCwwLDAsMC0wLjFsMCwwYzAuMS0yLjIsMS4zLTQuNCwzLjQtNS43bDguMy01bC00LjgtMi45Yy0yLjUtMS41LTMuNy00LjItMy42LTYuNwoJCQlWNjYuOGMwLTAuMSwwLTAuMiwwLTAuNGMwLDAsMC0zLjgsMC00LjZ2LTAuMWwwLDBjMC0yLjUsMS4yLTQuOSwzLjYtNi40TDU4LjIsMzdjMi41LTEuNSw1LjQtMi4zLDguMi0yLjNjMi44LDAsNS43LDAuOCw4LjIsMi4zCgkJCWw5LDUuNGwxMy0yNi4xYzAuMy0xLjgsMS45LTIuOSwzLjUtMi45YzAuNiwwLDEuMiwwLjIsMS44LDAuNWw0LjEsMi40TDEwOC42LDExIE0yNS44LDY2LjFDMjUuOCw2Ni4xLDI1LjcsNjYuMSwyNS44LDY2LjEKCQkJQzI1LjcsNjYuMSwyNS43LDY2LjEsMjUuOCw2Ni4xQzI1LjcsNjYuMSwyNS44LDY2LjEsMjUuOCw2Ni4xIE0yNi41LDY3bDAuMSwwLjFDMjYuNiw2NywyNi42LDY3LDI2LjUsNjcKCQkJYy0wLjEtMC4xLTAuMi0wLjEtMC4yLTAuMmwwLDBDMjYuNCw2Ni44LDI2LjUsNjYuOSwyNi41LDY3IE0yNy44LDY4Yy0wLjMtMC4yLTAuNi0wLjQtMC44LTAuNmwwLDBDMjcuMyw2Ny42LDI3LjUsNjcuOCwyNy44LDY4CgkJCSBNMTQ0LjUsODMuM3YwLjF2MC4xYzAsMC4xLDAsMC4xLDAsMC4ydjAuNXYtMC43QzE0NC41LDgzLjUsMTQ0LjUsODMuNCwxNDQuNSw4My4zIE0xNDQuMyw4NC43TDE0NC4zLDg0LjcKCQkJQzE0NC4zLDg0LjYsMTQ0LjMsODQuNiwxNDQuMyw4NC43QzE0NC4zOTk5OSw4NC42LDE0NC4zOTk5OSw4NC42LDE0NC4zLDg0LjdDMTQ0LjMsODQuNiwxNDQuMyw4NC43LDE0NC4zLDg0LjcgTTE0NC4xMDAwMSw4NS41CgkJCUwxNDQuMTAwMDEsODUuNWMwLTAuMSwwLTAuMSwwLTAuMWwwLDBWODUuNSBNMTQzLjgsODYuNEwxNDMuOCw4Ni40TDE0My44LDg2LjRjMCwwLDAtMC4xLDAuMTAwMDEtMC4xdi0wLjFsMCwwCgkJCUMxNDMuOCw4Ni4yLDE0My44LDg2LjMsMTQzLjgsODYuNCBNMTQzLjMsODcuMkwxNDMuMyw4Ny4yTDE0My4zLDg3LjJjMC0wLjEsMC4xMDAwMS0wLjEsMC4xMDAwMS0wLjJjMCwwLDAtMC4xLDAuMTAwMDEtMC4xCgkJCUMxNDMuMzk5OTksODcsMTQzLjM5OTk5LDg3LjEsMTQzLjMsODcuMiBNMTQyLjcsODhMMTQyLjcsODhMMTQyLjcsODhjMC4xMDAwMS0wLjEsMC4xMDAwMS0wLjIsMC4yLTAuMmMwLDAsMC0wLjEsMC4xMDAwMS0wLjEKCQkJQzE0Mi44OTk5OSw4Ny43LDE0Mi44LDg3LjksMTQyLjcsODggTTE0Miw4OC43TDE0Miw4OC43TDE0Miw4OC43YzAuMTAwMDEtMC4xLDAuMi0wLjIsMC4zOTk5OS0wLjNjMCwwLDAsMCwwLjEwMDAxLTAuMQoJCQlDMTQyLjMsODguNCwxNDIuMTAwMDEsODguNiwxNDIsODguNyBNMTQwLjg5OTk5LDg5LjVMMTQwLjg5OTk5LDg5LjVMMTQwLjg5OTk5LDg5LjVMMTQwLjg5OTk5LDg5LjVMMTQwLjg5OTk5LDg5LjUKCQkJYzAuMy0wLjIsMC41LTAuMywwLjgtMC41bDAsMEMxNDEuNSw4OS4xLDE0MS4yLDg5LjMsMTQwLjg5OTk5LDg5LjUgTTIxLjEsMTE5LjJMMjEuMSwxMTkuMkwyMS4xLDExOS4yIE0yMS40LDEyMAoJCQlDMjEuNCwxMjAsMjEuNCwxMjAuMSwyMS40LDEyMEMyMS40LDEyMC4xLDIxLjQsMTIwLDIxLjQsMTIwTDIxLjQsMTIwTDIxLjQsMTIwIE0yMS44LDEyMC44QzIxLjgsMTIwLjksMjEuOCwxMjAuOSwyMS44LDEyMC44CgkJCUMyMS44LDEyMC45LDIxLjgsMTIwLjksMjEuOCwxMjAuOEwyMS44LDEyMC44TDIxLjgsMTIwLjggTTIyLjMsMTIxLjZsMC4xLDAuMUMyMi40LDEyMS43LDIyLjQsMTIxLjcsMjIuMywxMjEuNmwtMC4xLTAuMQoJCQlDMjIuMywxMjEuNSwyMi4zLDEyMS42LDIyLjMsMTIxLjYgTTIzLDEyMi4zYzAuMSwwLjEsMC4xLDAuMSwwLjIsMC4yQzIzLjEsMTIyLjUsMjMuMSwxMjIuNCwyMywxMjIuM3MtMC4xLTAuMS0wLjItMC4yCgkJCUMyMi45LDEyMi4yLDIyLjksMTIyLjMsMjMsMTIyLjMgTTI0LjIsMTIzLjNsMC4xLDAuMUMyNC4zLDEyMy4zLDI0LjIsMTIzLjMsMjQuMiwxMjMuM2MtMC4yLTAuMS0wLjQtMC4zLTAuNy0wLjUKCQkJQzIzLjcsMTIzLDIzLjksMTIzLjEsMjQuMiwxMjMuMyBNMjQuMiw2MS42djAuMWwwLDBDMjQuMiw2MS44LDI0LjIsNjEuNywyNC4yLDYxLjZMMjQuMiw2MS42IE0xMDcuOCw4LjJsLTEsMS45bC0xLjcsMy40CgkJCWwtMi4yLTEuM2MtMC45LTAuNS0xLjktMC44LTIuOC0wLjhjLTIuNiwwLTQuOCwxLjgtNS40LDQuMmwtMTEuOSwyNGwtNy4xLTQuM2MtMi44LTEuNy02LTIuNi05LjItMi42Yy0zLjMsMC02LjUsMC45LTkuMiwyLjYKCQkJTDI2LjgsNTMuNmMtMi4zLDEuNC0zLjgsMy41LTQuMyw2aC0wLjJ2MmMwLDAsMCwwLDAsMC4xdjAuMXY0LjZjMCwwLjEsMCwwLjMsMCwwLjR2MjkuNmMtMC4yLDMuNSwxLjYsNi43LDQuNSw4LjVsMS45LDEuMgoJCQlsLTUuNSwzLjNjLTIuNCwxLjQtMy45LDMuNy00LjMsNi40bC0wLjEsMC4xdjAuOXYwLjFjMCwwLjIsMCwwLjQsMCwwLjZ2NC45YzAsMy4yLDEuNyw2LjIsNC40LDcuOGwyOS44LDE4CgkJCWMyLjksMS43LDYuMiwyLjcsOS42LDIuOGwwLjUsMC4zbDAuNi0wLjNjMy41LTAuMTAwMDEsNi45LTEsMTAtMi44OTk5OUw4Ni4yLDE0MC41bDUuNywzLjM5OTk5CgkJCWMzLjEsMS44OTk5OSw2LjcsMi44OTk5OSwxMC40LDIuODk5OTlzNy4zLTEsMTAuNC0yLjg5OTk5bDI5LjItMTcuNmMzLTEuOCw0LjgtNS4yLDQuNS04LjdWODguN2MwLTAuMiwwLTAuNSwwLTAuN3YtNC43di0wLjIKCQkJdi0wLjFsMCwwYy0wLjEwMDAxLTMuMy0xLjgtNi4yLTQuNS03LjlsLTYuMy0zLjhsMTIuMi0yNC43bDAuMTAwMDEtMC4zTDE0OCw0NmMwLjctMy43LTEtNy40LTQuMi05LjNsLTEuMzk5OTktMC44bDEuOC0zLjYKCQkJbDAuOC0xLjdsLTEuNjAwMDEtMUwxMDkuNiw5LjNMMTA3LjgsOC4yTDEwNy44LDguMnoiLz4KCTwvZz4KCTxwYXRoIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzVENjM2NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik04MC43LDExMS43TDU4LjQsOTguNGwtMTYuMiw5LjdsMzUuNywyMS40bDguMS00Ljl2LTMuNAoJCUM4NiwxMTcuMyw4NCwxMTMuNyw4MC43LDExMS43eiIvPgoJPHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik04NiwxMjEuMmMwLTMuOS0yLTcuNS01LjQtOS41TDU4LjQsOTguNGwtMzEuOCwxOS4xbDM1LjYsMjEuMzk5OTkKCQlMODYsMTI0LjZWMTIxLjJ6Ii8+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjQThBOEE4IiBkPSJNMTQyLjg5OTk5LDg3LjdjMCwwLDAuMTAwMDEtMC4xLDAuMTAwMDEtMC4yQzE0Myw4Ny42LDE0Myw4Ny43LDE0Mi44OTk5OSw4Ny43eiIvPgoJCTxwYXRoIGZpbGw9IiNBOEE4QTgiIGQ9Ik0xNDEuNyw4OWMwLjEwMDAxLDAsMC4xMDAwMS0wLjEsMC4yLTAuMkMxNDEuOCw4OC45LDE0MS43LDg4LjksMTQxLjcsODl6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTE0NC4zLDg0LjdDMTQ0LjMsODQuNiwxNDQuMyw4NC42LDE0NC4zLDg0LjdDMTQ0LjMsODQuNiwxNDQuMyw4NC42LDE0NC4zLDg0Ljd6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTE0My4zOTk5OSw4N2MwLDAsMC0wLjEsMC4xMDAwMS0wLjFMMTQzLjM5OTk5LDg3eiIvPgoJCTxwYXRoIGZpbGw9IiNBOEE4QTgiIGQ9Ik0xNDQuMTAwMDEsODUuNUMxNDQuMTAwMDEsODUuNCwxNDQuMTAwMDEsODUuNCwxNDQuMTAwMDEsODUuNQoJCQlDMTQ0LjEwMDAxLDg1LjQsMTQ0LjEwMDAxLDg1LjQsMTQ0LjEwMDAxLDg1LjV6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTE0My44LDg2LjNDMTQzLjgsODYuMiwxNDMuOCw4Ni4yLDE0My44LDg2LjNDMTQzLjgsODYuMiwxNDMuOCw4Ni4yLDE0My44LDg2LjN6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTE0Mi41LDg4LjJjLTAuMTAwMDEsMC4xLTAuMTAwMDEsMC4xLTAuMiwwLjJDMTQyLjM5OTk5LDg4LjMsMTQyLjUsODguMywxNDIuNSw4OC4yeiIvPgoJCTxwYXRoIGZpbGw9IiNBOEE4QTgiIGQ9Ik0xMDAuNCwxMDkuNmMwLjUsMC4xLDEsMC4xLDEuNSwwLjFDMTAxLjMsMTA5LjYsMTAwLjksMTA5LjYsMTAwLjQsMTA5LjZ6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTEwNi4xLDEwOS4yYy0xLjQsMC4zLTIuOSwwLjQtNC4zLDAuNEMxMDMuMywxMDkuNywxMDQuNywxMDkuNiwxMDYuMSwxMDkuMnoiLz4KCQk8cGF0aCBmaWxsPSIjQThBOEE4IiBkPSJNMTEwLjUsMTA3LjdjMC40LTAuMiwwLjgtMC40LDEuMi0wLjdDMTExLjMsMTA3LjMsMTEwLjksMTA3LjUsMTEwLjUsMTA3Ljd6Ii8+CgkJPHBhdGggZmlsbD0iI0E4QThBOCIgZD0iTTE0NC41LDg4LjZjMCwwLjIsMCwwLjQtMC4xMDAwMSwwLjZsMCwwbDAsMGMtMC4zLDItMS41LDMuOS0zLjUsNS4xbC0yOS4yLDE3LjYKCQkJYy0zLjUsMi4xLTcuNCwyLjktMTEuMywyLjV2MzAuMzk5OTljMy45LDAuMzk5OTksNy44LTAuMzk5OTksMTEuMy0yLjVsMjkuMi0xNy42YzIuNjAwMDEtMS41LDMuOC00LjMsMy41LTYuOVY4OC42SDE0NC41eiIvPgoJCTxwYXRoIGZpbGw9IiNBOEE4QTgiIGQ9Ik0xMDkuMiwxMDguM2MwLjMtMC4xLDAuNi0wLjMsMC45LTAuNEMxMDkuOCwxMDguMSwxMDkuNSwxMDguMiwxMDkuMiwxMDguM3oiLz4KCQk8cGF0aCBmaWxsPSIjQThBOEE4IiBkPSJNMTA3LjgsMTA4LjhjMC4zLTAuMSwwLjYtMC4yLDEtMC4zQzEwOC40LDEwOC42LDEwOC4xLDEwOC43LDEwNy44LDEwOC44eiIvPgoJCTxwYXRoIGZpbGw9IiNBOEE4QTgiIGQ9Ik0xMDYuMywxMDkuMmMwLjQtMC4xLDAuOC0wLjIsMS4yLTAuM0MxMDcuMSwxMDksMTA2LjcsMTA5LjEsMTA2LjMsMTA5LjJ6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjODY4Njg3IiBkPSJNMTQ0LjUsODMuMVY4M1Y4My4xTDE0NC41LDgzLjF6Ii8+CgkJPHBhdGggZmlsbD0iIzg2ODY4NyIgZD0iTTE0NC41LDgzLjV2MC45di0wLjVjLTAuMiwyLjItMS4zOTk5OSw0LjQtMy41LDUuN2wtMjkuMiwxNy42Yy01LjgsMy41LTEzLDMuNS0xOC44LDBMMjcuOCw2OAoJCQljLTIuNC0xLjQtMy42LTMuOS0zLjYtNi4zbDAsMHY0LjhjMCwwLjEsMCwwLjMsMCwwLjRjMC4xLDIuNCwxLjMsNC43LDMuNiw2TDkyLjg5OTk5LDExMmM1LjgsMy41LDEzLDMuNSwxOC44LDBsMjkuMi0xNy42CgkJCWMyLTEuMiwzLjEwMDAxLTMuMSwzLjUtNS4xbDAsMGwwLDBDMTQ0LjUsODguOSwxNDQuNSw4OC40LDE0NC41LDg4di00LjdDMTQ0LjUsODMuMywxNDQuNSw4My40LDE0NC41LDgzLjV6Ii8+Cgk8L2c+Cgk8Zz4KCQk8cmVjdCB4PSIxMDIuMDgxMDkiIHk9IjM4LjExMDIzIiBmaWxsPSIjNzE3Njc3IiB3aWR0aD0iMCIgaGVpZ2h0PSIzMC43MDAwNCIvPgoJCTxwYXRoIGZpbGw9IiM3MTc2NzciIGQ9Ik03MS4yLDYyLjNjLTIuOS0xLjgtNC01LjEtMy4yLThMMzAuNyw2OS43bDI2LjQsMTUuOGwzMS4zLTEzTDcxLjIsNjIuM3oiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGZpbGw9IiMzQjQwNDQiIGQ9Ik0xMDAsMTE0LjJ2LTQuOSIvPgoJCTxwYXRoIGZpbGw9IiMzQjQwNDQiIGQ9Ik0xMTEuNiwxMTEuOGwyOS4yLTE3LjZjMi0xLjIsMy4xMDAwMS0zLjEsMy41LTUuMWwwLDBsMCwwYzAuMTAwMDEtMC40LDAuMTAwMDEtMC45LDAuMTAwMDEtMS4zdi00LjcKCQkJYzAsMC4xLDAsMC4yLDAsMC4ydjAuOXYtMC41Yy0wLjIsMi4yLTEuMzk5OTksNC40LTMuNSw1LjdsLTI5LjIsMTcuNmMtMy42LDIuMS03LjcsMy0xMS43LDIuNXY0LjkKCQkJQzEwNCwxMTQuNywxMDguMSwxMTMuOSwxMTEuNiwxMTEuOHoiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xNDQuNSw4My4xVjgzVjgzLjFMMTQ0LjUsODMuMXoiLz4KCQk8cGF0aCBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTQ0LjUsODMuNXYwLjl2LTAuNQoJCQljLTAuMiwyLjItMS4zOTk5OSw0LjQtMy41LDUuN2wtMjkuMiwxNy42Yy01LjgsMy41LTEzLDMuNS0xOC44LDBMMjcuOCw2OGMtMi40LTEuNC0zLjYtMy45LTMuNi02LjNsMCwwdjQuOGMwLDAuMSwwLDAuMywwLDAuNAoJCQljMC4xLDIuNCwxLjMsNC43LDMuNiw2TDkyLjg5OTk5LDExMmM1LjgsMy41LDEzLDMuNSwxOC44LDBsMjkuMi0xNy42YzItMS4yLDMuMTAwMDEtMy4xLDMuNS01LjFsMCwwbDAsMAoJCQlDMTQ0LjUsODguOSwxNDQuNSw4OC40LDE0NC41LDg4di00LjdDMTQ0LjUsODMuMywxNDQuNSw4My40LDE0NC41LDgzLjV6Ii8+Cgk8L2c+Cgk8ZWxsaXBzZSBmaWxsPSIjQzlDOUM5IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY3g9IjU0LjUiIGN5PSI1MC43IiByeD0iNS4zIiByeT0iMy44Ii8+Cgk8ZWxsaXBzZSBmaWxsPSIjNTFBRDQ0IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY3g9IjQ1LjMiIGN5PSI1Ni41IiByeD0iMy40IiByeT0iMi41Ii8+Cgk8ZWxsaXBzZSBmaWxsPSIjRkM5NjAzIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY3g9IjM3LjYiIGN5PSI2MS40IiByeD0iMy40IiByeT0iMi41Ii8+Cgk8cGF0aCBmaWxsPSIjMzQzQTNEIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTgzLjcsNDIuNUw3MS4yLDUwYy00LjYsMi44LTQuNiw5LjUsMCwxMi4zTDEwMSw4MC4yCgkJYzUuNywzLjQsMTIuOCwzLjQsMTguNSwwTDEzMyw3Mkw4My43LDQyLjV6Ii8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxMjEuMSw3NC41IDE0Mi41LDMxLjQgMTA4LjYsMTEgODcuMyw1NC4xIAkiLz4KCTxwYXRoIG9wYWNpdHk9IjAuMyIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIGQ9Ik0xNjUuNywxMTUuNmwtMjEuMi0xMy41djE1LjhjMC4yLDIuNi0xLDUuNC0zLjUsNi45bC0yOS4yLDE3LjYwMDAxCgkJYy0zLDEuOC02LjQsMi43LTkuNywyLjYwMDAxbC0xLjUsMi4xMDAwMWwyOS4zLTIuMmwzNi4wOTk5OS0yMC4zQzE2OS4zLDEyMi41LDE2OS4yLDExNy41LDE2NS43LDExNS42eiIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjNUQ2MzY2IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI4OS43LDQ5LjMgODcuMyw1NC4xIDEyMS4xLDc0LjUgMTIzLjUsNjkuNiAJIi8+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "pyramid", "name": "pyramid", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB3aWR0aD0iNTUyLjVweCIgaGVpZ2h0PSI0NDcuNXB4IiB2aWV3Qm94PSIwIDAgNTUyLjUgNDQ3LjUiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDU1Mi41IDQ0Ny41IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIzMDkuMjAwMDEsNDE0Ljg5OTk5IDI3NC41LDQwOC4xMDAwMSAyNzMuODk5OTksMTY4LjIgNTUxLjUsMjcyICIvPgo8Zz4KCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04ODIuMDk5OTgsMTk3Ljg5OTk5YzAuMDk5OTgsMC44OTk5OSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN0w4ODIuMDk5OTgsMTk3Ljg5OTk5TDg4Mi4wOTk5OCwxOTcuODk5OTl6IgoJCS8+Cgk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNODg0LjIwMDAxLDIwMC42MDAwMWgtMy43OTk5OWMwLTAuOCwwLTEuNjAwMDEtMC4wOTk5OC0yLjVMODgwLDE5Nmg0LjA5OTk4TDg4NC4yMDAwMSwyMDAuNjAwMDEKCQlMODg0LjIwMDAxLDIwMC42MDAwMXoiLz4KPC9nPgo8Zz4KCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04ODIuMDk5OTgsMTAyLjNjMC4wOTk5OCwwLjksMC4wOTk5OCwxLjgsMC4wOTk5OCwyLjdMODgyLjA5OTk4LDEwMi4zTDg4Mi4wOTk5OCwxMDIuM3oiLz4KCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik04ODQuMjAwMDEsMTA1aC0zLjc5OTk5YzAtMC44LDAtMS42LTAuMDk5OTgtMi41bC0wLjI5OTk5LTIuMWg0LjA5OTk4TDg4NC4yMDAwMSwxMDVMODg0LjIwMDAxLDEwNXoiLz4KPC9nPgo8cGF0aCBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNODgyLjA5OTk4LDE5Ny44OTk5OQoJYzAuMDk5OTgsMC44OTk5OSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN0w4ODIuMDk5OTgsMTk3Ljg5OTk5TDg4Mi4wOTk5OCwxOTcuODk5OTl6Ii8+CjxwYXRoIGZpbGw9IiNCMkNCRUQiIHN0cm9rZT0iIzIzMUYyMCIgc3Ryb2tlLXdpZHRoPSI0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik04ODIuMDk5OTgsMTAyLjMKCWMwLjA5OTk4LDAuOSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN0w4ODIuMDk5OTgsMTAyLjNMODgyLjA5OTk4LDEwMi4zeiIvPgo8Zz4KCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iODUuMywyODcuMzk5OTkgMjc1LDEwMy44IDQ2NC43MDAwMSwyODcuMzk5OTkgMjc1LDQwMS41IAkiLz4KCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik0yNzUsMTEwbDE4Mi4zOTk5OSwxNzYuNjAwMDFMMjc1LDM5Ni4yOTk5OUw5Mi43LDI4Ni42MDAwMUwyNzUsMTEwIE0yNzUsOTcuNWwtNi4yOTk5OSw2LjFMODYuNCwyODAuMTAwMDEKCQlsLTguNCw4LjEwMDAxbDEwLDZMMjcwLjM5OTk5LDQwNEwyNzUsNDA2Ljc5OTk5TDI3OS42MDAwMSw0MDRMNDYyLDI5NC4yOTk5OWwxMC02bC04LjM5OTk5LTguMTAwMDFMMjgxLjI5OTk5LDEwMy42TDI3NSw5Ny41CgkJTDI3NSw5Ny41eiIvPgo8L2c+Cjxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iMjc1LDM5Ni4yOTk5OSAyNzUsMTEwIDkyLjcsMjg2LjYwMDAxICIvPgo8cmVjdCB4PSIyNzIuMjAwMDEiIHk9IjEwNC42IiBmaWxsPSIjMjMxRjIwIiB3aWR0aD0iNS43MDAwMSIgaGVpZ2h0PSIyOTguNSIvPgo8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9IjI3Mi4yMDAwMSwxNTcuOCA5Ny4xLDI4OS4yOTk5OSA5Mi43LDI4Ni42MDAwMSAyNzIuMjAwMDEsMTEzLjUgIi8+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "queue", "name": "queue", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI3Ny4zcHgiIGhlaWdodD0iNjNweCIgdmlld0JveD0iMCAwIDc3LjMgNjMiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDc3LjMgNjMiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfMyI+CjwvZz4KPGcgaWQ9IkxheWVyXzQiPgoJPHBvbHlnb24gZmlsbD0iIzRGNjU4NyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzYuNSwxMC44IDYuNiwyOC44IDYuNiwzOS45IAoJCTM2LjUsMjIgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzUuMSwxMC4xIDM2LjUsMTAuNyA2LjYsMjguOCAKCQk2LjYsMzkuOSA1LjIsMzkuMiA1LjIsMjguMSAJIi8+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjM2LjUsMTUuOCAzMy44LDI4LjYgNDIuMyw1NC4zIDM3LjksNTguOSA0NS45LDU4LjkgNzcuMywzOS45IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM5M0E2QzYiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjM2LjUsNTcuOCA2LjYsMzkuOSAzNi41LDIyIAoJCTY2LjMsMzkuOCAJIi8+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjIwLjUsMzkuOSA2LjYsMzkuOSAxMy41LDM2LjIgCSIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIyMC40LDQzIDM4LjUsNTMuOCA2MC45LDQwLjMgNDIuOSwyOS40IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjM2LjUsNDkuOSAxMy41LDM2LjIgMzYuNSwyMi40IAoJCTU5LjQsMzYuMiAJIi8+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjIwLjQsMzYuNSAzOC41LDQ3LjMgNTguMiwzNS40IDQwLjEsMjQuNiAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIzNi41LDQzLjMgMTMuNSwyOS41IDM2LjUsMTUuOCAKCQk1OS40LDI5LjUgCSIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIyMC41LDI5LjkgMzguNiw0MC44IDU4LjQsMjguOSA0MC4zLDE4LjEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzYuNSwzNi4zIDEzLjUsMjIuNiAzNi41LDguOCAKCQk1OS40LDIyLjYgCSIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIyMC41LDIyLjkgMzguNiwzMy44IDU4LjQsMjEuOSA0MC4zLDExLjEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIzNi40LDI5LjUgMTMuNSwxNS44IDM2LjQsMiA1OS4zLDE1LjggCSIvPgoJPHBvbHlnb24gZmlsbD0iIzRGNjU4NyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNjcuNywyOS4zIDM3LjksNDcuNCAzNy45LDU4LjQgCgkJNjcuNyw0MC41IAkiLz4KCTxwb2x5Z29uIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjM2LjQsMjkuNSAxMy41LDE1LjggMzYuNCwyIAoJCTU5LjMsMTUuOCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI2Ni4zLDI4LjYgNjcuNywyOS4zIDM3LjksNDcuNCAKCQkzNy45LDU4LjQgMzYuNSw1Ny44IDM2LjUsNDYuNyAJIi8+CjwvZz4KPGcgaWQ9IkxheWVyXzYiPgo8L2c+CjxnIGlkPSJMYXllcl81Ij4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "router", "name": "router", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1MThweCIgaGVpZ2h0PSI0NzdweCIgdmlld0JveD0iMCAwIDUxOCA0NzciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDUxOCA0NzciIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04NjAuNDAwMDIsMjQ3LjM5OTk5YzAuMDk5OTgsMC44OTk5OSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN3YtMi43SDg2MC40MDAwMnoiLz4KCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik04NjIuNDAwMDIsMjUwLjEwMDAxaC0zLjc5OTk5YzAtMC44LDAtMS42MDAwMS0wLjA5OTk4LTIuNUw4NTguMzAwMDUsMjQ1LjVoNC4wOTk5OFYyNTAuMTAwMDEKCQlMODYyLjQwMDAyLDI1MC4xMDAwMXoiLz4KPC9nPgo8Zz4KCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik04NjAuNDAwMDIsMTUxLjhjMC4wOTk5OCwwLjg5OTk5LDAuMDk5OTgsMS44LDAuMDk5OTgsMi43di0yLjdIODYwLjQwMDAyeiIvPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTg2Mi40MDAwMiwxNTQuNWgtMy43OTk5OWMwLTAuOCwwLTEuNjAwMDEtMC4wOTk5OC0yLjVsLTAuMjAwMDEtMi4xMDAwMWg0LjA5OTk4VjE1NC41TDg2Mi40MDAwMiwxNTQuNXoiCgkJLz4KPC9nPgo8cGF0aCBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNODYwLjQwMDAyLDI0Ny4zOTk5OQoJYzAuMDk5OTgsMC44OTk5OSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN3YtMi43SDg2MC40MDAwMnoiLz4KPHBhdGggZmlsbD0iI0IyQ0JFRCIgc3Ryb2tlPSIjMjMxRjIwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTg2MC40MDAwMiwxNTEuOAoJYzAuMDk5OTgsMC44OTk5OSwwLjA5OTk4LDEuOCwwLjA5OTk4LDIuN3YtMi43SDg2MC40MDAwMnoiLz4KPGc+Cgk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNMjQyLjcsMjU2Ljc5OTk5SDIzOWMtNS44LDAtMTAuNS00LjctMTAuNS0xMC41VjQ3LjJjMC01LjgsNC43LTEwLjUsMTAuNS0xMC41aDMuOGM1LjgsMCwxMC41LDQuNywxMC41LDEwLjVWMjQ2LjMKCQlDMjUzLjIsMjUyLjEwMDAxLDI0OC41LDI1Ni43OTk5OSwyNDIuNywyNTYuNzk5OTl6Ii8+Cgk8cGF0aCBmaWxsPSIjMDAwMDAwIiBkPSJNMzk2Ljg5OTk5LDM0My41aC0zLjc5OTk5Yy01Ljc5OTk5LDAtMTAuNS00LjcwMDAxLTEwLjUtMTAuNVYxMzMuODk5OTljMC01LjgsNC43MDAwMS0xMC41LDEwLjUtMTAuNWgzLjc5OTk5CgkJYzUuNzk5OTksMCwxMC41LDQuNywxMC41LDEwLjVWMzMzQzQwNy4zOTk5OSwzMzguNzk5OTksNDAyLjcwMDAxLDM0My41LDM5Ni44OTk5OSwzNDMuNXoiLz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iMzU4LDQyMi4zOTk5OSAyNzcuMjk5OTksNDQzLjEwMDAxIDIwNi44LDE1My4zIDUwOC4yOTk5OSwzMzQuNSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjY5LjcsNDA1LjIwMDAxIDY5LDQwNS4yMDAwMSA2OSw0MDUuNzAwMDEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NDRDhFRSIgcG9pbnRzPSI2OS43LDMxNy4yOTk5OSA2OSwzMTcuMjk5OTkgNjksMzE3LjcwMDAxIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iMjc3LjI5OTk5LDM1NS43OTk5OSA3MSwyMjguNjAwMDEgMjA2LjgsMTQ2LjcgNDEzLjcwMDAxLDI3My43OTk5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjY5LjcsMjI5LjM5OTk5IDY5LDIyOS4zOTk5OSA2OSwyMjkuOCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQjVDNURDIiBwb2ludHM9IjY5LDMwNC4zOTk5OSAyNzcuMjk5OTksNDMyLjc5OTk5IDI3Ny4yOTk5OSw0MzIuNzk5OTkgMjc3LjI5OTk5LDM1OC4yMDAwMSA2OSwyMjkuOCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjRkZGRkZGIiBwb2ludHM9IjI5MSwzNDcuNSAxMTQuNSwyMzcuMTAwMDEgMjM1LjgsMTY0LjEwMDAxIDIwNi44LDE0Ni43IDcxLDIyOC42MDAwMSAyNzcuMjk5OTksMzU1Ljc5OTk5IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM2ODg1QTkiIHBvaW50cz0iNDE1LjcwMDAxLDM0OS41IDI3Ny4yOTk5OSw0MzIuNzk5OTkgMjc3LjI5OTk5LDQzMi43OTk5OSAyNzcuMjk5OTksMzU4LjIwMDAxIDQxNS43MDAwMSwyNzUgCSIvPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTQyMy44OTk5OSwyNzAuMjAwMDFMMjA2LjgsMTM3bC0xNDUsODcuMzk5OTlMNjAuOCwyMjV2ODN2MC43MDAwMUwyNzcuMjk5OTksNDQybDE0Ni42MDAwMS04OC4yMDAwMQoJCVYyNzAuMjAwMDF6IE0yMDYuOCwxNDYuN2wyMDYuOTAwMDEsMTI3LjAwMDAybC0xMzYuMzk5OTksODJMNzEsMjI4LjYwMDAxTDIwNi44LDE0Ni43eiBNNDEzLjYwMDAxLDI3OC42MDAwMXY2OS42OTk5OAoJCWwtMTM0LjMwMDAyLDgwLjc5OTk5di02OS43MDAwMUw0MTMuNjAwMDEsMjc4LjYwMDAxeiBNMjc1LjIwMDAxLDM1OS4zOTk5OXY2OS42OTk5OEw3MSwzMDMuMjAwMDF2LTY5LjdMMjc1LjIwMDAxLDM1OS4zOTk5OXoiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMjUzLjIsMzg5LjI5OTk5IDEzNC4zLDMxNS42MDAwMSAxMzQuMywzMDQuNjAwMDEgMjUzLjIsMzc4LjI5OTk5IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMTE4LjYsMzA1Ljg5OTk5IDEwNy4yLDI5OC43OTk5OSAxMDcuMiwyODcuNzk5OTkgMTE4LjYsMjk0Ljg5OTk5IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMTAwLjQsMjk0LjcwMDAxIDg5LDI4Ny42MDAwMSA4OSwyNzYuNjAwMDEgMTAwLjQsMjgzLjcwMDAxIAkiLz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "server", "name": "server", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NDMuNTIzOTlweCIgaGVpZ2h0PSI1MDguODUxMDFweCIgdmlld0JveD0iMCAwIDU0My41MjM5OSA1MDguODUxMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDU0My41MjM5OSA1MDguODUxMDEiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTg4My4xMTQwMSwyNjcuMTgxYzAuMDg4MDEsMC45MDIwMSwwLjE0NiwxLjgwNzAxLDAuMTQ2LDIuNzE3MDFWMjY3LjE4MUg4ODMuMTE0MDF6Ii8+Cgk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNODg1LjE1MzAyLDI2OS44OTdoLTMuNzg0OTdjMC0wLjc2MDk5LTAuMDQ0OTgtMS41ODgwMS0wLjEzOC0yLjUzMjAxbC0wLjIwMzk4LTIuMDc3aDQuMTI3MDF2NC42MDkwMQoJCUg4ODUuMTUzMDJ6Ii8+CjwvZz4KPGc+Cgk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNODgzLjExNDAxLDE3MS42MTMwMWMwLjA4ODAxLDAuOTAxLDAuMTQ2LDEuODA2LDAuMTQ2LDIuNzE2di0yLjcxNkg4ODMuMTE0MDF6Ii8+Cgk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNODg1LjE1MzAyLDE3NC4zMjg5OWgtMy43ODQ5N2MwLTAuNzYxLTAuMDQ0OTgtMS41ODgtMC4xMzgtMi41M2wtMC4yMDM5OC0yLjA3OGg0LjEyNzAxdjQuNjA4SDg4NS4xNTMwMnoiCgkJLz4KPC9nPgo8cGF0aCBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNODgzLjExNDAxLDI2Ny4xODEKCWMwLjA4ODAxLDAuOTAyMDEsMC4xNDYsMS44MDcwMSwwLjE0NiwyLjcxNzAxVjI2Ny4xODFIODgzLjExNDAxeiIvPgo8cGF0aCBmaWxsPSIjQjJDQkVEIiBzdHJva2U9IiMyMzFGMjAiIHN0cm9rZS13aWR0aD0iNCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNODgzLjExNDAxLDE3MS42MTMwMQoJYzAuMDg4MDEsMC45MDEsMC4xNDYsMS44MDYsMC4xNDYsMi43MTZ2LTIuNzE2SDg4My4xMTQwMXoiLz4KPGc+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjMwNS4yODI5OSw0NzguNDkxIDI3MS40NzE5OCw0NzEuODkyIDEzMC45NDIsOTMuMDQ2IDU0MS4xMDkwMSwzNDEuMzggCgkJCSIvPgoJPHBvbHlnb24gZmlsbD0iIzM2NUU3RiIgcG9pbnRzPSI0MzkuODQ2MDEsMjgwLjg3NSAyNzIuNTAxMDEsMzgwLjAwMTAxIDEwMC4yNzgsMjc5LjU5Njk4IDk1LjEwNSwyODMuMzA0OTkgMjcxLjQ3NTAxLDM4OS4wOTY5OCAKCQk0NDYuNTA2OTksMjg1LjExMzAxIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDQ0Q4RUUiIHBvaW50cz0iOTIuNjkzLDI4NC4zNzkgOTEuOTkyLDI4NC4zNzkgOTEuOTkyLDI4NC43OTA5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMjMxRjIwIiBwb2ludHM9IjkxLjQ5MiwyODUuNjY1MDEgOTEuNDkyLDI4My44NzkgOTQuNTMxLDI4My44NzkgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0I1QzVEQyIgcG9pbnRzPSI5MS45OTIsMzU2LjI5MDAxIDI3MS40Njg5OSw0NjIuNTk5IDI3MS40NzUwMSw0NjIuNTk1IDI3MS40NzUwMSwzOTEuMDk2OTggOTEuOTkyLDI4NC44MDA5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjQ1MC45NTkwMSwzNTYuMjkwMDEgMjcxLjQ4MTk5LDQ2Mi41OTkgMjcxLjQ3NTAxLDQ2Mi41OTUgMjcxLjQ3NTAxLDM5MS4wOTY5OCAKCQk0NTAuOTU5MDEsMjg0LjgwMDk5IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDQ0Q4RUUiIHBvaW50cz0iOTIuNjkzLDIwMC4wNTcwMSA5MS45OTIsMjAwLjA1NzAxIDkxLjk5MiwyMDAuNDcwOTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzIzMUYyMCIgcG9pbnRzPSI5MS40OTIsMjAxLjM0NyA5MS40OTIsMTk5LjU1NzAxIDk0LjUyMywxOTkuNTU3MDEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NERDlFRSIgcG9pbnRzPSIyNzEuNDc1MDEsMjIwLjEzMSA5My45NTIsMTE0Ljk5NSAyNzAuOTE1OTksOS45ODYgNDQ4Ljk5MiwxMTQuOTk5IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMzNjVFN0YiIHBvaW50cz0iNDM3Ljc4MjAxLDE5Mi42NyAyNzAuOTE1MDEsMjkxLjQ5Mzk5IDEwNC42MSwxOTMuMDAxMDEgOTEuOTg2LDIwMC40NzcwMSAyNzEuNDc1MDEsMzA2Ljc3NiAKCQk0NTAuOTY1LDIwMC40NzcwMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQjVDNURDIiBwb2ludHM9IjkxLjk5MiwyNzEuOTY3OTkgMjcxLjQ2ODk5LDM3OC4yOCAyNzEuNDc1MDEsMzc4LjI3Mzk5IDI3MS40NzUwMSwzMDYuNzc2IDkxLjk5MiwyMDAuNDggCSIvPgoJPHBvbHlnb24gZmlsbD0iIzY4ODVBOSIgcG9pbnRzPSI0NTAuOTU5MDEsMjcxLjk2Nzk5IDI3MS40ODE5OSwzNzguMjggMjcxLjQ3NTAxLDM3OC4yNzM5OSAyNzEuNDc1MDEsMzA2Ljc3NiA0NTAuOTU5MDEsMjAwLjQ4IAkKCQkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDQ0Q4RUUiIHBvaW50cz0iOTIuNjkzLDExNS43MzggOTEuOTkyLDExNS43MzggOTEuOTkyLDExNi4xNSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQjVDNURDIiBwb2ludHM9IjkxLjk5MiwxODcuNjQ5OTkgMjcxLjQ2ODk5LDI5My45NTgwMSAyNzEuNDc1MDEsMjkzLjk1NDAxIDI3MS40NzUwMSwyMjIuNDU1IDkxLjk5MiwxMTYuMTU4IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNGRkZGRkYiIHBvaW50cz0iMjg0Ljc5MTk5LDIxMi4yNDQgMTM2LjI0OCwxMjMuMTMzIDI5OS4xMDgsMjYuNjExIDI3MC45MTU5OSw5Ljk4NiA5My45NTIsMTE0Ljk5NSAKCQkyNzEuNDc1MDEsMjIwLjEzMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjNjg4NUE5IiBwb2ludHM9IjQ1MC45NTkwMSwxODcuNjQ5OTkgMjcxLjQ4MTk5LDI5My45NTgwMSAyNzEuNDc1MDEsMjkzLjk1NDAxIDI3MS40NzUwMSwyMjIuNDU1IAoJCTQ1MC45NTkwMSwxMTYuMTU4IAkiLz4KCTxnPgoJCTxnIG9wYWNpdHk9IjAuOCIgZmlsbD0iIzAwMDAwMCI+CgkJCTxwb2x5Z29uIGZpbGw9IiNFQUY0RkYiIHBvaW50cz0iMTU1LjkzNSwyMjIuOTYwMDEgMTk3LjYxNTAxLDE4MS4yNzkwMSAxNTUuODQyLDE1Ni41MzkgMTE0LjE2NSwxOTguMjE2IAkJCSIvPgoJCTwvZz4KCQk8ZyBvcGFjaXR5PSIwLjgiIGZpbGw9IiMwMDAwMDAiPgoJCQk8cG9seWdvbiBmaWxsPSIjRUFGNEZGIiBwb2ludHM9IjE4NS45NTM5OSwyNDAuODkzMDEgMjI3LjgyODk5LDE5OS4wMTgwMSAyMDYuODQzOTksMTg2LjU4OSAxNjQuOTY4OTksMjI4LjQ2NCAJCQkiLz4KCQk8L2c+Cgk8L2c+Cgk8Zz4KCQk8ZyBvcGFjaXR5PSIwLjgiIGZpbGw9IiMwMDAwMDAiPgoJCQk8cG9seWdvbiBmaWxsPSIjRUFGNEZGIiBwb2ludHM9IjE1NS44ODQ5OSwzMDcuNDE1MDEgMTk3LjcwMiwyNjUuNTk2MDEgMTU1Ljc5MjAxLDI0MC43NzQgMTEzLjk3NywyODIuNTkgCQkJIi8+CgkJPC9nPgoJCTxnIG9wYWNpdHk9IjAuOCIgZmlsbD0iIzAwMDAwMCI+CgkJCTxwb2x5Z29uIGZpbGw9IiNFQUY0RkYiIHBvaW50cz0iMTg2LjAwMiwzMjUuNDA3MDEgMjI4LjAxNywyODMuMzkzMDEgMjA2Ljk2MywyNzAuOTI0OTkgMTY0Ljk1LDMxMi45MzYgCQkJIi8+CgkJPC9nPgoJPC9nPgoJPGc+CgkJPGcgb3BhY2l0eT0iMC44IiBmaWxsPSIjMDAwMDAwIj4KCQkJPHBvbHlnb24gZmlsbD0iI0VBRjRGRiIgcG9pbnRzPSIxNTUuOTI3LDM5MS41ODQ5OSAxOTcuNjI4MDEsMzQ5Ljg4IDE1NS44MzI5OSwzMjUuMTI3OTkgMTE0LjEzNCwzNjYuODI3IAkJCSIvPgoJCTwvZz4KCQk8ZyBvcGFjaXR5PSIwLjgiIGZpbGw9IiMwMDAwMDAiPgoJCQk8cG9seWdvbiBmaWxsPSIjRUFGNEZGIiBwb2ludHM9IjE4NS45NjEsNDA5LjUyNiAyMjcuODU4OTksMzY3LjYzIDIwNi44NjQsMzU1LjE5NCAxNjQuOTY2LDM5Ny4wOTEgCQkJIi8+CgkJPC9nPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIxMzkuODkyLDE3Mi4wNjEgMTIxLjk5MiwxNjEuNDYwMDEgMTIxLjk5MiwxNzcuODg2OTkgMTM5Ljg5MiwxODguNDg5IAkJIi8+Cgk8L2c+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjE2NC4yNDY5OSwxODcuNDE2IDE0Ni4zNDcsMTc2LjgxNCAxNDYuMzQ3LDE5My4yNDEgMTY0LjI0Njk5LDIwMy44NDM5OSAJCSIvPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIxMzkuODkyLDI1Ni4zMjQwMSAxMjEuOTkyLDI0NS43MjQgMTIxLjk5MiwyNjIuMTQ5OTkgMTM5Ljg5MiwyNzIuNzUyOTkgCQkiLz4KCTwvZz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMTY0LjI0Njk5LDI3MS42Nzk5OSAxNDYuMzQ3LDI2MS4wNzggMTQ2LjM0NywyNzcuNTA1IDE2NC4yNDY5OSwyODguMTA2OTkgCQkiLz4KCTwvZz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMTM5Ljg5MiwzNDIuMzcyMDEgMTIxLjk5MiwzMzEuNzY5OTkgMTIxLjk5MiwzNDguMTk4IDEzOS44OTIsMzU4Ljc5OTk5IAkJIi8+Cgk8L2c+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjE2NC4yNDY5OSwzNTcuNzI4IDE0Ni4zNDcsMzQ3LjEyNjAxIDE0Ni4zNDcsMzYzLjU1MiAxNjQuMjQ2OTksMzc0LjE1NSAJCSIvPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIyNDcuNjQ3OTksMjQ4LjcyNCAxNzEuMzg0LDIwMy41NTkwMSAxNzEuMzg0LDE5Ni42MzQ5OSAyNDcuNjc5LDI0MS43NyAJCSIvPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIyNDcuNjQ3OTksMzMyLjgyMTAxIDE3MS4zODQsMjg3LjY1Mzk5IDE3MS4zODQsMjgwLjczMTk5IDI0Ny42NzksMzI1Ljg2ODAxIAkJIi8+Cgk8L2c+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjI0Ny42NDc5OSw0MTYuOTE5MDEgMTcxLjM4NCwzNzEuNzUyOTkgMTcxLjM4NCwzNjQuODI5MDEgMjQ3LjY3OSw0MDkuOTYzOTkgCQkiLz4KCTwvZz4KCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik00NTguOTU1OTksMTExLjU4OUwyNzAuOTAzOTksMC42OTFMODQuOTcxLDExMS4wMjFsLTAuOTc5LDAuNTgydjc5LjYwNjAxbDUuMTMzLDIuODU1bC01LjEzMywyLjg1Njk5CgkJdjc3LjY3bDUuMDM2LDMuNTA0bC01LjAzOSwyLjYzNTk5djgwLjExNmwxODcuNDgzLDExMS4wNDNsMTg2LjUwNC0xMTAuNDYxbDAuOTgwMDEtMC41ODJWMjgyLjcxNmwtNC41NzgtMy4xMzUwMWw0LjU3OC0zLjIwNwoJCXYtODAuNDUzbC01LjEzMy0yLjg1Njk5bDUuMTMzLTIuODU1TDQ1OC45NTU5OSwxMTEuNTg5TDQ1OC45NTU5OSwxMTEuNTg5eiBNMjcwLjkxNTk5LDkuOTg2bDE3OC4wNzQ5OCwxMDUuMDEzTDI3MS40NzUwMSwyMjAuMTMxCgkJTDkzLjk1MiwxMTQuOTk1TDI3MC45MTU5OSw5Ljk4NnogTTQ0OC45NTkwMSwxMTkuNjY3djY2Ljg0NEwyNzMuNDc2MDEsMjkwLjQ1MnYtNjYuODU2TDQ0OC45NTkwMSwxMTkuNjY3eiBNMjY5LjQ3NTAxLDIyMy41OTU5OQoJCXY2Ni44NTVMOTMuOTkyLDE4Ni41MTF2LTY2Ljg0NEwyNjkuNDc1MDEsMjIzLjU5NTk5eiBNOTMuOTkyLDI3MC44Mjh2LTY2LjgzODk5bDE3NS40ODMsMTAzLjkyNTk5djY2Ljg1OTAxTDkzLjk5MiwyNzAuODI4egoJCSBNMjY5LjQ3NTAxLDQ1OS4wOTI5OWwtMTc1LjQ4My0xMDMuOTQ0di02Ni44NGwxNzUuNDgzLDEwMy45MjU5OVY0NTkuMDkyOTlMMjY5LjQ3NTAxLDQ1OS4wOTI5OXogTTk1LjEwNSwyODMuMzA0OTlsNS4xNzMtMy43MDgwMQoJCWwxNzAuMTcyOTksMTAwLjQwMzk5YzAuMDI2LDAuMDE1OTksMC4wNTYsMC4wMTk5OSwwLjA4MzAxLDAuMDM1YzAuMTA5OTksMC4wNTg5OSwwLjIyNTAxLDAuMTA2OTksMC4zNDI5OSwwLjE0NDk5CgkJYzAuMDQ5MDEsMC4wMTQwMSwwLjA5Njk4LDAuMDI3MDEsMC4xNDcsMC4wMzljMC4xNTEsMC4wMzUsMC4zMDQ5OSwwLjA2MSwwLjQ1OTk5LDAuMDYxYzAuMDM1LDAsMC4wNjktMC4wMTE5OSwwLjEwNTAxLTAuMDE0MDEKCQljMC4xMDgtMC4wMDgsMC4yMTYtMC4wMTk5OSwwLjMyMTAxLTAuMDQzYzAuMDc3LTAuMDE4MDEsMC4xNTEtMC4wNDA5OSwwLjIyNjk5LTAuMDY3OTkKCQljMC4wNjc5OS0wLjAyMzAxLDAuMTM1OTktMC4wNTMwMSwwLjIwMi0wLjA4NmMwLjA1Mzk5LTAuMDI0OTksMC4xMTItMC4wMzY5OSwwLjE2NC0wLjA2Nzk5bDE2Ny4zNDUtOTkuMTI2MDFsNi42NjEwMSw0LjIzODAxCgkJTDI3MS40NzUwMSwzODguNzcyTDk1LjEwNSwyODMuMzA0OTl6IE00NDguOTU5MDEsMzU1LjE0ODk5bC0xNzUuNDgzLDEwMy45NDR2LTY2Ljg1Njk5TDQ0OC45NTkwMSwyODguMzFWMzU1LjE0ODk5CgkJTDQ0OC45NTkwMSwzNTUuMTQ4OTl6IE00NDguOTU5MDEsMjcwLjgyOGwtMTc1LjQ4MywxMDMuOTQ1OTh2LTY2Ljg1OTAxbDE3NS40ODMtMTAzLjkyNTk5VjI3MC44MjhMNDQ4Ljk1OTAxLDI3MC44Mjh6CgkJIE0yNzEuNDc1MDEsMzA0LjQ1Mkw5My45NTIsMTk5LjMxNzk5bDYuOTAzLTQuMDkzbDE2OS41OTUsMTAwLjQ1Mjk5YzAuMTYyOTksMC4wOTYwMSwwLjMzNzAxLDAuMTY0LDAuNTE1OTksMC4yMTEKCQljMC4xNTYwMSwwLjA0MDk5LDAuMzE2MDEsMC4wNiwwLjQ3Njk5LDAuMDYyMDFjMC4wMDgsMCwwLjAxNTk5LDAuMDA0LDAuMDIzMDEsMC4wMDRjMCwwLDAsMCwwLjAwMTAxLDAKCQljMC4wMDUsMCwwLjAxMDAxLDAuMDAyMDEsMC4wMTUwMSwwLjAwMjAxYzAuMzUzLDAsMC43MDU5OS0wLjA5Mzk5LDEuMDE5OTktMC4yNzg5OWwxNjkuNTkzMDItMTAwLjQ1Mmw2LjkwMjAxLDQuMDkyCgkJTDI3MS40NzUwMSwzMDQuNDUyeiIvPgo8L2c+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "speech", "name": "speech", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyMDYuOHB4IiBoZWlnaHQ9IjIxMC42MDAwMXB4IiB2aWV3Qm94PSIwIDAgMjA2LjggMjEwLjYwMDAxIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMDYuOCAyMTAuNjAwMDEiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJSb29mdG9wXzFfIiBvcGFjaXR5PSIwLjEiPgoJPHBhdGggZmlsbD0iIzFFMUUxRSIgZD0iTTExNC4zLDIwOC4zOTk5OWwtNjEuMi0zNi44Yy0zLjItMS44OTk5OS0zLjItNi41LDAtOC4zOTk5OUw4MC4zLDE0Ni44YzEuNi0wLjg5OTk5LDMuNS0wLjg5OTk5LDUsMAoJCWw2Ni44LDQwYzMuMiwxLjg5OTk5LDMuMiw2LjUsMCw4LjM5OTk5bC0yMiwxMy4yQzEyNS4zLDIxMS4zLDExOS4yLDIxMS4zLDExNC4zLDIwOC4zOTk5OXoiLz4KPC9nPgo8cG9seWdvbiBkaXNwbGF5PSJub25lIiBmaWxsPSIjQ0REOUVFIiBwb2ludHM9IjEwMS42LDI2OC43MDAwMSAtODAuOCwxNTkuMTAwMDEgMTAxLDQ5LjYgMjg0LDE1OS4xMDAwMSAiLz4KPGc+Cgk8cGF0aCBmaWxsPSIjOEU4RThFIiBkPSJNMTQzLjEwMDAxLDE1My4zOTk5OWMtMC4xMDAwMSwwLjEwMDAxLTAuMiwwLjEwMDAxLTAuMywwLjJsLTQuNywyLjhMMTI3LjMsMTYzCgkJYy0xLDAuNjAwMDEtMi4yLDAuODk5OTktMy40LDAuODk5OTljLTEuMiwwLTIuMy0wLjMtMy4zLTAuODk5OTlMNTgsMTI1LjRMMzAuNCwxNDJ2LTMzLjJsLTAuNS0wLjNjLTMuNC0yLjEtNS42LTUuOC01LjYtOS45VjI1CgkJYzAtMi40LDEuMy00LjYsMy40LTUuN2wxNS05YzEuMS0wLjksMi42LTEuNCw0LTEuNGMxLjIsMCwyLjMsMC4zLDMuMywwLjlsMTUsOWMwLjItMi4yLDEuNC00LjIsMy40LTUuMmwxNS05CgkJYzEuMS0wLjksMi42LTEuNCw0LTEuNGMxLjIsMCwyLjMsMC4zLDMuMywwLjlsOTAuNjk5OTksNTQuNUMxODQuNzk5OTksNjAuNywxODcsNjQuNCwxODcsNjguNXY3My42MDAwMQoJCWMwLDIuMzk5OTktMS4zLDQuNjAwMDEtMy41LDUuOGwtMy44OTk5OSwyLjNsMC44LDIyLjg5OTk5TDE2Mi41LDE4NEwxNDMuMTAwMDEsMTUzLjM5OTk5eiIvPgoJPHBhdGggZmlsbD0iIzAwMDAwMCIgZD0iTTg3LjQsNC44YzAuOSwwLDEuNywwLjIsMi42LDAuN0wxODAuNyw2MGMzLDEuOCw0LjgsNS4xLDQuOCw4LjZ2NzMuNmMwLDItMS4yLDMuNy0yLjgsNC41bC00LjcsMi44bDAuOCwyMi44OTk5OUwxNjMsMTgyCgkJbC0xOS41LTMwLjhjLTAuMzk5OTksMC4zOTk5OS0wLjg5OTk5LDAuOC0xLjM5OTk5LDEuMTAwMDFsLTQuNywyLjhsLTEwLjksNi42MDAwMWwwLDBjLTAuOCwwLjUtMS43LDAuNy0yLjYsMC43cy0xLjctMC4yLTIuNi0wLjcKCQlsLTI1LjEtMTUuMTAwMDFsLTQzLjMtMjZsNSwzbC0yNi4xLDE1Ljd2LTMxLjRsLTEuMi0wLjdjLTMtMS44LTQuOC01LjEtNC44LTguNlYyNC45YzAtMiwxLjEtMy42LDIuNi00LjRsMCwwbDAsMGwwLDBsMTUuMS05LjEKCQljMC45LTAuNywyLTEuMSwzLjEtMS4xYzAuOSwwLDEuNywwLjIsMi42LDAuN2wxNy4zLDEwLjR2LTIuMWMwLTIsMS4xLTMuNiwyLjYtNC40bDAsMGwwLDBsMCwwbDE1LjEtOS4xCgkJQzg1LjIsNS4yLDg2LjMsNC44LDg3LjQsNC44IE04Ny40LDEuOGMtMS43LDAtMy40LDAuNi00LjgsMS42bC0xNC45LDguOWMtMS43LDAuOS0zLDIuNC0zLjYsNC4ybC0xMy4zLThjLTEuMy0wLjgtMi43LTEuMS00LjEtMS4xCgkJQzQ1LDcuNCw0My4zLDgsNDEuOSw5TDI3LDE3LjljLTIuNiwxLjQtNC4yLDQuMS00LjIsN3Y3My42YzAsNC41LDIuMyw4LjcsNi4xLDExdjI5Ljd2NS4zbDQuNS0yLjdMNTgsMTI3LjFsMzYuNywyMi4xTDExOS44LDE2NC4zCgkJYzEuMiwwLjgsMi43LDEuMTAwMDEsNC4xLDEuMTAwMDFzMi45LTAuMzk5OTksNC4xLTEuMTAwMDFsMTAuOS02LjYwMDAxbDMuNy0yLjJsMTcuODk5OTksMjguMTAwMDFsMS42MDAwMSwyLjVsMi41LTEuNQoJCUwxODAuNDk5OTgsMTc1bDEuNS0wLjg5OTk5bC0wLjEwMDAxLTEuOGwtMC43LTIxLjEwMDAxbDMuMTAwMDEtMS44YzIuNy0xLjM5OTk5LDQuMy00LjEwMDAxLDQuMy03LjEwMDAxVjY4LjYKCQljMC00LjUtMi4zOTk5OS04LjgtNi4zLTExLjFMOTEuNSwzQzkwLjMsMi4yLDg4LjksMS44LDg3LjQsMS44TDg3LjQsMS44eiIvPgo8L2c+CjxnIGlkPSJTcGVlY2hfQnViYmxlXzFfM18iPgoJPHBhdGggZmlsbD0iI0JCQjhCOSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTE0MCw2NS42TDQ5LjIsMTEuMQoJCWMtMS45LTEuMi00LjEtMC44LTUuNywwLjRsLTE1LjEsOS4xYzEuNS0wLjgsMy4zLTAuOSw0LjksMC4xTDEyNCw3NS4yYzMsMS44LDQuOCw1LjEsNC44LDguNnY3My42YzAsMS44OTk5OS0xLDMuMzk5OTktMi40LDQuMwoJCWwwLDBsMTAuOS02LjYwMDAxbDQuNy0yLjhjMS42MDAwMS0wLjgsMi44LTIuMzk5OTksMi44LTQuNVY3NC4yQzE0NC44LDcwLjcsMTQzLDY3LjQsMTQwLDY1LjZ6Ii8+Cgk8cGF0aCBmaWxsPSIjRTBFMEUwIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTI0LjEsNzUuMkwzMy40LDIwLjcKCQljLTMuMy0yLTcuNiwwLjQtNy42LDQuM3Y3My42YzAsMy41LDEuOCw2LjgsNC44LDguNmwxLjIsMC43djMxLjM5OTk5TDUzLDEyMC42bDQzLjMsMjYuMDAwMDFsMjUuMSwxNS4xMDAwMQoJCWMzLjMsMiw3LjYtMC4zOTk5OSw3LjYtNC4zVjgzLjhDMTI4Ljg5OTk5LDgwLjIsMTI3LjEsNzcsMTI0LjEsNzUuMnoiLz4KCTxwb2x5Z29uIGZpbGw9IiM3Rjk1QUMiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzMuNSw0NC45IDMzLjUsNTIuNSAxMDIuNyw5NCAKCQkxMDIuNyw4Ni41IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiM3Rjk1QUMiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMzMuNSw2My41IDMzLjUsNzEgMTE0LjksMTE5LjkgCgkJMTE0LjksMTEyLjMgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzdGOTVBQyIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIzMy41LDgyIDMzLjUsODkuNiA4NC42LDEyMC4zIAoJCTg0LjYsMTEyLjggCSIvPgoJPHBhdGggZmlsbD0iIzhFOEU4RSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjEuMjUiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTE0My42MDAwMSw2OS41bC0xNi4yLDlsMCwwCgkJYzEsMS42LDEuNSwzLjQsMS41LDUuM3Y3My41OTk5OWMwLDEuODk5OTktMSwzLjM5OTk5LTIuNCw0LjNsMCwwbDEwLjktNi42MDAwMWw0LjctMi44YzEuNjAwMDEtMC44LDIuOC0yLjM5OTk5LDIuOC00LjVWNzQuMgoJCUMxNDQuOCw3Mi41LDE0NC4zOTk5OSw3MC45LDE0My42MDAwMSw2OS41eiIvPgo8L2c+Cjxwb2x5Z29uIGZpbGw9IiM3Nzc3NzciIHBvaW50cz0iMTYyLjEwMDAxLDE1Ni4xMDAwMSAxNjMsMTgyIDE3OC44OTk5OSwxNzIuMzk5OTkgMTc4LjEwMDAxLDE0OS41ICIvPgo8ZyBpZD0iU2hhZG93Ij4KCTxwYXRoIGZpbGw9IiM1QjVCNUIiIGQ9Ik03MS40LDEwMS42Ii8+Cgk8cG9seWxpbmUgb3BhY2l0eT0iMC41IiBmaWxsPSIjMUUxRTFFIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI3MS40LDEwMS42IDkwLjUsMTM0LjggMTI4Ljg5OTk5LDE1OC41IAoJCTE0NC44OTk5OSwxNDguODk5OTkgMTQ0LjIsMTQ1LjMgNzEuNCwxMDEuNiAJIi8+CjwvZz4KPGcgaWQ9IlNwZWVjaF9CdWJibGVfMV8yXyI+Cgk8cG9seWdvbiBmaWxsPSIjNzc3Nzc3IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjE2Mi4xMDAwMSwxNTYuMTAwMDEgMTYzLDE4MiAKCQkxNzguODk5OTksMTcyLjM5OTk5IDE3OC4xMDAwMSwxNDkuNSAJIi8+Cgk8cGF0aCBmaWxsPSIjQkJCOEI5IiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTgwLjcsNjAuMUw5MCw1LjVjLTEuOS0xLjItNC4xLTAuOC01LjcsMC40CgkJTDY5LjIsMTVjMS41LTAuOCwzLjMtMC45LDQuOSwwLjFsOTAuNjk5OTksNTQuNWMzLDEuOCw0LjgsNS4xLDQuOCw4LjZ2NzMuNTk5OTljMCwxLjg5OTk5LTEsMy4zOTk5OS0yLjM5OTk5LDQuM2wwLDAKCQlsMTAuODk5OTktNi42MDAwMWw0LjctMi44YzEuNjAwMDEtMC44LDIuOC0yLjM5OTk5LDIuOC00LjVWNjguNkMxODUuNjAwMDEsNjUuMSwxODMuNyw2MS45LDE4MC43LDYwLjF6Ii8+Cgk8cGF0aCBmaWxsPSIjRkZGRkZGIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBkPSJNMTY0LjgsNjkuNkw3NC4xLDE1LjEKCQljLTMuMy0yLTcuNiwwLjQtNy42LDQuM1Y5M2MwLDMuNSwxLjgsNi44LDQuOCw4LjZMMTM3LDE0MWwyNiw0MWwtMC44OTk5OS0yNS44OTk5OWMzLjMsMiw3LjYwMDAxLTAuMzk5OTksNy42MDAwMS00LjNWNzguMgoJCUMxNjkuNyw3NC43LDE2Ny44LDcxLjQsMTY0LjgsNjkuNnoiLz4KCTxwb2x5Z29uIGZpbGw9IiM3Rjk1QUMiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iODYuNiw0Ni41IDg2LjYsNTQgMTU1LjgsOTUuNiAKCQkxNTUuOCw4OCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjN0Y5NUFDIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9Ijc0LjMsNTcuOSA3NC4zLDY1LjQgCgkJMTU1LjYwMDAxLDExNC4zIDE1NS42MDAwMSwxMDYuOCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjN0Y5NUFDIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMS4yNSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjEwNC43LDk1IDEwNC43LDEwMi41IDE1NS44LDEzMy4zIAoJCTE1NS44LDEyNS43IAkiLz4KCTxwYXRoIGZpbGw9IiM4RThFOEUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxLjI1IiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIGQ9Ik0xODQuMzk5OTksNjMuOWwtMTYuMiw5bDAsMAoJCWMxLDEuNiwxLjUsMy40LDEuNSw1LjN2NzMuNmMwLDEuODk5OTktMSwzLjM5OTk5LTIuMzk5OTksNC4zbDAsMEwxNzguMiwxNDkuNWw0LjctMi44YzEuNjAwMDEtMC44LDIuOC0yLjM5OTk5LDIuOC00LjVWNjguNgoJCUMxODUuNjAwMDEsNjYuOSwxODUuMTAwMDEsNjUuMywxODQuMzk5OTksNjMuOXoiLz4KPC9nPgo8cG9seWdvbiBmaWxsPSIjOEU4RThFIiBwb2ludHM9IjMxLjksMTM5LjMgNTgsMTIzLjYgNTMsMTIwLjYgIi8+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "sphere", "name": "sphere", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB3aWR0aD0iMjQyLjNweCIgaGVpZ2h0PSIxOTIuMnB4IiB2aWV3Qm94PSIwIDAgMjQyLjMgMTkyLjIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDI0Mi4zIDE5Mi4yIiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHBhdGggb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgZD0iTTEzNSwxMDEuOGMtNCwwLTcuOSwwLjEtMTEuNywwLjR2NjkuNjk5OTljMy44LDAuMiw3LjcsMC4zOTk5OSwxMS43LDAuMzk5OTkKCWM0NS4zOTk5OSwwLDgyLjItMTUuOCw4Mi4yLTM1LjJDMjE3LjIsMTE3LjUsMTgwLjM5OTk5LDEwMS44LDEzNSwxMDEuOHoiLz4KPGNpcmNsZSBmaWxsPSIjREFFMkVGIiBjeD0iMTIxLjYiIGN5PSI5NS44IiByPSI3NS40Ii8+CjxwYXRoIGZpbGw9IiM2RDg1QTYiIGQ9Ik0xODgsOTEuMmMwLTM4LjQtMzAuNS02OS42LTY4LjYtNzAuN2MtMzguNywxLjEtNzAsMzEuMy03Myw2OS41YzAsMC40LDAsMC44LDAsMS4yCgljMCwzOS4wOTk5OSwzMS43LDcwLjgsNzAuOCw3MC44UzE4OCwxMzAuMywxODgsOTEuMnoiLz4KPHBhdGggZmlsbD0iI0I4QzVEQSIgZD0iTTE3NS42MDAwMSw3Ny45YzAtMTYuNS02LTMxLjYtMTUuODk5OTktNDMuM2MtMTEuMy04LjUtMjUuMi0xMy43LTQwLjQtMTQuMkMxMDAsMjEsODIuNSwyOC44LDY5LjUsNDEuM2wwLDAKCUM1Ni41LDUzLjgsNDcuOSw3MC45LDQ2LjQsOTBsMCwwYzAsMC40LDAsMC44LDAsMS4yYzAsNC45LDAuNSw5LjcsMS40LDE0LjNjMTAuNSwyMy4yLDMzLjgsMzkuMyw2MC45LDM5LjMKCUMxNDUuNywxNDQuOCwxNzUuNjAwMDEsMTE0LjgsMTc1LjYwMDAxLDc3Ljl6Ii8+CjxwYXRoIGZpbGw9IiNDRkQ5RUMiIGQ9Ik02OS41LDQxLjNMNjkuNSw0MS4zQzU5LjMsNTEsNTEuOSw2My41LDQ4LjQsNzcuNUM1OC40LDk0LjYsNzYuOSwxMDYsOTgsMTA2YzMxLjgsMCw1Ny41LTI1LjcsNTcuNS01Ny41CgljMC02LjYtMS4xMDAwMS0xMi45LTMuMi0xOC44Yy05LjgtNS42LTIxLTktMzMtOS40QzEwMCwyMSw4Mi41LDI4LjgsNjkuNSw0MS4zeiIvPgo8Y2lyY2xlIGlkPSJPdXRsaW5lIiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBjeD0iMTIxLjYiIGN5PSI5NS44IiByPSI3NS40Ii8+Cjwvc3ZnPgo=", "isIsometric": true, "collection": "isoflow" }, { "id": "storage", "name": "storage", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI1NjcuMTE3OThweCIgaGVpZ2h0PSI1NTQuNTg2cHgiIHZpZXdCb3g9IjAgMCA1NjcuMTE3OTggNTU0LjU4NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgNTY3LjExNzk4IDU1NC41ODYiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPGc+CgkJPHBhdGggb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgZD0iTTUyNy44NjEwMiwzNjcuMjYxOTljLTAuMzYyOTgsNjQuMjQzOTktOTAuNjE3OTgsMTE1LjY3My0yMDEuNTkxLDExNC44NwoJCQljLTExMC45NzQtMC44MDItMjAwLjY0MTAxLTUzLjUzMjAxLTIwMC4yNzkwMS0xMTcuNzc2YzAuMzYzLTY0LjI0Miw5MC42MTgtMTE1LjY3MDk5LDIwMS41OTItMTE0Ljg2OQoJCQlDNDM4LjU1NiwyNTAuMjkxLDUyOC4yMjMwMiwzMDMuMDE5OTksNTI3Ljg2MTAyLDM2Ny4yNjE5OXoiLz4KCQk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMjgyLjc0MiwzOTEuMDQzYy05NC44MzgtMC42MDU5OS0xNzEuNS0zNS40MDI5OC0xNzEuMjMtNzcuNzIxMDFsLTAuNDU5LDcxLjg2MzAxCgkJCWMtMC4zMSw0OC41MTQwMSw3Ni4zMjAwMSw4OC4zMzMwMSwxNzEuMTU3OTksODguOTM5Yzk0LjgzNzAxLDAuNjA1OTksMTcxLjk3LTM4LjIzMDk5LDE3Mi4yNzg5OS04Ni43NDVsMC40NTkwMS03MS44NjMwMQoJCQlDNDU0LjY3OTk5LDM1Ny44MzMwMSwzNzcuNTc5OTksMzkxLjY0ODAxLDI4Mi43NDIsMzkxLjA0M3oiLz4KCQk8cGF0aCBmaWxsPSIjQzNENUVBIiBkPSJNMjAzLjI5MSw0NjMuNzkxOTljNy40MDQwMSwyLjAxNywxNS4xMjUsMy43NjU5OSwyMy4xMTcsNS4yMjRsMC41MjY5OS04Mi40NzUwMQoJCQljLTcuOTkzLTEuMjc4OTktMTUuNzE1LTIuODEyMDEtMjMuMTIxLTQuNTc3TDIwMy4yOTEsNDYzLjc5MTk5eiIvPgoJCTxwYXRoIGZpbGw9IiNDM0Q1RUEiIGQ9Ik0xMTEuNTEzLDMxMy4zMjEwMWwtMC40NTksNzEuODYzMDFjLTAuMTk4LDMxLjAzOSwzMS4xMDAwMSw1OC41MTgwMSw3OC41MTEsNzQuNDQ2OTlsMC41MTktODEuMzA2CgkJCUMxNDIuNjYxLDM2NC4zOTIsMTExLjM0LDM0MC4zOTcsMTExLjUxMywzMTMuMzIxMDF6Ii8+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTI4My40MjU5OSwyODMuOTcyOTljLTk0LjgzOC0wLjYwNTk5LTE3MS41LTM1LjQwMy0xNzEuMjMtNzcuNzIxMDFsLTAuNDU5LDcxLjg2MzAxCgkJCWMtMC4zMSw0OC41MTQwMSw3Ni4zMjAwMSw4OC4zMzYsMTcxLjE1ODAyLDg4Ljk0MTAxYzk0LjgzNzAxLDAuNjA1OTksMTcxLjk3LTM4LjIzMywxNzIuMjc4OTktODYuNzQ3MDFsMC40NTkwMS03MS44NjMwMQoJCQlDNDU1LjM2NDAxLDI1MC43NjQwMSwzNzguMjY0MDEsMjg0LjU3OTAxLDI4My40MjU5OSwyODMuOTcyOTl6Ii8+CgkJPHBhdGggZmlsbD0iI0MzRDVFQSIgZD0iTTE5MC4yNDg5OSwzNTIuNTYyMDFsMC41MTktODEuMzA3MDFjLTQ3LjQyMy0xMy45MzEtNzguNzQ0LTM3LjkyODAxLTc4LjU3MS02NS4wMDI5OWwtMC40NTksNzEuODYzMDEKCQkJQzExMS41MzksMzA5LjE1NSwxNDIuODM4LDMzNi42MzMsMTkwLjI0ODk5LDM1Mi41NjIwMXoiLz4KCQk8cGF0aCBmaWxsPSIjQzNENUVBIiBkPSJNMjAzLjk3NTAxLDM1Ni43MjE5OGM3LjQwNDAxLDIuMDE3LDE1LjEyNSwzLjc2NTk5LDIzLjExNyw1LjIyNGwwLjUyNjk5LTgyLjQ3NjAxCgkJCWMtNy45OTMtMS4yNzgwMi0xNS43MTUtMi44MTEtMjMuMTIxLTQuNTc1OTlMMjAzLjk3NTAxLDM1Ni43MjE5OHoiLz4KCQk8cGF0aCBmaWxsPSIjQ0REOUVFIiBkPSJNNDU2LjM1MDAxLDk2LjMyYy0wLjMxLDQ4LjUxNC03Ny40NDE5OSw4Ny4zNTA5OS0xNzIuMjc5MDIsODYuNzQ1CgkJCWMtOTQuODM4LTAuNjA2LTE3MS40NjgtNDAuNDI1LTE3MS4xNTgtODguOTM5YzAuMzEtNDguNTEzLDc3LjQ0Mi04Ny4zNSwxNzIuMjgwMDEtODYuNzQ0CgkJCUMzODAuMDMxMDEsNy45ODgsNDU2LjY2LDQ3LjgwNyw0NTYuMzUwMDEsOTYuMzJ6Ii8+CgkJPHBhdGggZmlsbD0iIzM2NUU3RiIgZD0iTTI4My41OTYwMSwyNTcuNDg4MDFjLTgxLjI5My0wLjUxOTAxLTE0OS4yLTI5Ljg1MS0xNjYuNzQ2OTktNjguNzczMDEKCQkJYy0zLjAwMiw1LjYyMTk5LTQuNjE0LDExLjQ5Mi00LjY1MiwxNy41MzZjLTAuMjcsNDIuMzE4MDEsNzYuMzkxLDc3LjExNjAxLDE3MS4yMjk5OCw3Ny43MjA5OQoJCQljOTQuODM3MDEsMC42MDU5OSwxNzEuOTM3MDEtMzMuMjA5LDE3Mi4yMDgwMS03NS41MjhjMC4wMzktNi4wNDQwMS0xLjQ5Nzk5LTExLjkzNDAxLTQuNDI4MDEtMTcuNTkzOTkKCQkJQzQzMy4xNjQsMjI5LjU0NSwzNjQuODg2OTksMjU4LjAwNjk5LDI4My41OTYwMSwyNTcuNDg4MDF6Ii8+CgkJPHBhdGggZmlsbD0iIzM2NUU3RiIgZD0iTTI4Mi44OTAwMSwzNjcuOTdjLTgyLjU1ODk5LTAuNTI3MDEtMTUxLjMwOTAxLTMwLjc3MzAxLTE2Ny41MjYtNzAuNjAwOTgKCQkJYy0yLjQ4Myw1LjEzOC0zLjgxNiwxMC40NzI5OS0zLjg1MSwxNS45NTJjLTAuMjcsNDIuMzE3OTksNzYuMzkxMDEsNzcuMTE2LDE3MS4yMyw3Ny43MjEwMQoJCQljOTQuODM3MDEsMC42MDU5OSwxNzEuOTM2OTgtMzMuMjA5MDEsMTcyLjIwNzk4LTc1LjUyNzk4YzAuMDM1LTUuNDc5LTEuMjMwMDEtMTAuODMwOTktMy42NDctMTYKCQkJQzQzNC41NzgsMzM5LjEzMywzNjUuNDQ4LDM2OC40OTc5OSwyODIuODkwMDEsMzY3Ljk3eiIvPgoJCTxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zMDMuMzE5LDE4MS4wMTgwMWM0LjY5Njk5LDAuMDMsOS4yMzk5OSwwLjIxNCwxMy44Mzg5OSwwLjA0NQoJCQljLTgzLjk1My00LjE1Ny0xNDkuNjYxLTQyLjY5Mi0xNDkuMzgxLTg2LjU4NmMwLjI4LTQzLjg5Myw2Ni40NzMwMS04MS4yNTYsMTUwLjQ3MzAxLTg0LjM0CgkJCWMtNC41OTYwMS0wLjIyOS05LjI0Ni0wLjA1OS0xMy45NDE5OS0wLjA4OWMtMTAuNTY1LTAuMTMzLTIwLjk5MzAxLDAuMzM3LTMxLjE2NTk5LDEuMzQKCQkJYzYuNTQxOTktMC42ODQsMTMuMjIyOTktMS4xNjIsMjAuMDIzMDEtMS40MTFjLTQuNTk2MDEtMC4yMjktOS4yNDYtMC4wNTktMTMuOTQxOTktMC4wODkKCQkJYy04Ni41NDEtMS4wODYtMTY0LjEyMSwzNy45NTQtMTY0LjQxNzAxLDg0LjI1MWMtMC4yOTYsNDYuMjk4LDc2LjU0Mzk5LDg2LjE2NCwxNjMuNDI3OTksODYuNzE4OTkKCQkJYzQuNjk2OTksMC4wMyw5LjIzOTk5LDAuMjE0LDEzLjgzODk5LDAuMDQ1Ii8+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTI4NC4wNzEwMSwxODMuMDY1OTljLTk0LjgzOC0wLjYwNi0xNzEuNDY4LTQwLjQyNS0xNzEuMTU4LTg4LjkzOWwtMC40NzksNzQuOTA2CgkJCWMtMC4zMSw0OC41MTQwMSw3Ni4zMiw4OC4zMzQwMSwxNzEuMTU3OTksODguOTRjOTQuODM3MDEsMC42MDU5OSwxNzEuOTctMzguMjMxOTksMTcyLjI3OTAyLTg2Ljc0NmwwLjQ3OC03NC45MDYKCQkJQzQ1Ni4wNDAwMSwxNDQuODM0LDM3OC45MDc5OSwxODMuNjcxMDEsMjg0LjA3MTAxLDE4My4wNjU5OXoiLz4KCQk8cGF0aCBmaWxsPSIjQzNENUVBIiBkPSJNMTkwLjk0NTAxLDI0My40NzlsMC40NzktNzQuOTA2MDFjLTQ3LjQxMS0xNS45Mjc5OS03OC43MDktNDMuNDA3LTc4LjUxMS03NC40NDdsLTAuNDc5LDc0LjkwNgoJCQlDMTEyLjIzNiwyMDAuMDcyMDEsMTQzLjUzNSwyMjcuNTUwOTksMTkwLjk0NTAxLDI0My40Nzl6Ii8+CgkJPHBhdGggZmlsbD0iI0MzRDVFQSIgZD0iTTIyNy43ODc5OSwyNTIuODYybDAuNDc5LTc0LjkwNjAxYy03Ljk5Mi0xLjQ1Nzk5LTE1LjcxMjAxLTMuMjA3LTIzLjExNy01LjIyMzAxbC0wLjQ3OSw3NC45MDU5OQoJCQlDMjEyLjA3NiwyNDkuNjU1LDIxOS43OTcsMjUxLjQwNDAxLDIyNy43ODc5OSwyNTIuODYyeiIvPgoJCTxwYXRoIGZpbGw9Im5vbmUiIGQ9Ik0xOTAuNzY4MDEsMjcxLjI1NWM0LjQzOSwxLjMwNDk5LDkuMDIyLDIuNTE5OTksMTMuNzMsMy42NDAwMWwwLjE3NC0yNy4yNTYKCQkJYy00LjcwNy0xLjI4My05LjI4OS0yLjY2OTAxLTEzLjcyNzAxLTQuMTZMMTkwLjc2ODAxLDI3MS4yNTV6Ii8+CgkJPHBhdGggZmlsbD0ibm9uZSIgZD0iTTE5MC4wODQsMzc4LjMyNTk5YzQuNDM5LDEuMzAzOTksOS4wMjIsMi41MTcsMTMuNzMsMy42MzkwMWwwLjE2MS0yNS4yNDMwMQoJCQljLTQuNzA3LTEuMjgyMDEtOS4yODktMi42NjgtMTMuNzI3MDEtNC4xNkwxOTAuMDg0LDM3OC4zMjU5OXoiLz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNNDU5LjA3NDAxLDE5MC45Njg5OWMzLjg2Mi03LjY0NSwzLjcxMzAxLTE5LjIyNzAxLDMuNzA0MDEtMTkuNjk5MDFsMC40NzgtNzQuODMyCgkJCWMwLjI1NS0yLjE0NiwyLjk4ODAxLTMyLjAwNS0zNy4yMTc5OS01OS42NTNDMzgyLjkxLDcuMTI4LDMyMy41MTE5OSwwLjMxNCwzMDQuMjkwOTksMC4xOTEKCQkJQzMwNC4yMzAwMSwwLjE4OSwyOTguODQsMC4wMDMsMjg1LjE5MTk5LDBjLTEzLjY0Ni0wLjE3MS0xOS4wMzIwMS0wLjA1NC0xOS4wNzE5OS0wLjA1MwoJCQljLTE5LjI0OC0wLjEyMy03OC43MjgsNS45MzItMTIyLjIzMSwzNS4wMzZjLTQwLjU1NiwyNy4xMzItMzguMjA1LDU3LjAyNC0zNy45NzcwMSw1OS4xNzNsLTAuNDc4LDc0LjgwMjAxCgkJCWMtMC4wMTUsMC41MDEwMS0wLjMxMywxMi4wODA5OSwzLjQ1MiwxOS43NzQ5OWMtNC4yMDEsOS4yNTEwMS0zLjcxMSwxNy4yMjUwMS0zLjY5LDE3LjQ3NGwtMC40NTksNzEuODYzMDEKCQkJYy0wLjA2OCwxMC42MjM5OSwyLjI1MSwxNi43OTMsMy4wNzMsMTguNjQwMDFjLTAuODU3LDEuODY0MDEtMy4yNDUsOC4yMDMtMy4zMjYsMjFsLTAuNDE2LDY1LjQ2MzAxCgkJCWMtMC4wNjEsMC45MzIwMS0xLjMwMywyMy4wNiwxNS44MjgsNDIuNTAyOTljMjcuOTE2LDMxLjY4MSw3Ni4wNTcwMSw1MC4zOTk5OSwxNDMuMTE2LDU1LjYzOAoJCQljMC4wOTY5OCwwLjAwNSw2LjUyODk5LDAuMjc2LDE0LjUxOTAxLDAuMzI3YzEuNDgwOTksMC4wMDksMy4wMjM5OSwwLjAwOCw0LjU4MzAxLTAuMDAxMDEKCQkJYzEuNTU3MDEsMC4wMywzLjEwMDAxLDAuMDQ5OTksNC41ODIsMC4wNmM3Ljk5NSwwLjA1MDk5LDE0LjQ0MTAxLTAuMTQwMDEsMTQuNTU4MDEtMC4xNDMwMQoJCQljNjcuMDktNC4zNzksMTE1LjQ2Ni0yMi40ODE5OSwxNDMuNzg0LTUzLjgwNDAyYzE3LjM3OS0xOS4yMjE5OCwxNi40MTkwMS00MS4zNjQ5OSwxNi4zNzIwMS00Mi4yMjI5OWwwLjQxOTAxLTY1LjUzNzAyCgkJCWMwLjA4Mi0xMi43OTgtMi4yMjUwMS0xOS4xNjYwMi0zLjA1ODAxLTIxLjA0MTAyYzAuODQ2MDEtMS44MzYsMy4yNDMwMS03Ljk3NTAxLDMuMzExLTE4LjU5OWwwLjQ1NDAxLTcxLjc2OQoJCQlDNDYyLjU2NCwyMDguMjQyLDQ2My4xNTYwMSwyMDAuMjcyOTksNDU5LjA3NDAxLDE5MC45Njg5OXogTTExMi45NDYsMzg1LjE5NjAxbDAuMzUxLTU0Ljk5NgoJCQljNi45MTMsMTQuOTcsMjMuMTQ5OTksMjguNjg5LDQ3LjQxMSwzOS43MDAwMWMzMi41Njk5OSwxNC43ODQsNzUuOTA0MDEsMjMuMDg3MDEsMTIyLjAyLDIzLjM4MTk5CgkJCWM0Ni4xMTQ5OSwwLjI5NTAxLDg5LjU1MDk5LTcuNDU0MDEsMTIyLjMwNzAxLTIxLjgyMTAxYzI0LjM5OTk5LTEwLjcwMDAxLDQwLjgxMS0yNC4yMTEsNDcuOTE1MDEtMzkuMDkyMDFsLTAuMzUxMDEsNTQuOTk1CgkJCWMtMC4zMDIsNDcuMjY3LTc2LjczMTAyLDg1LjIzNTAyLTE3MC4zNzI5OSw4NC42MzY5OUMxODguNTgyLDQ3MS40MDUsMTEyLjY0NCw0MzIuNDYzMDEsMTEyLjk0NiwzODUuMTk2MDF6IE0yODUuMTc4OTksOS42MjMKCQkJYzkzLjU4MiwwLjU5OCwxNjkuNDcyOTksMzkuNDg1LDE2OS4xNzIsODYuNjg1Yy0wLjMwMiw0Ny4yMDEtNzYuNjgzMDEsODUuMTE1MDEtMTcwLjI2NDk4LDg0LjUxNzk5CgkJCWMtOTMuNTgzMDEtMC41OTgwMS0xNjkuNDc0LTM5LjQ4NS0xNjkuMTczLTg2LjY4NkMxMTUuMjE0LDQ2LjkzOSwxOTEuNTk1LDkuMDI1LDI4NS4xNzg5OSw5LjYyM3ogTTE2MS45NjgsMTU4LjU5NQoJCQljMzIuNTgyLDE2LjkyOTk5LDc1Ljk0MDk5LDI2LjQxNjk5LDEyMi4wODksMjYuNzExYzQ2LjE0ODAxLDAuMjk1LDg5LjYyMzk5LTguNjM2OTksMTIyLjQxOTAxLTI1LjE0OTk5CgkJCWMyNC4zNjQ5OS0xMi4yNjgwMSw0MC43NTI5OS0yNy42OTgsNDcuODU5MDEtNDQuNjVsLTAuMzU1OTksNTUuNzA3MDFjLTAuMDMyOTksNS4xODMtMC45ODMsMTAuMjU1LTIuNzY5MDEsMTUuMTc1OTkKCQkJbC0wLjA5NS0wLjE4MjAxbC0xLjY4MjAxLDMuNjA4Yy04LjU3OTAxLDE4LjM5OTk5LTI5LjUyNzk4LDM1LjA3NDAxLTU4Ljk4ODk4LDQ2Ljk1MgoJCQljLTMwLjE3NDAxLDEyLjE2NC02OC4xMTYsMTguNzI4LTEwNi44MzQ5OSwxOC40OGMtMzguNzItMC4yNDY5OS03Ni41NzUtNy4yOTUtMTA2LjU5MS0xOS44NDM5OQoJCQljLTI5LjMwNzAxLTEyLjI1MzAxLTUwLjA0Mi0yOS4xOTMwMS01OC4zODQ5OS00Ny43MDFsLTEuNjM2LTMuNjI5bC0wLjA5NywwLjE4MWMtMS43MjMtNC45NDI5OS0yLjYwOC0xMC4wMjcwMS0yLjU3NS0xNS4yMTAwMQoJCQlsMC4zNTYtNTUuNzA3QzEyMS41NzIsMTMwLjM3ODAxLDEzNy43NjEsMTQ2LjAxNjAxLDE2MS45NjgsMTU4LjU5NXogTTQ1My42MzQsMjA4LjQzMjAxCgkJCWMtMC4xMjUsMTkuNDg4MDEtMTcuNjU3MDEsMzcuODc5LTQ5LjM2ODAxLDUxLjc4Njk5QzM3MS45NywyNzQuMzgzLDMyOS4wNiwyODIuMDIzMDEsMjgzLjQ0LDI4MS43MzE5OQoJCQljLTQ1LjYyMS0wLjI5MDk5LTg4LjQyOTk5LTguNDc5LTEyMC41NDE5OS0yMy4wNTQwMmMtMzEuNTMtMTQuMzEyLTQ4LjgyNi0zMi45MjU5OS00OC43MDItNTIuNDE0CgkJCWMwLjAyNy00LjI1MzAxLDAuOTEtOC41MDUsMi42MjEtMTIuNjg3YzcuOTA0LDE0LjkyLDIzLjEwODk5LDI4LjU4Niw0NC43MTcsMzkuODEzCgkJCWMzMi41NjcsMTYuOTI0LDc1LjkxMSwyNi40MDcwMSwxMjIuMDQzOTksMjYuNzAyczg5LjU5Mzk5LTguNjM0LDEyMi4zNzUtMjUuMTQKCQkJYzIxLjc0ODk5LTEwLjk1LDM3LjEyNzAxLTI0LjQyMTAxLDQ1LjIyMTAxLTM5LjIzNzk5QzQ1Mi44MzMwMSwxOTkuOTE0OTksNDUzLjY2MTAxLDIwNC4xNzksNDUzLjYzNCwyMDguNDMyMDF6CgkJCSBNMTYxLjM5MzAxLDI2Mi44MzA5OWMzMi41NzAwMSwxNC43ODI5OSw3NS45MDUsMjMuMDg4MDEsMTIyLjAxOTk5LDIzLjM4MTk5CgkJCWM0Ni4xMTQ5OSwwLjI5NTAxLDg5LjU1MDk5LTcuNDU1OTksMTIyLjMwNzAxLTIxLjgyMTAxYzI0LjM5OTk5LTEwLjcwMSw0MC44MTEtMjQuMjExLDQ3LjkxNTAxLTM5LjA5MWwtMC4zNTEwMSw1NC45OTUKCQkJYy0wLjMwMiw0Ny4yNjkwMS03Ni43MzEwMiw4NS4yMzctMTcwLjM3Mjk5LDg0LjYzOTAxYy05My42NDMwMS0wLjU5Nzk5LTE2OS41ODA5OS0zOS41NDAwMS0xNjkuMjc5MDEtODYuODA4OTlsMC4zNTEtNTQuOTk1CgkJCUMxMjAuODk1LDIzOC4xMDEsMTM3LjEzMSwyNTEuODE5LDE2MS4zOTMwMSwyNjIuODMwOTl6IE00NTIuOTUwMDEsMzE1LjUwMjAxYy0wLjEyNSwxOS40ODctMTcuNjU3MDEsMzcuODc5LTQ5LjM2ODAxLDUxLjc4NjAxCgkJCWMtMzIuMjk1MDEsMTQuMTY0LTc1LjIwNTk5LDIxLjgwNDk5LTEyMC44MjU5OSwyMS41MTNjLTQ1LjYyMS0wLjI5MDk5LTg4LjQyOTk5LTguNDgwMDEtMTIwLjU0MTk5LTIzLjA1NDk5CgkJCWMtMzEuNTMtMTQuMzExLTQ4LjgyNi0zMi45MjU5OS00OC43MDItNTIuNDEyOTljMC4wMjMtMy42MzE5OSwwLjY3Mi03LjI3MzAxLDEuOTMxLTEwLjg2ODAxCgkJCWM5LjI2MSwxOC4zMzA5OSwyOS42NzcsMzQuOTA3OTksNTguMDkxLDQ3LjAzNWMzMC42OTI5OSwxMy4xMDAwMSw2OS41MjQ5OSwyMC40NTU5OSwxMDkuMzQxLDIwLjcwOTk5CgkJCXM3OC43Mzg5OC02LjYwNTAxLDEwOS41OTYwMS0xOS4zMTIwMWMyOC41NjY5OS0xMS43NjMsNDkuMTkyOTktMjguMDc3LDU4LjY4NzAxLTQ2LjI4OQoJCQlDNDUyLjM3MjAxLDMwOC4yMjEwMSw0NTIuOTcyOTksMzExLjg3LDQ1Mi45NTAwMSwzMTUuNTAyMDF6Ii8+Cgk8L2c+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "switch-module", "name": "switch-module", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI3MDEuNDAwMDJweCIgaGVpZ2h0PSI0NzdweCIgdmlld0JveD0iMCAwIDcwMS40MDAwMiA0NzciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDcwMS40MDAwMiA0NzciIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8Zz4KCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik0xMDQzLjgwMDA1LDI0Ny4zOTk5OWMwLjA5OTk4LDAuODk5OTksMC4wOTk5OCwxLjgsMC4wOTk5OCwyLjd2LTIuN0gxMDQzLjgwMDA1eiIvPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTEwNDUuODAwMDUsMjUwLjEwMDAxSDEwNDJjMC0wLjgsMC0xLjYwMDAxLTAuMDk5OTgtMi41bC0wLjE5OTk1LTIuMTAwMDFoNC4wOTk5OFYyNTAuMTAwMDEKCQlMMTA0NS44MDAwNSwyNTAuMTAwMDF6Ii8+CjwvZz4KPGc+Cgk8cGF0aCBmaWxsPSIjNjg4NUE5IiBkPSJNMTA0My44MDAwNSwxNTEuOGMwLjA5OTk4LDAuODk5OTksMC4wOTk5OCwxLjgsMC4wOTk5OCwyLjd2LTIuN0gxMDQzLjgwMDA1eiIvPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTEwNDUuODAwMDUsMTU0LjVIMTA0MmMwLTAuOCwwLTEuNjAwMDEtMC4wOTk5OC0yLjVsLTAuMTk5OTUtMi4xMDAwMWg0LjA5OTk4VjE1NC41TDEwNDUuODAwMDUsMTU0LjV6Ii8+CjwvZz4KPHBhdGggZmlsbD0iI0IyQ0JFRCIgc3Ryb2tlPSIjMjMxRjIwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTEwNDMuODAwMDUsMjQ3LjM5OTk5CgljMC4wOTk5OCwwLjg5OTk5LDAuMDk5OTgsMS44LDAuMDk5OTgsMi43di0yLjdIMTA0My44MDAwNXoiLz4KPHBhdGggZmlsbD0iI0IyQ0JFRCIgc3Ryb2tlPSIjMjMxRjIwIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgZD0iTTEwNDMuODAwMDUsMTUxLjgKCWMwLjA5OTk4LDAuODk5OTksMC4wOTk5OCwxLjgsMC4wOTk5OCwyLjd2LTIuN0gxMDQzLjgwMDA1eiIvPgo8Zz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iNDc4Ljg5OTk5LDQxNC4yOTk5OSAzNzAuNjAwMDEsNDQyLjEwMDAxIDI5Ny4yMDAwMSw1Mi4yIAoJCTcwMS40MDAwMiwyOTUuMjAwMDEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0NDRDhFRSIgcG9pbnRzPSI5Mi40LDM5MS4yOTk5OSA5MS40LDM5MS4yOTk5OSA5MS40LDM5MS44OTk5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjQ0NEOEVFIiBwb2ludHM9IjkyLjQsMjczLjM5OTk5IDkxLjQsMjczLjM5OTk5IDkxLjQsMjc0IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDREQ5RUUiIHBvaW50cz0iMzcwLjYwMDAxLDMyNSA5NC4xLDE1NC41IDI5Ny4yMDAwMSw0My40IDU3NC41LDIxMy43IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNDQ0Q4RUUiIHBvaW50cz0iOTIuNCwxNTUuNSA5MS40LDE1NS41IDkxLjQsMTU2LjEwMDAxIAkiLz4KCTxwb2x5Z29uIGZpbGw9IiNCNUM1REMiIHBvaW50cz0iOTEuNCwyNTYuMTAwMDEgMzcwLjYwMDAxLDQyOC4yMDAwMSAzNzAuNjAwMDEsNDI4LjIwMDAxIDM3MC42MDAwMSwzMjguMjAwMDEgOTEuNCwxNTYuMTAwMDEgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIzODksMzEzLjg5OTk5IDE1Mi4zLDE2NS44OTk5OSAzMzYsNjYuNyAyOTcuMjAwMDEsNDMuNCA5NC4xLDE1NC41IDM3MC42MDAwMSwzMjUgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzY4ODVBOSIgcG9pbnRzPSI1NzcuMjAwMDEsMzE1LjI5OTk5IDM3MC42MDAwMSw0MjguMjAwMDEgMzcwLjYwMDAxLDQyOC4yMDAwMSAzNzAuNjAwMDEsMzI4LjIwMDAxIAoJCTU3Ny4yMDAwMSwyMTUuMzk5OTkgCSIvPgoJPHBhdGggZmlsbD0iIzIzMUYyMCIgZD0iTTU4OC4yMDAwMSwyMDlsLTI5MS0xNzguNUw4MS43LDE0OC44OTk5OWwtMS4zLDAuOFYyNjF2MC44OTk5OWwyOTAuMjk5OTksMTc4LjY5OTk4bDIxNy42MDAwMS0xMTkuNQoJCUw1ODguMjAwMDEsMjA5eiBNMjk3LjIwMDAxLDQzLjRMNTc0LjUsMjEzLjcwMDAxTDM3MC42MDAwMSwzMjVMOTQuMSwxNTQuNUwyOTcuMjAwMDEsNDMuNHogTTU3NC41LDIyMC4zdjkzLjQwMDAxCgkJTDM3My4zOTk5OSw0MjMuMjk5OTl2LTkzLjVMNTc0LjUsMjIwLjN6IE0zNjcuODk5OTksMzI5Ljc5OTk5djkzLjVMOTQuMiwyNTQuNVYxNjFMMzY3Ljg5OTk5LDMyOS43OTk5OXoiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMTgyLjcsMjkyLjg5OTk5IDE1OSwyNzguMTAwMDEgMTU5LDI1NS4xMDAwMSAxODIuNywyNjkuODk5OTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIxNDQuOCwyNjkuNjAwMDEgMTIxLjEsMjU0LjggMTIxLjEsMjMxLjg5OTk5IDE0NC44LDI0Ni43IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMjU4LjM5OTk5LDM0MC44OTk5OSAyMzQuNjAwMDEsMzI2LjEwMDAxIDIzNC42MDAwMSwzMDMuMjAwMDEgMjU4LjM5OTk5LDMxOCAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjIyMC41LDMxNy43MDAwMSAxOTYuOCwzMDIuODk5OTkgMTk2LjgsMjc5Ljg5OTk5IDIyMC41LDI5NC43MDAwMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjMzNS4yOTk5OSwzODguMjAwMDEgMzExLjYwMDAxLDM3My4zOTk5OSAzMTEuNjAwMDEsMzUwLjUgMzM1LjI5OTk5LDM2NS4yOTk5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjI5Ny4zOTk5OSwzNjUgMjczLjcwMDAxLDM1MC4yMDAwMSAyNzMuNzAwMDEsMzI3LjIwMDAxIDI5Ny4zOTk5OSwzNDIgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIxODIuMzk5OTksMjU5LjYwMDAxIDE1OC43LDI0NC44IDE1OC43LDIyMS44OTk5OSAxODIuMzk5OTksMjM2LjcgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIxNDQuNjAwMDEsMjM2LjM5OTk5IDEyMC45LDIyMS42MDAwMSAxMjAuOSwxOTguNjAwMDEgMTQ0LjYwMDAxLDIxMy4zOTk5OSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjI1OC4xMDAwMSwzMDcuNzAwMDEgMjM0LjM5OTk5LDI5Mi44OTk5OSAyMzQuMzk5OTksMjcwIDI1OC4xMDAwMSwyODQuNzk5OTkgCSIvPgoJPHBvbHlnb24gZmlsbD0iIzAwMDAwMCIgcG9pbnRzPSIyMjAuMywyODQuMzk5OTkgMTk2LjUsMjY5LjYwMDAxIDE5Ni41LDI0Ni43IDIyMC4zLDI2MS41IAkiLz4KCTxwb2x5Z29uIGZpbGw9IiMwMDAwMDAiIHBvaW50cz0iMzM1LDM1NSAzMTEuMjk5OTksMzQwLjIwMDAxIDMxMS4yOTk5OSwzMTcuMjk5OTkgMzM1LDMzMi4xMDAwMSAJIi8+Cgk8cG9seWdvbiBmaWxsPSIjMDAwMDAwIiBwb2ludHM9IjI5Ny4yMDAwMSwzMzEuNzAwMDEgMjczLjUsMzE2Ljg5OTk5IDI3My41LDI5NCAyOTcuMjAwMDEsMzA4Ljc5OTk5IAkiLz4KPC9nPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "tower", "name": "tower", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSIyNjAuMzk5OTlweCIgaGVpZ2h0PSI1MTcuNzAwMDFweCIgdmlld0JveD0iMCAwIDI2MC4zOTk5OSA1MTcuNzAwMDEiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDI2MC4zOTk5OSA1MTcuNzAwMDEiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJMYXllcl8xXzFfIiBkaXNwbGF5PSJub25lIj4KCTxwb2x5Z29uIGRpc3BsYXk9ImlubGluZSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjQTdBOUFDIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIzNDUuNjAwMDEsMzc1LjM5OTk5IAoJCTEzMC4yLDQ5OS43OTk5OSAtODUuMiwzNzUuMzk5OTkgLTg1LjIsMTI2LjcgMTMwLjIsMi4zIDM0NS42MDAwMSwxMjYuNyAJIi8+CjwvZz4KPGcgaWQ9IkxheWVyXzJfMV8iPgoJPHBhdGggZmlsbD0iIzVBNUI1QiIgZD0iTTIxNy4xMDAwMSw0NDQuMTAwMDFsLTkuMzk5OTktNTEuNWwtOS00OS4zOTk5OWwwLDBsMCwwbDAsMGwtMjIuNS0xMjMuN2wwLDBsLTcuNS00MS4ybDAsMAoJCWwtNS4zOTk5OS0yOS42MDAwMWwtMC43LTFjLTAuMi0wLjUtMC41LTEtMS0xLjM5OTk5bC0wLjEwMDAxLTAuMTAwMDFsLTE2LjItOS4zbDAuNy0wLjM5OTk5bC0xNS43LTkuMWwtMTUuNyw5LjFsMC43LDAuMzk5OTkKCQlsLTE1LjcsOS4xMDAwMUw5OSwxNDZsMCwwbDAsMGMtMC45LDAuNjAwMDEtMS41LDEuNS0xLjcsMi41bDAsMGwtMTIuOSw3MC44OTk5OWwwLDBMNjEuNywzNDMuMjAwMDFsMCwwbDAsMGwwLDBsLTksNDkuMzk5OTkKCQlsLTkuNCw1MS41Yy0wLjQsMiwxLDMuODk5OTksMyw0LjI5OTk5YzAuMiwwLDAuNCwwLjEwMDAxLDAuNywwLjEwMDAxYzEuNywwLDMuMy0xLjIwMDAxLDMuNi0zbDguNS00Ni4zOTk5OWw2Ny41LDM5djU0Ljc5OTk5CgkJYzAsMiwxLjcsMy43MDAwMSwzLjcsMy43MDAwMXMzLjctMS43MDAwMSwzLjctMy43MDAwMVY0MzhsNjcuNS0zOWw4LjUsNDYuMzk5OTljMC4zLDEuNzk5OTksMS44OTk5OSwzLDMuNjAwMDEsMwoJCWMwLjIsMCwwLjM5OTk5LDAsMC43LTAuMTAwMDFDMjE2LjEwMDAxLDQ0OCwyMTcuNSw0NDYuMTAwMDEsMjE3LjEwMDAxLDQ0NC4xMDAwMXogTTEyNi41LDI3Ni44OTk5OUw5OC41LDIzMC41bDI4LDE2LjJWMjc2Ljg5OTk5egoJCSBNMTMzLjg5OTk5LDI0Ni43bDI4LTE2LjJsLTI4LDQ2LjM5OTk5VjI0Ni43eiBNOTIsMjE4LjJsNS4xLTI4LjJsMjMuOCw0NC44OTk5OUw5MiwyMTguMnogTTExOS42LDI3OS43MDAwMWwtMzUuMS0yMC4yOTk5OQoJCWw1LjMtMjkuMTAwMDFMMTE5LjYsMjc5LjcwMDAxeiBNMTcwLjYwMDAxLDIzMC4zOTk5OWw1LjMsMjkuMTAwMDFsLTM1LjA5OTk5LDIwLjI5OTk5TDE3MC42MDAwMSwyMzAuMzk5OTl6IE0xMzkuNSwyMzQuODk5OTkKCQlMMTYzLjMsMTkwbDUuMTAwMDEsMjguMkwxMzkuNSwyMzQuODk5OTl6IE0xMzMuODk5OTksMjI5Ljd2LTI4LjYwMDAxbDIxLjgtMTIuNjAwMDFMMTMzLjg5OTk5LDIyOS43eiBNMTI2LjUsMjI5LjdsLTIxLjgtNDEuMgoJCWwyMS44LDEyLjYwMDAxVjIyOS43eiBNMTI2LjUsMjkyLjIwMDAxVjMyMy41bC0zNC4yLTUxLjEwMDAxTDEyNi41LDI5Mi4yMDAwMXogTTEzMy44OTk5OSwyOTIuMjAwMDFsMzQuMi0xOS43OTk5OWwtMzQuMiw1MS4xMDAwMQoJCVYyOTIuMjAwMDF6IE0xNjAuODk5OTksMTc3bC0yMC42MDAwMSwxMS44OTk5OWwxNy4zLTI5Ljg5OTk5TDE2MC44OTk5OSwxNzd6IE05OS41LDE3N2wzLjItMTcuM2wxNy4yLDI5LjEwMDAxTDk5LjUsMTc3egoJCSBNMTE4LjMsMzI0LjVMNzcsMzAwLjcwMDAxTDgyLjQsMjcxTDExOC4zLDMyNC41eiBNMTc4LDI3MWw1LjM5OTk5LDI5LjcwMDAxbC00MS4zLDIzLjg5OTk5TDE3OCwyNzF6IE0xMzMuODk5OTksMTg1LjJ2LTE4LjYwMDAxCgkJbDE2LjItOS4zTDEzMy44OTk5OSwxODUuMnogTTEyNi41LDE4NS4zOTk5OWwtMTctMjguN2wxNyw5LjhWMTg1LjM5OTk5eiBNMTI2LjUsMzM3Ljc5OTk5djMyLjEwMDAxTDg2LjEsMzE0LjVMMTI2LjUsMzM3Ljc5OTk5egoJCSBNMTMzLjg5OTk5LDMzNy43OTk5OUwxNzQuMjk5OTksMzE0LjVsLTQwLjM5OTk5LDU1LjM5OTk5VjMzNy43OTk5OXogTTEzMy44OTk5OSwxNjAuNXYtMTcuMmw0LTIuM2wxNC44OTk5OSw4LjYwMDAxCgkJTDEzMy44OTk5OSwxNjAuNXogTTEyMi42LDE0MWw0LDIuM3YxNy4ybC0xOC45LTEwLjg5OTk5TDEyMi42LDE0MXogTTc1LDMxMS43MDAwMWw0Miw1Ny42MDAwMWwtNDcuNS0yNy4zOTk5OUw3NSwzMTEuNzAwMDF6CgkJIE0xODUuNSwzMTEuNzAwMDFsNS41LDMwLjIwMDAxbC00Ny41LDI3LjM5OTk5TDE4NS41LDMxMS43MDAwMXogTTEyNi41LDM4My4zOTk5OXY0MC44OTk5OUw3OCwzNTUuMzk5OTlMMTI2LjUsMzgzLjM5OTk5egoJCSBNMTMzLjg5OTk5LDM4My4zOTk5OWw0OC41LTI4bC00OC41LDY4Ljg5OTk5VjM4My4zOTk5OXogTTY3LjQsMzUzLjIwMDAxbDUwLjEsNzEuMjAwMDFsLTU3LjEtMzNMNjcuNCwzNTMuMjAwMDF6CgkJIE0xNDIuODk5OTksNDI0LjI5OTk5TDE5MywzNTMuMDk5OThsNywzOC4yMDAwMUwxNDIuODk5OTksNDI0LjI5OTk5eiIvPgoJPHBhdGggZmlsbD0iIzgwODI4NSIgZD0iTTEzMCwxMjcuNGwtMTUuNSw4LjlsMC43LDAuMzk5OTlMOTkuNSwxNDUuOEw5OSwxNDZsMCwwbDAsMGMtMC45LDAuNjAwMDEtMS41LDEuNS0xLjcsMi41bDAsMAoJCWwtMTIuOSw3MC44OTk5OWwwLDBMNjEuNywzNDMuMjAwMDFsMCwwbDAsMGwwLDBsLTksNDkuMzk5OTlsLTkuNCw1MS41Yy0wLjQsMiwxLDMuODk5OTksMyw0LjI5OTk5YzAuMiwwLDAuNCwwLjEwMDAxLDAuNywwLjEwMDAxCgkJYzEuNywwLDMuMy0xLjIwMDAxLDMuNi0zbDguNS00Ni4zOTk5OWw2Ny41LDM5djU0Ljc5OTk5YzAsMiwxLjUsMy41LDMuMzk5OTksMy43MDAwMVYxMjcuNEwxMzAsMTI3LjR6IE0xMDIuNywxNTkuNjAwMDEKCQlsMTcuMiwyOS4xMDAwMUw5OS41LDE3N0wxMDIuNywxNTkuNjAwMDF6IE05Ny4yLDE5MGwyMy44LDQ0Ljg5OTk5bC0yOS0xNi43TDk3LjIsMTkweiBNODkuOCwyMzAuMzk5OTlsMjkuOCw0OS4zOTk5OQoJCWwtMzUuMS0yMC4yOTk5OUw4OS44LDIzMC4zOTk5OXogTTgyLjQsMjcxbDM1LjksNTMuNjAwMDFMNzcsMzAwLjcwMDAxTDgyLjQsMjcxeiBNNzUsMzExLjcwMDAxbDQyLDU3LjYwMDAxbC00Ny41LTI3LjM5OTk5CgkJTDc1LDMxMS43MDAwMXogTTYwLjUsMzkxLjM5OTk5bDctMzguMjAwMDFsNTAuMSw3MS4xOTk5OEw2MC41LDM5MS4zOTk5OXogTTEyNi41LDQyNC4yOTk5OUw3OCwzNTUuMzk5OTlsNDguNSwyOFY0MjQuMjk5OTl6CgkJIE0xMjYuNSwzNjkuODk5OTlMODYuMSwzMTQuNWw0MC40LDIzLjI5OTk5VjM2OS44OTk5OXogTTEyNi41LDMyMy41bC0zNC4yLTUxLjEwMDAxbDM0LjIsMTkuNzk5OTlWMzIzLjV6IE0xMjYuNSwyNzYuODk5OTkKCQlMOTguNSwyMzAuNWwyOCwxNi4yVjI3Ni44OTk5OXogTTEyNi41LDIyOS43bC0yMS44LTQxLjJsMjEuOCwxMi42MDAwMVYyMjkuN3ogTTEyNi41LDE4NS4zOTk5OWwtMTctMjguN2wxNyw5LjhWMTg1LjM5OTk5egoJCSBNMTI2LjUsMTYwLjVsLTE4LjktMTAuODk5OTlMMTIyLjUsMTQxbDQsMi4zVjE2MC41TDEyNi41LDE2MC41eiIvPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzgwODI4NSIgcG9pbnRzPSIxMzAuMiwxNDUuMyAxMDcuMywxMzIgMTIzLjIsMTAgMTMwLjIsMTQgCQkiLz4KCTwvZz4KCTxnPgoJCTxwb2x5Z29uIGZpbGw9IiM1QTVCNUIiIHBvaW50cz0iMTMwLjIsMTQ1LjMgMTUzLjIsMTMyIDEzNy4yLDEwIDEzMC4yLDE0IAkJIi8+Cgk8L2c+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjQkNCRUMwIiBwb2ludHM9IjEzMC4yLDUuOSAxMjMuMiwxMCAxMzAuMiwxNCAxMzcuMiwxMCAJCSIvPgoJPC9nPgo8L2c+CjxwYXRoIG9wYWNpdHk9IjAuMjUiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNMjAzLjMsNDQ3LjI5OTk5YzUuMzk5OTksMTIuNzk5OTktMTguNSwzMC4yMDAwMS01OC41OTk5OSwzMgoJUzYzLjQsNDY2LjYwMDAxLDU4LDQ1My43MDAwMXMxOS44LTMwLjUsNTkuOS0zMi4yMDAwMVMxOTcuODk5OTksNDM0LjUsMjAzLjMsNDQ3LjI5OTk5eiIvPgo8L3N2Zz4K", "isIsometric": true, "collection": "isoflow" }, { "id": "truck-2", "name": "truck-2", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMjQzLjU1IDIxOC4xNCI+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwcGF0aCI+PHBhdGggZD0iTTM4Ljc1LDEzNC41NGMuMDItNS4zNiwxLjkxLTkuMTMsNC45Ni0xMC45IiBmaWxsPSJub25lIi8+PC9jbGlwUGF0aD48L2RlZnM+PGcgaWQ9IkJvZHkiPjxnIGlkPSJSZWFyV2hlZWwiPjxwYXRoIGlkPSJTaGFkb3ciIGQ9Ik0yLjU3LDEyMS42N2w3NC4yNC00MS44MWMxLjU5LS45MiwzLjU1LS45Myw1LjE0LS4wMmwxNTQuMTUsODcuODljMy40NSwxLjk3LDMuNDgsNi45MiwuMDUsOC45M2wtNjcuOTYsMzguODNjLTUuOTgsMy41LTEzLjM3LDMuNTQtMTkuMzgsLjExTDIuNiwxMzAuNmMtMy40NS0xLjk3LTMuNDctNi45NC0uMDQtOC45NFoiIGZpbGw9IiMwMTAxMDEiIGlzb2xhdGlvbj0iaXNvbGF0ZSIgb3BhY2l0eT0iLjQiLz48cGF0aCBkPSJNMTc2LjE1LDE5Ni41Yy4wMy05LjA3LTYuMzUtMjAuMTItMTQuMjUtMjQuNjgtMy44LTIuMTktNy4yNS0yLjQ3LTkuODItMS4xN2gwbC0uMiwuMXMtLjEsLjA1LS4xNSwuMDhsLTEzLjU2LDcuMzV2LjAyYy0yLjk4LDEuMjktNC44OSw0LjYtNC45LDkuNTItLjAzLDkuMDcsNi4zNSwyMC4xMiwxNC4yNSwyNC42OCwzLjk4LDIuMyw3LjU4LDIuNSwxMC4xOCwuOTlsMTMuMDktNy4xN2MuNzMtLjI1LDEuNC0uNjIsMi4wMS0xLjFoLjAyYzIuMDctMS42NywzLjMzLTQuNjEsMy4zNC04LjYxWiIgZmlsbD0iIzJiMmIyYiIvPjxlbGxpcHNlIGN4PSIxNDcuNiIgY3k9IjE5Ni4yNSIgcng9IjYuNDgiIHJ5PSIxMS4xOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTc4LjYyIDEwMC43NCkgcm90YXRlKC0zMC4xNikiIGZpbGw9IiNlZmVmZWYiLz48L2c+PGcgaWQ9IlJlYXJXaGVlbC0yIj48cGF0aCBkPSJNMjMzLjk4LDE2OC40OGMuMDMtOS4wNy02LjM1LTIwLjEyLTE0LjI1LTI0LjY4LTMuOC0yLjE5LTcuMjUtMi40Ny05LjgyLTEuMTdoMGwtLjIsLjFzLS4xLC4wNS0uMTUsLjA4bC0xMy41Niw3LjM1di4wMmMtMi45OCwxLjI5LTQuODksNC42LTQuOSw5LjUyLS4wMyw5LjA3LDYuMzUsMjAuMTIsMTQuMjUsMjQuNjgsMy45OCwyLjMsNy41OCwyLjUsMTAuMTgsLjk5bDEzLjA5LTcuMTdjLjczLS4yNSwxLjQtLjYyLDIuMDEtMS4xaC4wMmMyLjA3LTEuNjcsMy4zMy00LjYxLDMuMzQtOC42MVoiIGZpbGw9IiMyYjJiMmIiLz48ZWxsaXBzZSBjeD0iMjA1LjQ0IiBjeT0iMTY4LjIzIiByeD0iNi40OCIgcnk9IjExLjE5IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTYuNzEgMTI2KSByb3RhdGUoLTMwLjE2KSIgZmlsbD0iI2VmZWZlZiIvPjwvZz48ZyBpZD0iVW5kZXJDYXJyaWFnZSI+PGcgaXNvbGF0aW9uPSJpc29sYXRlIj48cG9seWdvbiBwb2ludHM9IjIzMi45OCAxNDQuNzggMTYzLjY5IDE4NS41MSAzNS4xNiAxMTEuMyAxMDQuNDUgNzAuNTggMjMyLjk4IDE0NC43OCIgZmlsbD0iIzk5OSIvPjxnPjxwYXRoIGQ9Ik0xNjMuNjUsMjAyLjgxYy0uMTcsMC0uMzQtLjA0LS41LS4xMy0uMzEtLjE4LS41LS41MS0uNS0uODdsLjA1LTE2LjNjMC0uMzUsLjE5LS42OCwuNDktLjg2bDY5LjI5LTQwLjcyYy4xNi0uMDksLjMzLS4xNCwuNTEtLjE0cy4zNCwuMDQsLjUsLjEzYy4zMSwuMTgsLjUsLjUxLC41LC44N2wtLjA1LDE2LjNjMCwuMzUtLjE5LC42OC0uNDksLjg2bC02OS4yOSw0MC43MmMtLjE2LC4wOS0uMzMsLjE0LS41MSwuMTRaIiBmaWxsPSIjNzI3MzcyIi8+PHBhdGggZD0iTTIzMi45OCwxNDQuNzhsLS4wNSwxNi4zLTY5LjI5LDQwLjcyLC4wNS0xNi4zLDY5LjI5LTQwLjcybTAtMmMtLjM1LDAtLjcsLjA5LTEuMDEsLjI4bC02OS4yOSw0MC43MmMtLjYxLC4zNi0uOTgsMS4wMS0uOTksMS43MmwtLjA1LDE2LjNjMCwuNzIsLjM4LDEuMzgsMSwxLjc0LC4zMSwuMTgsLjY1LC4yNywxLC4yN3MuNy0uMDksMS4wMS0uMjhsNjkuMjktNDAuNzJjLjYxLS4zNiwuOTgtMS4wMSwuOTktMS43MmwuMDUtMTYuM2MwLS43Mi0uMzgtMS4zOC0xLTEuNzQtLjMxLS4xOC0uNjUtLjI3LTEtLjI3aDBabTAsNGgwWiIgZmlsbD0iIzFkMWQxYiIvPjwvZz48Zz48cGF0aCBkPSJNMTYzLjY1LDIwMi44MWMtLjE3LDAtLjM1LS4wNC0uNS0uMTNMMzQuNjIsMTI4LjQ3Yy0uMzEtLjE4LS41LS41MS0uNS0uODdsLjA1LTE2LjNjMC0uMzYsLjE5LS42OSwuNS0uODYsLjE1LS4wOSwuMzMtLjEzLC41LS4xM3MuMzUsLjA0LC41LC4xM2wxMjguNTMsNzQuMjFjLjMxLC4xOCwuNSwuNTEsLjUsLjg3bC0uMDUsMTYuM2MwLC4zNi0uMTksLjY5LS41LC44Ni0uMTUsLjA5LS4zMywuMTMtLjUsLjEzWiIgZmlsbD0iIzcyNzM3MiIvPjxwYXRoIGQ9Ik0zNS4xNiwxMTEuM2wxMjguNTMsNzQuMjEtLjA1LDE2LjNMMzUuMTIsMTI3LjZsLjA1LTE2LjNtMC0yYy0uMzQsMC0uNjksLjA5LTEsLjI3LS42MiwuMzYtMSwxLjAxLTEsMS43M2wtLjA1LDE2LjNjMCwuNzIsLjM4LDEuMzgsMSwxLjc0bDEyOC41Myw3NC4yMWMuMzEsLjE4LC42NSwuMjcsMSwuMjdzLjY5LS4wOSwxLS4yN2MuNjItLjM2LDEtMS4wMSwxLTEuNzNsLjA1LTE2LjNjMC0uNzItLjM4LTEuMzgtMS0xLjc0TDM2LjE2LDEwOS41N2MtLjMxLS4xOC0uNjUtLjI3LTEtLjI3aDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9nPjwvZz48L2c+PGcgaXNvbGF0aW9uPSJpc29sYXRlIj48ZyBpc29sYXRpb249Imlzb2xhdGUiPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwcGF0aCkiPjxnIGlzb2xhdGlvbj0iaXNvbGF0ZSI+PHBhdGggZD0iTTQwLjc3LDEyNi41MmMuNzktMS4yMywxLjc4LTIuMiwyLjk0LTIuODgiIGZpbGw9IiM1ZjYwNjAiLz48cGF0aCBkPSJNMTAzLjg2LDg4LjY5Yy0xLjE2LC42OC0yLjE2LDEuNjQtMi45NCwyLjg4IiBmaWxsPSIjNWY2MDYwIi8+PHBhdGggZD0iTTQwLjEzLDEyNy42OGMuMTktLjQxLC40MS0uOCwuNjQtMS4xNiIgZmlsbD0iIzYwNjI2MSIvPjxwYXRoIGQ9Ik0xMDAuOTIsOTEuNTZjLS4yMywuMzYtLjQ1LC43NS0uNjQsMS4xNiIgZmlsbD0iIzYwNjI2MSIvPjxwYXRoIGQ9Ik0zOS43MiwxMjguNjVjLjEyLS4zMywuMjYtLjY2LC40MS0uOTciIGZpbGw9IiM2MjYzNjMiLz48cGF0aCBkPSJNMTAwLjI3LDkyLjczYy0uMTUsLjMxLS4yOCwuNjMtLjQxLC45NyIgZmlsbD0iIzYyNjM2MyIvPjxwYXRoIGQ9Ik0zOS40MiwxMjkuNTRjLjA5LS4zMSwuMTktLjYxLC4zLS44OSIgZmlsbD0iIzY0NjU2NCIvPjxwYXRoIGQ9Ik05OS44Nyw5My42OWMtLjExLC4yOS0uMjEsLjU5LS4zLC44OSIgZmlsbD0iIzY0NjU2NCIvPjxwYXRoIGQ9Ik0zOS4yLDEzMC40MWMuMDctLjMsLjE0LS41OSwuMjMtLjg3IiBmaWxsPSIjNjU2NjY2Ii8+PHBhdGggZD0iTTk5LjU3LDk0LjU5Yy0uMDgsLjI4LS4xNiwuNTctLjIzLC44NyIgZmlsbD0iIzY1NjY2NiIvPjxwYXRoIGQ9Ik0zOS4wMywxMzEuMjRjLjA1LS4yOCwuMS0uNTYsLjE3LS44MyIgZmlsbD0iIzY3Njg2OCIvPjxwYXRoIGQ9Ik05OS4zNCw5NS40NmMtLjA2LC4yNy0uMTIsLjU1LS4xNywuODMiIGZpbGw9IiM2NzY4NjgiLz48cGF0aCBkPSJNMzguOTEsMTMyLjA1Yy4wMy0uMjgsLjA4LS41NSwuMTItLjgxIiBmaWxsPSIjNjg2OTY5Ii8+PHBhdGggZD0iTTk5LjE4LDk2LjI5Yy0uMDUsLjI3LS4wOSwuNTQtLjEyLC44MSIgZmlsbD0iIzY4Njk2OSIvPjxwYXRoIGQ9Ik0zOC44MiwxMzIuODdjLjAyLS4yOCwuMDUtLjU1LC4wOC0uODIiIGZpbGw9IiM2YTZiNmIiLz48cGF0aCBkPSJNOTkuMDYsOTcuMWMtLjAzLC4yNy0uMDYsLjU0LS4wOCwuODIiIGZpbGw9IiM2YTZiNmIiLz48cGF0aCBkPSJNMzguNzcsMTMzLjY3Yy4wMS0uMjcsLjAzLS41NCwuMDUtLjgiIGZpbGw9IiM2YjZjNmMiLz48cGF0aCBkPSJNOTguOTcsOTcuOTJjLS4wMiwuMjYtLjA0LC41My0uMDUsLjgiIGZpbGw9IiM2YjZjNmMiLz48cGF0aCBkPSJNMzguNzUsMTM0LjVjMC0uMjgsMC0uNTUsLjAyLS44MiIgZmlsbD0iIzZkNmU2ZSIvPjxwYXRoIGQ9Ik05OC45Miw5OC43MmMtLjAxLC4yNy0uMDIsLjU0LS4wMiwuODIiIGZpbGw9IiM2ZDZlNmUiLz48cGF0aCBkPSJNMzguNzUsMTM0LjU0di0uMDUiIGZpbGw9IiM2ZTZmNmYiLz48cGF0aCBkPSJNOTguOSw5OS41NHYuMDUiIGZpbGw9IiM2ZTZmNmYiLz48L2c+PC9nPjwvZz48ZyBpZD0iRnJvbnRXaGVlbCI+PGc+PHBhdGggZD0iTTU5LjU3LDE2MC4wNWMtMi4xOCwwLTQuNS0uNy02LjkxLTIuMDktOC4xNi00LjcxLTE0Ljc4LTE2LjE3LTE0Ljc1LTI1LjU1LC4wMS01LjA0LDEuOTUtOC43OSw1LjMzLTEwLjM1LC4wMy0uMDIsLjA2LS4wNCwuMS0uMDZsMTMuOTEtNy41M2MuMDYtLjAzLC4xMy0uMDYsLjE5LS4wOCwxLjEzLS41NCwyLjM4LS44MSwzLjczLS44MSwyLjE3LDAsNC40OSwuNyw2Ljg5LDIuMDgsOC4xNiw0LjcxLDE0Ljc4LDE2LjE3LDE0Ljc1LDI1LjU1LS4wMSw0LjExLTEuMzEsNy40Mi0zLjY2LDkuMzMtLjA0LC4wNC0uMDksLjA4LS4xNCwuMTEtLjY1LC41LTEuMzcsLjktMi4xNiwxLjE5bC0xMy4wMiw3LjEzYy0xLjI0LC43Mi0yLjY3LDEuMDktNC4yNSwxLjA5aDBaIiBmaWxsPSIjMmIyYjJiIi8+PHBhdGggZD0iTTYxLjE2LDExNC41N2MxLjkyLDAsNC4wOSwuNjIsNi4zOSwxLjk1LDcuOSw0LjU2LDE0LjI3LDE1LjYxLDE0LjI1LDI0LjY4LS4wMSw0LTEuMjcsNi45NC0zLjM0LDguNTloLS4wMmMtLjYsLjQ5LTEuMjgsLjg2LTIuMDEsMS4xMWwtMTMuMDksNy4xN2MtMS4wOSwuNjQtMi4zNywuOTctMy43NywuOTctMS45MywwLTQuMTEtLjYzLTYuNDEtMS45Ni03LjktNC41Ni0xNC4yNy0xNS42MS0xNC4yNS0yNC42OCwuMDEtNC45MiwxLjkyLTguMjMsNC45MS05LjUydi0uMDJsMTMuNTYtNy4zNXMuMS0uMDUsLjE1LS4wOGwuMi0uMTFoMGMxLjAyLS41LDIuMTctLjc3LDMuNDMtLjc3bTAtMmMtMS40OCwwLTIuODUsLjMtNC4xLC44OS0uMSwuMDMtLjIsLjA4LS4yOSwuMTNsLS4yLC4xMWMtLjA3LC4wNC0uMTIsLjA3LS4xNywuMDlsLTEzLjU1LDcuMzRzLS4wNywuMDQtLjExLC4wNmMtMy43LDEuNzQtNS44Miw1LjgxLTUuODQsMTEuMjItLjAzLDkuODYsNi42NywyMS40NywxNS4yNSwyNi40MiwyLjU2LDEuNDgsNS4wNSwyLjIzLDcuNDEsMi4yMywxLjc0LDAsMy4zNC0uNDEsNC43NS0xLjIzbDEyLjk0LTcuMDhjLjg3LS4zMiwxLjY4LS43NywyLjQtMS4zNCwuMDItLjAxLC4wMy0uMDMsLjA1LS4wNGgwYzIuNjItMi4xLDQuMDgtNS43LDQuMDktMTAuMTUsLjAzLTkuODYtNi42Ny0yMS40Ni0xNS4yNS0yNi40Mi0yLjU1LTEuNDctNS4wMy0yLjIyLTcuMzktMi4yMmgwWiIgZmlsbD0iIzFkMWQxYiIvPjwvZz48ZWxsaXBzZSBjeD0iNTMuMjUiIGN5PSIxNDAuOTUiIHJ4PSI2LjQ4IiByeT0iMTEuMTkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC02My42MSA0NS44NCkgcm90YXRlKC0zMC4xNikiIGZpbGw9IiNlZmVmZWYiLz48L2c+PGc+PHBhdGggZD0iTTY2LjM2LDcyLjU4bC0uMTcsNTguMjQtMTAuMzktNmMtOS4zOS01LjQyLTE3LjAyLTEuMDctMTcuMDYsOS43Mkw1LjM0LDExNS4yNWwuMDUtMTcuMzlMMjQuOSw0OC42NGw0MS40NiwyMy45NFoiIGZpbGw9IiNiNmM1ZGQiLz48cG9seWdvbiBwb2ludHM9IjEzLjggNzYuNjQgNjYuMjYgMTA3LjE4IDY2LjM2IDcyLjU4IDI0LjkgNDguNjQgMTMuOCA3Ni42NCIvPjwvZz48cG9seWdvbiBwb2ludHM9IjY2LjM2IDcyLjU4IDEyNi41MSAzNy42MiAxMjYuMzQgOTUuODcgNjYuMiAxMzAuODIgNjYuMzYgNzIuNTgiIGZpbGw9IiM2ODg1YWEiLz48cG9seWdvbiBwb2ludHM9IjI0LjkgNDguNjQgODUuMDUgMTMuNjkgMTI2LjUxIDM3LjYyIDY2LjM2IDcyLjU4IDI0LjkgNDguNjQiIGZpbGw9IiNjZGQ5ZWUiLz48L2c+PHBvbHlnb24gaWQ9IkZyb250IiBwb2ludHM9IjE3Mi40NCAxOTkuMTEgMTcyLjcxIDEwNS42OCAyNDAuMjEgNjYuMTYgMjQwLjU1IDE1OS43NiAxNzIuNDQgMTk5LjExIiBmaWxsPSIjNjg4NWFhIiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBvbHlnb24gaWQ9IlNpZGUiIHBvaW50cz0iNjIuOCAxMzUuODEgNjMuMDcgNDIuMzcgMTcyLjcxIDEwNS42OCAxNzIuNDQgMTk5LjExIDYyLjggMTM1LjgxIiBmaWxsPSIjYjZjNWRkIiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBvbHlnb24gaWQ9IlRvcCIgcG9pbnRzPSI2My4wNyA0Mi4zNyAxMzAuODIgMyAyNDAuMjEgNjYuMTYgMTcyLjcxIDEwNS42OCA2My4wNyA0Mi4zNyIgZmlsbD0iI2NkZDllZSIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIvPjxwb2x5Z29uIHBvaW50cz0iMTcyLjQ0IDE3My43OCA2Mi44IDExMi4wNCA2Mi44IDkzLjMxIDE3Mi40NCAxNTUuMDUgMTcyLjQ0IDE3My43OCIgZmlsbD0iI2Q2MDc1NiIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS13aWR0aD0iMiIvPjxsaW5lIHgxPSIyMTAuMjQiIHkxPSI4NC4yMiIgeDI9IjIxMC4yNCIgeTI9IjE3Ny4yOCIgZmlsbD0iIzY4ODVhYSIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIvPjwvZz48cGF0aCBkPSJNMTMwLjgyLDNsMTA5LjM5LDYzLjE2LC4zNCw5My42MS03LjA3LDQuMDljLjMyLDEuNTcsLjUxLDMuMTMsLjUxLDQuNjMtLjAxLDQtMS4yNyw2Ljk0LTMuMzQsOC41OWgtLjAyYy0uNiwuNDktMS4yOCwuODYtMi4wMSwxLjExbC0xMy4wOSw3LjE3Yy0xLjA5LC42NC0yLjM3LC45Ny0zLjc3LC45Ny0xLjkzLDAtNC4xMS0uNjMtNi40MS0xLjk2LTEuMTQtLjY2LTIuMjQtMS40Ni0zLjMtMi4zNmwtMjUuOTIsMTQuOThjLS4xMSwzLjc1LTEuMzMsNi41My0zLjMyLDguMTFoLS4wMmMtLjYsLjQ5LTEuMjgsLjg2LTIuMDEsMS4xMWwtMTMuMDksNy4xN2MtMS4wOSwuNjQtMi4zNywuOTctMy43NywuOTctMS45MywwLTQuMTEtLjYzLTYuNDEtMS45Ni03LjktNC41Ni0xNC4yNy0xNS42MS0xNC4yNS0yNC42OCwwLTEuMTgsLjEzLTIuMjYsLjM0LTMuMjVsLTU3LjY1LTMzLjI4LTEyLjYxLDYuOTFjLTEuMDksLjY0LTIuMzcsLjk3LTMuNzcsLjk3LTEuOTMsMC00LjExLS42My02LjQxLTEuOTYtNy45LTQuNTYtMTQuMjctMTUuNjEtMTQuMjUtMjQuNjgsMC0uMiwuMDItLjM3LC4wMy0uNTYtLjEyLC44NS0uMTgsMS43NC0uMTgsMi42OUw1LjM0LDExNS4yNWwuMDUtMTcuMzlMMjQuOSw0OC42NCw4NS4wNSwxMy42OWwxMy43NCw3LjkzTDEzMC44MiwzbTAtM2MtLjUyLDAtMS4wNCwuMTQtMS41MSwuNDFsLTMwLjUzLDE3Ljc0LTEyLjIzLTcuMDZjLS40Ni0uMjctLjk4LS40LTEuNS0uNC0uNTIsMC0xLjA0LC4xNC0xLjUxLC40MUwyMy4zOSw0Ni4wNWMtLjU4LC4zNC0xLjAzLC44Ni0xLjI4LDEuNDlMMi42LDk2Ljc1Yy0uMTQsLjM1LS4yMSwuNzItLjIxLDEuMWwtLjA1LDE3LjM5YzAsMS4wNywuNTcsMi4wNywxLjUsMi42MWwzMi40MiwxOC43MmMxLjUxLDkuMTEsNy43MiwxOC42OSwxNS40LDIzLjEzLDIuNzEsMS41Nyw1LjM3LDIuMzYsNy45MSwyLjM2LDEuOTIsMCwzLjY4LS40Niw1LjI0LTEuMzZsMTEuMS02LjA4LDU0LjQzLDMxLjQyYy0uMDUsLjU1LS4wNywxLjEtLjA3LDEuNjYtLjAzLDEwLjE5LDYuODksMjIuMTcsMTUuNzUsMjcuMjksMi43MSwxLjU3LDUuMzcsMi4zNiw3LjkxLDIuMzYsMS45MiwwLDMuNjgtLjQ2LDUuMjQtMS4zNmwxMi44Ni03LjA0Yy45NC0uMzYsMS44MS0uODUsMi41OS0xLjQ2LC4wMi0uMDIsLjA0LS4wMywuMDYtLjA1aDBjMi40Mi0xLjkzLDMuODktNC45LDQuMzItOC42NWwyMi43OS0xMy4xN2MuNjksLjUsMS4zOCwuOTUsMi4wNiwxLjM0LDIuNzEsMS41Nyw1LjM3LDIuMzYsNy45MSwyLjM2LDEuOTIsMCwzLjY4LS40Niw1LjI0LTEuMzZsMTIuODYtNy4wNGMuOTQtLjM2LDEuODEtLjg1LDIuNTktMS40NiwuMDItLjAyLC4wNC0uMDMsLjA2LS4wNWgwYzIuODctMi4yOSw0LjQ1LTYuMTcsNC40Ny0xMC45MywwLS45OC0uMDctMi4wMS0uMi0zLjA4bDUuMjctMy4wNGMuOTMtLjU0LDEuNS0xLjUzLDEuNS0yLjYxbC0uMzQtOTMuNjFjMC0xLjA3LS41OC0yLjA1LTEuNS0yLjU5TDEzMi4zMiwuNGMtLjQ2LS4yNy0uOTgtLjQtMS41LS40aDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9zdmc+", "isIsometric": true, "collection": "isoflow" }, { "id": "truck", "name": "truck", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNDAuODEgMjYwLjU1Ij48cGF0aCBpZD0iU2hhZG93IiBkPSJNMTMuNzUsMTY3Ljc4bDcxLjM5LTQwLjIxYzEuNTMtLjg5LDMuNDEtLjksNC45NS0uMDJsMTQ4LjIzLDg0LjUxYzMuMzEsMS44OSwzLjM0LDYuNjYsLjA1LDguNTlsLTY1LjM1LDM3LjM0Yy01Ljc1LDMuMzYtMTIuODUsMy40MS0xOC42NCwuMTFMMTMuNzgsMTc2LjM4Yy0zLjMyLTEuODktMy4zNC02LjY3LS4wNC04LjU5WiIgZmlsbD0iIzAxMDEwMSIgaXNvbGF0aW9uPSJpc29sYXRlIiBvcGFjaXR5PSIuNCIvPjxnIGlkPSJCb2R5Ij48ZyBpZD0iUmVhcldoZWVsIj48cGF0aCBkPSJNNTQuMywxNjQuMTVjLjAzLTkuMDctNi4zNS0yMC4xMi0xNC4yNS0yNC42OC0zLjgtMi4xOS03LjI1LTIuNDctOS44Mi0xLjE3aDBsLS4yLC4xcy0uMSwuMDUtLjE1LC4wOGwtMTMuNTYsNy4zNXYuMDJjLTIuOTgsMS4yOS00Ljg5LDQuNi00LjksOS41Mi0uMDMsOS4wNyw2LjM1LDIwLjEyLDE0LjI1LDI0LjY4LDMuOTgsMi4zLDcuNTgsMi41LDEwLjE4LC45OWwxMy4wOS03LjE3Yy43My0uMjUsMS40LS42MiwyLjAxLTEuMWguMDJjMi4wNy0xLjY3LDMuMzMtNC42MSwzLjM0LTguNjFaIiBmaWxsPSIjMmIyYjJiIi8+PGVsbGlwc2UgY3g9IjI1Ljc2IiBjeT0iMTYzLjkiIHJ4PSI2LjQ4IiByeT0iMTEuMTkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC03OC44NyAzNS4xMykgcm90YXRlKC0zMC4xNikiIGZpbGw9IiNlZmVmZWYiLz48L2c+PGcgaWQ9IlVuZGVyQ2FycmlhZ2UiPjxnIGlzb2xhdGlvbj0iaXNvbGF0ZSI+PHBvbHlnb24gcG9pbnRzPSIxOTIuMDMgMTc3LjIgMTM5LjE4IDIwNy45MSAxMC42NSAxMzMuNzEgNjMuNSAxMDIuOTkgMTkyLjAzIDE3Ny4yIiBmaWxsPSIjOTk5Ii8+PHBvbHlnb24gcG9pbnRzPSIxOTIuMDMgMTc3LjIgMTkxLjk4IDE5My41IDEzOS4xMyAyMjQuMjEgMTM5LjE4IDIwNy45MSAxOTIuMDMgMTc3LjIiIGZpbGw9IiM4ZThmOGYiLz48cG9seWdvbiBwb2ludHM9IjEzOS4xOCAyMDcuOTEgMTM5LjEzIDIyNC4yMSAxMC42IDE1MC4wMSAxMC42NSAxMzMuNzEgMTM5LjE4IDIwNy45MSIgZmlsbD0iIzcyNzM3MiIvPjwvZz48L2c+PGcgaWQ9IkZyb250V2hlZWwiPjxwYXRoIGQ9Ik0xNTYuMjYsMjIzLjY0Yy4wMy05LjA3LTYuMzUtMjAuMTItMTQuMjUtMjQuNjgtMy44LTIuMTktNy4yNS0yLjQ3LTkuODItMS4xN2gwbC0uMiwuMXMtLjEsLjA1LS4xNSwuMDhsLTEzLjU2LDcuMzV2LjAyYy0yLjk4LDEuMjktNC44OSw0LjYtNC45LDkuNTItLjAzLDkuMDcsNi4zNSwyMC4xMiwxNC4yNSwyNC42OCwzLjk4LDIuMyw3LjU4LDIuNSwxMC4xOCwuOTlsMTMuMDktNy4xN2MuNzMtLjI1LDEuNC0uNjIsMi4wMS0xLjFoLjAyYzIuMDctMS42NywzLjMzLTQuNjEsMy4zNC04LjYxWiIgZmlsbD0iIzJiMmIyYiIvPjxlbGxpcHNlIGN4PSIxMjcuNjciIGN5PSIyMjMuMSIgcng9IjYuNDgiIHJ5PSIxMS4xOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTk0LjgxIDk0LjM1KSByb3RhdGUoLTMwLjE2KSIgZmlsbD0iI2VmZWZlZiIvPjwvZz48ZyBpZD0iU2lkZSI+PHBvbHlnb24gcG9pbnRzPSIzIDEzNS44MSAzLjI2IDQyLjM3IDExMi45MSAxMDUuNjggMTEyLjY0IDE5OS4xMSAzIDEzNS44MSIgZmlsbD0iI2I2YzVkZCIvPjwvZz48cG9seWdvbiBpZD0iVG9wIiBwb2ludHM9IjMuMjYgNDIuMzcgNzEuMDIgMyAxODAuNCA2Ni4xNiAxMTIuOTEgMTA1LjY4IDMuMjYgNDIuMzciIGZpbGw9IiNjZGQ5ZWUiLz48cG9seWdvbiBpZD0iRnJvbnQiIHBvaW50cz0iMTEyLjY0IDE5OS4xMSAxMTIuOTEgMTA1LjY4IDE4MC40IDY2LjE2IDE4MC43NSAxNTkuNzYgMTEyLjY0IDE5OS4xMSIgZmlsbD0iIzY4ODVhYSIvPjxwb2x5Z29uIHBvaW50cz0iMTEyLjY0IDE3My43OCAzIDExMi4wNCAzIDkzLjMxIDExMi42NCAxNTUuMDUgMTEyLjY0IDE3My43OCIgZmlsbD0iI2Q2MDc1NiIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHN0cm9rZS13aWR0aD0iMiIvPjxnIGlkPSJPdXRsaW5lIj48cG9seWdvbiBpZD0iU2lkZS0yIiBwb2ludHM9IjMgMTM1LjgxIDMuMjYgNDIuMzcgMTEyLjkxIDEwNS42OCAxMTIuNjQgMTk5LjExIDMgMTM1LjgxIiBmaWxsPSJub25lIiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLXdpZHRoPSIyIi8+PHBvbHlnb24gaWQ9IkZyb250LTIiIHBvaW50cz0iMTEyLjY0IDE5OS4xMSAxMTIuOTEgMTA1LjY4IDE4MC40IDY2LjE2IDE4MC43NSAxNTkuNzYgMTEyLjY0IDE5OS4xMSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIvPjxsaW5lIGlkPSJVbmRlcmNhcnJpYWdlIiB4MT0iMTEyLjkxIiB5MT0iMjA5Ljc0IiB4Mj0iMTAuNiIgeTI9IjE1MC4wMSIgZmlsbD0iIzcyNzM3MiIgc3Ryb2tlPSIjMWQxZDFiIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS13aWR0aD0iMiIvPjwvZz48L2c+PGcgaWQ9IkNhYiI+PGcgaWQ9IkZyb250LTMiPjxwb2x5Z29uIGlkPSJGcm9udC00IiBwb2ludHM9IjE1Ny4zMiAxNjguMTcgMTc0LjI0IDIzMi4yNCAyMzMuNTQgMTk2LjkxIDIxNi43NyAxMzMuMDcgMTU3LjMyIDE2OC4xNyIgZmlsbD0iIzg1OWViYiIvPjxwb2x5bGluZSBwb2ludHM9IjIxMy44OSAxMzQuNzggMTYwLjMxIDE2Ni42NSAxNzAuNjUgMjA2LjA2IDIyNC42NSAxNzQuODggMjE0LjMxIDEzNS40OCIvPjxwb2x5Z29uIHBvaW50cz0iMTgwLjUzIDIwMC4zNSAyMDEuOTIgMTg4LjAxIDIxNy4xIDE0Ni4xIDIxNC4zMSAxMzUuNDggMjEzLjg5IDEzNC43OCAyMDEuNjYgMTQyLjA1IDE4MC41MyAyMDAuMzUiIGZpbGw9IiM2ODg1YWEiLz48cG9seWdvbiBwb2ludHM9IjE3MC4zNSAyMDQuOTMgMTcwLjY1IDIwNi4wNiAxNzUuNzEgMjAzLjE0IDE5Ni44IDE0NC45NCAxOTAuOCAxNDguNTEgMTcwLjM1IDIwNC45MyIgZmlsbD0iIzY4ODVhYSIvPjxwb2x5Z29uIHBvaW50cz0iMjEzLjg5IDEzNC43OCAxNjAuMzEgMTY2LjY1IDE3MC42NSAyMDYuMDYgMjI0LjY1IDE3NC44OCAyMTMuODkgMTM0Ljc4IiBmaWxsPSJub25lIiBzdHJva2U9IiMxZDFkMWIiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9nPjxwb2x5Z29uIGlkPSJSb29mIiBwb2ludHM9IjExOS43NSAxNDYuNjYgMTc5LjI5IDExMS4yMiAyMTYuNzcgMTMzLjA3IDE1Ny4zMiAxNjguMTcgMTE5Ljc1IDE0Ni42NiIgZmlsbD0iI2NkZDllZSIvPjxnIGlkPSJHcmlsbCI+PHBvbHlnb24gaWQ9IkdyaWxsLTIiIHBvaW50cz0iMTczLjMzIDI0OC40MSAyMzMuNTQgMjEzLjI5IDIzMy41NCAxOTYuOTEgMTc0LjI0IDIzMi4yNCAxNzMuMzMgMjQ4LjQxIiBmaWxsPSIjYThhN2E3Ii8+PHBvbHlnb24gcG9pbnRzPSIxNzQuMjQgMjMyLjI0IDIzMy41NCAxOTYuOTEgMjMzLjU0IDIxMy4yOSAxNzMuMzMgMjQ4LjQxIDE3NC4yNCAyMzIuMjQiIGZpbGw9IiM2ODg1YWEiLz48ZyBpZD0iTGlnaHRzIj48Zz48cG9seWdvbiBwb2ludHM9IjE3Ny42IDIzMy4yMyAxODkuMSAyMjYuMzcgMTg5LjEgMjM2LjU3IDE3Ny43MSAyNDMuMjIgMTc3LjYgMjMzLjIzIiBmaWxsPSIjZmZmIi8+PHBhdGggZD0iTTE4OC4xLDIyOC4xM3Y3Ljg2bC05LjQxLDUuNDktLjA5LTcuNyw5LjUtNS42Nm0yLTMuNTJsLTEzLjUxLDguMDUsLjE0LDEyLjI4LDEzLjM3LTcuOHYtMTIuNTNoMFoiIGZpbGw9IiMxZDFkMWIiLz48L2c+PGc+PHBvbHlnb24gcG9pbnRzPSIyMTkuMiAyMDguMjcgMjMxLjEyIDIwMS4xNyAyMzEuMTIgMjExLjkgMjE5LjI3IDIxOC44MSAyMTkuMiAyMDguMjciIGZpbGw9IiNmZmYiLz48cGF0aCBkPSJNMjMwLjEyLDIwMi45M3Y4LjRsLTkuODcsNS43Ni0uMDUtOC4yNCw5LjkyLTUuOTFtMi0zLjUybC0xMy45Myw4LjMsLjA5LDEyLjg0LDEzLjg0LTguMDh2LTEzLjA3aDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9nPjwvZz48L2c+PGcgaWQ9IlNpZGUtMyI+PHBhdGggaWQ9IlJpZ2h0U2lkZSIgZD0iTTE1NC44NywxNjUuODNsLTE1LjY2LTkuMDQtMTcuMTQtOS44OWMtMS44MS0xLjA1LTMuMjktLjIxLTMuMjksMS44OGwtLjA4LDI2LjYtLjA3LDI0Ljg5LDkuNiw1LjU0YzQuMjEsMi40NCw4LDYuNiwxMC43NSwxMS4zNiwyLjc2LDQuNzcsNC40NSwxMC4xNSw0LjQ0LDE1bDExLjA2LDYuMzgsMTYuNzIsOS42NWMxLjk3LDEuMTQsMy40LS4xOCwyLjktMi42NmwuMTQtMTMuMy0xNS4zOS02MC41OGMtLjQ3LTIuMzUtMi4xMS00Ljc1LTMuOTgtNS44MyIgZmlsbD0iI2I2YzVkZCIvPjxwYXRoIGlkPSJTaWRlV2luZG93IiBkPSJNMTY3LjU5LDIwNi4wNmwtOC43NC0zNC40MWMtLjQ3LTIuMzUtMi4xMS00Ljc1LTMuOTgtNS44M2wtMTUuNjYtOS4wNC0xNy4xNC05Ljg5Yy0xLjgxLTEuMDUtMy4yOS0uMjEtMy4yOSwxLjg4bC0uMDgsMjYuNnYyLjRsNDguODgsMjguMjhaIi8+PC9nPjxnIGlkPSJPdXRsaW5lLTIiPjxwb2x5bGluZSBpZD0iUm9vZi0yIiBwb2ludHM9IjE1Ny4zMiAxNjguMTcgMTIwLjQ2IDE0Ny4wNiAxODEuOSAxMTAuNTkiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzFkMWQxYiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiLz48cG9seWxpbmUgaWQ9IlJpZ2h0U2lkZS0yIiBwb2ludHM9IjIxNi43NyAxMzMuMDcgMTU3LjMyIDE2OC4xNyAxNzQuMjQgMjMyLjI0IDE3NC4yNCAyNTAuMjciIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzFkMWQxYiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2Utd2lkdGg9IjIiLz48ZyBpZD0iUmlnaHRTaWRlLTMiPjxwYXRoIGQ9Ik0xMjAuODEsMTQ4LjQ5Yy4wNywuMDMsLjE2LC4wNywuMjcsLjEzbDE3LjE0LDkuODksMTUuNjYsOS4wNGMxLjM3LC43OSwyLjY2LDIuNzIsMy4wMiw0LjQ5di4wNWwuMDIsLjA1LDE1LjMzLDYwLjMzLS4xNCwxMy4wNHYuMjFsLjA0LC4yMWMuMDQsLjIyLC4wNiwuNCwuMDYsLjUzbC0xNi43Mi05LjY1LTEwLjA4LTUuODJjLS4yMy00LjczLTEuODctOS45NS00LjY4LTE0LjgzLTMuMDQtNS4yNi03LjExLTkuNTUtMTEuNDgtMTIuMDlsLTguNTktNC45NiwuMDctMjMuNzMsLjA4LTI2LjZjMC0uMTEsMC0uMjEsLjAyLS4yOG0tLjItMi4wNWMtMS4wOCwwLTEuODEsLjg1LTEuODIsMi4zM2wtLjA4LDI2LjYtLjA3LDI0Ljg5LDkuNiw1LjU0YzQuMjEsMi40NCw4LDYuNiwxMC43NSwxMS4zNiwyLjc2LDQuNzcsNC40NSwxMC4xNSw0LjQ0LDE1bDExLjA2LDYuMzgsMTYuNzIsOS42NWMuNDksLjI4LC45NCwuNDEsMS4zNCwuNDEsMS4yMiwwLDEuOTQtMS4yMSwxLjU2LTMuMDdsLjE0LTEzLjMtMTUuMzktNjAuNThjLS40Ny0yLjM1LTIuMTEtNC43NS0zLjk4LTUuODNsLTE1LjY2LTkuMDQtMTcuMTQtOS44OWMtLjUzLS4zMS0xLjAzLS40NS0xLjQ3LS40NWgwWiIgZmlsbD0iIzFkMWQxYiIvPjwvZz48L2c+PC9nPjxnIGlkPSJPdXRsaW5lLTMiPjxwYXRoIGQ9Ik03MS4wMiwzbDEwOS4zOSw2My4xNiwuMTcsNDUuODIsMzYuMiwyMS4xLDE2Ljc3LDYzLjg0djE2LjM4bC02MC4xNywzNS4xaC0uMDJsLS4wMiwuMDJoMGMtLjIzLC4xMy0uNDksLjItLjc5LC4yLS40LDAtLjg1LS4xMy0xLjM0LS40MWwtMTYuNzItOS42NS02LjY2LTMuODUtOS42Nyw1LjYyLS4zNSwuMmMtMS4wOSwuNjQtMi4zNywuOTctMy43NywuOTctMS45MywwLTQuMTEtLjYzLTYuNDEtMS45Ni03LjktNC41Ni0xNC4yNy0xNS42MS0xNC4yNS0yNC42OCwwLS41MiwuMDMtMS4wMSwuMDctMS40OSwuMDItLjI4LC4wNy0uNTMsLjEtLjgsLjAzLS4xNywuMDUtLjM1LC4wOC0uNTIsLjA2LS4zNCwuMTQtLjY3LC4yMi0uOTksLjAyLS4wNiwuMDMtLjEyLC4wNS0uMTcsLjEtLjM2LC4yMS0uNzEsLjM0LTEuMDRsLTY0LjY3LTM3LjM0Yy0uNTcsLjcxLTEuMjIsMS4zLTEuOTYsMS43M2wtMTEuNzEsNi44aDBjLTEuMDksLjYzLTIuMzYsLjk2LTMuNzYsLjk2LTEuOTMsMC00LjExLS42My02LjQxLTEuOTYtNy45LTQuNTYtMTQuMjctMTUuNjEtMTQuMjUtMjQuNjgsMC0xLjY4LC4yMy0zLjE4LC42NS00LjQ3bC0xLjUxLS44NywuMDMtOS43OS03LjYzLTQuNDEsLjI2LTkzLjQ0TDcxLjAyLDNtNjYuNzgsMjM3LjUyaDBtMCwwaDBNNzEuMDIsMGMtLjUyLDAtMS4wNCwuMTQtMS41MSwuNDFMMS43NiwzOS43OGMtLjkyLC41NC0xLjQ5LDEuNTItMS40OSwyLjU5TDAsMTM1LjhjMCwxLjA3LC41NywyLjA3LDEuNSwyLjYxbDYuMTMsMy41NC0uMDIsOC4wNmMwLC45MSwuNDEsMS43NiwxLjA5LDIuMzItLjE1LC45Ni0uMjMsMS45Ny0uMjMsMy4wMi0uMDMsMTAuMTksNi44OSwyMi4xNywxNS43NSwyNy4yOSwyLjcxLDEuNTcsNS4zNywyLjM2LDcuOTEsMi4zNiwxLjg2LDAsMy41Ny0uNDMsNS4xLTEuMjcsLjA2LS4wMywuMTItLjA2LC4xOC0uMDlsMTEuNzEtNi44Yy4zMS0uMTgsLjYxLS4zOCwuOS0uNmw2MC43MSwzNS4wNWMtLjAyLC4wOC0uMDMsLjE3LS4wNSwuMjUtLjAzLC4xNC0uMDUsLjMxLS4wNywuNDdsLS4wMiwuMTQtLjAyLC4xN2MtLjA0LC4yNi0uMDcsLjUyLS4xLC44LS4wNSwuNTktLjA4LDEuMTYtLjA4LDEuNzQtLjAzLDEwLjE5LDYuODksMjIuMTcsMTUuNzUsMjcuMjksMi43MSwxLjU3LDUuMzcsMi4zNiw3LjkxLDIuMzYsMS44NSwwLDMuNTYtLjQzLDUuMDktMS4yNywuMTItLjA2LC4yMy0uMTIsLjM0LS4xOWwuMi0uMTIsOC4xNy00Ljc0LDUuMTYsMi45OCwxNi43Miw5LjY1Yy45MywuNTQsMS44OSwuODEsMi44NCwuODEsLjc1LDAsMS40OC0uMTgsMi4xMi0uNTEsLjA2LS4wMywuMTItLjA2LC4xNy0uMDloLjAybC4wMi0uMDJoMGw2MC4xNy0zNS4xYy45Mi0uNTQsMS40OS0xLjUyLDEuNDktMi41OXYtMTYuMzhjMC0uMjYtLjAzLS41MS0uMS0uNzZsLTE2Ljc3LTYzLjg0Yy0uMi0uNzctLjctMS40My0xLjM5LTEuODNsLTM0LjcyLTIwLjI0LS4xNi00NC4xYzAtMS4wNy0uNTgtMi4wNS0xLjUtMi41OUw3Mi41MiwuNGMtLjQ2LS4yNy0uOTgtLjQtMS41LS40aDBaIiBmaWxsPSIjMWQxZDFiIi8+PC9nPjwvc3ZnPg==", "isIsometric": true, "collection": "isoflow" }, { "id": "user", "name": "user", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI2NzYuODg4OThweCIgaGVpZ2h0PSI2MzguNjI3OTlweCIgdmlld0JveD0iMCAwIDY3Ni44ODg5OCA2MzguNjI3OTkiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDY3Ni44ODg5OCA2MzguNjI3OTkiCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnPgoJPHBhdGggb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgZD0iTTQxNS45MzEsNDgxLjE2TDQxNS45MzEsNDgxLjE2Ii8+Cgk8Zz4KCQk8cGF0aCBmaWxsPSIjMjMxRjIwIiBkPSJNNDExLjY1OSw0NzkuMTMxOTlsMy41MTA5OSwyLjAyODAybDMuNTE1MDEtMi4wMjgwMiIvPgoJPC9nPgo8L2c+CjxnPgoJPGc+CgkJPHBhdGggZmlsbD0iI0MzRDVFQSIgZD0iTTM5Ni45MzYsMTgxLjg0N2MwLDQ4LjkyNy0zOS42NjI5OSw2NS42OTItODguNTkyMDEsMzcuNDQzMDFjLTQ4LjkyNy0yOC4yNDgtODguNTktOTAuODEyLTg4LjU5LTEzOS43MzkKCQkJczM5LjY2Mjk5LTY1LjY5Miw4OC41OS0zNy40NDNDMzU3LjI3Mzk5LDcwLjM1NSwzOTYuOTM2LDEzMi45MTgsMzk2LjkzNiwxODEuODQ3eiIvPgoJCTxwYXRoIGZpbGw9IiNGQUZDRkYiIGQ9Ik0yNDMuODM1MDEsOTEuNzcyYzAtNDIuNjgyLDMwLjE4NTk5LTYwLjg4Myw3MC4zNzE5OS00Ni4wODJjLTEuOTM3OTktMS4yNDYtMy44OTItMi40NDUtNS44NjItMy41ODMKCQkJYy00OC45MjctMjguMjQ4LTg4LjU5LTExLjQ4NC04OC41OSwzNy40NDNjMCw0OC45Mjc5OSwzOS42NjI5OSwxMTEuNDkxOTksODguNTksMTM5LjczOQoJCQljNi4yNDcwMSwzLjYwNiwxMi4zMzg5OSw2LjQ3NCwxOC4yMTg5OSw4LjYzOTAxQzI4MC4zNzEsMTk4LjI0MDAxLDI0My44MzUwMSwxMzguNzI5LDI0My44MzUwMSw5MS43NzJ6Ii8+CgkJPHBhdGggZmlsbD0iIzM2NUU3RiIgZD0iTTI0NS43MTUsMTYzLjY3M2wtMjguMzEzLDE2LjEzNjk5bDAuMDAzMDEsMC4wMDRjMC40Mjc5OS0wLjIzMzk5LDAuODY4LTAuNDUyLDEuMzA0LTAuNjc1CgkJCWMwLjI0Mi0wLjEyMzk5LDAuNDc4LTAuMjU0LDAuNzIzMDEtMC4zNzM5OWMwLjgzMi0wLjQwOCwxLjY3Nzk5LTAuNzk0MDEsMi41MzUtMS4xNjI5OWMwLjI4LTAuMTIsMC41NjU5OS0wLjIzMywwLjg0OS0wLjM0ODAxCgkJCWMwLjYzOTAxLTAuMjYzLDEuMjg2LTAuNTE1LDEuOTQwOTktMC43NTVjMC4zMDgtMC4xMTQsMC42MTUwMS0wLjIyNiwwLjkyNS0wLjMzNTAxYzAuNzkyMDEtMC4yNzYsMS41OTMtMC41MzcsMi40MDQwMS0wLjc4CgkJCWMwLjEzOTAxLTAuMDQyMDEsMC4yNzYtMC4wODksMC40MTQ5OS0wLjEzYzAuOTU3LTAuMjgsMS45MjctMC41MzIsMi45MDgtMC43NjdjMC4yNjE5OS0wLjA2MywwLjUyOC0wLjExOSwwLjc5MjAxLTAuMTc5CgkJCWMwLjc3NC0wLjE3NSwxLjU1NC0wLjMzNiwyLjM0Mzk5LTAuNDgzYzAuMjY2MDEtMC4wNDksMC41MzItMC4xMDEsMC44LTAuMTQ3YzIuMDc4OTktMC4zNiw0LjIwOS0wLjYyNjAxLDYuMzg4LTAuNzk2MDEKCQkJYzAuMjI3MDEtMC4wMTcsMC40NTctMC4wMzEwMSwwLjY4Ni0wLjA0N2MwLjkyNzk5LTAuMDYzLDEuODYzMDEtMC4xMSwyLjgwOTAxLTAuMTM4YzAuMTk5MDEtMC4wMDYsMC4zOTYtMC4wMTYwMSwwLjU5Ny0wLjAyCgkJCWMyLjI5NS0wLjA1MDk5LDQuNjM4LDAsNy4wMjgsMC4xNTNjMC4xODYsMC4wMTMsMC4zNzUsMC4wMjY5OSwwLjU2MywwLjAzOTk5YzEuMDkxLDAuMDc4LDIuMTksMC4xNzUsMy4yOTksMC4yOTUKCQkJYzAuMDc4LDAuMDA5LDAuMTUzOTksMC4wMTMsMC4yMzMsMC4wMmMwLjAwMTAxLDAuMDAxMDEsMC4wMDIwMSwwLjAwMiwwLjAwMjAxLDAuMDAyYy0yLjg4My0zLjcyMi01LjYzOC03LjUzNzk5LTguMjUtMTEuNDI3CgkJCUMyNDcuNjg0MDEsMTYyLjM3LDI0Ni42ODQwMSwxNjMsMjQ1LjcxNSwxNjMuNjczeiIvPgoJCTxwYXRoIGZpbGw9IiNDM0Q1RUEiIGQ9Ik00MzAuMDEwMDEsMzc5LjQyM2MtMC4wMTA5OS0wLjM2MzAxLTAuMDIzOTktMC43MjY5OS0wLjA0MDAxLTEuMDkKCQkJYy0wLjA0OTAxLTEuMjE3OTktMC4xMTQwMS0yLjQzOS0wLjE5NjAxLTMuNjY2OTljLTAuMDE0MDEtMC4xNzU5OS0wLjAyMS0wLjM1MTk5LTAuMDM0LTAuNTMKCQkJYy0wLjEwMDAxLTEuMzgxOTktMC4yMjI5OS0yLjc3MzAxLTAuMzY0MDEtNC4xNjhjLTAuMDM2OTktMC4zNTMtMC4wNzctMC43MDctMC4xMTQwMS0xLjA2MjAxCgkJCWMtMC4xMjc5OS0xLjE3Ny0wLjI2OTk5LTIuMzU2OTktMC40Mjg5OS0zLjU0MDk5Yy0wLjAzNC0wLjI3MS0wLjA2NjAxLTAuNTM5LTAuMTA0LTAuODA4OTkKCQkJYy0wLjE5Mjk5LTEuNDA5LTAuNDEtMi44MjQwMS0wLjY0NDAxLTQuMjQyYy0wLjA1MzAxLTAuMzE2OTktMC4xMDk5OS0wLjYzNTAxLTAuMTY0LTAuOTUyCgkJCWMtMC4yMDQ5OS0xLjE5MTAxLTAuNDI0MDEtMi4zODQtMC42NTUtMy41ODJjLTAuMDYyOTktMC4zMTYwMS0wLjEyMjAxLTAuNjM0LTAuMTgzMDEtMC45NTAwMQoJCQljLTAuMjg5LTEuNDM3OTktMC41OTUtMi44ODMtMC45MjMtNC4zMjkwMWMtMC4wNTQ5OS0wLjI0MS0wLjExNDAxLTAuNDgzLTAuMTY4LTAuNzI2MDFjLTAuMjkwOTktMS4yNTUtMC41OTUtMi41MTMtMC45MTUwMS0zLjc3MgoJCQljLTAuMDg0MDEtMC4zMzItMC4xNjY5OS0wLjY2NTk5LTAuMjUyMDEtMC45OTg5OWMtMC4zODE5OS0xLjQ3NTAxLTAuNzc4OTktMi45NTItMS4xOTkwMS00LjQzMQoJCQljLTAuMDI3MDEtMC4wOTc5OS0wLjA1ODAxLTAuMTk2MDEtMC4wODQ5OS0wLjI5NTAxYy0wLjM5OTk5LTEuMzkwOTktMC44MTY5OS0yLjc4NC0xLjI0ODk5LTQuMTc4OTkKCQkJYy0wLjEwMTAxLTAuMzI0MDEtMC4yMDItMC42NDk5OS0wLjMwMzAxLTAuOTc1MDFjLTAuOTQ2MDEtMi45OTc5OS0xLjk2Nzk5LTYuMDAxMDEtMy4wNjIwMS05LjAwNQoJCQljLTAuMTA5MDEtMC4yOTQwMS0wLjIxNS0wLjU4ODAxLTAuMzI0MDEtMC44ODEwMWMtMC41MzY5OS0xLjQ1MDk5LTEuMDg0OTktMi45MDMwMi0xLjY1Mzk5LTQuMzU0CgkJCWMtMC4wMjEtMC4wNDk5OS0wLjA0MDAxLTAuMTAwMDEtMC4wNi0wLjE0OTk5Yy0wLjYwNTk5LTEuNTQ0MDEtMS4yMzQ5OS0zLjA4Ni0xLjg4MTk5LTQuNjI3OTkKCQkJYy0wLjA5NjAxLTAuMjM0MDEtMC4xOTY5OS0wLjQ2ODk5LTAuMjk1MDEtMC43MDNjLTAuNjAwMDEtMS40MjA5OS0xLjIxMzk5LTIuODQxLTEuODQ1LTQuMjYwMDEKCQkJYy0wLjA1MzAxLTAuMTE4MDEtMC4xMDQtMC4yMzctMC4xNTYwMS0wLjM1NTk5Yy0wLjY5OC0xLjU2MS0xLjQxNTk5LTMuMTE4OTktMi4xNDk5OS00LjY3NTk5CgkJCWMtMC4wNjYwMS0wLjE0NDAxLTAuMTM1OTktMC4yODYwMS0wLjIwNDAxLTAuNDI4OTljLTAuNjg5LTEuNDUyLTEuMzkyLTIuODk5OTktMi4xMTA5OS00LjM0Njk4CgkJCWMtMC4wNzEwMS0wLjE0Mi0wLjE0MDk5LTAuMjgyMDEtMC4yMTIwMS0wLjQyNDk5Yy0wLjc5MDAxLTEuNTgyLTEuNTk2OTgtMy4xNTc5OS0yLjQyNDAxLTQuNzMzCgkJCWMtMC4wMDI5OS0wLjAwNS0wLjAwNjAxLTAuMDA5LTAuMDA4LTAuMDE0MDFjLTAuODA4MDEtMS41NDAwMS0xLjYzOTAxLTMuMDc0MDEtMi40ODAwMS00LjYwNAoJCQljLTAuMDc4LTAuMTQwOTktMC4xNTMwMi0wLjI4MTAxLTAuMjMzLTAuNDIwOTljLTEuNzItMy4xMTMwMS0zLjUxMDAxLTYuMjA3LTUuMzY4MDEtOS4yNzcwMQoJCQljLTAuMDY5LTAuMTEzMDEtMC4xMzU5OS0wLjIyNjAxLTAuMjA3LTAuMzM4OTljLTEuODgtMy4wOTktMy44MjkwMS02LjE3My01Ljg0MS05LjIxNzAxCgkJCWMtMC4wNDUwMS0wLjA2Njk5LTAuMDg4OTktMC4xMzMtMC4xMzE5OS0wLjIwMDAxYy0yLjA0NTk5LTMuMDg4MDEtNC4xNTYwMS02LjE0Ni02LjMyOTk5LTkuMTY0CgkJCWMtMC4wMDIwMS0wLjAwMjk5LTAuMDAyOTktMC4wMDY5OS0wLjAwOC0wLjAxMDAxYy03LjY5Njk5LTEwLjY5Mi0xNi4xNzQ5OS0yMC45MTI5OS0yNS4yNjAwMS0zMC4zOTcKCQkJYy0xNC40OTYsMS45NzQtMzIuMjM1OTktMi4xODMtNTEuMzk4OTktMTMuMjQ4Yy0xOS4xNjEwMS0xMS4wNjMtMzYuOTAxLTI3LjM4ODk5LTUxLjM5NDk5LTQ2LjA5OQoJCQljLTAuMDc5MDEtMC4wMDktMC4xNTcwMS0wLjAxNjAxLTAuMjM1OTktMC4wMjI5OWMtMS4xMDg5OS0wLjEyLTIuMjA3OTktMC4yMTctMy4yOTktMC4yOTUKCQkJYy0wLjE4OC0wLjAxMy0wLjM3NjAxLTAuMDI2OTktMC41NjMtMC4wMzk5OWMtMi4zOS0wLjE1My00LjczMy0wLjIwMzk5LTcuMDI4LTAuMTUzYy0wLjIwMSwwLjAwNS0wLjM5OSwwLjAxNDAxLTAuNTk3LDAuMDIKCQkJYy0wLjk0NiwwLjAyOC0xLjg4MSwwLjA3NDAxLTIuODA5MDEsMC4xMzhjLTAuMjI5LDAuMDE2MDEtMC40NTc5OSwwLjAyOTAxLTAuNjg2LDAuMDQ3Yy0yLjE3OSwwLjE2OTAxLTQuMzA4LDAuNDM2LTYuMzg4LDAuNzk2MDEKCQkJYy0wLjI2ODAxLDAuMDQ3LTAuNTM0LDAuMDk3LTAuOCwwLjE0N2MtMC43ODk5OSwwLjE0Ny0xLjU3MDAxLDAuMzA5MDEtMi4zNDM5OSwwLjQ4M2MtMC4yNjQwMSwwLjA2LTAuNTMsMC4xMTUwMS0wLjc5MjAxLDAuMTc5CgkJCWMtMC45ODE5OSwwLjIzNS0xLjk1MiwwLjQ4Ny0yLjkwOCwwLjc2N2MtMC4xNCwwLjA0MS0wLjI3Njk5LDAuMDg4LTAuNDE0OTksMC4xM2MtMC44MTIsMC4yNDQtMS42MTIsMC41MDUtMi40MDQwMSwwLjc4CgkJCWMtMC4zMSwwLjEwOC0wLjYxOSwwLjIyMDk5LTAuOTI1LDAuMzM1MDFjLTAuNjU0MDEsMC4yNDAwMS0xLjMwMDk5LDAuNDkzLTEuOTQwOTksMC43NTUKCQkJYy0wLjI4NCwwLjExNTAxLTAuNTcwMDEsMC4yMjgtMC44NDksMC4zNDgwMWMtMC44NTgsMC4zNjktMS43MDM5OSwwLjc1NC0yLjUzNSwxLjE2Mjk5Yy0wLjI0NSwwLjEyLTAuNDgxLDAuMjUtMC43MjMwMSwwLjM3Mzk5CgkJCWMtMTkuOTI3OTksMTAuMTY5MDEtMzIuMTA1LDMyLjA1MDk5LTMyLjEwNSw2My44MTdWNDA5LjU2bDI0My40ODA5OSwxNDAuNTczVjM4My41MwoJCQlDNDMwLjA4NzAxLDM4Mi4xNjgsNDMwLjA1NDk5LDM4MC43OTgsNDMwLjAxMDAxLDM3OS40MjN6Ii8+CgkJPHBhdGggZmlsbD0iI0ZBRkNGRiIgZD0iTTIwOS4xNjkwMSw0MjAuNDExOTlWMjUzLjgwOTAxYzAtMzEuNzY1LDEyLjE3Ny01My42NDc5OSwzMi4xMDUtNjMuODE3CgkJCWMwLjI0Mi0wLjEyMywwLjQ3OS0wLjI1NCwwLjcyMzAxLTAuMzczOTljMC44MzItMC40MDgsMS42Nzc5OS0wLjc5NSwyLjUzNS0xLjE2Mjk5YzAuMjgtMC4xMiwwLjU2NTk5LTAuMjMxOTksMC44NDktMC4zNDgwMQoJCQljMC42NC0wLjI2MTk5LDEuMjg2LTAuNTE1LDEuOTQwOTktMC43NTVjMC4zMDcwMS0wLjExNCwwLjYxNTAxLTAuMjI2LDAuOTI1LTAuMzM1MDFjMC43OTIwMS0wLjI3NiwxLjU5Mi0wLjUzNywyLjQwNDAxLTAuNzgKCQkJYzAuMTM5MDEtMC4wNDIwMSwwLjI3Ni0wLjA4OSwwLjQxNi0wLjEzYzAuOTU1OTktMC4yNzkwMSwxLjkyNTk5LTAuNTMyLDIuOTA3LTAuNzY2MDFjMC4yNjE5OS0wLjA2MywwLjUyOC0wLjExOSwwLjc5My0wLjE3OQoJCQljMC43NzI5OS0wLjE3NSwxLjU1NC0wLjMzNTAxLDIuMzQzLTAuNDgxOTljMC4yNjU5OS0wLjA0OSwwLjUzMjAxLTAuMTAxLDAuNzk5OTktMC4xNDdjMi4wNzkwMS0wLjM2LDQuMjA5MDEtMC42MjYwMSw2LjM4OC0wLjc5NQoJCQljMC4yMjY5OS0wLjAxODAxLDAuNDU3LTAuMDMxMDEsMC42ODYtMC4wNDdjMC4yMTMwMS0wLjAxNjAxLDAuNDMyMDEtMC4wMTksMC42NDQ5OS0wLjAzMgoJCQljLTMuMDAyMDEtMy4zODkwMS01Ljg5OTk5LTYuODg0LTguNjc4MDEtMTAuNDY4OTljLTAuMDc5MDEtMC4wMDktMC4xNTcwMS0wLjAxNjAxLTAuMjM1OTktMC4wMjI5OQoJCQljLTEuMTA4OTktMC4xMi0yLjIwNzk5LTAuMjE3LTMuMjk5LTAuMjk1Yy0wLjE4OC0wLjAxMy0wLjM3NjAxLTAuMDI2OTktMC41NjMtMC4wMzk5OWMtMi4zOS0wLjE1My00LjczMy0wLjIwMzk5LTcuMDI4LTAuMTUzCgkJCWMtMC4yMDEsMC4wMDUtMC4zOTksMC4wMTQwMS0wLjU5NywwLjAyYy0wLjk0NiwwLjAyOC0xLjg4MSwwLjA3NDAxLTIuODA5MDEsMC4xMzhjLTAuMjI5LDAuMDE2MDEtMC40NTc5OSwwLjAyOTAxLTAuNjg2LDAuMDQ3CgkJCWMtMi4xNzksMC4xNjkwMS00LjMwOCwwLjQzNi02LjM4OCwwLjc5NjAxYy0wLjI2ODAxLDAuMDQ3LTAuNTM0LDAuMDk3LTAuOCwwLjE0N2MtMC43ODk5OSwwLjE0Ny0xLjU3MDAxLDAuMzA5MDEtMi4zNDM5OSwwLjQ4MwoJCQljLTAuMjY0MDEsMC4wNi0wLjUzLDAuMTE1MDEtMC43OTIwMSwwLjE3OWMtMC45ODE5OSwwLjIzNS0xLjk1MiwwLjQ4Ny0yLjkwOCwwLjc2N2MtMC4xNCwwLjA0MS0wLjI3Njk5LDAuMDg4LTAuNDE0OTksMC4xMwoJCQljLTAuODEyLDAuMjQ0LTEuNjEyLDAuNTA1LTIuNDA0MDEsMC43OGMtMC4zMSwwLjEwOC0wLjYxOSwwLjIyMDk5LTAuOTI1LDAuMzM1MDFjLTAuNjU0MDEsMC4yNDAwMS0xLjMwMDk5LDAuNDkzLTEuOTQwOTksMC43NTUKCQkJYy0wLjI4NCwwLjExNTAxLTAuNTcwMDEsMC4yMjgtMC44NDksMC4zNDgwMWMtMC44NTgsMC4zNjktMS43MDM5OSwwLjc1NC0yLjUzNSwxLjE2Mjk5Yy0wLjI0NSwwLjEyLTAuNDgxLDAuMjUtMC43MjMwMSwwLjM3Mzk5CgkJCWMtMTkuOTI3OTksMTAuMTY5MDEtMzIuMTA1LDMyLjA1MDk5LTMyLjEwNSw2My44MTdWNDA5LjU2bDI0My40ODA5OSwxNDAuNTczdi0yLjE3Mjk3TDIwOS4xNjkwMSw0MjAuNDExOTl6Ii8+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTQzMC4wODcwMSwxNjIuODYyYzAsMjMuMTE4LTguODU2OTksMzkuMDU0OTktMjMuMzYzMDEsNDYuNDUzOTlsLTI5LjUyODk5LDE3LjA2M2wtMC4wOTUtMC4xMjEKCQkJYzEyLjQwMS04LjA4NTAxLDE5LjgzNzAxLTIzLjIzMSwxOS44MzcwMS00NC40MTI5OWMwLTM4LjQxNTAxLTI0LjQ1My04NS4yNDQtNTguNjQ3LTExNy4yNTYKCQkJYy00LjA5LTMuODM2LTguMzIwMDEtNy40NTUtMTIuNjY1MDEtMTAuODEyYy01LjU4NDAxLTQuMzM0LTExLjM2Ni04LjI1NS0xNy4yOC0xMS42NjhjLTAuMjM0OTktMC4xNDEtMC40NzktMC4yNzMtMC43MTM5OS0wLjQwNAoJCQljLTI1LjE0OTk5LTE0LjMyOC00Ny43ODc5OS0xNi43MjYtNjMuNzk5LTguOTY5bDI4LjM3NTAyLTE2LjIyN2MxLjU5Nzk5LTEuMDgxLDMuMjcxLTIuMDQsNS4wMjg5OS0yLjg3NwoJCQljMTYuMTQyLTcuNjgxLDM4Ljk2MS01LjExNCw2NC4yNTksOS40ODVjMC40NjEsMC4yNjQsMC45MjIsMC41MzcsMS4zODMsMC44MTljNS4yMzcsMy4xMDMsMTAuMzcsNi41OSwxNS4zNDI5OSwxMC40MjYKCQkJYzQuNTk2OTgsMy41MjYsOS4wNjI5OSw3LjMzMywxMy4zNzc5OSwxMS4zODVDNDA1LjcwODAxLDc3Ljc3LDQzMC4wODcwMSwxMjQuNTA1LDQzMC4wODcwMSwxNjIuODYyeiIvPgoJCTxwYXRoIGZpbGw9IiNFOUYyRkYiIGQ9Ik0zNDIuODc3OTksMjMuOTM3TDM0Mi4wNiwyNS4yNDRsLTMwLjY5NTk4LDE2LjQ2MmgtMy43MzE5OQoJCQljLTI1LjE0OTk5LTE0LjMyOC00Ny43ODc5OS0xNi43MjYtNjMuNzk5LTguOTY5bDI4LjM3NDk4LTE2LjIyN2MxLjU5Njk4LTEuMDgxLDMuMjcxLTIuMDQsNS4wMjg5OS0yLjg3NwoJCQljMTYuMTQyLTcuNjgxLDM4Ljk2MS01LjExNCw2NC4yNTksOS40ODVDMzQxLjk1NTk5LDIzLjM4MiwzNDIuNDE2OTksMjMuNjU0LDM0Mi44Nzc5OSwyMy45Mzd6Ii8+CgkJPHBhdGggZmlsbD0iI0U5RjJGRiIgZD0iTTM3MS41OTksNDUuNzQ5bC0yLjExNiwwLjYwMmwtMzAuNDQxMDEsMTYuNDA2bC0wLjc1MjAxLDEuODMzYy00LjA5LTMuODM2LTguMzIwMDEtNy40NTUtMTIuNjY1MDEtMTAuODEyCgkJCWwwLjk0LTEuNjQ1bDI5Ljg2MDk5LTE2Ljc3M2wxLjc5NTAxLTAuOTk2QzM2Mi44MTc5OSwzNy44ODgsMzY3LjI4NSw0MS42OTcsMzcxLjU5OSw0NS43NDl6Ii8+CgkJPGc+CgkJCTxwYXRoIGZpbGw9IiM2ODg1QTkiIGQ9Ik0yMzkuMjYxOTksMzUuMzU3TDIzOS4yNjE5OSwzNS4zNTdMMjM5LjI2MTk5LDM1LjM1N3oiLz4KCQk8L2c+CgkJPHBhdGggZmlsbD0iIzY4ODVBOSIgZD0iTTM5NS4yNjMsMjE1LjkzN0wzNzcuMTg5LDIyNi4zNzk5OWwtMC4wODcwMS0wLjEyMTk5YzAuMTAxMDEtMC4wNjcsMC4xOTQtMC4xNDMwMSwwLjI5NTAxLTAuMjA5CgkJCWMtNS4wOTYwMSwzLjM5Mi0xMS4wMzQsNS41ODgtMTcuNjYxMDEsNi40ODljOS4wODgwMSw5LjQ4NTk5LDE3LjU3MDAxLDE5LjcwMiwyNS4yNjgwMSwzMC4zOTcKCQkJYzAuMDA1LDAuMDAyOTksMC4wMDYwMSwwLjAwNjk5LDAuMDA4LDAuMDEwMDFjMi4xNzQwMSwzLjAxOTAxLDQuMjg1LDYuMDc1OTksNi4zMjk5OSw5LjE2NAoJCQljMC4wNDMsMC4wNjY5OSwwLjA4NzAxLDAuMTMzLDAuMTMxOTksMC4yMDAwMWMyLjAxMTk5LDMuMDQ0MDEsMy45NTk5OSw2LjExODAxLDUuODQxLDkuMjE3MDEKCQkJYzAuMDcxMDEsMC4xMTQwMSwwLjEzOCwwLjIyNjAxLDAuMjA3LDAuMzM4OTljMS44NTgsMy4wNzAwMSwzLjY0ODAxLDYuMTYyOTksNS4zNjgwMSw5LjI3NzAxCgkJCWMwLjA3OTk5LDAuMTM5MDEsMC4xNTM5OSwwLjI4LDAuMjMzLDAuNDIwOTljMC44NDEsMS41MywxLjY3MywzLjA2NCwyLjQ4MDAxLDQuNjA0YzAuMDAyMDEsMC4wMDUsMC4wMDUsMC4wMDksMC4wMDgsMC4wMTQwMQoJCQljMC44MjcsMS41NzUwMSwxLjYzNCwzLjE1MSwyLjQyNDAxLDQuNzMzYzAuMDcxMDEsMC4xNDMwMSwwLjE0MDk5LDAuMjgyOTksMC4yMTIwMSwwLjQyNDk5CgkJCWMwLjcxODk5LDEuNDQ2OTksMS40MjMsMi44OTYsMi4xMTA5OSw0LjM0NzAyYzAuMDY2OTksMC4xNDMwMSwwLjEzOCwwLjI4NSwwLjIwNDAxLDAuNDI4OTkKCQkJYzAuNzM0OTksMS41NTcwMSwxLjQ1MywzLjExNiwyLjE0OTk5LDQuNjc1OTljMC4wNTIsMC4xMTgwMSwwLjEwMywwLjIzNywwLjE1NjAxLDAuMzU1OTkKCQkJYzAuNjMxOTksMS40MTkwMSwxLjI0NiwyLjgzODAxLDEuODQ1LDQuMjYwMDFjMC4wOTc5OSwwLjIzNDAxLDAuMTk5MDEsMC40Njg5OSwwLjI5NTAxLDAuNzAzCgkJCWMwLjY0NywxLjU0MDk5LDEuMjc2LDMuMDg0MDEsMS44ODE5OSw0LjYyNzk5YzAuMDE5OTksMC4wNDk5OSwwLjAzNzk5LDAuMTAwMDEsMC4wNiwwLjE0OTk5CgkJCWMwLjU3MDAxLDEuNDUwOTksMS4xMTcsMi45MDMwMiwxLjY1Mzk5LDQuMzU0YzAuMTA5MDEsMC4yOTQwMSwwLjIxNSwwLjU4ODAxLDAuMzI0MDEsMC44ODEwMQoJCQljMS4wOTM5OSwzLjAwNCwyLjExNiw2LjAwOCwzLjA2MjAxLDkuMDA1YzAuMTAxMDEsMC4zMjQwMSwwLjIwMiwwLjY0OTk5LDAuMzAzMDEsMC45NzUwMQoJCQljMC40MzIwMSwxLjM5NDk5LDAuODQ5LDIuNzg3OTksMS4yNDg5OSw0LjE3ODk5YzAuMDI3MDEsMC4xMDAwMSwwLjA1ODAxLDAuMTk2OTksMC4wODQ5OSwwLjI5NTAxCgkJCWMwLjQyMDk5LDEuNDgwMDEsMC44MTY5OSwyLjk1NTk5LDEuMTk5MDEsNC40MzFjMC4wODQ5OSwwLjMzNDAxLDAuMTY4LDAuNjY1OTksMC4yNTIwMSwwLjk5ODk5CgkJCWMwLjMyMDAxLDEuMjU5LDAuNjIzOTksMi41MTcsMC45MTUwMSwzLjc3MmMwLjA1NDk5LDAuMjQyLDAuMTE0MDEsMC40ODQ5OSwwLjE2OCwwLjcyNTAxCgkJCWMwLjMyOCwxLjQ0NjAxLDAuNjM0LDIuODkwMDEsMC45MjMsNC4zMjkwMWMwLjA2MSwwLjMxNjAxLDAuMTIxLDAuNjM0LDAuMTgzMDEsMC45NTAwMWMwLjIzMDk5LDEuMTk4LDAuNDUwMDEsMi4zOTIsMC42NTUsMy41ODIKCQkJYzAuMDUzMDEsMC4zMTY5OSwwLjEwOTk5LDAuNjM1MDEsMC4xNjQsMC45NTJjMC4yMzQwMSwxLjQxOTAxLDAuNDUwOTksMi44MzIsMC42NDQwMSw0LjI0MgoJCQljMC4wMzc5OSwwLjI3MSwwLjA3MTAxLDAuNTM5LDAuMTA0LDAuODA4OTljMC4xNTksMS4xODUsMC4yOTk5OSwyLjM2NDAxLDAuNDI4OTksMy41NDA5OQoJCQljMC4wMzY5OSwwLjM1NTAxLDAuMDc3LDAuNzA4MDEsMC4xMTQwMSwxLjA2MjAxYzAuMTQwOTksMS4zOTQ5OSwwLjI2NTAxLDIuNzg2OTksMC4zNjQwMSw0LjE2OAoJCQljMC4wMTMsMC4xNzgwMSwwLjAxOTk5LDAuMzUzLDAuMDM0LDAuNTNjMC4wODIsMS4yMjY5OSwwLjE0NywyLjQ0OCwwLjE5NjAxLDMuNjY2OTkKCQkJYzAuMDE1OTksMC4zNjQwMSwwLjAyODk5LDAuNzI2OTksMC4wNDAwMSwxLjA5YzAuMDQ1MDEsMS4zNzYwMSwwLjA3NywyLjc0NSwwLjA3Nyw0LjEwNTAxdjE2Ni42MDNsMzMuMTQ4OTktMTkuMTE0OTlWMzY0LjQxMTAxCgkJCUM0NjMuMjM0OTksMzE2LjQ4MDk5LDQzNS41MjgwMiwyNTkuMDQwMDEsMzk1LjI2MywyMTUuOTM3eiIvPgoJCTxwYXRoIGZpbGw9IiMyMzFGMjAiIGQ9Ik00MDkuOTk1LDIxOC4yODJsMS4yMjUwMS0wLjcwNzk5YzE4LjIzMDk5LTkuNDE5MDEsMjguMjY1OTktMjguODM3MDEsMjguMjY1OTktNTQuNzExCgkJCWMwLTI1LjQyMy05LjczNDAxLTU0Ljc3OS0yNy40MDktODIuNjU5Yy0xNy41My0yNy42NTItNDAuOTI4MDEtNTAuODE1LTY1Ljg4My02NS4yMjNDMzI5LjIyLDUuMTgsMzEyLjMwMDk5LDAsMjk3LjI2NTk5LDAKCQkJYy04LjgwNDk5LDAtMTYuOTA1LDEuNzMxLTI0LjA3NDAxLDUuMTQzYy0yLjA0NTk5LDAuOTc1LTQuMDQ0MDEsMi4xMDgtNS45NDI5OSwzLjM3TDIzOS40MzEsMjQuNDI4CgkJCWMtMTguNzU0LDkuMjQ2LTI5LjA3ODk5LDI4LjgwNy0yOS4wNzg5OSw1NS4xMjA5OWMwLDI0LjUyMSw4Ljg5Miw1Mi4zMDA5OSwyNS4xOTQsNzkuMDk4MDFMMjEyLjgxLDE3MS42MDUwMQoJCQljLTIyLjk2MjAxLDEyLjYyNS0zNS42MDY5OSwzNy45NjQtMzUuNjA2OTksNzEuMzUwMDF2MTcyLjAzMTAxbDI1Mi44Nzc5OSwxNDUuOTk4OTZsNDIuNTU0MDItMjQuNTM4MDJWMzY0LjQxMTAxCgkJCUM0NzIuNjM2OTksMzE4LjQ0Njk5LDQ0OC44NTUwMSwyNjMuMzYzMDEsNDA5Ljk5NSwyMTguMjgyeiBNMjczLjE0MDAxLDE4LjE0bDAuMTE4OTktMC4wNzQKCQkJYzEuNTI0OTktMS4wMywzLjEzNTAxLTEuOTUyLDQuNzg1LTIuNzM4YzUuNjQwOTktMi42ODQsMTIuMTA4LTQuMDQ2LDE5LjIyMjk5LTQuMDQ2YzEzLjA1NiwwLDI4LjAyNDk5LDQuNjU4LDQzLjI4Njk5LDEzLjQ2OAoJCQljNDguMzMyLDI3LjkwNCw4Ny42NTIwMSw4OS44NjEsODcuNjUyMDEsMTM4LjExMDk5YzAsMjEuNTI3MDEtNy45MzM5OSwzNy40MjktMjIuMzQxLDQ0Ljc3ODk5bC0xMC4yNDg5OSw1LjkyMTAxdi0wLjAwMgoJCQlsLTYuNzA5OTksMy44NzdjMC4zODEwMS0wLjUzMiwwLjc0Nzk5LTEuMDc2LDEuMTA1OTktMS42M2MwLjAzNC0wLjA0OSwwLjA2Njk5LTAuMDk5LDAuMTAwMDEtMC4xNDkKCQkJYzAuMzQ2OTgtMC41NDEsMC42ODM5OS0xLjA5Mzk5LDEuMDEwOTktMS42NTQwMWMwLjAzNjk5LTAuMDYxLDAuMDcxOTktMC4xMjEsMC4xMDkwMS0wLjE4MwoJCQljMC4zMjgtMC41NjU5OSwwLjY0NDAxLTEuMTQzMDEsMC45NTItMS43M2MwLjAyNi0wLjA0OSwwLjA1Mi0wLjEwMDAxLDAuMDc4LTAuMTQ5YzAuMzE1LTAuNjA4LDAuNjIxLTEuMjI1MDEsMC45MTY5OS0xLjg1NgoJCQljMC4wMDI5OS0wLjAwOCwwLjAwNjAxLTAuMDE2MDEsMC4wMTA5OS0wLjAyMjk5YzEuNzk1MDEtMy44NTEsMy4xODIwMS04LjA5OSw0LjEyNzAxLTEyLjcxNQoJCQljMC4wMzEwMS0wLjE0OSwwLjA2Mjk5LTAuMjk2MDEsMC4wOTI5OS0wLjQ0NmMwLjExODAxLTAuNTk1LDAuMjIyOTktMS4xOTcwMSwwLjMyNy0xLjgwNAoJCQljMC4wNDU5OS0wLjI2MywwLjA5LTAuNTI2LDAuMTMxOTktMC43OTIwMWMwLjA4NDk5LTAuNTQyMDEsMC4xNjUwMS0xLjA4OSwwLjI0MS0xLjY0MTAxCgkJCWMwLjA0OC0wLjM1MDAxLDAuMDkyMDEtMC43MDM5OSwwLjEzMy0xLjA1OGMwLjA2LTAuNDk2OTksMC4xMTYtMC45OTYsMC4xNjY5OS0xLjVjMC4wNDUwMS0wLjQzNiwwLjA4MDk5LTAuODc3LDAuMTE4MDEtMS4zMTkKCQkJYzAuMDM2OTktMC40NDcwMSwwLjA3NTAxLTAuODkyLDAuMTA1OTktMS4zNDM5OWMwLjAzNjk5LTAuNTU0OTksMC4wNjEtMS4xMTgsMC4wODcwMS0xLjY4MQoJCQljMC4wMTU5OS0wLjM1OCwwLjAzNjk5LTAuNzExLDAuMDQ5MDEtMS4wNzNjMC4wMzEwMS0wLjkzOSwwLjA0OTAxLTEuODg2LDAuMDQ5MDEtMi44NDU5OQoJCQljMC00OS40NDgtNDAuMTY1MDEtMTEyLjg2Ni04OS41MzE5OC0xNDEuMzY3Yy0yLjk2ODk5LTEuNzE0LTUuOTI4OTktMy4yNzYtOC44NzIwMS00LjY4NAoJCQljLTAuNjY1OTktMC4zMTgtMS4zMjkwMS0wLjYxNy0xLjk5MzAxLTAuOTIxYy0wLjMxMjk5LTAuMTQzLTAuNjI3OTktMC4yOTUtMC45NDEwMS0wLjQzNQoJCQljLTExLjcxNzAxLTUuMjE2LTIzLjA3NTk5LTcuOTM2LTMzLjM2NDk5LTcuOTM2Yy0xLjAyMiwwLTIuMDI3MDEsMC4wMzQtMy4wMTk5OSwwLjA4NWMtMC4xNDQ5OSwwLjAwOC0wLjI4Njk5LDAuMDItMC40MzEsMC4wMjkKCQkJYy0wLjg0NSwwLjA1MS0xLjY4MSwwLjExOS0yLjUwNSwwLjIwOGMtMC4wNzcsMC4wMDgtMC4xNTcwMSwwLjAxLTAuMjMzLDAuMDE5TDI3My4xNDAwMSwxOC4xNHogTTM1NS42MzgsMjMxLjAzNzk5CgkJCWMtMC4zMjgsMC4wMTgwMS0wLjY1Nzk5LDAuMDM2LTAuOTg5OTksMC4wNDljLTAuNjAxOTksMC4wMjItMS4yMDU5OSwwLjAzNy0xLjgxOSwwLjAzOTk5Yy0wLjA4NDAxLDAtMC4xNjgsMC4wMDQtMC4yNTQsMC4wMDQKCQkJYy0xMC40ODMsMC4wMDEwMS0yMi4xOTkwMS0zLjAwOC0zNC4zMDg5OS04Ljc1MTAxYy0xLjAzOS0wLjQ5My0yLjA4MDk5LTEuMDAyLTMuMTI2MDEtMS41MzYKCQkJYy0wLjA0OTAxLTAuMDI0OTktMC4wOTc5OS0wLjA0OS0wLjE0Ny0wLjA3NDAxYy0wLjI3Ni0wLjE0Mi0wLjU1Mi0wLjI5NDAxLTAuODI5OTktMC40MzkKCQkJYy0xLjYyMS0wLjg0NS0zLjI0NzAxLTEuNzI5LTQuODc3OTktMi42N2MtMS4wMDgtMC41ODA5OS0yLjAxMDk5LTEuMTg1LTMuMDE1MDEtMS43OTgKCQkJYy0wLjI1OC0wLjE1Ny0wLjUxNTk5LTAuMzE1OTktMC43NzMwMS0wLjQ3NmMtMC45NzY5OS0wLjYwNS0xLjk1My0xLjIyMDk5LTIuOTI0MDEtMS44NTUKCQkJYy0wLjEwOTAxLTAuMDcxLTAuMjE2LTAuMTQ1LTAuMzI1MDEtMC4yMTZjLTAuODcyMDEtMC41NzIwMS0xLjc0MS0xLjE1OS0yLjYwNjk5LTEuNzU0Yy0wLjMyMTAxLTAuMjItMC42NDItMC40NDItMC45NjMwMS0wLjY2NgoJCQljLTAuODQxLTAuNTg4LTEuNjgxLTEuMTg1LTIuNTE4MDEtMS43OTNjLTAuMzQ5LTAuMjU1LTAuNjk4LTAuNTE1LTEuMDQ3LTAuNzcyOTljLTAuNjQyLTAuNDc2LTEuMjg1LTAuOTU3OTktMS45MjMtMS40NDYKCQkJYy0wLjQxMTk5LTAuMzE0LTAuODI1MDEtMC42My0xLjIzNDk5LTAuOTQ5MDFjLTAuNjgxLTAuNTMtMS4zNTkwMS0xLjA2Nzk5LTIuMDM2MDEtMS42MTA5OQoJCQljLTAuNTU3MDEtMC40NDgtMS4xMTItMC45MDE5OS0xLjY2NTk5LTEuMzU4Yy0wLjQ2MjAxLTAuMzgyLTAuOTI0OTktMC43NjUtMS4zODY5OS0xLjE1NDAxCgkJCWMtMC40NzktMC40MDMtMC45NTctMC44MDYtMS40MzMwMS0xLjIxNWMtMC42NjEwMS0wLjU3MDAxLTEuMzIwMDEtMS4xNDUtMS45NzY5OS0xLjcyNzAxCgkJCWMtMC41NDgtMC40ODctMS4wOTM5OS0wLjk4LTEuNjQwMDEtMS40NzRjLTAuNDA3OTktMC4zNzEtMC44MTYwMS0wLjc0My0xLjIyMTk4LTEuMTJjLTAuNDcxOTgtMC40MzYtMC45NDI5OS0wLjg3LTEuNDExMDEtMS4zMTIKCQkJYy0wLjcxODk5LTAuNjc3OTktMS40MzUtMS4zNjYtMi4xNDgwMS0yLjA2MWMtMC40Mjk5OS0wLjQxOC0wLjg1Njk5LTAuODQyLTEuMjgyOTktMS4yNjUKCQkJYy0wLjUwNjAxLTAuNTAyLTEuMDA5LTEuMDA4LTEuNTEwOTktMS41MTgwMWMtMC40MjA5OS0wLjQyNy0wLjg0MS0wLjg1NS0xLjI1OS0xLjI4NgoJCQljLTAuNzA5MDEtMC43MzM5OS0xLjQxNC0xLjQ3NC0yLjExNDk5LTIuMjIzMDFjLTAuMzAyLTAuMzIwMDEtMC42MDEwMS0wLjY0MzAxLTAuODk4OTktMC45NjYKCQkJYy0wLjgyOTk5LTAuODk3OTktMS42NTYwMS0xLjgwMDk5LTIuNDc0LTIuNzE4OTljLTAuMDgyLTAuMDkyLTAuMTY0LTAuMTg2LTAuMjQ1LTAuMjc4CgkJCWMtMC44NDY5OC0wLjk1Mzk5LTEuNjg3MDEtMS45MTkwMS0yLjUyMS0yLjg5NWMtMC4xMTctMC4xMzgtMC4yMzU5OS0wLjI3NDk5LTAuMzUzLTAuNDEyOTkKCQkJYy0wLjkxNTAxLTEuMDc2LTEuODIzLTIuMTY0OTktMi43MjE5OC0zLjI2NjAxYy0wLjAxMTk5LTAuMDE1LTAuMDIzMDEtMC4wMjkwMS0wLjAzNS0wLjA0NQoJCQljLTAuOTM5LTEuMTUxOTktMS44Njg5OS0yLjMxNzk5LTIuNzg3OTktMy40OTY5OXYtMC4yMDVsLTAuNDkxLTAuNDI1Yy0wLjIzNTk5LTAuMzA2LTAuNDY4OTktMC42MTItMC43MDMtMC45MTgKCQkJYy0wLjM1OTk5LTAuNDcwOTktMC43MTc5OS0wLjk0Mi0xLjA3NTAxLTEuNDE3MDFjLTAuMjgtMC4zNzMtMC41NTg5OS0wLjc0Njk5LTAuODM1MDEtMS4xMjMKCQkJYy0wLjM1NS0wLjQ4LTAuNzA3OTktMC45NjIwMS0xLjA2MS0xLjQ0NTAxYy0wLjI3Mi0wLjM3Mzk5LTAuNTQyMDEtMC43NDY5OS0wLjgxMi0xLjEyMwoJCQljLTAuMzU4OTktMC41MDEwMS0wLjcxMy0xLjAwNC0xLjA2NTk5LTEuNTA3Yy0wLjI1Mi0wLjM1OC0wLjUwNy0wLjcxNS0wLjc1Ni0xLjA3NDAxYy0wLjU1OTAxLTAuODA0LTEuMTEwOTktMS42MTItMS42NTUtMi40MgoJCQljLTE3LjY0NC0yNi4yMDUtMjguNzM4MDEtNTUuNy0yOC43MzgwMS04MS4zMjljMC0yMi4zMjgsOC4zMTc5OS0zOC4xNCwyMi45ODktNDUuMThsMC4wMzMsMC4wNjIKCQkJYzUuNjk5MDEtMi43NjIsMTIuMjQ4OTktNC4xNjMsMTkuNDY3OTktNC4xNjNjMTAuNjA4LDAsMjIuNDc2OTksMy4wNzQsMzQuNzM4OTgsOC45NTNjMS44MzgwMSwwLjg4MSwzLjY4Mzk5LDEuODI2LDUuNTM0LDIuODMxCgkJCWMwLjA0OTk5LDAuMDI3LDAuMTAwMDEsMC4wNTIsMC4xNDk5OSwwLjA4YzAuOTUyLDAuNTE4LDEuOTAzOTksMS4wNTIsMi44NTY5OSwxLjYwMwoJCQljNDguMzMyLDI3LjkwNCw4Ny42NTEsODkuODU5OTksODcuNjUxLDEzOC4xMTA5OWMwLDEuMTE4LTAuMDI3MDEsMi4yMTUtMC4wNjksMy4zYy0wLjAxMywwLjM1My0wLjAzNCwwLjcwMS0wLjA0OTk5LDEuMDUwOTkKCQkJYy0wLjAzNzk5LDAuNzM1OTktMC4wODQwMSwxLjQ2NC0wLjE0MDk5LDIuMTg0MDFjLTAuMDMxMDEsMC4zODgtMC4wNjI5OSwwLjc3NDk5LTAuMDk3OTksMS4xNTgKCQkJYy0wLjA2OSwwLjcyOC0wLjE0OTk5LDEuNDQ1MDEtMC4yMzkwMSwyLjE1NWMtMC4wNDAwMSwwLjMxNC0wLjA3MTk5LDAuNjM0LTAuMTE0MDEsMC45NDUwMQoJCQljLTAuMTM1OTksMC45OC0wLjI4OSwxLjk0NTAxLTAuNDYyMDEsMi44OTMwMWMtMC4wNDgsMC4yNTQtMC4xMDMsMC41MDEwMS0wLjE1MSwwLjc1MmMtMC4xNDQwMSwwLjczOS0wLjI5OCwxLjQ3MDk5LTAuNDY3MDEsMi4xOQoJCQljLTAuMDY2MDEsMC4yNzY5OS0wLjEzMywwLjU1Mjk5LTAuMjAyLDAuODI4Yy0wLjE5MTAxLDAuNzY3LTAuMzk0OTksMS41MjQ5OS0wLjYxMzAxLDIuMjY4MDEKCQkJYy0wLjA0NTAxLDAuMTQ3OTktMC4wODQ5OSwwLjI5OS0wLjEyNzk5LDAuNDQ3MDFjLTIuOTc5LDkuNzg5LTguMzU1MDEsMTcuNDA1LTE1Ljk2MzAxLDIyLjQ2NgoJCQljLTAuMDAyOTksMC4wMDMwMS0wLjAwOCwwLjAwNi0wLjAxMywwLjAwOWMtNC44NzksMy4yNDYtMTAuNTUwOTksNS4zMjUtMTYuODYwOTksNi4xODUKCQkJYy0wLjk1MDAxLDAuMTI4MDEtMS45MjIsMC4yMjUwMS0yLjkwMzk5LDAuM0MzNTYuMjY3LDIzMC45OTgsMzU1Ljk1NDk5LDIzMS4wMiwzNTUuNjM4LDIzMS4wMzc5OXogTTI0Ni42NDYsMTY1LjMwNzAxCgkJCWwwLjE0MzAxLTAuMDljMC40MjktMC4yOTksMC44NzgwMS0wLjU5ODAxLDEuMzY1MDEtMC45MDhjMS41MjkwMSwyLjIzNSwzLjExNCw0LjQ1NTk5LDQuNzM4MDEsNi42NDMwMQoJCQljLTAuODM4LTAuMDUyLTEuNjYyOTktMC4wNzgtMi40OTEtMC4xMDZjLTAuMzU2LTAuMDEzLTAuNzE4LTAuMDM3OTktMS4wNzMtMC4wNDYwMWMtMS4xOTYtMC4wMjQ5OS0yLjM3OS0wLjAyNi0zLjU0NywwCgkJCWwtMC42MDg5OSwwLjAyYy0wLjk3MDk5LDAuMDI5MDEtMS45Mjk5OSwwLjA3Ny0yLjg4MSwwLjE0MzAxbC0wLjcwMzk5LDAuMDQ4Yy0xLjA4OSwwLjA4NTAxLTIuMTczLDAuMTk1MDEtMy4yNDgsMC4zMjcKCQkJYy0wLjM0NTk5LDAuMDQ0MDEtMC42ODQwMSwwLjEwMy0xLjAyOTAxLDAuMTQ5OTljLTAuNjc3OTksMC4wOTUtMS4zNjA5OSwwLjE4My0yLjAyNjk5LDAuMjk1TDI0Ni42NDYsMTY1LjMwNzAxegoJCQkgTTQyOC4yMDU5OSw1NDYuODc1TDE4OC40ODUsNDA4LjQ3NFYyNDIuOTU1OTljMC0yOS44NDYwMSwxMS4wMzc5OS01MS45MTUwMSwzMS4wODA5OS02Mi4xNDJsMC4zMDItMC4xNTcKCQkJYzAuMTMtMC4wNjksMC4yNTktMC4xMzY5OSwwLjM5MzAxLTAuMjAyYzAuNzY1LTAuMzc1LDEuNTg4LTAuNzU0LDIuNDQ5MDEtMS4xMjM5OWMwLjE3OS0wLjA3NywwLjM2LTAuMTQ5OTksMC41NDEtMC4yMjIKCQkJbDAuMjgtMC4xMTRjMC42MTgtMC4yNTQsMS4yNDMtMC40OTY5OSwxLjg3NjAxLTAuNzNjMC4yOTgtMC4xMSwwLjU5NS0wLjIxODk5LDAuODk0LTAuMzIzCgkJCWMwLjc2NjAxLTAuMjY2MDEsMS41Mzk5OS0wLjUxOSwyLjMzLTAuNzU1bDAuMzk5LTAuMTI1YzAuODczOTktMC4yNTUsMS43OTYwMS0wLjQ5OCwyLjgxOS0wLjc0M2wwLjc2OS0wLjE3MwoJCQljMC43NTEwMS0wLjE2OTAxLDEuNTA3LTAuMzI2LDIuMjc0LTAuNDY4OTlsMC43NzYtMC4xNDMwMWMwLjI4My0wLjA0OSwwLjU3Ny0wLjA4MiwwLjg2MzAxLTAuMTI4MDEKCQkJYzAuNzM1LTAuMTE3LDEuNDY4LTAuMjM3LDIuMjE1LTAuMzI4OTljMS4wMzUtMC4xMjgwMSwyLjA4Mi0wLjIzMzk5LDMuMTM2LTAuMzE3bDAuNjY3MDEtMC4wNDUKCQkJYzAuOTAzLTAuMDYyLDEuODE0LTAuMTA2OTksMi43MzctMC4xMzQ5OWwwLjI5MS0wLjAwOTk5bDAuMjkxLTAuMDA5YzIuMDQ3LTAuMDQ1LDQuMTYsMC4wMDMwMSw2LjI4NiwwLjEyNQoJCQljMC4xOTA5OSwwLjAxMSwwLjM4MSwwLjAxMywwLjU3MywwLjAyNGwwLjU1NDk5LDAuMDM5OTljMC44ODYsMC4wNjMsMS43ODIsMC4xNDcsMi42ODMsMC4yNDAwMQoJCQljMTEuNzY2MDEsMTUuMDY3OTksMjUuMTU4LDI4LjAxOSwzOS4yODA5OSwzOC4wNzg5OWMwLjM0Nzk5LDAuMjQ4OTksMC42OTYwMSwwLjQ5ODk5LDEuMDQ1OTksMC43NDMKCQkJYzAuNDk4OTksMC4zNTAwMSwwLjk5Nzk5LDAuNjk3MDEsMS41LDEuMDM5YzAuNjc3LDAuNDY0LDEuMzU1MDEsMC45MTkwMSwyLjAzNjAxLDEuMzY5YzAuMzU1MDEsMC4yMzUsMC43MDkwMSwwLjQ3LDEuMDY1LDAuNwoJCQljMC44NjQ5OSwwLjU2MywxLjczMywxLjExMiwyLjYwNCwxLjY1MTk5YzAuMjg1LDAuMTc3LDAuNTcxOTksMC4zNTMsMC44NTgsMC41MjhjMS4wMTMsMC42MTksMi4wMjgwMiwxLjIyOCwzLjA0OTAxLDEuODE3OTkKCQkJYzEuMDc5OTksMC42MjM5OSwyLjE1Nzk5LDEuMjIzMDEsMy4yMzU5OSwxLjgwNmMwLjMyMDAxLDAuMTczLDAuNjM5MDEsMC4zMzgsMC45NTk5OSwwLjUwNgoJCQljMC43NjcsMC40MDgsMS41MzUsMC44MDkwMSwyLjMwMiwxLjE5NmMwLjM0Nzk5LDAuMTc1LDAuNjk2OTksMC4zNDM5OSwxLjA0NDAxLDAuNTE2MDEKCQkJYzAuNzYwMDEsMC4zNzM5OSwxLjUxODAxLDAuNzQwMDEsMi4yNzQ5OSwxLjA5Mzk5YzAuMzE0LDAuMTQ3LDAuNjI3OTksMC4yOTEsMC45NCwwLjQzNDAxCgkJCWMwLjgyOTk5LDAuMzc5LDEuNjU3OTksMC43NDYsMi40ODU5OSwxLjEwMDAxYzAuMjI4LDAuMDk4MDEsMC40NTgwMSwwLjE5OCwwLjY4NzAxLDAuMjk1CgkJCWMxLjAyNiwwLjQzMjAxLDIuMDQ3LDAuODQ1LDMuMDY2MDEsMS4yMzgwMWMwLjAyNDk5LDAuMDA5OTksMC4wNDkwMSwwLjAyLDAuMDc1OTksMC4wMwoJCQljOS44NDY5OCwzLjc4Nzk5LDE5LjM1OTk5LDUuNzU4LDI4LjEwMTAxLDUuNzU4YzAuMTg1LDAsMC4zNjQwMS0wLjAxMSwwLjU0OTAxLTAuMDEzYzAuMTAzLTAuMDAxMDEsMC4yMDctMC4wMDUsMC4zMS0wLjAwNwoJCQljMC44MjMtMC4wMTMsMS42NDA5OS0wLjAzOTk5LDIuNDQ1MDEtMC4wODUwMWMwLjIzNTk5LTAuMDEzLDAuNDctMC4wMzIsMC43MDQwMS0wLjA0OWMwLjcwMDAxLTAuMDQ5LDEuMzg5MDEtMC4xMSwyLjA3My0wLjE4MwoJCQljMC4xMTg5OS0wLjAxMywwLjI0Mzk5LTAuMDE4MDEsMC4zNjMwMS0wLjAzMmMwLjg5ODAxLDAuOTQyOTksMS43OTUwMSwxLjg5LDIuNjgzMDEsMi44NDkKCQkJYzAuMDA2MDEsMC4wMDcsMC4wMTMsMC4wMTQwMSwwLjAxOTk5LDAuMDJjMi4yMDQ5OSwyLjM4Niw0LjM3NSw0LjgxNzk5LDYuNTA2OTksNy4yOTQwMWMwLjAwNSwwLjAwNSwwLjAwOSwwLjAwOSwwLjAxMywwLjAxNDAxCgkJCWMxLjAzOSwxLjIwNywyLjA3MTAxLDIuNDI1OTksMy4wOTEsMy42NTE5OWMwLjAzMTAxLDAuMDM3OTksMC4wNjI5OSwwLjA3NDAxLDAuMDkyOTksMC4xMTIKCQkJYzAuOTc5LDEuMTc3OTksMS45NDgsMi4zNjcsMi45MDksMy41NjJjMC4wNzEwMSwwLjA4OSwwLjE0MzAxLDAuMTc1LDAuMjEzOTksMC4yNjNjMC45MTE5OSwxLjEzNjk5LDEuODEyOTksMi4yODQsMi43MDgwMSwzLjQzNgoJCQljMC4xMTQwMSwwLjE0NywwLjIzMDk5LDAuMjkyMDEsMC4zNDUsMC40Mzk5OWMwLjg1OTk5LDEuMTEyLDEuNzA4MDEsMi4yMzE5OSwyLjU1MiwzLjM1NTk5CgkJCWMwLjE0MDk5LDAuMTg3OTksMC4yODQsMC4zNzI5OSwwLjQyNDk5LDAuNTYyMDFjMC45NzgsMS4zMDgwMSwxLjk0Mjk5LDIuNjI1LDIuODk2LDMuOTUwMDFsMC4xMjcwMSwwLjE3NTk5bDAuMDAyMDEsMC4wMDIwMQoJCQljMi4wODcwMSwyLjkwMzAyLDQuMTYxMDEsNS45MDksNi4xNjY5OSw4LjkzNWwwLjEzMTk5LDAuMjAwMDFjMS45OTIsMy4wMTE5OSwzLjk0Mjk5LDYuMDkyMDEsNS44MSw5LjE2Njk5bDAuMTk2MDEsMC4zMjQwMQoJCQljMS44NCwzLjAzNjk5LDMuNjMzLDYuMTM2OTksNS4zNDIwMSw5LjIyOGwwLjIyLDAuNDAyMDFjMC44MjE5OSwxLjQ5MiwxLjYzMTAxLDIuOTg3LDIuNDE5MDEsNC40ODgwMWwwLjAyNzAxLDAuMDUwOTkKCQkJbDAuMDQ1MDEsMC4wODQ5OWMwLjgxNCwxLjU0OTk5LDEuNjA4LDMuMTAzLDIuMzg5MDEsNC42NjRsMC4yMDcsMC40MThjMC43MTYsMS40MzYsMS40MTQsMi44NzUsMi4wOTc5OSw0LjMxNjAxbDAuMjAyLDAuNDI0OTkKCQkJYzAuNzI5LDEuNTQ1OTksMS40NDE5OSwzLjA5Mjk5LDIuMTI2MDEsNC42MjIwMWwwLjE2MTk5LDAuMzcyMDFjMC42Mjc5OSwxLjQwNzk5LDEuMjM1OTksMi44MTY5OSwxLjgyNyw0LjIxNzAxbDAuMjk4LDAuNzA3CgkJCWMwLjY0MDk5LDEuNTI4OTksMS4yNjUwMSwzLjA1ODAxLDEuODY3LDQuNTg4MDFsMS43NDg5OS0wLjY4NzAxbC0xLjY5MTAxLDAuODM3MDFjMC41NjUsMS40Mzc5OSwxLjEwOTAxLDIuODc3OTksMS42NDA5OSw0LjMxNQoJCQlsMC4zMjMsMC44NzVjMS4wODIsMi45NywyLjEwMyw1Ljk3NCwzLjAzMTAxLDguOTIwOTlsMC4zMDMwMSwwLjk3MTAxYzAuNDI3LDEuMzg0LDAuODQxLDIuNzYzLDEuMjMzLDQuMTMzbDAuMDg4OTksMC4yOTk5OQoJCQljMC40MTU5OSwxLjQ2NiwwLjgwODk5LDIuOTI3LDEuMTg3OTksNC4zODkwMWwwLjI1MTAxLDAuOTg5OTljMC4zMTY5OSwxLjI0NzAxLDAuNjE4MDEsMi40OTEsMC45MDM5OSwzLjcyOWwwLjE2Njk5LDAuNzIxOTgKCQkJYzAuMzI0MDEsMS40Mjg5OSwwLjYyNzk5LDIuODU4LDAuOTExOTksNC4yNzQ5OWwwLjE4MjAxLDAuOTQ0YzAuMjMwMDEsMS4xODYsMC40NDUwMSwyLjM2NywwLjY0NywzLjUzNjAxbDAuMTY0LDAuOTQ5MDEKCQkJYzAuMjMwOTksMS4zOTk5OSwwLjQ0NTAxLDIuNzk3LDAuNjM4LDQuMjAwMDFsMC4xMDEwMSwwLjc5MDk5YzAuMTU2MDEsMS4xNzA5OSwwLjI5NywyLjMzNDk5LDAuNDIyLDMuNDg3bDAuMTE0MDEsMS4wNTQ5OQoJCQljMC4xMzgsMS4zNzksMC4yNjE5OSwyLjc1MjAxLDAuMzU4LDQuMTE4MDFsMC4wMzQsMC41MTk5OWMwLjA4MDk5LDEuMjA5MDEsMC4xNDQwMSwyLjQxMTAxLDAuMTk0LDMuNjM1OTkKCQkJYzAuMDE0MDEsMC4zNTAwMSwwLjAyNzAxLDAuNzAyLDAuMDM3OTksMS4wNTQ5OWMwLjA0OTk5LDEuNTgwOTksMC4wNzUwMSwyLjg2NiwwLjA3NTAxLDQuMDQ0MDFMNDI4LjIwNTk5LDU0Ni44NzUKCQkJTDQyOC4yMDU5OSw1NDYuODc1eiBNNDYxLjM1NTAxLDUyOS45Mjk5OUw0MzEuOTY2LDU0Ni44NzcwMVYzODMuNTI4OTljMC0xLjIyLTAuMDIzMDEtMi41NDUwMS0wLjA3NTAxLTQuMTYyOTkKCQkJYy0wLjAxMy0wLjM2ODk5LTAuMDI2LTAuNzM5MDEtMC4wNDE5OS0xLjEwOTAxYy0wLjA0OTk5LTEuMjM0OTktMC4xMTQwMS0yLjQ3MTAxLTAuMjAwOTktMy43MjhsLTAuMDMyMDEtMC41MjYKCQkJYy0wLjEwMTAxLTEuMzk5OTktMC4yMjYwMS0yLjgwODAxLTAuMzY4OTktNC4yMjlsLTAuMTE0MDEtMS4wNzEwMWMtMC4wNjYwMS0wLjU5NS0wLjE0Ni0xLjE5MTk5LTAuMjE1LTEuNzg5CgkJCWMtMC4wNzQwMS0wLjU5Njk4LTAuMTM4LTEuMTkyOTktMC4yMTcwMS0xLjc4OWwtMC4wMzIwMS0wLjI1OWMtMC4wMjM5OS0wLjE5MTAxLTAuMDQ5MDEtMC4zODE5OS0wLjA3NTAxLTAuNTY1CgkJCWMtMC4xOTQtMS40MjU5OS0wLjQxMjk5LTIuODU2OTktMC42NTIwMS00LjI5N2wtMC4xNjUwMS0wLjk2MWMtMC4yMDgwMS0xLjIwMy0wLjQyODk5LTIuNDA3OTktMC42NjQtMy42MjVsLTAuMTg1LTAuOTU3CgkJCWMtMC4yOTA5OS0xLjQ1NDk5LTAuNjAxOTktMi45MTQtMC45MzIwMS00LjM3Nzk5bC0wLjA4NDAxLTAuMzY0MDFsLTAuMDg0OTktMC4zNjcKCQkJYy0wLjI5NDAxLTEuMjY3LTAuNjAxOTktMi41MzY5OS0wLjkyNDAxLTMuODEyMDFsLTAuMjU1LTEuMDA2OTljLTAuMzg1MDEtMS40ODkwMS0wLjc4Njk5LTIuOTc5LTEuMjA5MDEtNC40Njc5OQoJCQlsLTAuMDg4OTktMC4zMDQ5OWMtMC40MDMwMi0xLjQwMzAyLTAuODI1MDEtMi44MTEtMS4yNjA5OS00LjIyMTAxbC0wLjMwNi0wLjk4MTk5Yy0wLjk0Njk5LTMuMDA1LTEuOTg1OTktNi4wNjEtMy4wODg5OS05LjA5MQoJCQlsLTAuMzI3LTAuODhjLTAuNTQwMDEtMS40NjMwMS0xLjA5Mzk5LTIuOTI0OTktMS42OTEwMS00LjQ1MmwtMC4wMzUtMC4wODg5OWMtMC42MDk5OS0xLjU1Ni0xLjI0Ni0zLjExMi0xLjg5Ni00LjY2MTAxCgkJCWwtMC4yOTk5OS0wLjcxMzk5Yy0wLjYwNTk5LTEuNDMyMDEtMS4yMjUwMS0yLjg2Mi0xLjg2Mi00LjI5OGwtMC4xNTM5OS0wLjM1MTk5Yy0wLjcwNDAxLTEuNTcxOTktMS40MjU5OS0zLjE0Mi0yLjE1OS00LjY5Njk5CgkJCWwtMC4yMTIwMS0wLjQ0Njk5Yy0wLjY5Mjk5LTEuNDYyMDEtMS40MDMwMi0yLjkyMi0yLjEyMzk5LTQuMzczOTlsLTAuMjE1LTAuNDMyMDEKCQkJYy0wLjc4MjAxLTEuNTY1LTEuNTgwOTktMy4xMjYwMS0yLjM5ODAxLTQuNjgzOTlsLTAuMDcxOTktMC4xMzUwMWMtMC44MDYtMS41Mzc5OS0xLjYzNTk5LTMuMDcwMDEtMi40NzYwMS00LjU5NjAxCgkJCWwtMC4yMzQwMS0wLjQyNDk5Yy0wLjc2MTk5LTEuMzgtMS41NDk5OS0yLjc2NDAxLTIuMzQ2MDEtNC4xNDZjLTEuMDAyMDEtMS43NDEtMi4wMTk5OS0zLjQ3Njk5LTMuMDU3MDEtNS4xOWwtMC4wMDI5OS0wLjAwNQoJCQljLTAuMDAyMDEtMC4wMDEwMS0wLjAwMjAxLTAuMDAyMDEtMC4wMDI5OS0wLjAwNGwtMC4yMDQwMS0wLjMzNmMtMS4zMDM5OS0yLjE0Ny0yLjY2MTk5LTQuMjk3LTQuMDQwMDEtNi40MzUKCQkJYy0wLjYxMi0wLjk1MDAxLTEuMjItMS45MDUtMS44NDEtMi44NDM5OWwtMC4xMzMtMC4yMDA5OWMtMS4wMjYtMS41NDctMi4wNzEwMS0zLjA5MjAxLTMuMTI2MDEtNC42MjIwMQoJCQljLTEuMDU2LTEuNTI4OTktMi4xMjEtMy4wNDA5OS0zLjE5LTQuNTI3MDFsLTAuMTE0MDEtMC4xOTUwMWwtMC4wNzQwMS0wLjA2NjAxYy0wLjkyNDAxLTEuMjgxMDEtMS44NjA5OS0yLjU1Mzk5LTIuODA0OTktMy44MjAwMQoJCQljLTAuMzczOTktMC41LTAuNzU0LTAuOTkzMDEtMS4xMzEwMS0xLjQ4OTk5Yy0wLjU2Nzk5LTAuNzUyLTEuMTM2OTktMS41MDUtMS43MTMwMS0yLjI1MTAxCgkJCWMtMC40ODMtMC42MjYwMS0wLjk3Mjk5LTEuMjQ1LTEuNDYxLTEuODY2Yy0wLjQ3NC0wLjYwNS0wLjk0OTAxLTEuMjEwMDEtMS40Mjg5OS0xLjgxMWMtMC41NDgtMC42ODktMS4xMDMtMS4zNzEtMS42NTktMi4wNTQKCQkJYy0wLjQyMi0wLjUxNjAxLTAuODQxLTEuMDM0LTEuMjY1MDEtMS41NDhjLTAuNjA2OTktMC43MzUtMS4yMTc5OS0xLjQ2Ni0xLjgzMi0yLjE5NAoJCQljLTAuMzczOTktMC40NDI5OS0wLjc0Nzk5LTAuODg2OTktMS4xMjUtMS4zMjdjLTAuNjYxMDEtMC43NzYtMS4zMjU5OS0xLjU0OC0xLjk5NS0yLjMxNTk5CgkJCWMtMC4zMjU5OS0wLjM3Mzk5LTAuNjUzMDItMC43NDYtMC45NzktMS4xMTdjLTAuNzE4OTktMC44MTU5OS0xLjQ0MTk5LTEuNjI5LTIuMTY5MDEtMi40MzYKCQkJYy0wLjI3MS0wLjMwMDk5LTAuNTQ1MDEtMC42MDAwMS0wLjgxNjk5LTAuODk5Yy0wLjc4LTAuODU4OTktMS41NjQtMS43MTUtMi4zNTUwMS0yLjU2MwoJCQljLTAuMDUzMDEtMC4wNTcwMS0wLjEwNTk5LTAuMTE3LTAuMTU5LTAuMTc1YzAuMTAzLTAuMDIxLDAuMjA0MDEtMC4wNDksMC4zMDYtMC4wNzEKCQkJYzQuNjczLTEuMDExOTksOC45NDkwMS0yLjY3NCwxMi43ODEwMS00Ljk0MDk5bDAuMDU4MDEsMC4wNzg5OXYtMC4wMDEwMWwyLjUxNy0xLjQ1M2wxNS43MzAwMS05LjA5CgkJCWM0MS4wMTgwMSw0NC4zNzMwMiw2Ni40NDk5OCwxMDAuMjMwOTksNjYuNDQ5OTgsMTQ2LjA5NTk4djE2NS41MTkwNEg0NjEuMzU1MDF6Ii8+Cgk8L2c+Cgk8cGF0aCBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBkPSJNNDcyLjYzNjk5LDUzNi40NDcwMmMwLDAsODcuOTA3OTktMzYuMzY0OTksNjUuMzE2MDEtMTAxLjIzNDAxCgkJYzEwMC43NjMtMzkuNzY3LDEyLjAzNjk5LTEzMy40Mjk5OS03Mi4xMjEtMTA1LjgzNDk5TDQ3Mi42MzY5OSw1MzYuNDQ3MDJ6Ii8+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" }, { "id": "vm", "name": "vm", "url": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI1LjMuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHdpZHRoPSI5MS42cHgiIGhlaWdodD0iODQuNHB4IiB2aWV3Qm94PSIwIDAgOTEuNiA4NC40IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCA5MS42IDg0LjQiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8ZyBpZD0iTGF5ZXJfNCI+Cgk8Zz4KCQk8cGF0aCBkPSJNNDMuNywzLjVsMjkuOCwxOC4xdjM2LjlsLTI5LjksMThMMTQuMSw1OC4zVjIxLjJMNDMuNywzLjUgTTQzLjcsMmMtMC4zLDAtMC41LDAuMS0wLjgsMC4yTDEzLjMsMTkuOQoJCQljLTAuNSwwLjMtMC43LDAuOC0wLjcsMS4zdjM3LjFjMCwwLjUsMC4zLDEsMC43LDEuM2wyOS41LDE4LjJjMC4yLDAuMSwwLjUsMC4yLDAuOCwwLjJzMC41LTAuMSwwLjgtMC4ybDI5LjktMTgKCQkJYzAuNS0wLjMsMC43LTAuOCwwLjctMS4zVjIxLjZjMC0wLjUtMC4zLTEtMC43LTEuM0w0NC41LDIuMkM0NC4zLDIuMSw0NCwyLDQzLjcsMkw0My43LDJ6Ii8+Cgk8L2c+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiM0RjY1ODciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjQzLjYsNzYuNSAxNC4xLDU4LjMgNDMuNyw0MC42IDczLjUsNTguNSAJIi8+Cgk8cG9seWdvbiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI0My43LDMuNSA3My41LDIxLjYgNzMuNSw1OC41IAoJCTQzLjcsNDAuNiAJIi8+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiM0RjY1ODciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjQzLjcsMy41IDczLjUsMjEuNiA3My41LDU4LjUgNDMuNyw0MC42IAkiLz4KCTxwb2x5Z29uIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjQzLjYsNzYuNSAxNC4xLDU4LjMgNDMuNyw0MC42IAoJCTczLjUsNTguNSAJIi8+Cgk8cG9seWdvbiBvcGFjaXR5PSIwLjQiIGZpbGw9IiM0RjY1ODciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjQzLjcsMy41IDE0LjEsMjEuMyAxNC4xLDU4LjMgNDMuNyw0MC42IAkiLz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuNCIgZmlsbD0iIzAwMDAwMCIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iNDQuOSw3MS4zIDIyLDU3LjUgNDQuOCw0My44IDY3LjksNTcuNSAJIi8+Cgk8Zz4KCQk8cG9seWdvbiBmaWxsPSIjNEY2NTg3IiBzdHJva2U9IiMwMTAyMDIiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI2Ny44LDQxLjggNDMuNiw1Ni41IDQzLjYsNjUuMyAKCQkJNjcuOCw1MC44IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0iI0NFRDhFQiIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMTkuNSw0MS44IDQzLjYsNTYuNSA0My42LDY1LjMgCgkJCTE5LjUsNTAuOCAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNDRUQ4RUIiIHBvaW50cz0iNDMuNiw1Ni40IDE5LjUsNDEuOCA0My40LDI3LjUgNjcuOCw0MS44IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNDMuNiw1Ni40IDE5LjUsNDEuOCA0My40LDI3LjUgCgkJCTY3LjgsNDEuOCAJCSIvPgoJPC9nPgoJPGc+CgkJPHBvbHlnb24gZmlsbD0iIzRGNjU4NyIgc3Ryb2tlPSIjMDEwMjAyIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNjcuOCwyOS41IDQzLjYsNDQuMSA0My42LDUzIAoJCQk2Ny44LDM4LjUgCQkiLz4KCQk8cG9seWdvbiBmaWxsPSIjQ0VEOEVCIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSIxOS41LDI5LjUgNDMuNiw0NC4xIDQzLjYsNTMgCgkJCTE5LjUsMzguNSAJCSIvPgoJCTxwb2x5Z29uIGZpbGw9IiNDRUQ4RUIiIHBvaW50cz0iNDMuNiw0NC4xIDE5LjUsMjkuNSA0My40LDE1LjEgNjcuOCwyOS41IAkJIi8+CgkJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iNDMuNiw0NC4xIDE5LjUsMjkuNSA0My40LDE1LjEgCgkJCTY3LjgsMjkuNSAJCSIvPgoJPC9nPgoJPHBvbHlnb24gb3BhY2l0eT0iMC4zIiBmaWxsPSIjQ0VEOEVCIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSIxNC4xLDIxLjIgNDMuNiwzOS42IDQzLjYsNzYuNSAxNC4xLDU4LjMgCSIvPgoJPHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSI0My43LDYuMSA3MS40LDIyLjkgNzMuNSwyMS42IDQzLjcsMy41IDE0LjEsMjEuMiAxNi4yLDIyLjUgCSIvPgoJPHBvbHlnb24gb3BhY2l0eT0iMC41IiBmaWxsPSIjRURGMEY0IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI0My42LDM5LjYgMTQuMSwyMS4yIDQzLjcsMy41IDczLjUsMjEuNiAJIi8+Cgk8cG9seWdvbiBmaWxsPSJub25lIiBzdHJva2U9IiMwMDAwMDAiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgcG9pbnRzPSI0My42LDM5LjYgMTQuMSwyMS4yIDQzLjcsMy41IAoJCTczLjUsMjEuNiAJIi8+Cgk8cG9seWxpbmUgb3BhY2l0eT0iMC40IiBmaWxsPSIjMDAwMDAwIiBlbmFibGUtYmFja2dyb3VuZD0ibmV3ICAgICIgcG9pbnRzPSI0My42LDc3LjkgNTkuNSw3Ni44IDkxLjYsNTcuNCA3My41LDQ3LjggNzMuNSw1OC41IAkiLz4KCTxwb2x5bGluZSBvcGFjaXR5PSIwLjQiIGZpbGw9IiMwMDAwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgICAgIiBwb2ludHM9IjQzLjYsNTIuOSA0OS45LDUyLjMgNjcuNiw0MS42IDY1LjEsNDAuMSA0My42LDUyLjkgCSIvPgoJPHBvbHlnb24gZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHJva2UtbWl0ZXJsaW1pdD0iMTAiIHBvaW50cz0iMTQuMSwyMS4yIDQzLjYsMzkuNiA0My42LDc2LjUgCgkJMTQuMSw1OC4zIAkiLz4KCTxwb2x5Z29uIG9wYWNpdHk9IjAuMyIgZmlsbD0iIzU1NjM3NyIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAgICAiIHBvaW50cz0iNzMuNSwyMS42IDQzLjYsMzkuNiA0My42LDc2LjUgNzMuNSw1OC41IAkiLz4KCTxwb2x5Z29uIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAxMDIwMiIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBwb2ludHM9IjczLjUsMjEuNiA0My42LDM5LjYgNDMuNiw3Ni41IAoJCTczLjUsNTguNSAJIi8+CjwvZz4KPC9zdmc+Cg==", "isIsometric": true, "collection": "isoflow" } ], "colors": [ { "id": "blue", "value": "#0066cc" }, { "id": "green", "value": "#00aa00" }, { "id": "red", "value": "#cc0000" }, { "id": "orange", "value": "#ff9900" }, { "id": "purple", "value": "#9900cc" }, { "id": "black", "value": "#000000" }, { "id": "gray", "value": "#666666" } ], "items": [ { "id": "54d3ba45-a798-4001-85c2-dba5ba9d5819", "name": "Card", "icon": "cardterminal" }, { "id": "c7c52bb5-abf6-4f42-aec0-d6ad0257c182", "name": "Untitled", "icon": "block" }, { "id": "064f4d2b-79f0-49ba-9614-9fe28439a0e1", "name": "Untitled", "icon": "cube" } ], "views": [ { "name": "Untitled view", "items": [ { "labelHeight": 80, "id": "064f4d2b-79f0-49ba-9614-9fe28439a0e1", "tile": { "x": 1, "y": 3 } }, { "labelHeight": 80, "id": "c7c52bb5-abf6-4f42-aec0-d6ad0257c182", "tile": { "x": 3, "y": 0 } }, { "labelHeight": 100, "id": "54d3ba45-a798-4001-85c2-dba5ba9d5819", "tile": { "x": -2, "y": 1 } } ], "connectors": [ { "id": "7f206f75-c3f4-45da-9ad3-bd62ebd60dfd", "color": "blue", "anchors": [ { "id": "2de1c110-3eb0-4c80-a941-05a262fae375", "ref": { "item": "064f4d2b-79f0-49ba-9614-9fe28439a0e1" } }, { "id": "a151977b-0e1c-4a02-9b52-30d524c43170", "ref": { "item": "c7c52bb5-abf6-4f42-aec0-d6ad0257c182" } } ], "labels": [ { "id": "d2126535-0bb7-462f-a4e3-9d471ef90448", "text": "Connector label", "position": 30, "height": 0, "line": "1" } ] }, { "id": "9dc846a3-4090-44e7-99d1-86137c966ad7", "color": "blue", "anchors": [ { "id": "7e3dbc1d-6069-4a4e-94ba-1780cf70dcb9", "ref": { "item": "54d3ba45-a798-4001-85c2-dba5ba9d5819" } }, { "id": "04f30198-29f6-4adb-926c-3334907d30b4", "ref": { "item": "064f4d2b-79f0-49ba-9614-9fe28439a0e1" } } ] } ], "rectangles": [ { "id": "111c0759-af9d-4b1a-96e5-c2272c320e2a", "color": "purple", "from": { "x": 4, "y": 4 }, "to": { "x": 6, "y": 3 }, "customColor": "" } ], "textBoxes": [ { "orientation": "X", "fontSize": 0.6, "content": "Text", "id": "87d05c9d-4e1b-42c7-9cd4-07fe0d39bba1", "tile": { "x": 0, "y": 0 } } ], "id": "1992b58a-9e1b-4f96-88b5-5035de4434b2", "lastUpdated": "2026-02-15T14:23:51.256Z" } ], "fitToScreen": true } ================================================ FILE: e2e-tests/tests/test_base_path_routing.py ================================================ """ E2E tests for verifying the app works correctly when served from different base paths. This catches issues with React Router and asset loading when deployed to subpaths. """ import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def get_base_url(): """Get the base URL from environment or use default.""" return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_base_path(): """Get the base path from environment.""" return os.getenv("FOSSFLOW_BASE_PATH", "/") def get_webdriver_url(): """Get the WebDriver URL from environment or use default.""" return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): """Create a Chrome WebDriver instance for each test.""" chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--enable-webgl") chrome_options.add_argument("--use-gl=swiftshader") chrome_options.add_argument("--enable-accelerated-2d-canvas") chrome_options.add_argument("--window-size=1920,1080") chrome_options.add_argument("--disable-blink-features=AutomationControlled") chrome_options.set_capability('goog:loggingPrefs', {'browser': 'ALL'}) webdriver_url = get_webdriver_url() driver = webdriver.Remote( command_executor=webdriver_url, options=chrome_options ) driver.implicitly_wait(10) yield driver driver.quit() def test_app_loads_at_base_path(driver): """Test that the app loads successfully at the configured base path.""" base_url = get_base_url() base_path = get_base_path() print(f"\nTesting app at base URL: {base_url}") print(f"Base path: {base_path}") # Navigate to the app driver.get(base_url) # Wait for React to mount time.sleep(3) # Verify we're at the correct URL current_url = driver.current_url print(f"Current URL: {current_url}") # The URL should contain our base path if base_path != "/": assert base_path in current_url, f"Expected base path '{base_path}' in URL '{current_url}'" # Check that the React app has mounted root = driver.find_element(By.ID, "root") assert root is not None, "React root element should exist" root_content = driver.execute_script("return document.getElementById('root').innerHTML.length;") assert root_content > 0, "React should have rendered content" print("✓ App loaded successfully at base path") def test_static_assets_load_correctly(driver): """Test that CSS, JS, and other static assets load from the correct path.""" base_url = get_base_url() base_path = get_base_path() driver.get(base_url) time.sleep(3) # Check for any failed resource loads in network failed_resources = driver.execute_script(""" const perf = performance.getEntriesByType('resource'); const failed = perf.filter(entry => { // Check for failed loads (status 404, 403, 500, etc.) // Note: transferSize === 0 might indicate CORS issues or failed loads return entry.transferSize === 0 && !entry.name.includes('data:'); }); return failed.map(r => ({ name: r.name, type: r.initiatorType })); """) if failed_resources: print(f"\n⚠ Found {len(failed_resources)} potentially failed resource loads:") for resource in failed_resources[:10]: print(f" - {resource['type']}: {resource['name']}") # Check that main JS bundle loaded js_loaded = driver.execute_script(""" const scripts = Array.from(document.getElementsByTagName('script')); return scripts.some(s => s.src && !s.src.includes('data:')); """) assert js_loaded, "JavaScript bundles should be loaded" print("✓ JavaScript bundles loaded") # Check that CSS loaded css_loaded = driver.execute_script(""" const links = Array.from(document.getElementsByTagName('link')); const hasCSS = links.some(l => l.rel === 'stylesheet' && l.href); const hasStyles = document.getElementsByTagName('style').length > 0; return hasCSS || hasStyles; """) assert css_loaded, "CSS should be loaded" print("✓ CSS loaded") # Check console for errors about failed loads logs = driver.get_log('browser') errors = [log for log in logs if 'Failed to load resource' in log.get('message', '') or '404' in log.get('message', '')] if errors: print(f"\n⚠ Found {len(errors)} resource loading errors in console:") for error in errors[:5]: print(f" {error['message'][:100]}") # Don't fail the test but warn about errors if len(errors) > 5: pytest.fail(f"Too many resource loading errors ({len(errors)}). Check asset paths.") print("✓ Static assets loaded correctly") def test_react_router_navigation_works(driver): """Test that React Router navigation works correctly with the base path.""" base_url = get_base_url() base_path = get_base_path() driver.get(base_url) time.sleep(3) # Get initial URL initial_url = driver.current_url print(f"\nInitial URL: {initial_url}") # Try navigating to a different route using React Router # Note: This assumes the app has navigation. Adjust based on actual routes. navigation_result = driver.execute_script(""" // Check if React Router is available const hasRouter = window.React && window.ReactDOM; // Try to find any links or buttons that might trigger navigation const links = document.querySelectorAll('a[href^="/"], a[href^="./"], a[href^="#"]'); const buttons = document.querySelectorAll('button'); return { hasReactApp: !!document.querySelector('#root').children.length, linkCount: links.length, buttonCount: buttons.length, currentPath: window.location.pathname }; """) print(f"Navigation check:") print(f" Has React App: {navigation_result['hasReactApp']}") print(f" Links found: {navigation_result['linkCount']}") print(f" Buttons found: {navigation_result['buttonCount']}") print(f" Current path: {navigation_result['currentPath']}") # Verify the current path matches our expected base path structure current_path = navigation_result['currentPath'] if base_path != "/" and not current_path.startswith(base_path.rstrip('/')): pytest.fail(f"Current path '{current_path}' doesn't start with base path '{base_path}'") print("✓ React Router configured correctly for base path") def test_router_basename_detection(driver): """Test that the React Router basename is correctly detected from the URL.""" base_url = get_base_url() base_path = get_base_path() driver.get(base_url) time.sleep(3) # Check what basename React Router is using # This executes the same logic as in App.tsx detected_basename = driver.execute_script(r""" // This replicates the basename detection logic from App.tsx const pathname = window.location.pathname; const basename = pathname.replace(/\/display\/.*$/, '').replace(/\/$/, '') || '/'; return basename; """) print(f"\nBasename detection:") print(f" Expected base path: {base_path}") print(f" Detected basename: {detected_basename}") print(f" Current pathname: {driver.execute_script('return window.location.pathname')}") # The detected basename should match our base path (normalized) expected = base_path.rstrip('/') or '/' detected = detected_basename.rstrip('/') or '/' if expected != detected: print(f"⚠ Warning: Basename mismatch - expected '{expected}', detected '{detected}'") # This might be okay if the app handles it correctly # Don't fail immediately, but check if the app still works # Verify the app actually rendered despite the mismatch app_rendered = driver.execute_script(""" return document.querySelector('.fossflow-container') !== null || document.querySelector('#root').children.length > 0; """) if not app_rendered: pytest.fail(f"App didn't render with basename mismatch. Expected '{expected}', got '{detected}'") print("✓ Router basename detection working correctly") def test_no_console_errors_at_base_path(driver): """Ensure there are no critical JavaScript errors when loaded at base path.""" base_url = get_base_url() base_path = get_base_path() driver.get(base_url) time.sleep(3) # Get console logs logs = driver.get_log('browser') # Filter for severe errors severe_errors = [log for log in logs if log['level'] == 'SEVERE'] # Common errors to ignore (that might not be real issues) ignored_patterns = [ 'favicon.ico', # Missing favicon is okay 'manifest.json', # Missing manifest is okay for basic functionality ] critical_errors = [] for error in severe_errors: message = error.get('message', '') if not any(pattern in message for pattern in ignored_patterns): critical_errors.append(error) if critical_errors: print(f"\n⚠ Found {len(critical_errors)} critical console errors:") for error in critical_errors[:5]: print(f" {error['message'][:150]}") # Check for specific routing-related errors routing_errors = [e for e in critical_errors if 'Router' in e['message'] or 'basename' in e['message']] if routing_errors: pytest.fail(f"Found React Router errors: {routing_errors[0]['message']}") else: print("✓ No critical console errors found") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_basic_load.py ================================================ """ Basic E2E tests for FossFLOW application. Tests basic page loading, canvas presence, and rendering. """ import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def get_base_url(): """Get the base URL from environment or use default.""" return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): """Get the WebDriver URL from environment or use default.""" return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): """Create a Chrome WebDriver instance for each test.""" chrome_options = Options() chrome_options.add_argument("--headless=new") # Use new headless mode chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") # Enable canvas and WebGL rendering chrome_options.add_argument("--enable-webgl") chrome_options.add_argument("--use-gl=swiftshader") # Software GL for headless chrome_options.add_argument("--enable-accelerated-2d-canvas") # Increase window size (some canvas libraries check viewport) chrome_options.add_argument("--window-size=1920,1080") # Disable features that might interfere chrome_options.add_argument("--disable-blink-features=AutomationControlled") # Enable logging to see what's happening chrome_options.set_capability('goog:loggingPrefs', {'browser': 'ALL'}) webdriver_url = get_webdriver_url() # Connect to remote WebDriver (Selenium Grid) driver = webdriver.Remote( command_executor=webdriver_url, options=chrome_options ) driver.implicitly_wait(10) yield driver # Cleanup driver.quit() def test_can_connect_to_server(driver): """Test that we can connect to the server and get a response.""" base_url = get_base_url() print(f"\nAttempting to navigate to: {base_url}") # Navigate to homepage driver.get(base_url) # Wait a bit for page to load time.sleep(3) # Just verify we got SOMETHING back page_source = driver.page_source print(f"Page source length: {len(page_source)} bytes") assert len(page_source) > 0, "Page source should not be empty" print("✓ Got page content from server") def test_homepage_loads(driver): """Test that the homepage loads successfully.""" base_url = get_base_url() # Navigate to homepage driver.get(base_url) # Wait for page to load time.sleep(5) # Get page title title = driver.title print(f"\nPage title: {title}") # Verify title contains relevant keywords or is not empty # Be more lenient - just check it's not empty assert len(title) > 0, f"Page title should not be empty. Got: '{title}'" print("✓ Homepage loaded with title") def test_page_has_body_and_root(driver): """Test that the page has basic HTML structure.""" base_url = get_base_url() # Navigate to homepage driver.get(base_url) # Wait for page to load time.sleep(5) # Check that body exists body = driver.find_element(By.TAG_NAME, "body") assert body is not None, "Body element should exist" print("\n✓ Body element found") # Check for React root element root = driver.find_element(By.ID, "root") assert root is not None, "React root element should exist" print("✓ React root element found") def test_javascript_is_executing(driver): """Test that JavaScript is actually running in the browser.""" base_url = get_base_url() # Navigate to homepage driver.get(base_url) time.sleep(5) # Check if JavaScript is enabled js_enabled = driver.execute_script("return true;") print(f"\n✓ JavaScript enabled: {js_enabled}") assert js_enabled, "JavaScript should be enabled" # Check if we can access window object has_window = driver.execute_script("return typeof window !== 'undefined';") print(f"✓ Window object available: {has_window}") assert has_window, "Window object should be available" # Check if React has mounted root_content = driver.execute_script("return document.getElementById('root').innerHTML.length;") print(f"✓ Root innerHTML length: {root_content} characters") if root_content == 0: print("⚠️ WARNING: React root is empty - React may not have mounted!") # Get browser console logs logs = driver.get_log('browser') if logs: print("\nBrowser console logs:") for log in logs[-10:]: # Last 10 logs print(f" [{log['level']}] {log['message']}") # Check for specific elements that React should create print("\nChecking for expected React-created elements...") all_divs = driver.execute_script("return document.querySelectorAll('div').length;") print(f" Total div elements: {all_divs}") all_buttons = driver.execute_script("return document.querySelectorAll('button').length;") print(f" Total button elements: {all_buttons}") all_canvases = driver.execute_script("return document.querySelectorAll('canvas').length;") print(f" Total canvas elements: {all_canvases}") assert root_content > 0, "React should have rendered content into the root element" print(f"✓ React has rendered content into root") def test_app_renders_diagram_components(driver): """Test that the app renders SVG-based diagram components (FossFLOW uses SVG).""" base_url = get_base_url() # Navigate to homepage driver.get(base_url) print("\nWaiting for FossFLOW app to render diagram components...") # Wait for the fossflow-container div to appear (max 10 seconds) try: container = WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) print("✓ FossFLOW container element found") except Exception as e: print(f"❌ FossFLOW container not found: {e}") # Get diagnostics logs = driver.get_log('browser') errors = [log for log in logs if log['level'] == 'SEVERE'] if errors: print(f"\nBrowser console errors:") for log in errors[:5]: print(f" {log['message'][:100]}") pytest.fail("FossFLOW container div not found - React may not have rendered") # Check that the app has rendered its UI components dom_info = driver.execute_script(""" return { divs: document.querySelectorAll('div').length, buttons: document.querySelectorAll('button').length, svgs: document.querySelectorAll('svg').length, hasFossflowContainer: document.querySelector('.fossflow-container') !== null }; """) print(f"\nDOM structure:") print(f" Divs: {dom_info['divs']}") print(f" Buttons: {dom_info['buttons']}") print(f" SVG elements: {dom_info['svgs']}") print(f" FossFLOW container: {dom_info['hasFossflowContainer']}") # Check for console errors logs = driver.get_log('browser') errors = [log for log in logs if log['level'] == 'SEVERE'] if errors: print(f"\n⚠️ Found {len(errors)} console errors:") for log in errors[:5]: print(f" {log['message'][:100]}") # Verify the app has rendered meaningful content assert dom_info['divs'] > 10, f"Expected many div elements, got {dom_info['divs']}" assert dom_info['buttons'] > 0, f"Expected buttons in the UI, got {dom_info['buttons']}" assert dom_info['hasFossflowContainer'], "FossFLOW container div should exist" print("\n✓ SUCCESS: FossFLOW app has rendered with UI components") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_connector_undo.py ================================================ """E2E test: place two nodes, connect them, then undo/redo the connector.""" import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") d = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) d.implicitly_wait(10) yield d d.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) return path def dismiss_modals(driver): """Dismiss all modals, dialogs, and tip popups.""" try: driver.execute_script(""" // Close MUI dialogs const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); dialogs.forEach(d => { const closeBtn = d.querySelector('button'); if (closeBtn) closeBtn.click(); }); // Close tip popups (X buttons) const closeIcons = document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]'); closeIcons.forEach(icon => { const btn = icon.closest('button'); if (btn) btn.click(); }); // Close anything with a close/dismiss aria-label document.querySelectorAll('button').forEach(btn => { const label = (btn.getAttribute('aria-label') || '').toLowerCase(); if (label.includes('close') || label.includes('dismiss')) btn.click(); }); """) time.sleep(0.5) # Second pass to catch the lazy loading modal that may appear later driver.execute_script(""" const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); dialogs.forEach(d => { const btns = d.querySelectorAll('button'); btns.forEach(b => { if (b.textContent.trim() === '×' || b.querySelector('svg')) b.click(); }); }); """) time.sleep(0.3) except Exception: pass def count_canvas_images(driver): return driver.execute_script(""" const c = document.querySelector('.fossflow-container'); if (!c) return 0; return c.querySelectorAll('img').length; """) def count_connector_polylines(driver): """Count SVG polylines inside the fossflow container (connector paths).""" return driver.execute_script(""" const c = document.querySelector('.fossflow-container'); if (!c) return 0; return c.querySelectorAll('svg polyline').length; """) def get_scene_state(driver): """Get scene connector count via React fiber store discovery.""" return driver.execute_script(""" // Walk React fiber tree to find the scene store var root = document.getElementById("root"); var containerKey = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!containerKey) return {error: "no react container"}; var fiber = root[containerKey]; var queue = [fiber]; var visited = 0; var sceneStore = null; var modelStore = null; while (queue.length > 0 && visited < 3000) { var node = queue.shift(); if (!node) continue; visited++; if (node.pendingProps && node.pendingProps.value && typeof node.pendingProps.value === "object" && node.pendingProps.value !== null && typeof node.pendingProps.value.getState === "function") { try { var state = node.pendingProps.value.getState(); if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) { sceneStore = node.pendingProps.value; } if (state && state.views !== undefined && state.items !== undefined && state.history) { modelStore = node.pendingProps.value; } } catch(e) {} } if (node.child) queue.push(node.child); if (node.sibling) queue.push(node.sibling); } if (!sceneStore || !modelStore) return {error: "stores not found", visited: visited}; var s = sceneStore.getState(); var m = modelStore.getState(); var cv = m.views && m.views[0]; return { connectors: Object.keys(s.connectors || {}).length, modelItems: (m.items || []).length, viewConnectors: cv && cv.connectors ? cv.connectors.length : 0, scenePastLen: s.history.past.length, sceneFutureLen: s.history.future.length, modelPastLen: m.history.past.length, modelFutureLen: m.history.future.length, }; """) def place_node_at(driver, x_offset, y_offset): """Select icon and place at a specific canvas offset.""" add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") add_btn.click() time.sleep(0.8) driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.trim().toUpperCase(); if (text.includes('ISOFLOW') && !text.includes('IMPORT')) { btn.click(); return; } } """) time.sleep(2) first_icon_btn = driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const img = btn.querySelector('img'); if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) return btn; } for (const btn of buttons) { const img = btn.querySelector('img'); if (img) return btn; } return null; """) if first_icon_btn is None: return False ActionChains(driver).click(first_icon_btn).perform() time.sleep(0.5) canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(driver).move_to_element_with_offset(canvas, x_offset, y_offset).click().perform() time.sleep(1) return True def click_connector_tool(driver): """Click the Connector tool button in the toolbar.""" btn = driver.execute_script(""" return document.querySelector("button[aria-label*='Connector']"); """) if btn: btn.click() time.sleep(0.5) return True return False def click_undo(driver): btn = driver.execute_script(""" return document.querySelector("button[aria-label*='Undo']"); """) if btn: btn.click() time.sleep(1) return True return False def click_redo(driver): btn = driver.execute_script(""" return document.querySelector("button[aria-label*='Redo']"); """) if btn: btn.click() time.sleep(1) return True return False def test_connector_undo_redo(driver): """Place 2 nodes, connect them, undo connector, redo connector.""" base_url = get_base_url() # --- Load app --- print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) # --- Place two nodes --- print("\n2. Placing node 1 at (350, 300)...") assert place_node_at(driver, 350, 300), "Failed to place node 1" imgs = count_canvas_images(driver) print(f" Images: {imgs}") print(" Placing node 2 at (600, 300)...") assert place_node_at(driver, 600, 300), "Failed to place node 2" imgs = count_canvas_images(driver) print(f" Images: {imgs}") assert imgs == 2, f"Expected 2 images after placing 2 nodes, got {imgs}" save_screenshot(driver, "conn_01_two_nodes") polylines_before = count_connector_polylines(driver) state_before = get_scene_state(driver) print(f" Polylines before connector: {polylines_before}") print(f" Scene state: {state_before}") # Dismiss any late-appearing modals (Lazy Loading popup) dismiss_modals(driver) time.sleep(0.5) save_screenshot(driver, "conn_01b_before_connect") # --- Get node image elements for clicking --- node_imgs = driver.find_elements(By.CSS_SELECTOR, ".fossflow-container img") print(f" Found {len(node_imgs)} node images") assert len(node_imgs) >= 2, f"Expected 2+ node images, got {len(node_imgs)}" # --- Activate connector tool (click mode) --- print("\n3. Activating Connector tool...") assert click_connector_tool(driver), "Failed to find Connector button" time.sleep(0.5) # --- Click on node 1 image (first click - start connector) --- print(" Clicking on node 1 to start connector...") ActionChains(driver).click(node_imgs[0]).perform() time.sleep(1) save_screenshot(driver, "conn_02_first_click") # --- Click on node 2 image (second click - complete connector) --- print(" Clicking on node 2 to complete connector...") ActionChains(driver).click(node_imgs[1]).perform() time.sleep(1) save_screenshot(driver, "conn_03_connected") polylines_after = count_connector_polylines(driver) state_after = get_scene_state(driver) print(f" Polylines after connector: {polylines_after}") print(f" Scene state: {state_after}") # Verify connector was created has_connector = polylines_after > polylines_before scene_has_connector = ( isinstance(state_after, dict) and state_after.get("connectors", 0) > 0 ) print(f" DOM has connector: {has_connector}") print(f" Scene store has connector: {scene_has_connector}") assert has_connector or scene_has_connector, ( f"No connector created. Polylines: {polylines_before} -> {polylines_after}, " f"Scene state: {state_after}" ) connector_polylines = polylines_after # --- Switch back to default mode (press Escape) --- print("\n4. Pressing Escape to exit connector mode...") ActionChains(driver).send_keys('\ue00c').perform() # Escape time.sleep(0.5) # --- Undo connector (create + update = 2 history entries) --- print("\n5. Undoing connector (2 steps: update then create)...") click_undo(driver) polylines_mid = count_connector_polylines(driver) state_mid = get_scene_state(driver) print(f" After undo 1: polylines={polylines_mid}, scene={state_mid}") click_undo(driver) polylines_undo = count_connector_polylines(driver) state_undo = get_scene_state(driver) print(f" After undo 2: polylines={polylines_undo}, scene={state_undo}") save_screenshot(driver, "conn_04_after_undo") assert polylines_undo < connector_polylines or ( isinstance(state_undo, dict) and state_undo.get("connectors", 0) == 0 ), ( f"Undo did not remove connector. Polylines: {connector_polylines} -> {polylines_undo}, " f"Scene state: {state_undo}" ) print(" Connector removed by undo.") # Verify nodes are still there imgs_after_undo = count_canvas_images(driver) print(f" Images after undo: {imgs_after_undo} (nodes should still be there)") assert imgs_after_undo == 2, f"Expected 2 images after undoing connector, got {imgs_after_undo}" # --- Redo connector (2 steps: create then update) --- print("\n6. Redoing connector (2 steps)...") click_redo(driver) click_redo(driver) time.sleep(0.5) polylines_redo = count_connector_polylines(driver) state_redo = get_scene_state(driver) print(f" Polylines after redo: {polylines_redo}") print(f" Scene state: {state_redo}") save_screenshot(driver, "conn_05_after_redo") assert polylines_redo >= connector_polylines or ( isinstance(state_redo, dict) and state_redo.get("connectors", 0) > 0 ), ( f"Redo did not restore connector. Polylines: {polylines_undo} -> {polylines_redo}, " f"Scene state: {state_redo}" ) print(" Connector restored by redo.") # --- Undo/redo cycle again --- print("\n7. Undoing connector again (2 steps)...") click_undo(driver) click_undo(driver) time.sleep(0.5) polylines_undo2 = count_connector_polylines(driver) state_undo2 = get_scene_state(driver) print(f" Polylines: {polylines_undo2}, connectors: {state_undo2.get('connectors', '?')}") assert polylines_undo2 < connector_polylines or ( isinstance(state_undo2, dict) and state_undo2.get("connectors", 0) == 0 ), "Second undo cycle did not remove connector" print(" Connector removed again.") print(" Redoing connector again (2 steps)...") click_redo(driver) click_redo(driver) time.sleep(0.5) polylines_redo2 = count_connector_polylines(driver) state_redo2 = get_scene_state(driver) print(f" Polylines: {polylines_redo2}, connectors: {state_redo2.get('connectors', '?')}") assert polylines_redo2 >= connector_polylines or ( isinstance(state_redo2, dict) and state_redo2.get("connectors", 0) > 0 ), "Second redo cycle did not restore connector" save_screenshot(driver, "conn_06_final") print("\n SUCCESS: Connector undo/redo cycle works correctly!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_export_svg.py ================================================ """E2E test: build a scene with nodes, rectangle, and text, then export as SVG.""" import os import time import glob import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") DOWNLOAD_DIR = "/tmp/fossflow-e2e-downloads" def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") # Configure download directory for headless Chrome prefs = { "download.default_directory": DOWNLOAD_DIR, "download.prompt_for_download": False, "download.directory_upgrade": True, "safebrowsing.enabled": True, } chrome_options.add_experimental_option("prefs", prefs) d = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) d.implicitly_wait(10) # For headless Chrome on Remote, enable download via CDP # This sets the download path inside the container try: d.execute("send_command", { "cmd": "Page.setDownloadBehavior", "params": {"behavior": "allow", "downloadPath": DOWNLOAD_DIR} }) except Exception: # Fallback: try CDP command directly try: d.execute_cdp_cmd("Page.setDownloadBehavior", { "behavior": "allow", "downloadPath": DOWNLOAD_DIR, }) except Exception: pass yield d d.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) return path def dismiss_modals(driver): """Dismiss all modals, dialogs, and tip popups (multiple passes for lazy ones).""" for _ in range(3): try: driver.execute_script(""" // Close MUI dialogs document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]').forEach(d => { const btns = d.querySelectorAll('button'); btns.forEach(b => { if (b.querySelector('svg') || b.textContent.trim() === '×') b.click(); }); const closeBtn = d.querySelector('button'); if (closeBtn) closeBtn.click(); }); // Close X buttons (CloseIcon, ClearIcon) document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]').forEach(icon => { const b = icon.closest('button'); if (b) b.click(); }); // Close anything with close/dismiss aria-label document.querySelectorAll('button').forEach(btn => { const l = (btn.getAttribute('aria-label') || '').toLowerCase(); if (l.includes('close') || l.includes('dismiss')) btn.click(); }); """) time.sleep(0.5) except Exception: pass def place_node_at(driver, x_offset, y_offset): """Select icon and place at a specific canvas offset.""" add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") add_btn.click() time.sleep(0.8) driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.trim().toUpperCase(); if (text.includes('ISOFLOW') && !text.includes('IMPORT')) { btn.click(); return; } } """) time.sleep(2) first_icon_btn = driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const img = btn.querySelector('img'); if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) return btn; } for (const btn of buttons) { const img = btn.querySelector('img'); if (img) return btn; } return null; """) if first_icon_btn is None: return False ActionChains(driver).click(first_icon_btn).perform() time.sleep(0.5) canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(driver).move_to_element_with_offset(canvas, x_offset, y_offset).click().perform() time.sleep(1) return True def draw_rectangle(driver, x, y, width, height): """Activate rectangle tool and drag to draw.""" rect_btn = driver.execute_script( "return document.querySelector(\"button[aria-label*='Rectangle']\");" ) if not rect_btn: return False rect_btn.click() time.sleep(0.5) canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") actions = ActionChains(driver) actions.move_to_element_with_offset(canvas, x, y) actions.click_and_hold() actions.move_by_offset(width, height) actions.release() actions.perform() time.sleep(1) return True def place_textbox(driver, x, y): """Activate text tool and click to place.""" text_btn = driver.execute_script( "return document.querySelector(\"button[aria-label*='Text']\");" ) if not text_btn: return False text_btn.click() time.sleep(0.5) canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(driver).move_to_element_with_offset(canvas, x, y).click().perform() time.sleep(1) # Press Escape to exit text mode and deselect ActionChains(driver).send_keys('\ue00c').perform() time.sleep(0.5) return True def get_scene_state(driver): """Get scene state via React fiber store discovery.""" return driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return {error: "no react"}; var fiber = root[ck], queue = [fiber], v = 0, ss = null, ms = null; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.connectors !== undefined && st.textBoxes !== undefined) ss = n.pendingProps.value; if (st && st.views !== undefined && st.items !== undefined) ms = n.pendingProps.value; } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } if (!ss || !ms) return {error: "stores not found"}; var s = ss.getState(), m = ms.getState(); var cv = m.views && m.views[0]; return { rectangles: cv && cv.rectangles ? cv.rectangles.length : 0, textBoxes: Object.keys(s.textBoxes || {}).length, connectors: Object.keys(s.connectors || {}).length, modelItems: (m.items || []).length, }; """) def test_export_svg(driver): """Build a scene with nodes, rectangle, and text, then export as SVG.""" base_url = get_base_url() # Clean up any previous downloads os.makedirs(DOWNLOAD_DIR, exist_ok=True) for f in glob.glob(os.path.join(DOWNLOAD_DIR, "fossflow-export-*")): os.remove(f) # --- Load app --- print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) # --- Place 2 nodes --- print("\n2. Placing nodes...") assert place_node_at(driver, 350, 300), "Failed to place node 1" print(" Node 1 placed.") assert place_node_at(driver, 600, 300), "Failed to place node 2" print(" Node 2 placed.") # Dismiss any late-appearing modals (Lazy Loading popup) dismiss_modals(driver) time.sleep(0.5) # --- Draw a rectangle --- print("\n3. Drawing rectangle...") assert draw_rectangle(driver, 100, 100, 150, 100), "Failed to draw rectangle" print(" Rectangle drawn.") # --- Place a text box --- print("\n4. Placing text box...") assert place_textbox(driver, 500, 200), "Failed to place text box" print(" Text box placed.") # --- Verify scene has all elements --- state = get_scene_state(driver) print(f"\n5. Scene state: {state}") assert isinstance(state, dict), f"Failed to get scene state: {state}" assert state.get("modelItems", 0) >= 2, f"Expected 2+ nodes, got {state}" assert state.get("rectangles", 0) >= 1, f"Expected 1+ rectangle, got {state}" assert state.get("textBoxes", 0) >= 1, f"Expected 1+ text box, got {state}" save_screenshot(driver, "export_01_scene_built") print(" Scene verified: nodes, rectangle, and text box all present.") # --- Open main menu --- print("\n6. Opening main menu...") menu_btn = driver.execute_script(""" // MainMenu uses IconButton with name="Main menu" var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var btn = buttons[i]; var label = (btn.getAttribute('aria-label') || '').toLowerCase(); var name = (btn.getAttribute('name') || '').toLowerCase(); if (label.includes('main menu') || name.includes('main menu') || label.includes('menu')) { return btn; } } // Fallback: look for the MUI MenuIcon (hamburger) var svgs = document.querySelectorAll('button svg'); for (var j = 0; j < svgs.length; j++) { var path = svgs[j].querySelector('path'); if (path) { var d = path.getAttribute('d') || ''; // MUI MenuIcon path starts with "M3 18h18v-2H3" if (d.includes('M3 18h18') || d.includes('M3 18')) return svgs[j].closest('button'); } } return null; """) assert menu_btn is not None, "Main menu button not found" menu_btn.click() time.sleep(1) save_screenshot(driver, "export_02_menu_open") print(" Main menu opened.") # --- Click "Export as image" --- print("\n7. Clicking 'Export as image'...") export_item = driver.execute_script(""" // Look for menu item containing "Export as image" or similar var items = document.querySelectorAll('[role="menuitem"], li.MuiMenuItem-root'); for (var i = 0; i < items.length; i++) { var text = items[i].textContent.trim().toLowerCase(); if (text.includes('export') && text.includes('image')) return items[i]; } // Fallback: any menu item with "export" for (var j = 0; j < items.length; j++) { var t = items[j].textContent.trim().toLowerCase(); if (t.includes('export')) return items[j]; } return null; """) assert export_item is not None, "Export as image menu item not found" export_item.click() time.sleep(2) save_screenshot(driver, "export_03_dialog_opening") print(" Clicked export menu item.") # --- Wait for export dialog --- print("\n8. Waiting for export dialog...") dialog = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, '[role="dialog"]')) ) print(" Export dialog appeared.") # Wait for the preview to render (SVG data needs to be generated) # The "Download as SVG" button is disabled until svgData is ready print(" Waiting for SVG data to generate...") svg_btn = None for attempt in range(30): # Up to 30 seconds svg_btn = driver.execute_script(""" var buttons = document.querySelectorAll('[role="dialog"] button'); for (var i = 0; i < buttons.length; i++) { var text = buttons[i].textContent.trim().toLowerCase(); if (text.includes('svg') && text.includes('download')) { return buttons[i]; } } return null; """) if svg_btn: is_disabled = driver.execute_script( "return arguments[0].disabled;", svg_btn ) if not is_disabled: print(f" SVG button ready after {attempt + 1}s.") break print(f" SVG button found but disabled (attempt {attempt + 1})...") else: print(f" SVG button not found yet (attempt {attempt + 1})...") time.sleep(1) else: save_screenshot(driver, "export_04_svg_timeout") pytest.fail("SVG download button never became enabled") save_screenshot(driver, "export_04_dialog_ready") # --- Click "Download as SVG" --- print("\n9. Clicking 'Download as SVG'...") svg_btn.click() time.sleep(3) # Wait for download to complete save_screenshot(driver, "export_05_after_download") print(" Download triggered.") # --- Verify SVG file was downloaded --- print("\n10. Checking for downloaded SVG file...") # Method 1: Check filesystem (works when download dir is accessible) svg_files = glob.glob(os.path.join(DOWNLOAD_DIR, "fossflow-export-*.svg")) if svg_files: svg_path = svg_files[0] svg_size = os.path.getsize(svg_path) print(f" Found SVG file: {svg_path} ({svg_size} bytes)") assert svg_size > 100, f"SVG file too small ({svg_size} bytes), likely empty" # Read first 500 chars to verify it's valid SVG with open(svg_path, "r", errors="replace") as f: content_start = f.read(500) is_svg = " 1000, ( f"Downloaded file doesn't appear to be valid SVG. Start: {content_start[:200]}" ) print(" SVG file verified!") else: # Method 2: If running in Docker, the download happens inside the container. # We can verify the download happened by checking browser download state. print(" No SVG file found locally (download may be inside Docker container).") print(" Verifying download was triggered via browser...") # Check that the button click didn't cause an error errors = driver.execute_script(""" var logs = []; try { var entries = performance.getEntriesByType('resource'); for (var i = entries.length - 1; i >= Math.max(0, entries.length - 5); i--) { logs.push(entries[i].name); } } catch(e) {} return logs; """) print(f" Recent resource loads: {errors}") # Check for any error alerts in the dialog export_error = driver.execute_script(""" var alerts = document.querySelectorAll('[role="dialog"] .MuiAlert-standardError'); return alerts.length; """) assert export_error == 0, "Export dialog shows an error alert" print(" No export errors detected. Download was triggered successfully.") print("\n SUCCESS: Scene exported as SVG!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_import_diagram.py ================================================ """E2E test: import a diagram JSON file and verify all elements loaded correctly.""" import os import time import json import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.remote.file_detector import LocalFileDetector SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") TEST_DIAGRAM = os.path.join(os.path.dirname(__file__), "..", "test-diagram.json") def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") d = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) d.implicitly_wait(10) # Enable local file detection for remote WebDriver (uploads files to remote) d.file_detector = LocalFileDetector() yield d d.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) return path def dismiss_modals(driver): """Dismiss all modals, dialogs, and tip popups (multiple passes for lazy ones).""" for _ in range(3): try: driver.execute_script(""" document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]').forEach(d => { var btns = d.querySelectorAll('button'); btns.forEach(b => { if (b.querySelector('svg') || b.textContent.trim() === '×') b.click(); }); var first = d.querySelector('button'); if (first) first.click(); }); document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]').forEach(icon => { var b = icon.closest('button'); if (b) b.click(); }); document.querySelectorAll('button').forEach(btn => { var l = (btn.getAttribute('aria-label') || '').toLowerCase(); if (l.includes('close') || l.includes('dismiss')) btn.click(); }); """) time.sleep(0.5) except Exception: pass def get_scene_state(driver): """Get full scene state via React fiber store discovery.""" return driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return {error: "no react"}; var fiber = root[ck], queue = [fiber], v = 0, ss = null, ms = null; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.connectors !== undefined && st.textBoxes !== undefined && st.history) ss = n.pendingProps.value; if (st && st.views !== undefined && st.items !== undefined && st.history) ms = n.pendingProps.value; } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } if (!ss || !ms) return {error: "stores not found"}; var s = ss.getState(), m = ms.getState(); var cv = m.views && m.views[0]; return { modelItems: (m.items || []).length, icons: (m.icons || []).length, views: (m.views || []).length, viewItems: cv ? (cv.items || []).length : 0, viewConnectors: cv && cv.connectors ? cv.connectors.length : 0, viewRectangles: cv && cv.rectangles ? cv.rectangles.length : 0, viewTextBoxes: cv && cv.textBoxes ? cv.textBoxes.length : 0, sceneConnectors: Object.keys(s.connectors || {}).length, sceneTextBoxes: Object.keys(s.textBoxes || {}).length, title: m.title || "", }; """) def load_expected_counts(): """Load the test diagram JSON and extract expected element counts.""" with open(TEST_DIAGRAM, "r") as f: data = json.load(f) view = data["views"][0] if data.get("views") else {} return { "modelItems": len(data.get("items", [])), "icons": len(data.get("icons", [])), "views": len(data.get("views", [])), "viewItems": len(view.get("items", [])), "viewConnectors": len(view.get("connectors", [])), "viewRectangles": len(view.get("rectangles", [])), "viewTextBoxes": len(view.get("textBoxes", [])), "title": data.get("title", ""), } def test_import_via_app_button(driver): """Import a diagram using the MainMenu 'Open' and verify all elements loaded.""" base_url = get_base_url() expected = load_expected_counts() print(f"\n1. Loading app at {base_url}") print(f" Expected from JSON: {expected}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) # Verify baseline (empty diagram) baseline = get_scene_state(driver) print(f" Baseline state: {baseline}") save_screenshot(driver, "import_01_baseline") # --- Import via MainMenu "Open" --- # The MainMenu "Open" creates a transient .click(). # We intercept this by overriding click() to capture the input element # so we can send_keys to it. print(f"\n2. Importing diagram from {TEST_DIAGRAM}...") # Step 1: Install interceptor that captures the file input before it clicks driver.execute_script(""" window.__capturedFileInput = null; var origClick = HTMLInputElement.prototype.click; HTMLInputElement.prototype.click = function() { if (this.type === 'file') { window.__capturedFileInput = this; // Don't actually click (which opens native dialog) // Instead, append to DOM so Selenium can interact with it this.style.position = 'fixed'; this.style.top = '0'; this.style.left = '0'; this.style.opacity = '0.01'; this.style.zIndex = '99999'; document.body.appendChild(this); return; } origClick.call(this); }; """) # Step 2: Open main menu and click "Open" menu_btn = driver.execute_script(""" var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var label = (buttons[i].getAttribute('aria-label') || '').toLowerCase(); var name = (buttons[i].getAttribute('name') || '').toLowerCase(); if (label.includes('main menu') || name.includes('main menu') || label.includes('menu')) return buttons[i]; } return null; """) assert menu_btn is not None, "Main menu button not found" menu_btn.click() time.sleep(1) save_screenshot(driver, "import_02_menu_open") # Click "Open" menu item open_item = driver.execute_script(""" var items = document.querySelectorAll('[role="menuitem"], li.MuiMenuItem-root'); for (var i = 0; i < items.length; i++) { var text = items[i].textContent.trim().toLowerCase(); if (text === 'open') return items[i]; } return null; """) assert open_item is not None, "'Open' menu item not found" open_item.click() time.sleep(1) # Step 3: Get the captured file input and send the file file_input = driver.execute_script("return window.__capturedFileInput;") assert file_input is not None, "File input was not captured by interceptor" print(" Captured file input via interceptor.") file_input.send_keys(os.path.abspath(TEST_DIAGRAM)) print(" File sent to input.") time.sleep(3) # Restore original click driver.execute_script(""" if (window.__origHTMLInputClick) { HTMLInputElement.prototype.click = window.__origHTMLInputClick; } """) # Check for alert dialogs (validation errors) try: alert = driver.switch_to.alert alert_text = alert.text alert.accept() print(f" Alert appeared: {alert_text}") pytest.fail(f"Import failed with alert: {alert_text}") except Exception: pass save_screenshot(driver, "import_03_after_import") # --- Wait for diagram to render --- print("\n3. Waiting for diagram to render...") time.sleep(2) dismiss_modals(driver) time.sleep(1) save_screenshot(driver, "import_03_rendered") # --- Verify imported elements --- print("\n4. Verifying imported elements...") state = get_scene_state(driver) print(f" Scene state: {state}") assert isinstance(state, dict) and "error" not in state, ( f"Failed to get scene state: {state}" ) # Verify model items (nodes) assert state["modelItems"] == expected["modelItems"], ( f"Expected {expected['modelItems']} model items, got {state['modelItems']}" ) print(f" Model items: {state['modelItems']} (expected {expected['modelItems']})") # Verify view items assert state["viewItems"] == expected["viewItems"], ( f"Expected {expected['viewItems']} view items, got {state['viewItems']}" ) print(f" View items: {state['viewItems']} (expected {expected['viewItems']})") # Verify connectors assert state["viewConnectors"] == expected["viewConnectors"], ( f"Expected {expected['viewConnectors']} connectors, got {state['viewConnectors']}" ) print(f" Connectors: {state['viewConnectors']} (expected {expected['viewConnectors']})") # Verify rectangles assert state["viewRectangles"] == expected["viewRectangles"], ( f"Expected {expected['viewRectangles']} rectangles, got {state['viewRectangles']}" ) print(f" Rectangles: {state['viewRectangles']} (expected {expected['viewRectangles']})") # Verify text boxes assert state["viewTextBoxes"] == expected["viewTextBoxes"], ( f"Expected {expected['viewTextBoxes']} text boxes, got {state['viewTextBoxes']}" ) print(f" Text boxes: {state['viewTextBoxes']} (expected {expected['viewTextBoxes']})") # --- Verify visual rendering --- print("\n5. Verifying visual elements...") # Check for node images on canvas img_count = driver.execute_script(""" var c = document.querySelector('.fossflow-container'); return c ? c.querySelectorAll('img').length : 0; """) print(f" Canvas images (nodes): {img_count}") assert img_count >= expected["modelItems"], ( f"Expected at least {expected['modelItems']} images (nodes), got {img_count}" ) # Check for connector polylines polyline_count = driver.execute_script(""" var c = document.querySelector('.fossflow-container'); return c ? c.querySelectorAll('svg polyline').length : 0; """) print(f" SVG polylines (connectors): {polyline_count}") # Check for rectangle polygons polygon_count = driver.execute_script(""" var c = document.querySelector('.fossflow-container'); return c ? c.querySelectorAll('svg polygon').length : 0; """) print(f" SVG polygons (rectangles): {polygon_count}") save_screenshot(driver, "import_04_verified") # --- Test undo after import (should work cleanly) --- print("\n6. Testing undo after import...") state_before_undo = get_scene_state(driver) undo_btn = driver.execute_script( "return document.querySelector(\"button[aria-label*='Undo']\");" ) if undo_btn: undo_btn.click() time.sleep(1) state_after_undo = get_scene_state(driver) print(f" Before undo: items={state_before_undo['modelItems']}") print(f" After undo: items={state_after_undo['modelItems']}") # Import should clear history, so undo should be a no-op or have minimal effect print(" Undo after import behaves correctly.") else: print(" No undo button found (OK for this test).") save_screenshot(driver, "import_05_after_undo") # --- Test that we can interact with imported elements --- print("\n7. Testing interaction with imported elements...") # Try clicking on a node image node_imgs = driver.find_elements(By.CSS_SELECTOR, ".fossflow-container img") if node_imgs: ActionChains(driver).click(node_imgs[0]).perform() time.sleep(0.5) save_screenshot(driver, "import_06_node_clicked") print(f" Clicked on first node. Interaction works.") else: print(" No node images found for click test.") # --- Re-export to verify round-trip --- print("\n8. Verifying export after import (round-trip)...") # Press Escape first to deselect ActionChains(driver).send_keys('\ue00c').perform() time.sleep(0.5) # Open main menu menu_btn = driver.execute_script(""" var buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var label = (buttons[i].getAttribute('aria-label') || '').toLowerCase(); var name = (buttons[i].getAttribute('name') || '').toLowerCase(); if (label.includes('main menu') || name.includes('main menu') || label.includes('menu')) return buttons[i]; } return null; """) if menu_btn: menu_btn.click() time.sleep(1) # Click Export as image export_item = driver.execute_script(""" var items = document.querySelectorAll('[role="menuitem"], li.MuiMenuItem-root'); for (var i = 0; i < items.length; i++) { var text = items[i].textContent.trim().toLowerCase(); if (text.includes('export') && text.includes('image')) return items[i]; } return null; """) if export_item: export_item.click() time.sleep(2) # Wait for export dialog try: WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.CSS_SELECTOR, '[role="dialog"]')) ) # Wait for SVG button to become enabled for attempt in range(15): svg_btn = driver.execute_script(""" var buttons = document.querySelectorAll('[role="dialog"] button'); for (var i = 0; i < buttons.length; i++) { var text = buttons[i].textContent.trim().toLowerCase(); if (text.includes('svg') && text.includes('download')) return buttons[i]; } return null; """) if svg_btn and not driver.execute_script("return arguments[0].disabled", svg_btn): print(" Export dialog rendered with SVG button ready.") break time.sleep(1) save_screenshot(driver, "import_07_export_dialog") print(" Round-trip export dialog verified.") # Close dialog cancel_btn = driver.execute_script(""" var buttons = document.querySelectorAll('[role="dialog"] button'); for (var i = 0; i < buttons.length; i++) { if (buttons[i].textContent.trim().toLowerCase() === 'cancel') return buttons[i]; } return null; """) if cancel_btn: cancel_btn.click() time.sleep(0.5) except Exception as e: print(f" Export dialog issue: {e}") else: print(" Export menu item not found.") else: print(" Main menu button not found.") save_screenshot(driver, "import_08_final") print(f"\n SUCCESS: Diagram imported with {expected['modelItems']} items, " f"{expected['viewConnectors']} connectors, {expected['viewRectangles']} rectangles, " f"{expected['viewTextBoxes']} text boxes!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_multi_node_undo.py ================================================ """E2E test: place multiple nodes, then undo/redo through them.""" import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") d = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) d.implicitly_wait(10) yield d d.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) return path def dismiss_modals(driver): try: driver.execute_script(""" const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); dialogs.forEach(d => { const closeBtn = d.querySelector('button'); if (closeBtn) closeBtn.click(); }); """) time.sleep(0.5) except Exception: pass def count_canvas_images(driver): return driver.execute_script(""" const c = document.querySelector('.fossflow-container'); if (!c) return 0; return c.querySelectorAll('img').length; """) def get_model_items_count(driver): """Get model items count via React fiber store discovery.""" return driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return -1; var fiber = root[ck], queue = [fiber], v = 0; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.views !== undefined && st.items !== undefined) { return (st.items || []).length; } } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } return -1; """) def place_node_at(driver, x_offset, y_offset): """Select icon and place at a specific canvas offset.""" # Click "Add item (N)" button add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") add_btn.click() time.sleep(0.8) # Expand ISOFLOW icon collection driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.trim().toUpperCase(); if (text.includes('ISOFLOW') && !text.includes('IMPORT')) { btn.click(); return; } } """) time.sleep(2) # Select first icon first_icon_btn = driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const img = btn.querySelector('img'); if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) return btn; } for (const btn of buttons) { const img = btn.querySelector('img'); if (img) return btn; } return null; """) if first_icon_btn is None: return False ActionChains(driver).click(first_icon_btn).perform() time.sleep(0.5) # Click on canvas at specific offset canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(driver).move_to_element_with_offset(canvas, x_offset, y_offset).click().perform() time.sleep(1) return True def click_undo(driver): btn = driver.execute_script(""" return document.querySelector("button[aria-label*='Undo']"); """) if btn: btn.click() time.sleep(1) return True return False def click_redo(driver): btn = driver.execute_script(""" return document.querySelector("button[aria-label*='Redo']"); """) if btn: btn.click() time.sleep(1) return True return False def test_multi_node_undo_redo(driver): """Place 3 nodes, undo all 3, redo all 3, then undo 2 and place a new one (forking history).""" base_url = get_base_url() print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) baseline_imgs = count_canvas_images(driver) print(f" Baseline images: {baseline_imgs}") # --- Place 3 nodes at different positions --- positions = [(300, 300), (500, 300), (700, 300)] for i, (x, y) in enumerate(positions): print(f"\n2.{i+1}. Placing node {i+1} at ({x}, {y})...") assert place_node_at(driver, x, y), f"Failed to place node {i+1}" imgs = count_canvas_images(driver) items = get_model_items_count(driver) print(f" Images: {imgs}, Model items: {items}") assert items == i + 1, f"Expected {i+1} model items, got {items}" save_screenshot(driver, "multi_01_three_nodes") after_3 = count_canvas_images(driver) items_3 = get_model_items_count(driver) print(f"\n After 3 nodes: images={after_3}, items={items_3}") assert items_3 == 3 # --- Undo all 3 nodes one by one --- print("\n3. Undoing all 3 nodes...") for i in range(3): click_undo(driver) imgs = count_canvas_images(driver) items = get_model_items_count(driver) expected = 2 - i print(f" After undo {i+1}: images={imgs}, items={items} (expected {expected})") assert items == expected, f"After undo {i+1}: expected {expected} items, got {items}" save_screenshot(driver, "multi_02_all_undone") assert get_model_items_count(driver) == 0, "Expected 0 items after undoing all 3" print(" All 3 nodes undone.") # --- Redo all 3 nodes one by one --- print("\n4. Redoing all 3 nodes...") for i in range(3): click_redo(driver) imgs = count_canvas_images(driver) items = get_model_items_count(driver) expected = i + 1 print(f" After redo {i+1}: images={imgs}, items={items} (expected {expected})") assert items == expected, f"After redo {i+1}: expected {expected} items, got {items}" save_screenshot(driver, "multi_03_all_redone") assert get_model_items_count(driver) == 3, "Expected 3 items after redoing all" print(" All 3 nodes redone.") # --- Undo 2, then place a new node (fork history) --- print("\n5. Undoing 2 nodes to fork history...") click_undo(driver) click_undo(driver) items = get_model_items_count(driver) print(f" After 2 undos: items={items} (expected 1)") assert items == 1, f"Expected 1 item after 2 undos, got {items}" # Check redo is available before fork can_redo = driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return null; var fiber = root[ck], queue = [fiber], v = 0; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.views !== undefined && st.items !== undefined && st.history) { return st.history.future.length > 0; } } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } return null; """) print(f" canRedo before fork: {can_redo}") assert can_redo, "Should be able to redo before forking" print(" Placing new node to fork history...") assert place_node_at(driver, 400, 300), "Failed to place fork node" items = get_model_items_count(driver) print(f" After fork placement: items={items} (expected 2)") assert items == 2, f"Expected 2 items after fork placement, got {items}" # Redo should now be impossible (future was cleared by new action) can_redo = driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return null; var fiber = root[ck], queue = [fiber], v = 0; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.views !== undefined && st.items !== undefined && st.history) { return st.history.future.length > 0; } } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } return null; """) print(f" canRedo after fork: {can_redo}") assert not can_redo, "Should NOT be able to redo after forking history" save_screenshot(driver, "multi_04_forked") # --- Undo the fork node --- print("\n6. Undoing the fork node...") click_undo(driver) items = get_model_items_count(driver) print(f" After undo fork: items={items} (expected 1)") assert items == 1, f"Expected 1 item after undoing fork, got {items}" # --- Redo the fork node --- print(" Redoing the fork node...") click_redo(driver) items = get_model_items_count(driver) print(f" After redo fork: items={items} (expected 2)") assert items == 2, f"Expected 2 item after redoing fork, got {items}" save_screenshot(driver, "multi_05_final") print("\n SUCCESS: Multi-node undo/redo with history forking works!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_node_placement.py ================================================ """ E2E tests for placing nodes on the FossFLOW canvas and undo/redo. Takes screenshots at each step to visually verify state. """ import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--enable-webgl") chrome_options.add_argument("--use-gl=swiftshader") chrome_options.add_argument("--enable-accelerated-2d-canvas") chrome_options.add_argument("--window-size=1920,1080") chrome_options.add_argument("--disable-blink-features=AutomationControlled") chrome_options.set_capability('goog:loggingPrefs', {'browser': 'ALL'}) driver = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) driver.implicitly_wait(10) yield driver driver.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) print(f" Screenshot saved: {path}") return path def dismiss_modals(driver): """Close any popup modals/dialogs that appear on first load.""" try: driver.execute_script(""" const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); dialogs.forEach(d => { const closeBtn = d.querySelector('button'); if (closeBtn) closeBtn.click(); }); const closeBtns = document.querySelectorAll('button[aria-label="Close"], button[aria-label="close"]'); closeBtns.forEach(b => b.click()); """) time.sleep(0.5) except Exception: pass def dismiss_tips(driver): """Close tip popups (Import Diagrams, Creating Connectors tips).""" try: driver.execute_script(""" const allButtons = document.querySelectorAll('button'); for (const btn of allButtons) { const ariaLabel = btn.getAttribute('aria-label') || ''; if (ariaLabel.toLowerCase().includes('close') || ariaLabel.toLowerCase().includes('dismiss')) { btn.click(); } } const closeIcons = document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]'); closeIcons.forEach(icon => { const btn = icon.closest('button'); if (btn) btn.click(); }); """) time.sleep(0.3) except Exception: pass def count_canvas_nodes(driver): """Count placed nodes on the canvas by checking for node images and labels.""" return driver.execute_script(""" const container = document.querySelector('.fossflow-container'); if (!container) return { images: 0, untitledLabels: 0, hasUntitled: false }; const allImgs = container.querySelectorAll('img'); const allText = container.innerText || ''; // Filter out "Untitled Diagram" and "Untitled view" from the count // We only want "Untitled" that appears as a standalone node label const hasUntitled = allText.includes('Untitled'); // Count standalone "Untitled" spans (node labels), but exclude // the bottom bar which has "Untitled Diagram > Untitled view" const spans = Array.from(container.querySelectorAll('span, p')); const untitledLabels = spans.filter(s => { const text = s.textContent.trim(); // Must be exactly "Untitled" (node label), not "Untitled Diagram" etc. return text === 'Untitled'; }); return { images: allImgs.length, untitledLabels: untitledLabels.length, hasUntitled: hasUntitled, allImgAlts: Array.from(allImgs).map(img => img.getAttribute('alt') || '(none)') }; """) def place_node(driver, screenshot_prefix=""): """Open icon panel, select first icon, click canvas to place a node. Returns True if node was placed successfully. """ pfx = f"{screenshot_prefix}_" if screenshot_prefix else "" # Click "Add item (N)" button add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") add_btn.click() time.sleep(1) # Expand the ISOFLOW icon collection driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.trim().toUpperCase(); if (text.includes('ISOFLOW') && !text.includes('IMPORT')) { btn.click(); return; } } """) time.sleep(3) # Select first icon with a small image (icon grid item) first_icon_btn = driver.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const img = btn.querySelector('img'); if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) { return btn; } } for (const btn of buttons) { const img = btn.querySelector('img'); if (img) return btn; } return null; """) if first_icon_btn is None: return False actions = ActionChains(driver) actions.click(first_icon_btn).perform() time.sleep(0.5) # Click on the canvas to place canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") actions = ActionChains(driver) actions.move_to_element_with_offset(canvas, 500, 400) actions.click() actions.perform() time.sleep(1) if screenshot_prefix: save_screenshot(driver, f"{pfx}placed") return True def find_toolbar_button(driver, name_substring): """Find a toolbar button by matching its tooltip/name text. The IconButton component wraps MUI Button inside a Tooltip. We find buttons whose parent tooltip has a matching title. """ btn = driver.execute_script(""" const target = arguments[0].toLowerCase(); // Try aria-label first const byAria = document.querySelector(`button[aria-label*='${arguments[0]}']`); if (byAria) return byAria; // Try title attribute const byTitle = document.querySelector(`button[title*='${arguments[0]}']`); if (byTitle) return byTitle; // Try finding via MUI Tooltip data attribute or svg icon const allButtons = document.querySelectorAll('button'); for (const btn of allButtons) { // Check if button or parent has matching tooltip text const title = btn.getAttribute('title') || ''; const ariaLabel = btn.getAttribute('aria-label') || ''; const ariaDescribedBy = btn.getAttribute('aria-describedby') || ''; if (title.toLowerCase().includes(target) || ariaLabel.toLowerCase().includes(target)) { return btn; } } return null; """, name_substring) return btn def get_undo_redo_debug_info(driver): """Get debug info about undo/redo buttons and store state.""" return driver.execute_script(""" const allButtons = Array.from(document.querySelectorAll('button')); const buttonInfo = allButtons.map(btn => ({ text: btn.textContent.trim().substring(0, 30), ariaLabel: btn.getAttribute('aria-label'), title: btn.getAttribute('title'), disabled: btn.disabled, className: btn.className.substring(0, 50) })).filter(b => b.ariaLabel || b.title); // Find undo/redo specific buttons const undoBtn = allButtons.find(b => (b.getAttribute('aria-label') || '').includes('Undo') || (b.getAttribute('title') || '').includes('Undo')); const redoBtn = allButtons.find(b => (b.getAttribute('aria-label') || '').includes('Redo') || (b.getAttribute('title') || '').includes('Redo')); return { totalButtons: allButtons.length, buttonsWithLabels: buttonInfo, undoButton: undoBtn ? { found: true, disabled: undoBtn.disabled, ariaLabel: undoBtn.getAttribute('aria-label'), title: undoBtn.getAttribute('title'), tagName: undoBtn.tagName, innerHTML: undoBtn.innerHTML.substring(0, 100) } : { found: false }, redoButton: redoBtn ? { found: true, disabled: redoBtn.disabled, ariaLabel: redoBtn.getAttribute('aria-label'), title: redoBtn.getAttribute('title'), } : { found: false } }; """) def click_undo(driver): """Click the Undo button in the toolbar.""" # Debug: check button state before clicking debug = get_undo_redo_debug_info(driver) print(f" DEBUG Undo button: {debug['undoButton']}") btn = find_toolbar_button(driver, "Undo") if btn is None: # Fallback: try keyboard shortcut print(" WARNING: Undo button not found, trying Ctrl+Z") actions = ActionChains(driver) actions.key_down('\ue009').send_keys('z').key_up('\ue009').perform() time.sleep(1) return is_disabled = driver.execute_script("return arguments[0].disabled", btn) print(f" DEBUG Undo button disabled={is_disabled}") if is_disabled: print(" WARNING: Undo button is DISABLED - canUndo is false!") btn.click() time.sleep(1) def click_redo(driver): """Click the Redo button in the toolbar.""" debug = get_undo_redo_debug_info(driver) print(f" DEBUG Redo button: {debug['redoButton']}") btn = find_toolbar_button(driver, "Redo") if btn is None: print(" WARNING: Redo button not found, trying Ctrl+Y") actions = ActionChains(driver) actions.key_down('\ue009').send_keys('y').key_up('\ue009').perform() time.sleep(1) return is_disabled = driver.execute_script("return arguments[0].disabled", btn) print(f" DEBUG Redo button disabled={is_disabled}") btn.click() time.sleep(1) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_place_node_on_canvas(driver): """Place a node on the canvas and verify it appears.""" base_url = get_base_url() print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) dismiss_tips(driver) time.sleep(0.5) save_screenshot(driver, "place_01_clean") # Count nodes before before = count_canvas_nodes(driver) print(f"2. Nodes before: images={before['images']}, labels={before['untitledLabels']}") # Place a node print("3. Placing node...") assert place_node(driver, "place"), "Failed to find icon to place" save_screenshot(driver, "place_02_after") # Count nodes after after = count_canvas_nodes(driver) print(f"4. Nodes after: images={after['images']}, labels={after['untitledLabels']}") assert after['images'] > before['images'] or after['untitledLabels'] > 0, ( f"Node was NOT placed. Before: {before}, After: {after}" ) print(" SUCCESS: Node placed on canvas!") def test_undo_redo_node(driver): """Place a node, undo to remove it, redo to restore it.""" base_url = get_base_url() # --- Setup: load app, dismiss popups --- print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) dismiss_tips(driver) time.sleep(0.5) # --- Baseline: empty canvas --- baseline = count_canvas_nodes(driver) print(f"2. Baseline (empty canvas): images={baseline['images']}, labels={baseline['untitledLabels']}") save_screenshot(driver, "undo_01_baseline") # --- Place a node --- print("3. Placing node...") assert place_node(driver, "undo"), "Failed to find icon to place" after_place = count_canvas_nodes(driver) print(f"4. After placement: images={after_place['images']}, labels={after_place['untitledLabels']}") save_screenshot(driver, "undo_02_node_placed") assert after_place['images'] > baseline['images'] or after_place['untitledLabels'] > 0, ( f"Node was not placed. Baseline: {baseline}, After: {after_place}" ) placed_images = after_place['images'] # --- Debug: dump store state BEFORE undo via React fiber --- print("5. Inspecting store state before undo (via React fiber)...") store_state = driver.execute_script(""" // Walk React fiber tree to find Zustand store contexts function findStores() { const root = document.getElementById('root'); if (!root) return { error: 'No root element' }; // Get the React fiber root const fiberKey = Object.keys(root).find(k => k.startsWith('__reactFiber')); if (!fiberKey) return { error: 'No React fiber found' }; let fiber = root[fiberKey]; // Walk the fiber tree looking for context values that look like Zustand stores const stores = {}; let visited = 0; const queue = [fiber]; while (queue.length > 0 && visited < 5000) { const node = queue.shift(); visited++; // Check memoizedState for context values if (node && node.memoizedState) { let st = node.memoizedState; while (st) { if (st.queue && st.queue.lastRenderedState) { const state = st.queue.lastRenderedState; // Look for model store (has 'views', 'items', 'history') if (state && state.views && state.items && state.history) { stores.modelStore = state; } // Look for scene store (has 'connectors', 'textBoxes', 'history') if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) { stores.sceneStore = state; } } st = st.next; } } // Check context if (node && node.pendingProps && node.pendingProps.value) { const val = node.pendingProps.value; if (typeof val === 'object' && val !== null && typeof val.getState === 'function') { try { const state = val.getState(); if (state && state.views && state.items && state.history) { stores.modelStoreApi = val; } if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) { stores.sceneStoreApi = val; } } catch(e) {} } } if (node && node.child) queue.push(node.child); if (node && node.sibling) queue.push(node.sibling); } // If we found the stores via context providers, use those if (stores.modelStoreApi) { window.__modelStore__ = stores.modelStoreApi; const ms = stores.modelStoreApi.getState(); const modelHistory = ms.history; const views = ms.views || []; const currentView = views[0]; let sceneInfo = {}; if (stores.sceneStoreApi) { window.__sceneStore__ = stores.sceneStoreApi; const ss = stores.sceneStoreApi.getState(); sceneInfo = { sceneHistoryPastLength: ss.history ? ss.history.past.length : -1, canUndoScene: ss.actions ? ss.actions.canUndo() : 'N/A', }; } return { found: true, modelHistoryPastLength: modelHistory ? modelHistory.past.length : -1, modelHistoryFutureLength: modelHistory ? modelHistory.future.length : -1, currentModelItemsCount: (ms.items || []).length, currentViewItemsCount: currentView ? (currentView.items || []).length : -1, currentViewsCount: views.length, canUndoModel: ms.actions ? ms.actions.canUndo() : 'N/A', canRedoModel: ms.actions ? ms.actions.canRedo() : 'N/A', ...sceneInfo, visited: visited, }; } return { error: 'Stores not found via fiber', visited: visited }; } return findStores(); """) print(f" Store state: {store_state}") # --- Click Undo --- print("6. Clicking Undo button...") click_undo(driver) time.sleep(0.5) # --- Debug: dump store state AFTER undo --- store_after_undo = driver.execute_script(""" if (!window.__modelStore__) return { error: 'No store ref' }; const ms = window.__modelStore__.getState(); const modelHistory = ms.history; const views = ms.views || []; const currentView = views[0]; const viewItems = currentView ? (currentView.items || []) : []; return { modelHistoryPastLength: modelHistory ? modelHistory.past.length : -1, modelHistoryFutureLength: modelHistory ? modelHistory.future.length : -1, currentModelItemsCount: (ms.items || []).length, currentViewItemsCount: viewItems.length, canUndoModel: ms.actions.canUndo(), canRedoModel: ms.actions.canRedo(), }; """) print(f" Store after undo: {store_after_undo}") after_undo = count_canvas_nodes(driver) print(f"7. After undo: images={after_undo['images']}, labels={after_undo['untitledLabels']}") save_screenshot(driver, "undo_03_after_undo") # If button click didn't work, try calling undo directly via JS if after_undo['images'] >= placed_images: print(" Button undo didn't remove node. Trying direct store undo via JS...") direct_result = driver.execute_script(""" if (!window.__modelStore__) return { error: 'No store ref' }; const ms = window.__modelStore__.getState(); // First check state before undo const beforeItems = (ms.items || []).length; const beforeViews = ms.views || []; const beforeViewItems = beforeViews[0] ? (beforeViews[0].items || []).length : -1; const beforePast = ms.history.past.length; // Call undo directly const modelUndoResult = ms.actions.undo(); // Check state after direct undo const msAfter = window.__modelStore__.getState(); const views = msAfter.views || []; const currentView = views[0]; return { modelUndoResult: modelUndoResult, beforeItems: beforeItems, beforeViewItems: beforeViewItems, beforePast: beforePast, afterModelItemsCount: (msAfter.items || []).length, afterViewItemsCount: currentView ? (currentView.items || []).length : -1, afterPastLength: msAfter.history.past.length, afterFutureLength: msAfter.history.future.length, }; """) print(f" Direct undo result: {direct_result}") time.sleep(1) after_undo = count_canvas_nodes(driver) print(f" After direct undo: images={after_undo['images']}, labels={after_undo['untitledLabels']}") save_screenshot(driver, "undo_03b_after_direct_undo") assert after_undo['images'] < placed_images, ( f"Undo did NOT remove the node. " f"Before undo: {placed_images} images, After undo: {after_undo['images']} images" ) print(" Undo removed the node.") # --- Click Redo --- print("7. Clicking Redo...") click_redo(driver) after_redo = count_canvas_nodes(driver) print(f"8. After redo: images={after_redo['images']}, labels={after_redo['untitledLabels']}") save_screenshot(driver, "undo_04_after_redo") assert after_redo['images'] >= placed_images, ( f"Redo did NOT restore the node. " f"After place: {placed_images} images, After redo: {after_redo['images']} images" ) print(" Redo restored the node.") # --- Undo again (verify cycle works) --- print("9. Clicking Undo again...") click_undo(driver) after_undo2 = count_canvas_nodes(driver) print(f"10. After second undo: images={after_undo2['images']}, labels={after_undo2['untitledLabels']}") save_screenshot(driver, "undo_05_after_undo2") assert after_undo2['images'] < placed_images, ( f"Second undo did NOT remove the node. " f"After redo: {placed_images} images, After undo2: {after_undo2['images']} images" ) print(" Second undo removed the node again.") print("\n SUCCESS: Undo/Redo/Undo cycle works correctly!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_rect_text_undo.py ================================================ """E2E tests: rectangle and text box creation with undo/redo.""" import os import time import pytest from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots") def get_base_url(): return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000") def get_webdriver_url(): return os.getenv("WEBDRIVER_URL", "http://localhost:4444") @pytest.fixture(scope="function") def driver(): chrome_options = Options() chrome_options.add_argument("--headless=new") chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("--window-size=1920,1080") d = webdriver.Remote( command_executor=get_webdriver_url(), options=chrome_options, ) d.implicitly_wait(10) yield d d.quit() def save_screenshot(driver, name): os.makedirs(SCREENSHOT_DIR, exist_ok=True) path = os.path.join(SCREENSHOT_DIR, f"{name}.png") driver.save_screenshot(path) return path def dismiss_modals(driver): try: driver.execute_script(""" document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]').forEach(d => { const b = d.querySelector('button'); if (b) b.click(); }); document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]').forEach(icon => { const b = icon.closest('button'); if (b) b.click(); }); document.querySelectorAll('button').forEach(btn => { const l = (btn.getAttribute('aria-label') || '').toLowerCase(); if (l.includes('close') || l.includes('dismiss')) btn.click(); }); """) time.sleep(0.5) except Exception: pass def get_scene_state(driver): """Get scene state via React fiber store discovery.""" return driver.execute_script(""" var root = document.getElementById("root"); var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); if (!ck) return {error: "no react"}; var fiber = root[ck], queue = [fiber], v = 0, ss = null, ms = null; while (queue.length > 0 && v < 3000) { var n = queue.shift(); if (!n) continue; v++; if (n.pendingProps && n.pendingProps.value && typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && typeof n.pendingProps.value.getState === "function") { try { var st = n.pendingProps.value.getState(); if (st && st.connectors !== undefined && st.textBoxes !== undefined && st.history) ss = n.pendingProps.value; if (st && st.views !== undefined && st.items !== undefined && st.history) ms = n.pendingProps.value; } catch(e) {} } if (n.child) queue.push(n.child); if (n.sibling) queue.push(n.sibling); } if (!ss || !ms) return {error: "stores not found"}; var s = ss.getState(), m = ms.getState(); var cv = m.views && m.views[0]; return { rectangles: cv && cv.rectangles ? cv.rectangles.length : 0, textBoxes: Object.keys(s.textBoxes || {}).length, connectors: Object.keys(s.connectors || {}).length, modelItems: (m.items || []).length, scenePast: s.history.past.length, sceneFuture: s.history.future.length, modelPast: m.history.past.length, modelFuture: m.history.future.length, }; """) def count_svg_polygons(driver): """Count SVG polygon/path elements that represent rectangles.""" return driver.execute_script(""" var c = document.querySelector('.fossflow-container'); if (!c) return 0; // Rectangles render as SVG with polygon elements inside IsoTileArea return c.querySelectorAll('svg polygon').length; """) def count_text_elements(driver): """Count Typography/text elements from TextBox components. TextBox renders a

with MuiTypography class containing textbox content. Default content is 'Text' from TEXTBOX_DEFAULTS. """ return driver.execute_script(""" var c = document.querySelector('.fossflow-container'); if (!c) return 0; var all = c.querySelectorAll('p.MuiTypography-root, span.MuiTypography-root'); // Filter to actual textbox content (not UI labels like 'Untitled') var count = 0; for (var i = 0; i < all.length; i++) { var t = all[i].textContent.trim(); if (t === 'Text' || t === 'text' || t.length > 0) { // Check it's inside a positioned container (textbox, not toolbar) var parent = all[i].closest('[style*="position"]'); if (parent) count++; } } return count; """) def click_undo(driver): btn = driver.execute_script("return document.querySelector(\"button[aria-label*='Undo']\");") if btn: btn.click() time.sleep(1) return True return False def click_redo(driver): btn = driver.execute_script("return document.querySelector(\"button[aria-label*='Redo']\");") if btn: btn.click() time.sleep(1) return True return False # --------------------------------------------------------------------------- # Rectangle test # --------------------------------------------------------------------------- def test_rectangle_undo_redo(driver): """Draw a rectangle by drag, undo to remove it, redo to restore.""" base_url = get_base_url() print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) state_before = get_scene_state(driver) polygons_before = count_svg_polygons(driver) print(f" Baseline: polygons={polygons_before}, state={state_before}") save_screenshot(driver, "rect_01_baseline") # --- Click Rectangle tool --- print("\n2. Activating Rectangle tool...") rect_btn = driver.execute_script( "return document.querySelector(\"button[aria-label*='Rectangle']\");" ) assert rect_btn, "Rectangle button not found" rect_btn.click() time.sleep(0.5) # --- Draw rectangle: mousedown, drag, mouseup --- print(" Drawing rectangle by drag...") canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") actions = ActionChains(driver) actions.move_to_element_with_offset(canvas, 400, 250) actions.click_and_hold() actions.move_by_offset(200, 150) actions.release() actions.perform() time.sleep(1) save_screenshot(driver, "rect_02_drawn") state_after = get_scene_state(driver) polygons_after = count_svg_polygons(driver) print(f" After draw: polygons={polygons_after}, state={state_after}") assert isinstance(state_after, dict) and state_after.get("rectangles", 0) > 0, ( f"No rectangle in store after drawing. State: {state_after}" ) rect_polygons = polygons_after print(f" Rectangle created. Store has {state_after['rectangles']} rectangle(s).") # --- Undo rectangle --- print("\n3. Undoing rectangle...") # Rectangle draw creates 1 history entry (createRectangle) + potentially # updateRectangle calls during drag. Undo until rectangles are 0. max_undos = 5 for i in range(max_undos): click_undo(driver) state_undo = get_scene_state(driver) if isinstance(state_undo, dict) and state_undo.get("rectangles", 0) == 0: print(f" Rectangle removed after {i+1} undo(s). State: {state_undo}") break else: state_undo = get_scene_state(driver) pytest.fail(f"Rectangle still present after {max_undos} undos. State: {state_undo}") polygons_undo = count_svg_polygons(driver) save_screenshot(driver, "rect_03_after_undo") print(f" Polygons after undo: {polygons_undo}") # --- Redo rectangle --- print("\n4. Redoing rectangle...") # Redo same number of times as we undid redo_count = i + 1 for j in range(redo_count): click_redo(driver) state_redo = get_scene_state(driver) polygons_redo = count_svg_polygons(driver) print(f" After {redo_count} redo(s): polygons={polygons_redo}, state={state_redo}") save_screenshot(driver, "rect_04_after_redo") assert isinstance(state_redo, dict) and state_redo.get("rectangles", 0) > 0, ( f"Redo did not restore rectangle. State: {state_redo}" ) print(" Rectangle restored by redo.") # --- Undo again to verify cycle --- print("\n5. Undoing rectangle again...") for _ in range(redo_count): click_undo(driver) state_undo2 = get_scene_state(driver) assert isinstance(state_undo2, dict) and state_undo2.get("rectangles", 0) == 0, ( f"Second undo cycle failed. State: {state_undo2}" ) print(" Rectangle removed again.") print("\n Redoing rectangle again...") for _ in range(redo_count): click_redo(driver) state_redo2 = get_scene_state(driver) assert isinstance(state_redo2, dict) and state_redo2.get("rectangles", 0) > 0, ( f"Second redo cycle failed. State: {state_redo2}" ) save_screenshot(driver, "rect_05_final") print("\n SUCCESS: Rectangle undo/redo cycle works!") # --------------------------------------------------------------------------- # TextBox test # --------------------------------------------------------------------------- def test_textbox_undo_redo(driver): """Create a text box, undo to remove it, redo to restore.""" base_url = get_base_url() print(f"\n1. Loading app at {base_url}") driver.get(base_url) WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(2) dismiss_modals(driver) time.sleep(0.5) state_before = get_scene_state(driver) print(f" Baseline: state={state_before}") save_screenshot(driver, "text_01_baseline") # --- Click Text tool (creates textbox at mouse position immediately) --- print("\n2. Clicking Text tool...") text_btn = driver.execute_script( "return document.querySelector(\"button[aria-label*='Text']\");" ) assert text_btn, "Text button not found" text_btn.click() time.sleep(0.5) # The textbox is created and follows the cursor in TEXTBOX mode. # We need to click on the canvas to place it (mouseup handler). print(" Clicking canvas to place text box...") canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(driver).move_to_element_with_offset(canvas, 500, 350).click().perform() time.sleep(1) save_screenshot(driver, "text_02_placed") state_after = get_scene_state(driver) print(f" After placement: state={state_after}") assert isinstance(state_after, dict) and state_after.get("textBoxes", 0) > 0, ( f"No text box in store after placement. State: {state_after}" ) print(f" TextBox created. Store has {state_after['textBoxes']} text box(es).") # --- Undo text box (may take multiple steps) --- print("\n3. Undoing text box...") undo_steps = 0 max_undos = 5 for i in range(max_undos): click_undo(driver) undo_steps += 1 state_undo = get_scene_state(driver) print(f" After undo {i+1}: state={state_undo}") if isinstance(state_undo, dict) and state_undo.get("textBoxes", 0) == 0: break else: pytest.fail(f"TextBox still present after {max_undos} undos. State: {state_undo}") save_screenshot(driver, "text_03_after_undo") print(f" TextBox removed after {undo_steps} undo(s).") # --- Redo text box --- print("\n4. Redoing text box...") for _ in range(undo_steps): click_redo(driver) state_redo = get_scene_state(driver) print(f" After {undo_steps} redo(s): state={state_redo}") save_screenshot(driver, "text_04_after_redo") assert isinstance(state_redo, dict) and state_redo.get("textBoxes", 0) > 0, ( f"Redo did not restore text box. State: {state_redo}" ) print(" TextBox restored by redo.") # --- Second undo/redo cycle --- print("\n5. Second undo/redo cycle...") for _ in range(undo_steps): click_undo(driver) state_undo2 = get_scene_state(driver) assert isinstance(state_undo2, dict) and state_undo2.get("textBoxes", 0) == 0, ( f"Second undo failed. State: {state_undo2}" ) print(" TextBox removed again.") for _ in range(undo_steps): click_redo(driver) state_redo2 = get_scene_state(driver) assert isinstance(state_redo2, dict) and state_redo2.get("textBoxes", 0) > 0, ( f"Second redo failed. State: {state_redo2}" ) save_screenshot(driver, "text_05_final") print("\n SUCCESS: TextBox undo/redo cycle works!") if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) ================================================ FILE: e2e-tests/tests/test_store_debug.py ================================================ """Direct store-level undo/redo debugging.""" import time import json from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def setup_driver(): opts = Options() opts.add_argument("--headless=new") opts.add_argument("--no-sandbox") opts.add_argument("--disable-dev-shm-usage") opts.add_argument("--window-size=1920,1080") opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'}) d = webdriver.Remote("http://localhost:4444", options=opts) d.implicitly_wait(10) return d def dump_store(d, label): """Dump detailed store state.""" result = d.execute_script(""" var ms = window.__modelStore__; var ss = window.__sceneStore__; if (!ms || !ss) return {error: "stores not found", hasModel: !!ms, hasScene: !!ss}; var m = ms.getState(); var s = ss.getState(); var cv = m.views && m.views[0]; return { model: { itemsLen: (m.items || []).length, itemIds: (m.items || []).map(function(i){return i.id}), viewsLen: (m.views || []).length, viewItemsLen: cv ? (cv.items || []).length : -1, viewItemIds: cv ? (cv.items || []).map(function(i){return i.id}) : [], iconsLen: (m.icons || []).length, histPastLen: m.history.past.length, histFutureLen: m.history.future.length, canUndo: m.actions.canUndo(), canRedo: m.actions.canRedo(), }, scene: { connectors: Object.keys(s.connectors || {}).length, textBoxes: Object.keys(s.textBoxes || {}).length, histPastLen: s.history.past.length, histFutureLen: s.history.future.length, canUndo: s.actions.canUndo(), canRedo: s.actions.canRedo(), } }; """) print(f"\n [{label}] Store state: {json.dumps(result, indent=2)}") return result def count_dom_nodes(d): """Count images and 'Untitled' labels in the DOM.""" return d.execute_script(""" var c = document.querySelector('.fossflow-container'); if (!c) return {images: 0, labels: 0}; var imgs = c.querySelectorAll('img').length; var spans = Array.from(c.querySelectorAll('span, p')); var labels = spans.filter(function(s){return s.textContent.trim() === 'Untitled'}).length; return {images: imgs, labels: labels}; """) def place_node(d): """Place a node using the same approach as the working e2e test.""" # Click "Add item (N)" button add_btn = d.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") add_btn.click() time.sleep(1) # Expand ISOFLOW icon collection d.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const text = btn.textContent.trim().toUpperCase(); if (text.includes('ISOFLOW') && !text.includes('IMPORT')) { btn.click(); return; } } """) time.sleep(3) # Select first icon button via ActionChains (not JS click) first_icon_btn = d.execute_script(""" const buttons = document.querySelectorAll('button'); for (const btn of buttons) { const img = btn.querySelector('img'); if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) { return btn; } } for (const btn of buttons) { const img = btn.querySelector('img'); if (img) return btn; } return null; """) if first_icon_btn is None: print(" ERROR: No icon button found") return False ActionChains(d).click(first_icon_btn).perform() time.sleep(0.5) # Click on canvas canvas = d.find_element(By.CLASS_NAME, "fossflow-container") ActionChains(d).move_to_element_with_offset(canvas, 500, 400).click().perform() time.sleep(1) return True def main(): d = setup_driver() try: d.get("http://localhost:3000") WebDriverWait(d, 15).until( EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) ) time.sleep(3) # Dismiss modals/tips d.execute_script(""" const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); dialogs.forEach(d => { const b = d.querySelector('button'); if(b) b.click(); }); """) time.sleep(0.5) # Check stores has = d.execute_script("return {m: !!window.__modelStore__, s: !!window.__sceneStore__}") print(f"Stores on window: {json.dumps(has)}") if not has.get("m") or not has.get("s"): print("ERROR: Stores not exported to window!") return # 1. Baseline dump_store(d, "BASELINE") dom = count_dom_nodes(d) print(f" DOM: {json.dumps(dom)}") # 2. Place node print("\n--- PLACING NODE ---") ok = place_node(d) print(f" place_node returned: {ok}") dom = count_dom_nodes(d) print(f" DOM after place: {json.dumps(dom)}") dump_store(d, "AFTER PLACE") if dom.get("images", 0) == 0: print("\n WARNING: No images in DOM - placement may have failed") # Screenshot for debugging d.save_screenshot("/tmp/debug_after_place.png") print(" Screenshot: /tmp/debug_after_place.png") # 3. Direct model undo print("\n--- MODEL UNDO ---") undo_result = d.execute_script(""" var ms = window.__modelStore__.getState(); var result = ms.actions.undo(); var after = window.__modelStore__.getState(); var cv = after.views && after.views[0]; var f = after.history.future; return { result: result, afterItems: (after.items || []).length, afterViewItems: cv ? (cv.items || []).length : -1, pastLen: after.history.past.length, futureLen: f.length, // Inspect what's in future[0] future0: f[0] ? { items: (f[0].items || []).length, views: (f[0].views || []).length, viewItems: f[0].views && f[0].views[0] ? (f[0].views[0].items || []).length : -1, } : null }; """) print(f" Model undo: {json.dumps(undo_result, indent=2)}") # Also undo scene scene_undo = d.execute_script(""" var ss = window.__sceneStore__.getState(); return { result: ss.actions.undo() }; """) print(f" Scene undo: {json.dumps(scene_undo)}") dump_store(d, "AFTER UNDO") time.sleep(0.5) dom = count_dom_nodes(d) print(f" DOM after undo: {json.dumps(dom)}") # 4. Direct model redo print("\n--- MODEL REDO ---") redo_result = d.execute_script(""" var ms = window.__modelStore__.getState(); var f = ms.history.future; var beforeInfo = { items: (ms.items || []).length, futureLen: f.length, future0: f[0] ? { items: (f[0].items || []).length, views: (f[0].views || []).length, viewItems: f[0].views && f[0].views[0] ? (f[0].views[0].items || []).length : -1, } : null }; var result = ms.actions.redo(); var after = window.__modelStore__.getState(); var cv = after.views && after.views[0]; return { before: beforeInfo, result: result, afterItems: (after.items || []).length, afterViewItems: cv ? (cv.items || []).length : -1, pastLen: after.history.past.length, futureLen: after.history.future.length, }; """) print(f" Model redo: {json.dumps(redo_result, indent=2)}") # Also redo scene scene_redo = d.execute_script(""" var ss = window.__sceneStore__.getState(); return { result: ss.actions.redo() }; """) print(f" Scene redo: {json.dumps(scene_redo)}") dump_store(d, "AFTER REDO") time.sleep(0.5) dom = count_dom_nodes(d) print(f" DOM after redo: {json.dumps(dom)}") print("\n--- ALL TESTS PASSED ---") finally: d.quit() if __name__ == "__main__": main() ================================================ FILE: nginx.conf ================================================ server { listen 80; # Basic authentication (AUTH_BASIC_SETTING replaced by docker-entrypoint.sh) auth_basic AUTH_BASIC_SETTING; auth_basic_user_file /etc/nginx/.htpasswd; server_name localhost; # Allow larger request bodies (up to 10MB) client_max_body_size 10M; # Serve static files location / { root /usr/share/nginx/html; try_files $uri /index.html; } # Proxy API requests to Node.js backend location /api/ { proxy_pass http://localhost:3001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Allow larger bodies for API requests client_max_body_size 10M; proxy_read_timeout 300s; proxy_connect_timeout 75s; } } ================================================ FILE: package.json ================================================ { "name": "fossflow-monorepo", "version": "1.10.8", "private": true, "description": "Monorepo for FossFLOW diagram editor and library", "workspaces": [ "packages/*" ], "scripts": { "dev": "cross-env NODE_ENV=development npm run start --workspace=packages/fossflow-app", "dev:win": "cross-env NODE_ENV=development npm run start --workspace=packages/fossflow-app", "dev:lib": "npm run dev --workspace=packages/fossflow-lib", "dev:backend": "npm run dev --workspace=packages/fossflow-backend", "build": "npm run build:lib && npm run build:app", "build:lib": "npm run build --workspace=packages/fossflow-lib", "build:app": "npm run build --workspace=packages/fossflow-app", "test": "npm run test --workspaces --if-present", "lint": "npm run lint --workspaces --if-present", "clean": "npm run clean --workspaces --if-present && rm -rf node_modules", "publish:lib": "npm run build:lib && npm publish --workspace=packages/fossflow-lib", "docker:build": "docker build -t fossflow:local .", "docker:run": "docker compose -f compose.dev.yml up", "update-version": "node scripts/update-version.js", "semantic-release": "semantic-release" }, "devDependencies": { "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.0.0", "conventional-changelog-conventionalcommits": "^9.3.0", "cross-env": "^10.1.0", "semantic-release": "^25.0.3", "typescript": "^5.9.3" }, "engines": { "node": ">=18.0.0", "npm": ">=9.0.0" }, "dependencies": { "dom-to-image-more": "^3.7.2" }, "overrides": { "lodash": "^4.17.23", "lodash-es": "^4.17.23", "tar": "^7.5.7" } } ================================================ FILE: packages/fossflow-app/LICENSE ================================================ This is free and unencumbered software released into the public domain. Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means. In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to ================================================ FILE: packages/fossflow-app/README.md ================================================ # FossFLOW - Isometric Diagramming Tool FossFLOW is a powerful, open-source Progressive Web App (PWA) for creating beautiful isometric diagrams. Built with React and the Isoflow (Now forked and published to NPM as fossflow) library, it runs entirely in your browser with offline support. ![Screenshot_20250630_160954](https://github.com/user-attachments/assets/e7f254ad-625f-4b8a-8efc-5293b5be9d55) - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/fossflow-lib/blob/main/CONTRIBUTORS.md)** - How to contribute to the project. ## Features - 🎨 **Isometric Diagramming** - Create stunning 3D-style technical diagrams - 💾 **Auto-Save** - Your work is automatically saved every 5 seconds - 📱 **PWA Support** - Install as a native app on Mac and Linux - 🔒 **Privacy-First** - All data stored locally in your browser - 📤 **Import/Export** - Share diagrams as JSON files - 🎯 **Session Storage** - Quick save without dialogs - 🌐 **Offline Support** - Work without internet connection ## Try it online Go to https://stan-smith.github.io/FossFLOW/ ## Quick start on local environment ```bash # Clone the repository git clone https://github.com/stan-smith/FossFLOW cd FossFLOW # Make sure you have npm installed # Install dependencies npm install # Start development server npm start ``` Open [http://localhost:3000](http://localhost:3000) in your browser. ## How to Use ### Creating Diagrams 1. **Add Items**: - Press the "+" button on the top right menu, the library of components will appear on the left. Drag and drop components from the library onto the canvas - Or, perform a right click on the grid and select "Add node", you can then click on the new node you created and customise it from the left menu 2. **Connect Items**: Use connectors to show relationships between components 3. **Customize**: Change colors, labels, and properties of items 4. **Navigate**: Pan and zoom to work on different areas ### Saving Your Work - **Auto-Save**: Diagrams are automatically saved to browser storage every 5 seconds - **Quick Save**: Click "Quick Save (Session)" for instant saves without popups - **Save As**: Use "Save New" to create a copy with a different name ### Managing Diagrams - **Load**: Click "Load" to see all your saved diagrams - **Import**: Load diagrams from JSON files shared by others - **Export**: Download your diagrams as JSON files to share or backup - **Storage**: Use "Storage Manager" to manage browser storage space ### Keyboard Shortcuts - `Delete` - Remove selected items - Mouse wheel - Zoom in/out - Click and drag - Pan around canvas - ***NEW*** Crtl+Z undo Ctrl+Y redo ## Building for Production ```bash # Create optimized production build npm run build # Serve the production build locally npx serve -s build ``` The build folder contains all files needed for deployment. If you need the app to be deployed to a custom path (i.e. not root), use instead: ```bash # Create optimized production build for given path PUBLIC_URL="https://mydomain.tld/path/to/app" npm run build ``` That will add the defined `PUBLIC_URL` as a prefix to all links to static files. ## Deployment ### Static Hosting Deploy the `build` folder to any static hosting service: - GitHub Pages - Netlify - Vercel - AWS S3 - Any web server ### Important Notes 1. **HTTPS Required**: PWA features require HTTPS (except localhost) 2. **Browser Storage**: Diagrams are saved in browser localStorage (~5-10MB limit) 3. **Backup**: Regularly export important diagrams as JSON files ## Browser Support - Chrome/Edge (Recommended) ✅ - Firefox ✅ - Safari ✅ - Mobile browsers with PWA support ✅ ## Troubleshooting ### Storage Full - Use Storage Manager to free space - Export and delete old diagrams - Clear browser data (last resort - will delete all diagrams) ### Can't Install PWA - Ensure using HTTPS - Try Chrome or Edge browsers - Check if already installed ### Lost Diagrams - Check browser's localStorage - Look for auto-saved versions - Always export important work ## Technology Stack - **React** - UI framework - **TypeScript** - Type safety - **Isoflow** - Isometric diagram engine - **PWA** - Offline-first web app ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. ## License Isoflow is released under the MIT license. FossFLOW is released under the Unlicense license, do what you want with it. ## Acknowledgments Built with the [Isoflow](https://github.com/markmanx/isoflow) library. x0z.co ================================================ FILE: packages/fossflow-app/package.json ================================================ { "name": "fossflow-app", "version": "1.10.8", "private": true, "description": "Progressive Web App for creating isometric diagrams", "dependencies": { "@isoflow/isopacks": "^0.0.10", "fossflow": "*", "i18next": "^25.8.18", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.1", "react-i18next": "^16.5.8", "react-router-dom": "^7.9.6", "web-vitals": "^5.1.0" }, "scripts": { "start": "rsbuild dev", "build": "rsbuild build", "preview": "rsbuild preview", "clean": "rm -rf build" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@rsbuild/core": "^1.7.3", "@rsbuild/plugin-react": "^1.4.6", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1" } } ================================================ FILE: packages/fossflow-app/public/i18n/app/bn-BD.json ================================================ { "nav": { "newDiagram": "নতুন ডায়াগ্রাম", "saveSessionOnly": "সংরক্ষণ করুন (শুধুমাত্র সেশন)", "loadSessionOnly": "লোড করুন (শুধুমাত্র সেশন)", "importFile": "ফাইল আমদানি করুন", "exportFile": "ফাইল রপ্তানি করুন", "quickSaveSession": "দ্রুত সংরক্ষণ (সেশন)", "serverStorage": "সার্ভার স্টোরেজ" }, "status": { "current": "বর্তমান", "untitled": "শিরোনামহীন ডায়াগ্রাম", "modified": "পরিবর্তিত", "sessionStorageNote": "শুধুমাত্র সেশন স্টোরেজ - স্থায়ীভাবে সংরক্ষণ করতে রপ্তানি করুন" }, "dialog": { "save": { "title": "ডায়াগ্রাম সংরক্ষণ করুন (শুধুমাত্র বর্তমান সেশন)", "warningTitle": "গুরুত্বপূর্ণ", "warningMessage": "এই সংরক্ষণটি অস্থায়ী এবং ব্রাউজার বন্ধ করলে হারিয়ে যাবে।", "warningExport": "আপনার কাজ স্থায়ীভাবে সংরক্ষণ করতে ফাইল রপ্তানি করুন ব্যবহার করুন।", "placeholder": "ডায়াগ্রামের নাম লিখুন", "btnSave": "সংরক্ষণ করুন", "btnCancel": "বাতিল করুন" }, "load": { "title": "ডায়াগ্রাম লোড করুন (শুধুমাত্র বর্তমান সেশন)", "noteTitle": "নোট", "noteMessage": "এই সংরক্ষণগুলি অস্থায়ী। আপনার ডায়াগ্রামগুলি স্থায়ীভাবে রাখতে রপ্তানি করুন।", "noSavedDiagrams": "এই সেশনে কোনো সংরক্ষিত ডায়াগ্রাম পাওয়া যায়নি", "updated": "আপডেট করা হয়েছে", "btnLoad": "লোড করুন", "btnDelete": "মুছুন", "btnClose": "বন্ধ করুন" }, "export": { "title": "ডায়াগ্রাম রপ্তানি করুন", "recommendedTitle": "প্রস্তাবিত", "recommendedMessage": "এটি আপনার কাজ স্থায়ীভাবে সংরক্ষণ করার সেরা উপায়।", "noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।", "btnDownload": "JSON ডাউনলোড করুন", "btnCancel": "বাতিল করুন" }, "readOnly": { "mode": "শুধুমাত্র দেখার মোড", "failed": "ডায়াগ্রাম লোড করতে ব্যর্থ" } }, "alert": { "enterDiagramName": "অনুগ্রহ করে ডায়াগ্রামের জন্য একটি নাম লিখুন", "diagramExists": "এই সেশনে \"{{name}}\" নামের একটি ডায়াগ্রাম ইতিমধ্যে বিদ্যমান। এটি এটি ওভাররাইট করবে। আপনি কি নিশ্চিত যে আপনি চালিয়ে যেতে চান?", "unsavedChanges": "আপনার অসংরক্ষিত পরিবর্তন আছে। লোড করা চালিয়ে যেতে চান?", "createNewDiagram": "একটি নতুন ডায়াগ্রাম তৈরি করবেন?", "unsavedChangesExport": "আপনার অসংরক্ষিত পরিবর্তন আছে। এটি সংরক্ষণ করতে প্রথমে আপনার ডায়াগ্রাম রপ্তানি করুন। চালিয়ে যেতে চান?", "confirmDelete": "আপনি কি নিশ্চিত যে আপনি এই ডায়াগ্রামটি মুছতে চান?", "storageFull": "স্টোরেজ পূর্ণ! স্টোরেজ ম্যানেজার খোলা হচ্ছে...", "autoSaveFailed": "স্টোরেজ পূর্ণ! অনুগ্রহ করে স্থান খালি করতে স্টোরেজ ম্যানেজার ব্যবহার করুন।", "beforeUnload": "আপনার অসংরক্ষিত পরিবর্তন আছে। আপনি কি নিশ্চিত যে আপনি ছেড়ে যেতে চান?", "quotaExceeded": "স্টোরেজ কোটা অতিক্রম করেছে। অনুগ্রহ করে গুরুত্বপূর্ণ ডায়াগ্রামগুলি রপ্তানি করুন এবং কিছু স্থান খালি করুন।" } } ================================================ FILE: packages/fossflow-app/public/i18n/app/de-DE.json ================================================ { "nav": { "newDiagram": "Neues Diagramm", "saveSessionOnly": "Speichern (nur Browser)", "loadSessionOnly": "Laden (nur Browser)", "importFile": "Datei importieren", "exportFile": "Datei exportieren", "quickSaveSession": "Schnellspeichern (Browser)", "serverStorage": "Server-Speicher" }, "status": { "current": "Aktuell", "untitled": "Unbenanntes Diagramm", "modified": "Geändert", "sessionStorageNote": "Nur Browserspeicher – zum dauerhaften Speichern exportieren" }, "dialog": { "save": { "title": "Diagramm speichern (nur aktuelle Browser-Sitzung)", "warningTitle": "Wichtig", "warningMessage": "Dieses Speichern ist temporär und der aktuelle Fortschritt geht verloren, wenn du den Browser schließt.", "warningExport": "Nutze Datei exportieren, um deine Arbeit dauerhaft zu speichern.", "placeholder": "Diagram benennen", "btnSave": "Speichern", "btnCancel": "Abbrechen" }, "load": { "title": "Diagramm laden (nur aktuelle Sitzung)", "noteTitle": "Hinweis", "noteMessage": "Diese Speicherstände sind temporär. Exportiere deine Diagramme, um sie dauerhaft zu behalten.", "noSavedDiagrams": "In dieser Sitzung wurden keine gespeicherten Diagramme gefunden", "updated": "Aktualisiert", "btnLoad": "Laden", "btnDelete": "Löschen", "btnClose": "Schließen" }, "export": { "title": "Diagramm exportieren", "recommendedTitle": "Empfohlen", "recommendedMessage": "Dies ist die beste Möglichkeit, deine Arbeit dauerhaft zu speichern.", "noteMessage": "Exportierte JSON-Dateien können später importiert oder mit anderen geteilt werden.", "btnDownload": "JSON herunterladen", "btnCancel": "Abbrechen" }, "readOnly": { "mode": "Nur-Ansicht-Modus", "failed": "Diagramm konnte nicht geladen werden" } }, "alert": { "enterDiagramName": "Bitte gib einen Diagrammnamen ein", "diagramExists": "Ein Diagramm mit dem Namen \"{{name}}\" existiert in dieser Sitzung bereits. Dadurch wird es überschrieben. Möchtest du fortfahren?", "unsavedChanges": "Du hast ungespeicherte Änderungen. Trotzdem laden?", "createNewDiagram": "Neues Diagramm erstellen?", "unsavedChangesExport": "Du hast ungespeicherte Änderungen. Exportiere dein Diagramm zuerst, um es zu speichern. Trotzdem fortfahren?", "confirmDelete": "Möchtest du dieses Diagramm wirklich löschen?", "storageFull": "Speicher voll! Speicherverwaltung wird geöffnet...", "autoSaveFailed": "Speicher voll! Bitte nutze die Speicherverwaltung, um Platz freizugeben.", "beforeUnload": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen?", "quotaExceeded": "Speicherlimit überschritten. Bitte exportiere wichtige Diagramme und schaffe etwas Platz." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/en-US.json ================================================ { "nav": { "newDiagram": "New Diagram", "saveSessionOnly": "Save (Session Only)", "loadSessionOnly": "Load (Session Only)", "importFile": "Import File", "exportFile": "Export File", "quickSaveSession": "Quick Save (Session)", "serverStorage": "Server Storage" }, "status": { "current": "Current", "untitled": "Untitled Diagram", "modified": "Modified", "sessionStorageNote": "Session storage only - export to save permanently" }, "dialog": { "save": { "title": "Save Diagram (Current Session Only)", "warningTitle": "Important", "warningMessage": "This save is temporary and will be lost when you close the browser.", "warningExport": "Use Export File to permanently save your work.", "placeholder": "Enter diagram name", "btnSave": "Save", "btnCancel": "Cancel" }, "load": { "title": "Load Diagram (Current Session Only)", "noteTitle": "Note", "noteMessage": "These saves are temporary. Export your diagrams to keep them permanently.", "noSavedDiagrams": "No saved diagrams found in this session", "updated": "Updated", "btnLoad": "Load", "btnDelete": "Delete", "btnClose": "Close" }, "export": { "title": "Export Diagram", "recommendedTitle": "Recommended", "recommendedMessage": "This is the best way to save your work permanently.", "noteMessage": "Exported JSON files can be imported later or shared with others.", "btnDownload": "Download JSON", "btnCancel": "Cancel" }, "readOnly": { "mode": "View-Only Mode", "failed": "Failed to load diagram" } }, "alert": { "enterDiagramName": "Please enter a diagram name", "diagramExists": "A diagram named \"{{name}}\" already exists in this session. This will overwrite it. Are you sure you want to continue?", "unsavedChanges": "You have unsaved changes. Continue loading?", "createNewDiagram": "Create a new diagram?", "unsavedChangesExport": "You have unsaved changes. Export your diagram first to save it. Continue?", "confirmDelete": "Are you sure you want to delete this diagram?", "storageFull": "Storage full! Opening Storage Manager...", "autoSaveFailed": "Storage full! Please use Storage Manager to free up space.", "beforeUnload": "You have unsaved changes. Are you sure you want to leave?", "quotaExceeded": "Storage quota exceeded. Please export important diagrams and clear some space." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/es-ES.json ================================================ { "nav": { "newDiagram": "Nuevo diagrama", "saveSessionOnly": "Guardar (Solo sesión)", "loadSessionOnly": "Cargar (Solo sesión)", "importFile": "Importar archivo", "exportFile": "Exportar archivo", "quickSaveSession": "Guardado rápido (Sesión)", "serverStorage": "Almacenamiento en servidor" }, "status": { "current": "Actual", "untitled": "Diagrama sin título", "modified": "Modificado", "sessionStorageNote": "Solo almacenamiento de sesión - exporta para guardar permanentemente" }, "dialog": { "save": { "title": "Guardar diagrama (Solo sesión actual)", "warningTitle": "Importante", "warningMessage": "Este guardado es temporal y se perderá al cerrar el navegador.", "warningExport": "Usa Exportar archivo para guardar tu trabajo de forma permanente.", "placeholder": "Ingresa el nombre del diagrama", "btnSave": "Guardar", "btnCancel": "Cancelar" }, "load": { "title": "Cargar diagrama (Solo sesión actual)", "noteTitle": "Nota", "noteMessage": "Estos guardados son temporales. Exporta tus diagramas para conservarlos de forma permanente.", "noSavedDiagrams": "No se encontraron diagramas guardados en esta sesión", "updated": "Actualizado", "btnLoad": "Cargar", "btnDelete": "Eliminar", "btnClose": "Cerrar" }, "export": { "title": "Exportar diagrama", "recommendedTitle": "Recomendado", "recommendedMessage": "Esta es la mejor forma de guardar tu trabajo de forma permanente.", "noteMessage": "Los archivos JSON exportados pueden importarse posteriormente o compartirse con otros.", "btnDownload": "Descargar JSON", "btnCancel": "Cancelar" }, "readOnly": { "mode": "Modo de solo lectura", "failed": "Error al cargar el diagrama" } }, "alert": { "enterDiagramName": "Por favor ingresa un nombre para el diagrama", "diagramExists": "Ya existe un diagrama llamado \"{{name}}\" en esta sesión. Esto lo sobrescribirá. ¿Estás seguro de que deseas continuar?", "unsavedChanges": "Tienes cambios sin guardar. ¿Continuar cargando?", "createNewDiagram": "¿Crear un nuevo diagrama?", "unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero si no quieres perder los cambios o continúa para comenzar uno nuevo.", "confirmDelete": "¿Estás seguro de que deseas eliminar este diagrama?", "storageFull": "¡Almacenamiento lleno! Abriendo el gestor de almacenamiento...", "autoSaveFailed": "¡Almacenamiento lleno! Por favor usa el gestor de almacenamiento para liberar espacio.", "beforeUnload": "Tienes cambios sin guardar. ¿Estás seguro de que deseas salir?", "quotaExceeded": "Cuota de almacenamiento excedida. Por favor exporta los diagramas importantes y libera espacio." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/fr-FR.json ================================================ { "nav": { "newDiagram": "Nouveau diagramme", "saveSessionOnly": "Enregistrer (Session uniquement)", "loadSessionOnly": "Charger (Session uniquement)", "importFile": "Importer un fichier", "exportFile": "Exporter un fichier", "quickSaveSession": "Enregistrement rapide (Session)", "serverStorage": "Stockage sur serveur" }, "status": { "current": "Actuel", "untitled": "Diagramme sans titre", "modified": "Modifié", "sessionStorageNote": "Stockage de session uniquement - exportez pour enregistrer définitivement" }, "dialog": { "save": { "title": "Enregistrer le diagramme (Session actuelle uniquement)", "warningTitle": "Important", "warningMessage": "Cet enregistrement est temporaire et sera perdu lors de la fermeture du navigateur.", "warningExport": "Utilisez Exporter un fichier pour enregistrer votre travail de manière permanente.", "placeholder": "Entrez le nom du diagramme", "btnSave": "Enregistrer", "btnCancel": "Annuler" }, "load": { "title": "Charger le diagramme (Session actuelle uniquement)", "noteTitle": "Remarque", "noteMessage": "Ces enregistrements sont temporaires. Exportez vos diagrammes pour les conserver de manière permanente.", "noSavedDiagrams": "Aucun diagramme enregistré trouvé dans cette session", "updated": "Mis à jour", "btnLoad": "Charger", "btnDelete": "Supprimer", "btnClose": "Fermer" }, "export": { "title": "Exporter le diagramme", "recommendedTitle": "Recommandé", "recommendedMessage": "C'est la meilleure façon d'enregistrer votre travail de manière permanente.", "noteMessage": "Les fichiers JSON exportés peuvent être importés ultérieurement ou partagés avec d'autres.", "btnDownload": "Télécharger JSON", "btnCancel": "Annuler" }, "readOnly": { "mode": "Mode lecture seule", "failed": "Échec du chargement du diagramme" } }, "alert": { "enterDiagramName": "Veuillez entrer un nom pour le diagramme", "diagramExists": "Un diagramme nommé \"{{name}}\" existe déjà dans cette session. Cela l'écrasera. Êtes-vous sûr de vouloir continuer ?", "unsavedChanges": "Vous avez des modifications non enregistrées. Continuer le chargement ?", "createNewDiagram": "Créer un nouveau diagramme ?", "unsavedChangesExport": "Vous avez des modifications non enregistrées. Exportez d'abord votre diagramme pour l'enregistrer. Continuer ?", "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce diagramme ?", "storageFull": "Stockage plein ! Ouverture du gestionnaire de stockage...", "autoSaveFailed": "Stockage plein ! Veuillez utiliser le gestionnaire de stockage pour libérer de l'espace.", "beforeUnload": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?", "quotaExceeded": "Quota de stockage dépassé. Veuillez exporter les diagrammes importants et libérer de l'espace." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/hi-IN.json ================================================ { "nav": { "newDiagram": "नया आरेख", "saveSessionOnly": "सहेजें (केवल सत्र)", "loadSessionOnly": "लोड करें (केवल सत्र)", "importFile": "फ़ाइल आयात करें", "exportFile": "फ़ाइल निर्यात करें", "quickSaveSession": "त्वरित सहेजें (सत्र)", "serverStorage": "सर्वर स्टोरेज" }, "status": { "current": "वर्तमान", "untitled": "शीर्षकहीन आरेख", "modified": "संशोधित", "sessionStorageNote": "केवल सत्र स्टोरेज - स्थायी रूप से सहेजने के लिए निर्यात करें" }, "dialog": { "save": { "title": "आरेख सहेजें (केवल वर्तमान सत्र)", "warningTitle": "महत्वपूर्ण", "warningMessage": "यह सहेजना अस्थायी है और ब्राउज़र बंद करने पर खो जाएगा।", "warningExport": "अपने काम को स्थायी रूप से सहेजने के लिए फ़ाइल निर्यात करें का उपयोग करें।", "placeholder": "आरेख का नाम दर्ज करें", "btnSave": "सहेजें", "btnCancel": "रद्द करें" }, "load": { "title": "आरेख लोड करें (केवल वर्तमान सत्र)", "noteTitle": "नोट", "noteMessage": "ये सहेजे गए अस्थायी हैं। अपने आरेखों को स्थायी रूप से रखने के लिए निर्यात करें।", "noSavedDiagrams": "इस सत्र में कोई सहेजा गया आरेख नहीं मिला", "updated": "अपडेट किया गया", "btnLoad": "लोड करें", "btnDelete": "हटाएं", "btnClose": "बंद करें" }, "export": { "title": "आरेख निर्यात करें", "recommendedTitle": "अनुशंसित", "recommendedMessage": "यह आपके काम को स्थायी रूप से सहेजने का सबसे अच्छा तरीका है।", "noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।", "btnDownload": "JSON डाउनलोड करें", "btnCancel": "रद्द करें" }, "readOnly": { "mode": "केवल देखने का मोड", "failed": "आरेख लोड करने में विफल" } }, "alert": { "enterDiagramName": "कृपया आरेख के लिए एक नाम दर्ज करें", "diagramExists": "इस सत्र में \"{{name}}\" नाम का एक आरेख पहले से मौजूद है। यह इसे अधिलेखित कर देगा। क्या आप वाकई जारी रखना चाहते हैं?", "unsavedChanges": "आपके पास असहेजे गए परिवर्तन हैं। लोड करना जारी रखें?", "createNewDiagram": "एक नया आरेख बनाएं?", "unsavedChangesExport": "आपके पास असहेजे गए परिवर्तन हैं। इसे सहेजने के लिए पहले अपने आरेख को निर्यात करें। जारी रखें?", "confirmDelete": "क्या आप वाकई इस आरेख को हटाना चाहते हैं?", "storageFull": "स्टोरेज भरा हुआ है! स्टोरेज प्रबंधक खोला जा रहा है...", "autoSaveFailed": "स्टोरेज भरा हुआ है! कृपया जगह खाली करने के लिए स्टोरेज प्रबंधक का उपयोग करें।", "beforeUnload": "आपके पास असहेजे गए परिवर्तन हैं। क्या आप वाकई छोड़ना चाहते हैं?", "quotaExceeded": "स्टोरेज कोटा पार हो गया। कृपया महत्वपूर्ण आरेखों को निर्यात करें और कुछ जगह खाली करें।" } } ================================================ FILE: packages/fossflow-app/public/i18n/app/id-ID.json ================================================ { "nav": { "newDiagram": "Diagram Baru", "saveSessionOnly": "Simpan (Hanya Sesi)", "loadSessionOnly": "Muat (Hanya Sesi)", "importFile": "Impor File", "exportFile": "Ekspor File", "quickSaveSession": "Simpan Cepat (Sesi)", "serverStorage": "Penyimpanan Server" }, "status": { "current": "Saat Ini", "untitled": "Diagram Tanpa Judul", "modified": "Dimodifikasi", "sessionStorageNote": "Hanya penyimpanan sesi - ekspor untuk menyimpan secara permanen" }, "dialog": { "save": { "title": "Simpan Diagram (Hanya Sesi Saat Ini)", "warningTitle": "Penting", "warningMessage": "Simpanan ini bersifat sementara dan akan hilang saat Anda menutup browser.", "warningExport": "Gunakan Ekspor File untuk menyimpan pekerjaan Anda secara permanen.", "placeholder": "Masukkan nama diagram", "btnSave": "Simpan", "btnCancel": "Batal" }, "load": { "title": "Muat Diagram (Hanya Sesi Saat Ini)", "noteTitle": "Catatan", "noteMessage": "Simpanan ini bersifat sementara. Ekspor diagram Anda untuk menyimpannya secara permanen.", "noSavedDiagrams": "Tidak ada diagram tersimpan yang ditemukan dalam sesi ini", "updated": "Diperbarui", "btnLoad": "Muat", "btnDelete": "Hapus", "btnClose": "Tutup" }, "export": { "title": "Ekspor Diagram", "recommendedTitle": "Direkomendasikan", "recommendedMessage": "Ini adalah cara terbaik untuk menyimpan pekerjaan Anda secara permanen.", "noteMessage": "File JSON yang diekspor dapat diimpor nanti atau dibagikan dengan orang lain.", "btnDownload": "Unduh JSON", "btnCancel": "Batal" }, "readOnly": { "mode": "Mode Hanya Baca", "failed": "Gagal memuat diagram" } }, "alert": { "enterDiagramName": "Silakan masukkan nama diagram", "diagramExists": "Diagram dengan nama \"{{name}}\" sudah ada dalam sesi ini. Ini akan menimpanya. Apakah Anda yakin ingin melanjutkan?", "unsavedChanges": "Anda memiliki perubahan yang belum disimpan. Lanjutkan memuat?", "createNewDiagram": "Buat diagram baru?", "unsavedChangesExport": "Anda memiliki perubahan yang belum disimpan. Ekspor diagram Anda terlebih dahulu untuk menyimpannya. Lanjutkan?", "confirmDelete": "Apakah Anda yakin ingin menghapus diagram ini?", "storageFull": "Penyimpanan penuh! Membuka Manajer Penyimpanan...", "autoSaveFailed": "Penyimpanan penuh! Silakan gunakan Manajer Penyimpanan untuk membebaskan ruang.", "beforeUnload": "Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin keluar?", "quotaExceeded": "Kuota penyimpanan terlampaui. Silakan ekspor diagram penting dan kosongkan beberapa ruang." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/it-IT.json ================================================ { "nav": { "newDiagram": "Nuovo Diagramma", "saveSessionOnly": "Salva (solo sessione)", "loadSessionOnly": "Carica (solo sessione)", "importFile": "Importa file", "exportFile": "Esporta file", "quickSaveSession": "Salvataggio rapido (sessione)", "serverStorage": "Archivio server" }, "status": { "current": "Corrente", "untitled": "Diagramma senza titolo", "modified": "Modificato", "sessionStorageNote": "Solo archiviazione di sessione - esporta per salvare in modo permanente" }, "dialog": { "save": { "title": "Salva diagramma (solo sessione corrente)", "warningTitle": "Importante", "warningMessage": "Questo salvataggio è temporaneo e verrà perso alla chiusura del browser.", "warningExport": "Usa Esporta file per salvare il tuo lavoro in modo permanente.", "placeholder": "Inserisci il nome del diagramma", "btnSave": "Salva", "btnCancel": "Annulla" }, "load": { "title": "Carica diagramma (solo sessione corrente)", "noteTitle": "Nota", "noteMessage": "Questi salvataggi sono temporanei. Esporta i tuoi diagrammi per conservarli in modo permanente.", "noSavedDiagrams": "Nessun diagramma salvato trovato in questa sessione", "updated": "Aggiornato", "btnLoad": "Carica", "btnDelete": "Elimina", "btnClose": "Chiudi" }, "export": { "title": "Esporta diagramma", "recommendedTitle": "Consigliato", "recommendedMessage": "Questo è il modo migliore per salvare il tuo lavoro in modo permanente.", "noteMessage": "I file JSON esportati possono essere importati in seguito o condivisi con altri.", "btnDownload": "Scarica JSON", "btnCancel": "Annulla" }, "readOnly": { "mode": "Modalità sola lettura", "failed": "Impossibile caricare il diagramma" } }, "alert": { "enterDiagramName": "Inserisci un nome per il diagramma", "diagramExists": "Un diagramma chiamato \"{{name}}\" esiste già in questa sessione. Verrà sovrascritto. Sei sicuro di voler continuare?", "unsavedChanges": "Hai modifiche non salvate. Continuare con il caricamento?", "createNewDiagram": "Creare un nuovo diagramma?", "unsavedChangesExport": "Hai modifiche non salvate. Esporta prima il tuo diagramma per salvarlo. Continuare?", "confirmDelete": "Sei sicuro di voler eliminare questo diagramma?", "storageFull": "Archiviazione piena! Apertura Gestore archiviazione...", "autoSaveFailed": "Archiviazione piena! Usa il Gestore archiviazione per liberare spazio.", "beforeUnload": "Hai modifiche non salvate. Sei sicuro di voler uscire?", "quotaExceeded": "Quota di archiviazione superata. Esporta i diagrammi importanti e libera spazio." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/pt-BR.json ================================================ { "nav": { "newDiagram": "Novo diagrama", "saveSessionOnly": "Salvar (Apenas sessão)", "loadSessionOnly": "Carregar (Apenas sessão)", "importFile": "Importar arquivo", "exportFile": "Exportar arquivo", "quickSaveSession": "Salvamento rápido (Sessão)", "serverStorage": "Armazenamento no servidor" }, "status": { "current": "Atual", "untitled": "Diagrama sem título", "modified": "Modificado", "sessionStorageNote": "Apenas armazenamento de sessão - exporte para salvar permanentemente" }, "dialog": { "save": { "title": "Salvar diagrama (Apenas sessão atual)", "warningTitle": "Importante", "warningMessage": "Este salvamento é temporário e será perdido ao fechar o navegador.", "warningExport": "Use Exportar arquivo para salvar seu trabalho permanentemente.", "placeholder": "Digite o nome do diagrama", "btnSave": "Salvar", "btnCancel": "Cancelar" }, "load": { "title": "Carregar diagrama (Apenas sessão atual)", "noteTitle": "Nota", "noteMessage": "Estes salvamentos são temporários. Exporte seus diagramas para mantê-los permanentemente.", "noSavedDiagrams": "Nenhum diagrama salvo encontrado nesta sessão", "updated": "Atualizado", "btnLoad": "Carregar", "btnDelete": "Excluir", "btnClose": "Fechar" }, "export": { "title": "Exportar diagrama", "recommendedTitle": "Recomendado", "recommendedMessage": "Esta é a melhor forma de salvar seu trabalho permanentemente.", "noteMessage": "Arquivos JSON exportados podem ser importados posteriormente ou compartilhados com outros.", "btnDownload": "Baixar JSON", "btnCancel": "Cancelar" }, "readOnly": { "mode": "Modo somente leitura", "failed": "Falha ao carregar diagrama" } }, "alert": { "enterDiagramName": "Por favor, digite um nome para o diagrama", "diagramExists": "Já existe um diagrama chamado \"{{name}}\" nesta sessão. Isso irá sobrescrevê-lo. Tem certeza de que deseja continuar?", "unsavedChanges": "Você tem alterações não salvas. Continuar carregando?", "createNewDiagram": "Criar um novo diagrama?", "unsavedChangesExport": "Você tem alterações não salvas. Exporte seu diagrama primeiro para salvá-lo. Continuar?", "confirmDelete": "Tem certeza de que deseja excluir este diagrama?", "storageFull": "Armazenamento cheio! Abrindo o gerenciador de armazenamento...", "autoSaveFailed": "Armazenamento cheio! Por favor, use o gerenciador de armazenamento para liberar espaço.", "beforeUnload": "Você tem alterações não salvas. Tem certeza de que deseja sair?", "quotaExceeded": "Cota de armazenamento excedida. Por favor, exporte os diagramas importantes e libere espaço." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/ru-RU.json ================================================ { "nav": { "newDiagram": "Новая диаграмма", "saveSessionOnly": "Сохранить (Только сеанс)", "loadSessionOnly": "Загрузить (Только сеанс)", "importFile": "Импортировать файл", "exportFile": "Экспортировать файл", "quickSaveSession": "Быстрое сохранение (Сеанс)", "serverStorage": "Серверное хранилище" }, "status": { "current": "Текущий", "untitled": "Диаграмма без названия", "modified": "Изменено", "sessionStorageNote": "Только хранилище сеанса - экспортируйте для постоянного сохранения" }, "dialog": { "save": { "title": "Сохранить диаграмму (Только текущий сеанс)", "warningTitle": "Важно", "warningMessage": "Это сохранение временное и будет потеряно при закрытии браузера.", "warningExport": "Используйте Экспортировать файл для постоянного сохранения вашей работы.", "placeholder": "Введите название диаграммы", "btnSave": "Сохранить", "btnCancel": "Отмена" }, "load": { "title": "Загрузить диаграмму (Только текущий сеанс)", "noteTitle": "Примечание", "noteMessage": "Эти сохранения временные. Экспортируйте свои диаграммы, чтобы сохранить их постоянно.", "noSavedDiagrams": "В этом сеансе не найдено сохраненных диаграмм", "updated": "Обновлено", "btnLoad": "Загрузить", "btnDelete": "Удалить", "btnClose": "Закрыть" }, "export": { "title": "Экспортировать диаграмму", "recommendedTitle": "Рекомендуется", "recommendedMessage": "Это лучший способ сохранить вашу работу постоянно.", "noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.", "btnDownload": "Скачать JSON", "btnCancel": "Отмена" }, "readOnly": { "mode": "Режим только для чтения", "failed": "Не удалось загрузить диаграмму" } }, "alert": { "enterDiagramName": "Пожалуйста, введите название диаграммы", "diagramExists": "Диаграмма с названием \"{{name}}\" уже существует в этом сеансе. Это перезапишет её. Вы уверены, что хотите продолжить?", "unsavedChanges": "У вас есть несохраненные изменения. Продолжить загрузку?", "createNewDiagram": "Создать новую диаграмму?", "unsavedChangesExport": "У вас есть несохраненные изменения. Сначала экспортируйте диаграмму, чтобы сохранить её. Продолжить?", "confirmDelete": "Вы уверены, что хотите удалить эту диаграмму?", "storageFull": "Хранилище заполнено! Открывается менеджер хранилища...", "autoSaveFailed": "Хранилище заполнено! Пожалуйста, используйте менеджер хранилища, чтобы освободить место.", "beforeUnload": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?", "quotaExceeded": "Квота хранилища превышена. Пожалуйста, экспортируйте важные диаграммы и освободите место." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/tr-TR.json ================================================ { "nav": { "newDiagram": "Yeni Diyagram", "saveSessionOnly": "Kaydet (Yalnızca Oturum)", "loadSessionOnly": "Yükle (Yalnızca Oturum)", "importFile": "Dosya İçe Aktar", "exportFile": "Dosya Dışa Aktar", "quickSaveSession": "Hızlı Kaydet (Oturum)", "serverStorage": "Sunucu Depolama" }, "status": { "current": "Mevcut", "untitled": "İsimsiz Diyagram", "modified": "Değiştirildi", "sessionStorageNote": "Yalnızca oturum depolama - kalıcı olarak kaydetmek için dışa aktar" }, "dialog": { "save": { "title": "Diyagramı Kaydet (Yalnızca Mevcut Oturum)", "warningTitle": "Önemli", "warningMessage": "Bu kayıt geçicidir ve tarayıcıyı kapattığınızda kaybolacaktır.", "warningExport": "Çalışmanızı kalıcı olarak kaydetmek için Dosya Dışa Aktar kullanın.", "placeholder": "Diyagram adı girin", "btnSave": "Kaydet", "btnCancel": "İptal" }, "load": { "title": "Diyagram Yükle (Yalnızca Mevcut Oturum)", "noteTitle": "Not", "noteMessage": "Bu kayıtlar geçicidir. Diyagramlarınızı kalıcı olarak saklamak için dışa aktarın.", "noSavedDiagrams": "Bu oturumda kaydedilmiş diyagram bulunamadı", "updated": "Güncellendi", "btnLoad": "Yükle", "btnDelete": "Sil", "btnClose": "Kapat" }, "export": { "title": "Diyagramı Dışa Aktar", "recommendedTitle": "Önerilen", "recommendedMessage": "Bu, çalışmanızı kalıcı olarak kaydetmenin en iyi yoludur.", "noteMessage": "Dışa aktarılan JSON dosyaları daha sonra içe aktarılabilir veya başkalarıyla paylaşılabilir.", "btnDownload": "JSON İndir", "btnCancel": "İptal" }, "readOnly": { "mode": "Yalnızca Görüntüleme Modu", "failed": "Diyagram yüklenemedi" } }, "alert": { "enterDiagramName": "Lütfen bir diyagram adı girin", "diagramExists": "\"{{name}}\" adlı bir diyagram bu oturumda zaten mevcut. Bu, üzerine yazacaktır. Devam etmek istediğinizden emin misiniz?", "unsavedChanges": "Kaydedilmemiş değişiklikleriniz var. Yüklemeye devam edilsin mi?", "createNewDiagram": "Yeni bir diyagram oluşturulsun mu?", "unsavedChangesExport": "Kaydedilmemiş değişiklikleriniz var. Önce diyagramınızı kaydetmek için dışa aktarın. Devam edilsin mi?", "confirmDelete": "Bu diyagramı silmek istediğinizden emin misiniz?", "storageFull": "Depolama dolu! Depolama Yöneticisi açılıyor...", "autoSaveFailed": "Depolama dolu! Lütfen alan açmak için Depolama Yöneticisi'ni kullanın.", "beforeUnload": "Kaydedilmemiş değişiklikleriniz var. Ayrılmak istediğinizden emin misiniz?", "quotaExceeded": "Depolama kotası aşıldı. Lütfen önemli diyagramları dışa aktarın ve biraz alan açın." } } ================================================ FILE: packages/fossflow-app/public/i18n/app/zh-CN.json ================================================ { "nav": { "newDiagram": "新建图表", "saveSessionOnly": "保存(仅会话)", "loadSessionOnly": "加载(仅会话)", "importFile": "导入文件", "exportFile": "导出文件", "quickSaveSession": "快速保存(会话)", "serverStorage": "服务端存储" }, "status": { "current": "当前", "untitled": "未命名图表", "modified": "已修改", "sessionStorageNote": "仅会话存储 - 导出以永久保存" }, "dialog": { "save": { "title": "保存图表(仅当前会话)", "warningTitle": "重要提示", "warningMessage": "此保存是临时的,关闭浏览器后将丢失。", "warningExport": "使用导出文件功能永久保存您的工作。", "placeholder": "输入图表名称", "btnSave": "保存", "btnCancel": "取消" }, "load": { "title": "加载图表(仅当前会话)", "noteTitle": "提示", "noteMessage": "这些保存是临时的。导出您的图表以永久保存。", "noSavedDiagrams": "当前会话中未找到已保存的图表", "updated": "更新时间", "btnLoad": "加载", "btnDelete": "删除", "btnClose": "关闭" }, "export": { "title": "导出图表", "recommendedTitle": "推荐", "recommendedMessage": "这是永久保存工作的最佳方式。", "noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。", "btnDownload": "下载 JSON", "btnCancel": "取消" }, "readOnly": { "mode": "阅读模式", "failed": "加载图表失败" } }, "alert": { "enterDiagramName": "请输入图表名称", "diagramExists": "名为\"{{name}}\"的图表已存在于此会话中。这将覆盖它。您确定要继续吗?", "unsavedChanges": "您有未保存的更改。继续加载?", "createNewDiagram": "创建新图表?", "unsavedChangesExport": "您有未保存的更改。请先导出图表以保存。继续?", "confirmDelete": "您确定要删除此图表吗?", "storageFull": "存储空间已满!正在打开存储管理器...", "autoSaveFailed": "存储空间已满!请使用存储管理器释放空间。", "beforeUnload": "您有未保存的更改。您确定要离开吗?", "quotaExceeded": "存储配额已超出。请导出重要图表并清理一些空间。" } } ================================================ FILE: packages/fossflow-app/public/index.html ================================================ FossFLOW - Isometric Diagramming Tool

================================================ FILE: packages/fossflow-app/public/manifest.json ================================================ { "short_name": "FossFLOW", "name": "FossFLOW - Isometric Diagramming Tool", "description": "Create beautiful isometric diagrams with FossFLOW", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" }, { "src": "logo192.png", "type": "image/png", "sizes": "192x192", "purpose": "any maskable" }, { "src": "logo512.png", "type": "image/png", "sizes": "512x512", "purpose": "any maskable" } ], "start_url": ".", "display": "standalone", "theme_color": "#2563eb", "background_color": "#ffffff", "orientation": "portrait-primary", "scope": "/", "categories": ["productivity", "utilities", "graphics"] } ================================================ FILE: packages/fossflow-app/public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: packages/fossflow-app/public/service-worker.js ================================================ const CACHE_NAME = 'fossflow-v1'; // Get the base path from the service worker's location const swPath = self.location.pathname; const basePath = swPath.substring(0, swPath.lastIndexOf('/') + 1); const urlsToCache = [ basePath, `${basePath}static/css/main.css`, `${basePath}static/js/bundle.js`, `${basePath}manifest.json`, `${basePath}favicon.ico`, `${basePath}logo192.png`, `${basePath}logo512.png` ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Opened cache'); return cache.addAll(urlsToCache); }) ); }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => { if (response) { return response; } return fetch(event.request).then( response => { if (!response || response.status !== 200 || response.type !== 'basic') { return response; } const responseToCache = response.clone(); caches.open(CACHE_NAME) .then(cache => { cache.put(event.request, responseToCache); }); return response; } ); }) ); }); self.addEventListener('activate', event => { const cacheWhitelist = [CACHE_NAME]; event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); ================================================ FILE: packages/fossflow-app/rsbuild.config.ts ================================================ import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import path from 'path'; const publicUrl = process.env.PUBLIC_URL || ''; const assetPrefix = publicUrl ? (publicUrl.endsWith('/') ? publicUrl : publicUrl + '/') : '/'; // Resolve React from root node_modules to avoid duplicate instances const rootNodeModules = path.resolve(__dirname, '../../node_modules'); export default defineConfig({ plugins: [pluginReact()], resolve: { alias: { // Force React to resolve from root node_modules 'react': path.join(rootNodeModules, 'react'), 'react-dom': path.join(rootNodeModules, 'react-dom'), }, }, html: { template: './public/index.html', templateParameters: { assetPrefix: assetPrefix, }, }, source: { // Define global constants that will be replaced at build time define: { 'process.env.PUBLIC_URL': JSON.stringify(publicUrl), 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), }, }, output: { distPath: { root: 'build', }, // https://rsbuild.rs/guide/advanced/browser-compatibility polyfill: 'usage', assetPrefix: assetPrefix, copy: [ { from: './src/i18n', to: 'i18n/app', }, ] } }); ================================================ FILE: packages/fossflow-app/src/App.css ================================================ .App { height: 100vh; display: flex; flex-direction: column; overflow: hidden; } .toolbar { background-color: #f5f5f5; border-bottom: 1px solid #ddd; padding: 10px; display: flex; gap: 10px; align-items: center; } .toolbar button { padding: 8px 16px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .toolbar button:hover { background-color: #0056b3; } .current-diagram { margin-left: auto; font-size: 14px; color: #666; } .fossflow-container { flex: 1; width: 100%; position: relative; /* Remove background color to let Isoflow's grid show through */ /* background-color: #f9f9f9; */ } /* Ensure Isoflow takes full height */ .fossflow-container > div { height: 100%; } .dialog-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .dialog { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); min-width: 400px; max-width: 600px; max-height: 80vh; overflow-y: auto; } .dialog h2 { margin-top: 0; margin-bottom: 20px; color: #333; } .dialog input[type="text"] { width: 100%; padding: 10px; font-size: 16px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 20px; box-sizing: border-box; } .dialog-buttons { display: flex; gap: 10px; justify-content: flex-end; } .dialog-buttons button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .dialog-buttons button:first-child { background-color: #007bff; color: white; } .dialog-buttons button:first-child:hover { background-color: #0056b3; } .dialog-buttons button:last-child { background-color: #6c757d; color: white; } .dialog-buttons button:last-child:hover { background-color: #5a6268; } .diagram-list { margin-bottom: 20px; max-height: 400px; overflow-y: auto; } .diagram-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border: 1px solid #eee; border-radius: 4px; margin-bottom: 10px; } .diagram-item:hover { background-color: #f8f9fa; } .diagram-actions { display: flex; gap: 5px; } .diagram-actions button { padding: 4px 12px; font-size: 12px; border: none; border-radius: 4px; cursor: pointer; } .diagram-actions button:first-child { background-color: #28a745; color: white; } .diagram-actions button:first-child:hover { background-color: #218838; } .diagram-actions button:last-child { background-color: #dc3545; color: white; } .diagram-actions button:last-child:hover { background-color: #c82333; } ================================================ FILE: packages/fossflow-app/src/App.tsx ================================================ import { useState, useEffect, useRef } from 'react'; import { Isoflow } from 'fossflow'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; import { useTranslation } from 'react-i18next'; import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils'; import { StorageManager } from './StorageManager'; import { DiagramManager } from './components/DiagramManager'; import { storageManager } from './services/storageService'; import ChangeLanguage from './components/ChangeLanguage'; import { allLocales } from 'fossflow'; import { useIconPackManager, IconPackName } from './services/iconPackManager'; import './App.css'; import { BrowserRouter, Route, Routes, useParams } from 'react-router-dom'; // Load core isoflow icons (always loaded) const coreIcons = flattenCollections([isoflowIsopack]); interface SavedDiagram { id: string; name: string; data: any; createdAt: string; updatedAt: string; } function App() { // Get base path from PUBLIC_URL, ensure no trailing slash for React Router const publicUrl = process.env.PUBLIC_URL || ''; // React Router basename should not have trailing slash const basename = publicUrl ? (publicUrl.endsWith('/') ? publicUrl.slice(0, -1) : publicUrl) : '/'; return ( } /> } /> ); } function EditorPage() { // Initialize icon pack manager with core icons const iconPackManager = useIconPackManager(coreIcons); const { readonlyDiagramId } = useParams<{ readonlyDiagramId: string }>(); const [diagrams, setDiagrams] = useState([]); const [isDiagramsInitialized, setIsDiagramsInitialized] = useState(false); const [currentDiagram, setCurrentDiagram] = useState( null ); const [diagramName, setDiagramName] = useState(''); const [showSaveDialog, setShowSaveDialog] = useState(false); const [showLoadDialog, setShowLoadDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); const [fossflowKey, setFossflowKey] = useState(0); // Key to force re-render of FossFLOW const [currentModel, setCurrentModel] = useState(null); // Store current model state const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [lastAutoSave, setLastAutoSave] = useState(null); const [showStorageManager, setShowStorageManager] = useState(false); const [showDiagramManager, setShowDiagramManager] = useState(false); const [serverStorageAvailable, setServerStorageAvailable] = useState(false); const isReadonlyUrl = window.location.pathname.startsWith('/display/') && readonlyDiagramId; // Initialize with empty diagram data // Create default colors for connectors const defaultColors = [ { id: 'blue', value: '#0066cc' }, { id: 'green', value: '#00aa00' }, { id: 'red', value: '#cc0000' }, { id: 'orange', value: '#ff9900' }, { id: 'purple', value: '#9900cc' }, { id: 'black', value: '#000000' }, { id: 'gray', value: '#666666' } ]; const [diagramData, setDiagramData] = useState(() => { // Initialize with last opened data if available const lastOpenedData = localStorage.getItem('fossflow-last-opened-data'); if (lastOpenedData) { try { const data = JSON.parse(lastOpenedData); const importedIcons = (data.icons || []).filter((icon: any) => { return icon.collection === 'imported'; }); const mergedIcons = [...coreIcons, ...importedIcons]; return { ...data, icons: mergedIcons, colors: data.colors?.length ? data.colors : defaultColors, fitToScreen: data.fitToScreen !== false }; } catch (e) { console.error('Failed to load last opened data:', e); } } // Default state if no saved data return { title: 'Untitled Diagram', icons: coreIcons, colors: defaultColors, items: [], views: [], fitToScreen: true }; }); // Check for server storage availability useEffect(() => { storageManager .initialize() .then(() => { setServerStorageAvailable(storageManager.isServerStorage()); }) .catch(console.error); }, []); // Check if readonlyDiagramId exists - if exists, load diagram in view-only mode useEffect(() => { if (!isReadonlyUrl || !serverStorageAvailable) return; const loadReadonlyDiagram = async () => { try { const storage = storageManager.getStorage(); // Get diagram metadata const diagramList = await storage.listDiagrams(); const diagramInfo = diagramList.find((d) => { return d.id === readonlyDiagramId; }); // Load the diagram data from server storage const data = await storage.loadDiagram(readonlyDiagramId); // Convert to SavedDiagram interface format const readonlyDiagram: SavedDiagram = { id: readonlyDiagramId, name: diagramInfo?.name || data.title || 'Readonly Diagram', data: data, createdAt: new Date().toISOString(), updatedAt: diagramInfo?.lastModified.toISOString() || new Date().toISOString() }; await loadDiagram(readonlyDiagram, true); } catch (error) { // Alert if unable to load readonly diagram and redirect to new diagram alert(t('dialog.readOnly.failed')); window.location.href = '/'; } }; loadReadonlyDiagram(); }, [readonlyDiagramId, serverStorageAvailable]); // Update diagramData when loaded icons change useEffect(() => { setDiagramData((prev) => { return { ...prev, icons: [ ...iconPackManager.loadedIcons, ...(prev.icons || []).filter((icon) => { return icon.collection === 'imported'; }) ] }; }); }, [iconPackManager.loadedIcons]); // Load diagrams from localStorage on component mount useEffect(() => { const savedDiagrams = localStorage.getItem('fossflow-diagrams'); if (savedDiagrams) { setDiagrams(JSON.parse(savedDiagrams)); setIsDiagramsInitialized(true); } // Load last opened diagram metadata (data is already loaded in state initialization) const lastOpenedId = localStorage.getItem('fossflow-last-opened'); if (lastOpenedId && savedDiagrams) { try { const allDiagrams = JSON.parse(savedDiagrams); const lastDiagram = allDiagrams.find((d: SavedDiagram) => { return d.id === lastOpenedId; }); if (lastDiagram) { setCurrentDiagram(lastDiagram); setDiagramName(lastDiagram.name); // Also set currentModel to match diagramData setCurrentModel(diagramData); } } catch (e) { console.error('Failed to restore last diagram metadata:', e); } } }, []); // Save diagrams to localStorage whenever they change useEffect(() => { if (!isDiagramsInitialized) return; try { // Store diagrams without the full icon data const diagramsToStore = diagrams.map((d) => { return { ...d, data: { ...d.data, icons: [] // Don't store icons with each diagram } }; }); localStorage.setItem( 'fossflow-diagrams', JSON.stringify(diagramsToStore) ); } catch (e) { console.error('Failed to save diagrams:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { alert(t('alert.quotaExceeded')); } } }, [diagrams]); const saveDiagram = () => { if (!diagramName.trim()) { alert(t('alert.enterDiagramName')); return; } // Check if a diagram with this name already exists (excluding current) const existingDiagram = diagrams.find((d) => { return d.name === diagramName.trim() && d.id !== currentDiagram?.id; }); if (existingDiagram) { const confirmOverwrite = window.confirm( t('alert.diagramExists', { name: diagramName }) ); if (!confirmOverwrite) { return; } } // Construct save data - include only imported icons const importedIcons = ( currentModel?.icons || diagramData.icons || [] ).filter((icon) => { return icon.collection === 'imported'; }); const savedData = { title: diagramName, icons: importedIcons, // Save only imported icons with diagram colors: currentModel?.colors || diagramData.colors || [], items: currentModel?.items || diagramData.items || [], views: currentModel?.views || diagramData.views || [], fitToScreen: true }; const newDiagram: SavedDiagram = { id: currentDiagram?.id || Date.now().toString(), name: diagramName, data: savedData, createdAt: currentDiagram?.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString() }; if (currentDiagram) { // Update existing diagram setDiagrams( diagrams.map((d) => { return d.id === currentDiagram.id ? newDiagram : d; }) ); } else if (existingDiagram) { // Replace existing diagram with same name setDiagrams( diagrams.map((d) => { return d.id === existingDiagram.id ? { ...newDiagram, id: existingDiagram.id, createdAt: existingDiagram.createdAt } : d; }) ); newDiagram.id = existingDiagram.id; newDiagram.createdAt = existingDiagram.createdAt; } else { // Add new diagram setDiagrams([...diagrams, newDiagram]); } setCurrentDiagram(newDiagram); setShowSaveDialog(false); setHasUnsavedChanges(false); setLastAutoSave(new Date()); // Save as last opened try { localStorage.setItem('fossflow-last-opened', newDiagram.id); localStorage.setItem( 'fossflow-last-opened-data', JSON.stringify(newDiagram.data) ); } catch (e) { console.error('Failed to save diagram:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { alert(t('alert.storageFull')); setShowStorageManager(true); } } }; const loadDiagram = async ( diagram: SavedDiagram, skipUnsavedCheck = false ) => { if ( !skipUnsavedCheck && hasUnsavedChanges && !window.confirm(t('alert.unsavedChanges')) ) { return; } // Auto-detect and load required icon packs await iconPackManager.loadPacksForDiagram(diagram.data.items || []); // Merge imported icons with loaded icon set const importedIcons = (diagram.data.icons || []).filter((icon: any) => { return icon.collection === 'imported'; }); const mergedIcons = [...iconPackManager.loadedIcons, ...importedIcons]; const dataWithIcons = { ...diagram.data, icons: mergedIcons }; setCurrentDiagram(diagram); setDiagramName(diagram.name); setDiagramData(dataWithIcons); setCurrentModel(dataWithIcons); setFossflowKey((prev) => { return prev + 1; }); // Force re-render of FossFLOW setShowLoadDialog(false); setHasUnsavedChanges(false); // Save as last opened (without icons) try { localStorage.setItem('fossflow-last-opened', diagram.id); localStorage.setItem( 'fossflow-last-opened-data', JSON.stringify(diagram.data) ); } catch (e) { console.error('Failed to save last opened:', e); } }; const deleteDiagram = (id: string) => { if (window.confirm(t('alert.confirmDelete'))) { setDiagrams( diagrams.filter((d) => { return d.id !== id; }) ); if (currentDiagram?.id === id) { setCurrentDiagram(null); setDiagramName(''); } } }; const newDiagram = () => { const message = hasUnsavedChanges ? t('alert.unsavedChangesExport') : t('alert.createNewDiagram'); if (window.confirm(message)) { const emptyDiagram: DiagramData = { title: 'Untitled Diagram', icons: iconPackManager.loadedIcons, // Use currently loaded icons colors: defaultColors, items: [], views: [], fitToScreen: true }; setCurrentDiagram(null); setDiagramName(''); setDiagramData(emptyDiagram); setCurrentModel(emptyDiagram); // Reset current model too setFossflowKey((prev) => { return prev + 1; }); // Force re-render of FossFLOW setHasUnsavedChanges(false); // Clear last opened localStorage.removeItem('fossflow-last-opened'); localStorage.removeItem('fossflow-last-opened-data'); } }; const handleModelUpdated = (model: any) => { // Store the current model state whenever it updates // The model from Isoflow contains the COMPLETE state including all icons // Simply store the complete model as-is since it has everything const updatedModel = { title: model.title || diagramName || 'Untitled', icons: model.icons || [], // This already includes ALL icons (default + imported) colors: model.colors || defaultColors, items: model.items || [], views: model.views || [], fitToScreen: true }; setCurrentModel(updatedModel); setDiagramData(updatedModel); if (!isReadonlyUrl) { setHasUnsavedChanges(true); } }; const exportDiagram = () => { // Use the most recent model data - prefer currentModel as it gets updated by handleModelUpdated const modelToExport = currentModel || diagramData; // Get ALL icons from the current model (which includes both default and imported) const allModelIcons = modelToExport.icons || []; // For safety, also check diagramData for any imported icons not in currentModel const diagramImportedIcons = (diagramData.icons || []).filter((icon) => { return icon.collection === 'imported'; }); // Create a map to deduplicate icons by ID, preferring the ones from currentModel const iconMap = new Map(); // First add all icons from the model (includes defaults + imported) allModelIcons.forEach((icon) => { iconMap.set(icon.id, icon); }); // Then add any imported icons from diagramData that might be missing diagramImportedIcons.forEach((icon) => { if (!iconMap.has(icon.id)) { iconMap.set(icon.id, icon); } }); // Get all unique icons const allIcons = Array.from(iconMap.values()); const exportData = { title: diagramName || modelToExport.title || 'Exported Diagram', icons: allIcons, // Include ALL icons (default + imported) for portability colors: modelToExport.colors || [], items: modelToExport.items || [], views: modelToExport.views || [], fitToScreen: true }; const jsonString = JSON.stringify(exportData, null, 2); // Create a blob and download link const blob = new Blob([jsonString], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${diagramName || 'diagram'}-${new Date().toISOString().split('T')[0]}.json`; a.click(); URL.revokeObjectURL(url); setShowExportDialog(false); setHasUnsavedChanges(false); // Mark as saved after export }; const handleDiagramManagerLoad = async (id: string, data: any) => { console.log(`App: handleDiagramManagerLoad called for diagram ${id}`); /** * Icon Persistence Strategy: * * NEW BEHAVIOR (after this fix): * - Server storage saves ALL icons (default collections + imported custom icons) * - When loading, if we detect default collection icons, use ALL icons from server * - This preserves imported custom icons without data loss * * BACKWARD COMPATIBILITY (for old saves): * - Old format only saved imported icons (collection='imported') * - If no default icons detected, merge imported icons with current defaults * - This ensures old diagrams still load correctly * * DETECTION: * - Check if loaded icons contain any default collection (isoflow, aws, gcp, etc.) * - If yes: New format, use all icons from server * - If no: Old format, merge imported with defaults */ const loadedIcons = data.icons || []; console.log(`App: Server sent ${loadedIcons.length} icons`); // Auto-detect and load required icon packs await iconPackManager.loadPacksForDiagram(data.items || []); // Strategy: Check if server has ALL icons (both default and imported) // Server storage now saves ALL icons, so we should use them directly // For backward compatibility with old saves, we detect and merge let finalIcons; const hasDefaultIcons = loadedIcons.some((icon: any) => { return ( icon.collection === 'isoflow' || icon.collection === 'aws' || icon.collection === 'gcp' ); }); if (hasDefaultIcons) { // New format: Server saved ALL icons (default + imported) // Use them directly to preserve any custom icon modifications console.log( `App: Using all ${loadedIcons.length} icons from server (includes defaults + imported)` ); finalIcons = loadedIcons; } else { // Old format: Server only saved imported icons // Merge imported icons with currently loaded icon packs const importedIcons = loadedIcons.filter((icon: any) => { return icon.collection === 'imported'; }); finalIcons = [...iconPackManager.loadedIcons, ...importedIcons]; console.log( `App: Old format detected. Merged ${importedIcons.length} imported icons with ${iconPackManager.loadedIcons.length} defaults = ${finalIcons.length} total` ); } const mergedData: DiagramData = { ...data, title: data.title || data.name || 'Loaded Diagram', icons: finalIcons, colors: data.colors?.length ? data.colors : defaultColors, fitToScreen: data.fitToScreen !== false }; const newDiagram = { id, name: data.name || 'Loaded Diagram', data: mergedData, createdAt: data.created || new Date().toISOString(), updatedAt: data.lastModified || new Date().toISOString() }; console.log(`App: Setting all state for diagram ${id}`); // Use a single batch of state updates to minimize re-render issues // Update diagram data and increment key in the same render cycle setDiagramName(newDiagram.name); setCurrentDiagram(newDiagram); setCurrentModel(mergedData); setHasUnsavedChanges(false); // Update diagramData and key together // This ensures Isoflow gets the correct data with the new key setDiagramData(mergedData); setFossflowKey((prev) => { const newKey = prev + 1; console.log(`App: Updated fossflowKey from ${prev} to ${newKey}`); return newKey; }); console.log( `App: Finished loading diagram ${id}, final icon count: ${finalIcons.length}` ); }; // i18n const { t, i18n } = useTranslation('app'); // Get locale with fallback to en-US if not found const currentLocale = allLocales[i18n.language as keyof typeof allLocales] || allLocales['en-US']; // Auto-save functionality useEffect(() => { if (!currentModel || !hasUnsavedChanges || !currentDiagram) return; const autoSaveTimer = setTimeout(() => { // Include imported icons in auto-save const importedIcons = ( currentModel?.icons || diagramData.icons || [] ).filter((icon) => { return icon.collection === 'imported'; }); const savedData = { title: diagramName || currentDiagram.name, icons: importedIcons, // Save imported icons in auto-save colors: currentModel.colors || [], items: currentModel.items || [], views: currentModel.views || [], fitToScreen: true }; const updatedDiagram: SavedDiagram = { ...currentDiagram, data: savedData, updatedAt: new Date().toISOString() }; setDiagrams((prevDiagrams) => { return prevDiagrams.map((d) => { return d.id === currentDiagram.id ? updatedDiagram : d; }); }); // Update last opened data try { localStorage.setItem( 'fossflow-last-opened-data', JSON.stringify(savedData) ); setLastAutoSave(new Date()); setHasUnsavedChanges(false); } catch (e) { console.error('Auto-save failed:', e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { alert(t('alert.autoSaveFailed')); setShowStorageManager(true); } } }, 5000); // Auto-save after 5 seconds of changes return () => { return clearTimeout(autoSaveTimer); }; }, [currentModel, hasUnsavedChanges, currentDiagram, diagramName]); // Warn before closing if there are unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges) { e.preventDefault(); e.returnValue = t('alert.beforeUnload'); return e.returnValue; } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { return window.removeEventListener('beforeunload', handleBeforeUnload); }; }, [hasUnsavedChanges]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+S or Cmd+S for Save if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); // Quick save if current diagram exists and has unsaved changes if (currentDiagram && hasUnsavedChanges) { saveDiagram(); } else { // Otherwise show save dialog setShowSaveDialog(true); } } // Ctrl+O or Cmd+O for Open/Load if ((e.ctrlKey || e.metaKey) && e.key === 'o') { e.preventDefault(); setShowLoadDialog(true); } }; window.addEventListener('keydown', handleKeyDown); return () => { return window.removeEventListener('keydown', handleKeyDown); }; }, [currentDiagram, hasUnsavedChanges]); return (
{!isReadonlyUrl && ( <> {serverStorageAvailable && ( )} )} {isReadonlyUrl && (
{t('dialog.readOnly.mode')}
)} {isReadonlyUrl ? ( {t('status.current')}: {diagramName} ) : ( <> {currentDiagram ? `${t('status.current')}: ${currentDiagram.name}` : diagramName || t('status.untitled')} {hasUnsavedChanges && ( • {t('status.modified')} )} ({t('status.sessionStorageNote')}) )}
{ iconPackManager.togglePack(packName as any, enabled); } }} />
{/* Save Dialog */} {showSaveDialog && (

{t('dialog.save.title')}

⚠️ {t('dialog.save.warningTitle')}:{' '} {t('dialog.save.warningMessage')}
{ return setDiagramName(e.target.value); }} onKeyDown={(e) => { return e.key === 'Enter' && saveDiagram(); }} autoFocus />
)} {/* Load Dialog */} {showLoadDialog && (

{t('dialog.load.title')}

⚠️ {t('dialog.load.noteTitle')}:{' '} {t('dialog.load.noteMessage')}
{diagrams.length === 0 ? (

{t('dialog.load.noSavedDiagrams')}

) : ( diagrams.map((diagram) => { return (
{diagram.name}
{t('dialog.load.updated')}:{' '} {new Date(diagram.updatedAt).toLocaleString()}
); }) )}
)} {/* Export Dialog */} {showExportDialog && (

{t('dialog.export.title')}

✅ {t('dialog.export.recommendedTitle')}:{' '} {t('dialog.export.recommendedMessage')}

{t('dialog.export.noteMessage')}

)} {/* Storage Manager */} {showStorageManager && ( { return setShowStorageManager(false); }} /> )} {/* Diagram Manager */} {showDiagramManager && ( { return setShowDiagramManager(false); }} /> )}
); } export default App; ================================================ FILE: packages/fossflow-app/src/EditorPage.tsx ================================================ import { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Isoflow } from 'fossflow'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; import { useTranslation } from 'react-i18next'; import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils'; import { StorageManager } from './StorageManager'; import { DiagramManager } from './components/DiagramManager'; import { storageManager } from './services/storageService'; import ChangeLanguage from './components/ChangeLanguage'; import { allLocales } from 'fossflow'; import { useIconPackManager, IconPackName } from './services/iconPackManager'; import './App.css'; // Load core isoflow icons (always loaded) const coreIcons = flattenCollections([isoflowIsopack]); interface SavedDiagram { id: string; name: string; data: any; createdAt: string; updatedAt: string; } function EditorPage() { // Get readonly diagram ID from route params const { readonlyDiagramId } = useParams<{ readonlyDiagramId: string }>(); const navigate = useNavigate(); // Check if we're in readonly mode based on the URL const isReadonlyUrl = window.location.pathname.includes('/display/') && readonlyDiagramId; // Log warning if in display mode useEffect(() => { if (isReadonlyUrl) { console.warn('FossFLOW is running in read-only display mode. Editing is disabled.'); console.log(`Viewing diagram: ${readonlyDiagramId}`); } }, [isReadonlyUrl, readonlyDiagramId]); // Initialize icon pack manager with core icons const iconPackManager = useIconPackManager(coreIcons); const [diagrams, setDiagrams] = useState([]); const [currentDiagram, setCurrentDiagram] = useState(null); const [diagramName, setDiagramName] = useState(''); const [showSaveDialog, setShowSaveDialog] = useState(false); const [showLoadDialog, setShowLoadDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false); const [fossflowKey, setFossflowKey] = useState(0); // Key to force re-render of FossFLOW const [currentModel, setCurrentModel] = useState(null); // Store current model state const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [lastAutoSave, setLastAutoSave] = useState(null); const [showStorageManager, setShowStorageManager] = useState(false); const [showDiagramManager, setShowDiagramManager] = useState(false); const [serverStorageAvailable, setServerStorageAvailable] = useState(false); // Initialize with empty diagram data // Create default colors for connectors const defaultColors = [ { id: 'blue', value: '#0066cc' }, { id: 'green', value: '#00aa00' }, { id: 'red', value: '#cc0000' }, { id: 'orange', value: '#ff6600' }, { id: 'purple', value: '#9900cc' }, { id: 'grey', value: '#666666' } ]; const emptyDiagramData: DiagramData = { scene: { iconData: [], nodeData: [], connectorData: [] }, nonSceneData: { properties: { scale: 1, scrollX: 0, scrollY: 0, showGrid: true, showMinimap: false, showSceneInspector: false } }, model: { categories: [], model: [], connectorColors: defaultColors } }; const [diagramData, setDiagramData] = useState(emptyDiagramData); const fileInputRef = useRef(null); const { t } = useTranslation(); // Load diagram for readonly mode useEffect(() => { if (isReadonlyUrl && readonlyDiagramId) { // Initialize storage and load diagram storageManager.initialize().then(async () => { try { const storage = storageManager.getStorage(); const diagramData = await storage.loadDiagram(readonlyDiagramId); if (diagramData) { const mergedData = mergeDiagramData(emptyDiagramData, diagramData); setDiagramData(mergedData); setCurrentDiagram({ id: readonlyDiagramId, name: diagramData.name || 'Diagram', data: mergedData, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }); setDiagramName(diagramData.name || 'Diagram'); setFossflowKey(prevKey => prevKey + 1); } } catch (error) { console.error(`Failed to load diagram with ID: ${readonlyDiagramId}`, error); // Redirect to home if diagram not found navigate('/'); } }).catch(error => { console.error('Error initializing storage:', error); navigate('/'); }); } }, [readonlyDiagramId, isReadonlyUrl]); // Check server storage availability useEffect(() => { storageManager.initialize().then((storage) => { setServerStorageAvailable(storageManager.isServerStorage()); }); }, []); // Load saved diagrams from localStorage on mount useEffect(() => { const saved = localStorage.getItem('fossflow_diagrams'); if (saved) { try { const parsedDiagrams = JSON.parse(saved); setDiagrams(parsedDiagrams); } catch (error) { console.error('Failed to parse saved diagrams:', error); } } }, []); // Auto-save to localStorage every 30 seconds if there are unsaved changes useEffect(() => { const autoSaveInterval = setInterval(() => { if (hasUnsavedChanges && currentModel && !isReadonlyUrl) { const autoSaveData = { ...currentDiagram, data: currentModel, updatedAt: new Date().toISOString() }; localStorage.setItem('fossflow_autosave', JSON.stringify(autoSaveData)); setLastAutoSave(new Date()); console.log('Auto-saved to localStorage'); } }, 30000); // 30 seconds return () => clearInterval(autoSaveInterval); }, [hasUnsavedChanges, currentModel, currentDiagram, isReadonlyUrl]); // Load auto-save on mount useEffect(() => { if (!isReadonlyUrl) { const autoSaveData = localStorage.getItem('fossflow_autosave'); if (autoSaveData) { try { const parsed = JSON.parse(autoSaveData); if (window.confirm('An auto-saved diagram was found. Would you like to restore it?')) { const mergedData = mergeDiagramData(diagramData, parsed.data); setDiagramData(mergedData); setCurrentModel(mergedData); setDiagramName(parsed.name || ''); setCurrentDiagram(parsed); setFossflowKey(prevKey => prevKey + 1); localStorage.removeItem('fossflow_autosave'); // Clear auto-save after restoring } } catch (error) { console.error('Failed to parse auto-save data:', error); } } } }, []); // Warn before leaving if there are unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (hasUnsavedChanges && !isReadonlyUrl) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [hasUnsavedChanges, isReadonlyUrl]); const handleModelUpdated = (model: any) => { const updatedData = { ...diagramData, ...model }; setCurrentModel(updatedData); // Only mark as having unsaved changes if not in readonly mode if (!isReadonlyUrl) { setHasUnsavedChanges(true); } }; const saveDiagram = () => { if (!diagramName.trim()) { alert('Please enter a name for the diagram'); return; } const diagramToSave = currentModel || diagramData; const savableData = extractSavableData(diagramToSave); const newDiagram: SavedDiagram = currentDiagram ? { ...currentDiagram, name: diagramName, data: savableData, updatedAt: new Date().toISOString() } : { id: Date.now().toString(), name: diagramName, data: savableData, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const updatedDiagrams = currentDiagram ? diagrams.map(d => d.id === currentDiagram.id ? newDiagram : d) : [...diagrams, newDiagram]; setDiagrams(updatedDiagrams); localStorage.setItem('fossflow_diagrams', JSON.stringify(updatedDiagrams)); setCurrentDiagram(newDiagram); setShowSaveDialog(false); setHasUnsavedChanges(false); // Clear auto-save after successful save localStorage.removeItem('fossflow_autosave'); }; const loadDiagram = (diagram: SavedDiagram) => { const mergedData = mergeDiagramData(diagramData, diagram.data); setDiagramData(mergedData); setCurrentModel(mergedData); setCurrentDiagram(diagram); setDiagramName(diagram.name); setShowLoadDialog(false); setHasUnsavedChanges(false); setFossflowKey(prevKey => prevKey + 1); // Force re-render FossFLOW }; const deleteDiagram = (id: string) => { if (window.confirm('Are you sure you want to delete this diagram?')) { const updatedDiagrams = diagrams.filter(d => d.id !== id); setDiagrams(updatedDiagrams); localStorage.setItem('fossflow_diagrams', JSON.stringify(updatedDiagrams)); if (currentDiagram?.id === id) { setCurrentDiagram(null); setDiagramName(''); } } }; const exportDiagram = () => { const diagramToExport = currentModel || diagramData; const savableData = extractSavableData(diagramToExport); const exportData = { name: diagramName || 'fossflow-diagram', version: '1.0', exportDate: new Date().toISOString(), data: savableData }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${diagramName || 'fossflow-diagram'}-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); // Safely remove the temporary element try { if (a.parentNode === document.body) { document.body.removeChild(a); } } catch (err) { console.warn('Failed to remove temporary download link:', err); } URL.revokeObjectURL(url); setShowExportDialog(false); }; const importDiagram = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const content = e.target?.result as string; const parsed = JSON.parse(content); // Merge the imported data with default data structure const mergedData = mergeDiagramData(diagramData, parsed.data || parsed); setDiagramData(mergedData); setCurrentModel(mergedData); setDiagramName(parsed.name || ''); setHasUnsavedChanges(true); setFossflowKey(prevKey => prevKey + 1); // Force re-render FossFLOW // Reset the file input if (fileInputRef.current) { fileInputRef.current.value = ''; } } catch (error) { console.error('Failed to import diagram:', error); alert('Failed to import diagram. Please check the file format.'); } }; reader.readAsText(file); }; const createNewDiagram = () => { if (hasUnsavedChanges && !window.confirm('You have unsaved changes. Do you want to continue?')) { return; } setDiagramData(emptyDiagramData); setCurrentModel(emptyDiagramData); setCurrentDiagram(null); setDiagramName(''); setHasUnsavedChanges(false); setFossflowKey(prevKey => prevKey + 1); // Force re-render FossFLOW // Clear auto-save when creating new diagram localStorage.removeItem('fossflow_autosave'); }; const handleDiagramManagerLoad = async (diagram: any) => { const mergedData = mergeDiagramData(diagramData, diagram.data); setDiagramData(mergedData); setCurrentModel(mergedData); setCurrentDiagram({ id: diagram.id, name: diagram.name, data: mergedData, createdAt: diagram.createdAt, updatedAt: diagram.updatedAt }); setDiagramName(diagram.name); setHasUnsavedChanges(false); setFossflowKey(prevKey => prevKey + 1); setShowDiagramManager(false); }; return (
{!isReadonlyUrl ? ( <> {serverStorageAvailable && ( <>
)}
{currentDiagram ? `${t('toolbar.current')}: ${diagramName}` : t('toolbar.untitled')} {hasUnsavedChanges && ' *'} {lastAutoSave && ( {t('toolbar.autoSaved')}: {lastAutoSave.toLocaleTimeString()} )} ) : (
👁️ {t('dialog.readOnly.mode')} - {diagramName || readonlyDiagramId}
)}
{ iconPackManager.togglePack(packName as any, enabled); } }} />
{/* Save Dialog */} {showSaveDialog && (

{t('dialog.save.title')}

⚠️ {t('dialog.save.warningTitle')}: {t('dialog.save.warningMessage')}
setDiagramName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && saveDiagram()} autoFocus />
)} {/* Load Dialog */} {showLoadDialog && (

{t('dialog.load.title')}

⚠️ {t('dialog.load.noteTitle')}: {t('dialog.load.noteMessage')}
{diagrams.length === 0 ? (

{t('dialog.load.noSavedDiagrams')}

) : ( diagrams.map(diagram => (
{diagram.name}
{t('dialog.load.updated')}: {new Date(diagram.updatedAt).toLocaleString()}
)) )}
)} {/* Export Dialog */} {showExportDialog && (

{t('dialog.export.title')}

✅ {t('dialog.export.recommendedTitle')}: {t('dialog.export.recommendedMessage')}

{t('dialog.export.noteMessage')}

)} {/* Storage Manager */} {showStorageManager && ( setShowStorageManager(false)} /> )} {/* Diagram Manager */} {showDiagramManager && ( setShowDiagramManager(false)} /> )}
); } export default EditorPage; ================================================ FILE: packages/fossflow-app/src/StorageManager.tsx ================================================ import React, { useState, useEffect } from 'react'; interface StorageInfo { used: number; diagrams: number; otherData: number; } export const StorageManager: React.FC<{ onClose: () => void }> = ({ onClose }) => { const [storageInfo, setStorageInfo] = useState({ used: 0, diagrams: 0, otherData: 0 }); useEffect(() => { calculateStorage(); }, []); const calculateStorage = () => { let totalSize = 0; let diagramsSize = 0; let otherSize = 0; for (const key in localStorage) { const value = localStorage.getItem(key); if (value) { const size = new Blob([value]).size; totalSize += size; if (key.startsWith('fossflow-')) { diagramsSize += size; } else { otherSize += size; } } } setStorageInfo({ used: totalSize, diagrams: diagramsSize, otherData: otherSize }); }; const formatBytes = (bytes: number) => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const clearOldDiagrams = () => { if (window.confirm('This will remove all saved diagrams. Are you sure?')) { const keysToRemove = []; for (const key in localStorage) { if (key.startsWith('fossflow-')) { keysToRemove.push(key); } } keysToRemove.forEach(key => localStorage.removeItem(key)); calculateStorage(); alert('All diagrams cleared. Please reload the page.'); window.location.reload(); } }; const exportAllDiagrams = () => { const diagrams = localStorage.getItem('fossflow-diagrams'); if (diagrams) { const blob = new Blob([diagrams], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `fossflow-backup-${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); } }; const storagePercentage = (storageInfo.used / (5 * 1024 * 1024)) * 100; // Assume 5MB limit return (

Storage Manager

Storage Usage

80 ? '#f44336' : storagePercentage > 60 ? '#ff9800' : '#4caf50', height: '100%', width: `${Math.min(storagePercentage, 100)}%`, transition: 'width 0.3s' }} />

Used: {formatBytes(storageInfo.used)} / ~5 MB ({storagePercentage.toFixed(1)}%)

  • FossFLOW diagrams: {formatBytes(storageInfo.diagrams)}
  • Other data: {formatBytes(storageInfo.otherData)}

Actions

Tips to save space:
  • Export diagrams you don't need immediately
  • Delete old versions of diagrams
  • Clear browser cache if needed
); }; ================================================ FILE: packages/fossflow-app/src/components/ChangeLanguage/index.tsx ================================================ import { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import './styles.css'; import { supportedLanguages } from '../../i18n'; const ChangeLanguage = () => { const { i18n } = useTranslation(); const [isOpen, setIsOpen] = useState(false); const [currentLang, setCurrentLang] = useState(i18n.language || 'en-US'); const dropdownRef = useRef(null); const changeLanguage = (lang: string) => { i18n.changeLanguage(lang); setCurrentLang(lang); setIsOpen(false); localStorage.setItem('i18nextLng', lang); }; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, []); return (
setIsOpen(true)} > A/文
{isOpen && (
{supportedLanguages.map(item => (
changeLanguage(item.value)} > {item.label}
)) }
)}
); }; export default ChangeLanguage; ================================================ FILE: packages/fossflow-app/src/components/ChangeLanguage/styles.css ================================================ .language-selector { position: relative; display: inline-block; font-size: 14px; cursor: pointer; } .language-display { padding: 8px 12px; border-radius: 4px; background-color: #f5f5f5; display: flex; align-items: center; justify-content: center; min-width: 60px; text-align: center; } .language-dropdown { position: absolute; top: 100%; left: 0; background-color: white; border-radius: 4px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); min-width: 120px; z-index: 1000; margin-top: 4px; overflow: hidden; white-space: nowrap; } .language-option { padding: 8px 12px; transition: background-color 0.2s; text-align: center; } .language-option:hover { background-color: #f0f0f0; } .language-option.active { background-color: #e6f7ff; color: #1890ff; } ================================================ FILE: packages/fossflow-app/src/components/DiagramManager.css ================================================ .diagram-manager-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10000; } .diagram-manager { background: white; border-radius: 8px; width: 90%; max-width: 800px; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); } .diagram-manager-header { display: flex; justify-content: space-between; align-items: center; padding: 20px; border-bottom: 1px solid #e0e0e0; } .diagram-manager-header h2 { margin: 0; font-size: 24px; } .close-button { background: none; border: none; font-size: 28px; cursor: pointer; color: #666; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; } .close-button:hover { color: #000; } .storage-info { padding: 15px 20px; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; gap: 15px; } .storage-badge { padding: 5px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; } .storage-badge.server { background: #e3f2fd; color: #1976d2; } .storage-badge.local { background: #fff3e0; color: #f57c00; } .storage-note { font-size: 14px; color: #666; } .error-message { background: #ffebee; color: #c62828; padding: 10px 20px; border-left: 4px solid #c62828; } .diagram-manager-actions { padding: 20px; border-bottom: 1px solid #e0e0e0; } .action-button { padding: 10px 20px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; background: #f5f5f5; color: #333; transition: background 0.2s; } .action-button:hover { background: #e0e0e0; } .action-button.primary { background: #4caf50; color: white; } .action-button.primary:hover { background: #45a049; } .action-button.danger { background: #f44336; color: white; } .action-button.danger:hover { background: #da190b; } .action-button.share { background: #2196f3; color: white; } .action-button.share:hover { background: #0b7dda; } .loading { padding: 40px; text-align: center; color: #666; } .diagram-list { flex: 1; overflow-y: auto; padding: 20px; } .empty-state { text-align: center; padding: 40px; color: #666; } .empty-state .hint { font-size: 14px; color: #999; margin-top: 10px; } .diagram-item { display: flex; justify-content: space-between; align-items: center; padding: 15px; border: 1px solid #e0e0e0; border-radius: 4px; margin-bottom: 10px; transition: background 0.2s; } .diagram-item:hover { background: #f5f5f5; } .diagram-info h3 { margin: 0 0 5px 0; font-size: 16px; } .diagram-meta { font-size: 13px; color: #666; } .diagram-actions { display: flex; gap: 10px; } .diagram-actions .action-button { padding: 6px 12px; font-size: 13px; } .save-dialog { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); min-width: 300px; } .save-dialog h3 { margin: 0 0 15px 0; } .save-dialog input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 15px; font-size: 14px; } .dialog-buttons { display: flex; gap: 10px; justify-content: flex-end; } .dialog-buttons button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .dialog-buttons button:first-child { background: #4caf50; color: white; } .dialog-buttons button:last-child { background: #f5f5f5; } ================================================ FILE: packages/fossflow-app/src/components/DiagramManager.tsx ================================================ import React, { useState, useEffect } from 'react'; import { storageManager, DiagramInfo } from '../services/storageService'; import './DiagramManager.css'; interface Props { onLoadDiagram: (id: string, data: any) => void; currentDiagramId?: string; currentDiagramData?: any; onClose: () => void; } export const DiagramManager: React.FC = ({ onLoadDiagram, currentDiagramId, currentDiagramData, onClose }) => { const [diagrams, setDiagrams] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isServerStorage, setIsServerStorage] = useState(false); const [saveName, setSaveName] = useState(''); const [showSaveDialog, setShowSaveDialog] = useState(false); useEffect(() => { loadDiagrams(); }, []); const loadDiagrams = async () => { try { setLoading(true); setError(null); console.log('DiagramManager: Initializing storage...'); // Initialize storage if not already done await storageManager.initialize(); const isServer = storageManager.isServerStorage(); setIsServerStorage(isServer); console.log( `DiagramManager: Using ${isServer ? 'server' : 'session'} storage` ); // Load diagram list const storage = storageManager.getStorage(); console.log('DiagramManager: Loading diagram list...'); const list = await storage.listDiagrams(); console.log(`DiagramManager: Loaded ${list.length} diagrams`); setDiagrams(list); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to load diagrams'; console.error('DiagramManager error:', err); setError(errorMsg); } finally { setLoading(false); } }; const handleLoad = async (id: string) => { try { setLoading(true); setError(null); console.log(`DiagramManager: Loading diagram ${id}...`); const storage = storageManager.getStorage(); const data = await storage.loadDiagram(id); console.log(`DiagramManager: Successfully loaded diagram ${id}`); onLoadDiagram(id, data); // Small delay to ensure parent component finishes state updates await new Promise((resolve) => { return setTimeout(resolve, 100); }); onClose(); } catch (err) { console.error(`DiagramManager: Failed to load diagram ${id}:`, err); setError(err instanceof Error ? err.message : 'Failed to load diagram'); } finally { setLoading(false); } }; const handleDelete = async (id: string) => { if (!window.confirm('Are you sure you want to delete this diagram?')) { return; } try { const storage = storageManager.getStorage(); await storage.deleteDiagram(id); await loadDiagrams(); // Refresh list } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete diagram'); } }; const handleCopyShareLink = (id: string) => { const shareUrl = `${window.location.origin}/display/${id}`; navigator.clipboard .writeText(shareUrl) .then(() => { alert(`Share link copied to clipboard:\n${shareUrl}`); }) .catch(() => { const textArea = document.createElement('textarea'); textArea.value = shareUrl; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); // Safely remove the temporary element try { if (textArea.parentNode === document.body) { document.body.removeChild(textArea); } } catch (err) { console.warn('Failed to remove temporary textarea:', err); } alert(`Share link copied to clipboard:\n${shareUrl}`); }); }; const handleSave = async () => { if (!saveName.trim()) { setError('Please enter a diagram name'); return; } try { const storage = storageManager.getStorage(); // Check if a diagram with this name already exists (excluding current diagram) const existingDiagram = diagrams.find((d) => { return d.name === saveName.trim() && d.id !== currentDiagramId; }); if (existingDiagram) { const confirmOverwrite = window.confirm( `A diagram named "${saveName}" already exists. This will overwrite it. Are you sure you want to continue?` ); if (!confirmOverwrite) { return; } // Delete the existing diagram first await storage.deleteDiagram(existingDiagram.id); } /** * Icon Persistence: Save ALL icons (default + imported) * * currentDiagramData comes from parent's currentModel/diagramData which includes: * - All default icon collections (isoflow, aws, gcp, azure, kubernetes) * - All imported custom icons (collection='imported') * * This ensures when loading, we have the complete icon set and don't lose * any custom imported icons. */ const dataToSave = { ...currentDiagramData, name: saveName }; console.log( `DiagramManager: Saving diagram with ${dataToSave.icons?.length || 0} icons` ); const importedCount = (dataToSave.icons || []).filter((icon: any) => { return icon.collection === 'imported'; }).length; console.log(`DiagramManager: Including ${importedCount} imported icons`); if (currentDiagramId) { // Update existing await storage.saveDiagram(currentDiagramId, dataToSave); } else { // Create new await storage.createDiagram(dataToSave); } setShowSaveDialog(false); setSaveName(''); await loadDiagrams(); // Refresh list } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save diagram'); } }; return (

Diagram Manager

{isServerStorage ? '🌐 Server Storage' : '💾 Local Storage'} {isServerStorage && ( Diagrams are saved on the server and available across all devices )}
{error &&
{error}
}
{loading ? (
Loading diagrams...
) : (
{diagrams.length === 0 ? (

No saved diagrams

Save your current diagram to get started

) : ( diagrams.map((diagram) => { return (

{diagram.name}

Last modified: {diagram.lastModified.toLocaleString()} {diagram.size && ` • ${(diagram.size / 1024).toFixed(1)} KB`}
); }) )}
)} {/* Save Dialog */} {showSaveDialog && (

Save Diagram

{ return setSaveName(e.target.value); }} onKeyDown={(e) => { return e.key === 'Enter' && handleSave(); }} autoFocus />
)}
); }; ================================================ FILE: packages/fossflow-app/src/components/ErrorBoundary.css ================================================ .error-page-container { width: 100%; height: 100vh; overflow: hidden; display: flex; justify-content: center; align-items: center; background-color: rgba(0, 0, 0, 0.5); } .error-container { min-height: 300px; width: 400px; background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); border: 1px solid #ddd; } .error-header { margin-bottom: 20px; text-align: center; } .error-header p { margin: 0; font-size: 24px; font-weight: 600; color: #333; } .error-content { margin-bottom: 30px; padding: 15px; background-color: #fff3cd; border: 1px solid #ffeeba; border-radius: 4px; } .error-content p { margin: 0; font-size: 14px; color: #856404; word-break: break-word; line-height: 1.4; } .error-footer { display: flex; gap: 10px; justify-content: center; } .error-button { display: inline-block; text-decoration: none; font-size: 14px; cursor: pointer; padding: 8px 16px; border: none; border-radius: 4px; background-color: #007bff; color: white; transition: background-color 0.2s ease; } .error-button:hover { background-color: #0056b3; } .error-button.refresh-button { background-color: #28a745; } .error-button.refresh-button:hover { background-color: #218838; } ================================================ FILE: packages/fossflow-app/src/components/ErrorBoundary.tsx ================================================ import './ErrorBoundary.css'; interface ErrorBoundaryFallbackUIProps { error: Error; } export default function ErrorBoundaryFallbackUI({ error }: ErrorBoundaryFallbackUIProps) { const onRefreshButtonPressed = () => { window.location.reload(); }; const onReportButtonPressed = () => { const errorDetails = { message: error.message, stack: error.stack, userAgent: navigator.userAgent, url: window.location.href, timestamp: new Date().toISOString() }; const githubUrl = new URL( 'https://github.com/stan-smith/FossFLOW/issues/new' ); githubUrl.searchParams.set('title', `Error: ${error.message}`); githubUrl.searchParams.set( 'body', `## Error Details\n\n\`\`\`\n${JSON.stringify(errorDetails, null, 2)}\n\`\`\`\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n## Actual Behavior\n\n## Environment\n- Browser: ${navigator.userAgent}\n- URL: ${window.location.href}\n- Timestamp: ${new Date().toISOString()}` ); window.open(githubUrl.toString(), '_blank'); }; return (

⚠️ Something went wrong!

Error: {error.message}

{error.stack && (
Show technical details
                {error.stack}
              
)}

📋 Before reporting this error:

  • Check if this error has already been reported{' '} here👀
  • Try refreshing the page first
  • Only report if this is a new, unreported issue

Note: If you can't find a similar issue, please report it with the details below.

); } ================================================ FILE: packages/fossflow-app/src/diagramUtils.ts ================================================ // Utility functions for handling diagram data export interface DiagramData { title: string; version?: string; description?: string; icons: any[]; colors: any[]; items: any[]; views: any[]; fitToScreen?: boolean; } // Deep merge two objects, with special handling for arrays export function mergeDiagramData(base: DiagramData, update: Partial): DiagramData { return { title: update.title !== undefined ? update.title : base.title, version: update.version !== undefined ? update.version : base.version, description: update.description !== undefined ? update.description : base.description, // For arrays, completely replace if provided, otherwise keep base icons: update.icons !== undefined ? update.icons : base.icons, colors: update.colors !== undefined ? update.colors : base.colors, items: update.items !== undefined ? update.items : base.items, views: update.views !== undefined ? update.views : base.views, fitToScreen: update.fitToScreen !== undefined ? update.fitToScreen : base.fitToScreen }; } // Extract only the data that should be saved/exported export function extractSavableData(fullData: DiagramData): DiagramData { return { title: fullData.title, version: fullData.version, description: fullData.description, // Only include non-empty arrays icons: fullData.icons || [], colors: fullData.colors || [], items: fullData.items || [], views: fullData.views || [], fitToScreen: fullData.fitToScreen !== false }; } // Validate diagram data structure export function validateDiagramData(data: any): data is DiagramData { return ( typeof data === 'object' && data !== null && Array.isArray(data.icons) && Array.isArray(data.colors) && Array.isArray(data.items) && Array.isArray(data.views) ); } ================================================ FILE: packages/fossflow-app/src/env.d.ts ================================================ /// ================================================ FILE: packages/fossflow-app/src/i18n/bn-BD.json ================================================ { "nav": { "newDiagram": "নতুন ডায়াগ্রাম", "saveSessionOnly": "সংরক্ষণ করুন (শুধুমাত্র সেশন)", "loadSessionOnly": "লোড করুন (শুধুমাত্র সেশন)", "importFile": "ফাইল আমদানি করুন", "exportFile": "ফাইল রপ্তানি করুন", "quickSaveSession": "দ্রুত সংরক্ষণ (সেশন)", "serverStorage": "সার্ভার স্টোরেজ" }, "status": { "current": "বর্তমান", "untitled": "শিরোনামহীন ডায়াগ্রাম", "modified": "পরিবর্তিত", "sessionStorageNote": "শুধুমাত্র সেশন স্টোরেজ - স্থায়ীভাবে সংরক্ষণ করতে রপ্তানি করুন" }, "dialog": { "save": { "title": "ডায়াগ্রাম সংরক্ষণ করুন (শুধুমাত্র বর্তমান সেশন)", "warningTitle": "গুরুত্বপূর্ণ", "warningMessage": "এই সংরক্ষণটি অস্থায়ী এবং ব্রাউজার বন্ধ করলে হারিয়ে যাবে।", "warningExport": "আপনার কাজ স্থায়ীভাবে সংরক্ষণ করতে ফাইল রপ্তানি করুন ব্যবহার করুন।", "placeholder": "ডায়াগ্রামের নাম লিখুন", "btnSave": "সংরক্ষণ করুন", "btnCancel": "বাতিল করুন" }, "load": { "title": "ডায়াগ্রাম লোড করুন (শুধুমাত্র বর্তমান সেশন)", "noteTitle": "নোট", "noteMessage": "এই সংরক্ষণগুলি অস্থায়ী। আপনার ডায়াগ্রামগুলি স্থায়ীভাবে রাখতে রপ্তানি করুন।", "noSavedDiagrams": "এই সেশনে কোনো সংরক্ষিত ডায়াগ্রাম পাওয়া যায়নি", "updated": "আপডেট করা হয়েছে", "btnLoad": "লোড করুন", "btnDelete": "মুছুন", "btnClose": "বন্ধ করুন" }, "export": { "title": "ডায়াগ্রাম রপ্তানি করুন", "recommendedTitle": "প্রস্তাবিত", "recommendedMessage": "এটি আপনার কাজ স্থায়ীভাবে সংরক্ষণ করার সেরা উপায়।", "noteMessage": "রপ্তানি করা JSON ফাইলগুলি পরে আমদানি করা যেতে পারে বা অন্যদের সাথে শেয়ার করা যেতে পারে।", "btnDownload": "JSON ডাউনলোড করুন", "btnCancel": "বাতিল করুন" }, "readOnly": { "mode": "শুধুমাত্র দেখার মোড", "failed": "ডায়াগ্রাম লোড করতে ব্যর্থ" } }, "alert": { "enterDiagramName": "অনুগ্রহ করে ডায়াগ্রামের জন্য একটি নাম লিখুন", "diagramExists": "এই সেশনে \"{{name}}\" নামের একটি ডায়াগ্রাম ইতিমধ্যে বিদ্যমান। এটি এটি ওভাররাইট করবে। আপনি কি নিশ্চিত যে আপনি চালিয়ে যেতে চান?", "unsavedChanges": "আপনার অসংরক্ষিত পরিবর্তন আছে। লোড করা চালিয়ে যেতে চান?", "createNewDiagram": "একটি নতুন ডায়াগ্রাম তৈরি করবেন?", "unsavedChangesExport": "আপনার অসংরক্ষিত পরিবর্তন আছে। এটি সংরক্ষণ করতে প্রথমে আপনার ডায়াগ্রাম রপ্তানি করুন। চালিয়ে যেতে চান?", "confirmDelete": "আপনি কি নিশ্চিত যে আপনি এই ডায়াগ্রামটি মুছতে চান?", "storageFull": "স্টোরেজ পূর্ণ! স্টোরেজ ম্যানেজার খোলা হচ্ছে...", "autoSaveFailed": "স্টোরেজ পূর্ণ! অনুগ্রহ করে স্থান খালি করতে স্টোরেজ ম্যানেজার ব্যবহার করুন।", "beforeUnload": "আপনার অসংরক্ষিত পরিবর্তন আছে। আপনি কি নিশ্চিত যে আপনি ছেড়ে যেতে চান?", "quotaExceeded": "স্টোরেজ কোটা অতিক্রম করেছে। অনুগ্রহ করে গুরুত্বপূর্ণ ডায়াগ্রামগুলি রপ্তানি করুন এবং কিছু স্থান খালি করুন।" } } ================================================ FILE: packages/fossflow-app/src/i18n/en-US.json ================================================ { "nav": { "newDiagram": "New Diagram", "saveSessionOnly": "Save (Session Only)", "loadSessionOnly": "Load (Session Only)", "importFile": "Import File", "exportFile": "Export File", "quickSaveSession": "Quick Save (Session)", "serverStorage": "Server Storage" }, "status": { "current": "Current", "untitled": "Untitled Diagram", "modified": "Modified", "sessionStorageNote": "Session storage only - export to save permanently" }, "dialog": { "save": { "title": "Save Diagram (Current Session Only)", "warningTitle": "Important", "warningMessage": "This save is temporary and will be lost when you close the browser.", "warningExport": "Use Export File to permanently save your work.", "placeholder": "Enter diagram name", "btnSave": "Save", "btnCancel": "Cancel" }, "load": { "title": "Load Diagram (Current Session Only)", "noteTitle": "Note", "noteMessage": "These saves are temporary. Export your diagrams to keep them permanently.", "noSavedDiagrams": "No saved diagrams found in this session", "updated": "Updated", "btnLoad": "Load", "btnDelete": "Delete", "btnClose": "Close" }, "export": { "title": "Export Diagram", "recommendedTitle": "Recommended", "recommendedMessage": "This is the best way to save your work permanently.", "noteMessage": "Exported JSON files can be imported later or shared with others.", "btnDownload": "Download JSON", "btnCancel": "Cancel" }, "readOnly": { "mode": "View-Only Mode", "failed": "Failed to load diagram" } }, "alert": { "enterDiagramName": "Please enter a diagram name", "diagramExists": "A diagram named \"{{name}}\" already exists in this session. This will overwrite it. Are you sure you want to continue?", "unsavedChanges": "You have unsaved changes. Continue loading?", "createNewDiagram": "Create a new diagram?", "unsavedChangesExport": "You have unsaved changes. Export your diagram first to save it. Continue?", "confirmDelete": "Are you sure you want to delete this diagram?", "storageFull": "Storage full! Opening Storage Manager...", "autoSaveFailed": "Storage full! Please use Storage Manager to free up space.", "beforeUnload": "You have unsaved changes. Are you sure you want to leave?", "quotaExceeded": "Storage quota exceeded. Please export important diagrams and clear some space." } } ================================================ FILE: packages/fossflow-app/src/i18n/es-ES.json ================================================ { "nav": { "newDiagram": "Nuevo diagrama", "saveSessionOnly": "Guardar (Solo sesión)", "loadSessionOnly": "Cargar (Solo sesión)", "importFile": "Importar archivo", "exportFile": "Exportar archivo", "quickSaveSession": "Guardado rápido (Sesión)", "serverStorage": "Almacenamiento en servidor" }, "status": { "current": "Actual", "untitled": "Diagrama sin título", "modified": "Modificado", "sessionStorageNote": "Solo almacenamiento de sesión - exporta para guardar permanentemente" }, "dialog": { "save": { "title": "Guardar diagrama (Solo sesión actual)", "warningTitle": "Importante", "warningMessage": "Este guardado es temporal y se perderá al cerrar el navegador.", "warningExport": "Usa Exportar archivo para guardar tu trabajo de forma permanente.", "placeholder": "Ingresa el nombre del diagrama", "btnSave": "Guardar", "btnCancel": "Cancelar" }, "load": { "title": "Cargar diagrama (Solo sesión actual)", "noteTitle": "Nota", "noteMessage": "Estos guardados son temporales. Exporta tus diagramas para conservarlos de forma permanente.", "noSavedDiagrams": "No se encontraron diagramas guardados en esta sesión", "updated": "Actualizado", "btnLoad": "Cargar", "btnDelete": "Eliminar", "btnClose": "Cerrar" }, "export": { "title": "Exportar diagrama", "recommendedTitle": "Recomendado", "recommendedMessage": "Esta es la mejor forma de guardar tu trabajo de forma permanente.", "noteMessage": "Los archivos JSON exportados pueden importarse posteriormente o compartirse con otros.", "btnDownload": "Descargar JSON", "btnCancel": "Cancelar" }, "readOnly": { "mode": "Modo de solo lectura", "failed": "Error al cargar el diagrama" } }, "alert": { "enterDiagramName": "Por favor ingresa un nombre para el diagrama", "diagramExists": "Ya existe un diagrama llamado \"{{name}}\" en esta sesión. Esto lo sobrescribirá. ¿Estás seguro de que deseas continuar?", "unsavedChanges": "Tienes cambios sin guardar. ¿Continuar cargando?", "createNewDiagram": "¿Crear un nuevo diagrama?", "unsavedChangesExport": "Tienes cambios sin guardar. Exporta tu diagrama primero para guardarlo. ¿Continuar?", "confirmDelete": "¿Estás seguro de que deseas eliminar este diagrama?", "storageFull": "¡Almacenamiento lleno! Abriendo el gestor de almacenamiento...", "autoSaveFailed": "¡Almacenamiento lleno! Por favor usa el gestor de almacenamiento para liberar espacio.", "beforeUnload": "Tienes cambios sin guardar. ¿Estás seguro de que deseas salir?", "quotaExceeded": "Cuota de almacenamiento excedida. Por favor exporta los diagramas importantes y libera espacio." } } ================================================ FILE: packages/fossflow-app/src/i18n/fr-FR.json ================================================ { "nav": { "newDiagram": "Nouveau diagramme", "saveSessionOnly": "Enregistrer (Session uniquement)", "loadSessionOnly": "Charger (Session uniquement)", "importFile": "Importer un fichier", "exportFile": "Exporter un fichier", "quickSaveSession": "Enregistrement rapide (Session)", "serverStorage": "Stockage sur serveur" }, "status": { "current": "Actuel", "untitled": "Diagramme sans titre", "modified": "Modifié", "sessionStorageNote": "Stockage de session uniquement - exportez pour enregistrer définitivement" }, "dialog": { "save": { "title": "Enregistrer le diagramme (Session actuelle uniquement)", "warningTitle": "Important", "warningMessage": "Cet enregistrement est temporaire et sera perdu lors de la fermeture du navigateur.", "warningExport": "Utilisez Exporter un fichier pour enregistrer votre travail de manière permanente.", "placeholder": "Entrez le nom du diagramme", "btnSave": "Enregistrer", "btnCancel": "Annuler" }, "load": { "title": "Charger le diagramme (Session actuelle uniquement)", "noteTitle": "Remarque", "noteMessage": "Ces enregistrements sont temporaires. Exportez vos diagrammes pour les conserver de manière permanente.", "noSavedDiagrams": "Aucun diagramme enregistré trouvé dans cette session", "updated": "Mis à jour", "btnLoad": "Charger", "btnDelete": "Supprimer", "btnClose": "Fermer" }, "export": { "title": "Exporter le diagramme", "recommendedTitle": "Recommandé", "recommendedMessage": "C'est la meilleure façon d'enregistrer votre travail de manière permanente.", "noteMessage": "Les fichiers JSON exportés peuvent être importés ultérieurement ou partagés avec d'autres.", "btnDownload": "Télécharger JSON", "btnCancel": "Annuler" }, "readOnly": { "mode": "Mode lecture seule", "failed": "Échec du chargement du diagramme" } }, "alert": { "enterDiagramName": "Veuillez entrer un nom pour le diagramme", "diagramExists": "Un diagramme nommé \"{{name}}\" existe déjà dans cette session. Cela l'écrasera. Êtes-vous sûr de vouloir continuer ?", "unsavedChanges": "Vous avez des modifications non enregistrées. Continuer le chargement ?", "createNewDiagram": "Créer un nouveau diagramme ?", "unsavedChangesExport": "Vous avez des modifications non enregistrées. Exportez d'abord votre diagramme pour l'enregistrer. Continuer ?", "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce diagramme ?", "storageFull": "Stockage plein ! Ouverture du gestionnaire de stockage...", "autoSaveFailed": "Stockage plein ! Veuillez utiliser le gestionnaire de stockage pour libérer de l'espace.", "beforeUnload": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?", "quotaExceeded": "Quota de stockage dépassé. Veuillez exporter les diagrammes importants et libérer de l'espace." } } ================================================ FILE: packages/fossflow-app/src/i18n/hi-IN.json ================================================ { "nav": { "newDiagram": "नया आरेख", "saveSessionOnly": "सहेजें (केवल सत्र)", "loadSessionOnly": "लोड करें (केवल सत्र)", "importFile": "फ़ाइल आयात करें", "exportFile": "फ़ाइल निर्यात करें", "quickSaveSession": "त्वरित सहेजें (सत्र)", "serverStorage": "सर्वर स्टोरेज" }, "status": { "current": "वर्तमान", "untitled": "शीर्षकहीन आरेख", "modified": "संशोधित", "sessionStorageNote": "केवल सत्र स्टोरेज - स्थायी रूप से सहेजने के लिए निर्यात करें" }, "dialog": { "save": { "title": "आरेख सहेजें (केवल वर्तमान सत्र)", "warningTitle": "महत्वपूर्ण", "warningMessage": "यह सहेजना अस्थायी है और ब्राउज़र बंद करने पर खो जाएगा।", "warningExport": "अपने काम को स्थायी रूप से सहेजने के लिए फ़ाइल निर्यात करें का उपयोग करें।", "placeholder": "आरेख का नाम दर्ज करें", "btnSave": "सहेजें", "btnCancel": "रद्द करें" }, "load": { "title": "आरेख लोड करें (केवल वर्तमान सत्र)", "noteTitle": "नोट", "noteMessage": "ये सहेजे गए अस्थायी हैं। अपने आरेखों को स्थायी रूप से रखने के लिए निर्यात करें।", "noSavedDiagrams": "इस सत्र में कोई सहेजा गया आरेख नहीं मिला", "updated": "अपडेट किया गया", "btnLoad": "लोड करें", "btnDelete": "हटाएं", "btnClose": "बंद करें" }, "export": { "title": "आरेख निर्यात करें", "recommendedTitle": "अनुशंसित", "recommendedMessage": "यह आपके काम को स्थायी रूप से सहेजने का सबसे अच्छा तरीका है।", "noteMessage": "निर्यात की गई JSON फ़ाइलों को बाद में आयात किया जा सकता है या दूसरों के साथ साझा किया जा सकता है।", "btnDownload": "JSON डाउनलोड करें", "btnCancel": "रद्द करें" }, "readOnly": { "mode": "केवल देखने का मोड", "failed": "आरेख लोड करने में विफल" } }, "alert": { "enterDiagramName": "कृपया आरेख के लिए एक नाम दर्ज करें", "diagramExists": "इस सत्र में \"{{name}}\" नाम का एक आरेख पहले से मौजूद है। यह इसे अधिलेखित कर देगा। क्या आप वाकई जारी रखना चाहते हैं?", "unsavedChanges": "आपके पास असहेजे गए परिवर्तन हैं। लोड करना जारी रखें?", "createNewDiagram": "एक नया आरेख बनाएं?", "unsavedChangesExport": "आपके पास असहेजे गए परिवर्तन हैं। इसे सहेजने के लिए पहले अपने आरेख को निर्यात करें। जारी रखें?", "confirmDelete": "क्या आप वाकई इस आरेख को हटाना चाहते हैं?", "storageFull": "स्टोरेज भरा हुआ है! स्टोरेज प्रबंधक खोला जा रहा है...", "autoSaveFailed": "स्टोरेज भरा हुआ है! कृपया जगह खाली करने के लिए स्टोरेज प्रबंधक का उपयोग करें।", "beforeUnload": "आपके पास असहेजे गए परिवर्तन हैं। क्या आप वाकई छोड़ना चाहते हैं?", "quotaExceeded": "स्टोरेज कोटा पार हो गया। कृपया महत्वपूर्ण आरेखों को निर्यात करें और कुछ जगह खाली करें।" } } ================================================ FILE: packages/fossflow-app/src/i18n/it-IT.json ================================================ { "nav": { "newDiagram": "Nuovo Diagramma", "saveSessionOnly": "Salva (solo sessione)", "loadSessionOnly": "Carica (solo sessione)", "importFile": "Importa file", "exportFile": "Esporta file", "quickSaveSession": "Salvataggio rapido (sessione)", "serverStorage": "Archivio server" }, "status": { "current": "Corrente", "untitled": "Diagramma senza titolo", "modified": "Modificato", "sessionStorageNote": "Solo archiviazione di sessione - esporta per salvare in modo permanente" }, "dialog": { "save": { "title": "Salva diagramma (solo sessione corrente)", "warningTitle": "Importante", "warningMessage": "Questo salvataggio è temporaneo e verrà perso alla chiusura del browser.", "warningExport": "Usa Esporta file per salvare il tuo lavoro in modo permanente.", "placeholder": "Inserisci il nome del diagramma", "btnSave": "Salva", "btnCancel": "Annulla" }, "load": { "title": "Carica diagramma (solo sessione corrente)", "noteTitle": "Nota", "noteMessage": "Questi salvataggi sono temporanei. Esporta i tuoi diagrammi per conservarli in modo permanente.", "noSavedDiagrams": "Nessun diagramma salvato trovato in questa sessione", "updated": "Aggiornato", "btnLoad": "Carica", "btnDelete": "Elimina", "btnClose": "Chiudi" }, "export": { "title": "Esporta diagramma", "recommendedTitle": "Consigliato", "recommendedMessage": "Questo è il modo migliore per salvare il tuo lavoro in modo permanente.", "noteMessage": "I file JSON esportati possono essere importati in seguito o condivisi con altri.", "btnDownload": "Scarica JSON", "btnCancel": "Annulla" }, "readOnly": { "mode": "Modalità sola lettura", "failed": "Impossibile caricare il diagramma" } }, "alert": { "enterDiagramName": "Inserisci un nome per il diagramma", "diagramExists": "Un diagramma chiamato \"{{name}}\" esiste già in questa sessione. Verrà sovrascritto. Sei sicuro di voler continuare?", "unsavedChanges": "Hai modifiche non salvate. Continuare con il caricamento?", "createNewDiagram": "Creare un nuovo diagramma?", "unsavedChangesExport": "Hai modifiche non salvate. Esporta prima il tuo diagramma per salvarlo. Continuare?", "confirmDelete": "Sei sicuro di voler eliminare questo diagramma?", "storageFull": "Archiviazione piena! Apertura Gestore archiviazione...", "autoSaveFailed": "Archiviazione piena! Usa il Gestore archiviazione per liberare spazio.", "beforeUnload": "Hai modifiche non salvate. Sei sicuro di voler uscire?", "quotaExceeded": "Quota di archiviazione superata. Esporta i diagrammi importanti e libera spazio." } } ================================================ FILE: packages/fossflow-app/src/i18n/pl-PL.json ================================================ { "nav": { "newDiagram": "Nowy Diagram", "saveSessionOnly": "Zapisz (tylko bieżąca sesja)", "loadSessionOnly": "Wczytaj (tylko bieżąca sesja)", "importFile": "Importuj Plik", "exportFile": "Eksportuj Plik", "quickSaveSession": "Szybki zapis (Sesji)", "serverStorage": "Dysk aplikacji" }, "status": { "current": "Obecny", "untitled": "Diagram bez tytułu", "modified": "Zmodyfikowany", "sessionStorageNote": "Tylko pamięć sesji – eksportuj, aby zapisać na stałe" }, "dialog": { "save": { "title": "Zapisz diagram (tylko bieżąca sesja)", "warningTitle": "Ważne", "warningMessage": "To zapisanie jest tymczasowe i zostanie utracone po zamknięciu przeglądarki.", "warningExport": "Użyj opcji Eksportuj plik, aby trwale zapisać swoją pracę..", "placeholder": "Wprowadź nazwę diagramu", "btnSave": "Zapisz", "btnCancel": "Anuluj" }, "load": { "title": "Wczytaj diagram (tylko bieżąca sesja)", "noteTitle": "Uwaga", "noteMessage": "Te zapisy są tymczasowe. Wyeksportuj swoje diagramy, aby zachować je na stałe.", "noSavedDiagrams": "W tej sesji nie znaleziono żadnych zapisanych diagramów.", "updated": "Zaktualizowano", "btnLoad": "Wczytaj", "btnDelete": "Usuń", "btnClose": "Zamknij" }, "export": { "title": "Eksportuj Diagram", "recommendedTitle": "Zalecane", "recommendedMessage": "To najlepszy sposób na trwałe zapisanie swojej pracy.", "noteMessage": "Wyeksportowane pliki JSON można później zaimportować lub udostępnić innym osobom.", "btnDownload": "Pobierz plik JSON", "btnCancel": "Anuluj" }, "readOnly": { "mode": "Tryb tylko do odczytu", "failed": "Nie udało się załadować diagramu" } }, "alert": { "enterDiagramName": "Proszę wprowadzić nazwę diagramu", "diagramExists": "W tej sesji istnieje już diagram o nazwie \"{{name}}\". Spowoduje to jego nadpisanie. Czy na pewno chcesz kontynuować?", "unsavedChanges": "Masz niezapisane zmiany. Kontynuować Wczytanie?", "createNewDiagram": "Utworzyć nowy diagram?", "unsavedChangesExport": "Masz niezapisane zmiany. Najpierw wyeksportuj diagram, aby go zapisać. Kontynuować?", "confirmDelete": "Czy na pewno chcesz usunąć ten diagram?", "storageFull": "Dysk pełny! Otwieranie menadżera dysku aplikacji...", "autoSaveFailed": "Dysk pełny! Użyj Menedżera dysku, aby zwolnić miejsce.", "beforeUnload": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?", "quotaExceeded": "Przekroczono limit miejsca na dysku. Proszę wyeksportować ważne diagramy i zwolnić trochę miejsca." } } ================================================ FILE: packages/fossflow-app/src/i18n/pt-BR.json ================================================ { "nav": { "newDiagram": "Novo diagrama", "saveSessionOnly": "Salvar (Apenas sessão)", "loadSessionOnly": "Carregar (Apenas sessão)", "importFile": "Importar arquivo", "exportFile": "Exportar arquivo", "quickSaveSession": "Salvamento rápido (Sessão)", "serverStorage": "Armazenamento no servidor" }, "status": { "current": "Atual", "untitled": "Diagrama sem título", "modified": "Modificado", "sessionStorageNote": "Apenas armazenamento de sessão - exporte para salvar permanentemente" }, "dialog": { "save": { "title": "Salvar diagrama (Apenas sessão atual)", "warningTitle": "Importante", "warningMessage": "Este salvamento é temporário e será perdido ao fechar o navegador.", "warningExport": "Use Exportar arquivo para salvar seu trabalho permanentemente.", "placeholder": "Digite o nome do diagrama", "btnSave": "Salvar", "btnCancel": "Cancelar" }, "load": { "title": "Carregar diagrama (Apenas sessão atual)", "noteTitle": "Nota", "noteMessage": "Estes salvamentos são temporários. Exporte seus diagramas para mantê-los permanentemente.", "noSavedDiagrams": "Nenhum diagrama salvo encontrado nesta sessão", "updated": "Atualizado", "btnLoad": "Carregar", "btnDelete": "Excluir", "btnClose": "Fechar" }, "export": { "title": "Exportar diagrama", "recommendedTitle": "Recomendado", "recommendedMessage": "Esta é a melhor forma de salvar seu trabalho permanentemente.", "noteMessage": "Arquivos JSON exportados podem ser importados posteriormente ou compartilhados com outros.", "btnDownload": "Baixar JSON", "btnCancel": "Cancelar" }, "readOnly": { "mode": "Modo somente leitura", "failed": "Falha ao carregar diagrama" } }, "alert": { "enterDiagramName": "Por favor, digite um nome para o diagrama", "diagramExists": "Já existe um diagrama chamado \"{{name}}\" nesta sessão. Isso irá sobrescrevê-lo. Tem certeza de que deseja continuar?", "unsavedChanges": "Você tem alterações não salvas. Continuar carregando?", "createNewDiagram": "Criar um novo diagrama?", "unsavedChangesExport": "Você tem alterações não salvas. Exporte seu diagrama primeiro para salvá-lo. Continuar?", "confirmDelete": "Tem certeza de que deseja excluir este diagrama?", "storageFull": "Armazenamento cheio! Abrindo o gerenciador de armazenamento...", "autoSaveFailed": "Armazenamento cheio! Por favor, use o gerenciador de armazenamento para liberar espaço.", "beforeUnload": "Você tem alterações não salvas. Tem certeza de que deseja sair?", "quotaExceeded": "Cota de armazenamento excedida. Por favor, exporte os diagramas importantes e libere espaço." } } ================================================ FILE: packages/fossflow-app/src/i18n/ru-RU.json ================================================ { "nav": { "newDiagram": "Новая диаграмма", "saveSessionOnly": "Сохранить (Только сеанс)", "loadSessionOnly": "Загрузить (Только сеанс)", "importFile": "Импортировать файл", "exportFile": "Экспортировать файл", "quickSaveSession": "Быстрое сохранение (Сеанс)", "serverStorage": "Серверное хранилище" }, "status": { "current": "Текущий", "untitled": "Диаграмма без названия", "modified": "Изменено", "sessionStorageNote": "Только хранилище сеанса - экспортируйте для постоянного сохранения" }, "dialog": { "save": { "title": "Сохранить диаграмму (Только текущий сеанс)", "warningTitle": "Важно", "warningMessage": "Это сохранение временное и будет потеряно при закрытии браузера.", "warningExport": "Используйте Экспортировать файл для постоянного сохранения вашей работы.", "placeholder": "Введите название диаграммы", "btnSave": "Сохранить", "btnCancel": "Отмена" }, "load": { "title": "Загрузить диаграмму (Только текущий сеанс)", "noteTitle": "Примечание", "noteMessage": "Эти сохранения временные. Экспортируйте свои диаграммы, чтобы сохранить их постоянно.", "noSavedDiagrams": "В этом сеансе не найдено сохраненных диаграмм", "updated": "Обновлено", "btnLoad": "Загрузить", "btnDelete": "Удалить", "btnClose": "Закрыть" }, "export": { "title": "Экспортировать диаграмму", "recommendedTitle": "Рекомендуется", "recommendedMessage": "Это лучший способ сохранить вашу работу постоянно.", "noteMessage": "Экспортированные файлы JSON можно импортировать позже или поделиться с другими.", "btnDownload": "Скачать JSON", "btnCancel": "Отмена" }, "readOnly": { "mode": "Режим только для чтения", "failed": "Не удалось загрузить диаграмму" } }, "alert": { "enterDiagramName": "Пожалуйста, введите название диаграммы", "diagramExists": "Диаграмма с названием \"{{name}}\" уже существует в этом сеансе. Это перезапишет её. Вы уверены, что хотите продолжить?", "unsavedChanges": "У вас есть несохраненные изменения. Продолжить загрузку?", "createNewDiagram": "Создать новую диаграмму?", "unsavedChangesExport": "У вас есть несохраненные изменения. Сначала экспортируйте диаграмму, чтобы сохранить её. Продолжить?", "confirmDelete": "Вы уверены, что хотите удалить эту диаграмму?", "storageFull": "Хранилище заполнено! Открывается менеджер хранилища...", "autoSaveFailed": "Хранилище заполнено! Пожалуйста, используйте менеджер хранилища, чтобы освободить место.", "beforeUnload": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?", "quotaExceeded": "Квота хранилища превышена. Пожалуйста, экспортируйте важные диаграммы и освободите место." } } ================================================ FILE: packages/fossflow-app/src/i18n/tr-TR.json ================================================ { "nav": { "newDiagram": "Yeni Diyagram", "saveSessionOnly": "Kaydet (Yalnızca Oturum)", "loadSessionOnly": "Yükle (Yalnızca Oturum)", "importFile": "Dosya İçe Aktar", "exportFile": "Dosya Dışa Aktar", "quickSaveSession": "Hızlı Kaydet (Oturum)", "serverStorage": "Sunucu Depolama" }, "status": { "current": "Mevcut", "untitled": "İsimsiz Diyagram", "modified": "Değiştirildi", "sessionStorageNote": "Yalnızca oturum depolama - kalıcı olarak kaydetmek için dışa aktar" }, "dialog": { "save": { "title": "Diyagramı Kaydet (Yalnızca Mevcut Oturum)", "warningTitle": "Önemli", "warningMessage": "Bu kayıt geçicidir ve tarayıcıyı kapattığınızda kaybolacaktır.", "warningExport": "Çalışmanızı kalıcı olarak kaydetmek için Dosya Dışa Aktar kullanın.", "placeholder": "Diyagram adı girin", "btnSave": "Kaydet", "btnCancel": "İptal" }, "load": { "title": "Diyagram Yükle (Yalnızca Mevcut Oturum)", "noteTitle": "Not", "noteMessage": "Bu kayıtlar geçicidir. Diyagramlarınızı kalıcı olarak saklamak için dışa aktarın.", "noSavedDiagrams": "Bu oturumda kaydedilmiş diyagram bulunamadı", "updated": "Güncellendi", "btnLoad": "Yükle", "btnDelete": "Sil", "btnClose": "Kapat" }, "export": { "title": "Diyagramı Dışa Aktar", "recommendedTitle": "Önerilen", "recommendedMessage": "Bu, çalışmanızı kalıcı olarak kaydetmenin en iyi yoludur.", "noteMessage": "Dışa aktarılan JSON dosyaları daha sonra içe aktarılabilir veya başkalarıyla paylaşılabilir.", "btnDownload": "JSON İndir", "btnCancel": "İptal" }, "readOnly": { "mode": "Yalnızca Görüntüleme Modu", "failed": "Diyagram yüklenemedi" } }, "alert": { "enterDiagramName": "Lütfen bir diyagram adı girin", "diagramExists": "\"{{name}}\" adlı bir diyagram bu oturumda zaten mevcut. Bu, üzerine yazacaktır. Devam etmek istediğinizden emin misiniz?", "unsavedChanges": "Kaydedilmemiş değişiklikleriniz var. Yüklemeye devam edilsin mi?", "createNewDiagram": "Yeni bir diyagram oluşturulsun mu?", "unsavedChangesExport": "Kaydedilmemiş değişiklikleriniz var. Önce diyagramınızı kaydetmek için dışa aktarın. Devam edilsin mi?", "confirmDelete": "Bu diyagramı silmek istediğinizden emin misiniz?", "storageFull": "Depolama dolu! Depolama Yöneticisi açılıyor...", "autoSaveFailed": "Depolama dolu! Lütfen alan açmak için Depolama Yöneticisi'ni kullanın.", "beforeUnload": "Kaydedilmemiş değişiklikleriniz var. Ayrılmak istediğinizden emin misiniz?", "quotaExceeded": "Depolama kotası aşıldı. Lütfen önemli diyagramları dışa aktarın ve biraz alan açın." } } ================================================ FILE: packages/fossflow-app/src/i18n/zh-CN.json ================================================ { "nav": { "newDiagram": "新建图表", "saveSessionOnly": "保存(仅会话)", "loadSessionOnly": "加载(仅会话)", "importFile": "导入文件", "exportFile": "导出文件", "quickSaveSession": "快速保存(会话)", "serverStorage": "服务端存储" }, "status": { "current": "当前", "untitled": "未命名图表", "modified": "已修改", "sessionStorageNote": "仅会话存储 - 导出以永久保存" }, "dialog": { "save": { "title": "保存图表(仅当前会话)", "warningTitle": "重要提示", "warningMessage": "此保存是临时的,关闭浏览器后将丢失。", "warningExport": "使用导出文件功能永久保存您的工作。", "placeholder": "输入图表名称", "btnSave": "保存", "btnCancel": "取消" }, "load": { "title": "加载图表(仅当前会话)", "noteTitle": "提示", "noteMessage": "这些保存是临时的。导出您的图表以永久保存。", "noSavedDiagrams": "当前会话中未找到已保存的图表", "updated": "更新时间", "btnLoad": "加载", "btnDelete": "删除", "btnClose": "关闭" }, "export": { "title": "导出图表", "recommendedTitle": "推荐", "recommendedMessage": "这是永久保存工作的最佳方式。", "noteMessage": "导出的 JSON 文件可以稍后导入或与他人共享。", "btnDownload": "下载 JSON", "btnCancel": "取消" }, "readOnly": { "mode": "阅读模式", "failed": "加载图表失败" } }, "alert": { "enterDiagramName": "请输入图表名称", "diagramExists": "名为\"{{name}}\"的图表已存在于此会话中。这将覆盖它。您确定要继续吗?", "unsavedChanges": "您有未保存的更改。继续加载?", "createNewDiagram": "创建新图表?", "unsavedChangesExport": "您有未保存的更改。请先导出图表以保存。继续?", "confirmDelete": "您确定要删除此图表吗?", "storageFull": "存储空间已满!正在打开存储管理器...", "autoSaveFailed": "存储空间已满!请使用存储管理器释放空间。", "beforeUnload": "您有未保存的更改。您确定要离开吗?", "quotaExceeded": "存储配额已超出。请导出重要图表并清理一些空间。" } } ================================================ FILE: packages/fossflow-app/src/i18n.ts ================================================ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; // Ensure PUBLIC_URL ends with slash for consistent path construction const publicUrl = process.env.PUBLIC_URL || ''; const basePath = publicUrl ? (publicUrl.endsWith('/') ? publicUrl : publicUrl + '/') : '/'; i18n .use(Backend) .use(LanguageDetector) .use(initReactI18next) .init({ fallbackLng: 'en-US', debug: process.env.NODE_ENV === 'development', interpolation: { escapeValue: false }, ns: ['app'], backend: { loadPath: `${basePath}i18n/{{ns}}/{{lng}}.json` }, detection: { order: ['localStorage'], caches: ['localStorage'] } }); export const supportedLanguages = [ { label: 'English', value: 'en-US' }, { label: '中文', value: 'zh-CN' }, { label: 'Español', value: 'es-ES' }, { label: 'Português', value: 'pt-BR' }, { label: 'Français', value: 'fr-FR' }, { label: 'हिन्दी', value: 'hi-IN' }, { label: 'বাংলা', value: 'bn-BD' }, { label: 'Русский', value: 'ru-RU' }, { label: 'Italian', value: 'it-IT' }, { label: 'Bahasa Indonesia', value: 'id-ID' }, { label: 'Deutsch', value: 'de-DE' }, { label: 'Türkçe', value: 'tr-TR' } ]; export default i18n; ================================================ FILE: packages/fossflow-app/src/index.css ================================================ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } ================================================ FILE: packages/fossflow-app/src/index.tsx ================================================ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import 'react-quill-new/dist/quill.snow.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import * as serviceWorkerRegistration from './serviceWorkerRegistration'; import { ErrorBoundary } from 'react-error-boundary'; import ErrorBoundaryFallbackUI from './components/ErrorBoundary'; import {I18nextProvider} from 'react-i18next'; import i18n from './i18n'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( ); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals reportWebVitals(); // Service worker registration - only in production for PWA functionality if (process.env.NODE_ENV === 'production') { serviceWorkerRegistration.register({ onSuccess: () => console.log('Service worker registered successfully'), onUpdate: () => console.log('Service worker update available') }); } else { // Disable service worker in development to avoid cache issues serviceWorkerRegistration.unregister(); } ================================================ FILE: packages/fossflow-app/src/minimalIcons.ts ================================================ // Minimal icons needed for Isoflow functionality // These are system icons that Isoflow uses internally export const getMinimalIcons = (allIcons: any[]) => { // Find connector/arrow related icons that Isoflow might need const essentialIconIds = [ 'arrow', 'connector', 'line', 'path', '_isoflow_', // Isoflow system icons 'isoflow-arrow', 'isoflow-connector' ]; // Filter to only include essential system icons const minimalIcons = allIcons.filter(icon => { const id = icon.id?.toLowerCase() || ''; return essentialIconIds.some(essential => id.includes(essential)); }); console.log(`Reduced icons from ${allIcons.length} to ${minimalIcons.length} essential icons`); // If no essential icons found, include at least the first few icons if (minimalIcons.length === 0 && allIcons.length > 0) { return allIcons.slice(0, 10); // Fallback to first 10 icons } return minimalIcons; }; ================================================ FILE: packages/fossflow-app/src/paymentFlowExample.json ================================================ { "title": "E-Commerce Payment Processing Flow", "icons": [], "colors": [ { "id": "blue", "value": "#0066cc" }, { "id": "green", "value": "#00aa00" }, { "id": "red", "value": "#cc0000" }, { "id": "orange", "value": "#ff9900" }, { "id": "purple", "value": "#9900cc" } ], "items": [ { "id": "customer", "type": "isoflow__person", "position": { "x": 50, "y": 200 }, "name": "Customer", "description": "Online shopper making a purchase" }, { "id": "web-app", "type": "isoflow__web_app", "position": { "x": 200, "y": 200 }, "name": "E-Commerce Site", "description": "React-based shopping platform" }, { "id": "api-gateway", "type": "isoflow__api", "position": { "x": 350, "y": 200 }, "name": "API Gateway", "description": "Routes payment requests" }, { "id": "load-balancer", "type": "isoflow__load_balancer", "position": { "x": 500, "y": 200 }, "name": "Load Balancer", "description": "Distributes traffic across payment services" }, { "id": "payment-service-1", "type": "isoflow__microservice", "position": { "x": 650, "y": 150 }, "name": "Payment Service A", "description": "Primary payment processor" }, { "id": "payment-service-2", "type": "isoflow__microservice", "position": { "x": 650, "y": 250 }, "name": "Payment Service B", "description": "Backup payment processor" }, { "id": "redis-cache", "type": "isoflow__redis", "position": { "x": 800, "y": 100 }, "name": "Redis Cache", "description": "Session & cart data" }, { "id": "auth-service", "type": "isoflow__authentication", "position": { "x": 350, "y": 350 }, "name": "Auth Service", "description": "OAuth 2.0 / JWT tokens" }, { "id": "fraud-detection", "type": "isoflow__shield", "position": { "x": 500, "y": 350 }, "name": "Fraud Detection", "description": "ML-based fraud analysis" }, { "id": "payment-gateway", "type": "isoflow__gateway", "position": { "x": 800, "y": 200 }, "name": "Payment Gateway", "description": "Stripe/PayPal integration" }, { "id": "bank-api", "type": "isoflow__bank", "position": { "x": 950, "y": 200 }, "name": "Banking API", "description": "Direct bank integration" }, { "id": "database", "type": "isoflow__database", "position": { "x": 650, "y": 350 }, "name": "PostgreSQL", "description": "Transaction history" }, { "id": "notification", "type": "isoflow__notification", "position": { "x": 800, "y": 350 }, "name": "Notification Service", "description": "Email/SMS confirmations" }, { "id": "monitoring", "type": "isoflow__monitoring", "position": { "x": 950, "y": 350 }, "name": "Monitoring", "description": "DataDog integration" }, { "id": "cdn", "type": "isoflow__cdn", "position": { "x": 200, "y": 50 }, "name": "CloudFlare CDN", "description": "Static assets & DDoS protection" }, { "id": "mobile-app", "type": "isoflow__mobile", "position": { "x": 50, "y": 50 }, "name": "Mobile App", "description": "iOS/Android app" }, { "id": "backup", "type": "isoflow__backup", "position": { "x": 650, "y": 450 }, "name": "Backup Service", "description": "Automated daily backups" }, { "id": "analytics", "type": "isoflow__analytics", "position": { "x": 350, "y": 50 }, "name": "Analytics", "description": "Google Analytics & Mixpanel" }, { "id": "queue", "type": "isoflow__queue", "position": { "x": 500, "y": 450 }, "name": "Message Queue", "description": "RabbitMQ for async processing" }, { "id": "logs", "type": "isoflow__logs", "position": { "x": 800, "y": 450 }, "name": "Log Aggregation", "description": "ELK Stack" } ], "connectors": [ { "id": "c1", "from": "customer", "to": "web-app", "name": "1. Browse & checkout", "color": "blue" }, { "id": "c2", "from": "mobile-app", "to": "web-app", "name": "Mobile API", "color": "blue" }, { "id": "c3", "from": "web-app", "to": "cdn", "name": "Static assets", "color": "orange" }, { "id": "c4", "from": "web-app", "to": "api-gateway", "name": "2. Payment request", "color": "green" }, { "id": "c5", "from": "api-gateway", "to": "auth-service", "name": "3. Verify auth", "color": "purple" }, { "id": "c6", "from": "api-gateway", "to": "load-balancer", "name": "4. Route payment", "color": "green" }, { "id": "c7", "from": "load-balancer", "to": "payment-service-1", "name": "5a. Process (primary)", "color": "green" }, { "id": "c8", "from": "load-balancer", "to": "payment-service-2", "name": "5b. Process (backup)", "color": "orange" }, { "id": "c9", "from": "payment-service-1", "to": "redis-cache", "name": "Cache session", "color": "blue" }, { "id": "c10", "from": "payment-service-1", "to": "fraud-detection", "name": "6. Check fraud", "color": "red" }, { "id": "c11", "from": "payment-service-1", "to": "payment-gateway", "name": "7. Process payment", "color": "green" }, { "id": "c12", "from": "payment-gateway", "to": "bank-api", "name": "8. Bank transfer", "color": "green" }, { "id": "c13", "from": "payment-service-1", "to": "database", "name": "9. Store transaction", "color": "blue" }, { "id": "c14", "from": "payment-service-1", "to": "notification", "name": "10. Send confirmation", "color": "purple" }, { "id": "c15", "from": "payment-service-1", "to": "monitoring", "name": "Metrics", "color": "orange" }, { "id": "c16", "from": "web-app", "to": "analytics", "name": "Track events", "color": "purple" }, { "id": "c17", "from": "database", "to": "backup", "name": "Daily backup", "color": "orange" }, { "id": "c18", "from": "payment-service-1", "to": "queue", "name": "Async tasks", "color": "blue" }, { "id": "c19", "from": "payment-service-1", "to": "logs", "name": "Audit logs", "color": "purple" }, { "id": "c20", "from": "notification", "to": "customer", "name": "11. Email/SMS receipt", "color": "green" } ], "views": [], "fitToScreen": true } ================================================ FILE: packages/fossflow-app/src/reportWebVitals.ts ================================================ import { ReportHandler } from 'web-vitals'; const reportWebVitals = (onPerfEntry?: ReportHandler) => { if (onPerfEntry && onPerfEntry instanceof Function) { import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { getCLS(onPerfEntry); getFID(onPerfEntry); getFCP(onPerfEntry); getLCP(onPerfEntry); getTTFB(onPerfEntry); }); } }; export default reportWebVitals; ================================================ FILE: packages/fossflow-app/src/serviceWorkerRegistration.ts ================================================ const isLocalhost = Boolean( window.location.hostname === 'localhost' || window.location.hostname === '[::1]' || window.location.hostname.match( /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ ) ); type Config = { onSuccess?: (registration: ServiceWorkerRegistration) => void; onUpdate?: (registration: ServiceWorkerRegistration) => void; }; export function register(config?: Config) { if ('serviceWorker' in navigator) { // Ensure PUBLIC_URL ends with slash for consistent path construction const publicUrlPath = process.env.PUBLIC_URL || ''; const basePath = publicUrlPath ? (publicUrlPath.endsWith('/') ? publicUrlPath : publicUrlPath + '/') : '/'; const publicUrl = new URL(basePath, window.location.href); if (publicUrl.origin !== window.location.origin) { return; } window.addEventListener('load', () => { const swUrl = `${basePath}service-worker.js`; if (isLocalhost) { checkValidServiceWorker(swUrl, config); navigator.serviceWorker.ready.then(() => { console.log( 'This web app is being served cache-first by a service worker.' ); }); } else { registerValidSW(swUrl, config); } }); } } function registerValidSW(swUrl: string, config?: Config) { navigator.serviceWorker .register(swUrl) .then((registration) => { registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker == null) { return; } installingWorker.onstatechange = () => { if (installingWorker.state === 'installed') { if (navigator.serviceWorker.controller) { console.log( 'New content is available and will be used when all tabs for this page are closed.' ); if (config && config.onUpdate) { config.onUpdate(registration); } } else { console.log('Content is cached for offline use.'); if (config && config.onSuccess) { config.onSuccess(registration); } } } }; }; }) .catch((error) => { console.error('Error during service worker registration:', error); }); } function checkValidServiceWorker(swUrl: string, config?: Config) { fetch(swUrl, { headers: { 'Service-Worker': 'script' }, }) .then((response) => { const contentType = response.headers.get('content-type'); if ( response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1) ) { navigator.serviceWorker.ready.then((registration) => { registration.unregister().then(() => { window.location.reload(); }); }); } else { registerValidSW(swUrl, config); } }) .catch(() => { console.log( 'No internet connection found. App is running in offline mode.' ); }); } export function unregister() { if ('serviceWorker' in navigator) { navigator.serviceWorker.ready .then((registration) => { registration.unregister(); }) .catch((error) => { console.error(error.message); }); } } ================================================ FILE: packages/fossflow-app/src/services/iconPackManager.ts ================================================ import { useState, useEffect, useCallback } from 'react'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; // Available icon packs (excluding core isoflow which is always loaded) export type IconPackName = 'aws' | 'gcp' | 'azure' | 'kubernetes'; export interface IconPackInfo { name: IconPackName; displayName: string; loaded: boolean; loading: boolean; error: string | null; iconCount: number; } export interface IconPackManagerState { lazyLoadingEnabled: boolean; enabledPacks: IconPackName[]; packInfo: Record; loadedIcons: any[]; } // localStorage keys const LAZY_LOADING_KEY = 'fossflow-lazy-loading-enabled'; const ENABLED_PACKS_KEY = 'fossflow-enabled-icon-packs'; // Pack metadata const PACK_METADATA: Record = { aws: 'AWS Icons', gcp: 'Google Cloud Icons', azure: 'Azure Icons', kubernetes: 'Kubernetes Icons' }; // Load preferences from localStorage export const loadLazyLoadingPreference = (): boolean => { const stored = localStorage.getItem(LAZY_LOADING_KEY); return stored === null ? true : stored === 'true'; // Default to true }; export const saveLazyLoadingPreference = (enabled: boolean): void => { localStorage.setItem(LAZY_LOADING_KEY, String(enabled)); }; export const loadEnabledPacks = (): IconPackName[] => { const stored = localStorage.getItem(ENABLED_PACKS_KEY); if (!stored) return []; try { return JSON.parse(stored) as IconPackName[]; } catch { return []; } }; export const saveEnabledPacks = (packs: IconPackName[]): void => { localStorage.setItem(ENABLED_PACKS_KEY, JSON.stringify(packs)); }; // Dynamic pack loader export const loadIconPack = async (packName: IconPackName): Promise => { switch (packName) { case 'aws': return (await import('@isoflow/isopacks/dist/aws')).default; case 'gcp': return (await import('@isoflow/isopacks/dist/gcp')).default; case 'azure': return (await import('@isoflow/isopacks/dist/azure')).default; case 'kubernetes': return (await import('@isoflow/isopacks/dist/kubernetes')).default; default: throw new Error(`Unknown icon pack: ${packName}`); } }; // React hook for managing icon packs export const useIconPackManager = (coreIcons: any[]) => { const [lazyLoadingEnabled, setLazyLoadingEnabled] = useState(() => loadLazyLoadingPreference() ); const [enabledPacks, setEnabledPacks] = useState(() => loadEnabledPacks() ); const [packInfo, setPackInfo] = useState>(() => { const info: Record = {}; const packNames: IconPackName[] = ['aws', 'gcp', 'azure', 'kubernetes']; packNames.forEach(name => { info[name] = { name, displayName: PACK_METADATA[name], loaded: false, loading: false, error: null, iconCount: 0 }; }); return info as Record; }); const [loadedIcons, setLoadedIcons] = useState(coreIcons); const [loadedPackData, setLoadedPackData] = useState>({} as Record); // Load a specific pack const loadPack = useCallback(async (packName: IconPackName) => { // Already loaded? if (packInfo[packName].loaded || packInfo[packName].loading) { return; } // Set loading state setPackInfo(prev => ({ ...prev, [packName]: { ...prev[packName], loading: true, error: null } })); try { const pack = await loadIconPack(packName); const flattenedIcons = flattenCollections([pack]); // Store the loaded pack data setLoadedPackData(prev => ({ ...prev, [packName]: pack })); // Update pack info setPackInfo(prev => ({ ...prev, [packName]: { ...prev[packName], loaded: true, loading: false, iconCount: flattenedIcons.length, error: null } })); // Add icons to the loaded icons array setLoadedIcons(prev => [...prev, ...flattenedIcons]); return flattenedIcons; } catch (error) { console.error(`Failed to load ${packName} icon pack:`, error); setPackInfo(prev => ({ ...prev, [packName]: { ...prev[packName], loading: false, error: error instanceof Error ? error.message : 'Failed to load pack' } })); throw error; } }, [packInfo]); // Enable/disable a pack const togglePack = useCallback(async (packName: IconPackName, enabled: boolean) => { if (enabled) { // Add to enabled packs const newEnabledPacks = [...enabledPacks, packName]; setEnabledPacks(newEnabledPacks); saveEnabledPacks(newEnabledPacks); // Load the pack await loadPack(packName); } else { // Remove from enabled packs const newEnabledPacks = enabledPacks.filter(p => p !== packName); setEnabledPacks(newEnabledPacks); saveEnabledPacks(newEnabledPacks); // Remove icons from loaded icons // We need to rebuild the icons array from core + enabled packs const newIcons = [coreIcons]; for (const pack of newEnabledPacks) { if (loadedPackData[pack]) { newIcons.push(flattenCollections([loadedPackData[pack]])); } } setLoadedIcons(newIcons.flat()); } }, [enabledPacks, loadPack, coreIcons, loadedPackData]); // Toggle lazy loading const toggleLazyLoading = useCallback((enabled: boolean) => { setLazyLoadingEnabled(enabled); saveLazyLoadingPreference(enabled); }, []); // Load all packs (for when lazy loading is disabled) const loadAllPacks = useCallback(async () => { const allPacks: IconPackName[] = ['aws', 'gcp', 'azure', 'kubernetes']; for (const pack of allPacks) { if (!packInfo[pack].loaded && !packInfo[pack].loading) { await loadPack(pack); } } }, [packInfo, loadPack]); // Auto-detect required packs from diagram data const loadPacksForDiagram = useCallback(async (diagramItems: any[]) => { if (!diagramItems || diagramItems.length === 0) return; // Extract unique collections from diagram items const collections = new Set(); diagramItems.forEach(item => { if (item.icon?.collection) { collections.add(item.icon.collection); } }); // Load any missing packs const packsToLoad: IconPackName[] = []; collections.forEach(collection => { if (collection !== 'isoflow' && collection !== 'imported') { const packName = collection as IconPackName; if (['aws', 'gcp', 'azure', 'kubernetes'].includes(packName)) { if (!packInfo[packName].loaded && !packInfo[packName].loading) { packsToLoad.push(packName); } } } }); // Load required packs for (const pack of packsToLoad) { await loadPack(pack); // Also add to enabled packs if (!enabledPacks.includes(pack)) { const newEnabledPacks = [...enabledPacks, pack]; setEnabledPacks(newEnabledPacks); saveEnabledPacks(newEnabledPacks); } } }, [packInfo, enabledPacks, loadPack]); // Initialize: Load enabled packs or all packs depending on lazy loading setting useEffect(() => { const initialize = async () => { if (!lazyLoadingEnabled) { // Load all packs immediately await loadAllPacks(); } else { // Load only enabled packs for (const pack of enabledPacks) { if (!packInfo[pack].loaded && !packInfo[pack].loading) { await loadPack(pack); } } } }; initialize(); }, []); // Only run once on mount return { lazyLoadingEnabled, enabledPacks, packInfo, loadedIcons, togglePack, toggleLazyLoading, loadAllPacks, loadPacksForDiagram, isPackEnabled: (packName: IconPackName) => enabledPacks.includes(packName) }; }; ================================================ FILE: packages/fossflow-app/src/services/storageService.ts ================================================ import { Model } from 'fossflow/dist/types'; export interface DiagramInfo { id: string; name: string; lastModified: Date; size?: number; } export interface StorageService { isAvailable(): Promise; listDiagrams(): Promise; loadDiagram(id: string): Promise; saveDiagram(id: string, data: Model): Promise; deleteDiagram(id: string): Promise; createDiagram(data: Model): Promise; } // Server Storage Implementation class ServerStorage implements StorageService { private baseUrl: string; private available: boolean | null = null; private availabilityCheckedAt: number | null = null; private readonly AVAILABILITY_CACHE_MS = 60000; // Re-check every 60 seconds constructor(baseUrl: string = '') { // In production (Docker), use relative paths (nginx proxy) // In development, use localhost:3001 const isDevelopment = window.location.hostname === 'localhost' && window.location.port === '3000'; this.baseUrl = baseUrl || (isDevelopment ? 'http://localhost:3001' : ''); } async isAvailable(): Promise { // Re-check availability if cache is stale const now = Date.now(); if (this.available !== null && this.availabilityCheckedAt !== null && (now - this.availabilityCheckedAt) < this.AVAILABILITY_CACHE_MS) { return this.available; } try { const response = await fetch(`${this.baseUrl}/api/storage/status`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(5000) // 5 second timeout }); const data = await response.json(); this.available = data.enabled; this.availabilityCheckedAt = Date.now(); console.log(`Server storage availability: ${this.available}`); return this.available ?? false; } catch (error) { console.log('Server storage not available:', error); this.available = false; this.availabilityCheckedAt = Date.now(); return false; } } async listDiagrams(): Promise { console.log(`Fetching diagrams from: ${this.baseUrl}/api/diagrams`); const response = await fetch(`${this.baseUrl}/api/diagrams`); console.log(`Response status: ${response.status}`); if (!response.ok) { const errorText = await response.text(); console.error('Failed to list diagrams:', errorText); throw new Error(`Failed to list diagrams: ${response.status} ${errorText}`); } const diagrams = await response.json(); console.log(`Received ${diagrams.length} diagrams from server:`, diagrams); return diagrams.map((d: any) => ({ ...d, lastModified: new Date(d.lastModified) })); } async loadDiagram(id: string): Promise { console.log(`ServerStorage: Loading diagram ${id} from ${this.baseUrl}/api/diagrams/${id}`); try { const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(10000) // 10 second timeout }); if (!response.ok) { const errorText = await response.text(); console.error(`ServerStorage: Failed to load diagram ${id}: ${response.status} ${errorText}`); throw new Error(`Failed to load diagram: ${response.status} ${errorText}`); } const data = await response.json(); console.log(`ServerStorage: Successfully loaded diagram ${id}, items: ${data.items?.length || 0}`); return data; } catch (error) { console.error(`ServerStorage: Error loading diagram ${id}:`, error); throw error; } } async saveDiagram(id: string, data: Model): Promise { console.log(`ServerStorage: Saving diagram ${id}`); try { const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(15000) // 15 second timeout for saves }); if (!response.ok) { const errorText = await response.text(); console.error(`ServerStorage: Failed to save diagram ${id}: ${response.status} ${errorText}`); throw new Error(`Failed to save diagram: ${response.status}`); } console.log(`ServerStorage: Successfully saved diagram ${id}`); } catch (error) { console.error(`ServerStorage: Error saving diagram ${id}:`, error); throw error; } } async deleteDiagram(id: string): Promise { const response = await fetch(`${this.baseUrl}/api/diagrams/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete diagram'); } async createDiagram(data: Model): Promise { const response = await fetch(`${this.baseUrl}/api/diagrams`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) throw new Error('Failed to create diagram'); const result = await response.json(); return result.id; } } // Session Storage Implementation (existing functionality) class SessionStorage implements StorageService { private readonly KEY_PREFIX = 'fossflow_diagram_'; private readonly LIST_KEY = 'fossflow_diagrams'; async isAvailable(): Promise { return true; // Session storage is always available } async listDiagrams(): Promise { const listStr = sessionStorage.getItem(this.LIST_KEY); if (!listStr) return []; const list = JSON.parse(listStr); return list.map((item: any) => ({ ...item, lastModified: new Date(item.lastModified) })); } async loadDiagram(id: string): Promise { const data = sessionStorage.getItem(`${this.KEY_PREFIX}${id}`); if (!data) throw new Error('Diagram not found'); return JSON.parse(data); } async saveDiagram(id: string, data: Model): Promise { sessionStorage.setItem(`${this.KEY_PREFIX}${id}`, JSON.stringify(data)); // Update list const list = await this.listDiagrams(); const existing = list.findIndex(d => d.id === id); const info: DiagramInfo = { id, name: (data as any).name || 'Untitled Diagram', lastModified: new Date(), size: JSON.stringify(data).length }; if (existing >= 0) { list[existing] = info; } else { list.push(info); } sessionStorage.setItem(this.LIST_KEY, JSON.stringify(list)); } async deleteDiagram(id: string): Promise { sessionStorage.removeItem(`${this.KEY_PREFIX}${id}`); // Update list const list = await this.listDiagrams(); const filtered = list.filter(d => d.id !== id); sessionStorage.setItem(this.LIST_KEY, JSON.stringify(filtered)); } async createDiagram(data: Model): Promise { const id = `diagram_${Date.now()}`; await this.saveDiagram(id, data); return id; } } // Storage Manager - decides which storage to use class StorageManager { private serverStorage: ServerStorage; private sessionStorage: SessionStorage; private activeStorage: StorageService | null = null; constructor() { this.serverStorage = new ServerStorage(); this.sessionStorage = new SessionStorage(); } async initialize(): Promise { // Try server storage first if (await this.serverStorage.isAvailable()) { console.log('Using server storage'); this.activeStorage = this.serverStorage; } else { console.log('Using session storage'); this.activeStorage = this.sessionStorage; } return this.activeStorage; } getStorage(): StorageService { if (!this.activeStorage) { throw new Error('Storage not initialized. Call initialize() first.'); } return this.activeStorage; } isServerStorage(): boolean { return this.activeStorage === this.serverStorage; } } // Export singleton instance export const storageManager = new StorageManager(); ================================================ FILE: packages/fossflow-app/src/usePersistedDiagram.ts ================================================ import { useState, useEffect, useCallback } from 'react'; import { DiagramData } from './diagramUtils'; interface PersistedDiagramData extends Omit { // We omit icons from persisted data to save space } export const usePersistedDiagram = (icons: any[]) => { // Helper to add icons back to diagram data const addIconsToDiagram = useCallback((data: PersistedDiagramData): DiagramData => { return { ...data, icons: icons }; }, [icons]); // Helper to remove icons before persisting const removeIconsFromDiagram = useCallback((data: DiagramData): PersistedDiagramData => { const { icons: _, ...dataWithoutIcons } = data; return dataWithoutIcons; }, []); // Safe localStorage operations const safeSetItem = useCallback((key: string, value: string) => { try { localStorage.setItem(key, value); return true; } catch (e) { console.error(`Failed to save to localStorage (${key}):`, e); if (e instanceof DOMException && e.name === 'QuotaExceededError') { // Try to clear some space const keysToCheck = ['fossflow-last-opened-data', 'fossflow-temp-data']; keysToCheck.forEach(k => { if (k !== key) { localStorage.removeItem(k); } }); // Try again try { localStorage.setItem(key, value); return true; } catch (e2) { console.error('Still failed after clearing space:', e2); return false; } } return false; } }, []); const safeGetItem = useCallback((key: string): string | null => { try { return localStorage.getItem(key); } catch (e) { console.error(`Failed to read from localStorage (${key}):`, e); return null; } }, []); return { addIconsToDiagram, removeIconsFromDiagram, safeSetItem, safeGetItem }; }; ================================================ FILE: packages/fossflow-app/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "target": "es5" }, "include": [ "src/**/*" ] } ================================================ FILE: packages/fossflow-backend/package.json ================================================ { "name": "fossflow-backend", "version": "1.10.8", "description": "Optional backend server for FossFLOW persistent storage", "main": "server.js", "type": "module", "scripts": { "start": "node server.js", "dev": "nodemon server.js" }, "dependencies": { "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "uuid": "^9.0.1" }, "devDependencies": { "nodemon": "^3.1.14" } } ================================================ FILE: packages/fossflow-backend/server.js ================================================ import express from 'express'; import cors from 'cors'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; // Load environment variables dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.BACKEND_PORT || 3001; // Configuration from environment variables const STORAGE_ENABLED = process.env.ENABLE_SERVER_STORAGE === 'true'; const STORAGE_PATH = process.env.STORAGE_PATH || '/data/diagrams'; const ENABLE_GIT_BACKUP = process.env.ENABLE_GIT_BACKUP === 'true'; // Middleware app.use(cors()); app.use(express.json({ limit: '10mb' })); // Health check / Storage status endpoint app.get('/api/storage/status', (req, res) => { res.json({ enabled: STORAGE_ENABLED, gitBackup: ENABLE_GIT_BACKUP, version: '1.0.0' }); }); // Only enable storage endpoints if storage is enabled if (STORAGE_ENABLED) { // Ensure storage directory exists async function ensureStorageDir() { try { await fs.access(STORAGE_PATH); console.log(`Storage directory exists: ${STORAGE_PATH}`); // Log current files const files = await fs.readdir(STORAGE_PATH); console.log(`Current files in storage: ${files.length} files`); if (files.length > 0) { console.log('Files:', files.join(', ')); } } catch { console.log(`Creating storage directory: ${STORAGE_PATH}`); await fs.mkdir(STORAGE_PATH, { recursive: true }); console.log(`Created storage directory: ${STORAGE_PATH}`); } } // Initialize storage ensureStorageDir().catch((err) => { console.error('Failed to initialize storage:', err); }); // List all diagrams app.get('/api/diagrams', async (req, res) => { try { // First check if storage directory exists try { await fs.access(STORAGE_PATH); } catch (err) { console.error(`Storage directory does not exist: ${STORAGE_PATH}`); return res.json([]); // Return empty array if directory doesn't exist } const files = await fs.readdir(STORAGE_PATH); console.log(`Found ${files.length} files in ${STORAGE_PATH}:`, files); const diagrams = []; for (const file of files) { if (file.endsWith('.json') && file !== 'metadata.json') { try { const filePath = path.join(STORAGE_PATH, file); const stats = await fs.stat(filePath); const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content); // Extract name from various possible locations const name = data.name || data.title || 'Untitled Diagram'; console.log(`Successfully read diagram: ${file} (name: ${name})`); diagrams.push({ id: file.replace('.json', ''), name: name, lastModified: stats.mtime, size: stats.size }); } catch (fileError) { console.error(`Error reading diagram file ${file}:`, fileError.message); // Skip this file and continue with others continue; } } } console.log(`Returning ${diagrams.length} diagrams`); res.json(diagrams); } catch (error) { console.error('Error listing diagrams:', error); res.status(500).json({ error: 'Failed to list diagrams', details: error.message }); } }); // Get specific diagram app.get('/api/diagrams/:id', async (req, res) => { const diagramId = req.params.id; console.log(`[GET /api/diagrams/${diagramId}] Loading diagram...`); try { const filePath = path.join(STORAGE_PATH, `${diagramId}.json`); console.log(`[GET /api/diagrams/${diagramId}] Reading from: ${filePath}`); const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content); console.log(`[GET /api/diagrams/${diagramId}] Successfully loaded, size: ${content.length} bytes, items: ${data.items?.length || 0}`); res.json(data); } catch (error) { if (error.code === 'ENOENT') { console.error(`[GET /api/diagrams/${diagramId}] Diagram not found`); res.status(404).json({ error: 'Diagram not found' }); } else { console.error(`[GET /api/diagrams/${diagramId}] Error reading diagram:`, error); res.status(500).json({ error: 'Failed to read diagram' }); } } }); // Save or update diagram app.put('/api/diagrams/:id', async (req, res) => { const diagramId = req.params.id; console.log(`[PUT /api/diagrams/${diagramId}] Saving diagram...`); try { const filePath = path.join(STORAGE_PATH, `${diagramId}.json`); const data = { ...req.body, id: diagramId, lastModified: new Date().toISOString() }; const iconCount = data.icons?.length || 0; const importedIconCount = (data.icons || []).filter(icon => icon.collection === 'imported').length; console.log(`[PUT /api/diagrams/${diagramId}] Writing to: ${filePath}`); console.log(`[PUT /api/diagrams/${diagramId}] Items: ${data.items?.length || 0}, Icons: ${iconCount} (${importedIconCount} imported)`); await fs.writeFile(filePath, JSON.stringify(data, null, 2)); console.log(`[PUT /api/diagrams/${diagramId}] Successfully saved`); // Git backup if enabled if (ENABLE_GIT_BACKUP) { // TODO: Implement git commit console.log('[PUT] Git backup not yet implemented'); } res.json({ success: true, id: diagramId }); } catch (error) { console.error(`[PUT /api/diagrams/${diagramId}] Error saving diagram:`, error); res.status(500).json({ error: 'Failed to save diagram' }); } }); // Delete diagram app.delete('/api/diagrams/:id', async (req, res) => { try { const filePath = path.join(STORAGE_PATH, `${req.params.id}.json`); await fs.unlink(filePath); res.json({ success: true }); } catch (error) { if (error.code === 'ENOENT') { res.status(404).json({ error: 'Diagram not found' }); } else { console.error('Error deleting diagram:', error); res.status(500).json({ error: 'Failed to delete diagram' }); } } }); // Create a new diagram app.post('/api/diagrams', async (req, res) => { try { const id = req.body.id || `diagram_${Date.now()}`; const filePath = path.join(STORAGE_PATH, `${id}.json`); // Check if already exists try { await fs.access(filePath); return res.status(409).json({ error: 'Diagram already exists' }); } catch { // File doesn't exist, proceed } const data = { ...req.body, id, created: new Date().toISOString(), lastModified: new Date().toISOString() }; await fs.writeFile(filePath, JSON.stringify(data, null, 2)); res.status(201).json({ success: true, id }); } catch (error) { console.error('Error creating diagram:', error); res.status(500).json({ error: 'Failed to create diagram' }); } }); } else { // Storage disabled - return appropriate responses app.get('/api/diagrams', (req, res) => { res.status(503).json({ error: 'Server storage is disabled' }); }); app.get('/api/diagrams/:id', (req, res) => { res.status(503).json({ error: 'Server storage is disabled' }); }); app.put('/api/diagrams/:id', (req, res) => { res.status(503).json({ error: 'Server storage is disabled' }); }); app.delete('/api/diagrams/:id', (req, res) => { res.status(503).json({ error: 'Server storage is disabled' }); }); app.post('/api/diagrams', (req, res) => { res.status(503).json({ error: 'Server storage is disabled' }); }); } // Start server app.listen(PORT, () => { console.log(`FossFLOW Backend Server running on port ${PORT}`); console.log(`Server storage: ${STORAGE_ENABLED ? 'ENABLED' : 'DISABLED'}`); if (STORAGE_ENABLED) { console.log(`Storage path: ${STORAGE_PATH}`); console.log(`Git backup: ${ENABLE_GIT_BACKUP ? 'ENABLED' : 'DISABLED'}`); } }); ================================================ FILE: packages/fossflow-lib/.gitignore ================================================ dist ================================================ FILE: packages/fossflow-lib/LICENSE ================================================ MIT License Copyright (c) 2025 Mark Mankarious Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/fossflow-lib/docs/.gitignore ================================================ node_modules .next ================================================ FILE: packages/fossflow-lib/docs/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: packages/fossflow-lib/docs/next.config.js ================================================ const withNextra = require('nextra')({ theme: 'nextra-theme-docs', themeConfig: './theme.config.tsx', basePath: '/docs', }); module.exports = withNextra(); ================================================ FILE: packages/fossflow-lib/docs/package.json ================================================ { "name": "isoflow-docs", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "dev": "next dev -p 3002", "build": "next build", "start": "next start -p 3002" }, "author": "", "license": "ISC", "dependencies": { "next": "^15.5.10", "nextra": "^4.3.0", "nextra-theme-docs": "^4.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" } } ================================================ FILE: packages/fossflow-lib/docs/pages/_meta.json ================================================ { "docs": "Isoflow Community Edition" } ================================================ FILE: packages/fossflow-lib/docs/pages/docs/_meta.json ================================================ { "index": "About the Community Edition", "installation": "Installation", "quickstart": "Quick start", "isopacks": "Loading Isopacks", "api": "API", "contributing": "Contributing" } ================================================ FILE: packages/fossflow-lib/docs/pages/docs/api/_meta.json ================================================ { "index": "Props", "initialData": "InitialData" } ================================================ FILE: packages/fossflow-lib/docs/pages/docs/api/index.mdx ================================================ # Props | Name | Type | Description | Default | | --- | --- | --- | --- | | `initialData` | [`object`](/docs/api/initialData) | The initial data that Isoflow will render. If `undefined`, isoflow loads a blank scene. | `undefined` | | `width` | `number` \| `string` | Width of the Isoflow renderer as a CSS value. | `100%` | | `height` | `number` \| `string` | Height of the Isoflow renderer as a CSS value. | `100%` | | `onModelUpdated` | `function` | A callback that is triggered whenever an item is added, updated or removed from the Model. The callback is called with the updated Model as the first argument. | `undefined` | | `enableDebugTools` | `boolean` | Enables extra tools for debugging purposes. | `false` | | `editorMode` | `"EXPLORABLE_READONLY"` \| `"NON_INTERACTIVE"` \| `"EDITABLE"` | Enables / disables editor features. | `"EDITABLE"` | | `mainMenuOptions` | `("ACTION.OPEN" \| "EXPORT.JSON" \| "EXPORT.PNG" \| "ACTION.CLEAR_CANVAS" \| "LINK.GITHUB" \| "LINK.DISCORD" \| "VERSION")[]` | Shows / hides options in the main menu. If `[]` is passed, the menu is hidden. | All enabled | | `renderer` | [`RendererProps`](#rendererprops) | Configuration for the renderer component. | `undefined` | ## RendererProps | Name | Type | Description | Default | | --- | --- | --- | --- | | `showGrid` | `boolean` | Controls whether the grid is visible. | `undefined` | | `backgroundColor` | `string` | Sets the background color of the renderer. | `undefined` | ================================================ FILE: packages/fossflow-lib/docs/pages/docs/api/initialData.mdx ================================================ # `initialData` The `initialData` object contains the following properties: | Name | Type | Default | | --- | --- | --- | | `version` | `string \| undefined` | `undefined` | | `title` | `string \| undefined` | `undefined` | | `description` | `string \| undefined` | `undefined` | | `icons` | [`Icon[]`](#icon) | `[]` | | `colors` | [`Color[]`](#color) | `[]` | | `items` | [`Item[]`](#item) | `[]` | | `views` | [`View[]`](#view) | `[]` | | `fitToView` | `boolean` | `false` | | `view` | `string \| undefined` | `undefined` | ## Example payload An example payload can be found here on [CodeSandbox](https://codesandbox.io/p/sandbox/github/markmanx/isoflow/tree/main). ## `Icon` ```js { id: string; name: string; url: string; collection?: string; isIsometric?: boolean; } ``` **Notes on Icons:** - `collection` is an optional property that can be used to group icons together in the icon picker. All icons with the same `collection` will be grouped together under the collection's name. - If you are using a non-isometric icon image, you can set `isIsometric` to `false` to render it with an isometric perpective and help it blend into the scene. ## `Color` ```js { id: string; value: string; } ``` **Notes on Colors:** The `value` property accepts any color value in a CSS acceptable format (e.g. a hex value like `#000000`, a string value for example `black` or an rgba value for example `rgba(0, 0, 0, 1)`). ## `Item` ```js { id: string; name: string; description?: string; icon: string; } ``` **Notes on Items:** - The `description` property can accept markdown. - The `icon` property accepts the `id` of an `Icon`. ## `View` ```js { id: string; name: string; description?: string; items: ViewItem[]; rectangles?: Rectangle[]; connectors?: Connector[]; textBoxes?: TextBox[]; } ``` **Notes on Views:** - The `description` property can accept markdown. ## `ViewItem` ```js { id: string; labelHeight?: number; tile: { x: number; y: number; } } ``` **Notes on ViewItems:** - The `id` property accepts the `id` of an `Item` (defined at root level). ## `Connector` ```js { id: string; description?: string; color?: string; width?: number; style?: 'SOLID' | 'DOTTED' | 'DASHED'; anchors: ConnectorAnchor[]; } ``` **Notes on connectors:** - The `color` property accepts an `id` of a `Color` (defined at root level). - A connector needs a minimum of 2 anchors to determine where it starts and ends. If you want more control over the connector's path you can specify additional anchors that the connector will pass through. ## `ConnectorAnchor` ```js id: string; ref: | { tile: { x: number; y: number; } } | { item: string; } ``` **Notes on ConnectorAnchors** - Connector anchors can reference either a `tile` or an `item`. If the reference is to an `item`, the anchor is dynamic and will be tied to the item's position. - When anchoring a connector to an `item`, you must specify the `id` of the of the item being referred to. ## `Rectangle` ```js { id: string; color?: string; from: { x: number; y: number; }; to: { x: number; y: number; }; } ``` ## `TextBox` ```js { id: string; tile: { x: number; y: number; }; content: string; fontSize?: number; orientation?: 'X' | 'Y'; } ``` ## `fitToView` ```js boolean ``` **Notes on fitToView:** - When set to `true`, the scene will automatically fit to the viewport when loaded. - This is useful for ensuring the entire diagram is visible when the component first renders. ## `view` ```js string | undefined ``` **Notes on view:** - Specifies which view to display initially by providing the view's `id`. - If `undefined`, no specific view will be selected on load. - The view must exist in the `views` array for this to work. ## Validation The `initialData` object is validated before Isoflow renders the scene, and an error is thrown if invalid data is detected. Examples of common errors are as follows: - A `ConnectorAnchor` references an `Item` that does not exist. - An `Item` references an `Icon` that does not exist. - A `Rectangle` has a `from` but not a `to` property. - A `Connector` has less than 2 anchors. ================================================ FILE: packages/fossflow-lib/docs/pages/docs/contributing.mdx ================================================ # Contributing ### Branching Strategy: Branches are named using the following convention: - `feature/` for new feature implementations - `fix/` for broken code / build / bug fixes - `chore/` non-breaking & non-fixing code changes such as linting, formatting, etc. ### Commit / PR Strategy: - Commits are to be squashed prior to merge - PRs are to target a singular issue in order to keep the commit history clean and easy to follow ### Deploying to NPM CI is sensitive to any tag pushed to `main` branch. It will build and deploy the app to NPM. To deploy: 1. Bump the version using `npm version patch` or similar 2. `git push && git push --tags` ## License Isoflow is MIT licensed (see [./LICENSE](https://github.com/markmanx/isoflow/blob/main/LICENSE)). ================================================ FILE: packages/fossflow-lib/docs/pages/docs/index.mdx ================================================ # About Isoflow is an open-core project. We offer the [Isoflow Community Edition](https://github.com/markmanx/isoflow) as fully-functional, open-source software under the MIT license. In addition, we also support our development efforts by offering **Isoflow Pro** with additional features for commercial use. You can read more about the differences between Pro and the Community Edition [here](https://isoflow.io/pro-vs-community-edition). ================================================ FILE: packages/fossflow-lib/docs/pages/docs/installation.mdx ================================================ # Installation Isoflow is published as a **React component** you can embed into your project. To install using `npm`: ```bash npm install isoflow ``` or `yarn`: ```bash yarn add isoflow ``` ### Demo The latest version of Isoflow is always synced here on [CodeSandbox](https://codesandbox.io/p/sandbox/github/markmanx/isoflow). ### Running Isoflow in development mode To run Isoflow on your local machine: 1. Clone the [Github repository](https://github.com/markmanx/isoflow). 2. `npm i` 3. `npm run start`. ### Developer documentation For detailed API documentation, examples and more, see the online [developer documentation](https://v2.isoflow.io/docs). You can also build and run the docs locally: - `npm run docs:build` - `npm run docs:start` ================================================ FILE: packages/fossflow-lib/docs/pages/docs/isopacks.mdx ================================================ # Isopacks **Isopacks** are add-on modules for Isoflow that contain icons and other assets. You can easily build your own from scratch or create and load your own. ### Cloud & Network Isopacks These are available as a separately maintained project on [Github](https://github.com/markmanx/isopacks). Below is a sample of icons available in the **Isoflow** Isopack:
server storage switch
In addition, Isopacks for **AWS**, **Azure**, **GCP**, and **Kubernetes** are included. You can choose which Isopacks to import into your app and which to leave out. ### Loading Isopacks into Isoflow 1. Install the `npm` package: ```bash npm i @isoflow/isopacks ``` 2. Import your selected Isopacks: ```jsx showLineNumbers import Isoflow from 'isoflow'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; import awsIsopack from '@isoflow/isopacks/dist/aws'; import gcpIsopack from '@isoflow/isopacks/dist/gcp'; import azureIsopack from '@isoflow/isopacks/dist/azure'; import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes'; const icons = flattenCollections([ isoflowIsopack, awsIsopack, azureIsopack, gcpIsopack, kubernetesIsopack ]); const App = () => { return ( ); } export default App; ``` ## Usage without Isoflow Isopacks can also be used without Isoflow or React (for example, you can simply drag and drop the images into slides or documents, or import into your vanilla Javascript / Typescript project). See the [Isopacks Github project](https://github.com/markmanx/isopacks) for more information. ## Self-hosting vs importing icons While you can import the icon images directly into your JS or TS application, it is recommended that you host the icon images yourself so that they can be lazy-loaded (referencing them via URL from a service like S3 or a CDN). ================================================ FILE: packages/fossflow-lib/docs/pages/docs/quickstart.mdx ================================================ # Quick Start Isoflow can be imported as an ES6 module: ```jsx import Isoflow from "isoflow"; ``` ### Basic usage ```jsx showLineNumbers import React from 'react'; import Isoflow from 'isoflow'; const App = () => { return ( ); } export default App; ``` **Note**: this will display a blank Isoflow editor, without icons (which is not very useful!). To initialise the editor with an iconset, see [Loading Isopacks](/docs/isopacks). ### Dimensions of Isoflow Isoflow takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Isoflow in has non-zero dimensions. ### Integration with NextJS Isoflow cannot be server-side rendered and has to be imported using `next/dynamic`: ```jsx showLineNumbers filename="IsoflowDynamic.jsx" import dynamic from 'next/dynamic'; export const IsoflowDynamic = dynamic(() => { return import('isoflow'); }, { ssr: false } ); ``` ```jsx showLineNumbers filename="App.jsx" import { IsoflowDynamic } from './IsoflowDynamic'; const App = () => { return ( ); } export default App; ``` ================================================ FILE: packages/fossflow-lib/docs/pages/index.tsx ================================================ import { useEffect } from 'react'; import { useRouter } from 'next/router'; export default function Home() { const { push } = useRouter(); useEffect(() => { push('/docs'); }, [push]); return null; } ================================================ FILE: packages/fossflow-lib/docs/theme.config.tsx ================================================ import React from 'react'; export default { darkMode: false, logo: () => { return ( Isoflow Developer Documentation ); }, nextThemes: { defaultTheme: 'light' }, project: { link: 'https://github.com/markmanx/isoflow' }, feedback: { content: null }, editLink: { component: () => { return null; } }, footer: { component: null } }; ================================================ FILE: packages/fossflow-lib/docs/tsconfig.json ================================================ { "compilerOptions": { "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "noEmit": true, "incremental": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] } ================================================ FILE: packages/fossflow-lib/jest.config.js ================================================ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", modulePaths: ['node_modules', ''], setupFilesAfterEnv: ['/jest.setup.js'], moduleNameMapper: { // Force React to resolve from root node_modules to avoid duplicate React instances "^react$": "/../../node_modules/react", "^react-dom$": "/../../node_modules/react-dom", "^react-dom/client$": "/../../node_modules/react-dom/client", "^react/jsx-runtime$": "/../../node_modules/react/jsx-runtime", "^react/jsx-dev-runtime$": "/../../node_modules/react/jsx-dev-runtime" }, testPathIgnorePatterns: [ '/node_modules/', '/dist/', '\\.d\\.ts$' ], coverageDirectory: 'coverage', collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.test.{ts,tsx}', '!src/**/__tests__/**', '!src/types/**', '!src/index.ts' ], coverageThreshold: { global: { branches: 10, functions: 10, lines: 10, statements: 10 } }, coverageReporters: ['json', 'lcov', 'text', 'html'] }; ================================================ FILE: packages/fossflow-lib/jest.setup.js ================================================ require('@testing-library/jest-dom'); ================================================ FILE: packages/fossflow-lib/package.json ================================================ { "name": "fossflow", "version": "1.10.8", "private": false, "description": "An open-source React component for drawing network diagrams - forked from isoflow.", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/stan-smith/FossFLOW.git" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "start": "rslib build --watch", "dev": "rslib build --watch", "build": "rslib build && tsc --project tsconfig.declaration.json && tsc-alias", "build:watch": "rslib build --watch", "test": "jest", "lint": "tsc --noEmit", "clean": "rm -rf dist", "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^5.18.0", "@mui/material": "^5.18.0", "auto-bind": "^5.0.1", "chroma-js": "^3.2.0", "dom-to-image": "^2.6.0", "file-saver": "^2.0.5", "gsap": "^3.14.2", "immer": "^11.1.4", "mui-color-input": "^2.0.3", "paper": "^0.12.18", "pathfinding": "^0.4.18", "react-hook-form": "^7.71.2", "react-quill-new": "^3.8.3", "react-router-dom": "^6.30.2", "uuid": "^9.0.1", "zod": "^3.25.76", "zustand": "^4.5.7" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "devDependencies": { "@isoflow/isopacks": "^0.0.10", "@rsbuild/core": "^1.7.3", "@rsbuild/plugin-react": "^1.4.6", "@rslib/core": "^0.20.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/chroma-js": "^3.1.2", "@types/dom-to-image": "^2.6.7", "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.14", "@types/jsdom": "^28.0.0", "@types/pathfinding": "^0.1.0", "@types/quill": "^2.0.14", "@types/uuid": "^9.0.8", "css-loader": "^7.1.4", "jest": "^29.7.0", "jest-environment-jsdom": "^30.3.0", "jsdom": "^29.0.0", "prettier": "^3.8.1", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.0", "style-loader": "^4.0.0", "ts-jest": "^29.4.6", "tsc-alias": "^1.8.16" } } ================================================ FILE: packages/fossflow-lib/rslib.config.ts ================================================ import { defineConfig } from '@rslib/core'; import { pluginReact } from '@rsbuild/plugin-react'; const packageJson = require('./package.json'); export default defineConfig({ lib: [ { format: 'cjs', syntax: 'es2021', output: { distPath: { root: './dist' }, }, style: { inject: false, }, }, ], plugins: [pluginReact()], source: { entry: { index: './src/index.ts', }, define: { PACKAGE_VERSION: JSON.stringify(packageJson.version), REPOSITORY_URL: JSON.stringify(packageJson.repository.url), }, }, resolve: { alias: { src: './src', components: './src/components', stores: './src/stores', styles: './src/styles', utils: './src/utils', hooks: './src/hooks', types: './src/types', }, }, output: { externals: ['react', 'react-dom'], target: 'node', filename: { css: 'styles.css', }, }, }); ================================================ FILE: packages/fossflow-lib/src/Isoflow.tsx ================================================ import React, { useEffect } from 'react'; import { ThemeProvider } from '@mui/material/styles'; import { Box } from '@mui/material'; import { theme } from 'src/styles/theme'; import { IsoflowProps } from 'src/types'; import { setWindowCursor, modelFromModelStore } from 'src/utils'; import { useModelStore, ModelProvider } from 'src/stores/modelStore'; import { SceneProvider } from 'src/stores/sceneStore'; import { LocaleProvider } from 'src/stores/localeStore'; import { GlobalStyles } from 'src/styles/GlobalStyles'; import { Renderer } from 'src/components/Renderer/Renderer'; import { UiOverlay } from 'src/components/UiOverlay/UiOverlay'; import { UiStateProvider, useUiStateStore } from 'src/stores/uiStateStore'; import { INITIAL_DATA, MAIN_MENU_OPTIONS } from 'src/config'; import { useInitialDataManager } from 'src/hooks/useInitialDataManager'; import enUS from 'src/i18n/en-US'; const App = ({ initialData, mainMenuOptions = MAIN_MENU_OPTIONS, width = '100%', height = '100%', onModelUpdated, enableDebugTools = false, editorMode = 'EDITABLE', renderer, locale = enUS, iconPackManager, }: IsoflowProps) => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const initialDataManager = useInitialDataManager(); const model = useModelStore((state) => { return modelFromModelStore(state); }); const { load } = initialDataManager; useEffect(() => { load({ ...INITIAL_DATA, ...initialData }); }, [initialData, load]); useEffect(() => { uiStateActions.setEditorMode(editorMode); uiStateActions.setMainMenuOptions(mainMenuOptions); }, [editorMode, uiStateActions, mainMenuOptions]); useEffect(() => { return () => { setWindowCursor('default'); }; }, []); useEffect(() => { if (!initialDataManager.isReady || !onModelUpdated) return; onModelUpdated(model); }, [model, initialDataManager.isReady, onModelUpdated]); useEffect(() => { uiStateActions.setEnableDebugTools(enableDebugTools); }, [enableDebugTools, uiStateActions]); useEffect(() => { if (renderer?.expandLabels !== undefined) { uiStateActions.setExpandLabels(renderer.expandLabels); } }, [renderer?.expandLabels, uiStateActions]); useEffect(() => { uiStateActions.setIconPackManager(iconPackManager || null); }, [iconPackManager, uiStateActions]); if (!initialDataManager.isReady) return null; return ( <> ); }; export const Isoflow = (props: IsoflowProps) => { return ( ); }; const useIsoflow = () => { const rendererEl = useUiStateStore((state) => { return state.rendererEl; }); const ModelActions = useModelStore((state) => { return state.actions; }); const uiStateActions = useUiStateStore((state) => { return state.actions; }); return { Model: ModelActions, uiState: uiStateActions, rendererEl }; }; export { useIsoflow }; export * from 'src/standaloneExports'; export default Isoflow; ================================================ FILE: packages/fossflow-lib/src/components/Circle/Circle.tsx ================================================ import React from 'react'; import { Coords } from 'src/types'; interface Props { tile: Coords; radius?: number; } export const Circle = ({ tile, radius, ...rest }: Props & React.SVGProps) => { return ; }; ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/ColorPicker.tsx ================================================ import { MuiColorButtonProps, MuiColorInput, MuiColorInputProps } from 'mui-color-input'; import React from 'react'; import { ColorSwatch } from './ColorSwatch'; interface Props extends Omit {} const ColorButtonElement = ({ bgColor, onClick }: MuiColorButtonProps) => { return ; }; export const ColorPicker = ({ value, onChange }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/ColorSelector.tsx ================================================ import React from 'react'; import { Box } from '@mui/material'; import { useScene } from 'src/hooks/useScene'; import { ColorSwatch } from './ColorSwatch'; interface Props { onChange: (color: string) => void; activeColor?: string; } export const ColorSelector = ({ onChange, activeColor }: Props) => { const { colors } = useScene(); return ( {colors.map((color) => { return ( { return onChange(color.id); }} isActive={activeColor === color.id} /> ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/ColorSwatch.tsx ================================================ import React from 'react'; import { Box, Button } from '@mui/material'; export type Props = { hex: string; isActive?: boolean; onClick: React.MouseEventHandler | undefined; }; export const ColorSwatch = ({ hex, onClick, isActive }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/CustomColorInput.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Box, TextField, IconButton, Tooltip } from '@mui/material'; import { Colorize as ColorizeIcon } from '@mui/icons-material'; import { ColorPicker } from './ColorPicker'; interface EyeDropper { open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>; } declare global { interface Window { EyeDropper?: { new (): EyeDropper; }; } } interface Props { value: string; onChange: (color: string) => void; } export const CustomColorInput = ({ value, onChange }: Props) => { const [localValue, setLocalValue] = useState(value); useEffect(() => { setLocalValue(value); }, [value]); const handleEyeDropper = async () => { if (!window.EyeDropper) return; const eyeDropper = new window.EyeDropper(); try { const result = await eyeDropper.open(); onChange(result.sRGBHex); } catch (e) { // User canceled or failed } }; const handleTextChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setLocalValue(newValue); // If it's a valid hex, update immediately if (/^#[0-9A-F]{6}$/i.test(newValue)) { onChange(newValue); } }; const handleBlur = () => { // On blur, if invalid, revert to prop value if (!/^#[0-9A-F]{6}$/i.test(localValue)) { setLocalValue(value); } }; const hasEyeDropper = typeof window !== 'undefined' && !!window.EyeDropper; return ( {hasEyeDropper && ( )} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/__tests__/ColorSelector.test.tsx ================================================ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ColorSelector } from '../ColorSelector'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; // Mock the useScene hook const mockColors = [ { id: 'color1', value: '#FF0000', name: 'Red' }, { id: 'color2', value: '#00FF00', name: 'Green' }, { id: 'color3', value: '#0000FF', name: 'Blue' }, { id: 'color4', value: '#FFFF00', name: 'Yellow' }, { id: 'color5', value: '#FF00FF', name: 'Magenta' } ]; jest.mock('../../../hooks/useScene', () => ({ useScene: jest.fn(() => ({ colors: mockColors })) })); describe('ColorSelector', () => { const defaultProps = { onChange: jest.fn(), activeColor: undefined }; const renderComponent = (props = {}) => { return render( ); }; beforeEach(() => { jest.clearAllMocks(); }); describe('rendering', () => { it('should render all colors from the scene', () => { renderComponent(); // Should render a button for each color const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); }); it('should render all color swatches', () => { renderComponent(); // Should render a button for each color const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); // Each button should be clickable buttons.forEach((button) => { expect(button).toBeEnabled(); }); }); it('should render empty state when no colors available', () => { const useScene = require('../../../hooks/useScene').useScene; useScene.mockImplementation(() => ({ colors: [] })); const { container } = renderComponent(); // Should render container but no buttons expect(container.firstChild).toBeInTheDocument(); expect(screen.queryAllByRole('button')).toHaveLength(0); // Restore mock useScene.mockImplementation(() => ({ colors: mockColors })); }); }); describe('user interactions', () => { it('should call onChange with correct color ID when clicked', () => { const onChange = jest.fn(); renderComponent({ onChange }); const buttons = screen.getAllByRole('button'); // Click the first color fireEvent.click(buttons[0]); expect(onChange).toHaveBeenCalledWith('color1'); // Click the third color fireEvent.click(buttons[2]); expect(onChange).toHaveBeenCalledWith('color3'); expect(onChange).toHaveBeenCalledTimes(2); }); it('should handle multiple rapid clicks', () => { const onChange = jest.fn(); renderComponent({ onChange }); const buttons = screen.getAllByRole('button'); // Rapidly click different colors fireEvent.click(buttons[0]); fireEvent.click(buttons[1]); fireEvent.click(buttons[2]); fireEvent.click(buttons[1]); expect(onChange).toHaveBeenCalledTimes(4); expect(onChange).toHaveBeenNthCalledWith(1, 'color1'); expect(onChange).toHaveBeenNthCalledWith(2, 'color2'); expect(onChange).toHaveBeenNthCalledWith(3, 'color3'); expect(onChange).toHaveBeenNthCalledWith(4, 'color2'); }); it('should be keyboard accessible', () => { const onChange = jest.fn(); renderComponent({ onChange }); const buttons = screen.getAllByRole('button'); // All buttons should be focusable (have tabIndex) buttons.forEach((button) => { expect(button).toHaveAttribute('tabindex', '0'); }); // Buttons should respond to clicks (keyboard Enter/Space triggers click) fireEvent.click(buttons[0]); expect(onChange).toHaveBeenCalledWith('color1'); fireEvent.click(buttons[1]); expect(onChange).toHaveBeenCalledWith('color2'); }); }); describe('active color indication', () => { it('should indicate the active color with scaled transform', () => { const { container } = renderComponent({ activeColor: 'color2' }); // Find all buttons (color swatches are inside buttons) const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); // The second button should contain the active color // We can check if the active prop was passed correctly const activeButton = buttons[1]; // color2 is at index 1 // Check that ColorSwatch received isActive=true for color2 // Since we can't easily check transform in JSDOM, we'll verify the component renders expect(activeButton).toBeInTheDocument(); }); it('should update active indication when activeColor prop changes', () => { const { rerender } = renderComponent({ activeColor: 'color1' }); let buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); // Change active color rerender( ); buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); // Verify buttons still render after prop change expect(buttons[2]).toBeInTheDocument(); }); it('should handle no active color', () => { renderComponent({ activeColor: undefined }); // All buttons should still render const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); buttons.forEach((button) => { expect(button).toBeInTheDocument(); }); }); it('should handle invalid active color ID gracefully', () => { renderComponent({ activeColor: 'invalid-color-id' }); // All buttons should still render even with invalid active color const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(mockColors.length); buttons.forEach((button) => { expect(button).toBeInTheDocument(); }); }); }); describe('edge cases', () => { it('should handle color with special characters in hex value', () => { const useScene = require('../../../hooks/useScene').useScene; useScene.mockImplementation(() => ({ colors: [ { id: 'special', value: '#C0FFEE', name: 'Coffee' } ] })); const onChange = jest.fn(); renderComponent({ onChange }); const button = screen.getByRole('button'); fireEvent.click(button); expect(onChange).toHaveBeenCalledWith('special'); // Restore mock useScene.mockImplementation(() => ({ colors: mockColors })); }); it('should handle very long color lists efficiently', () => { const useScene = require('../../../hooks/useScene').useScene; const manyColors = Array.from({ length: 100 }, (_, i) => ({ id: `color${i}`, value: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, name: `Color ${i}` })); useScene.mockImplementation(() => ({ colors: manyColors })); const { container } = renderComponent(); const buttons = screen.getAllByRole('button'); expect(buttons).toHaveLength(100); // Restore mock useScene.mockImplementation(() => ({ colors: mockColors })); }); it('should handle onChange being required properly', () => { // onChange is a required prop, so we test with a valid function const onChange = jest.fn(); renderComponent({ onChange }); const buttons = screen.getAllByRole('button'); // Should work normally with onChange provided fireEvent.click(buttons[0]); expect(onChange).toHaveBeenCalledWith('color1'); }); it('should handle colors being updated dynamically', () => { const useScene = require('../../../hooks/useScene').useScene; const onChange = jest.fn(); // Start with 3 colors useScene.mockImplementation(() => ({ colors: mockColors.slice(0, 3) })); const { rerender } = renderComponent({ onChange }); expect(screen.getAllByRole('button')).toHaveLength(3); // Update to 5 colors useScene.mockImplementation(() => ({ colors: mockColors })); rerender( ); expect(screen.getAllByRole('button')).toHaveLength(5); // Click the newly added color const buttons = screen.getAllByRole('button'); fireEvent.click(buttons[4]); expect(onChange).toHaveBeenCalledWith('color5'); }); }); }); ================================================ FILE: packages/fossflow-lib/src/components/ColorSelector/__tests__/CustomColorInput.test.tsx ================================================ import React from 'react'; import { render, screen, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import { CustomColorInput } from '../CustomColorInput'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; // Mock ColorPicker since we don't need to test external library behavior jest.mock('../ColorPicker', () => ({ ColorPicker: ({ value, onChange }: { value: string; onChange: (color: string) => void }) => (
onChange('#FFFFFF')}> {value}
) })); describe('CustomColorInput', () => { const defaultProps = { value: '#FF0000', onChange: jest.fn() }; const renderComponent = (props = {}) => { return render( ); }; beforeEach(() => { jest.clearAllMocks(); }); it('renders correctly with initial value', () => { renderComponent(); const input = screen.getByRole('textbox') as HTMLInputElement; expect(input.value).toBe('#FF0000'); expect(screen.getByTestId('color-picker')).toHaveTextContent('#FF0000'); }); it('updates input value on change', () => { renderComponent(); const input = screen.getByRole('textbox') as HTMLInputElement; fireEvent.change(input, { target: { value: '#00FF00' } }); expect(input.value).toBe('#00FF00'); }); it('calls onChange when valid hex is entered', () => { const onChange = jest.fn(); renderComponent({ onChange }); const input = screen.getByRole('textbox'); fireEvent.change(input, { target: { value: '#00FF00' } }); expect(onChange).toHaveBeenCalledWith('#00FF00'); }); it('does not call onChange when invalid hex is entered', () => { const onChange = jest.fn(); renderComponent({ onChange }); const input = screen.getByRole('textbox'); fireEvent.change(input, { target: { value: 'invalid' } }); expect(onChange).not.toHaveBeenCalled(); }); it('reverts to prop value on blur if input is invalid', () => { renderComponent({ value: '#FF0000' }); const input = screen.getByRole('textbox') as HTMLInputElement; fireEvent.change(input, { target: { value: 'invalid' } }); fireEvent.blur(input); expect(input.value).toBe('#FF0000'); }); it('keeps valid value on blur', () => { const onChange = jest.fn(); renderComponent({ value: '#FF0000', onChange }); const input = screen.getByRole('textbox') as HTMLInputElement; fireEvent.change(input, { target: { value: '#00FF00' } }); fireEvent.blur(input); expect(input.value).toBe('#00FF00'); expect(onChange).toHaveBeenCalledWith('#00FF00'); }); it('updates local state when prop value changes', () => { const { rerender } = renderComponent({ value: '#FF0000' }); const input = screen.getByRole('textbox') as HTMLInputElement; expect(input.value).toBe('#FF0000'); rerender( ); expect(input.value).toBe('#0000FF'); }); describe('EyeDropper interaction', () => { beforeAll(() => { // Mock EyeDropper API Object.defineProperty(window, 'EyeDropper', { writable: true, value: jest.fn().mockImplementation(() => ({ open: jest.fn().mockResolvedValue({ sRGBHex: '#123456' }) })) }); }); afterAll(() => { // @ts-ignore delete window.EyeDropper; }); it('renders eyedropper button when API is supported', () => { renderComponent(); expect(screen.getByRole('button', { name: /pick color/i })).toBeInTheDocument(); }); it('calls onChange with picked color', async () => { const onChange = jest.fn(); renderComponent({ onChange }); const button = screen.getByRole('button', { name: /pick color/i }); await act(async () => { fireEvent.click(button); }); expect(onChange).toHaveBeenCalledWith('#123456'); }); it('handles EyeDropper cancellation gracefully', async () => { const onChange = jest.fn(); // Mock rejection (user cancelled) (window.EyeDropper as any).mockImplementation(() => ({ open: jest.fn().mockRejectedValue(new Error('Canceled')) })); renderComponent({ onChange }); const button = screen.getByRole('button', { name: /pick color/i }); await act(async () => { fireEvent.click(button); }); expect(onChange).not.toHaveBeenCalled(); }); }); describe('EyeDropper unsupported', () => { beforeAll(() => { // @ts-ignore window.EyeDropper = undefined; }); it('does not render eyedropper button when API is not supported', () => { renderComponent(); expect(screen.queryByRole('button', { name: /pick color/i })).not.toBeInTheDocument(); }); }); }); ================================================ FILE: packages/fossflow-lib/src/components/ConnectorEmptySpaceTooltip/ConnectorEmptySpaceTooltip.tsx ================================================ import React, { useState, useEffect, useRef } from 'react'; import { Box, Paper, Typography, Fade } from '@mui/material'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore'; import { useScene } from 'src/hooks/useScene'; import { useTranslation } from 'src/stores/localeStore'; export const ConnectorEmptySpaceTooltip = () => { const { t } = useTranslation('connectorEmptySpaceTooltip'); const [showTooltip, setShowTooltip] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const modeType = useUiStateStore((state) => state.mode.type); const isConnecting = useUiStateStore((state) => state.mode.type === 'CONNECTOR' ? state.mode.isConnecting : false ); const connectorId = useUiStateStore((state) => state.mode.type === 'CONNECTOR' ? state.mode.id : null ); // Get store API for imperative access to mouse position (without subscribing) const storeApi = useUiStateStoreApi(); const { connectors } = useScene(); const previousIsConnectingRef = useRef(isConnecting); const shownForConnectorRef = useRef(null); useEffect(() => { const wasConnecting = previousIsConnectingRef.current; // Detect when we transition from isConnecting to not isConnecting (connection completed) if ( modeType === 'CONNECTOR' && wasConnecting && !isConnecting && !connectorId // After connection is complete, id is set to null ) { // Find the most recently created connector const latestConnector = connectors[connectors.length - 1]; if (latestConnector && latestConnector.id !== shownForConnectorRef.current) { // Check if either end is connected to empty space (tile reference) const hasEmptySpaceConnection = latestConnector.anchors.some( anchor => anchor.ref.tile && !anchor.ref.item ); if (hasEmptySpaceConnection) { // Show tooltip near the mouse position (read imperatively to avoid subscribing) const currentMousePosition = storeApi.getState().mouse.position.screen; setTooltipPosition({ x: currentMousePosition.x, y: currentMousePosition.y }); setShowTooltip(true); shownForConnectorRef.current = latestConnector.id; // Auto-hide after 12 seconds const timer = setTimeout(() => { setShowTooltip(false); }, 12000); return () => clearTimeout(timer); } } } // Hide tooltip when switching away from connector mode if (modeType !== 'CONNECTOR') { setShowTooltip(false); } previousIsConnectingRef.current = isConnecting; }, [modeType, isConnecting, connectorId, connectors, storeApi]); // Remove the click handler - tooltip should persist // It will only hide after timeout or mode change if (!showTooltip) { return null; } return ( {t('message')} {t('instruction')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ConnectorHintTooltip/ConnectorHintTooltip.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Box, IconButton, Paper, Typography, useTheme } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useTranslation } from 'src/stores/localeStore'; const STORAGE_KEY = 'fossflow_connector_hint_dismissed'; interface Props { toolMenuRef?: React.RefObject; } export const ConnectorHintTooltip = ({ toolMenuRef }: Props) => { const { t } = useTranslation('connectorHintTooltip'); const theme = useTheme(); const connectorInteractionMode = useUiStateStore((state) => state.connectorInteractionMode); const modeType = useUiStateStore((state) => state.mode.type); const isConnecting = useUiStateStore((state) => state.mode.type === 'CONNECTOR' ? state.mode.isConnecting : false ); const [isDismissed, setIsDismissed] = useState(true); const [position, setPosition] = useState({ top: 16, right: 16 }); useEffect(() => { // Check if the hint has been dismissed before const dismissed = localStorage.getItem(STORAGE_KEY); if (dismissed !== 'true') { setIsDismissed(false); } }, []); useEffect(() => { // Calculate position based on toolbar if (toolMenuRef?.current) { const toolMenuRect = toolMenuRef.current.getBoundingClientRect(); // Position tooltip below the toolbar with some spacing setPosition({ top: toolMenuRect.bottom + 16, right: 16 }); } else { // Fallback position if no toolbar ref const appPadding = theme.customVars?.appPadding || { x: 16, y: 16 }; setPosition({ top: appPadding.y + 500, // Approximate toolbar height right: appPadding.x }); } }, [toolMenuRef, theme]); const handleDismiss = () => { setIsDismissed(true); localStorage.setItem(STORAGE_KEY, 'true'); }; if (isDismissed) { return null; } return ( {connectorInteractionMode === 'click' ? t('tipCreatingConnectors') : t('tipConnectorTools')} {connectorInteractionMode === 'click' ? ( <> {t('clickInstructionStart')} {t('clickInstructionMiddle')} {t('clickInstructionStart')} {t('clickInstructionEnd')} {modeType === 'CONNECTOR' && isConnecting && ( {t('nowClickTarget')} )} ) : ( <> {t('dragStart')} {t('dragEnd')} )} {t('rerouteStart')} {t('rerouteMiddle')} {t('rerouteEnd')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ConnectorRerouteTooltip/ConnectorRerouteTooltip.tsx ================================================ import React, { useState, useEffect, useRef } from 'react'; import { Box, IconButton, Paper, Typography, Fade } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore'; import { useScene } from 'src/hooks/useScene'; import { useTranslation } from 'src/stores/localeStore'; const STORAGE_KEY = 'fossflow_connector_reroute_hint_dismissed'; export const ConnectorRerouteTooltip = () => { const { t } = useTranslation('connectorRerouteTooltip'); const [showTooltip, setShowTooltip] = useState(false); const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); const modeType = useUiStateStore((state) => state.mode.type); const isConnecting = useUiStateStore((state) => state.mode.type === 'CONNECTOR' ? state.mode.isConnecting : false ); const connectorId = useUiStateStore((state) => state.mode.type === 'CONNECTOR' ? state.mode.id : null ); const storeApi = useUiStateStoreApi(); const { connectors } = useScene(); const previousIsConnectingRef = useRef(isConnecting); const shownForConnectorRef = useRef(null); const [isDismissed, setIsDismissed] = useState(true); useEffect(() => { // Check if the hint has been dismissed before const dismissed = localStorage.getItem(STORAGE_KEY); if (dismissed !== 'true') { setIsDismissed(false); } }, []); useEffect(() => { if (isDismissed) { return; } const wasConnecting = previousIsConnectingRef.current; // Detect when we transition from isConnecting to not isConnecting (connection completed) if ( modeType === 'CONNECTOR' && wasConnecting && !isConnecting && !connectorId // After connection is complete, id is set to null ) { // Find the most recently created connector const latestConnector = connectors[connectors.length - 1]; if (latestConnector && latestConnector.id !== shownForConnectorRef.current) { // Show tooltip near the mouse position (read imperatively) const currentMousePosition = storeApi.getState().mouse.position.screen; setTooltipPosition({ x: currentMousePosition.x, y: currentMousePosition.y }); setShowTooltip(true); shownForConnectorRef.current = latestConnector.id; // Auto-hide after 15 seconds const timer = setTimeout(() => { setShowTooltip(false); }, 15000); return () => clearTimeout(timer); } } // Hide tooltip when switching away from connector mode if (modeType !== 'CONNECTOR') { setShowTooltip(false); } previousIsConnectingRef.current = isConnecting; }, [modeType, isConnecting, connectorId, connectors, isDismissed, storeApi]); const handleDismiss = () => { setShowTooltip(false); setIsDismissed(true); localStorage.setItem(STORAGE_KEY, 'true'); }; if (!showTooltip || isDismissed) { return null; } return ( {t('title')} {t('instructionStart')} {t('instructionSelect')} {t('instructionMiddle')} {t('instructionClick')} {t('instructionAnd')} {t('instructionDrag')} {t('instructionEnd')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ConnectorSettings/ConnectorSettings.tsx ================================================ import React from 'react'; import { Box, FormControl, FormLabel, RadioGroup, FormControlLabel, Radio, Typography, Paper } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useTranslation } from 'src/stores/localeStore'; export const ConnectorSettings = () => { const connectorInteractionMode = useUiStateStore((state) => state.connectorInteractionMode); const setConnectorInteractionMode = useUiStateStore((state) => state.actions.setConnectorInteractionMode); const { t } = useTranslation(); const handleChange = (event: React.ChangeEvent) => { setConnectorInteractionMode(event.target.value as 'click' | 'drag'); }; return ( {t('settings.connector.title')} {t('settings.connector.connectionMode')} } label={ {t('settings.connector.clickMode')} {t('settings.connector.clickModeDesc')} } /> } label={ {t('settings.connector.dragMode')} {t('settings.connector.dragModeDesc')} } sx={{ mt: 1 }} /> {t('settings.connector.note')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ContextMenu/ContextMenu.tsx ================================================ import React from 'react'; import { Menu, MenuItem } from '@mui/material'; interface MenuItemI { label: string; onClick: () => void; } interface Props { onClose: () => void; anchorEl?: HTMLElement | null; menuItems: MenuItemI[]; } export const ContextMenu = ({ onClose, anchorEl, menuItems }: Props) => { return ( {menuItems.map((item, index) => { return {item.label}; })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ContextMenu/ContextMenuManager.tsx ================================================ import React, { useCallback } from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { generateId, findNearestUnoccupiedTile } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; import { useModelStore } from 'src/stores/modelStore'; import { VIEW_ITEM_DEFAULTS } from 'src/config'; import { ContextMenu } from './ContextMenu'; interface Props { anchorEl?: HTMLElement | null; } export const ContextMenuManager = ({ anchorEl }: Props) => { const scene = useScene(); const model = useModelStore((state) => { return state; }); const contextMenu = useUiStateStore((state) => { return state.contextMenu; }); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const onClose = useCallback(() => { uiStateActions.setContextMenu(null); }, [uiStateActions]); return ( { if (!contextMenu) return; if (model.icons.length > 0) { const modelItemId = generateId(); const firstIcon = model.icons[0]; // Find nearest unoccupied tile (should return the same tile since context menu is for empty tiles) const targetTile = findNearestUnoccupiedTile(contextMenu.tile, scene) || contextMenu.tile; scene.placeIcon({ modelItem: { id: modelItemId, name: 'Untitled', icon: firstIcon.id }, viewItem: { ...VIEW_ITEM_DEFAULTS, id: modelItemId, tile: targetTile } }); } onClose(); } }, { label: 'Add Rectangle', onClick: () => { if (!contextMenu) return; if (model.colors.length > 0) { scene.createRectangle({ id: generateId(), color: model.colors[0].id, from: contextMenu.tile, to: contextMenu.tile }); } onClose(); } } ]} /> ); // Remove ITEM context menu since layer ordering only works for rectangles // and provides no value for regular diagram items }; ================================================ FILE: packages/fossflow-lib/src/components/Cursor/Cursor.tsx ================================================ import React, { memo } from 'react'; import chroma from 'chroma-js'; import { useTheme } from '@mui/material'; import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea'; import { useUiStateStore } from 'src/stores/uiStateStore'; export const Cursor = memo(() => { const theme = useTheme(); const tile = useUiStateStore((state) => { return state.mouse.position.tile; }); const zoom = useUiStateStore((state) => { return state.zoom; }); return ( ); }); ================================================ FILE: packages/fossflow-lib/src/components/DOMErrorBoundary/DOMErrorBoundary.tsx ================================================ import React, { Component, ReactNode } from 'react'; interface DOMErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; onError?: (error: Error) => void; } interface DOMErrorBoundaryState { hasError: boolean; errorCount: number; } /** * Error boundary that catches and handles DOM manipulation errors * such as "Failed to execute 'removeChild' on 'Node'" */ class DOMErrorBoundary extends Component { constructor(props: DOMErrorBoundaryProps) { super(props); this.state = { hasError: false, errorCount: 0 }; } static getDerivedStateFromError(error: Error): Partial | null { // Check if this is a DOM manipulation error we're trying to handle if ( error.message.includes('removeChild') || error.message.includes('insertBefore') || error.message.includes('appendChild') || error.message.includes('The node to be removed is not a child') ) { // Return state update to trigger re-render return { hasError: true, errorCount: 0 }; } // For other errors, let them propagate return null; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log the error for debugging purposes if ( error.message.includes('removeChild') || error.message.includes('insertBefore') || error.message.includes('appendChild') || error.message.includes('The node to be removed is not a child') ) { console.warn('DOM manipulation error caught and handled:', { message: error.message, componentStack: errorInfo.componentStack }); // Call optional error callback if (this.props.onError) { this.props.onError(error); } // Prevent infinite error loops by tracking error count this.setState((prevState) => ({ errorCount: prevState.errorCount + 1 })); // If we get too many errors in a row, show fallback if (this.state.errorCount > 3) { console.error('Too many DOM errors, showing fallback'); return; } // Schedule a recovery attempt after the current render cycle setTimeout(() => { this.setState({ hasError: false }); }, 0); } } componentDidUpdate(_prevProps: DOMErrorBoundaryProps, prevState: DOMErrorBoundaryState) { // Reset error state if we successfully rendered after an error if (prevState.hasError && !this.state.hasError) { this.setState({ errorCount: 0 }); } } render() { if (this.state.hasError && this.state.errorCount > 3) { // If too many errors, show fallback or placeholder return ( this.props.fallback || (
Component temporarily unavailable due to rendering errors
) ); } // Normal render or retry after error return this.props.children; } } export default DOMErrorBoundary; ================================================ FILE: packages/fossflow-lib/src/components/DOMErrorBoundary/index.ts ================================================ export { default as DOMErrorBoundary } from './DOMErrorBoundary'; ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/DebugUtils.tsx ================================================ import React from 'react'; import { Box } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; import { useScene } from 'src/hooks/useScene'; import { LineItem } from './LineItem'; export const DebugUtils = () => { const uiState = useUiStateStore( ({ scroll, mouse, zoom, mode, rendererEl }) => { return { scroll, mouse, zoom, mode, rendererEl }; } ); const scene = useScene(); const { size: rendererSize } = useResizeObserver(uiState.rendererEl); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/LineItem.tsx ================================================ import React from 'react'; import { Typography, Box } from '@mui/material'; import { Value } from './Value'; interface Props { title: string; value: string | number; } export const LineItem = ({ title, value }: Props) => { return ( { return `1px solid ${theme.palette.grey[300]}`; } }} > {title} ); }; ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/SizeIndicator.tsx ================================================ import React, { useMemo } from 'react'; import { Box } from '@mui/material'; import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; const BORDER_WIDTH = 6; export const SizeIndicator = () => { const { getUnprojectedBounds } = useDiagramUtils(); const diagramBoundingBox = useMemo(() => { return getUnprojectedBounds(); }, [getUnprojectedBounds]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/Value.tsx ================================================ import React from 'react'; import { Box, Typography } from '@mui/material'; interface Props { value: string; } export const Value = ({ value }: Props) => { return ( { return `1px solid ${theme.palette.grey[400]}`; }, borderRadius: 2, maxWidth: 200 }} > {value} ); }; ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/__tests__/DebugUtils.test.tsx ================================================ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; import { ModelProvider } from 'src/stores/modelStore'; import { SceneProvider } from 'src/stores/sceneStore'; import { UiStateProvider } from 'src/stores/uiStateStore'; import { DebugUtils } from '../DebugUtils'; describe('DebugUtils', () => { const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => { return ( {children} ); }; it('renders without crashing', () => { render( ); expect(screen.getByText('Mouse')).toBeInTheDocument(); }); it('matches snapshot', () => { const { asFragment } = render( ); expect(asFragment()).toMatchSnapshot(); }); }); ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/__tests__/LineItem.test.tsx ================================================ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; import { LineItem } from '../LineItem'; const renderWithTheme = (ui: React.ReactElement) => { return render({ui}); }; describe('LineItem', () => { it('renders title and value', () => { renderWithTheme(); expect(screen.getByText('Test Title')).toBeInTheDocument(); expect(screen.getByText('Test Value')).toBeInTheDocument(); }); it('matches snapshot', () => { const { asFragment } = renderWithTheme( ); expect(asFragment()).toMatchSnapshot(); }); }); ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/__tests__/SizeIndicator.test.tsx ================================================ import React from 'react'; import { render } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; import { ModelProvider } from 'src/stores/modelStore'; import { SceneProvider } from 'src/stores/sceneStore'; import { UiStateProvider } from 'src/stores/uiStateStore'; import { SizeIndicator } from '../SizeIndicator'; describe('SizeIndicator', () => { const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => { return ( {children} ); }; it('renders without crashing', () => { const { container } = render( ); const box = container.querySelector('div'); expect(box).toBeInTheDocument(); expect(box).toHaveStyle('border: 6px solid red'); }); it('matches snapshot', () => { const { asFragment } = render( ); expect(asFragment()).toMatchSnapshot(); }); }); ================================================ FILE: packages/fossflow-lib/src/components/DebugUtils/__tests__/Value.test.tsx ================================================ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; import { Value } from '../Value'; const renderWithTheme = (ui: React.ReactElement) => { return render({ui}); }; describe('Value', () => { it('renders value', () => { renderWithTheme(); expect(screen.getByText('Test Value')).toBeInTheDocument(); }); it('matches snapshot', () => { const { asFragment } = renderWithTheme(); expect(asFragment()).toMatchSnapshot(); }); }); ================================================ FILE: packages/fossflow-lib/src/components/DragAndDrop/DragAndDrop.tsx ================================================ import React, { useMemo } from 'react'; import { Box } from '@mui/material'; import { Coords } from 'src/types'; import { getTilePosition } from 'src/utils'; import { useIcon } from 'src/hooks/useIcon'; interface Props { iconId: string; tile: Coords; } export const DragAndDrop = ({ iconId, tile }: Props) => { const { iconComponent } = useIcon(iconId); const tilePosition = useMemo(() => { return getTilePosition({ tile, origin: 'BOTTOM' }); }, [tile]); return ( {iconComponent} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ExportImageDialog/ExportImageDialog.tsx ================================================ import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react'; import { Dialog, DialogContent, DialogTitle, Box, Button, Stack, Alert, Checkbox, FormControlLabel, Typography, Slider, Select, MenuItem, FormControl } from '@mui/material'; import { useModelStore } from 'src/stores/modelStore'; import { exportAsImage, exportAsSVG, downloadFile as downloadFileUtil, base64ToBlob, generateGenericFilename, modelFromModelStore } from 'src/utils'; import { ModelStore, Size, Coords } from 'src/types'; import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { Isoflow } from 'src/Isoflow'; import { Loader } from 'src/components/Loader/Loader'; import { customVars } from 'src/styles/theme'; import { ColorPicker } from 'src/components/ColorSelector/ColorPicker'; import { DOMErrorBoundary } from 'src/components/DOMErrorBoundary'; interface Props { quality?: number; onClose: () => void; } interface CropArea { x: number; y: number; width: number; height: number; } export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => { const containerRef = useRef(null); const cropCanvasRef = useRef(null); const isExporting = useRef(false); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState(null); const currentView = useUiStateStore((state) => state.view); const [imageData, setImageData] = React.useState(); const [svgData, setSvgData] = useState(); const [croppedImageData, setCroppedImageData] = useState(); const [exportError, setExportError] = useState(false); const { getUnprojectedBounds } = useDiagramUtils(); const uiStateActions = useUiStateStore((state) => state.actions); const model = useModelStore((state): Omit => { return modelFromModelStore(state); }); // Crop states const [cropToContent, setCropToContent] = useState(false); const [cropArea, setCropArea] = useState(null); const [isInCropMode, setIsInCropMode] = useState(false); // Scale/DPI state const [exportScale, setExportScale] = useState(2); const [scaleMode, setScaleMode] = useState<'preset' | 'custom'>('preset'); // DPI presets const dpiPresets = [ { label: '1x (72 DPI)', value: 1 }, { label: '2x (144 DPI)', value: 2 }, { label: '3x (216 DPI)', value: 3 }, { label: '4x (288 DPI)', value: 4 } ]; // Use original bounds for the base image const bounds = useMemo(() => { return getUnprojectedBounds(); }, [getUnprojectedBounds]); // Note: No need to manually set mode here - the hidden Isoflow component // with editorMode="NON_INTERACTIVE" will handle its own mode state const [transparentBackground, setTransparentBackground] = useState(false); const [backgroundColor, setBackgroundColor] = useState( customVars.customPalette.diagramBg ); const exportImage = useCallback(async () => { if (!containerRef.current || isExporting.current) { return; } isExporting.current = true; // Base size without scale (scale is applied via CSS transform) const containerSize = { width: bounds.width, height: bounds.height }; const bgColor = transparentBackground ? 'transparent' : backgroundColor; try { // Export both PNG and SVG in parallel const [pngData, svgDataResult] = await Promise.all([ exportAsImage(containerRef.current as HTMLDivElement, containerSize, exportScale, bgColor), exportAsSVG(containerRef.current as HTMLDivElement, containerSize, bgColor) ]); setImageData(pngData); setSvgData(svgDataResult); isExporting.current = false; } catch (err) { console.error(err); setExportError(true); isExporting.current = false; } }, [bounds, exportScale, transparentBackground, backgroundColor]); // Crop the image based on selected area const cropImage = useCallback((cropArea: CropArea, sourceImage: string) => { return new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = () => { // Calculate the scaling factors between display canvas (500x300) and actual image const displayCanvas = cropCanvasRef.current; if (!displayCanvas) { reject(new Error('Display canvas not found')); return; } const scaleX = img.width / displayCanvas.width; const scaleY = img.height / displayCanvas.height; // Calculate the actual crop area in the source image coordinates const actualCropArea = { x: cropArea.x * scaleX, y: cropArea.y * scaleY, width: cropArea.width * scaleX, height: cropArea.height * scaleY }; // Set canvas size to the actual crop dimensions canvas.width = actualCropArea.width; canvas.height = actualCropArea.height; if (ctx) { // Draw the cropped portion from the source image ctx.drawImage( img, actualCropArea.x, actualCropArea.y, actualCropArea.width, actualCropArea.height, 0, 0, actualCropArea.width, actualCropArea.height ); resolve(canvas.toDataURL('image/png')); } else { reject(new Error('Could not get canvas context')); } }; img.onerror = () => reject(new Error('Failed to load image')); img.src = sourceImage; }); }, []); // Handle crop area generation - only when not in crop mode (after applying) useEffect(() => { if (cropToContent && cropArea && imageData && !isInCropMode) { cropImage(cropArea, imageData) .then(setCroppedImageData) .catch(console.error); } else if (!cropToContent || !cropArea) { setCroppedImageData(undefined); } }, [cropArea, imageData, cropToContent, cropImage, isInCropMode]); // Mouse handlers for crop selection const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!isInCropMode) return; e.preventDefault(); const canvas = cropCanvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; setDragStart({ x, y }); setIsDragging(true); setCropArea(null); }, [isInCropMode]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging || !dragStart || !isInCropMode) return; e.preventDefault(); const canvas = cropCanvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const newCropArea: CropArea = { x: Math.min(dragStart.x, x), y: Math.min(dragStart.y, y), width: Math.abs(x - dragStart.x), height: Math.abs(y - dragStart.y) }; setCropArea(newCropArea); }, [isDragging, dragStart, isInCropMode]); const handleMouseUp = useCallback((e: React.MouseEvent) => { if (!isDragging) return; e.preventDefault(); setIsDragging(false); setDragStart(null); }, [isDragging]); // Add mouse leave handler to stop dragging when leaving canvas const handleMouseLeave = useCallback(() => { setIsDragging(false); setDragStart(null); }, []); // Draw crop overlay useEffect(() => { const canvas = cropCanvasRef.current; if (!canvas || !imageData) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const img = new Image(); img.onload = () => { // Calculate scaling factors between canvas and actual image const scaleX = img.width / canvas.width; const scaleY = img.height / canvas.height; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw checkerboard if transparent background if (transparentBackground) { const squareSize = 10; for (let y = 0; y < canvas.height; y += squareSize) { for (let x = 0; x < canvas.width; x += squareSize) { ctx.fillStyle = (x / squareSize + y / squareSize) % 2 === 0 ? '#f0f0f0' : 'transparent'; ctx.fillRect(x, y, squareSize, squareSize); } } } // Draw the image scaled to fit canvas ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // Draw crop overlay if in crop mode if (isInCropMode) { // Semi-transparent overlay ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Clear crop area and draw border only if there's a valid selection if (cropArea && cropArea.width > 5 && cropArea.height > 5) { // Clear the selected area (remove overlay) ctx.clearRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height); // Redraw the original image in the selected area ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // Redraw the overlay everywhere except the selected area ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; // Top area if (cropArea.y > 0) { ctx.fillRect(0, 0, canvas.width, cropArea.y); } // Bottom area if (cropArea.y + cropArea.height < canvas.height) { ctx.fillRect(0, cropArea.y + cropArea.height, canvas.width, canvas.height - (cropArea.y + cropArea.height)); } // Left area if (cropArea.x > 0) { ctx.fillRect(0, cropArea.y, cropArea.x, cropArea.height); } // Right area if (cropArea.x + cropArea.width < canvas.width) { ctx.fillRect(cropArea.x + cropArea.width, cropArea.y, canvas.width - (cropArea.x + cropArea.width), cropArea.height); } ctx.restore(); // Draw crop border ctx.strokeStyle = '#2196f3'; ctx.lineWidth = 2; ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height); } // Add instruction text only when no selection or dragging if (!cropArea || cropArea.width <= 5 || cropArea.height <= 5) { ctx.fillStyle = 'white'; ctx.font = '14px Arial'; ctx.textAlign = 'left'; ctx.fillText('Click and drag to select crop area', 10, 25); } } }; img.src = imageData; }, [imageData, isInCropMode, cropArea, transparentBackground]); const [showGrid, setShowGrid] = useState(false); const handleShowGridChange = (checked: boolean) => { setShowGrid(checked); }; const [expandLabels, setExpandLabels] = useState(true); const handleExpandLabelsChange = (checked: boolean) => { setExpandLabels(checked); }; const handleTransparentBackgroundChange = (checked: boolean) => { setTransparentBackground(checked); if (checked) { setBackgroundColor('transparent'); } else { setBackgroundColor(customVars.customPalette.diagramBg); } }; const handleBackgroundColorChange = (color: string) => { setBackgroundColor(color); }; const handleCropToContentChange = (checked: boolean) => { setCropToContent(checked); if (checked) { setIsInCropMode(true); setCropArea(null); setCroppedImageData(undefined); setIsDragging(false); setDragStart(null); } else { setIsInCropMode(false); setCropArea(null); setCroppedImageData(undefined); setIsDragging(false); setDragStart(null); } }; const handleRecrop = () => { setIsInCropMode(true); setCropArea(null); setCroppedImageData(undefined); setIsDragging(false); setDragStart(null); }; const handleAcceptCrop = () => { setIsInCropMode(false); }; // Reset image data when non-crop options change useEffect(() => { if (!cropToContent) { setImageData(undefined); setSvgData(undefined); setExportError(false); isExporting.current = false; const timer = setTimeout(() => { exportImage(); }, 200); return () => clearTimeout(timer); } }, [showGrid, backgroundColor, expandLabels, exportImage, cropToContent, exportScale, transparentBackground]); useEffect(() => { if (!imageData) { const timer = setTimeout(() => { exportImage(); }, 200); return () => clearTimeout(timer); } }, [exportImage, imageData]); const downloadFile = useCallback(() => { const dataToDownload = croppedImageData || imageData; if (!dataToDownload) return; const data = base64ToBlob( dataToDownload.replace('data:image/png;base64,', ''), 'image/png;charset=utf-8' ); downloadFileUtil(data, generateGenericFilename('png')); }, [imageData, croppedImageData]); const downloadSvgFile = useCallback(async () => { if (!svgData) return; try { // Fetch the data URL as a blob to handle encoding properly const response = await fetch(svgData); const blob = await response.blob(); downloadFileUtil(blob, generateGenericFilename('svg')); } catch (error) { console.error('SVG download failed:', error); setExportError(true); } }, [svgData]); const displayImage = croppedImageData || imageData; return ( Export as image Browser Compatibility Notice
For best results, please use Chrome or Edge. Firefox currently has compatibility issues with the export feature.
{!imageData && ( <> )} {displayImage && ( {cropToContent && !croppedImageData ? ( e.preventDefault()} /> {isInCropMode && ( Click and drag to select the area you want to export )} ) : ( )} )} Options { handleShowGridChange(event.target.checked); }} /> } /> { handleExpandLabelsChange(event.target.checked); }} /> } /> { handleCropToContentChange(event.target.checked); }} /> } /> } /> { handleTransparentBackgroundChange(event.target.checked); }} /> } /> Export Quality (DPI) {scaleMode === 'custom' && ( Scale: {exportScale.toFixed(1)}x ({(exportScale * 72).toFixed(0)} DPI) setExportScale(value as number)} min={1} max={5} step={0.1} marks={[ { value: 1, label: '1x' }, { value: 2, label: '2x' }, { value: 3, label: '3x' }, { value: 4, label: '4x' }, { value: 5, label: '5x' } ]} valueLabelDisplay="auto" valueLabelFormat={(value) => `${value.toFixed(1)}x`} /> )} {/* Crop controls */} {cropToContent && imageData && ( {croppedImageData ? ( Crop applied successfully ) : cropArea ? ( ) : isInCropMode ? ( Select an area to crop, or uncheck "Crop to content" to use full image ) : null} )} {displayImage && ( )} {exportError && ( Could not export image )}
); }; ================================================ FILE: packages/fossflow-lib/src/components/FreehandLasso/FreehandLasso.tsx ================================================ import React, { useMemo } from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { createSmoothPath } from 'src/utils'; export const FreehandLasso = () => { const modeType = useUiStateStore((state) => state.mode.type); const path = useUiStateStore((state) => state.mode.type === 'FREEHAND_LASSO' ? state.mode.path : [] ); const rendererEl = useUiStateStore((state) => state.rendererEl); const rendererSize = rendererEl?.getBoundingClientRect(); const smoothPath = useMemo(() => { if (modeType !== 'FREEHAND_LASSO' || path.length < 2) { return ''; } return createSmoothPath(path); }, [modeType, path]); if (modeType !== 'FREEHAND_LASSO' || path.length < 2) { return null; } const width = rendererSize?.width || 0; const height = rendererSize?.height || 0; return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/Gradient/Gradient.tsx ================================================ import React from 'react'; import { Box, SxProps } from '@mui/material'; interface Props { sx?: SxProps; } export const Gradient = ({ sx }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/Grid/Grid.tsx ================================================ import React, { useEffect, useRef, useState } from 'react'; import { Box } from '@mui/material'; import gsap from 'gsap'; import { Size } from 'src/types'; import gridTileSvg from 'src/assets/grid-tile-bg.svg'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { PROJECTED_TILE_SIZE } from 'src/config'; import { SizeUtils } from 'src/utils/SizeUtils'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; export const Grid = () => { const elementRef = useRef(null); const { size } = useResizeObserver(elementRef.current); const [isFirstRender, setIsFirstRender] = useState(true); const scroll = useUiStateStore((state) => { return state.scroll; }); const zoom = useUiStateStore((state) => { return state.zoom; }); useEffect(() => { if (!elementRef.current) return; const tileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom); const elSize = elementRef.current.getBoundingClientRect(); const backgroundPosition: Size = { width: elSize.width / 2 + scroll.position.x + tileSize.width / 2, height: elSize.height / 2 + scroll.position.y }; gsap.to(elementRef.current, { duration: isFirstRender ? 0 : 0.016, // ~1 frame at 60fps for smooth motion ease: 'none', // Linear easing for immediate response backgroundSize: `${tileSize.width}px ${tileSize.height * 2}px`, backgroundPosition: `${backgroundPosition.width}px ${backgroundPosition.height}px` }); if (isFirstRender) { setIsFirstRender(false); } }, [scroll, zoom, isFirstRender, size]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/HelpDialog/HelpDialog.tsx ================================================ import React from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Typography, Box, Divider } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { DialogTypeEnum } from 'src/types/ui'; import { useTranslation } from 'src/stores/localeStore'; interface ShortcutItem { action: string; shortcut: string; description: string; } export const HelpDialog = () => { const { t } = useTranslation('helpDialog'); const dialog = useUiStateStore((state) => { return state.dialog; }); const setDialog = useUiStateStore((state) => { return state.actions.setDialog; }); const isOpen = dialog === DialogTypeEnum.HELP; const handleClose = () => { setDialog(null); }; const keyboardShortcuts = [ { action: t('undoAction'), shortcut: 'Ctrl+Z', description: t('undoDescription') }, { action: t('redoAction'), shortcut: 'Ctrl+Y', description: t('redoDescription') }, { action: t('redoAltAction'), shortcut: 'Ctrl+Shift+Z', description: t('redoAltDescription') }, { action: t('helpAction'), shortcut: 'F1', description: t('helpDescription') }, { action: t('zoomInAction'), shortcut: t('zoomInShortcut'), description: t('zoomInDescription') }, { action: t('zoomOutAction'), shortcut: t('zoomOutShortcut'), description: t('zoomOutDescription') }, { action: t('panCanvasAction'), shortcut: t('panCanvasShortcut'), description: t('panCanvasDescription') }, { action: t('contextMenuAction'), shortcut: t('contextMenuShortcut'), description: t('contextMenuDescription') } ]; const mouseInteractions = [ { action: t('selectToolAction'), shortcut: t('selectToolShortcut'), description: t('selectToolDescription') }, { action: t('panToolAction'), shortcut: t('panToolShortcut'), description: t('panToolDescription') }, { action: t('addItemAction'), shortcut: t('addItemShortcut'), description: t('addItemDescription') }, { action: t('drawRectangleAction'), shortcut: t('drawRectangleShortcut'), description: t('drawRectangleDescription') }, { action: t('createConnectorAction'), shortcut: t('createConnectorShortcut'), description: t('createConnectorDescription') }, { action: t('addTextAction'), shortcut: t('addTextShortcut'), description: t('addTextDescription') } ]; return ( {t('title')} {t('keyboardShortcuts')} {t('action')} {t('shortcut')} {t('description')} {keyboardShortcuts.map((shortcut, index) => { return ( {shortcut.action} {shortcut.shortcut} {shortcut.description} ); })}
{t('mouseInteractions')} {t('action')} {t('method')} {t('description')} {mouseInteractions.map((interaction, index) => { return ( {interaction.action} {interaction.shortcut} {interaction.description} ); })}
{t('note')} {t('noteContent')}
); }; ================================================ FILE: packages/fossflow-lib/src/components/HotkeySettings/HotkeySettings.tsx ================================================ import React from 'react'; import { Box, Select, MenuItem, FormControl, InputLabel, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { HOTKEY_PROFILES, HotkeyProfile } from 'src/config/hotkeys'; import { useTranslation } from 'src/stores/localeStore'; export const HotkeySettings = () => { const hotkeyProfile = useUiStateStore((state) => state.hotkeyProfile); const setHotkeyProfile = useUiStateStore((state) => state.actions.setHotkeyProfile); const { t } = useTranslation(); const currentMapping = HOTKEY_PROFILES[hotkeyProfile]; const tools = [ { name: t('settings.hotkeys.toolSelect'), key: currentMapping.select }, { name: t('settings.hotkeys.toolPan'), key: currentMapping.pan }, { name: t('settings.hotkeys.toolAddItem'), key: currentMapping.addItem }, { name: t('settings.hotkeys.toolRectangle'), key: currentMapping.rectangle }, { name: t('settings.hotkeys.toolConnector'), key: currentMapping.connector }, { name: t('settings.hotkeys.toolText'), key: currentMapping.text } ]; return ( {t('settings.hotkeys.title')} {t('settings.hotkeys.profile')} {hotkeyProfile !== 'none' && ( {t('settings.hotkeys.tool')} {t('settings.hotkeys.hotkey')} {tools.map((tool) => ( {tool.name} {tool.key ? tool.key.toUpperCase() : '-'} ))}
)} {t('settings.hotkeys.note')}
); }; ================================================ FILE: packages/fossflow-lib/src/components/IconButton/IconButton.tsx ================================================ import React, { useMemo } from 'react'; import { Button, Box, useTheme } from '@mui/material'; import Tooltip, { TooltipProps } from '@mui/material/Tooltip'; interface Props { name: string; Icon: React.ReactNode; isActive?: boolean; onClick: (e: React.MouseEvent) => void; tooltipPosition?: TooltipProps['placement']; disabled?: boolean; } export const IconButton = ({ name, Icon, onClick, isActive = false, disabled = false, tooltipPosition = 'bottom' }: Props) => { const theme = useTheme(); const iconColor = useMemo(() => { if (isActive) { return 'grey.200'; } if (disabled) { return 'grey.800'; } return 'grey.500'; }, [disabled, isActive]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/IconPackSettings/IconPackSettings.tsx ================================================ import React from 'react'; import { Box, FormControl, FormLabel, FormControlLabel, Switch, Checkbox, Typography, Paper, CircularProgress, Alert, Divider } from '@mui/material'; import { useTranslation } from 'src/stores/localeStore'; export interface IconPackSettingsProps { lazyLoadingEnabled: boolean; onToggleLazyLoading: (enabled: boolean) => void; packInfo: Array<{ name: string; displayName: string; loaded: boolean; loading: boolean; error: string | null; iconCount: number; }>; enabledPacks: string[]; onTogglePack: (packName: string, enabled: boolean) => void; } export const IconPackSettings: React.FC = ({ lazyLoadingEnabled, onToggleLazyLoading, packInfo, enabledPacks, onTogglePack }) => { const { t } = useTranslation(); const handleLazyLoadingChange = (event: React.ChangeEvent) => { onToggleLazyLoading(event.target.checked); }; const handlePackToggle = (packName: string) => (event: React.ChangeEvent) => { onTogglePack(packName, event.target.checked); }; return ( {t('settings.iconPacks.title')} {/* Lazy Loading Toggle */} {t('settings.iconPacks.lazyLoading')} {t('settings.iconPacks.lazyLoadingDesc')} {/* Core Isoflow (Always Loaded) */} {t('settings.iconPacks.coreIsoflow')} {t('settings.iconPacks.alwaysEnabled')} {/* Available Icon Packs */} {t('settings.iconPacks.availablePacks')} {!lazyLoadingEnabled && ( {t('settings.iconPacks.lazyLoadingDisabledNote')} )} {packInfo.map((pack) => ( {pack.displayName} {pack.loading && ( <> {t('settings.iconPacks.loading')} )} {pack.loaded && !pack.loading && ( {t('settings.iconPacks.loaded')} • {t('settings.iconPacks.iconCount').replace('{count}', String(pack.iconCount))} )} {pack.error && ( {pack.error} )} {!pack.loaded && !pack.loading && !pack.error && ( {t('settings.iconPacks.notLoaded')} )} ))} {t('settings.iconPacks.note')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ImportHintTooltip/ImportHintTooltip.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Box, IconButton, Paper, Typography } from '@mui/material'; import { Close as CloseIcon, FolderOpen as FolderOpenIcon } from '@mui/icons-material'; import { useTranslation } from 'src/stores/localeStore'; const STORAGE_KEY = 'fossflow_import_hint_dismissed'; export const ImportHintTooltip = () => { const { t } = useTranslation('importHintTooltip'); const [isDismissed, setIsDismissed] = useState(true); useEffect(() => { // Check if the hint has been dismissed before const dismissed = localStorage.getItem(STORAGE_KEY); if (dismissed !== 'true') { setIsDismissed(false); } }, []); const handleDismiss = () => { setIsDismissed(true); localStorage.setItem(STORAGE_KEY, 'true'); }; if (isDismissed) { return null; } return ( {t('title')} {t('instructionStart')} {t('menuButton')} {t('instructionMiddle')} {t('openButton')} {t('instructionEnd')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/IsoTileArea/IsoTileArea.tsx ================================================ import React, { useMemo, memo } from 'react'; import { Coords } from 'src/types'; import { Svg } from 'src/components/Svg/Svg'; import { useIsoProjection } from 'src/hooks/useIsoProjection'; interface Props { from: Coords; to: Coords; origin?: Coords; fill?: string; cornerRadius?: number; stroke?: { width: number; color: string; dashArray?: string; }; } export const IsoTileArea = memo(({ from, to, fill = 'none', cornerRadius = 0, stroke }: Props) => { const { css, pxSize } = useIsoProjection({ from, to }); const strokeParams = useMemo(() => { if (!stroke) return {}; const params: Record = { stroke: stroke.color, strokeWidth: stroke.width }; if (stroke.dashArray) { params.strokeDasharray = stroke.dashArray; } return params; }, [stroke]); return ( ); }); ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/ConnectorControls/ConnectorControls.tsx ================================================ import React, { useState, useMemo } from 'react'; import { Connector, ConnectorLabel, connectorStyleOptions, connectorLineTypeOptions } from 'src/types'; import { Box, Slider, Select, MenuItem, TextField, IconButton as MUIIconButton, FormControlLabel, Switch, Typography, Button, Paper } from '@mui/material'; import { useConnector } from 'src/hooks/useConnector'; import { ColorSelector } from 'src/components/ColorSelector/ColorSelector'; import { ColorPicker } from 'src/components/ColorSelector/ColorPicker'; import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useScene } from 'src/hooks/useScene'; import { Close as CloseIcon, Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'; import { getConnectorLabels, generateId } from 'src/utils'; import { ControlsContainer } from '../components/ControlsContainer'; import { Section } from '../components/Section'; import { DeleteButton } from '../components/DeleteButton'; interface Props { id: string; } export const ConnectorControls = ({ id }: Props) => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const connector = useConnector(id); const { updateConnector, deleteConnector } = useScene(); const [useCustomColor, setUseCustomColor] = useState( !!connector?.customColor ); // Get all labels (including migrated legacy labels) const labels = useMemo(() => { if (!connector) return []; return getConnectorLabels(connector); }, [connector]); // If connector doesn't exist, return null if (!connector) { return null; } const isDoubleLineType = connector.lineType === 'DOUBLE' || connector.lineType === 'DOUBLE_WITH_CIRCLE'; const handleAddLabel = () => { if (labels.length >= 256) return; const newLabel: ConnectorLabel = { id: generateId(), text: '', position: 50, height: 0, line: '1' }; // Migrate legacy labels if needed and add new label const updatedLabels = [...labels, newLabel]; updateConnector(connector.id, { labels: updatedLabels, // Clear legacy fields on first new label addition description: undefined, startLabel: undefined, endLabel: undefined, startLabelHeight: undefined, centerLabelHeight: undefined, endLabelHeight: undefined }); }; const handleUpdateLabel = ( labelId: string, updates: Partial ) => { const updatedLabels = labels.map((label) => { return label.id === labelId ? { ...label, ...updates } : label; }); updateConnector(connector.id, { labels: updatedLabels, // Clear legacy fields description: undefined, startLabel: undefined, endLabel: undefined, startLabelHeight: undefined, centerLabelHeight: undefined, endLabelHeight: undefined }); }; const handleDeleteLabel = (labelId: string) => { const updatedLabels = labels.filter((label) => { return label.id !== labelId; }); updateConnector(connector.id, { labels: updatedLabels, // Clear legacy fields description: undefined, startLabel: undefined, endLabel: undefined, startLabelHeight: undefined, centerLabelHeight: undefined, endLabelHeight: undefined }); }; return ( {/* Close button */} { return uiStateActions.setItemControls(null); }} sx={{ position: 'absolute', top: 16, right: 16, zIndex: 2 }} size="small" >
{labels.length} / 256 labels {labels.length === 0 && ( No labels. Click "Add Label" to create one. )} {labels.map((label, index) => { return ( Label {index + 1} { return handleDeleteLabel(label.id); }} color="error" > { return handleUpdateLabel(label.id, { text: e.target.value }); }} fullWidth sx={{ mb: 2 }} /> { const inputValue = e.target.value; // Allow empty input if (inputValue === '') { handleUpdateLabel(label.id, { position: 0 }); return; } const value = parseInt(inputValue, 10); if (!Number.isNaN(value)) { handleUpdateLabel(label.id, { position: Math.max(0, Math.min(100, value)) }); } }} onBlur={(e) => { // On blur, ensure we have a valid value if (e.target.value === '') { handleUpdateLabel(label.id, { position: 0 }); } }} inputProps={{ min: 0, max: 100 }} sx={{ flex: 1 }} /> {isDoubleLineType && ( )} Height Offset { return handleUpdateLabel(label.id, { height: value as number }); }} /> { return handleUpdateLabel(label.id, { showLine: e.target.checked }); }} /> } label="Show Dotted Line" /> ); })}
{ setUseCustomColor(e.target.checked); if (!e.target.checked) { updateConnector(connector.id, { customColor: '' }); } }} /> } label="Use Custom Color" sx={{ mb: 2 }} /> {useCustomColor ? ( { updateConnector(connector.id, { customColor: color }); }} /> ) : ( { return updateConnector(connector.id, { color, customColor: '' }); }} activeColor={connector.color} /> )}
{ updateConnector(connector.id, { width: newWidth as number }); }} />
{ updateConnector(connector.id, { showArrow: e.target.checked }); }} /> } label="Show Arrow" />
{ uiStateActions.setItemControls(null); deleteConnector(connector.id); }} />
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/Icon.tsx ================================================ import React from 'react'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import { Button, Typography } from '@mui/material'; import { Icon as IconI } from 'src/types'; const SIZE = 50; interface Props { icon: IconI; onClick?: () => void; onMouseDown?: () => void; onDoubleClick?: () => void; } export const Icon = ({ icon, onClick, onMouseDown, onDoubleClick }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/IconCollection.tsx ================================================ import React, { useState } from 'react'; import { Divider, Stack, Typography, Button } from '@mui/material'; import { ExpandMore as ChevronDownIcon, ExpandLess as ChevronUpIcon } from '@mui/icons-material'; import { Icon as IconI } from 'src/types'; import { Section } from 'src/components/ItemControls/components/Section'; import { IconGrid } from './IconGrid'; interface Props { id?: string; icons: IconI[]; onClick?: (icon: IconI) => void; onMouseDown?: (icon: IconI) => void; isExpanded: boolean; } export const IconCollection = ({ id, icons, onClick, onMouseDown, isExpanded: _isExpanded }: Props) => { const [isExpanded, setIsExpanded] = useState(_isExpanded); return (
{isExpanded && ( )}
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/IconGrid.tsx ================================================ import React from 'react'; import { Icon as IconI } from 'src/types'; import { Grid, Box } from '@mui/material'; import { Icon } from './Icon'; interface Props { icons: IconI[]; onMouseDown?: (icon: IconI) => void; onClick?: (icon: IconI) => void; onDoubleClick?: (icon: IconI) => void; hoveredIndex?: number; onHover?: (index: number) => void; } export const IconGrid = ({ icons, onMouseDown, onClick, onDoubleClick, hoveredIndex, onHover }: Props) => { return ( {icons.map((icon, index) => { const isHovered = hoveredIndex === index; return ( onHover?.(index)} > { onClick?.(icon); }} onMouseDown={() => { onMouseDown?.(icon); }} onDoubleClick={() => { onDoubleClick?.(icon); }} /> ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/IconSelectionControls.tsx ================================================ import React, { useCallback, useRef, useState } from 'react'; import { Stack, Alert, IconButton as MUIIconButton, Box, Button, FormControlLabel, Checkbox, Typography, Slider } from '@mui/material'; import { ControlsContainer } from 'src/components/ItemControls/components/ControlsContainer'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useModelStore } from 'src/stores/modelStore'; import { Icon } from 'src/types'; import { Section } from 'src/components/ItemControls/components/Section'; import { Searchbox } from 'src/components/ItemControls/IconSelectionControls/Searchbox'; import { useIconFiltering } from 'src/hooks/useIconFiltering'; import { useIconCategories } from 'src/hooks/useIconCategories'; import { Close as CloseIcon, FileUpload as FileUploadIcon } from '@mui/icons-material'; import { Icons } from './Icons'; import { IconGrid } from './IconGrid'; import { generateId } from 'src/utils'; export const IconSelectionControls = () => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const mode = useUiStateStore((state) => { return state.mode; }); const iconCategoriesState = useUiStateStore((state) => state.iconCategoriesState); const modelActions = useModelStore((state) => state.actions); const currentIcons = useModelStore((state) => state.icons); const { setFilter, filteredIcons, filter } = useIconFiltering(); const { iconCategories } = useIconCategories(); const fileInputRef = useRef(null); const [treatAsIsometric, setTreatAsIsometric] = useState(true); const [iconScale, setIconScale] = useState(100); const [showAlert, setShowAlert] = useState(() => { // Check localStorage to see if user has dismissed the alert return localStorage.getItem('fossflow-show-drag-hint') !== 'false'; }); const onMouseDown = useCallback( (icon: Icon) => { if (mode.type !== 'PLACE_ICON') return; uiStateActions.setMode({ type: 'PLACE_ICON', showCursor: true, id: icon.id }); }, [mode, uiStateActions] ); const handleImportClick = useCallback(() => { fileInputRef.current?.click(); }, []); const dismissAlert = useCallback(() => { setShowAlert(false); localStorage.setItem('fossflow-show-drag-hint', 'false'); }, []); const handleFileSelect = useCallback(async (event: React.ChangeEvent) => { const files = event.target.files; if (!files || files.length === 0) return; const newIcons: Icon[] = []; const existingNames = new Set(currentIcons.map(icon => icon.name.toLowerCase())); for (let i = 0; i < files.length; i++) { const file = files[i]; // Check if file is an image if (!file.type.startsWith('image/')) { console.warn(`Skipping non-image file: ${file.name}`); continue; } // Generate unique name let baseName = file.name.replace(/\.[^/.]+$/, ''); // Remove extension let finalName = baseName; let counter = 1; while (existingNames.has(finalName.toLowerCase())) { finalName = `${baseName}_${counter}`; counter++; } existingNames.add(finalName.toLowerCase()); // Load and scale the image const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async (e) => { const originalDataUrl = e.target?.result as string; // For SVG files, use as-is since they scale naturally if (file.type === 'image/svg+xml') { resolve(originalDataUrl); return; } // For raster images, scale them to fit in a square bounding box const img = new Image(); img.onload = () => { // Create canvas for scaling const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) { resolve(originalDataUrl); // Fallback to original return; } // Use a square target size for consistent display // This ensures all icons have the same bounding box const TARGET_SIZE = 128; // Square size for consistency // Calculate scaling to fit within square while maintaining aspect ratio const basScale = Math.min(TARGET_SIZE / img.width, TARGET_SIZE / img.height); // Apply user's custom scaling const finalScale = basScale * (iconScale / 100); const scaledWidth = img.width * finalScale; const scaledHeight = img.height * finalScale; // Set canvas to square size canvas.width = TARGET_SIZE; canvas.height = TARGET_SIZE; // Clear canvas with transparent background ctx.clearRect(0, 0, TARGET_SIZE, TARGET_SIZE); // Calculate position to center the image in the square const x = (TARGET_SIZE - scaledWidth) / 2; const y = (TARGET_SIZE - scaledHeight) / 2; // Enable image smoothing for better quality ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // Draw scaled and centered image ctx.drawImage(img, x, y, scaledWidth, scaledHeight); // Convert to data URL (using PNG for transparency) resolve(canvas.toDataURL('image/png')); }; img.onerror = () => reject(new Error('Failed to load image')); img.src = originalDataUrl; }; reader.onerror = reject; reader.readAsDataURL(file); }); newIcons.push({ id: generateId(), name: finalName, url: dataUrl, collection: 'imported', isIsometric: treatAsIsometric // Use user's preference }); } if (newIcons.length > 0) { // Add new icons to the model const updatedIcons = [...currentIcons, ...newIcons]; modelActions.set({ icons: updatedIcons }); // Update icon categories to include imported collection const hasImported = iconCategoriesState.some(cat => cat.id === 'imported'); if (!hasImported) { uiStateActions.setIconCategoriesState([ ...iconCategoriesState, { id: 'imported', isExpanded: true } ]); } } // Reset input event.target.value = ''; }, [currentIcons, modelActions, iconCategoriesState, uiStateActions, treatAsIsometric, iconScale]); return ( {/* Close button */} { return uiStateActions.setItemControls(null); }} sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2, padding: 0, background: 'none' }} size="small" > } > {filteredIcons && (
)} {!filteredIcons && ( )}
setTreatAsIsometric(e.target.checked)} size="small" /> } label={ Treat as isometric (3D view) } sx={{ mt: 1, ml: 0 }} /> Uncheck for flat icons (logos, UI elements) {showAlert && ( You can drag and drop any item below onto the canvas. )}
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/Icons.tsx ================================================ import React from 'react'; import { Grid } from '@mui/material'; import { IconCollectionStateWithIcons, Icon } from 'src/types'; import { IconCollection } from './IconCollection'; interface Props { iconCategories: IconCollectionStateWithIcons[]; onClick?: (icon: Icon) => void; onMouseDown?: (icon: Icon) => void; } export const Icons = ({ iconCategories, onClick, onMouseDown }: Props) => { return ( {iconCategories.map((cat) => { return ( ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/Searchbox.tsx ================================================ import React from 'react'; import { TextField, InputAdornment } from '@mui/material'; import { Search as SearchIcon } from '@mui/icons-material'; interface Props { value: string; onChange: (value: string) => void; } export const Searchbox = ({ value, onChange }: Props) => { return ( { return onChange(e.target.value as string); }} InputProps={{ startAdornment: ( ) }} /> ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/IconSelectionControls/__tests__/Icon.test.tsx ================================================ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { Icon } from '../Icon'; import { Icon as IconI } from 'src/types'; describe('Icon', () => { const flatIcon: IconI = { id: 'flaticon', name: 'flat icon', url: 'src/assets/grid-tile-bg.svg', isIsometric: false } const isometricIcon: IconI = { id: 'isoicon', name: 'isometric icon', url: 'src/assets/grid-tile-bg.svg', isIsometric: true } it("should show 'flat' label for non isometric icon", () => { render( ) const label = screen.getByText('flat') expect(label).toBeInTheDocument() }) it("should not show 'flat' label for isometric icon", () => { render( ) expect(screen.queryByText('flat')).toBeNull() }) }) ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/ItemControlsManager.tsx ================================================ import React, { useMemo } from 'react'; import { Box } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { IconSelectionControls } from 'src/components/ItemControls/IconSelectionControls/IconSelectionControls'; import { NodeControls } from './NodeControls/NodeControls'; import { ConnectorControls } from './ConnectorControls/ConnectorControls'; import { TextBoxControls } from './TextBoxControls/TextBoxControls'; import { RectangleControls } from './RectangleControls/RectangleControls'; export const ItemControlsManager = () => { const itemControls = useUiStateStore((state) => { return state.itemControls; }); const Controls = useMemo(() => { switch (itemControls?.type) { case 'ITEM': return ; case 'CONNECTOR': return ; case 'TEXTBOX': return ; case 'RECTANGLE': return ; case 'ADD_ITEM': return ; default: return null; } }, [itemControls]); return ( {Controls} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/NodeControls/NodeControls.tsx ================================================ import React, { useState, useCallback, useEffect } from 'react'; import { Box, Stack, Button, IconButton as MUIIconButton } from '@mui/material'; import { ChevronRight as ChevronRightIcon, ChevronLeft as ChevronLeftIcon, Close as CloseIcon } from '@mui/icons-material'; import { useIconCategories } from 'src/hooks/useIconCategories'; import { useIcon } from 'src/hooks/useIcon'; import { useScene } from 'src/hooks/useScene'; import { useViewItem } from 'src/hooks/useViewItem'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useModelItem } from 'src/hooks/useModelItem'; import { ControlsContainer } from '../components/ControlsContainer'; import { Icons } from '../IconSelectionControls/Icons'; import { NodeSettings } from './NodeSettings/NodeSettings'; import { Section } from '../components/Section'; import { QuickIconSelector } from './QuickIconSelector'; interface Props { id: string; } const ModeOptions = { SETTINGS: 'SETTINGS', CHANGE_ICON: 'CHANGE_ICON' } as const; type Mode = keyof typeof ModeOptions; export const NodeControls = ({ id }: Props) => { const [mode, setMode] = useState('SETTINGS'); const { updateModelItem, updateViewItem, deleteViewItem } = useScene(); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const viewItem = useViewItem(id); const modelItem = useModelItem(id); const { iconCategories } = useIconCategories(); const { icon } = useIcon(modelItem?.icon || ''); const onSwitchMode = useCallback((newMode: Mode) => { setMode(newMode); }, []); // Listen for quick icon change event (triggered by 'i' hotkey) useEffect(() => { const handleQuickIconChange = () => { setMode('CHANGE_ICON'); }; window.addEventListener('quickIconChange', handleQuickIconChange); return () => { window.removeEventListener('quickIconChange', handleQuickIconChange); }; }, []); // If items don't exist, return null (component will unmount) if (!viewItem || !modelItem) { return null; } return ( { return theme.customVars.customPalette.diagramBg; }, position: 'relative' }} > {/* Close button */} { return uiStateActions.setItemControls(null); }} sx={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }} size="small" >
{mode === 'SETTINGS' && ( )} {mode === 'CHANGE_ICON' && ( )}
{mode === 'SETTINGS' && ( { updateModelItem(viewItem.id, updates); }} onViewItemUpdated={(updates) => { updateViewItem(viewItem.id, updates); }} onDeleted={() => { uiStateActions.setItemControls(null); deleteViewItem(viewItem.id); }} /> )} {mode === 'CHANGE_ICON' && ( { updateModelItem(viewItem.id, { icon: _icon.id }); }} onClose={() => { onSwitchMode('SETTINGS'); }} /> )}
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/NodeControls/NodeSettings/NodeSettings.tsx ================================================ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { Slider, Box, TextField } from '@mui/material'; import { ModelItem, ViewItem } from 'src/types'; import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor'; import { useModelItem } from 'src/hooks/useModelItem'; import { useModelStore } from 'src/stores/modelStore'; import { DeleteButton } from '../../components/DeleteButton'; import { Section } from '../../components/Section'; export type NodeUpdates = { model: Partial; view: Partial; }; interface Props { node: ViewItem; onModelItemUpdated: (updates: Partial) => void; onViewItemUpdated: (updates: Partial) => void; onDeleted: () => void; } export const NodeSettings = ({ node, onModelItemUpdated, onViewItemUpdated, onDeleted }: Props) => { const modelItem = useModelItem(node.id); const modelActions = useModelStore((state) => state.actions); const icons = useModelStore((state) => state.icons); // Local state for smooth slider interaction const currentIcon = icons.find(icon => icon.id === modelItem?.icon); const [localScale, setLocalScale] = useState(currentIcon?.scale || 1); const debounceRef = useRef(undefined); // Update local scale when icon changes useEffect(() => { setLocalScale(currentIcon?.scale || 1); }, [currentIcon?.scale]); // Debounced update to store const updateIconScale = useCallback((scale: number) => { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { const updatedIcons = icons.map(icon => icon.id === modelItem?.icon ? { ...icon, scale } : icon ); modelActions.set({ icons: updatedIcons }); }, 100); // 100ms debounce }, [icons, modelItem?.icon, modelActions]); // Handle slider change with local state + debounced store update const handleScaleChange = useCallback((e: Event, newScale: number | number[]) => { const scale = newScale as number; setLocalScale(scale); // Immediate UI update updateIconScale(scale); // Debounced store update }, [updateIconScale]); // Cleanup timeout on unmount useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, []); if (!modelItem) { return null; } return ( <>
{ const text = e.target.value as string; if (modelItem.name !== text) onModelItemUpdated({ name: text }); }} />
{ if (modelItem.description !== text) onModelItemUpdated({ description: text }); }} />
{modelItem.name && (
{ const labelHeight = newHeight as number; onViewItemUpdated({ labelHeight }); }} />
)}
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/NodeControls/QuickIconSelector.tsx ================================================ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Box, Stack, Typography, Divider, TextField, InputAdornment, Alert } from '@mui/material'; import { Search as SearchIcon } from '@mui/icons-material'; import { Icon } from 'src/types'; import { useModelStore } from 'src/stores/modelStore'; import { useIconCategories } from 'src/hooks/useIconCategories'; import { IconGrid } from '../IconSelectionControls/IconGrid'; import { Icons } from '../IconSelectionControls/Icons'; import { Section } from '../components/Section'; interface Props { onIconSelected: (icon: Icon) => void; onClose?: () => void; currentIconId?: string; } // Store recently used icons in localStorage const RECENT_ICONS_KEY = 'fossflow-recent-icons'; const MAX_RECENT_ICONS = 12; const getRecentIcons = (): string[] => { try { const stored = localStorage.getItem(RECENT_ICONS_KEY); return stored ? JSON.parse(stored) : []; } catch { return []; } }; const addToRecentIcons = (iconId: string) => { const recent = getRecentIcons(); // Remove if already exists and add to front const filtered = recent.filter(id => id !== iconId); const updated = [iconId, ...filtered].slice(0, MAX_RECENT_ICONS); localStorage.setItem(RECENT_ICONS_KEY, JSON.stringify(updated)); }; // Escape special regex characters const escapeRegex = (str: string): string => { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; export const QuickIconSelector = ({ onIconSelected, onClose, currentIconId }: Props) => { const [searchTerm, setSearchTerm] = useState(''); const [hoveredIndex, setHoveredIndex] = useState(0); const searchInputRef = useRef(null); const icons = useModelStore((state) => state.icons); const { iconCategories } = useIconCategories(); // Get recently used icons const recentIconIds = useMemo(() => getRecentIcons(), []); const recentIcons = useMemo(() => { return recentIconIds .map(id => icons.find(icon => icon.id === id)) .filter(Boolean) as Icon[]; }, [recentIconIds, icons]); // Filter icons based on search const filteredIcons = useMemo(() => { if (!searchTerm) return null; try { // Escape special regex characters to prevent errors const escapedSearch = escapeRegex(searchTerm); const regex = new RegExp(escapedSearch, 'gi'); return icons.filter(icon => regex.test(icon.name)); } catch (e) { // If regex still fails somehow, fall back to simple includes const lowerSearch = searchTerm.toLowerCase(); return icons.filter(icon => icon.name.toLowerCase().includes(lowerSearch)); } }, [searchTerm, icons]); // Focus search input on mount useEffect(() => { searchInputRef.current?.focus(); }, []); // Handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only handle navigation if we're showing search results if (!filteredIcons || filteredIcons.length === 0) return; const itemsPerRow = 4; // Adjust based on your grid layout const totalItems = filteredIcons.length; switch (e.key) { case 'ArrowDown': e.preventDefault(); setHoveredIndex(prev => Math.min(prev + itemsPerRow, totalItems - 1) ); break; case 'ArrowUp': e.preventDefault(); setHoveredIndex(prev => Math.max(prev - itemsPerRow, 0) ); break; case 'ArrowLeft': e.preventDefault(); setHoveredIndex(prev => prev > 0 ? prev - 1 : prev ); break; case 'ArrowRight': e.preventDefault(); setHoveredIndex(prev => prev < totalItems - 1 ? prev + 1 : prev ); break; case 'Enter': e.preventDefault(); if (filteredIcons[hoveredIndex]) { handleIconSelect(filteredIcons[hoveredIndex]); } break; case 'Escape': e.preventDefault(); onClose?.(); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [filteredIcons, hoveredIndex, onClose]); const handleIconSelect = useCallback((icon: Icon) => { addToRecentIcons(icon.id); onIconSelected(icon); }, [onIconSelected]); const handleIconDoubleClick = useCallback((icon: Icon) => { handleIconSelect(icon); onClose?.(); }, [handleIconSelect, onClose]); return (
{/* Search Box */} { setSearchTerm(e.target.value); setHoveredIndex(0); // Reset hover when searching }} InputProps={{ startAdornment: ( ) }} size="small" autoFocus /> {/* Recently Used Icons - Show when no search */} {!searchTerm && recentIcons.length > 0 && ( <> RECENTLY USED )}
{/* Search Results */} {searchTerm && filteredIcons && ( <>
SEARCH RESULTS ({filteredIcons.length} icons)
{filteredIcons.length > 0 ? (
) : (
No icons found matching "{searchTerm}"
)}
)} {/* Original Icon Libraries - Show when no search */} {!searchTerm && ( {}} // Not needed for selection /> )} {/* Help Text */}
{searchTerm ? 'Use arrow keys to navigate • Enter to select • Double-click to select and close' : 'Type to search • Click category to expand • Double-click to select and close' }
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/RectangleControls/RectangleControls.tsx ================================================ import React, { useState } from 'react'; import { Box, IconButton as MUIIconButton, FormControlLabel, Switch, Typography } from '@mui/material'; import { useRectangle } from 'src/hooks/useRectangle'; import { ColorSelector } from 'src/components/ColorSelector/ColorSelector'; import { ColorPicker } from 'src/components/ColorSelector/ColorPicker'; import { CustomColorInput } from 'src/components/ColorSelector/CustomColorInput'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useScene } from 'src/hooks/useScene'; import { Close as CloseIcon } from '@mui/icons-material'; import { ControlsContainer } from '../components/ControlsContainer'; import { Section } from '../components/Section'; import { DeleteButton } from '../components/DeleteButton'; interface Props { id: string; } export const RectangleControls = ({ id }: Props) => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const rectangle = useRectangle(id); const { updateRectangle, deleteRectangle } = useScene(); const [useCustomColor, setUseCustomColor] = useState(!!rectangle?.customColor); // If rectangle doesn't exist, return null if (!rectangle) { return null; } return ( {/* Close button */} { return uiStateActions.setItemControls(null); }} sx={{ position: 'absolute', top: 8, right: 8, zIndex: 2 }} size="small" >
{ setUseCustomColor(e.target.checked); if (!e.target.checked) { updateRectangle(rectangle.id, { customColor: '' }); } }} /> } label="Use Custom Color" sx={{ mb: 2 }} /> {useCustomColor ? ( { updateRectangle(rectangle.id, { customColor: color }); }} /> ) : ( { updateRectangle(rectangle.id, { color, customColor: '' }); }} activeColor={rectangle.color} /> )}
{ uiStateActions.setItemControls(null); deleteRectangle(rectangle.id); }} />
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/TextBoxControls/TextBoxControls.tsx ================================================ import React from 'react'; import { ProjectionOrientationEnum } from 'src/types'; import { Box, TextField, ToggleButton, ToggleButtonGroup, Slider, IconButton as MUIIconButton } from '@mui/material'; import { TextRotationNone as TextRotationNoneIcon, Close as CloseIcon } from '@mui/icons-material'; import { useTextBox } from 'src/hooks/useTextBox'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { getIsoProjectionCss } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; import { ControlsContainer } from '../components/ControlsContainer'; import { Section } from '../components/Section'; import { DeleteButton } from '../components/DeleteButton'; interface Props { id: string; } export const TextBoxControls = ({ id }: Props) => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const textBox = useTextBox(id); const { updateTextBox, deleteTextBox } = useScene(); // If textBox doesn't exist, return null if (!textBox) { return null; } return ( {/* Close button */} { return uiStateActions.setItemControls(null); }} sx={{ position: 'absolute', top: 16, right: 16, zIndex: 2 }} size="small" >
{ updateTextBox(textBox.id, { content: e.target.value as string }); }} />
{ updateTextBox(textBox.id, { fontSize: newSize as number }); }} />
{ if (textBox.orientation === orientation || orientation === null) return; updateTextBox(textBox.id, { orientation }); }} >
{ uiStateActions.setItemControls(null); deleteTextBox(textBox.id); }} />
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/components/ControlsContainer.tsx ================================================ import React from 'react'; import { Box, Divider } from '@mui/material'; interface Props { header?: React.ReactNode; children: React.ReactNode; } export const ControlsContainer = ({ header, children }: Props) => { return ( e.stopPropagation()} onContextMenu={e => e.stopPropagation()} sx={{ position: 'relative', height: '100%', width: '100%', display: 'flex', flexDirection: 'column', pb: 2 }} > {header && ( {header} )} {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/components/DeleteButton.tsx ================================================ import React from 'react'; import { DeleteOutlined as DeleteIcon } from '@mui/icons-material'; import { Button } from '@mui/material'; interface Props { onClick: () => void; } export const DeleteButton = ({ onClick }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/components/Header.tsx ================================================ import React from 'react'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import { Section } from './Section'; interface Props { title: string; } export const Header = ({ title }: Props) => { return (
{title}
); }; ================================================ FILE: packages/fossflow-lib/src/components/ItemControls/components/Section.tsx ================================================ import React from 'react'; import { Box, SxProps, Typography, Stack } from '@mui/material'; interface Props { children: React.ReactNode; title?: string; sx?: SxProps; } export const Section = ({ children, sx, title }: Props) => { return ( {title && ( {title} )} {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Label/ExpandButton.tsx ================================================ import React from 'react'; import { Button as MuiButton, SxProps } from '@mui/material'; import { ExpandMore as ReadMoreIcon, ExpandLess as ReadLessIcon } from '@mui/icons-material'; interface Props { isExpanded: boolean; onClick: () => void; sx?: SxProps; } export const ExpandButton = ({ isExpanded, onClick, sx }: Props) => { return ( {isExpanded ? ( ) : ( )} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Label/ExpandableLabel.tsx ================================================ import React, { useState, useRef, useEffect, useMemo } from 'react'; import { Box } from '@mui/material'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; import { Gradient } from 'src/components/Gradient/Gradient'; import { ExpandButton } from './ExpandButton'; import { Label, Props as LabelProps } from './Label'; import { useUiStateStore } from 'src/stores/uiStateStore'; type Props = Omit & { onToggleExpand?: (isExpanded: boolean) => void; }; const STANDARD_LABEL_HEIGHT = 80; export const ExpandableLabel = ({ children, onToggleExpand, ...rest }: Props) => { const forceExpandLabels = useUiStateStore((state) => state.expandLabels); const editorMode = useUiStateStore((state) => state.editorMode); const labelSettings = useUiStateStore((state) => state.labelSettings); const [isExpanded, setIsExpanded] = useState(false); const contentRef = useRef(null); const { observe, size: contentSize } = useResizeObserver(); useEffect(() => { if (!contentRef.current) return; observe(contentRef.current); }, [observe]); const effectiveExpanded = useMemo(() => { // Only force expand in NON_INTERACTIVE mode (export preview) const shouldForceExpand = forceExpandLabels && editorMode === 'NON_INTERACTIVE'; return shouldForceExpand || isExpanded; }, [forceExpandLabels, isExpanded, editorMode]); const containerMaxHeight = useMemo(() => { return effectiveExpanded ? undefined : STANDARD_LABEL_HEIGHT; }, [effectiveExpanded]); const isContentTruncated = useMemo(() => { return !effectiveExpanded && contentSize.height >= STANDARD_LABEL_HEIGHT - 10; }, [effectiveExpanded, contentSize.height]); // Determine overflow behavior based on mode const overflowBehavior = useMemo(() => { if (editorMode === 'NON_INTERACTIVE') { // In export mode, no overflow needed - container expands to fit return 'visible'; } // In interactive modes, use scroll when expanded, hidden when collapsed return effectiveExpanded ? 'scroll' : 'hidden'; }, [editorMode, effectiveExpanded]); useEffect(() => { contentRef.current?.scrollTo({ top: 0 }); }, [effectiveExpanded]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/Label/Label.tsx ================================================ import React, { useRef } from 'react'; import { Box, SxProps } from '@mui/material'; const CONNECTOR_DOT_SIZE = 3; export interface Props { labelHeight?: number; maxWidth: number; maxHeight?: number; expandDirection?: 'CENTER' | 'BOTTOM'; children: React.ReactNode; sx?: SxProps; showLine?: boolean; } export const Label = ({ children, maxWidth, maxHeight, expandDirection = 'CENTER', labelHeight = 0, sx, showLine = true }: Props) => { const contentRef = useRef(null); return ( {labelHeight > 0 && showLine && ( )} {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Label/__tests__/Label.test.tsx ================================================ import React from 'react'; import { render, screen } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { theme } from 'src/styles/theme'; import { Label } from '../Label'; const renderWithTheme = (ui: React.ReactElement) => { return render({ui}); }; describe('Label', () => { describe('dotted line', () => { it('should render dotted line with pointerEvents none to not block clicks', () => { const { container } = renderWithTheme( ); // Find the SVG element (the dotted line container) const svg = container.querySelector('svg'); expect(svg).toBeTruthy(); // Check that the SVG has pointerEvents set to none const svgStyles = window.getComputedStyle(svg!); expect(svgStyles.pointerEvents).toBe('none'); }); it('should not render dotted line when labelHeight is 0', () => { const { container } = renderWithTheme( ); const svg = container.querySelector('svg'); expect(svg).toBeNull(); }); it('should not render dotted line when showLine is false', () => { const { container } = renderWithTheme( ); const svg = container.querySelector('svg'); expect(svg).toBeNull(); }); it('should render children correctly', () => { renderWithTheme( ); expect(screen.getByTestId('label-content')).toHaveTextContent( 'Test Label Content' ); }); }); }); ================================================ FILE: packages/fossflow-lib/src/components/LabelSettings/LabelSettings.tsx ================================================ import React from 'react'; import { Box, Typography, Slider } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; export const LabelSettings = () => { const labelSettings = useUiStateStore((state) => state.labelSettings); const setLabelSettings = useUiStateStore((state) => state.actions.setLabelSettings); const handlePaddingChange = (_event: Event, value: number | number[]) => { setLabelSettings({ ...labelSettings, expandButtonPadding: value as number }); }; return ( Configure label display settings Expand Button Padding Bottom padding when expand button is visible (prevents text overlap) Current: {labelSettings.expandButtonPadding} theme units ); }; ================================================ FILE: packages/fossflow-lib/src/components/Lasso/Lasso.tsx ================================================ import React from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea'; export const Lasso = () => { const modeType = useUiStateStore((state) => state.mode.type); const selection = useUiStateStore((state) => state.mode.type === 'LASSO' ? state.mode.selection : null ); if (modeType !== 'LASSO' || !selection) { return null; } const { startTile, endTile } = selection; return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/LassoHintTooltip/LassoHintTooltip.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Box, IconButton, Paper, Typography, useTheme } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useTranslation } from 'src/stores/localeStore'; const STORAGE_KEY = 'fossflow_lasso_hint_dismissed'; interface Props { toolMenuRef?: React.RefObject; } export const LassoHintTooltip = ({ toolMenuRef }: Props) => { const { t } = useTranslation('lassoHintTooltip'); const theme = useTheme(); const modeType = useUiStateStore((state) => state.mode.type); const [isDismissed, setIsDismissed] = useState(true); const [position, setPosition] = useState({ top: 16, right: 16 }); useEffect(() => { // Check if the hint has been dismissed before const dismissed = localStorage.getItem(STORAGE_KEY); if (dismissed !== 'true') { setIsDismissed(false); } }, []); useEffect(() => { // Calculate position based on toolbar if (toolMenuRef?.current) { const toolMenuRect = toolMenuRef.current.getBoundingClientRect(); // Position tooltip below the toolbar with some spacing setPosition({ top: toolMenuRect.bottom + 16, right: 16 }); } else { // Fallback position if no toolbar ref const appPadding = theme.customVars?.appPadding || { x: 16, y: 16 }; setPosition({ top: appPadding.y + 500, // Approximate toolbar height right: appPadding.x }); } }, [toolMenuRef, theme]); const handleDismiss = () => { setIsDismissed(true); localStorage.setItem(STORAGE_KEY, 'true'); }; // Only show when in LASSO or FREEHAND_LASSO mode if (isDismissed || (modeType !== 'LASSO' && modeType !== 'FREEHAND_LASSO')) { return null; } const isFreehandMode = modeType === 'FREEHAND_LASSO'; return ( {isFreehandMode ? t('tipFreehandLasso') : t('tipLasso')} {isFreehandMode ? ( <> {t('freehandDragStart')} {t('freehandDragMiddle')} {t('freehandDragEnd')} {t('freehandComplete')} ) : ( <> {t('lassoDragStart')} {t('lassoDragEnd')} )} {t('moveStart')} {t('moveMiddle')} {t('moveEnd')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/LazyLoadingWelcomeNotification/LazyLoadingWelcomeNotification.tsx ================================================ import React, { useState, useEffect } from 'react'; import { Box, IconButton, Paper, Typography, useTheme } from '@mui/material'; import { Close as CloseIcon, Menu as MenuIcon } from '@mui/icons-material'; import { useTranslation } from 'src/stores/localeStore'; const STORAGE_KEY = 'fossflow-lazy-loading-welcome-dismissed'; export const LazyLoadingWelcomeNotification = () => { const { t } = useTranslation('lazyLoadingWelcome'); const theme = useTheme(); const [isDismissed, setIsDismissed] = useState(true); useEffect(() => { // Check if the notification has been dismissed before const dismissed = localStorage.getItem(STORAGE_KEY); if (dismissed !== 'true') { setIsDismissed(false); } }, []); const handleDismiss = () => { setIsDismissed(true); localStorage.setItem(STORAGE_KEY, 'true'); }; if (isDismissed) { return null; } return ( {t('title')} {t('message')} {t('configPath')} {t('configPath2')} {t('canDisable')} {t('signature')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Loader/Loader.tsx ================================================ import React from 'react'; import { Box, CircularProgress, CircularProgressProps } from '@mui/material'; interface Props { size?: number; color?: CircularProgressProps['color']; isInline?: boolean; } export const Loader = ({ size = 1, color = 'primary', isInline }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/MainMenu/MainMenu.tsx ================================================ import React, { useState, useCallback, useMemo } from 'react'; import { Menu, Typography, Divider, Card } from '@mui/material'; import { Menu as MenuIcon, GitHub as GitHubIcon, DataObject as ExportJsonIcon, ImageOutlined as ExportImageIcon, FolderOpen as FolderOpenIcon, DeleteOutline as DeleteOutlineIcon, Undo as UndoIcon, Redo as RedoIcon, Settings as SettingsIcon, } from '@mui/icons-material'; import { UiElement } from 'src/components/UiElement/UiElement'; import { IconButton } from 'src/components/IconButton/IconButton'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { exportAsJSON, exportAsCompactJSON, transformFromCompactFormat } from 'src/utils/exportOptions'; import { modelFromModelStore } from 'src/utils'; import { useInitialDataManager } from 'src/hooks/useInitialDataManager'; import { useModelStore } from 'src/stores/modelStore'; import { useHistory } from 'src/hooks/useHistory'; import { DialogTypeEnum } from 'src/types/ui'; import { MenuItem } from './MenuItem'; import { useTranslation } from 'src/stores/localeStore'; export const MainMenu = () => { const [anchorEl, setAnchorEl] = useState(null); const model = useModelStore((state) => { return modelFromModelStore(state); }); const isMainMenuOpen = useUiStateStore((state) => { return state.isMainMenuOpen; }); const mainMenuOptions = useUiStateStore((state) => { return state.mainMenuOptions; }); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const initialDataManager = useInitialDataManager(); const { undo, redo, canUndo, canRedo, clearHistory } = useHistory(); const { t } = useTranslation('mainMenu'); const onToggleMenu = useCallback( (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); uiStateActions.setIsMainMenuOpen(true); }, [uiStateActions] ); const gotoUrl = useCallback((url: string) => { window.open(url, '_blank'); }, []); const { load } = initialDataManager; const onOpenModel = useCallback(async () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'application/json'; fileInput.onchange = async (event) => { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) { throw new Error('No file selected'); } const fileReader = new FileReader(); fileReader.onload = async (e) => { const rawData = JSON.parse(e.target?.result as string); let modelData = rawData; // Check format and transform if needed if (rawData._?.f === 'compact') { modelData = transformFromCompactFormat(rawData); } load(modelData); clearHistory(); // Clear history when loading new model }; fileReader.readAsText(file); uiStateActions.resetUiState(); }; await fileInput.click(); uiStateActions.setIsMainMenuOpen(false); }, [uiStateActions, load, clearHistory]); const onExportAsJSON = useCallback(async () => { exportAsJSON(model); uiStateActions.setIsMainMenuOpen(false); }, [model, uiStateActions]); const onExportAsCompactJSON = useCallback(async () => { exportAsCompactJSON(model); uiStateActions.setIsMainMenuOpen(false); }, [model, uiStateActions]); const onExportAsImage = useCallback(() => { uiStateActions.setIsMainMenuOpen(false); uiStateActions.setDialog(DialogTypeEnum.EXPORT_IMAGE); }, [uiStateActions]); const { clear } = initialDataManager; const onClearCanvas = useCallback(() => { clear(); clearHistory(); // Clear history when clearing canvas uiStateActions.setIsMainMenuOpen(false); }, [uiStateActions, clear, clearHistory]); const handleUndo = useCallback(() => { undo(); uiStateActions.setIsMainMenuOpen(false); }, [undo, uiStateActions]); const handleRedo = useCallback(() => { redo(); uiStateActions.setIsMainMenuOpen(false); }, [redo, uiStateActions]); const onOpenSettings = useCallback(() => { uiStateActions.setIsMainMenuOpen(false); uiStateActions.setDialog(DialogTypeEnum.SETTINGS); }, [uiStateActions]); const sectionVisibility = useMemo(() => { return { actions: Boolean( mainMenuOptions.find((opt) => { return opt.includes('ACTION') || opt.includes('EXPORT'); }) ), links: Boolean( mainMenuOptions.find((opt) => { return opt.includes('LINK'); }) ), version: Boolean(mainMenuOptions.includes('VERSION')) }; }, [mainMenuOptions]); if (mainMenuOptions.length === 0) { return null; } return ( } name="Main menu" onClick={onToggleMenu} isActive={isMainMenuOpen} /> { uiStateActions.setIsMainMenuOpen(false); }} elevation={0} sx={{ mt: 2 }} MenuListProps={{ sx: { minWidth: '250px', py: 0 } }} > {/* Undo/Redo Section */} } disabled={!canUndo} > {t('undo')} } disabled={!canRedo} > {t('redo')} {(canUndo || canRedo) && sectionVisibility.actions && } {/* File Actions */} {mainMenuOptions.includes('ACTION.OPEN') && ( }> {t('open')} )} {mainMenuOptions.includes('EXPORT.JSON') && ( }> {t('exportJson')} )} {mainMenuOptions.includes('EXPORT.JSON') && ( }> {t('exportCompactJson')} )} {mainMenuOptions.includes('EXPORT.PNG') && ( }> {t('exportImage')} )} {mainMenuOptions.includes('ACTION.CLEAR_CANVAS') && ( }> {t('clearCanvas')} )} }> {t('settings')} {sectionVisibility.links && ( <> {mainMenuOptions.includes('LINK.GITHUB') && ( { return gotoUrl(`${REPOSITORY_URL}`); }} Icon={} > {t('gitHub')} )} )} {sectionVisibility.version && ( <> {mainMenuOptions.includes('VERSION') && ( FossFLOW v{PACKAGE_VERSION} )} )} ); }; ================================================ FILE: packages/fossflow-lib/src/components/MainMenu/MenuItem.tsx ================================================ import React from 'react'; import { MenuItem as MuiMenuItem, ListItemIcon } from '@mui/material'; export interface Props { onClick?: () => void; Icon?: React.ReactNode; children: string | React.ReactNode; disabled?: boolean; } export const MenuItem = ({ onClick, Icon, children, disabled = false }: Props) => { return ( {Icon} {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/PanSettings/PanSettings.tsx ================================================ import React from 'react'; import { Box, Typography, FormControlLabel, Switch, Slider, Paper, Divider } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useTranslation } from 'src/stores/localeStore'; export const PanSettings = () => { const panSettings = useUiStateStore((state) => state.panSettings); const setPanSettings = useUiStateStore((state) => state.actions.setPanSettings); const { t } = useTranslation(); const handleToggle = (setting: keyof typeof panSettings) => { if (typeof panSettings[setting] === 'boolean') { setPanSettings({ ...panSettings, [setting]: !panSettings[setting] }); } }; const handleSpeedChange = (value: number) => { setPanSettings({ ...panSettings, keyboardPanSpeed: value }); }; return ( {t('settings.pan.title')} {t('settings.pan.mousePanOptions')} handleToggle('emptyAreaClickPan')} /> } label={t('settings.pan.emptyAreaClickPan')} /> handleToggle('middleClickPan')} /> } label={t('settings.pan.middleClickPan')} /> handleToggle('rightClickPan')} /> } label={t('settings.pan.rightClickPan')} /> handleToggle('ctrlClickPan')} /> } label={t('settings.pan.ctrlClickPan')} /> handleToggle('altClickPan')} /> } label={t('settings.pan.altClickPan')} /> {t('settings.pan.keyboardPanOptions')} handleToggle('arrowKeysPan')} /> } label={t('settings.pan.arrowKeys')} /> handleToggle('wasdPan')} /> } label={t('settings.pan.wasdKeys')} /> handleToggle('ijklPan')} /> } label={t('settings.pan.ijklKeys')} /> {t('settings.pan.keyboardPanSpeed')} handleSpeedChange(value as number)} min={5} max={50} step={5} marks valueLabelDisplay="auto" /> {t('settings.pan.note')} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Renderer/Renderer.tsx ================================================ import React, { useEffect, useMemo, useRef } from 'react'; import { Box } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useInteractionManager } from 'src/interaction/useInteractionManager'; import { Grid } from 'src/components/Grid/Grid'; import { Cursor } from 'src/components/Cursor/Cursor'; import { Nodes } from 'src/components/SceneLayers/Nodes/Nodes'; import { Rectangles } from 'src/components/SceneLayers/Rectangles/Rectangles'; import { Connectors } from 'src/components/SceneLayers/Connectors/Connectors'; import { ConnectorLabels } from 'src/components/SceneLayers/ConnectorLabels/ConnectorLabels'; import { TextBoxes } from 'src/components/SceneLayers/TextBoxes/TextBoxes'; import { SizeIndicator } from 'src/components/DebugUtils/SizeIndicator'; import { SceneLayer } from 'src/components/SceneLayer/SceneLayer'; import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager'; import { Lasso } from 'src/components/Lasso/Lasso'; import { FreehandLasso } from 'src/components/FreehandLasso/FreehandLasso'; import { useScene } from 'src/hooks/useScene'; import { RendererProps } from 'src/types/rendererProps'; export const Renderer = ({ showGrid, backgroundColor }: RendererProps) => { const containerRef = useRef(null); const interactionsRef = useRef(null); const enableDebugTools = useUiStateStore((state) => { return state.enableDebugTools; }); const showCursor = useUiStateStore((state) => { return state.mode.showCursor; }); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const { setInteractionsElement } = useInteractionManager(); const { items, rectangles, connectors, textBoxes } = useScene(); useEffect(() => { if (!containerRef.current || !interactionsRef.current) return; setInteractionsElement(interactionsRef.current); uiStateActions.setRendererEl(containerRef.current); }, [setInteractionsElement, uiStateActions]); const isShowGrid = useMemo(() => { return showGrid === undefined || showGrid; }, [showGrid]); return ( backgroundColor === 'transparent' ? 'transparent' : (backgroundColor ?? theme.customVars.customPalette.diagramBg) }} > {isShowGrid && } {showCursor && ( )} {enableDebugTools && ( )} {/* Interaction layer: this is where events are detected */} ); }; ================================================ FILE: packages/fossflow-lib/src/components/RichTextEditor/RichTextEditor.tsx ================================================ import React, { useMemo } from 'react'; import ReactQuill from 'react-quill-new'; import { Box } from '@mui/material'; import RichTextEditorErrorBoundary from './RichTextEditorErrorBoundary'; interface Props { value?: string; onChange?: (value: string) => void; readOnly?: boolean; height?: number; styles?: React.CSSProperties; } // Rich text formatting tools const tools = [ 'bold', 'italic', 'underline', 'strike', 'link', { header: [1, 2, 3, false] }, { list: 'ordered' }, { list: 'bullet' }, 'blockquote', 'code-block' ]; // Formats that Quill should recognize const formats = [ 'bold', 'italic', 'underline', 'strike', 'link', 'header', 'list', 'bullet', 'blockquote', 'code-block' ]; export const RichTextEditor = ({ value, onChange, readOnly, height = 120, styles }: Props) => { const modules = useMemo(() => { if (!readOnly) return { toolbar: tools }; return { toolbar: false }; }, [readOnly]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/RichTextEditor/RichTextEditorErrorBoundary.tsx ================================================ import React, { Component, ReactNode } from 'react'; interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; } interface ErrorBoundaryState { hasError: boolean; errorCount: number; } class RichTextEditorErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false, errorCount: 0 }; } static getDerivedStateFromError(error: Error): Partial | null { // Check if this is the specific DOM manipulation error we're trying to handle if ( error.message.includes('removeChild') || error.message.includes('insertBefore') || error.message.includes('appendChild') ) { // Return state update to trigger re-render return { hasError: true, errorCount: 0 }; } // For other errors, let them propagate return null; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // Log the error for debugging purposes if (error.message.includes('removeChild') || error.message.includes('insertBefore') || error.message.includes('appendChild')) { console.warn('RichTextEditor DOM manipulation error caught and handled:', { message: error.message, componentStack: errorInfo.componentStack }); // Prevent infinite error loops by tracking error count this.setState(prevState => ({ errorCount: prevState.errorCount + 1 })); // If we get too many errors in a row, show fallback if (this.state.errorCount > 3) { console.error('Too many RichTextEditor errors, showing fallback'); return; } // Schedule a recovery attempt after the current render cycle setTimeout(() => { this.setState({ hasError: false }); }, 0); } } componentDidUpdate(_prevProps: ErrorBoundaryProps, prevState: ErrorBoundaryState) { // Reset error state if we successfully rendered after an error if (prevState.hasError && !this.state.hasError) { this.setState({ errorCount: 0 }); } } render() { if (this.state.hasError && this.state.errorCount > 3) { // If too many errors, show fallback or placeholder return this.props.fallback || (
Rich text editor temporarily unavailable
); } // Normal render or retry after error return this.props.children; } } export default RichTextEditorErrorBoundary; ================================================ FILE: packages/fossflow-lib/src/components/RichTextEditor/index.ts ================================================ export { RichTextEditor } from './RichTextEditor'; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayer/SceneLayer.tsx ================================================ import React, { useRef, useEffect, useState, memo } from 'react'; import gsap from 'gsap'; import { Box, SxProps } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; interface Props { children?: React.ReactNode; order?: number; sx?: SxProps; disableAnimation?: boolean; } export const SceneLayer = memo(({ children, order = 0, sx, disableAnimation }: Props) => { const [isFirstRender, setIsFirstRender] = useState(true); const elementRef = useRef(null); const scroll = useUiStateStore((state) => { return state.scroll; }); const zoom = useUiStateStore((state) => { return state.zoom; }); useEffect(() => { if (!elementRef.current) return; gsap.to(elementRef.current, { duration: disableAnimation || isFirstRender ? 0 : 0.016, // ~1 frame at 60fps for smooth motion ease: 'none', // Linear easing for immediate response translateX: scroll.position.x, translateY: scroll.position.y, scale: zoom }); if (isFirstRender) { setIsFirstRender(false); } }, [zoom, scroll, disableAnimation, isFirstRender]); return ( {children} ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/ConnectorLabels/ConnectorLabel.tsx ================================================ import React, { useMemo, memo } from 'react'; import { Box, Typography } from '@mui/material'; import { useScene } from 'src/hooks/useScene'; import { useConnector } from 'src/hooks/useConnector'; import { connectorPathTileToGlobal, getTilePosition, getConnectorLabels, getLabelTileIndex } from 'src/utils'; import { PROJECTED_TILE_SIZE, UNPROJECTED_TILE_SIZE } from 'src/config'; import { Label } from 'src/components/Label/Label'; import { ConnectorLabel as ConnectorLabelType } from 'src/types'; interface Props { connector: ReturnType['connectors'][0]; } export const ConnectorLabel = memo(({ connector: sceneConnector }: Props) => { const connector = useConnector(sceneConnector.id); const labels = useMemo(() => { if (!connector) return []; return getConnectorLabels(connector); }, [connector]); // Calculate label positions based on percentage and line assignment const labelPositions = useMemo(() => { if (!connector) return []; return labels .map((label) => { const tileIndex = getLabelTileIndex( sceneConnector.path.tiles.length, label.position ); const tile = sceneConnector.path.tiles[tileIndex]; if (!tile) return null; let position = getTilePosition({ tile: connectorPathTileToGlobal( tile, sceneConnector.path.rectangle.from ) }); // For double line types, offset labels based on line assignment const lineType = connector.lineType || 'SINGLE'; if ( (lineType === 'DOUBLE' || lineType === 'DOUBLE_WITH_CIRCLE') && label.line === '2' ) { // Calculate offset perpendicular to line direction const { tiles } = sceneConnector.path; if (tileIndex > 0 && tileIndex < tiles.length - 1) { const prev = tiles[tileIndex - 1]; const next = tiles[tileIndex + 1]; const dx = next.x - prev.x; const dy = next.y - prev.y; const len = Math.sqrt(dx * dx + dy * dy) || 1; // Perpendicular offset (matches the offset in Connector.tsx) const connectorWidthPx = (UNPROJECTED_TILE_SIZE / 100) * (connector.width || 15); const offset = connectorWidthPx * 3; const perpX = -dy / len; const perpY = dx / len; position = { x: position.x - perpX * offset, y: position.y - perpY * offset }; } } return { label, position }; }) .filter( ( item ): item is { label: ConnectorLabelType; position: { x: number; y: number }; } => { return item !== null; } ); }, [labels, sceneConnector.path, connector?.lineType, connector?.width]); return ( <> {labelPositions.map(({ label, position }) => { return ( ); })} ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/ConnectorLabels/ConnectorLabels.tsx ================================================ import React from 'react'; import { useScene } from 'src/hooks/useScene'; import { ConnectorLabel } from './ConnectorLabel'; interface Props { connectors: ReturnType['connectors']; } export const ConnectorLabels = ({ connectors }: Props) => { return ( <> {connectors .filter((connector) => { return Boolean( connector.description || connector.startLabel || connector.endLabel || (connector.labels && connector.labels.length > 0) ); }) .map((connector) => { return ; })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Connectors/Connector.tsx ================================================ import React, { useMemo, memo } from 'react'; import { useTheme, Box } from '@mui/material'; import { UNPROJECTED_TILE_SIZE } from 'src/config'; import { getAnchorTile, getColorVariant, getConnectorDirectionIcon } from 'src/utils'; import { Circle } from 'src/components/Circle/Circle'; import { Svg } from 'src/components/Svg/Svg'; import { useIsoProjection } from 'src/hooks/useIsoProjection'; import { useConnector } from 'src/hooks/useConnector'; import { useScene } from 'src/hooks/useScene'; import { useColor } from 'src/hooks/useColor'; interface Props { connector: ReturnType['connectors'][0]; isSelected?: boolean; } export const Connector = memo(({ connector: _connector, isSelected }: Props) => { const theme = useTheme(); const predefinedColor = useColor(_connector.color); const { currentView } = useScene(); const connector = useConnector(_connector.id); if (!connector) { return null; } // Use custom color if provided, otherwise use predefined color const color = connector.customColor ? { value: connector.customColor } : predefinedColor; if (!color) { return null; } const { css, pxSize } = useIsoProjection({ ...connector.path.rectangle }); const drawOffset = useMemo(() => { return { x: UNPROJECTED_TILE_SIZE / 2, y: UNPROJECTED_TILE_SIZE / 2 }; }, []); const connectorWidthPx = useMemo(() => { return (UNPROJECTED_TILE_SIZE / 100) * connector.width; }, [connector.width]); const pathString = useMemo(() => { return connector.path.tiles.reduce((acc, tile) => { return `${acc} ${tile.x * UNPROJECTED_TILE_SIZE + drawOffset.x},${ tile.y * UNPROJECTED_TILE_SIZE + drawOffset.y }`; }, ''); }, [connector.path.tiles, drawOffset]); // Create offset paths for double lines const offsetPaths = useMemo(() => { if (!connector.lineType || connector.lineType === 'SINGLE') return null; const tiles = connector.path.tiles; if (tiles.length < 2) return null; const offset = connectorWidthPx * 3; // Larger spacing between double lines for visibility const path1Points: string[] = []; const path2Points: string[] = []; for (let i = 0; i < tiles.length; i++) { const curr = tiles[i]; let dx = 0, dy = 0; // Calculate perpendicular offset based on line direction if (i > 0 && i < tiles.length - 1) { const prev = tiles[i - 1]; const next = tiles[i + 1]; const dx1 = curr.x - prev.x; const dy1 = curr.y - prev.y; const dx2 = next.x - curr.x; const dy2 = next.y - curr.y; // Average direction for smooth corners const avgDx = (dx1 + dx2) / 2; const avgDy = (dy1 + dy2) / 2; const len = Math.sqrt(avgDx * avgDx + avgDy * avgDy) || 1; // Perpendicular vector dx = -avgDy / len; dy = avgDx / len; } else if (i === 0 && tiles.length > 1) { // Start point const next = tiles[1]; const dirX = next.x - curr.x; const dirY = next.y - curr.y; const len = Math.sqrt(dirX * dirX + dirY * dirY) || 1; dx = -dirY / len; dy = dirX / len; } else if (i === tiles.length - 1 && tiles.length > 1) { // End point const prev = tiles[i - 1]; const dirX = curr.x - prev.x; const dirY = curr.y - prev.y; const len = Math.sqrt(dirX * dirX + dirY * dirY) || 1; dx = -dirY / len; dy = dirX / len; } const x = curr.x * UNPROJECTED_TILE_SIZE + drawOffset.x; const y = curr.y * UNPROJECTED_TILE_SIZE + drawOffset.y; path1Points.push(`${x + dx * offset},${y + dy * offset}`); path2Points.push(`${x - dx * offset},${y - dy * offset}`); } return { path1: path1Points.join(' '), path2: path2Points.join(' ') }; }, [connector.path.tiles, connector.lineType, connectorWidthPx, drawOffset]); const anchorPositions = useMemo(() => { if (!isSelected) return []; return connector.anchors.map((anchor) => { const position = getAnchorTile(anchor, currentView); return { id: anchor.id, x: (connector.path.rectangle.from.x - position.x) * UNPROJECTED_TILE_SIZE + drawOffset.x, y: (connector.path.rectangle.from.y - position.y) * UNPROJECTED_TILE_SIZE + drawOffset.y }; }); }, [ currentView, connector.path.rectangle, connector.anchors, drawOffset, isSelected ]); const directionIcon = useMemo(() => { return getConnectorDirectionIcon(connector.path.tiles); }, [connector.path.tiles]); const strokeDashArray = useMemo(() => { switch (connector.style) { case 'DASHED': return `${connectorWidthPx * 2}, ${connectorWidthPx * 2}`; case 'DOTTED': return `0, ${connectorWidthPx * 1.8}`; case 'SOLID': default: return 'none'; } }, [connector.style, connectorWidthPx]); const lineType = connector.lineType || 'SINGLE'; return ( {lineType === 'SINGLE' ? ( <> ) : offsetPaths ? ( <> {/* First line of double */} {/* Second line of double */} ) : null} {/* Circle for port-channel representation */} {lineType === 'DOUBLE_WITH_CIRCLE' && connector.path.tiles.length >= 2 && (() => { const midIndex = Math.floor(connector.path.tiles.length / 2); const midTile = connector.path.tiles[midIndex]; const x = midTile.x * UNPROJECTED_TILE_SIZE + drawOffset.x; const y = midTile.y * UNPROJECTED_TILE_SIZE + drawOffset.y; // Calculate rotation based on line direction at middle point let rotation = 0; if (midIndex > 0 && midIndex < connector.path.tiles.length - 1) { const prevTile = connector.path.tiles[midIndex - 1]; const nextTile = connector.path.tiles[midIndex + 1]; const dx = nextTile.x - prevTile.x; const dy = nextTile.y - prevTile.y; rotation = Math.atan2(dy, dx) * (180 / Math.PI); } // Increased size to encompass both lines with the spacing const circleRadiusX = connectorWidthPx * 5; // Wider to cover both lines const circleRadiusY = connectorWidthPx * 4; // Height to encompass both lines return ( ); })()} {anchorPositions.map((anchor) => { return ( ); })} {directionIcon && connector.showArrow !== false && ( )} ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Connectors/Connectors.tsx ================================================ import React, { useMemo } from 'react'; import type { useScene } from 'src/hooks/useScene'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { Connector } from './Connector'; interface Props { connectors: ReturnType['connectors']; } export const Connectors = ({ connectors }: Props) => { const itemControls = useUiStateStore((state) => { return state.itemControls; }); const mode = useUiStateStore((state) => { return state.mode; }); const selectedConnectorId = useMemo(() => { if (mode.type === 'CONNECTOR') { return mode.id; } if (itemControls?.type === 'CONNECTOR') { return itemControls.id; } return null; }, [mode, itemControls]); return ( <> {[...connectors].reverse().map((connector) => { return ( ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon.tsx ================================================ import React, { useRef, useEffect } from 'react'; import { Box } from '@mui/material'; import { PROJECTED_TILE_SIZE } from 'src/config'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; interface Props { url: string; scale?: number; onImageLoaded?: () => void; } export const IsometricIcon = ({ url, scale = 1, onImageLoaded }: Props) => { const ref = useRef(null); const { observe, disconnect } = useResizeObserver(); useEffect(() => { if (!ref.current) return; observe(ref.current); return disconnect; }, [observe, disconnect]); return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon.tsx ================================================ import React from 'react'; import { Box } from '@mui/material'; import { Icon } from 'src/types'; import { PROJECTED_TILE_SIZE } from 'src/config'; import { getIsoProjectionCss } from 'src/utils'; interface Props { icon: Icon; } export const NonIsometricIcon = ({ icon }: Props) => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Nodes/Node/Node.tsx ================================================ import React, { useMemo, memo } from 'react'; import { Box, Typography, Stack } from '@mui/material'; import { PROJECTED_TILE_SIZE, DEFAULT_LABEL_HEIGHT, MARKDOWN_EMPTY_VALUE } from 'src/config'; import { getTilePosition } from 'src/utils'; import { useIcon } from 'src/hooks/useIcon'; import { ViewItem } from 'src/types'; import { useModelItem } from 'src/hooks/useModelItem'; import { ExpandableLabel } from 'src/components/Label/ExpandableLabel'; import { RichTextEditor } from 'src/components/RichTextEditor/RichTextEditor'; interface Props { node: ViewItem; order: number; } export const Node = memo(({ node, order }: Props) => { const modelItem = useModelItem(node.id); const { iconComponent } = useIcon(modelItem?.icon); const position = useMemo(() => { return getTilePosition({ tile: node.tile, origin: 'BOTTOM' }); }, [node.tile]); const description = useMemo(() => { if ( !modelItem || modelItem.description === undefined || modelItem.description === MARKDOWN_EMPTY_VALUE ) return null; return modelItem.description; }, [modelItem?.description]); // If modelItem doesn't exist, don't render the node if (!modelItem) { return null; } return ( {(modelItem?.name || description) && ( {modelItem.name && ( {modelItem.name} )} {modelItem.description && modelItem.description !== MARKDOWN_EMPTY_VALUE && ( )} )} {iconComponent && ( {iconComponent} )} ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Nodes/Nodes.tsx ================================================ import React from 'react'; import { ViewItem } from 'src/types'; import { Node } from './Node/Node'; interface Props { nodes: ViewItem[]; } export const Nodes = ({ nodes }: Props) => { return ( <> {[...nodes].reverse().map((node) => { return ( ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Rectangles/Rectangle.tsx ================================================ import React, { memo } from 'react'; import { useScene } from 'src/hooks/useScene'; import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea'; import { getColorVariant } from 'src/utils'; import { useColor } from 'src/hooks/useColor'; type Props = ReturnType['rectangles'][0]; export const Rectangle = memo(({ from, to, color: colorId, customColor }: Props) => { const predefinedColor = useColor(colorId); // Use custom color if provided, otherwise use predefined color const color = customColor ? { value: customColor } : predefinedColor; if (!color) { return null; } return ( ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/Rectangles/Rectangles.tsx ================================================ import React from 'react'; import { useScene } from 'src/hooks/useScene'; import { Rectangle } from './Rectangle'; interface Props { rectangles: ReturnType['rectangles']; } export const Rectangles = ({ rectangles }: Props) => { return ( <> {[...rectangles].reverse().map((rectangle) => { return ; })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/TextBoxes/TextBox.tsx ================================================ import React, { useMemo, memo } from 'react'; import { Box, Typography } from '@mui/material'; import { toPx, CoordsUtils } from 'src/utils'; import { useIsoProjection } from 'src/hooks/useIsoProjection'; import { useTextBoxProps } from 'src/hooks/useTextBoxProps'; import { useScene } from 'src/hooks/useScene'; interface Props { textBox: ReturnType['textBoxes'][0]; } export const TextBox = memo(({ textBox }: Props) => { const { paddingX, fontProps } = useTextBoxProps(textBox); const to = useMemo(() => { return CoordsUtils.add(textBox.tile, { x: textBox.size.width, y: 0 }); }, [textBox.tile, textBox.size.width]); const { css } = useIsoProjection({ from: textBox.tile, to, orientation: textBox.orientation }); return ( {textBox.content} ); }); ================================================ FILE: packages/fossflow-lib/src/components/SceneLayers/TextBoxes/TextBoxes.tsx ================================================ import React from 'react'; import { useScene } from 'src/hooks/useScene'; import { TextBox } from './TextBox'; interface Props { textBoxes: ReturnType['textBoxes']; } export const TextBoxes = ({ textBoxes }: Props) => { return ( <> {[...textBoxes].reverse().map((textBox) => { return ; })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/SettingsDialog/SettingsDialog.tsx ================================================ import React, { useState } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, IconButton, Tabs, Tab, Box } from '@mui/material'; import { Close as CloseIcon } from '@mui/icons-material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { HotkeySettings } from '../HotkeySettings/HotkeySettings'; import { PanSettings } from '../PanSettings/PanSettings'; import { ZoomSettings } from '../ZoomSettings/ZoomSettings'; import { LabelSettings } from '../LabelSettings/LabelSettings'; import { ConnectorSettings } from '../ConnectorSettings/ConnectorSettings'; import { IconPackSettings } from '../IconPackSettings/IconPackSettings'; import { useTranslation } from 'src/stores/localeStore'; export interface SettingsDialogProps { iconPackManager?: { lazyLoadingEnabled: boolean; onToggleLazyLoading: (enabled: boolean) => void; packInfo: Array<{ name: string; displayName: string; loaded: boolean; loading: boolean; error: string | null; iconCount: number; }>; enabledPacks: string[]; onTogglePack: (packName: string, enabled: boolean) => void; }; } export const SettingsDialog = ({ iconPackManager }: SettingsDialogProps) => { const dialog = useUiStateStore((state) => state.dialog); const setDialog = useUiStateStore((state) => state.actions.setDialog); const [tabValue, setTabValue] = useState(0); const { t } = useTranslation(); const isOpen = dialog === 'SETTINGS'; const handleClose = () => { setDialog(null); }; const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setTabValue(newValue); }; return ( Settings theme.palette.grey[500], }} > {iconPackManager && } {tabValue === 0 && } {tabValue === 1 && } {tabValue === 2 && } {tabValue === 3 && } {tabValue === 4 && } {tabValue === 5 && iconPackManager && ( )} ); }; ================================================ FILE: packages/fossflow-lib/src/components/Svg/Svg.tsx ================================================ import React, { useMemo } from 'react'; import { Size } from 'src/types'; type Props = React.SVGProps & { children: React.ReactNode; style?: React.CSSProperties; viewboxSize?: Size; }; export const Svg = ({ children, style, viewboxSize, ...rest }: Props) => { const dimensionProps = useMemo(() => { if (!viewboxSize) return {}; return { viewBox: `0 0 ${viewboxSize.width} ${viewboxSize.height}`, width: `${viewboxSize.width}px`, height: `${viewboxSize.height}px` }; }, [viewboxSize]); return ( {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ToolMenu/ToolMenu.tsx ================================================ import React, { useCallback } from 'react'; import { Stack, Divider } from '@mui/material'; import { PanToolOutlined as PanToolIcon, NearMeOutlined as NearMeIcon, AddOutlined as AddIcon, EastOutlined as ConnectorIcon, CropSquareOutlined as CropSquareIcon, Title as TitleIcon, Undo as UndoIcon, Redo as RedoIcon, Help as HelpIcon, HighlightAltOutlined as LassoIcon, GestureOutlined as FreehandLassoIcon } from '@mui/icons-material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { IconButton } from 'src/components/IconButton/IconButton'; import { UiElement } from 'src/components/UiElement/UiElement'; import { useScene } from 'src/hooks/useScene'; import { useHistory } from 'src/hooks/useHistory'; import { TEXTBOX_DEFAULTS } from 'src/config'; import { generateId } from 'src/utils'; import { HOTKEY_PROFILES } from 'src/config/hotkeys'; export const ToolMenu = () => { const { createTextBox } = useScene(); const { undo, redo, canUndo, canRedo } = useHistory(); const mode = useUiStateStore((state) => { return state.mode; }); const uiStateStoreActions = useUiStateStore((state) => { return state.actions; }); const mousePosition = useUiStateStore((state) => { return state.mouse.position.tile; }); const hotkeyProfile = useUiStateStore((state) => { return state.hotkeyProfile; }); const hotkeys = HOTKEY_PROFILES[hotkeyProfile]; const handleUndo = useCallback(() => { undo(); }, [undo]); const handleRedo = useCallback(() => { redo(); }, [redo]); const createTextBoxProxy = useCallback(() => { const textBoxId = generateId(); createTextBox({ ...TEXTBOX_DEFAULTS, id: textBoxId, tile: mousePosition }); uiStateStoreActions.setMode({ type: 'TEXTBOX', showCursor: false, id: textBoxId }); }, [uiStateStoreActions, createTextBox, mousePosition]); return ( {/* Undo/Redo Section */} } onClick={handleUndo} disabled={!canUndo} /> } onClick={handleRedo} disabled={!canRedo} /> {/* Main Tools */} } onClick={() => { uiStateStoreActions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); }} isActive={mode.type === 'CURSOR' || mode.type === 'DRAG_ITEMS'} /> } onClick={() => { uiStateStoreActions.setMode({ type: 'LASSO', showCursor: true, selection: null, isDragging: false }); }} isActive={mode.type === 'LASSO'} /> } onClick={() => { uiStateStoreActions.setMode({ type: 'FREEHAND_LASSO', showCursor: true, path: [], selection: null, isDragging: false }); }} isActive={mode.type === 'FREEHAND_LASSO'} /> } onClick={() => { uiStateStoreActions.setMode({ type: 'PAN', showCursor: false }); uiStateStoreActions.setItemControls(null); }} isActive={mode.type === 'PAN'} /> } onClick={() => { uiStateStoreActions.setItemControls({ type: 'ADD_ITEM' }); uiStateStoreActions.setMode({ type: 'PLACE_ICON', showCursor: true, id: null }); }} isActive={mode.type === 'PLACE_ICON'} /> } onClick={() => { uiStateStoreActions.setMode({ type: 'RECTANGLE.DRAW', showCursor: true, id: null }); }} isActive={mode.type === 'RECTANGLE.DRAW'} /> } onClick={() => { uiStateStoreActions.setMode({ type: 'CONNECTOR', id: null, showCursor: true }); }} isActive={mode.type === 'CONNECTOR'} /> } onClick={createTextBoxProxy} isActive={mode.type === 'TEXTBOX'} /> ); }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/NodeTransformControls.tsx ================================================ import React from 'react'; import { useViewItem } from 'src/hooks/useViewItem'; import { TransformControls } from './TransformControls'; interface Props { id: string; } export const NodeTransformControls = ({ id }: Props) => { const node = useViewItem(id); if (!node) { return null; } return ; }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/RectangleTransformControls.tsx ================================================ import React, { useCallback } from 'react'; import { useRectangle } from 'src/hooks/useRectangle'; import { AnchorPosition } from 'src/types'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { TransformControls } from './TransformControls'; interface Props { id: string; } export const RectangleTransformControls = ({ id }: Props) => { const rectangle = useRectangle(id); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const onAnchorMouseDown = useCallback( (key: AnchorPosition) => { if (!rectangle) return; uiStateActions.setMode({ type: 'RECTANGLE.TRANSFORM', id: rectangle.id, selectedAnchor: key, showCursor: true }); }, [rectangle?.id, uiStateActions] ); if (!rectangle) { return null; } return ( ); }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/TextBoxTransformControls.tsx ================================================ import React, { useMemo } from 'react'; import { getTextBoxEndTile } from 'src/utils'; import { useTextBox } from 'src/hooks/useTextBox'; import { TransformControls } from './TransformControls'; interface Props { id: string; } export const TextBoxTransformControls = ({ id }: Props) => { const textBox = useTextBox(id); const to = useMemo(() => { if (!textBox) return { x: 0, y: 0 }; return getTextBoxEndTile(textBox, textBox.size); }, [textBox]); if (!textBox) { return null; } return ; }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/TransformAnchor.tsx ================================================ import React, { useState } from 'react'; import { Coords } from 'src/types'; import { useTheme, Box } from '@mui/material'; import { getIsoProjectionCss } from 'src/utils'; import { Svg } from 'src/components/Svg/Svg'; import { TRANSFORM_ANCHOR_SIZE, TRANSFORM_CONTROLS_COLOR } from 'src/config'; interface Props { position: Coords; onMouseDown: () => void; } const strokeWidth = 2; export const TransformAnchor = ({ position, onMouseDown }: Props) => { const [isHovered, setIsHovered] = useState(false); const theme = useTheme(); return ( { setIsHovered(true); }} onMouseOut={() => { setIsHovered(false); }} onMouseDown={onMouseDown} sx={{ position: 'absolute', transform: getIsoProjectionCss(), width: TRANSFORM_ANCHOR_SIZE, height: TRANSFORM_ANCHOR_SIZE }} style={{ left: position.x - TRANSFORM_ANCHOR_SIZE / 2, top: position.y - TRANSFORM_ANCHOR_SIZE / 2 }} > ); }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/TransformControls.tsx ================================================ import React, { useMemo } from 'react'; import { Coords, AnchorPosition } from 'src/types'; import { Svg } from 'src/components/Svg/Svg'; import { TRANSFORM_CONTROLS_COLOR } from 'src/config'; import { useIsoProjection } from 'src/hooks/useIsoProjection'; import { getBoundingBox, outermostCornerPositions, getTilePosition, convertBoundsToNamedAnchors } from 'src/utils'; import { TransformAnchor } from './TransformAnchor'; interface Props { from: Coords; to: Coords; onAnchorMouseDown?: (anchorPosition: AnchorPosition) => void; } const strokeWidth = 2; export const TransformControls = ({ from, to, onAnchorMouseDown }: Props) => { const { css, pxSize } = useIsoProjection({ from, to }); const anchors = useMemo(() => { if (!onAnchorMouseDown) return []; const corners = getBoundingBox([from, to]); const namedCorners = convertBoundsToNamedAnchors(corners); const cornerPositions = Object.entries(namedCorners).map( ([key, value], i) => { const position = getTilePosition({ tile: value, origin: outermostCornerPositions[i] }); return { position, onMouseDown: () => { onAnchorMouseDown(key as AnchorPosition); } }; } ); return cornerPositions; }, [onAnchorMouseDown, from, to]); return ( <> {anchors.map(({ position, onMouseDown }) => { return ( ); })} ); }; ================================================ FILE: packages/fossflow-lib/src/components/TransformControlsManager/TransformControlsManager.tsx ================================================ import React from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { RectangleTransformControls } from './RectangleTransformControls'; import { TextBoxTransformControls } from './TextBoxTransformControls'; import { NodeTransformControls } from './NodeTransformControls'; export const TransformControlsManager = () => { const itemControls = useUiStateStore((state) => { return state.itemControls; }); switch (itemControls?.type) { case 'ITEM': return ; case 'RECTANGLE': return ; case 'TEXTBOX': return ; default: return null; } }; ================================================ FILE: packages/fossflow-lib/src/components/UiElement/UiElement.tsx ================================================ import React from 'react'; import { Card, SxProps } from '@mui/material'; interface Props { children: React.ReactNode; sx?: SxProps; style?: React.CSSProperties; } export const UiElement = ({ children, sx, style }: Props) => { return ( {children} ); }; ================================================ FILE: packages/fossflow-lib/src/components/UiOverlay/UiOverlay.tsx ================================================ import React, { useCallback, useMemo, useRef } from 'react'; import { Box, useTheme, Typography, Stack } from '@mui/material'; import { ChevronRight } from '@mui/icons-material'; import { EditorModeEnum, DialogTypeEnum } from 'src/types'; import { UiElement } from 'components/UiElement/UiElement'; import { SceneLayer } from 'src/components/SceneLayer/SceneLayer'; import { DragAndDrop } from 'src/components/DragAndDrop/DragAndDrop'; import { ItemControlsManager } from 'src/components/ItemControls/ItemControlsManager'; import { ToolMenu } from 'src/components/ToolMenu/ToolMenu'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { MainMenu } from 'src/components/MainMenu/MainMenu'; import { ZoomControls } from 'src/components/ZoomControls/ZoomControls'; import { DebugUtils } from 'src/components/DebugUtils/DebugUtils'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; import { ContextMenuManager } from 'src/components/ContextMenu/ContextMenuManager'; import { useScene } from 'src/hooks/useScene'; import { useModelStore } from 'src/stores/modelStore'; import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog'; import { HelpDialog } from '../HelpDialog/HelpDialog'; import { SettingsDialog } from '../SettingsDialog/SettingsDialog'; import { ConnectorHintTooltip } from '../ConnectorHintTooltip/ConnectorHintTooltip'; import { ConnectorEmptySpaceTooltip } from '../ConnectorEmptySpaceTooltip/ConnectorEmptySpaceTooltip'; import { ConnectorRerouteTooltip } from '../ConnectorRerouteTooltip/ConnectorRerouteTooltip'; import { ImportHintTooltip } from '../ImportHintTooltip/ImportHintTooltip'; import { LassoHintTooltip } from '../LassoHintTooltip/LassoHintTooltip'; import { LazyLoadingWelcomeNotification } from '../LazyLoadingWelcomeNotification/LazyLoadingWelcomeNotification'; import { CoordsUtils, getTilePosition } from 'src/utils'; const ToolsEnum = { MAIN_MENU: 'MAIN_MENU', ZOOM_CONTROLS: 'ZOOM_CONTROLS', TOOL_MENU: 'TOOL_MENU', ITEM_CONTROLS: 'ITEM_CONTROLS', VIEW_TITLE: 'VIEW_TITLE' } as const; interface EditorModeMapping { [k: string]: (keyof typeof ToolsEnum)[]; } const EDITOR_MODE_MAPPING: EditorModeMapping = { [EditorModeEnum.EDITABLE]: [ 'ITEM_CONTROLS', 'ZOOM_CONTROLS', 'TOOL_MENU', 'MAIN_MENU', 'VIEW_TITLE' ], [EditorModeEnum.EXPLORABLE_READONLY]: ['ZOOM_CONTROLS', 'VIEW_TITLE'], [EditorModeEnum.NON_INTERACTIVE]: [] }; const getEditorModeMapping = (editorMode: keyof typeof EditorModeEnum) => { const availableUiFeatures = EDITOR_MODE_MAPPING[editorMode]; return availableUiFeatures; }; export const UiOverlay = () => { const theme = useTheme(); const contextMenuAnchorRef = useRef(null); const toolMenuRef = useRef(null); const { appPadding } = theme.customVars; const spacing = useCallback( (multiplier: number) => { return parseInt(theme.spacing(multiplier), 10); }, [theme] ); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const enableDebugTools = useUiStateStore((state) => { return state.enableDebugTools; }); const mode = useUiStateStore((state) => { return state.mode; }); const mouse = useUiStateStore((state) => { return state.mouse; }); const dialog = useUiStateStore((state) => { return state.dialog; }); const itemControls = useUiStateStore((state) => { return state.itemControls; }); const { currentView } = useScene(); const editorMode = useUiStateStore((state) => { return state.editorMode; }); const availableTools = useMemo(() => { return getEditorModeMapping(editorMode); }, [editorMode]); const rendererEl = useUiStateStore((state) => { return state.rendererEl; }); const title = useModelStore((state) => { return state.title; }); const iconPackManager = useUiStateStore((state) => { return state.iconPackManager; }); const contextMenu = useUiStateStore((state) => { return state.contextMenu; }); const { size: rendererSize } = useResizeObserver(rendererEl); return ( <> {availableTools.includes('ITEM_CONTROLS') && itemControls && ( )} {availableTools.includes('TOOL_MENU') && ( )} {availableTools.includes('ZOOM_CONTROLS') && ( )} {availableTools.includes('MAIN_MENU') && ( )} {availableTools.includes('VIEW_TITLE') && ( {title} {currentView.name} )} {enableDebugTools && ( )} {mode.type === 'PLACE_ICON' && mode.id && ( )} {dialog === DialogTypeEnum.EXPORT_IMAGE && ( { return uiStateActions.setDialog(null); }} /> )} {dialog === DialogTypeEnum.HELP && } {dialog === DialogTypeEnum.SETTINGS && } {/* Show hint tooltips only in editable mode */} {editorMode === EditorModeEnum.EDITABLE && } {editorMode === EditorModeEnum.EDITABLE && } {editorMode === EditorModeEnum.EDITABLE && } {editorMode === EditorModeEnum.EDITABLE && } {editorMode === EditorModeEnum.EDITABLE && } {/* Show lazy loading welcome notification if icon pack manager is provided */} {iconPackManager && } {contextMenu && ( )} ); }; ================================================ FILE: packages/fossflow-lib/src/components/ZoomControls/ZoomControls.tsx ================================================ import React from 'react'; import { Add as ZoomInIcon, Remove as ZoomOutIcon, CropFreeOutlined as FitToScreenIcon, Help as HelpIcon } from '@mui/icons-material'; import { Stack, Box, Typography, Divider } from '@mui/material'; import { toPx } from 'src/utils'; import { UiElement } from 'src/components/UiElement/UiElement'; import { IconButton } from 'src/components/IconButton/IconButton'; import { MAX_ZOOM, MIN_ZOOM } from 'src/config'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; import { DialogTypeEnum } from 'src/types/ui'; export const ZoomControls = () => { const uiStateStoreActions = useUiStateStore((state) => { return state.actions; }); const zoom = useUiStateStore((state) => { return state.zoom; }); const { fitToView } = useDiagramUtils(); return ( } onClick={uiStateStoreActions.decrementZoom} disabled={zoom >= MAX_ZOOM} /> {Math.ceil(zoom * 100)}% } onClick={uiStateStoreActions.incrementZoom} disabled={zoom <= MIN_ZOOM} /> } onClick={fitToView} /> } onClick={() => { return uiStateStoreActions.setDialog(DialogTypeEnum.HELP); }} /> ); }; ================================================ FILE: packages/fossflow-lib/src/components/ZoomSettings/ZoomSettings.tsx ================================================ import React from 'react'; import { Box, FormControl, FormGroup, FormControlLabel, Switch, Typography } from '@mui/material'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useLocale } from 'src/stores/localeStore'; export const ZoomSettings = () => { const zoomSettings = useUiStateStore((state) => state.zoomSettings); const setZoomSettings = useUiStateStore((state) => state.actions.setZoomSettings); const locale = useLocale(); const handleToggle = (setting: keyof typeof zoomSettings) => { setZoomSettings({ ...zoomSettings, [setting]: !zoomSettings[setting] }); }; return ( {locale.settings.zoom.description} handleToggle('zoomToCursor')} /> } label={ {locale.settings.zoom.zoomToCursor} {locale.settings.zoom.zoomToCursorDesc} } /> ); }; ================================================ FILE: packages/fossflow-lib/src/config/hotkeys.ts ================================================ export type HotkeyProfile = 'qwerty' | 'smnrct' | 'none'; export interface HotkeyMapping { select: string | null; pan: string | null; addItem: string | null; rectangle: string | null; connector: string | null; text: string | null; lasso: string | null; freehandLasso: string | null; } export const HOTKEY_PROFILES: Record = { qwerty: { select: 'q', pan: 'w', addItem: 'e', rectangle: 'r', connector: 't', text: 'y', lasso: 'l', freehandLasso: 'f' }, smnrct: { select: 's', pan: 'm', addItem: 'n', rectangle: 'r', connector: 'c', text: 't', lasso: 'l', freehandLasso: 'f' }, none: { select: null, pan: null, addItem: null, rectangle: null, connector: null, text: null, lasso: null, freehandLasso: null } }; export const DEFAULT_HOTKEY_PROFILE: HotkeyProfile = 'smnrct'; ================================================ FILE: packages/fossflow-lib/src/config/labelSettings.ts ================================================ export interface LabelSettings { expandButtonPadding: number; // Padding in theme units when expand button is visible } export const DEFAULT_LABEL_SETTINGS: LabelSettings = { expandButtonPadding: 0 // Default 0 theme units (no extra padding) }; ================================================ FILE: packages/fossflow-lib/src/config/panSettings.ts ================================================ export interface PanSettings { // Mouse pan options middleClickPan: boolean; rightClickPan: boolean; ctrlClickPan: boolean; altClickPan: boolean; emptyAreaClickPan: boolean; // Keyboard pan options arrowKeysPan: boolean; wasdPan: boolean; ijklPan: boolean; // Pan speed keyboardPanSpeed: number; } export const DEFAULT_PAN_SETTINGS: PanSettings = { // Mouse options - start with common defaults middleClickPan: true, rightClickPan: false, ctrlClickPan: false, altClickPan: false, emptyAreaClickPan: true, // Keyboard options arrowKeysPan: true, wasdPan: false, ijklPan: false, // Pan speed (pixels per key press) keyboardPanSpeed: 20 }; ================================================ FILE: packages/fossflow-lib/src/config/zoomSettings.ts ================================================ export interface ZoomSettings { // Zoom behavior zoomToCursor: boolean; } export const DEFAULT_ZOOM_SETTINGS: ZoomSettings = { // Default to zoom-to-cursor for better UX zoomToCursor: true }; ================================================ FILE: packages/fossflow-lib/src/config.ts ================================================ import { Size, InitialData, MainMenuOptions, Icon, Connector, TextBox, ViewItem, View, Rectangle, Colors } from 'src/types'; import { CoordsUtils } from 'src/utils'; import { customVars } from './styles/theme'; // TODO: This file could do with better organisation and convention for easier reading. export const UNPROJECTED_TILE_SIZE = 100; export const TILE_PROJECTION_MULTIPLIERS: Size = { width: 1.415, height: 0.819 }; export const PROJECTED_TILE_SIZE = { width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width, height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height }; export const DEFAULT_COLOR: Colors[0] = { id: '__DEFAULT__', value: customVars.customPalette.defaultColor }; export const DEFAULT_FONT_FAMILY = 'Roboto, Arial, sans-serif'; export const VIEW_DEFAULTS: Required< Omit > = { name: 'Untitled view', items: [], connectors: [], rectangles: [], textBoxes: [] }; export const VIEW_ITEM_DEFAULTS: Required> = { labelHeight: 80 }; export const CONNECTOR_DEFAULTS: Required> = { width: 10, description: '', startLabel: '', endLabel: '', startLabelHeight: 0, centerLabelHeight: 0, endLabelHeight: 0, labels: [], customColor: '', anchors: [], style: 'SOLID', lineType: 'SINGLE', showArrow: true }; // The boundaries of the search area for the pathfinder algorithm // is the grid that encompasses the two nodes + the offset below. export const CONNECTOR_SEARCH_OFFSET = { x: 1, y: 1 }; export const TEXTBOX_DEFAULTS: Required> = { orientation: 'X', fontSize: 0.6, content: 'Text' }; export const TEXTBOX_PADDING = 0.2; export const TEXTBOX_FONT_WEIGHT = 'bold'; export const RECTANGLE_DEFAULTS: Required< Omit > = { customColor: '' }; export const ZOOM_INCREMENT = 0.05; export const MIN_ZOOM = 0.1; export const MAX_ZOOM = 1; export const TRANSFORM_ANCHOR_SIZE = 30; export const TRANSFORM_CONTROLS_COLOR = '#0392ff'; export const INITIAL_DATA: InitialData = { title: 'Untitled', version: '', icons: [], colors: [DEFAULT_COLOR], items: [], views: [], fitToView: false }; export const INITIAL_UI_STATE = { zoom: 1, scroll: { position: CoordsUtils.zero(), offset: CoordsUtils.zero() } }; export const INITIAL_SCENE_STATE = { connectors: {}, textBoxes: {} }; export const MAIN_MENU_OPTIONS: MainMenuOptions = [ 'ACTION.OPEN', 'EXPORT.JSON', 'EXPORT.PNG', 'ACTION.CLEAR_CANVAS', 'LINK.DISCORD', 'LINK.GITHUB', 'VERSION' ]; export const DEFAULT_ICON: Icon = { id: 'default', name: 'block', isIsometric: true, url: '' }; export const DEFAULT_LABEL_HEIGHT = 20; export const PROJECT_BOUNDING_BOX_PADDING = 3; export const MARKDOWN_EMPTY_VALUE = '


'; ================================================ FILE: packages/fossflow-lib/src/examples/BasicEditor/BasicEditor.tsx ================================================ import React from 'react'; import Isoflow from 'src/Isoflow'; import { initialData } from '../initialData'; export const BasicEditor = () => { return ; }; ================================================ FILE: packages/fossflow-lib/src/examples/DebugTools/DebugTools.tsx ================================================ import React from 'react'; import Isoflow from 'src/Isoflow'; import { initialData } from '../initialData'; export const DebugTools = () => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/examples/ReadonlyMode/ReadonlyMode.tsx ================================================ import React from 'react'; import Isoflow from 'src/Isoflow'; import { initialData } from '../initialData'; export const ReadonlyMode = () => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/examples/index.tsx ================================================ import React, { useState, useMemo } from 'react'; import { Box, Select, MenuItem, useTheme } from '@mui/material'; import { BasicEditor } from './BasicEditor/BasicEditor'; import { DebugTools } from './DebugTools/DebugTools'; import { ReadonlyMode } from './ReadonlyMode/ReadonlyMode'; const examples = [ { name: 'Basic editor', component: BasicEditor }, { name: 'Debug tools', component: DebugTools }, { name: 'Read-only mode', component: ReadonlyMode } ]; export const Examples = () => { const theme = useTheme(); const [currentExample, setCurrentExample] = useState(0); const Example = useMemo(() => { return examples[currentExample].component; }, [currentExample]); return ( {Example && } ); }; ================================================ FILE: packages/fossflow-lib/src/examples/initialData.ts ================================================ /* eslint-disable import/no-extraneous-dependencies */ import { Colors, Icons, InitialData } from 'src/Isoflow'; import { flattenCollections } from '@isoflow/isopacks/dist/utils'; import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; import awsIsopack from '@isoflow/isopacks/dist/aws'; import gcpIsopack from '@isoflow/isopacks/dist/gcp'; import azureIsopack from '@isoflow/isopacks/dist/azure'; import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes'; const isopacks = flattenCollections([ isoflowIsopack, awsIsopack, azureIsopack, gcpIsopack, kubernetesIsopack ]); export const colors: Colors = [ { id: 'color1', value: '#a5b8f3' }, { id: 'color2', value: '#bbadfb' }, { id: 'color3', value: '#f4eb8e' }, { id: 'color4', value: '#f0aca9' }, { id: 'color5', value: '#fad6ac' }, { id: 'color6', value: '#a8dc9d' }, { id: 'color7', value: '#b3e5e3' } ]; export const icons: Icons = isopacks; export const initialData: InitialData = { title: 'Airport management software system', icons, colors, items: [ { id: 'item1', name: 'Airport Operational Database', icon: 'storage', description: '

Each airport has its own central database that stores and updates all necessary data regarding daily flights, seasonal schedules, available resources, and other flight-related information, like billing data and flight fees. AODB is a key feature for the functioning of an airport.


This database is connected to the rest of the airport modules: airport information systems, revenue management systems, and air traffic management.


The system can supply different information for different segments of users: passengers, airport staff, crew, or members of specific departments, authorities, business partners, or police.


AODB represents the information on a graphical display.


AODB functions include:

- Reference-data processing

- Seasonal scheduling

- Daily flight schedule processing

- Processing of payments

' }, { id: 'bc6fdded-a090-4eae-b1fe-fe0ee0fd1c92', name: 'Landside operations', icon: 'office', description: '

This subsystem is aimed at serving passengers and maintenance of terminal buildings, parking facilities, and vehicular traffic circular drives. Passenger operations include baggage handling and tagging.

' }, { id: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f', name: 'Passenger facilitation services', icon: 'user', description: '

Includes passenger processing (check-in, boarding, border control) and baggage handling (tagging, dropping and handling). They follow passengers to the shuttle buses to carry them to their flights. Arrival operations include boarding control and baggage handling.

' }, { id: 'a147b06a-324a-47ab-9e16-ac9101aa3d28', name: 'Border control (customs and security services)', icon: 'block', description: '

In airports, security services usually unite perimeter security, terminal security, and border controls. These services require biometric authentication and integration into government systems to allow a customs officer to view the status of a passenger.

' }, { id: '4a27ed88-abf2-448b-af07-5d2b6ebdb67f', name: 'Common use services (self-service check-in systems)', icon: 'block', description: '

An airport must ensure smooth passenger flow. Various digital self-services, like check-in kiosks or automated self-service gates, make it happen. Self-service options, especially check-in kiosks, remain popular. Worldwide in 2018, passengers used kiosks to check themselves in 88 percent of the time.

' }, { id: 'c54ab120-44d2-46d2-9fc1-efd83ab67307', name: 'Baggage handling', icon: 'block', description: '

A passenger must check a bag before it’s loaded on the aircraft. The time the baggage is loaded is displayed and tracked until the destination is reached and the bag is returned to the owners.

' }, { id: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05', name: 'Terminal management systems', icon: 'function-module', description: '

Includes maintenance and monitoring of management systems for assets, buildings, electrical grids, environmental systems, and vertical transportation organization. It also facilitates staff communications and management.

' }, { id: '040dfb11-f920-48cf-bf96-64234db1b7e8', name: 'Maintenance and monitoring', icon: 'block' }, { id: 'a71d7911-261d-4b6e-895a-27765baf0403', name: 'Resource management', icon: 'block' }, { id: '67895813-ac6f-4dd4-9ae2-e994e9a5aa09', name: 'Staff management', icon: 'block', description: '

Staff modules provide the necessary information about ongoing processes in the airport, such as data on flights (in ICAO or UTC formats) and other important events to keep responsible staff members updated. Information is distributed through the airport radio system, or displayed on a PC connected via the airport LAN or on mobile devices.

' }, { id: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a', name: 'Information management', icon: 'queue', description: '

This subsystem is responsible for the collection and distribution of daily flight information, storing of seasonal and arrival/departure information, as well as the connection with airlines.

' }, { id: '00ff4dc0-09f9-4932-aa90-6c207da2989b', name: 'Public address (PA) systems', icon: 'block', description: '

Informs passengers and airport staff about any changes and processes of importance, for instance, gates, times of arrival, calls, and alerts. Also, information can be communicated to pilots, aircraft staff, crew, etc. PA systems usually include voice messages broadcasted through loudspeakers.

' }, { id: '791abb72-5481-4713-88a8-a9fe51cb5408', name: 'Flight Information Display Systems (FIDS)', icon: 'block', description: '

Exhibits the status of boarding, gates, aircraft, flight number, and other flight details. A computer controls the screens that are connected to the data management systems and displays up-to-date information about flights in real time. Some airports have a digital FIDS in the form of apps or on their websites. Also, the displays may show other public information such as the weather, news, safety messages, menus, and advertising. Airports can choose the type, languages, and means of entering the information, whether it be manually or loaded from a central database.

' }, { id: 'fe621de2-793b-42f9-968e-4cac33b8d5fe', name: 'Automatic Terminal Information Service (ATIS)', icon: 'block', description: '

Broadcasts the weather reports, the condition of the runway, or other local information for pilots and crews.


Some airport software vendors offer off-the-shelf solutions to facilitate particular tasks, like maintenance, or airport operations. However, most of them provide integrated systems that comprise modules for several operations.

' }, { id: '24d4a8b3-6056-4c3f-8f0b-143683509438', name: 'Airside operations', icon: 'plane', description: '

Includes systems to handle aircraft landing and navigation, airport traffic management, runway management, and ground handling safety.

' }, { id: '2ac34480-95cc-4b01-8efd-683ec46fcd68', name: 'Apron handling', icon: 'block', description: '

Apron (or ground handling) deals with aircraft servicing. This includes passenger boarding and guidance, cargo and mail loading, and apron services. Apron services include aircraft guiding, cleaning, drainage, deicing, catering, and fueling. At this stage, the software facilitates dealing with information about the weight of the baggage and cargo load, number of passengers, boarding bridges parking, and the ground services that must be supplied to the aircraft. By entering this information into the system, their costs can be calculated and invoiced through the billing system.

' }, { id: '9172d115-93ae-4e89-bd75-4979b7f8a49a', name: 'ATC Tower', icon: 'block', description: '

The Air Traffic Control Tower is a structure that delivers air and ground control of the aircraft. It ensures safety by guiding and navigating the vehicles and aircraft. It is performed by way of visual signaling, radar, and radio communication in the air and on the ground. The main focus of the tower is to make sure that all aircraft have been assigned to the right place, that passengers aren’t at risk, and that the aircraft will have a suitable passenger boarding bridge allocated on the apron.


The ATC tower has a control room that serves as a channel between landside (terminal) and airside operations in airports. The control room personnel are tasked with ensuring the security and safety of the passengers as well as ground handling. Usually, a control room has CCTV monitors and air traffic control systems that maintain the order in the terminal and on the apron.

' }, { id: '2db4a232-2cf3-4277-9cd4-e2c0a35a4eac', name: 'Aeronautical Fixed Telecommunication Network (AFTN) Systems', icon: 'block', description: '

AFTN systems handle communication and exchange of data including navigation services. Usually, airports exchange traffic environment messages, safety messages, information about the weather, geographic material, disruptions, etc. They serve as communication between airports and aircraft.


Software for aeronautical telecommunications stores flight plans and flight information, entered in ICAO format and UTC. The information stored can be used for planning and statistical purposes. For airports, it’s important to understand the aircraft type and its weight to assign it to the right place on the runway. AFTN systems hold the following information:


- Aircraft registration

- Runway used

- Actual time of landing and departure

- Number of circuits

- Number and type of approaches

- New estimates of arrival and departure

- New flight information


Air traffic management is performed from an ATC tower.

' }, { id: 'b46088d6-7bd4-4ccf-9d35-cf56a891d869', name: 'Invoicing and billing', icon: 'paymentcard', description: '

Each flight an airport handles generates a defined revenue for the airport paid by the airline operating the aircraft. Aeronautical invoicing systems make payment possible for any type and size of aircraft. It accepts payments in cash and credit in multiple currencies. The billing also extends to ATC services.


Depending on the aircraft type and weight and ground services provided, an airport can calculate the aeronautical fee and issue an invoice with a bill. It is calculated using the following data:


- Aircraft registration

- Parking time at the airport

- Airport point of departure and/or landing

- Times at the different points of entry or departure


The data is entered or integrated from ATC. Based on this information, the airport calculates the charges and sends the bills.

' }, { id: 'afa7b887-8aff-45a6-86fa-7a896626e920', name: 'ATC Tower Billing', icon: 'block' }, { id: 'd917b7d7-a5c4-479e-a366-da8d22ea8ebb', name: 'Non Aeronautical revenue', icon: 'block' } ], views: [ { id: 'overview', name: 'Overview', items: [ { labelHeight: 80, id: 'd917b7d7-a5c4-479e-a366-da8d22ea8ebb', tile: { x: 5, y: -11 } }, { labelHeight: 80, id: 'afa7b887-8aff-45a6-86fa-7a896626e920', tile: { x: 2, y: -11 } }, { labelHeight: 80, id: 'b46088d6-7bd4-4ccf-9d35-cf56a891d869', tile: { x: 4, y: -7 } }, { labelHeight: 80, id: '2db4a232-2cf3-4277-9cd4-e2c0a35a4eac', tile: { x: 16, y: -3 } }, { labelHeight: 80, id: '9172d115-93ae-4e89-bd75-4979b7f8a49a', tile: { x: 16, y: 0 } }, { labelHeight: 80, id: '2ac34480-95cc-4b01-8efd-683ec46fcd68', tile: { x: 16, y: 3 } }, { labelHeight: 80, id: '24d4a8b3-6056-4c3f-8f0b-143683509438', tile: { x: 11, y: 0 } }, { labelHeight: 80, id: 'fe621de2-793b-42f9-968e-4cac33b8d5fe', tile: { x: 7, y: 12 } }, { labelHeight: 80, id: '791abb72-5481-4713-88a8-a9fe51cb5408', tile: { x: 4, y: 12 } }, { labelHeight: 80, id: '00ff4dc0-09f9-4932-aa90-6c207da2989b', tile: { x: 1, y: 12 } }, { labelHeight: 80, id: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a', tile: { x: 4, y: 6 } }, { labelHeight: 80, id: '67895813-ac6f-4dd4-9ae2-e994e9a5aa09', tile: { x: -11, y: 8 } }, { labelHeight: 80, id: 'a71d7911-261d-4b6e-895a-27765baf0403', tile: { x: -8, y: 8 } }, { labelHeight: 80, id: '040dfb11-f920-48cf-bf96-64234db1b7e8', tile: { x: -5, y: 8 } }, { labelHeight: 80, id: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05', tile: { x: -5, y: 4 } }, { labelHeight: 80, id: 'c54ab120-44d2-46d2-9fc1-efd83ab67307', tile: { x: -11, y: -9 } }, { labelHeight: 80, id: '4a27ed88-abf2-448b-af07-5d2b6ebdb67f', tile: { x: -8, y: -9 } }, { labelHeight: 80, id: 'a147b06a-324a-47ab-9e16-ac9101aa3d28', tile: { x: -5, y: -9 } }, { labelHeight: 180, id: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f', tile: { x: -5, y: -4 } }, { labelHeight: 180, id: 'bc6fdded-a090-4eae-b1fe-fe0ee0fd1c92', tile: { x: -4, y: 0 } }, { id: 'item1', tile: { x: 4, y: 0 }, labelHeight: 140 } ], connectors: [ { id: '527b88f3-4b50-4639-9802-cfc475cd08aa', color: 'color6', anchors: [ { id: 'abe857f8-6219-4030-b5cf-6a7de2bff9be', ref: { item: 'd917b7d7-a5c4-479e-a366-da8d22ea8ebb' } }, { id: '21b9d415-1429-4e68-9b35-46705c32e8a4', ref: { tile: { x: 5, y: -10 } } }, { id: '0e768e42-228d-44c5-bc30-c8d40ebb69c6', ref: { tile: { x: 4, y: -10 } } }, { id: 'c9d4b849-a044-4c43-9e8a-ec370cac7dd6', ref: { item: 'b46088d6-7bd4-4ccf-9d35-cf56a891d869' } } ], width: 10, description: '', style: 'SOLID' }, { id: '073ecd08-0bff-4274-81e7-2fe35b0ac085', color: 'color6', anchors: [ { id: 'aed7740d-75e5-471e-a9a0-01bbfb751a2c', ref: { item: 'afa7b887-8aff-45a6-86fa-7a896626e920' } }, { id: 'eccb2e42-64cd-446c-a23a-74b8f759fdd1', ref: { tile: { x: 2, y: -10 } } }, { id: 'd0f7054b-54e7-45f5-9e20-2ce766611477', ref: { tile: { x: 4, y: -10 } } }, { id: 'c6fa8a43-722a-49a9-84f4-b76114322b0d', ref: { item: 'b46088d6-7bd4-4ccf-9d35-cf56a891d869' } } ], width: 10, description: '', style: 'SOLID' }, { id: '170009dd-b855-4b91-ba49-07a998cf0485', color: 'color6', anchors: [ { id: '24f16db1-6a3c-44b4-978d-6aa66f0b049f', ref: { item: 'b46088d6-7bd4-4ccf-9d35-cf56a891d869' } }, { id: '034ffa18-b55b-4941-acd8-02dbd8c47bfb', ref: { item: 'item1' } } ] }, { id: 'ae8f457f-df03-4582-925d-4c81131608fa', color: 'color2', anchors: [ { id: '39a462b8-bc96-490b-849a-1199e44cfa8a', ref: { item: '2db4a232-2cf3-4277-9cd4-e2c0a35a4eac' } }, { id: 'f4d0339b-45c8-4eb7-a3be-94616a4969a5', ref: { tile: { x: 15, y: -3 } } }, { id: '3b1bc55b-ceb2-416d-8f1d-722273180e83', ref: { tile: { x: 15, y: 0 } } }, { id: '072adfbc-9888-434a-bae4-9639fff026a4', ref: { item: '24d4a8b3-6056-4c3f-8f0b-143683509438' } } ], width: 10, description: '', style: 'SOLID' }, { id: '4aba5eaf-e2b3-4b64-9af0-98cebafef64a', color: 'color2', anchors: [ { id: '2410836d-4820-4492-8c64-d89b069ce9ec', ref: { item: '9172d115-93ae-4e89-bd75-4979b7f8a49a' } }, { id: 'f583f42d-2eec-47a5-9d45-6dfd0603cb69', ref: { item: '24d4a8b3-6056-4c3f-8f0b-143683509438' } } ] }, { id: '5cfb2816-10cd-4c90-b584-f045c26074c8', color: 'color2', anchors: [ { id: '66854e7d-e46c-49b0-8f26-186742369158', ref: { item: '2ac34480-95cc-4b01-8efd-683ec46fcd68' } }, { id: 'ffc7345f-854c-41ef-96ed-fd1cbeb4b3d6', ref: { tile: { x: 15, y: 3 } } }, { id: '93e5620a-8d94-464e-bcc4-a4b20da4b6e7', ref: { tile: { x: 15, y: 0 } } }, { id: 'd97de77f-ac92-42a9-892b-3e30485817ff', ref: { item: '24d4a8b3-6056-4c3f-8f0b-143683509438' } } ], width: 10, description: '', style: 'SOLID' }, { id: '7392a711-e861-4c85-b394-49e47f2dd874', color: 'color2', anchors: [ { id: 'bd8d118a-f676-4ca0-bfbd-b993625aece7', ref: { item: '24d4a8b3-6056-4c3f-8f0b-143683509438' } }, { id: '613866b0-e6dd-4d8a-9a67-d8ce443273ec', ref: { item: 'item1' } } ] }, { id: '1e329a8d-3fc9-40ea-8e82-67b478b35f16', color: 'color7', anchors: [ { id: 'dd31d564-3b3a-4428-b05c-88b243173d21', ref: { item: 'fe621de2-793b-42f9-968e-4cac33b8d5fe' } }, { id: '8b9dec3b-59e3-4ddf-9a2b-12e7e6092916', ref: { tile: { x: 7, y: 11 } } }, { id: '5728c55e-03b6-4faa-b801-7a342a0b7650', ref: { tile: { x: 4, y: 11 } } }, { id: '3678c2f1-4c13-4959-8aaf-8d27611b6ea7', ref: { item: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a' } } ], width: 10, description: '', style: 'SOLID' }, { id: 'ff1c44f2-83f8-4596-a27f-54914b7da562', color: 'color7', anchors: [ { id: '30064e8e-7894-4b83-833b-a171c10327e6', ref: { item: '791abb72-5481-4713-88a8-a9fe51cb5408' } }, { id: 'b5aabbda-2802-4cf8-90c2-adfbc5bc5b5c', ref: { item: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a' } } ] }, { id: '8c1f03c8-351a-4a39-9570-5fd4959f0272', color: 'color7', anchors: [ { id: 'e8e8c135-3852-4475-96bc-6d6ec3eef8f0', ref: { item: '00ff4dc0-09f9-4932-aa90-6c207da2989b' } }, { id: '56b0d80b-31b5-4960-bf01-47c2d2a9e90a', ref: { tile: { x: 1, y: 11 } } }, { id: 'c42d6159-e6b8-447e-a197-0b7c761f2516', ref: { tile: { x: 4, y: 11 } } }, { id: 'c858062e-faa9-410d-9a35-5090c3b42af5', ref: { item: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a' } } ], width: 10, description: '', style: 'SOLID' }, { id: '630f5788-9f0b-44df-83f9-c60e40928617', color: 'color7', anchors: [ { id: 'c258cf27-4d97-4814-8438-96c7f9fa50c0', ref: { item: 'cf6b6e6e-f491-4547-b4ac-c5eecba8464a' } }, { id: '3bdd5f99-0fc8-4b35-b2be-fa5473a51bfa', ref: { item: 'item1' } } ] }, { id: '2f251ef8-d35e-4b57-ad48-dcd6f81325bc', color: 'color1', anchors: [ { id: '3d0c51d0-0b90-4062-90e5-306e2aa2f633', ref: { item: '040dfb11-f920-48cf-bf96-64234db1b7e8' } }, { id: '46d83bb9-30a5-4856-afb2-0e91076fce62', ref: { item: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05' } } ] }, { id: '965ff8f1-59e9-45ae-b484-ba6ec4f546d0', color: 'color1', anchors: [ { id: 'e031f407-88d7-4606-9d0d-99ffd12662d7', ref: { item: 'a71d7911-261d-4b6e-895a-27765baf0403' } }, { id: '6bffb346-eb7e-45b7-a68f-23b49eed30c2', ref: { tile: { x: -8, y: 6 } } }, { id: 'bb0fb0a7-492a-411b-8f74-9218ef607259', ref: { tile: { x: -5, y: 6 } } }, { id: '4992ecf1-26bc-47bf-a781-4e5267cd0c02', ref: { item: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05' } } ], width: 10, description: '', style: 'SOLID' }, { id: '3b09e7aa-97f9-40b7-81e8-84e5e610d1e2', color: 'color1', anchors: [ { id: '760ef8a7-b619-41bc-a61f-3d63fa1c2879', ref: { item: '67895813-ac6f-4dd4-9ae2-e994e9a5aa09' } }, { id: 'd03b1bea-d63f-4960-b186-ccc582a0da1b', ref: { tile: { x: -11, y: 6 } } }, { id: 'a2c1583d-667e-481c-9ddc-e1fd13a39203', ref: { tile: { x: -5, y: 6 } } }, { id: 'bc60ae76-c0f1-4c7f-8ec1-acdec06a9250', ref: { item: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05' } } ], width: 10, description: '', style: 'SOLID' }, { id: '7180a187-4254-46af-806f-4184250d9609', color: 'color1', anchors: [ { id: 'cb98ae3b-f88d-45ab-9dd8-0ced127e0ee1', ref: { item: 'a2d5f2c4-ea64-4b3c-8c82-d50be88adb05' } }, { id: '8a9f4c57-0685-4624-bf30-f5b27f34662a', ref: { item: 'bc6fdded-a090-4eae-b1fe-fe0ee0fd1c92' } } ] }, { id: '2bbf530c-5b0a-4405-a6ef-9df4784ba49b', color: 'color1', anchors: [ { id: '4072b959-8f00-4cef-9888-98b6faa5671b', ref: { item: 'c54ab120-44d2-46d2-9fc1-efd83ab67307' } }, { id: '020bc704-e25f-4a3a-a613-6fb7ce800f7e', ref: { tile: { x: -11, y: -6 } } }, { id: '6febf444-92b1-42ac-b6f3-afd71c3ee452', ref: { tile: { x: -5, y: -6 } } }, { id: '237f901a-d01d-4d9b-8bd1-eb11efedaa1b', ref: { item: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f' } } ], width: 10, description: '', style: 'SOLID' }, { id: '1074fc66-d2ff-49ed-85d3-87a0ded7f54b', color: 'color1', anchors: [ { id: 'b2db4f0d-59e7-4715-9d28-ea43887ae8c8', ref: { item: '4a27ed88-abf2-448b-af07-5d2b6ebdb67f' } }, { id: 'e742a2df-8911-40d8-9227-3d5f391f3beb', ref: { tile: { x: -8, y: -6 } } }, { id: 'e1ddafc5-0f79-41af-a9f6-e8f9007accb6', ref: { tile: { x: -5, y: -6 } } }, { id: '03cb17bd-34cb-4c80-989d-f299aa1a2915', ref: { item: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f' } } ], width: 10, description: '', style: 'SOLID' }, { id: '120566e0-c0df-4d85-81b3-d6b224484668', color: 'color1', anchors: [ { id: '433e4f1f-0bf9-44f5-ab9f-d98861206b59', ref: { item: 'a147b06a-324a-47ab-9e16-ac9101aa3d28' } }, { id: '1affa818-d601-4ab3-b507-d71ece11920c', ref: { item: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f' } } ] }, { id: '2185c84e-5277-40f1-82b9-774dd9f64e2a', color: 'color1', anchors: [ { id: 'aa663458-6df7-4bb2-946b-c8cc6ab29957', ref: { item: 'e0462e01-8acd-461c-89a2-42c6a04d5f7f' } }, { id: '77530d07-6183-420c-affe-91a99b685db0', ref: { item: 'bc6fdded-a090-4eae-b1fe-fe0ee0fd1c92' } } ] }, { id: '2e025225-169c-4609-bf93-a4a7aa602b00', color: 'color1', anchors: [ { id: '5870d75a-066c-422e-a517-c44417961809', ref: { item: 'bc6fdded-a090-4eae-b1fe-fe0ee0fd1c92' } }, { id: '0fdeeb60-9820-41dc-a73b-96196e035331', ref: { item: 'item1' } } ] } ], rectangles: [ { id: '75637566-6d10-49fb-b3ec-85584250475d', color: 'color6', from: { x: 1, y: -10 }, to: { x: 6, y: -12 } }, { id: '35cbdf0d-daa1-4939-9901-dd9aee36903f', color: 'color2', from: { x: 15, y: 4 }, to: { x: 17, y: -4 } }, { id: 'ae50ce7d-7b3e-49ec-8fe0-e2e09c4f2dfa', color: 'color7', from: { x: 0, y: 13 }, to: { x: 8, y: 11 } }, { id: 'e35ec239-f1eb-4e83-9112-d3b6b3f01f2c', color: 'color1', from: { x: -4, y: 9 }, to: { x: -12, y: 6 } }, { id: '27bea545-8505-4ebe-ae72-01de85833465', color: 'color1', from: { x: -4, y: -6 }, to: { x: -12, y: -10 } }, { id: '0a74d0a7-b987-480f-ada1-f5a575eae0b9', color: 'color5', from: { x: 3, y: 1 }, to: { x: 5, y: -1 } } ], textBoxes: [ { orientation: 'Y', fontSize: 0.6, content: 'Airside operations', id: 'f19b5d77-733e-48be-93a0-a0b0cae276d4', tile: { x: 14, y: -1 } }, { orientation: 'X', fontSize: 0.6, content: 'Information management', id: 'a15c0d88-1682-4fc8-9678-62e029df4574', tile: { x: 0, y: 10 } }, { orientation: 'X', fontSize: 0.6, content: 'Terminal management', id: 'e8ae777d-2c29-4c8e-8f61-0c63fac32d11', tile: { x: -12, y: 5 } }, { orientation: 'X', fontSize: 0.6, content: 'Passenger facilitation', id: '82132c7f-704e-49f1-86e7-e4f072e56779', tile: { x: -12, y: -11 } }, { orientation: 'X', fontSize: 0.6, content: 'AODB', id: '52070439-245d-45ab-974a-615427c1c3d1', tile: { x: 2, y: -2 } } ] } ] }; ================================================ FILE: packages/fossflow-lib/src/fixtures/colors.ts ================================================ import { Colors } from 'src/types'; export const colors: Colors = [ { id: 'color1', value: '#000000' }, { id: 'color2', value: '#ffffff' } ]; ================================================ FILE: packages/fossflow-lib/src/fixtures/icons.ts ================================================ import { Model } from 'src/types'; export const icons: Model['icons'] = [ { id: 'icon1', name: 'Icon1', url: 'https://isoflow.io/static/assets/icons/networking/server.svg' }, { id: 'icon2', name: 'Icon2', url: 'https://isoflow.io/static/assets/icons/networking/block.svg' } ]; ================================================ FILE: packages/fossflow-lib/src/fixtures/model.ts ================================================ import { Model } from 'src/types'; import { icons } from './icons'; import { modelItems } from './modelItems'; import { views } from './views'; import { colors } from './colors'; export const model: Model = { version: '1.0.0', title: 'TestModel', description: 'TestModelDescription', colors, icons, items: modelItems, views } as const; ================================================ FILE: packages/fossflow-lib/src/fixtures/modelItems.ts ================================================ import { Model } from 'src/types'; export const modelItems: Model['items'] = [ { id: 'node1', name: 'Node1', icon: 'icon1', description: 'Node1Description' }, { id: 'node2', name: 'Node2', icon: 'icon2' }, { id: 'node3', name: 'Node3', icon: 'icon1' } ]; ================================================ FILE: packages/fossflow-lib/src/fixtures/views.ts ================================================ import { Model } from 'src/types'; export const views: Model['views'] = [ { id: 'view1', name: 'View1', description: 'View1Description', items: [ { id: 'node1', tile: { x: 0, y: 0 } }, { id: 'node2', tile: { x: 0, y: 4 } }, { id: 'node3', tile: { x: 0, y: -4 } } ], rectangles: [ { id: 'rectangle1', color: 'color1', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }, { id: 'rectangle2', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } } ], connectors: [ { id: 'connector1', color: 'color1', anchors: [ { id: 'anch1-1', ref: { item: 'node1' } }, { id: 'anch1-2', ref: { item: 'node2' } } ] }, { id: 'connector2', anchors: [ { id: 'anch2-1', ref: { item: 'node2' } }, { id: 'anch2-2', ref: { item: 'node3' } } ] } ] } ]; ================================================ FILE: packages/fossflow-lib/src/global.d.ts ================================================ import { Size, Coords } from 'src/types'; declare global { let PACKAGE_VERSION: string; let REPOSITORY_URL: string; interface Window { Isoflow: { getUnprojectedBounds: () => Size & Coords; fitToView: () => void; }; } } declare module 'react-quill' { import React from 'react'; export interface ReactQuillProps { value?: string; onChange?: (value: string, delta: any, source: any, editor: any) => void; readOnly?: boolean; theme?: string; modules?: any; formats?: string[]; style?: React.CSSProperties; className?: string; placeholder?: string; bounds?: string | HTMLElement; scrollingContainer?: string | HTMLElement; preserveWhitespace?: boolean; tabIndex?: number; onFocus?: (range: any, source: any, editor: any) => void; onBlur?: (previousRange: any, source: any, editor: any) => void; onKeyPress?: (event: React.KeyboardEvent) => void; onKeyDown?: (event: React.KeyboardEvent) => void; onKeyUp?: (event: React.KeyboardEvent) => void; } const ReactQuill: React.ForwardRefExoticComponent>; export default ReactQuill; } ================================================ FILE: packages/fossflow-lib/src/hooks/__tests__/useHistory.test.tsx ================================================ import { renderHook, act } from '@testing-library/react'; import { useHistory } from '../useHistory'; // Mock implementations const mockModelStore = { canUndo: jest.fn(), canRedo: jest.fn(), undo: jest.fn(), redo: jest.fn(), saveToHistory: jest.fn(), clearHistory: jest.fn() }; const mockSceneStore = { canUndo: jest.fn(), canRedo: jest.fn(), undo: jest.fn(), redo: jest.fn(), saveToHistory: jest.fn(), clearHistory: jest.fn() }; // Mock the store hooks jest.mock('../../stores/modelStore', () => ({ useModelStore: jest.fn((selector) => { const state = { actions: mockModelStore }; return selector ? selector(state) : state; }) })); jest.mock('../../stores/sceneStore', () => ({ useSceneStore: jest.fn((selector) => { const state = { actions: mockSceneStore }; return selector ? selector(state) : state; }) })); describe('useHistory', () => { beforeEach(() => { jest.clearAllMocks(); // Reset mock implementations mockModelStore.canUndo.mockReturnValue(false); mockModelStore.canRedo.mockReturnValue(false); mockModelStore.undo.mockReturnValue(true); mockModelStore.redo.mockReturnValue(true); mockSceneStore.canUndo.mockReturnValue(false); mockSceneStore.canRedo.mockReturnValue(false); mockSceneStore.undo.mockReturnValue(true); mockSceneStore.redo.mockReturnValue(true); }); describe('undo/redo basic functionality', () => { it('should initialize with no undo/redo capability', () => { const { result } = renderHook(() => useHistory()); expect(result.current.canUndo).toBe(false); expect(result.current.canRedo).toBe(false); }); it('should call saveToHistory on both stores', () => { const { result } = renderHook(() => useHistory()); act(() => { result.current.saveToHistory(); }); expect(mockModelStore.saveToHistory).toHaveBeenCalled(); expect(mockSceneStore.saveToHistory).toHaveBeenCalled(); }); it('should perform undo when model store has history', () => { mockModelStore.canUndo.mockReturnValue(true); const { result } = renderHook(() => useHistory()); expect(result.current.canUndo).toBe(true); act(() => { const success = result.current.undo(); expect(success).toBe(true); }); expect(mockModelStore.undo).toHaveBeenCalled(); }); it('should perform undo when scene store has history', () => { mockSceneStore.canUndo.mockReturnValue(true); const { result } = renderHook(() => useHistory()); expect(result.current.canUndo).toBe(true); act(() => { const success = result.current.undo(); expect(success).toBe(true); }); expect(mockSceneStore.undo).toHaveBeenCalled(); }); it('should perform redo when model store has future', () => { mockModelStore.canRedo.mockReturnValue(true); const { result } = renderHook(() => useHistory()); expect(result.current.canRedo).toBe(true); act(() => { const success = result.current.redo(); expect(success).toBe(true); }); expect(mockModelStore.redo).toHaveBeenCalled(); }); it('should return false when undo is called with no history', () => { mockModelStore.undo.mockReturnValue(false); mockSceneStore.undo.mockReturnValue(false); const { result } = renderHook(() => useHistory()); act(() => { const success = result.current.undo(); expect(success).toBe(false); }); }); it('should return false when redo is called with no future', () => { mockModelStore.redo.mockReturnValue(false); mockSceneStore.redo.mockReturnValue(false); const { result } = renderHook(() => useHistory()); act(() => { const success = result.current.redo(); expect(success).toBe(false); }); }); }); describe('transaction functionality', () => { it('should save history before transaction and not during', () => { const { result } = renderHook(() => useHistory()); act(() => { result.current.transaction(() => { // This should not trigger saveToHistory due to transaction result.current.saveToHistory(); }); }); // Should save once before transaction starts expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1); expect(mockSceneStore.saveToHistory).toHaveBeenCalledTimes(1); }); it('should track transaction state correctly', () => { const { result } = renderHook(() => useHistory()); expect(result.current.isInTransaction()).toBe(false); act(() => { result.current.transaction(() => { expect(result.current.isInTransaction()).toBe(true); }); }); expect(result.current.isInTransaction()).toBe(false); }); it('should prevent nested transactions', () => { const { result } = renderHook(() => useHistory()); act(() => { result.current.transaction(() => { // First transaction saves history expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1); // Nested transaction should not save again result.current.transaction(() => { // Still in transaction expect(result.current.isInTransaction()).toBe(true); }); // Should still be 1 save expect(mockModelStore.saveToHistory).toHaveBeenCalledTimes(1); }); }); }); it('should handle transaction errors gracefully', () => { const { result } = renderHook(() => useHistory()); expect(() => { act(() => { result.current.transaction(() => { throw new Error('Test error'); }); }); }).toThrow('Test error'); // Transaction should be cleaned up expect(result.current.isInTransaction()).toBe(false); }); }); describe('history management', () => { it('should clear all history', () => { const { result } = renderHook(() => useHistory()); act(() => { result.current.clearHistory(); }); expect(mockModelStore.clearHistory).toHaveBeenCalled(); expect(mockSceneStore.clearHistory).toHaveBeenCalled(); }); it('should check both stores for undo capability', () => { // Only model has undo mockModelStore.canUndo.mockReturnValue(true); mockSceneStore.canUndo.mockReturnValue(false); const { result: result1 } = renderHook(() => useHistory()); expect(result1.current.canUndo).toBe(true); // Only scene has undo mockModelStore.canUndo.mockReturnValue(false); mockSceneStore.canUndo.mockReturnValue(true); const { result: result2 } = renderHook(() => useHistory()); expect(result2.current.canUndo).toBe(true); // Both have undo mockModelStore.canUndo.mockReturnValue(true); mockSceneStore.canUndo.mockReturnValue(true); const { result: result3 } = renderHook(() => useHistory()); expect(result3.current.canUndo).toBe(true); // Neither has undo mockModelStore.canUndo.mockReturnValue(false); mockSceneStore.canUndo.mockReturnValue(false); const { result: result4 } = renderHook(() => useHistory()); expect(result4.current.canUndo).toBe(false); }); it('should check both stores for redo capability', () => { // Only model has redo mockModelStore.canRedo.mockReturnValue(true); mockSceneStore.canRedo.mockReturnValue(false); const { result: result1 } = renderHook(() => useHistory()); expect(result1.current.canRedo).toBe(true); // Only scene has redo mockModelStore.canRedo.mockReturnValue(false); mockSceneStore.canRedo.mockReturnValue(true); const { result: result2 } = renderHook(() => useHistory()); expect(result2.current.canRedo).toBe(true); }); }); describe('edge cases', () => { it('should handle missing store actions gracefully', () => { // Mock stores returning undefined actions const useModelStore = require('../../stores/modelStore').useModelStore; const useSceneStore = require('../../stores/sceneStore').useSceneStore; useModelStore.mockImplementation((selector) => { const state = { actions: undefined }; return selector ? selector(state) : state; }); useSceneStore.mockImplementation((selector) => { const state = { actions: undefined }; return selector ? selector(state) : state; }); const { result } = renderHook(() => useHistory()); // Should not throw and return safe defaults expect(result.current.canUndo).toBe(false); expect(result.current.canRedo).toBe(false); act(() => { expect(result.current.undo()).toBe(false); expect(result.current.redo()).toBe(false); // These should not throw result.current.saveToHistory(); result.current.clearHistory(); result.current.transaction(() => {}); }); // Restore mocks for other tests useModelStore.mockImplementation((selector) => { const state = { actions: mockModelStore }; return selector ? selector(state) : state; }); useSceneStore.mockImplementation((selector) => { const state = { actions: mockSceneStore }; return selector ? selector(state) : state; }); }); it('should not save history during active transaction', () => { const { result } = renderHook(() => useHistory()); act(() => { result.current.transaction(() => { // Clear previous calls from transaction setup mockModelStore.saveToHistory.mockClear(); mockSceneStore.saveToHistory.mockClear(); // Try to save during transaction result.current.saveToHistory(); // Should not have saved expect(mockModelStore.saveToHistory).not.toHaveBeenCalled(); expect(mockSceneStore.saveToHistory).not.toHaveBeenCalled(); }); }); }); }); }); ================================================ FILE: packages/fossflow-lib/src/hooks/__tests__/useInitialDataManager.test.tsx ================================================ import { renderHook, act } from '@testing-library/react'; import { useInitialDataManager } from '../useInitialDataManager'; import { InitialData } from 'src/types'; import * as modelStoreModule from 'src/stores/modelStore'; import * as uiStateStoreModule from 'src/stores/uiStateStore'; import * as useViewModule from 'src/hooks/useView'; // Mock console methods const originalConsoleWarn = console.warn; const originalConsoleLog = console.log; const originalAlert = window.alert; beforeAll(() => { console.warn = jest.fn(); console.log = jest.fn(); window.alert = jest.fn(); }); afterAll(() => { console.warn = originalConsoleWarn; console.log = originalConsoleLog; window.alert = originalAlert; }); // Mock dependencies jest.mock('src/stores/modelStore'); jest.mock('src/stores/uiStateStore'); jest.mock('src/hooks/useView'); jest.mock('src/schemas/model', () => ({ modelSchema: { safeParse: jest.fn() } })); describe('useInitialDataManager - Orphaned Connector Handling', () => { let mockModelStore: any; let mockUiStateStore: any; let mockChangeView: jest.Mock; let mockModelSchema: any; beforeEach(() => { jest.clearAllMocks(); // Setup mock model store mockModelStore = { actions: { set: jest.fn() }, icons: [], colors: [] }; (modelStoreModule.useModelStore as jest.Mock).mockImplementation((selector) => { if (typeof selector === 'function') { return selector(mockModelStore); } return mockModelStore; }); // Setup mock UI state store mockUiStateStore = { actions: { setScroll: jest.fn(), setZoom: jest.fn(), setIconCategoriesState: jest.fn(), resetUiState: jest.fn() }, rendererEl: null, editorMode: 'INTERACTIVE' }; (uiStateStoreModule.useUiStateStore as jest.Mock).mockImplementation((selector) => { if (typeof selector === 'function') { return selector(mockUiStateStore); } return mockUiStateStore; }); // Setup mock changeView mockChangeView = jest.fn(); (useViewModule.useView as jest.Mock).mockReturnValue({ changeView: mockChangeView }); // Setup mock model schema mockModelSchema = require('src/schemas/model').modelSchema; mockModelSchema.safeParse.mockReturnValue({ success: true }); }); it('should filter out connectors with invalid item references during load', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [ { id: 'item1', tile: { x: 0, y: 0 } }, { id: 'item2', tile: { x: 1, y: 0 } } ], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'item1' }, face: 'right' }, { id: 'anchor2', ref: { item: 'item2' }, face: 'left' } ] }, { id: 'connector2', anchors: [ { id: 'anchor3', ref: { item: 'item1' }, face: 'top' }, { id: 'anchor4', ref: { item: 'nonexistent' }, face: 'bottom' } // Invalid reference ] }, { id: 'connector3', anchors: [ { id: 'anchor5', ref: { item: 'nonexistent1' }, face: 'right' }, // Invalid reference { id: 'anchor6', ref: { item: 'nonexistent2' }, face: 'left' } // Invalid reference ] } ], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // Check that the model store was called with filtered connectors const setCall = mockModelStore.actions.set.mock.calls[0][0]; expect(setCall.views[0].connectors).toHaveLength(1); expect(setCall.views[0].connectors[0].id).toBe('connector1'); // Check that warnings were logged for removed connectors expect(console.warn).toHaveBeenCalledWith('Removing connector connector2 due to invalid item references'); expect(console.warn).toHaveBeenCalledWith('Removing connector connector3 due to invalid item references'); }); it('should allow connectors that reference other anchors', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [ { id: 'item1', tile: { x: 0, y: 0 } } ], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'item1' }, face: 'right' }, { id: 'anchor2', ref: { anchor: 'anchor3' }, face: 'left' } // References another anchor ] } ], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // Connector with anchor reference should be preserved const setCall = mockModelStore.actions.set.mock.calls[0][0]; expect(setCall.views[0].connectors).toHaveLength(1); expect(setCall.views[0].connectors[0].id).toBe('connector1'); }); it('should handle views with no connectors', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // Should not throw and should load successfully expect(mockModelStore.actions.set).toHaveBeenCalled(); expect(result.current.isReady).toBe(true); }); it('should handle all connectors being invalid', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'nonexistent1' }, face: 'right' }, { id: 'anchor2', ref: { item: 'nonexistent2' }, face: 'left' } ] }, { id: 'connector2', anchors: [ { id: 'anchor3', ref: { item: 'deleted1' }, face: 'top' }, { id: 'anchor4', ref: { item: 'deleted2' }, face: 'bottom' } ] } ], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // All connectors should be removed const setCall = mockModelStore.actions.set.mock.calls[0][0]; expect(setCall.views[0].connectors).toHaveLength(0); expect(console.warn).toHaveBeenCalledTimes(2); }); it('should handle mixed valid and invalid anchor references', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [ { id: 'item1', tile: { x: 0, y: 0 } }, { id: 'item2', tile: { x: 1, y: 0 } } ], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'item1' }, face: 'right' }, // Valid { id: 'anchor2', ref: { item: 'item2' }, face: 'left' }, // Valid { id: 'anchor3', ref: { item: 'nonexistent' }, face: 'top' } // Invalid ] } ], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // Connector with any invalid anchor should be removed const setCall = mockModelStore.actions.set.mock.calls[0][0]; expect(setCall.views[0].connectors).toHaveLength(0); expect(console.warn).toHaveBeenCalledWith('Removing connector connector1 due to invalid item references'); }); it('should not modify original initialData object', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'nonexistent' }, face: 'right' }, { id: 'anchor2', ref: { item: 'item1' }, face: 'left' } ] } ], rectangles: [], textBoxes: [] } ] }; const originalData = JSON.parse(JSON.stringify(initialData)); act(() => { result.current.load(initialData); }); // Original data should not be modified expect(initialData).toEqual(originalData); }); it('should handle validation errors gracefully', () => { const { result } = renderHook(() => useInitialDataManager()); mockModelSchema.safeParse.mockReturnValueOnce({ success: false, error: { errors: [{ message: 'Validation failed' }] } }); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [] }; act(() => { result.current.load(initialData); }); expect(window.alert).toHaveBeenCalledWith('There is an error in your model.'); expect(mockModelStore.actions.set).not.toHaveBeenCalled(); expect(result.current.isReady).toBe(false); }); it('should preserve connectors with all valid references', () => { const { result } = renderHook(() => useInitialDataManager()); const initialData: InitialData = { version: '1.0', title: 'Test', description: '', colors: [], icons: [], items: [], views: [ { id: 'view1', name: 'Test View', items: [ { id: 'item1', tile: { x: 0, y: 0 } }, { id: 'item2', tile: { x: 1, y: 0 } }, { id: 'item3', tile: { x: 2, y: 0 } } ], connectors: [ { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'item1' }, face: 'right' }, { id: 'anchor2', ref: { item: 'item2' }, face: 'left' } ] }, { id: 'connector2', anchors: [ { id: 'anchor3', ref: { item: 'item2' }, face: 'right' }, { id: 'anchor4', ref: { item: 'item3' }, face: 'left' } ] } ], rectangles: [], textBoxes: [] } ] }; act(() => { result.current.load(initialData); }); // All valid connectors should be preserved const setCall = mockModelStore.actions.set.mock.calls[0][0]; expect(setCall.views[0].connectors).toHaveLength(2); expect(setCall.views[0].connectors[0].id).toBe('connector1'); expect(setCall.views[0].connectors[1].id).toBe('connector2'); expect(console.warn).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/fossflow-lib/src/hooks/useColor.ts ================================================ import { useMemo } from 'react'; import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useColor = (colorId?: string) => { const { colors } = useScene(); const color = useMemo(() => { if (colorId === undefined) { return colors.length > 0 ? colors[0] : null; } const item = getItemById(colors, colorId); return item ? item.value : null; }, [colorId, colors]); return color; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useConnector.ts ================================================ import { useMemo } from 'react'; import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useConnector = (id: string) => { const { connectors } = useScene(); const connector = useMemo(() => { const item = getItemById(connectors, id); return item ? item.value : null; }, [connectors, id]); return connector; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useDiagramUtils.ts ================================================ import { useCallback } from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { Size, Coords } from 'src/types'; import { getUnprojectedBounds as getUnprojectedBoundsUtil, getVisualBounds as getVisualBoundsUtil, getFitToViewParams as getFitToViewParamsUtil, CoordsUtils } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; import { useResizeObserver } from './useResizeObserver'; export const useDiagramUtils = () => { const scene = useScene(); const rendererEl = useUiStateStore((state) => { return state.rendererEl; }); const { size: rendererSize } = useResizeObserver(rendererEl); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const getUnprojectedBounds = useCallback((): Size & Coords => { return getUnprojectedBoundsUtil(scene.currentView); }, [scene.currentView]); const getVisualBounds = useCallback((): Size & Coords => { return getVisualBoundsUtil(scene.currentView); }, [scene.currentView]); const getFitToViewParams = useCallback( (viewportSize: Size) => { return getFitToViewParamsUtil(scene.currentView, viewportSize); }, [scene.currentView] ); const fitToView = useCallback(async () => { const { zoom, scroll } = getFitToViewParams(rendererSize); uiStateActions.setScroll({ position: scroll, offset: CoordsUtils.zero() }); uiStateActions.setZoom(zoom); }, [uiStateActions, getFitToViewParams, rendererSize]); return { getUnprojectedBounds, getVisualBounds, fitToView, getFitToViewParams }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useHistory.ts ================================================ import { useCallback, useRef } from 'react'; import { useModelStore } from 'src/stores/modelStore'; import { useSceneStore } from 'src/stores/sceneStore'; export const useHistory = () => { // Track if we're in a transaction to prevent nested history saves const transactionInProgress = useRef(false); // Get store actions const modelActions = useModelStore((state) => { return state?.actions; }); const sceneActions = useSceneStore((state) => { return state?.actions; }); // Get history state const modelCanUndo = useModelStore((state) => { return state?.actions?.canUndo?.() ?? false; }); const sceneCanUndo = useSceneStore((state) => { return state?.actions?.canUndo?.() ?? false; }); const modelCanRedo = useModelStore((state) => { return state?.actions?.canRedo?.() ?? false; }); const sceneCanRedo = useSceneStore((state) => { return state?.actions?.canRedo?.() ?? false; }); // Derived values const canUndo = modelCanUndo || sceneCanUndo; const canRedo = modelCanRedo || sceneCanRedo; // Transaction wrapper - groups multiple operations into single history entry const transaction = useCallback( (operations: () => void) => { if (!modelActions || !sceneActions) return; // Prevent nested transactions if (transactionInProgress.current) { operations(); return; } // Save current state before transaction modelActions.saveToHistory(); sceneActions.saveToHistory(); // Mark transaction as in progress transactionInProgress.current = true; try { // Execute all operations without saving intermediate history operations(); } finally { // Always reset transaction state transactionInProgress.current = false; } // Note: We don't save after transaction - the final state is already current }, [modelActions, sceneActions] ); const undo = useCallback(() => { if (!modelActions || !sceneActions) return false; let undoPerformed = false; // Try to undo model first, then scene if (modelActions.canUndo()) { undoPerformed = modelActions.undo() || undoPerformed; } if (sceneActions.canUndo()) { undoPerformed = sceneActions.undo() || undoPerformed; } return undoPerformed; }, [modelActions, sceneActions]); const redo = useCallback(() => { if (!modelActions || !sceneActions) return false; let redoPerformed = false; // Try to redo model first, then scene if (modelActions.canRedo()) { redoPerformed = modelActions.redo() || redoPerformed; } if (sceneActions.canRedo()) { redoPerformed = sceneActions.redo() || redoPerformed; } return redoPerformed; }, [modelActions, sceneActions]); const saveToHistory = useCallback(() => { // Don't save during transactions if (transactionInProgress.current) { return; } if (!modelActions || !sceneActions) return; modelActions.saveToHistory(); sceneActions.saveToHistory(); }, [modelActions, sceneActions]); const clearHistory = useCallback(() => { if (!modelActions || !sceneActions) return; modelActions.clearHistory(); sceneActions.clearHistory(); }, [modelActions, sceneActions]); return { undo, redo, canUndo, canRedo, saveToHistory, clearHistory, transaction, isInTransaction: () => { return transactionInProgress.current; } }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useIcon.tsx ================================================ import React, { useMemo, useEffect } from 'react'; import { useModelStore } from 'src/stores/modelStore'; import { getItemById } from 'src/utils'; import { IsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon'; import { NonIsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon'; import { DEFAULT_ICON } from 'src/config'; export const useIcon = (id: string | undefined) => { const [hasLoaded, setHasLoaded] = React.useState(false); const icons = useModelStore((state) => { return state.icons; }); const icon = useMemo(() => { if (!id) return DEFAULT_ICON; const item = getItemById(icons, id); return item ? item.value : DEFAULT_ICON; }, [icons, id]); useEffect(() => { setHasLoaded(false); }, [icon.url]); const iconComponent = useMemo(() => { if (!icon.isIsometric) { setHasLoaded(true); return ; } return ( { setHasLoaded(true); }} /> ); }, [icon]); return { icon, iconComponent, hasLoaded }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useIconCategories.ts ================================================ import { useMemo } from 'react'; import { IconCollectionStateWithIcons } from 'src/types'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useModelStore } from 'src/stores/modelStore'; export const useIconCategories = () => { const icons = useModelStore((state) => { return state.icons; }); const iconCategoriesState = useUiStateStore((state) => { return state.iconCategoriesState; }); const iconCategories = useMemo(() => { return iconCategoriesState.map((collection) => { return { ...collection, icons: icons.filter((icon) => { return icon.collection === collection.id; }) }; }); }, [icons, iconCategoriesState]); return { iconCategories }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useIconFiltering.ts ================================================ import { useState, useMemo } from 'react'; import { useModelStore } from 'src/stores/modelStore'; import { Icon } from 'src/types'; export const useIconFiltering = () => { const [filter, setFilter] = useState(''); const icons = useModelStore((state) => { return state.icons; }); const filteredIcons = useMemo(() => { if (filter === '') return null; // Escape special regex characters to treat filter as literal string const escapedFilter = filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(escapedFilter, 'gi'); return icons.filter((icon: Icon) => { if (!filter) { return true; } return regex.test(icon.name); }); }, [icons, filter]); return { setFilter, filter, filteredIcons }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useInitialDataManager.ts ================================================ import { useCallback, useState, useRef } from 'react'; import { InitialData, IconCollectionState } from 'src/types'; import { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config'; import { getFitToViewParams, CoordsUtils, categoriseIcons, generateId, getItemByIdOrThrow } from 'src/utils'; import * as reducers from 'src/stores/reducers'; import { useModelStore } from 'src/stores/modelStore'; import { useView } from 'src/hooks/useView'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { modelSchema } from 'src/schemas/model'; export const useInitialDataManager = () => { const [isReady, setIsReady] = useState(false); const prevInitialData = useRef(undefined); const model = useModelStore((state) => { return state; }); const uiStateActions = useUiStateStore((state) => { return state.actions; }); const rendererEl = useUiStateStore((state) => { return state.rendererEl; }); const editorMode = useUiStateStore((state) => { return state.editorMode; }); const { changeView } = useView(); const load = useCallback( (_initialData: InitialData) => { if (!_initialData || prevInitialData.current === _initialData) return; // Deep comparison to prevent unnecessary reloads when data hasn't actually changed // Skip this check for NON_INTERACTIVE mode (used by export) to ensure proper initialization if (prevInitialData.current && editorMode !== 'NON_INTERACTIVE') { const prevConnectors = JSON.stringify(prevInitialData.current.views?.[0]?.connectors || []); const newConnectors = JSON.stringify(_initialData.views?.[0]?.connectors || []); const prevItems = JSON.stringify(prevInitialData.current.items || []); const newItems = JSON.stringify(_initialData.items || []); const prevIcons = JSON.stringify(prevInitialData.current.icons || []); const newIcons = JSON.stringify(_initialData.icons || []); const prevColors = JSON.stringify(prevInitialData.current.colors || []); const newColors = JSON.stringify(_initialData.colors || []); if (prevConnectors === newConnectors && prevItems === newItems && prevIcons === newIcons && prevColors === newColors) { // Data hasn't actually changed, skip reload return; } } setIsReady(false); const validationResult = modelSchema.safeParse(_initialData); if (!validationResult.success) { // TODO: let's get better at reporting error messages here (starting with how we present them to users) // - not in console but in a modal console.log(validationResult.error.errors); window.alert('There is an error in your model.'); return; } // Clean up invalid connector references before loading const initialData = { ..._initialData }; initialData.views = initialData.views.map(view => { if (!view.connectors) return view; const validConnectors = view.connectors.filter(connector => { // Check if all anchors reference existing items const hasValidAnchors = connector.anchors.every(anchor => { if (anchor.ref.item) { // Check if the referenced item exists in the view return view.items.some(item => item.id === anchor.ref.item); } return true; // Allow anchors that reference other anchors }); if (!hasValidAnchors) { console.warn(`Removing connector ${connector.id} due to invalid item references`); } return hasValidAnchors; }); return { ...view, connectors: validConnectors }; }); if (initialData.views.length === 0) { const updates = reducers.view({ action: 'CREATE_VIEW', payload: {}, ctx: { state: { model: initialData, scene: INITIAL_SCENE_STATE }, viewId: generateId() } }); Object.assign(initialData, updates.model); } prevInitialData.current = initialData; model.actions.set(initialData, true); const view = getItemByIdOrThrow( initialData.views, initialData.view ?? initialData.views[0].id ); changeView(view.value.id, initialData); if (initialData.fitToView) { const rendererSize = rendererEl?.getBoundingClientRect(); const { zoom, scroll } = getFitToViewParams(view.value, { width: rendererSize?.width ?? 0, height: rendererSize?.height ?? 0 }); uiStateActions.setScroll({ position: scroll, offset: CoordsUtils.zero() }); uiStateActions.setZoom(zoom); } const categoriesState: IconCollectionState[] = categoriseIcons( initialData.icons ).map((collection) => { return { id: collection.name, isExpanded: false }; }); uiStateActions.setIconCategoriesState(categoriesState); setIsReady(true); }, [changeView, model.actions, rendererEl, uiStateActions, editorMode] ); const clear = useCallback(() => { load({ ...INITIAL_DATA, icons: model.icons, colors: model.colors }); uiStateActions.resetUiState(); }, [load, model.icons, model.colors, uiStateActions]); return { load, clear, isReady }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useIsoProjection.ts ================================================ import { useMemo } from 'react'; import { Coords, Size, ProjectionOrientationEnum } from 'src/types'; import { getBoundingBox, getIsoProjectionCss, getTilePosition } from 'src/utils'; import { UNPROJECTED_TILE_SIZE } from 'src/config'; interface Props { from: Coords; to: Coords; originOverride?: Coords; orientation?: keyof typeof ProjectionOrientationEnum; } export const useIsoProjection = ({ from, to, originOverride, orientation }: Props): { css: React.CSSProperties; position: Coords; gridSize: Size; pxSize: Size; } => { const gridSize = useMemo(() => { return { width: Math.abs(from.x - to.x) + 1, height: Math.abs(from.y - to.y) + 1 }; }, [from, to]); const origin = useMemo(() => { if (originOverride) return originOverride; const boundingBox = getBoundingBox([from, to]); return boundingBox[3]; }, [from, to, originOverride]); const position = useMemo(() => { const pos = getTilePosition({ tile: origin, origin: orientation === 'Y' ? 'TOP' : 'LEFT' }); return pos; }, [origin, orientation]); const pxSize = useMemo(() => { return { width: gridSize.width * UNPROJECTED_TILE_SIZE, height: gridSize.height * UNPROJECTED_TILE_SIZE }; }, [gridSize]); return useMemo(() => ({ css: { position: 'absolute' as const, left: position.x, top: position.y, width: `${pxSize.width}px`, height: `${pxSize.height}px`, transform: getIsoProjectionCss(orientation), transformOrigin: 'top left' }, position, gridSize, pxSize }), [position, pxSize, gridSize, orientation]); }; ================================================ FILE: packages/fossflow-lib/src/hooks/useModelItem.ts ================================================ import { useMemo } from 'react'; import { ModelItem } from 'src/types'; import { useModelStore } from 'src/stores/modelStore'; import { getItemById } from 'src/utils'; export const useModelItem = (id: string): ModelItem | null => { const items = useModelStore((state) => state.items); const modelItem = useMemo(() => { const item = getItemById(items, id); return item ? item.value : null; }, [id, items]); return modelItem; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useRectangle.ts ================================================ import { useMemo } from 'react'; import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useRectangle = (id: string) => { const { rectangles } = useScene(); const rectangle = useMemo(() => { const item = getItemById(rectangles, id); return item ? item.value : null; }, [rectangles, id]); return rectangle; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useResizeObserver.ts ================================================ import { useCallback, useEffect, useRef, useState } from 'react'; import { Size } from 'src/types'; export const useResizeObserver = (el?: HTMLElement | null) => { const resizeObserverRef = useRef(undefined); const [size, setSize] = useState({ width: 0, height: 0 }); const disconnect = useCallback(() => { resizeObserverRef.current?.disconnect(); }, []); const observe = useCallback( (element: HTMLElement) => { disconnect(); resizeObserverRef.current = new ResizeObserver(() => { setSize({ width: element.clientWidth, height: element.clientHeight }); }); resizeObserverRef.current.observe(element); }, [disconnect] ); useEffect(() => { return () => { disconnect(); }; }, [disconnect]); useEffect(() => { if (el) observe(el); }, [observe, el]); return { size, disconnect, observe }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useScene.ts ================================================ import { useCallback, useMemo, useRef } from 'react'; import { shallow } from 'zustand/shallow'; import { ModelItem, ViewItem, Connector, TextBox, Rectangle } from 'src/types'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useModelStore, useModelStoreApi } from 'src/stores/modelStore'; import { useSceneStore, useSceneStoreApi } from 'src/stores/sceneStore'; import * as reducers from 'src/stores/reducers'; import type { State } from 'src/stores/reducers/types'; import { getItemByIdOrThrow } from 'src/utils'; import { CONNECTOR_DEFAULTS, RECTANGLE_DEFAULTS, TEXTBOX_DEFAULTS } from 'src/config'; export const useScene = () => { const { views, colors, icons, items, version, title, description } = useModelStore( (state) => ({ views: state.views, colors: state.colors, icons: state.icons, items: state.items, version: state.version, title: state.title, description: state.description }), shallow ); const { connectors: sceneConnectors, textBoxes: sceneTextBoxes } = useSceneStore( (state) => ({ connectors: state.connectors, textBoxes: state.textBoxes }), shallow ); const currentViewId = useUiStateStore((state) => state.view); const transactionInProgress = useRef(false); const modelStoreApi = useModelStoreApi(); const sceneStoreApi = useSceneStoreApi(); const currentView = useMemo(() => { if (!views || !currentViewId) { return { id: '', name: 'Default View', items: [], connectors: [], rectangles: [], textBoxes: [] }; } try { return getItemByIdOrThrow(views, currentViewId).value; } catch (error) { return ( views[0] || { id: currentViewId, name: 'Default View', items: [], connectors: [], rectangles: [], textBoxes: [] } ); } }, [currentViewId, views]); const itemsList = useMemo(() => { return currentView.items ?? []; }, [currentView.items]); const colorsList = useMemo(() => { return colors ?? []; }, [colors]); const connectorsList = useMemo(() => { return (currentView.connectors ?? []).map((connector) => { const sceneConnector = sceneConnectors?.[connector.id]; return { ...CONNECTOR_DEFAULTS, ...connector, ...sceneConnector }; }); }, [currentView.connectors, sceneConnectors]); const rectanglesList = useMemo(() => { return (currentView.rectangles ?? []).map((rectangle) => { return { ...RECTANGLE_DEFAULTS, ...rectangle }; }); }, [currentView.rectangles]); const textBoxesList = useMemo(() => { return (currentView.textBoxes ?? []).map((textBox) => { const sceneTextBox = sceneTextBoxes?.[textBox.id]; return { ...TEXTBOX_DEFAULTS, ...textBox, ...sceneTextBox }; }); }, [currentView.textBoxes, sceneTextBoxes]); const getState = useCallback((): State => { const model = modelStoreApi.getState(); const scene = sceneStoreApi.getState(); return { model: { version: model.version, title: model.title, description: model.description, colors: model.colors, icons: model.icons, items: model.items, views: model.views }, scene: { connectors: scene.connectors, textBoxes: scene.textBoxes } }; }, [modelStoreApi, sceneStoreApi]); const setState = useCallback( (newState: State) => { modelStoreApi.getState().actions.set(newState.model, true); sceneStoreApi.getState().actions.set(newState.scene, true); }, [modelStoreApi, sceneStoreApi] ); const saveToHistoryBeforeChange = useCallback(() => { if (transactionInProgress.current) { return; } modelStoreApi.getState().actions.saveToHistory(); sceneStoreApi.getState().actions.saveToHistory(); }, [modelStoreApi, sceneStoreApi]); const createModelItem = useCallback( (newModelItem: ModelItem) => { if (!transactionInProgress.current) { saveToHistoryBeforeChange(); } const newState = reducers.createModelItem(newModelItem, getState()); setState(newState); return newState; }, [getState, setState, saveToHistoryBeforeChange] ); const updateModelItem = useCallback( (id: string, updates: Partial) => { saveToHistoryBeforeChange(); const newState = reducers.updateModelItem(id, updates, getState()); setState(newState); }, [getState, setState, saveToHistoryBeforeChange] ); const deleteModelItem = useCallback( (id: string) => { saveToHistoryBeforeChange(); const newState = reducers.deleteModelItem(id, getState()); setState(newState); }, [getState, setState, saveToHistoryBeforeChange] ); const createViewItem = useCallback( (newViewItem: ViewItem, currentState?: State) => { if (!currentViewId) return; if (!transactionInProgress.current) { saveToHistoryBeforeChange(); } const stateToUse = currentState || getState(); const newState = reducers.view({ action: 'CREATE_VIEWITEM', payload: newViewItem, ctx: { viewId: currentViewId, state: stateToUse } }); setState(newState); return newState; }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const updateViewItem = useCallback( (id: string, updates: Partial, currentState?: State) => { if (!currentViewId) return getState(); if (!transactionInProgress.current) { saveToHistoryBeforeChange(); } const stateToUse = currentState || getState(); const newState = reducers.view({ action: 'UPDATE_VIEWITEM', payload: { id, ...updates }, ctx: { viewId: currentViewId, state: stateToUse } }); setState(newState); return newState; }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const deleteViewItem = useCallback( (id: string) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_VIEWITEM', payload: id, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const createConnector = useCallback( (newConnector: Connector) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_CONNECTOR', payload: newConnector, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const updateConnector = useCallback( (id: string, updates: Partial) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'UPDATE_CONNECTOR', payload: { id, ...updates }, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const deleteConnector = useCallback( (id: string) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_CONNECTOR', payload: id, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const createTextBox = useCallback( (newTextBox: TextBox) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_TEXTBOX', payload: newTextBox, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const updateTextBox = useCallback( (id: string, updates: Partial, currentState?: State) => { if (!currentViewId) return currentState || getState(); if (!transactionInProgress.current) { saveToHistoryBeforeChange(); } const stateToUse = currentState || getState(); const newState = reducers.view({ action: 'UPDATE_TEXTBOX', payload: { id, ...updates }, ctx: { viewId: currentViewId, state: stateToUse } }); setState(newState); return newState; }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const deleteTextBox = useCallback( (id: string) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_TEXTBOX', payload: id, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const createRectangle = useCallback( (newRectangle: Rectangle) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'CREATE_RECTANGLE', payload: newRectangle, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const updateRectangle = useCallback( (id: string, updates: Partial, currentState?: State) => { if (!currentViewId) return currentState || getState(); if (!transactionInProgress.current) { saveToHistoryBeforeChange(); } const stateToUse = currentState || getState(); const newState = reducers.view({ action: 'UPDATE_RECTANGLE', payload: { id, ...updates }, ctx: { viewId: currentViewId, state: stateToUse } }); setState(newState); return newState; }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const deleteRectangle = useCallback( (id: string) => { if (!currentViewId) return; saveToHistoryBeforeChange(); const newState = reducers.view({ action: 'DELETE_RECTANGLE', payload: id, ctx: { viewId: currentViewId, state: getState() } }); setState(newState); }, [getState, setState, currentViewId, saveToHistoryBeforeChange] ); const transaction = useCallback( (operations: () => void) => { if (transactionInProgress.current) { operations(); return; } saveToHistoryBeforeChange(); transactionInProgress.current = true; try { operations(); } finally { transactionInProgress.current = false; } }, [saveToHistoryBeforeChange] ); const placeIcon = useCallback( (params: { modelItem: ModelItem; viewItem: ViewItem }) => { saveToHistoryBeforeChange(); transactionInProgress.current = true; try { const stateAfterModelItem = createModelItem(params.modelItem); if (stateAfterModelItem) { createViewItem(params.viewItem, stateAfterModelItem); } } finally { transactionInProgress.current = false; } }, [createModelItem, createViewItem, saveToHistoryBeforeChange] ); return { items: itemsList, connectors: connectorsList, colors: colorsList, rectangles: rectanglesList, textBoxes: textBoxesList, currentView, createModelItem, updateModelItem, deleteModelItem, createViewItem, updateViewItem, deleteViewItem, createConnector, updateConnector, deleteConnector, createTextBox, updateTextBox, deleteTextBox, createRectangle, updateRectangle, deleteRectangle, transaction, placeIcon }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useTextBox.ts ================================================ import { useMemo } from 'react'; import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useTextBox = (id: string) => { const { textBoxes } = useScene(); const textBox = useMemo(() => { const item = getItemById(textBoxes, id); return item ? item.value : null; }, [textBoxes, id]); return textBox; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useTextBoxProps.ts ================================================ import { useMemo } from 'react'; import { TextBox } from 'src/types'; import { UNPROJECTED_TILE_SIZE, DEFAULT_FONT_FAMILY, TEXTBOX_DEFAULTS, TEXTBOX_FONT_WEIGHT, TEXTBOX_PADDING } from 'src/config'; export const useTextBoxProps = (textBox: TextBox) => { const fontProps = useMemo(() => { return { fontSize: UNPROJECTED_TILE_SIZE * (textBox.fontSize ?? TEXTBOX_DEFAULTS.fontSize), fontFamily: DEFAULT_FONT_FAMILY, fontWeight: TEXTBOX_FONT_WEIGHT }; }, [textBox.fontSize]); const paddingX = useMemo(() => { return UNPROJECTED_TILE_SIZE * TEXTBOX_PADDING; }, []); return { paddingX, fontProps }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useView.ts ================================================ import { useCallback } from 'react'; import { useUiStateStore } from 'src/stores/uiStateStore'; import { useSceneStore } from 'src/stores/sceneStore'; import * as reducers from 'src/stores/reducers'; import { Model } from 'src/types'; import { INITIAL_SCENE_STATE } from 'src/config'; export const useView = () => { const uiStateActions = useUiStateStore((state) => { return state.actions; }); const sceneActions = useSceneStore((state) => { return state.actions; }); const changeView = useCallback( (viewId: string, model: Model) => { const newState = reducers.view({ action: 'SYNC_SCENE', payload: undefined, ctx: { viewId, state: { model, scene: INITIAL_SCENE_STATE } } }); sceneActions.set(newState.scene, true); uiStateActions.setView(viewId); }, [uiStateActions, sceneActions] ); return { changeView }; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useViewItem.ts ================================================ import { useMemo } from 'react'; import { getItemById } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; export const useViewItem = (id: string) => { const { items } = useScene(); const viewItem = useMemo(() => { const item = getItemById(items, id); return item ? item.value : null; }, [items, id]); return viewItem; }; ================================================ FILE: packages/fossflow-lib/src/hooks/useWindowUtils.ts ================================================ import { useEffect } from 'react'; import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; export const useWindowUtils = () => { const { fitToView, getUnprojectedBounds } = useDiagramUtils(); useEffect(() => { window.Isoflow = { getUnprojectedBounds, fitToView }; }, [getUnprojectedBounds, fitToView]); }; ================================================ FILE: packages/fossflow-lib/src/i18n/bn-BD.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "এটি একটি উদাহরণ পাঠ্য" }, mainMenu: { undo: "পূর্বাবস্থায় ফেরান", redo: "পুনরায় করুন", open: "খুলুন", exportJson: "JSON হিসাবে রপ্তানি করুন", exportCompactJson: "কমপ্যাক্ট JSON হিসাবে রপ্তানি করুন", exportImage: "ছবি হিসাবে রপ্তানি করুন", clearCanvas: "ক্যানভাস পরিষ্কার করুন", settings: "সেটিংস", gitHub: "GitHub" }, helpDialog: { title: "কীবোর্ড শর্টকাট এবং সহায়তা", close: "বন্ধ করুন", keyboardShortcuts: "কীবোর্ড শর্টকাট", mouseInteractions: "মাউস ইন্টারঅ্যাকশন", action: "ক্রিয়া", shortcut: "শর্টকাট", method: "পদ্ধতি", description: "বিবরণ", note: "নোট:", noteContent: "দ্বন্দ্ব এড়াতে ইনপুট ফিল্ড, টেক্সট এরিয়া বা সম্পাদনাযোগ্য উপাদানে টাইপ করার সময় কীবোর্ড শর্টকাট নিষ্ক্রিয় থাকে।", // Keyboard shortcuts undoAction: "পূর্বাবস্থায় ফেরান", undoDescription: "শেষ ক্রিয়াটি পূর্বাবস্থায় ফেরান", redoAction: "পুনরায় করুন", redoDescription: "শেষ পূর্বাবস্থায় ফেরানো ক্রিয়া পুনরায় করুন", redoAltAction: "পুনরায় করুন (বিকল্প)", redoAltDescription: "পুনরায় করার জন্য বিকল্প শর্টকাট", helpAction: "সহায়তা", helpDescription: "কীবোর্ড শর্টকাট সহ সহায়তা ডায়ালগ খুলুন", zoomInAction: "জুম ইন করুন", zoomInShortcut: "মাউস হুইল উপরে", zoomInDescription: "ক্যানভাসে জুম ইন করুন", zoomOutAction: "জুম আউট করুন", zoomOutShortcut: "মাউস হুইল নিচে", zoomOutDescription: "ক্যানভাস থেকে জুম আউট করুন", panCanvasAction: "ক্যানভাস প্যান করুন", panCanvasShortcut: "বাম-ক্লিক + টেনে আনুন", panCanvasDescription: "প্যান মোডে ক্যানভাস প্যান করুন", contextMenuAction: "প্রসঙ্গ মেনু", contextMenuShortcut: "ডান-ক্লিক", contextMenuDescription: "আইটেম বা খালি স্থানের জন্য প্রসঙ্গ মেনু খুলুন", // Mouse interactions selectToolAction: "নির্বাচন টুল", selectToolShortcut: "নির্বাচন বোতামে ক্লিক করুন", selectToolDescription: "নির্বাচন মোডে স্যুইচ করুন", panToolAction: "প্যান টুল", panToolShortcut: "প্যান বোতামে ক্লিক করুন", panToolDescription: "ক্যানভাস সরানোর জন্য প্যান মোডে স্যুইচ করুন", addItemAction: "আইটেম যোগ করুন", addItemShortcut: "আইটেম যোগ করুন বোতামে ক্লিক করুন", addItemDescription: "নতুন আইটেম যোগ করতে আইকন পিকার খুলুন", drawRectangleAction: "আয়তক্ষেত্র আঁকুন", drawRectangleShortcut: "আয়তক্ষেত্র বোতামে ক্লিক করুন", drawRectangleDescription: "আয়তক্ষেত্র অঙ্কন মোডে স্যুইচ করুন", createConnectorAction: "সংযোগকারী তৈরি করুন", createConnectorShortcut: "সংযোগকারী বোতামে ক্লিক করুন", createConnectorDescription: "সংযোগকারী মোডে স্যুইচ করুন", addTextAction: "পাঠ্য যোগ করুন", addTextShortcut: "পাঠ্য বোতামে ক্লিক করুন", addTextDescription: "একটি নতুন টেক্সট বক্স তৈরি করুন" }, connectorHintTooltip: { tipCreatingConnectors: "টিপ: সংযোগকারী তৈরি করা", tipConnectorTools: "টিপ: সংযোগকারী টুল", clickInstructionStart: "ক্লিক করুন", clickInstructionMiddle: "প্রথম নোড বা পয়েন্টে, তারপর", clickInstructionEnd: "দ্বিতীয় নোড বা পয়েন্টে একটি সংযোগ তৈরি করতে।", nowClickTarget: "সংযোগ সম্পূর্ণ করতে এখন লক্ষ্যে ক্লিক করুন।", dragStart: "টেনে আনুন", dragEnd: "প্রথম নোড থেকে দ্বিতীয় নোডে একটি সংযোগ তৈরি করতে।", rerouteStart: "একটি সংযোগকারী পুনর্নির্দেশ করতে,", rerouteMiddle: "বাম-ক্লিক করুন", rerouteEnd: "সংযোগকারী লাইনের সাথে যে কোনও পয়েন্টে এবং অ্যাঙ্কর পয়েন্ট তৈরি বা সরাতে টেনে আনুন।" }, lassoHintTooltip: { tipLasso: "টিপ: ল্যাসো নির্বাচন", tipFreehandLasso: "টিপ: ফ্রিহ্যান্ড ল্যাসো নির্বাচন", lassoDragStart: "ক্লিক করুন এবং টেনে আনুন", lassoDragEnd: "আপনি যে আইটেমগুলি নির্বাচন করতে চান তার চারপাশে একটি আয়তক্ষেত্রাকার নির্বাচন বক্স আঁকতে।", freehandDragStart: "ক্লিক করুন এবং টেনে আনুন", freehandDragMiddle: "একটি আঁকতে", freehandDragEnd: "মুক্ত আকৃতি", freehandComplete: "আইটেমগুলির চারপাশে। আকৃতির ভিতরের সমস্ত আইটেম নির্বাচন করতে ছেড়ে দিন।", moveStart: "একবার নির্বাচিত হলে,", moveMiddle: "নির্বাচনের ভিতরে ক্লিক করুন", moveEnd: "এবং সমস্ত নির্বাচিত আইটেম একসাথে সরাতে টেনে আনুন।" }, importHintTooltip: { title: "ডায়াগ্রাম আমদানি করুন", instructionStart: "ডায়াগ্রাম আমদানি করতে, ক্লিক করুন", menuButton: "মেনু বোতাম", instructionMiddle: "(☰) উপরের বাম কোণে, তারপর নির্বাচন করুন", openButton: "\"খুলুন\"", instructionEnd: "আপনার ডায়াগ্রাম ফাইল লোড করতে।" }, connectorRerouteTooltip: { title: "টিপ: সংযোগকারী পুনর্নির্দেশ করুন", instructionStart: "একবার আপনার সংযোগকারী স্থাপন করা হলে আপনি আপনার ইচ্ছামতো তাদের পুনর্নির্দেশ করতে পারেন।", instructionSelect: "সংযোগকারী নির্বাচন করুন", instructionMiddle: "প্রথমে, তারপর", instructionClick: "সংযোগকারী পথে ক্লিক করুন", instructionAnd: "এবং", instructionDrag: "টেনে আনুন", instructionEnd: "এটি পরিবর্তন করতে!" }, connectorEmptySpaceTooltip: { message: "এই সংযোগকারীটিকে একটি নোডের সাথে সংযোগ করতে,", instruction: "সংযোগকারীর শেষে বাম-ক্লিক করুন এবং এটিকে পছন্দসই নোডে টানুন।" }, settings: { zoom: { description: "মাউস হুইল ব্যবহার করার সময় জুম আচরণ কনফিগার করুন।", zoomToCursor: "কার্সারে জুম করুন", zoomToCursorDesc: "সক্রিয় থাকলে, মাউস কার্সার অবস্থানে কেন্দ্রীভূত জুম ইন/আউট। নিষ্ক্রিয় থাকলে, জুম ক্যানভাসে কেন্দ্রীভূত।" }, hotkeys: { title: "শর্টকাট সেটিংস", profile: "শর্টকাট প্রোফাইল", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "কোন শর্টকাট নেই", tool: "টুল", hotkey: "শর্টকাট", toolSelect: "নির্বাচন করুন", toolPan: "প্যান করুন", toolAddItem: "আইটেম যোগ করুন", toolRectangle: "আয়তক্ষেত্র", toolConnector: "সংযোগকারী", toolText: "পাঠ্য", note: "নোট: টেক্সট ফিল্ডে টাইপ না করার সময় শর্টকাটগুলি কাজ করে" }, pan: { title: "প্যান সেটিংস", mousePanOptions: "মাউস প্যান বিকল্প", emptyAreaClickPan: "খালি এলাকায় ক্লিক করুন এবং টেনে আনুন", middleClickPan: "মধ্য ক্লিক করুন এবং টেনে আনুন", rightClickPan: "ডান ক্লিক করুন এবং টেনে আনুন", ctrlClickPan: "Ctrl + ক্লিক করুন এবং টেনে আনুন", altClickPan: "Alt + ক্লিক করুন এবং টেনে আনুন", keyboardPanOptions: "কীবোর্ড প্যান বিকল্প", arrowKeys: "তীর কী", wasdKeys: "WASD কী", ijklKeys: "IJKL কী", keyboardPanSpeed: "কীবোর্ড প্যান গতি", note: "নোট: নিবেদিত প্যান টুলের পাশাপাশি প্যান বিকল্পগুলি কাজ করে" }, connector: { title: "সংযোগকারী সেটিংস", connectionMode: "সংযোগ তৈরির মোড", clickMode: "ক্লিক মোড (প্রস্তাবিত)", clickModeDesc: "প্রথম নোডে ক্লিক করুন, তারপর একটি সংযোগ তৈরি করতে দ্বিতীয় নোডে ক্লিক করুন", dragMode: "টেনে আনার মোড", dragModeDesc: "প্রথম নোড থেকে দ্বিতীয় নোডে ক্লিক করুন এবং টেনে আনুন", note: "নোট: আপনি যেকোনো সময় এই সেটিং পরিবর্তন করতে পারেন। সংযোগকারী টুল সক্রিয় থাকলে নির্বাচিত মোড ব্যবহার করা হবে।" }, iconPacks: { title: "আইকন প্যাক ব্যবস্থাপনা", lazyLoading: "লেজি লোডিং সক্ষম করুন", lazyLoadingDesc: "দ্রুত স্টার্টআপের জন্য চাহিদা অনুযায়ী আইকন প্যাক লোড করুন", availablePacks: "উপলব্ধ আইকন প্যাক", coreIsoflow: "Core Isoflow (সর্বদা লোড)", alwaysEnabled: "সর্বদা সক্রিয়", awsPack: "AWS আইকন", gcpPack: "Google Cloud আইকন", azurePack: "Azure আইকন", kubernetesPack: "Kubernetes আইকন", loading: "লোড হচ্ছে...", loaded: "লোড করা হয়েছে", notLoaded: "লোড করা হয়নি", iconCount: "{count} আইকন", lazyLoadingDisabledNote: "লেজি লোডিং নিষ্ক্রিয়। সমস্ত আইকন প্যাক স্টার্টআপে লোড করা হয়।", note: "আইকন প্যাকগুলি আপনার প্রয়োজন অনুসারে সক্রিয় বা নিষ্ক্রিয় করা যেতে পারে। নিষ্ক্রিয় প্যাকগুলি মেমরি ব্যবহার হ্রাস করবে এবং কর্মক্ষমতা উন্নত করবে।" } }, lazyLoadingWelcome: { title: "নতুন বৈশিষ্ট্য: লেজি লোডিং!", message: "হেই! জনপ্রিয় চাহিদার পরে, আমরা আইকনগুলির লেজি লোডিং প্রয়োগ করেছি, তাই এখন আপনি যদি অ-মানক আইকন প্যাক সক্ষম করতে চান তবে আপনি 'কনফিগারেশন' বিভাগে সেগুলি সক্ষম করতে পারেন।", configPath: "হ্যামবার্গার আইকনে ক্লিক করুন", configPath2: "কনফিগারেশন অ্যাক্সেস করতে উপরের বাম দিকে।", canDisable: "আপনি চাইলে এই আচরণ নিষ্ক্রিয় করতে পারেন।", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/en-US.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "This is an example text" }, mainMenu: { undo: "Undo", redo: "Redo", open: "Open", exportJson: "Export as JSON", exportCompactJson: "Export as Compact JSON", exportImage: "Export as image", clearCanvas: "Clear the canvas", settings: "Settings", gitHub: "GitHub" }, helpDialog: { title: "Keyboard Shortcuts & Help", close: "Close", keyboardShortcuts: "Keyboard Shortcuts", mouseInteractions: "Mouse Interactions", action: "Action", shortcut: "Shortcut", method: "Method", description: "Description", note: "Note:", noteContent: "Keyboard shortcuts are disabled when typing in input fields, text areas, or content-editable elements to prevent conflicts.", // Keyboard shortcuts undoAction: "Undo", undoDescription: "Undo the last action", redoAction: "Redo", redoDescription: "Redo the last undone action", redoAltAction: "Redo (Alternative)", redoAltDescription: "Alternative redo shortcut", helpAction: "Help", helpDescription: "Open help dialog with keyboard shortcuts", zoomInAction: "Zoom In", zoomInShortcut: "Mouse Wheel Up", zoomInDescription: "Zoom in on the canvas", zoomOutAction: "Zoom Out", zoomOutShortcut: "Mouse Wheel Down", zoomOutDescription: "Zoom out from the canvas", panCanvasAction: "Pan Canvas", panCanvasShortcut: "Left-click + Drag", panCanvasDescription: "Pan the canvas when in Pan mode", contextMenuAction: "Context Menu", contextMenuShortcut: "Right-click", contextMenuDescription: "Open context menu for items or empty space", // Mouse interactions selectToolAction: "Select Tool", selectToolShortcut: "Click Select button", selectToolDescription: "Switch to selection mode", panToolAction: "Pan Tool", panToolShortcut: "Click Pan button", panToolDescription: "Switch to pan mode for moving canvas", addItemAction: "Add Item", addItemShortcut: "Click Add item button", addItemDescription: "Open icon picker to add new items", drawRectangleAction: "Draw Rectangle", drawRectangleShortcut: "Click Rectangle button", drawRectangleDescription: "Switch to rectangle drawing mode", createConnectorAction: "Create Connector", createConnectorShortcut: "Click Connector button", createConnectorDescription: "Switch to connector mode", addTextAction: "Add Text", addTextShortcut: "Click Text button", addTextDescription: "Create a new text box" }, connectorHintTooltip: { tipCreatingConnectors: "Tip: Creating Connectors", tipConnectorTools: "Tip: Connector Tools", clickInstructionStart: "Click", clickInstructionMiddle: "on the first node or point, then", clickInstructionEnd: "on the second node or point to create a connection.", nowClickTarget: "Now click on the target to complete the connection.", dragStart: "Drag", dragEnd: "from the first node to the second node to create a connection.", rerouteStart: "To reroute a connector,", rerouteMiddle: "left-click", rerouteEnd: "on any point along the connector line and drag to create or move anchor points." }, lassoHintTooltip: { tipLasso: "Tip: Lasso Selection", tipFreehandLasso: "Tip: Freehand Lasso Selection", lassoDragStart: "Click and drag", lassoDragEnd: "to draw a rectangular selection box around items you want to select.", freehandDragStart: "Click and drag", freehandDragMiddle: "to draw a", freehandDragEnd: "freeform shape", freehandComplete: "around items. Release to select all items inside the shape.", moveStart: "Once selected,", moveMiddle: "click inside the selection", moveEnd: "and drag to move all selected items together." }, importHintTooltip: { title: "Import Diagrams", instructionStart: "To import diagrams, click the", menuButton: "menu button", instructionMiddle: "(☰) in the top left corner, then select", openButton: "\"Open\"", instructionEnd: "to load your diagram files." }, connectorRerouteTooltip: { title: "Tip: Reroute Connectors", instructionStart: "Once your connectors are placed you can reroute them as you please.", instructionSelect: "Select the connector", instructionMiddle: "first, then", instructionClick: "click on the connector path", instructionAnd: "and", instructionDrag: "drag", instructionEnd: "to change it!" }, connectorEmptySpaceTooltip: { message: "To connect this connector to a node,", instruction: "left-click on the end of the connector and drag it to the desired node." }, settings: { zoom: { description: "Configure zoom behavior when using the mouse wheel.", zoomToCursor: "Zoom to Cursor", zoomToCursorDesc: "When enabled, zoom in/out centered on the mouse cursor position. When disabled, zoom is centered on the canvas." }, hotkeys: { title: "Hotkey Settings", profile: "Hotkey Profile", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "No Hotkeys", tool: "Tool", hotkey: "Hotkey", toolSelect: "Select", toolPan: "Pan", toolAddItem: "Add Item", toolRectangle: "Rectangle", toolConnector: "Connector", toolText: "Text", note: "Note: Hotkeys work when not typing in text fields" }, pan: { title: "Pan Settings", mousePanOptions: "Mouse Pan Options", emptyAreaClickPan: "Click and drag on empty area", middleClickPan: "Middle click and drag", rightClickPan: "Right click and drag", ctrlClickPan: "Ctrl + click and drag", altClickPan: "Alt + click and drag", keyboardPanOptions: "Keyboard Pan Options", arrowKeys: "Arrow keys", wasdKeys: "WASD keys", ijklKeys: "IJKL keys", keyboardPanSpeed: "Keyboard Pan Speed", note: "Note: Pan options work in addition to the dedicated Pan tool" }, connector: { title: "Connector Settings", connectionMode: "Connection Creation Mode", clickMode: "Click Mode (Recommended)", clickModeDesc: "Click the first node, then click the second node to create a connection", dragMode: "Drag Mode", dragModeDesc: "Click and drag from the first node to the second node", note: "Note: You can change this setting at any time. The selected mode will be used when the Connector tool is active." }, iconPacks: { title: "Icon Pack Management", lazyLoading: "Enable Lazy Loading", lazyLoadingDesc: "Load icon packs on demand for faster startup", availablePacks: "Available Icon Packs", coreIsoflow: "Core Isoflow (Always Loaded)", alwaysEnabled: "Always enabled", awsPack: "AWS Icons", gcpPack: "Google Cloud Icons", azurePack: "Azure Icons", kubernetesPack: "Kubernetes Icons", loading: "Loading...", loaded: "Loaded", notLoaded: "Not loaded", iconCount: "{count} icons", lazyLoadingDisabledNote: "Lazy loading is disabled. All icon packs are loaded at startup.", note: "Icon packs can be enabled or disabled based on your needs. Disabled packs will reduce memory usage and improve performance." } }, lazyLoadingWelcome: { title: "New Feature: Lazy Loading!", message: "Hey! After popular demand, we have implemented Lazy Loading of icons, so now if you want to enable non-standard icon packs you can enable them in the 'Configuration' section.", configPath: "Click on the Hamburger icon", configPath2: "in the top left to access Configuration.", canDisable: "You can disable this behaviour if you wish.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/es-ES.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Este es un texto de ejemplo" }, mainMenu: { undo: "Deshacer", redo: "Rehacer", open: "Abrir", exportJson: "Exportar como JSON", exportCompactJson: "Exportar como JSON compacto", exportImage: "Exportar como imagen", clearCanvas: "Limpiar el lienzo", settings: "Configuración", gitHub: "GitHub" }, helpDialog: { title: "Atajos de teclado y ayuda", close: "Cerrar", keyboardShortcuts: "Atajos de teclado", mouseInteractions: "Interacciones del ratón", action: "Acción", shortcut: "Atajo", method: "Método", description: "Descripción", note: "Nota:", noteContent: "Los atajos de teclado se desactivan al escribir en campos de entrada, áreas de texto o elementos editables para evitar conflictos.", // Keyboard shortcuts undoAction: "Deshacer", undoDescription: "Deshacer la última acción", redoAction: "Rehacer", redoDescription: "Rehacer la última acción deshecha", redoAltAction: "Rehacer (Alternativo)", redoAltDescription: "Atajo alternativo para rehacer", helpAction: "Ayuda", helpDescription: "Abrir diálogo de ayuda con atajos de teclado", zoomInAction: "Acercar", zoomInShortcut: "Rueda del ratón hacia arriba", zoomInDescription: "Acercar en el lienzo", zoomOutAction: "Alejar", zoomOutShortcut: "Rueda del ratón hacia abajo", zoomOutDescription: "Alejar del lienzo", panCanvasAction: "Desplazar lienzo", panCanvasShortcut: "Clic izquierdo + Arrastrar", panCanvasDescription: "Desplazar el lienzo en modo desplazamiento", contextMenuAction: "Menú contextual", contextMenuShortcut: "Clic derecho", contextMenuDescription: "Abrir menú contextual para elementos o espacio vacío", // Mouse interactions selectToolAction: "Herramienta de selección", selectToolShortcut: "Clic en botón Seleccionar", selectToolDescription: "Cambiar al modo de selección", panToolAction: "Herramienta de desplazamiento", panToolShortcut: "Clic en botón Desplazar", panToolDescription: "Cambiar al modo de desplazamiento para mover el lienzo", addItemAction: "Añadir elemento", addItemShortcut: "Clic en botón Añadir elemento", addItemDescription: "Abrir selector de iconos para añadir nuevos elementos", drawRectangleAction: "Dibujar rectángulo", drawRectangleShortcut: "Clic en botón Rectángulo", drawRectangleDescription: "Cambiar al modo de dibujo de rectángulos", createConnectorAction: "Crear conector", createConnectorShortcut: "Clic en botón Conector", createConnectorDescription: "Cambiar al modo de conector", addTextAction: "Añadir texto", addTextShortcut: "Clic en botón Texto", addTextDescription: "Crear un nuevo cuadro de texto" }, connectorHintTooltip: { tipCreatingConnectors: "Consejo: Crear conectores", tipConnectorTools: "Consejo: Herramientas de conectores", clickInstructionStart: "Haz clic", clickInstructionMiddle: "en el primer nodo o punto, luego", clickInstructionEnd: "en el segundo nodo o punto para crear una conexión.", nowClickTarget: "Ahora haz clic en el objetivo para completar la conexión.", dragStart: "Arrastra", dragEnd: "desde el primer nodo al segundo nodo para crear una conexión.", rerouteStart: "Para cambiar la ruta de un conector,", rerouteMiddle: "haz clic izquierdo", rerouteEnd: "en cualquier punto a lo largo de la línea del conector y arrastra para crear o mover puntos de anclaje." }, lassoHintTooltip: { tipLasso: "Consejo: Selección de lazo", tipFreehandLasso: "Consejo: Selección de lazo libre", lassoDragStart: "Haz clic y arrastra", lassoDragEnd: "para dibujar un cuadro de selección rectangular alrededor de los elementos que deseas seleccionar.", freehandDragStart: "Haz clic y arrastra", freehandDragMiddle: "para dibujar una", freehandDragEnd: "forma libre", freehandComplete: "alrededor de los elementos. Suelta para seleccionar todos los elementos dentro de la forma.", moveStart: "Una vez seleccionados,", moveMiddle: "haz clic dentro de la selección", moveEnd: "y arrastra para mover todos los elementos seleccionados juntos." }, importHintTooltip: { title: "Importar diagramas", instructionStart: "Para importar diagramas, haz clic en el", menuButton: "botón de menú", instructionMiddle: "(☰) en la esquina superior izquierda, luego selecciona", openButton: "\"Abrir\"", instructionEnd: "para cargar tus archivos de diagrama." }, connectorRerouteTooltip: { title: "Consejo: Cambiar ruta de conectores", instructionStart: "Una vez que tus conectores estén colocados, puedes cambiar su ruta como desees.", instructionSelect: "Selecciona el conector", instructionMiddle: "primero, luego", instructionClick: "haz clic en la ruta del conector", instructionAnd: "y", instructionDrag: "arrastra", instructionEnd: "para cambiarlo!" }, connectorEmptySpaceTooltip: { message: "Para conectar este conector a un nodo,", instruction: "haz clic izquierdo en el extremo del conector y arrástralo al nodo deseado." }, settings: { zoom: { description: "Configura el comportamiento del zoom al usar la rueda del ratón.", zoomToCursor: "Zoom al cursor", zoomToCursorDesc: "Cuando está habilitado, el zoom se centra en la posición del cursor del ratón. Cuando está deshabilitado, el zoom se centra en el lienzo." }, hotkeys: { title: "Configuración de atajos", profile: "Perfil de atajos", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Sin atajos", tool: "Herramienta", hotkey: "Atajo", toolSelect: "Seleccionar", toolPan: "Desplazar", toolAddItem: "Añadir elemento", toolRectangle: "Rectángulo", toolConnector: "Conector", toolText: "Texto", note: "Nota: Los atajos funcionan cuando no estás escribiendo en campos de texto" }, pan: { title: "Configuración de desplazamiento", mousePanOptions: "Opciones de desplazamiento con ratón", emptyAreaClickPan: "Clic y arrastrar en área vacía", middleClickPan: "Clic central y arrastrar", rightClickPan: "Clic derecho y arrastrar", ctrlClickPan: "Ctrl + clic y arrastrar", altClickPan: "Alt + clic y arrastrar", keyboardPanOptions: "Opciones de desplazamiento con teclado", arrowKeys: "Teclas de flechas", wasdKeys: "Teclas WASD", ijklKeys: "Teclas IJKL", keyboardPanSpeed: "Velocidad de desplazamiento con teclado", note: "Nota: Las opciones de desplazamiento funcionan además de la herramienta de desplazamiento dedicada" }, connector: { title: "Configuración de conectores", connectionMode: "Modo de creación de conexiones", clickMode: "Modo clic (Recomendado)", clickModeDesc: "Haz clic en el primer nodo, luego haz clic en el segundo nodo para crear una conexión", dragMode: "Modo arrastrar", dragModeDesc: "Haz clic y arrastra desde el primer nodo hasta el segundo nodo", note: "Nota: Puedes cambiar esta configuración en cualquier momento. El modo seleccionado se usará cuando la herramienta de conector esté activa." }, iconPacks: { title: "Gestión de Paquetes de Iconos", lazyLoading: "Activar Carga Diferida", lazyLoadingDesc: "Cargar paquetes de iconos bajo demanda para un inicio más rápido", availablePacks: "Paquetes de Iconos Disponibles", coreIsoflow: "Core Isoflow (Siempre Cargado)", alwaysEnabled: "Siempre activado", awsPack: "Iconos AWS", gcpPack: "Iconos Google Cloud", azurePack: "Iconos Azure", kubernetesPack: "Iconos Kubernetes", loading: "Cargando...", loaded: "Cargado", notLoaded: "No cargado", iconCount: "{count} iconos", lazyLoadingDisabledNote: "La carga diferida está desactivada. Todos los paquetes de iconos se cargan al iniciar.", note: "Los paquetes de iconos se pueden activar o desactivar según tus necesidades. Los paquetes desactivados reducirán el uso de memoria y mejorarán el rendimiento." } }, lazyLoadingWelcome: { title: "Nueva Funcionalidad: ¡Carga Diferida!", message: "¡Hola! Después de la demanda popular, hemos implementado la Carga Diferida de iconos, así que ahora si quieres activar paquetes de iconos no estándar puedes activarlos en la sección 'Configuración'.", configPath: "Haz clic en el icono de Hamburguesa", configPath2: "en la esquina superior izquierda para acceder a la Configuración.", canDisable: "Puedes desactivar este comportamiento si lo deseas.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/fr-FR.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Ceci est un texte d'exemple" }, mainMenu: { undo: "Annuler", redo: "Refaire", open: "Ouvrir", exportJson: "Exporter en JSON", exportCompactJson: "Exporter en JSON compact", exportImage: "Exporter en image", clearCanvas: "Effacer le canevas", settings: "Paramètres", gitHub: "GitHub" }, helpDialog: { title: "Raccourcis clavier et aide", close: "Fermer", keyboardShortcuts: "Raccourcis clavier", mouseInteractions: "Interactions de la souris", action: "Action", shortcut: "Raccourci", method: "Méthode", description: "Description", note: "Remarque :", noteContent: "Les raccourcis clavier sont désactivés lors de la saisie dans les champs de saisie, les zones de texte ou les éléments modifiables pour éviter les conflits.", // Keyboard shortcuts undoAction: "Annuler", undoDescription: "Annuler la dernière action", redoAction: "Refaire", redoDescription: "Refaire la dernière action annulée", redoAltAction: "Refaire (Alternatif)", redoAltDescription: "Raccourci alternatif pour refaire", helpAction: "Aide", helpDescription: "Ouvrir la boîte de dialogue d'aide avec les raccourcis clavier", zoomInAction: "Zoom avant", zoomInShortcut: "Molette de la souris vers le haut", zoomInDescription: "Effectuer un zoom avant sur le canevas", zoomOutAction: "Zoom arrière", zoomOutShortcut: "Molette de la souris vers le bas", zoomOutDescription: "Effectuer un zoom arrière sur le canevas", panCanvasAction: "Déplacer le canevas", panCanvasShortcut: "Clic gauche + Glisser", panCanvasDescription: "Déplacer le canevas en mode déplacement", contextMenuAction: "Menu contextuel", contextMenuShortcut: "Clic droit", contextMenuDescription: "Ouvrir le menu contextuel pour les éléments ou l'espace vide", // Mouse interactions selectToolAction: "Outil de sélection", selectToolShortcut: "Cliquer sur le bouton Sélectionner", selectToolDescription: "Passer en mode sélection", panToolAction: "Outil de déplacement", panToolShortcut: "Cliquer sur le bouton Déplacer", panToolDescription: "Passer en mode déplacement pour déplacer le canevas", addItemAction: "Ajouter un élément", addItemShortcut: "Cliquer sur le bouton Ajouter un élément", addItemDescription: "Ouvrir le sélecteur d'icônes pour ajouter de nouveaux éléments", drawRectangleAction: "Dessiner un rectangle", drawRectangleShortcut: "Cliquer sur le bouton Rectangle", drawRectangleDescription: "Passer en mode dessin de rectangles", createConnectorAction: "Créer un connecteur", createConnectorShortcut: "Cliquer sur le bouton Connecteur", createConnectorDescription: "Passer en mode connecteur", addTextAction: "Ajouter du texte", addTextShortcut: "Cliquer sur le bouton Texte", addTextDescription: "Créer une nouvelle zone de texte" }, connectorHintTooltip: { tipCreatingConnectors: "Astuce : Créer des connecteurs", tipConnectorTools: "Astuce : Outils de connecteurs", clickInstructionStart: "Cliquez", clickInstructionMiddle: "sur le premier nœud ou point, puis", clickInstructionEnd: "sur le deuxième nœud ou point pour créer une connexion.", nowClickTarget: "Cliquez maintenant sur la cible pour terminer la connexion.", dragStart: "Glissez", dragEnd: "du premier nœud au deuxième nœud pour créer une connexion.", rerouteStart: "Pour réacheminer un connecteur,", rerouteMiddle: "cliquez avec le bouton gauche", rerouteEnd: "sur n'importe quel point le long de la ligne du connecteur et glissez pour créer ou déplacer des points d'ancrage." }, lassoHintTooltip: { tipLasso: "Astuce : Sélection au lasso", tipFreehandLasso: "Astuce : Sélection au lasso libre", lassoDragStart: "Cliquez et glissez", lassoDragEnd: "pour dessiner une zone de sélection rectangulaire autour des éléments que vous souhaitez sélectionner.", freehandDragStart: "Cliquez et glissez", freehandDragMiddle: "pour dessiner une", freehandDragEnd: "forme libre", freehandComplete: "autour des éléments. Relâchez pour sélectionner tous les éléments à l'intérieur de la forme.", moveStart: "Une fois sélectionnés,", moveMiddle: "cliquez à l'intérieur de la sélection", moveEnd: "et glissez pour déplacer tous les éléments sélectionnés ensemble." }, importHintTooltip: { title: "Importer des diagrammes", instructionStart: "Pour importer des diagrammes, cliquez sur le", menuButton: "bouton de menu", instructionMiddle: "(☰) dans le coin supérieur gauche, puis sélectionnez", openButton: "\"Ouvrir\"", instructionEnd: "pour charger vos fichiers de diagramme." }, connectorRerouteTooltip: { title: "Astuce : Réacheminer les connecteurs", instructionStart: "Une fois vos connecteurs placés, vous pouvez les réacheminer comme vous le souhaitez.", instructionSelect: "Sélectionnez le connecteur", instructionMiddle: "d'abord, puis", instructionClick: "cliquez sur le chemin du connecteur", instructionAnd: "et", instructionDrag: "glissez", instructionEnd: "pour le modifier !" }, connectorEmptySpaceTooltip: { message: "Pour connecter ce connecteur à un nœud,", instruction: "cliquez avec le bouton gauche sur l'extrémité du connecteur et faites-le glisser vers le nœud souhaité." }, settings: { zoom: { description: "Configurer le comportement du zoom lors de l'utilisation de la molette de la souris.", zoomToCursor: "Zoom sur le curseur", zoomToCursorDesc: "Lorsqu'il est activé, le zoom est centré sur la position du curseur de la souris. Lorsqu'il est désactivé, le zoom est centré sur le canevas." }, hotkeys: { title: "Paramètres des raccourcis", profile: "Profil de raccourcis", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Aucun raccourci", tool: "Outil", hotkey: "Raccourci", toolSelect: "Sélectionner", toolPan: "Déplacer", toolAddItem: "Ajouter un élément", toolRectangle: "Rectangle", toolConnector: "Connecteur", toolText: "Texte", note: "Remarque : Les raccourcis fonctionnent lorsque vous ne tapez pas dans des champs de texte" }, pan: { title: "Paramètres de déplacement", mousePanOptions: "Options de déplacement à la souris", emptyAreaClickPan: "Cliquer et glisser sur une zone vide", middleClickPan: "Clic du milieu et glisser", rightClickPan: "Clic droit et glisser", ctrlClickPan: "Ctrl + clic et glisser", altClickPan: "Alt + clic et glisser", keyboardPanOptions: "Options de déplacement au clavier", arrowKeys: "Touches fléchées", wasdKeys: "Touches WASD", ijklKeys: "Touches IJKL", keyboardPanSpeed: "Vitesse de déplacement au clavier", note: "Remarque : Les options de déplacement fonctionnent en plus de l'outil de déplacement dédié" }, connector: { title: "Paramètres des connecteurs", connectionMode: "Mode de création de connexion", clickMode: "Mode clic (Recommandé)", clickModeDesc: "Cliquez sur le premier nœud, puis cliquez sur le deuxième nœud pour créer une connexion", dragMode: "Mode glisser", dragModeDesc: "Cliquez et glissez du premier nœud au deuxième nœud", note: "Remarque : Vous pouvez modifier ce paramètre à tout moment. Le mode sélectionné sera utilisé lorsque l'outil de connecteur est actif." }, iconPacks: { title: "Gestion des Packs d'Icônes", lazyLoading: "Activer le Chargement Paresseux", lazyLoadingDesc: "Charger les packs d'icônes à la demande pour un démarrage plus rapide", availablePacks: "Packs d'Icônes Disponibles", coreIsoflow: "Core Isoflow (Toujours Chargé)", alwaysEnabled: "Toujours activé", awsPack: "Icônes AWS", gcpPack: "Icônes Google Cloud", azurePack: "Icônes Azure", kubernetesPack: "Icônes Kubernetes", loading: "Chargement...", loaded: "Chargé", notLoaded: "Non chargé", iconCount: "{count} icônes", lazyLoadingDisabledNote: "Le chargement paresseux est désactivé. Tous les packs d'icônes sont chargés au démarrage.", note: "Les packs d'icônes peuvent être activés ou désactivés selon vos besoins. Les packs désactivés réduiront l'utilisation de la mémoire et amélioreront les performances." } }, lazyLoadingWelcome: { title: "Nouvelle Fonctionnalité : Chargement Paresseux !", message: "Salut ! Suite à une forte demande, nous avons implémenté le Chargement Paresseux des icônes, donc maintenant si vous voulez activer des packs d'icônes non standard, vous pouvez les activer dans la section 'Configuration'.", configPath: "Cliquez sur l'icône Hamburger", configPath2: "en haut à gauche pour accéder à la Configuration.", canDisable: "Vous pouvez désactiver ce comportement si vous le souhaitez.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/hi-IN.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "यह एक उदाहरण पाठ है" }, mainMenu: { undo: "पूर्ववत करें", redo: "फिर से करें", open: "खोलें", exportJson: "JSON के रूप में निर्यात करें", exportCompactJson: "संक्षिप्त JSON के रूप में निर्यात करें", exportImage: "छवि के रूप में निर्यात करें", clearCanvas: "कैनवास साफ़ करें", settings: "सेटिंग्स", gitHub: "GitHub" }, helpDialog: { title: "कीबोर्ड शॉर्टकट और सहायता", close: "बंद करें", keyboardShortcuts: "कीबोर्ड शॉर्टकट", mouseInteractions: "माउस इंटरैक्शन", action: "क्रिया", shortcut: "शॉर्टकट", method: "विधि", description: "विवरण", note: "नोट:", noteContent: "टकराव से बचने के लिए इनपुट फ़ील्ड, टेक्स्ट एरिया या संपादन योग्य तत्वों में टाइप करते समय कीबोर्ड शॉर्टकट अक्षम हो जाते हैं।", // Keyboard shortcuts undoAction: "पूर्ववत करें", undoDescription: "अंतिम क्रिया को पूर्ववत करें", redoAction: "फिर से करें", redoDescription: "अंतिम पूर्ववत की गई क्रिया को फिर से करें", redoAltAction: "फिर से करें (वैकल्पिक)", redoAltDescription: "फिर से करने के लिए वैकल्पिक शॉर्टकट", helpAction: "सहायता", helpDescription: "कीबोर्ड शॉर्टकट के साथ सहायता संवाद खोलें", zoomInAction: "ज़ूम इन करें", zoomInShortcut: "माउस व्हील ऊपर", zoomInDescription: "कैनवास पर ज़ूम इन करें", zoomOutAction: "ज़ूम आउट करें", zoomOutShortcut: "माउस व्हील नीचे", zoomOutDescription: "कैनवास से ज़ूम आउट करें", panCanvasAction: "कैनवास को पैन करें", panCanvasShortcut: "बाएँ-क्लिक + ड्रैग", panCanvasDescription: "पैन मोड में कैनवास को पैन करें", contextMenuAction: "संदर्भ मेनू", contextMenuShortcut: "राइट-क्लिक", contextMenuDescription: "आइटम या खाली स्थान के लिए संदर्भ मेनू खोलें", // Mouse interactions selectToolAction: "चयन उपकरण", selectToolShortcut: "चयन बटन क्लिक करें", selectToolDescription: "चयन मोड पर स्विच करें", panToolAction: "पैन उपकरण", panToolShortcut: "पैन बटन क्लिक करें", panToolDescription: "कैनवास को स्थानांतरित करने के लिए पैन मोड पर स्विच करें", addItemAction: "आइटम जोड़ें", addItemShortcut: "आइटम जोड़ें बटन क्लिक करें", addItemDescription: "नए आइटम जोड़ने के लिए आइकन पिकर खोलें", drawRectangleAction: "आयत बनाएं", drawRectangleShortcut: "आयत बटन क्लिक करें", drawRectangleDescription: "आयत ड्राइंग मोड पर स्विच करें", createConnectorAction: "कनेक्टर बनाएं", createConnectorShortcut: "कनेक्टर बटन क्लिक करें", createConnectorDescription: "कनेक्टर मोड पर स्विच करें", addTextAction: "टेक्स्ट जोड़ें", addTextShortcut: "टेक्स्ट बटन क्लिक करें", addTextDescription: "एक नया टेक्स्ट बॉक्स बनाएं" }, connectorHintTooltip: { tipCreatingConnectors: "टिप: कनेक्टर बनाना", tipConnectorTools: "टिप: कनेक्टर उपकरण", clickInstructionStart: "क्लिक करें", clickInstructionMiddle: "पहले नोड या बिंदु पर, फिर", clickInstructionEnd: "दूसरे नोड या बिंदु पर कनेक्शन बनाने के लिए।", nowClickTarget: "अब कनेक्शन पूरा करने के लिए लक्ष्य पर क्लिक करें।", dragStart: "ड्रैग करें", dragEnd: "पहले नोड से दूसरे नोड तक कनेक्शन बनाने के लिए।", rerouteStart: "कनेक्टर को पुनर्मार्गित करने के लिए,", rerouteMiddle: "बाएँ-क्लिक करें", rerouteEnd: "कनेक्टर लाइन के साथ किसी भी बिंदु पर और एंकर बिंदुओं को बनाने या स्थानांतरित करने के लिए ड्रैग करें।" }, lassoHintTooltip: { tipLasso: "टिप: लासो चयन", tipFreehandLasso: "टिप: फ्रीहैंड लासो चयन", lassoDragStart: "क्लिक करें और ड्रैग करें", lassoDragEnd: "उन आइटम के चारों ओर एक आयताकार चयन बॉक्स बनाने के लिए जिन्हें आप चुनना चाहते हैं।", freehandDragStart: "क्लिक करें और ड्रैग करें", freehandDragMiddle: "एक बनाने के लिए", freehandDragEnd: "मुक्त आकार", freehandComplete: "आइटम के चारों ओर। आकार के अंदर सभी आइटम का चयन करने के लिए छोड़ें।", moveStart: "एक बार चयनित होने पर,", moveMiddle: "चयन के अंदर क्लिक करें", moveEnd: "और सभी चयनित आइटम को एक साथ स्थानांतरित करने के लिए ड्रैग करें।" }, importHintTooltip: { title: "आरेख आयात करें", instructionStart: "आरेख आयात करने के लिए, क्लिक करें", menuButton: "मेनू बटन", instructionMiddle: "(☰) ऊपरी बाएँ कोने में, फिर चुनें", openButton: "\"खोलें\"", instructionEnd: "अपनी आरेख फ़ाइलें लोड करने के लिए।" }, connectorRerouteTooltip: { title: "टिप: कनेक्टर्स को पुनर्मार्गित करें", instructionStart: "एक बार आपके कनेक्टर्स स्थापित हो जाने के बाद आप उन्हें अपनी इच्छानुसार पुनर्मार्गित कर सकते हैं।", instructionSelect: "कनेक्टर का चयन करें", instructionMiddle: "पहले, फिर", instructionClick: "कनेक्टर पथ पर क्लिक करें", instructionAnd: "और", instructionDrag: "ड्रैग करें", instructionEnd: "इसे बदलने के लिए!" }, connectorEmptySpaceTooltip: { message: "इस कनेक्टर को एक नोड से कनेक्ट करने के लिए,", instruction: "कनेक्टर के अंत पर बाईं-क्लिक करें और इसे वांछित नोड पर खींचें।" }, settings: { zoom: { description: "माउस व्हील का उपयोग करते समय ज़ूम व्यवहार को कॉन्फ़िगर करें।", zoomToCursor: "कर्सर पर ज़ूम करें", zoomToCursorDesc: "सक्षम होने पर, माउस कर्सर की स्थिति पर केंद्रित ज़ूम इन/आउट। अक्षम होने पर, ज़ूम कैनवास पर केंद्रित होता है।" }, hotkeys: { title: "शॉर्टकट सेटिंग्स", profile: "शॉर्टकट प्रोफ़ाइल", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "कोई शॉर्टकट नहीं", tool: "उपकरण", hotkey: "शॉर्टकट", toolSelect: "चयन करें", toolPan: "पैन करें", toolAddItem: "आइटम जोड़ें", toolRectangle: "आयत", toolConnector: "कनेक्टर", toolText: "टेक्स्ट", note: "नोट: टेक्स्ट फ़ील्ड में टाइप न करने पर शॉर्टकट काम करते हैं" }, pan: { title: "पैन सेटिंग्स", mousePanOptions: "माउस पैन विकल्प", emptyAreaClickPan: "खाली क्षेत्र पर क्लिक करें और ड्रैग करें", middleClickPan: "मध्य क्लिक करें और ड्रैग करें", rightClickPan: "राइट क्लिक करें और ड्रैग करें", ctrlClickPan: "Ctrl + क्लिक करें और ड्रैग करें", altClickPan: "Alt + क्लिक करें और ड्रैग करें", keyboardPanOptions: "कीबोर्ड पैन विकल्प", arrowKeys: "एरो कुंजी", wasdKeys: "WASD कुंजी", ijklKeys: "IJKL कुंजी", keyboardPanSpeed: "कीबोर्ड पैन गति", note: "नोट: समर्पित पैन उपकरण के अलावा पैन विकल्प काम करते हैं" }, connector: { title: "कनेक्टर सेटिंग्स", connectionMode: "कनेक्शन निर्माण मोड", clickMode: "क्लिक मोड (अनुशंसित)", clickModeDesc: "पहले नोड पर क्लिक करें, फिर कनेक्शन बनाने के लिए दूसरे नोड पर क्लिक करें", dragMode: "ड्रैग मोड", dragModeDesc: "पहले नोड से दूसरे नोड तक क्लिक करें और ड्रैग करें", note: "नोट: आप किसी भी समय इस सेटिंग को बदल सकते हैं। जब कनेक्टर उपकरण सक्रिय होता है तो चयनित मोड का उपयोग किया जाएगा।" }, iconPacks: { title: "आइकन पैक प्रबंधन", lazyLoading: "लेज़ी लोडिंग सक्षम करें", lazyLoadingDesc: "तेज़ स्टार्टअप के लिए आवश्यकता पर आइकन पैक लोड करें", availablePacks: "उपलब्ध आइकन पैक", coreIsoflow: "Core Isoflow (हमेशा लोड)", alwaysEnabled: "हमेशा सक्षम", awsPack: "AWS आइकन", gcpPack: "Google Cloud आइकन", azurePack: "Azure आइकन", kubernetesPack: "Kubernetes आइकन", loading: "लोड हो रहा है...", loaded: "लोड किया गया", notLoaded: "लोड नहीं किया गया", iconCount: "{count} आइकन", lazyLoadingDisabledNote: "लेज़ी लोडिंग अक्षम है। सभी आइकन पैक स्टार्टअप पर लोड किए जाते हैं।", note: "आइकन पैक आपकी आवश्यकताओं के आधार पर सक्षम या अक्षम किए जा सकते हैं। अक्षम पैक मेमोरी उपयोग को कम करेंगे और प्रदर्शन में सुधार करेंगे।" } }, lazyLoadingWelcome: { title: "नई सुविधा: लेज़ी लोडिंग!", message: "अरे! लोकप्रिय मांग के बाद, हमने आइकन की लेज़ी लोडिंग लागू की है, इसलिए अब यदि आप गैर-मानक आइकन पैक सक्षम करना चाहते हैं तो आप उन्हें 'कॉन्फ़िगरेशन' अनुभाग में सक्षम कर सकते हैं।", configPath: "हैमबर्गर आइकन पर क्लिक करें", configPath2: "कॉन्फ़िगरेशन तक पहुंचने के लिए ऊपरी बाएं में।", canDisable: "यदि आप चाहें तो आप इस व्यवहार को अक्षम कर सकते हैं।", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/id-ID.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Ini adalah contoh teks" }, mainMenu: { undo: "Batalkan", redo: "Ulangi", open: "Buka", exportJson: "Ekspor sebagai JSON", exportCompactJson: "Ekspor sebagai JSON Ringkas", exportImage: "Ekspor sebagai gambar", clearCanvas: "Bersihkan kanvas", settings: "Pengaturan", gitHub: "GitHub" }, helpDialog: { title: "Pintasan Keyboard & Bantuan", close: "Tutup", keyboardShortcuts: "Pintasan Keyboard", mouseInteractions: "Interaksi Mouse", action: "Aksi", shortcut: "Pintasan", method: "Metode", description: "Deskripsi", note: "Catatan:", noteContent: "Pintasan keyboard dinonaktifkan saat mengetik di bidang input, area teks, atau elemen yang dapat diedit untuk mencegah konflik.", // Keyboard shortcuts undoAction: "Batalkan", undoDescription: "Batalkan aksi terakhir", redoAction: "Ulangi", redoDescription: "Ulangi aksi terakhir yang dibatalkan", redoAltAction: "Ulangi (Alternatif)", redoAltDescription: "Pintasan alternatif untuk mengulangi", helpAction: "Bantuan", helpDescription: "Buka dialog bantuan dengan pintasan keyboard", zoomInAction: "Perbesar", zoomInShortcut: "Roda Mouse Naik", zoomInDescription: "Perbesar kanvas", zoomOutAction: "Perkecil", zoomOutShortcut: "Roda Mouse Turun", zoomOutDescription: "Perkecil kanvas", panCanvasAction: "Geser Kanvas", panCanvasShortcut: "Klik Kiri + Seret", panCanvasDescription: "Geser kanvas saat dalam mode Geser", contextMenuAction: "Menu Konteks", contextMenuShortcut: "Klik Kanan", contextMenuDescription: "Buka menu konteks untuk item atau ruang kosong", // Mouse interactions selectToolAction: "Alat Pilih", selectToolShortcut: "Klik tombol Pilih", selectToolDescription: "Beralih ke mode pemilihan", panToolAction: "Alat Geser", panToolShortcut: "Klik tombol Geser", panToolDescription: "Beralih ke mode geser untuk memindahkan kanvas", addItemAction: "Tambah Item", addItemShortcut: "Klik tombol Tambah item", addItemDescription: "Buka pemilih ikon untuk menambahkan item baru", drawRectangleAction: "Gambar Persegi Panjang", drawRectangleShortcut: "Klik tombol Persegi Panjang", drawRectangleDescription: "Beralih ke mode menggambar persegi panjang", createConnectorAction: "Buat Konektor", createConnectorShortcut: "Klik tombol Konektor", createConnectorDescription: "Beralih ke mode konektor", addTextAction: "Tambah Teks", addTextShortcut: "Klik tombol Teks", addTextDescription: "Buat kotak teks baru" }, connectorHintTooltip: { tipCreatingConnectors: "Tip: Membuat Konektor", tipConnectorTools: "Tip: Alat Konektor", clickInstructionStart: "Klik", clickInstructionMiddle: "pada node atau titik pertama, lalu", clickInstructionEnd: "pada node atau titik kedua untuk membuat koneksi.", nowClickTarget: "Sekarang klik pada target untuk menyelesaikan koneksi.", dragStart: "Seret", dragEnd: "dari node pertama ke node kedua untuk membuat koneksi.", rerouteStart: "Untuk mengubah rute konektor,", rerouteMiddle: "klik kiri", rerouteEnd: "pada titik mana pun di sepanjang garis konektor dan seret untuk membuat atau memindahkan titik jangkar." }, lassoHintTooltip: { tipLasso: "Tip: Seleksi Lasso", tipFreehandLasso: "Tip: Seleksi Lasso Bebas", lassoDragStart: "Klik dan seret", lassoDragEnd: "untuk menggambar kotak seleksi persegi panjang di sekitar item yang ingin Anda pilih.", freehandDragStart: "Klik dan seret", freehandDragMiddle: "untuk menggambar", freehandDragEnd: "bentuk bebas", freehandComplete: "di sekitar item. Lepas untuk memilih semua item di dalam bentuk.", moveStart: "Setelah dipilih,", moveMiddle: "klik di dalam seleksi", moveEnd: "dan seret untuk memindahkan semua item yang dipilih bersama." }, importHintTooltip: { title: "Impor Diagram", instructionStart: "Untuk mengimpor diagram, klik", menuButton: "tombol menu", instructionMiddle: "(☰) di pojok kiri atas, lalu pilih", openButton: "\"Buka\"", instructionEnd: "untuk memuat file diagram Anda." }, connectorRerouteTooltip: { title: "Tip: Ubah Rute Konektor", instructionStart: "Setelah konektor Anda ditempatkan, Anda dapat mengubah rutenya sesuai keinginan.", instructionSelect: "Pilih konektor", instructionMiddle: "terlebih dahulu, lalu", instructionClick: "klik pada jalur konektor", instructionAnd: "dan", instructionDrag: "seret", instructionEnd: "untuk mengubahnya!" }, connectorEmptySpaceTooltip: { message: "Untuk menghubungkan konektor ini ke node,", instruction: "klik kiri pada ujung konektor dan seret ke node yang diinginkan." }, settings: { zoom: { description: "Konfigurasi perilaku zoom saat menggunakan roda mouse.", zoomToCursor: "Zoom ke Kursor", zoomToCursorDesc: "Saat diaktifkan, zoom masuk/keluar terpusat pada posisi kursor mouse. Saat dinonaktifkan, zoom terpusat pada kanvas." }, hotkeys: { title: "Pengaturan Pintasan", profile: "Profil Pintasan", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Tidak Ada Pintasan", tool: "Alat", hotkey: "Pintasan", toolSelect: "Pilih", toolPan: "Geser", toolAddItem: "Tambah Item", toolRectangle: "Persegi Panjang", toolConnector: "Konektor", toolText: "Teks", note: "Catatan: Pintasan berfungsi saat tidak mengetik di bidang teks" }, pan: { title: "Pengaturan Geser", mousePanOptions: "Opsi Geser Mouse", emptyAreaClickPan: "Klik dan seret pada area kosong", middleClickPan: "Klik tengah dan seret", rightClickPan: "Klik kanan dan seret", ctrlClickPan: "Ctrl + klik dan seret", altClickPan: "Alt + klik dan seret", keyboardPanOptions: "Opsi Geser Keyboard", arrowKeys: "Tombol panah", wasdKeys: "Tombol WASD", ijklKeys: "Tombol IJKL", keyboardPanSpeed: "Kecepatan Geser Keyboard", note: "Catatan: Opsi geser berfungsi selain alat Geser khusus" }, connector: { title: "Pengaturan Konektor", connectionMode: "Mode Pembuatan Koneksi", clickMode: "Mode Klik (Direkomendasikan)", clickModeDesc: "Klik node pertama, lalu klik node kedua untuk membuat koneksi", dragMode: "Mode Seret", dragModeDesc: "Klik dan seret dari node pertama ke node kedua", note: "Catatan: Anda dapat mengubah pengaturan ini kapan saja. Mode yang dipilih akan digunakan saat alat Konektor aktif." }, iconPacks: { title: "Manajemen Paket Ikon", lazyLoading: "Aktifkan Lazy Loading", lazyLoadingDesc: "Muat paket ikon sesuai permintaan untuk startup yang lebih cepat", availablePacks: "Paket Ikon Tersedia", coreIsoflow: "Core Isoflow (Selalu Dimuat)", alwaysEnabled: "Selalu diaktifkan", awsPack: "Ikon AWS", gcpPack: "Ikon Google Cloud", azurePack: "Ikon Azure", kubernetesPack: "Ikon Kubernetes", loading: "Memuat...", loaded: "Dimuat", notLoaded: "Tidak dimuat", iconCount: "{count} ikon", lazyLoadingDisabledNote: "Lazy loading dinonaktifkan. Semua paket ikon dimuat saat startup.", note: "Paket ikon dapat diaktifkan atau dinonaktifkan sesuai kebutuhan Anda. Paket yang dinonaktifkan akan mengurangi penggunaan memori dan meningkatkan performa." } }, lazyLoadingWelcome: { title: "Fitur Baru: Lazy Loading!", message: "Hai! Setelah banyak permintaan, kami telah mengimplementasikan Lazy Loading ikon, jadi sekarang jika Anda ingin mengaktifkan paket ikon non-standar, Anda dapat mengaktifkannya di bagian 'Konfigurasi'.", configPath: "Klik pada ikon Hamburger", configPath2: "di kiri atas untuk mengakses Konfigurasi.", canDisable: "Anda dapat menonaktifkan perilaku ini jika diinginkan.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/index.ts ================================================ import enUS from './en-US'; import zhCN from './zh-CN'; import esES from './es-ES'; import ptBR from './pt-BR'; import frFR from './fr-FR'; import hiIN from './hi-IN'; import bnBD from './bn-BD'; import ruRU from './ru-RU'; import plPL from './pl-PL'; import idID from './id-ID'; import itIT from './it-IT'; import trTR from './tr-TR'; const locales = { 'en-US': enUS, 'zh-CN': zhCN, 'es-ES': esES, 'pt-BR': ptBR, 'fr-FR': frFR, 'hi-IN': hiIN, 'bn-BD': bnBD, 'ru-RU': ruRU, 'pl-PL': plPL, 'id-ID': idID, 'it-IT': itIT, 'tr-TR': trTR }; export default locales; ================================================ FILE: packages/fossflow-lib/src/i18n/it-IT.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Questo è un testo di esempio" }, mainMenu: { undo: "Annulla", redo: "Ripeti", open: "Apri", exportJson: "Esporta come JSON", exportCompactJson: "Esporta come JSON compatto", exportImage: "Esporta come immagine", clearCanvas: "Pulisci la tela", settings: "Impostazioni", gitHub: "GitHub" }, helpDialog: { title: "Scorciatoie da tastiera e aiuto", close: "Chiudi", keyboardShortcuts: "Scorciatoie da tastiera", mouseInteractions: "Interazioni del mouse", action: "Azione", shortcut: "Scorciatoia", method: "Metodo", description: "Descrizione", note: "Nota:", noteContent: "Le scorciatoie da tastiera sono disattivate durante la digitazione in campi di testo o elementi modificabili per evitare conflitti.", // Keyboard shortcuts undoAction: "Annulla", undoDescription: "Annulla l'ultima azione", redoAction: "Ripeti", redoDescription: "Ripeti l'ultima azione annullata", redoAltAction: "Ripeti (Alternativa)", redoAltDescription: "Scorciatoia alternativa per ripetere", helpAction: "Aiuto", helpDescription: "Apri la finestra di aiuto con le scorciatoie da tastiera", zoomInAction: "Ingrandisci", zoomInShortcut: "Rotella del mouse su", zoomInDescription: "Ingrandisci la tela", zoomOutAction: "Rimpicciolisci", zoomOutShortcut: "Rotella del mouse giù", zoomOutDescription: "Rimpicciolisci la tela", panCanvasAction: "Sposta la tela", panCanvasShortcut: "Clic sinistro + trascina", panCanvasDescription: "Muovi la tela in modalità panoramica", contextMenuAction: "Menu contestuale", contextMenuShortcut: "Tasto destro", contextMenuDescription: "Apri il menu contestuale per elementi o spazio vuoto", // Mouse interactions selectToolAction: "Strumento Selezione", selectToolShortcut: "Clicca il pulsante Selezione", selectToolDescription: "Passa alla modalità selezione", panToolAction: "Strumento Panoramica", panToolShortcut: "Clicca il pulsante Panoramica", panToolDescription: "Passa alla modalità panoramica per spostare la tela", addItemAction: "Aggiungi elemento", addItemShortcut: "Clicca il pulsante Aggiungi elemento", addItemDescription: "Apri il selettore di icone per aggiungere nuovi elementi", drawRectangleAction: "Disegna rettangolo", drawRectangleShortcut: "Clicca il pulsante Rettangolo", drawRectangleDescription: "Passa alla modalità disegno rettangolo", createConnectorAction: "Crea connettore", createConnectorShortcut: "Clicca il pulsante Connettore", createConnectorDescription: "Passa alla modalità connettore", addTextAction: "Aggiungi testo", addTextShortcut: "Clicca il pulsante Testo", addTextDescription: "Crea una nuova casella di testo" }, connectorHintTooltip: { tipCreatingConnectors: "Suggerimento: Creazione connettori", tipConnectorTools: "Suggerimento: Strumenti connettore", clickInstructionStart: "Clicca", clickInstructionMiddle: "sul primo nodo o punto, poi", clickInstructionEnd: "sul secondo nodo o punto per creare una connessione.", nowClickTarget: "Ora clicca sull'obiettivo per completare la connessione.", dragStart: "Trascina", dragEnd: "dal primo nodo al secondo nodo per creare una connessione.", rerouteStart: "Per riorientare un connettore,", rerouteMiddle: "clicca con il tasto sinistro", rerouteEnd: "su un punto qualsiasi lungo la linea del connettore e trascina per creare o spostare i punti di ancoraggio." }, lassoHintTooltip: { tipLasso: "Suggerimento: Selezione Lasso", tipFreehandLasso: "Suggerimento: Selezione Lasso a mano libera", lassoDragStart: "Clicca e trascina", lassoDragEnd: "per disegnare un riquadro di selezione rettangolare attorno agli elementi da selezionare.", freehandDragStart: "Clicca e trascina", freehandDragMiddle: "per disegnare una", freehandDragEnd: "forma libera", freehandComplete: "attorno agli elementi. Rilascia per selezionare tutti gli elementi all'interno della forma.", moveStart: "Una volta selezionati,", moveMiddle: "clicca all'interno della selezione", moveEnd: "e trascina per muovere tutti gli elementi selezionati insieme." }, importHintTooltip: { title: "Importa diagrammi", instructionStart: "Per importare diagrammi, clicca sul", menuButton: "pulsante del menu", instructionMiddle: "(☰) in alto a sinistra, poi seleziona", openButton: "\"Apri\"", instructionEnd: "per caricare i tuoi file di diagramma." }, connectorRerouteTooltip: { title: "Suggerimento: Riorienta connettori", instructionStart: "Una volta posizionati i connettori, puoi riorientarli come preferisci.", instructionSelect: "Seleziona prima il connettore,", instructionMiddle: "poi", instructionClick: "clicca sul percorso del connettore", instructionAnd: "e", instructionDrag: "trascina", instructionEnd: "per modificarlo!" }, connectorEmptySpaceTooltip: { message: "Per collegare questo connettore a un nodo,", instruction: "clicca con il tasto sinistro sulla fine del connettore e trascinalo sul nodo desiderato." }, settings: { zoom: { description: "Configura il comportamento dello zoom quando si usa la rotella del mouse.", zoomToCursor: "Zoom sul cursore", zoomToCursorDesc: "Se abilitato, ingrandisci o riduci centrando sul cursore del mouse. Se disabilitato, lo zoom è centrato sulla tela." }, hotkeys: { title: "Impostazioni scorciatoie", profile: "Profilo scorciatoie", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Nessuna scorciatoia", tool: "Strumento", hotkey: "Scorciatoia", toolSelect: "Seleziona", toolPan: "Panoramica", toolAddItem: "Aggiungi elemento", toolRectangle: "Rettangolo", toolConnector: "Connettore", toolText: "Testo", note: "Nota: Le scorciatoie funzionano quando non stai digitando nei campi di testo" }, pan: { title: "Impostazioni Panoramica", mousePanOptions: "Opzioni panoramica con mouse", emptyAreaClickPan: "Clicca e trascina su un'area vuota", middleClickPan: "Clic centrale e trascina", rightClickPan: "Clic destro e trascina", ctrlClickPan: "Ctrl + clic e trascina", altClickPan: "Alt + clic e trascina", keyboardPanOptions: "Opzioni panoramica con tastiera", arrowKeys: "Tasti freccia", wasdKeys: "Tasti WASD", ijklKeys: "Tasti IJKL", keyboardPanSpeed: "Velocità panoramica tastiera", note: "Nota: Le opzioni di panoramica funzionano insieme allo strumento Panoramica dedicato" }, connector: { title: "Impostazioni Connettore", connectionMode: "Modalità creazione connessione", clickMode: "Modalità clic (consigliata)", clickModeDesc: "Clicca sul primo nodo, poi sul secondo per creare una connessione", dragMode: "Modalità trascinamento", dragModeDesc: "Clicca e trascina dal primo nodo al secondo per creare una connessione", note: "Nota: Puoi modificare questa impostazione in qualsiasi momento. La modalità selezionata verrà usata quando lo strumento Connettore è attivo." }, iconPacks: { title: "Gestione pacchetti di icone", lazyLoading: "Abilita caricamento ritardato (Lazy Loading)", lazyLoadingDesc: "Carica i pacchetti di icone su richiesta per un avvio più rapido", availablePacks: "Pacchetti di icone disponibili", coreIsoflow: "Isoflow di base (sempre caricato)", alwaysEnabled: "Sempre abilitato", awsPack: "Icone AWS", gcpPack: "Icone Google Cloud", azurePack: "Icone Azure", kubernetesPack: "Icone Kubernetes", loading: "Caricamento...", loaded: "Caricato", notLoaded: "Non caricato", iconCount: "{count} icone", lazyLoadingDisabledNote: "Il caricamento ritardato è disabilitato. Tutti i pacchetti di icone vengono caricati all'avvio.", note: "I pacchetti di icone possono essere abilitati o disabilitati in base alle tue esigenze. I pacchetti disabilitati riducono l'uso di memoria e migliorano le prestazioni." } }, lazyLoadingWelcome: { title: "Nuova funzione: Lazy Loading!", message: "Ciao! Su grande richiesta, abbiamo implementato il caricamento ritardato (Lazy Loading) delle icone. Ora, se desideri abilitare pacchetti di icone non standard, puoi farlo nella sezione 'Configurazione'.", configPath: "Clicca sull'icona dell'hamburger", configPath2: "in alto a sinistra per accedere alla Configurazione.", canDisable: "Puoi disattivare questo comportamento se lo desideri.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/pl-PL.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "To jest przykładowy tekst" }, mainMenu: { undo: "Cofnij", redo: "Ponów", open: "Otwórz", exportJson: "Eksportuj do JSON", exportCompactJson: "Eksportuj jako kompaktowy JSON", exportImage: "Eksportuj do obrazu", clearCanvas: "Wyczyść obszar roboczy", settings: "Ustawienia", gitHub: "GitHub" }, helpDialog: { title: "Skróty klawiaturowe i Pomoc", close: "Zamknij", keyboardShortcuts: "Skróty klawiaturowe", mouseInteractions: "Interakcje myszy", action: "Operacja", shortcut: "Skrót", method: "Metoda", description: "Opis", note: "Uwagi:", noteContent: "Skróty klawiaturowe są wyłączone podczas wpisywania danych w polach wprowadzania danych, obszarach tekstowych lub elementach z edytowalną treścią, aby zapobiec konfliktom.", // Keyboard shortcuts undoAction: "Cofnij", undoDescription: "Cofnij do ostatniej operacji", redoAction: "Powtórz", redoDescription: "Ponów ostatnia operację", redoAltAction: "Powtórz (alternatywa)", redoAltDescription: "Alternatywny skrót do ponownego wykonania", helpAction: "Pomoc", helpDescription: "Otwórz okno dialogowe pomocy za pomocą skrótów klawiaturowych", zoomInAction: "Powiększ", zoomInShortcut: "Kółko myszy w górę", zoomInDescription: "Powiększ obszar roboczy", zoomOutAction: "Pomniejsz", zoomOutShortcut: "Kółko muszy w dół", zoomOutDescription: "Pomniejsz obszar roboczy", panCanvasAction: "Przesuwanie obszaru roboczego", panCanvasShortcut: "Kliknij lewym przyciskiem myszy + przeciągnij", panCanvasDescription: "Przesuwaj obszar roboczy w trybie przesuwania", contextMenuAction: "Menu kontekstowe", contextMenuShortcut: "Prawy przycisk myszy", contextMenuDescription: "Otwórz menu kontekstowe dla elementów lub pustej przestrzeni", // Mouse interactions selectToolAction: "Wybierz narzędzie", selectToolShortcut: "Kliknij przycisk Wybierz", selectToolDescription: "Przejdź do trybu wyboru", panToolAction: "Narzędzie przesuwania", panToolShortcut: "Kliknij przycisk „Przesuwania”", panToolDescription: "Przejdź do trybu przesuwania, aby przesuwać obszar roboczy", addItemAction: "Dodaj element", addItemShortcut: "Kliknij przycisk Dodaj element", addItemDescription: "Otwórz narzędzie do wyboru opcji, aby dodać nowe elementy.", drawRectangleAction: "Narysuj prostokąt", drawRectangleShortcut: "Kliknij przycisk Prostokąt", drawRectangleDescription: "Przejdź do trybu rysowania prostokątów", createConnectorAction: "Stwórz połączenie", createConnectorShortcut: "Kliknij przycisk Połączenie", createConnectorDescription: "Przełącz do trybu połączenia", addTextAction: "Dodaj Tekst", addTextShortcut: "Kliknij przycisk Tekst", addTextDescription: "Utwórz nowe pole tekstowe" }, connectorHintTooltip: { tipCreatingConnectors: "Wskazówka: Tworzenie połączeń", tipConnectorTools: "Wskazówka: Narzędzia do połączeń", clickInstructionStart: "Kliknij", clickInstructionMiddle: "w pierwszym węźle lub punkcie, a następnie", clickInstructionEnd: "na drugim węźle lub punkcie, aby utworzyć połączenie.", nowClickTarget: "Teraz kliknij na cel, aby zakończyć połączenie.", dragStart: "Przeciagnij", dragEnd: "od pierwszego węzła do drugiego węzła, aby utworzyć połączenie.", rerouteStart: "Aby zmienić trasę połączenia,", rerouteMiddle: "prawy przycisk myszy", rerouteEnd: "w dowolnym miejscu wzdłuż linii łącznika i przeciągnij, aby utworzyć lub przenieść punkty kotwiczenia." }, lassoHintTooltip: { tipLasso: "Wskazówka: Zaznaczanie za pomocą narzędzia Lasso", tipFreehandLasso: "Wskazówka: Zaznaczanie narzędziem Lasso z wolnej ręki", lassoDragStart: "Kliknij i przeciągnij", lassoDragEnd: "aby narysować prostokątne pole wyboru wokół elementów, które chcesz zaznaczyć.", freehandDragStart: "Kliknij i przeciągnij", freehandDragMiddle: "aby rysować", freehandDragEnd: "dowolny kształt", freehandComplete: "wokół elementów. Zwolnij, aby zaznaczyć wszystkie elementy wewnątrz kształtu.", moveStart: "Po wybraniu", moveMiddle: "kliknij wewnątrz zaznaczenia,", moveEnd: "i przeciągnij, aby przenieść wszystkie zaznaczone elementy razem." }, importHintTooltip: { title: "Importuj Diagramy", instructionStart: "Aby zaimportować diagramy, kliknij przycisk", menuButton: "Przycisk menu", instructionMiddle: "(☰) w lewym górnym rogu, a następnie wybierz", openButton: "\"Otwórz\"", instructionEnd: "aby załadować pliki diagramów." }, connectorRerouteTooltip: { title: "Wskazówka: Zmiana trasy połączenia", instructionStart: "Po umieszczeniu połączenia można je dowolnie przekierowywać..", instructionSelect: "Wybierz połączenie", instructionMiddle: "następnie", instructionClick: "kliknij na ścieżkę połączenia", instructionAnd: "i", instructionDrag: "przesuń", instructionEnd: "aby zmienić!" }, connectorEmptySpaceTooltip: { message: "Aby połączyć to połączenie z węzłem,", instruction: "kliknij lewym przyciskiem myszy koniec połączenia i przeciągnij go do żądanego węzła." }, settings: { zoom: { description: "Skonfiguruj zachowanie powiększania podczas korzystania z kółka myszy.", zoomToCursor: "Powiększ do kursora", zoomToCursorDesc: "Po włączeniu funkcji powiększanie/pomniejszanie odbywa się w oparciu o położenie kursora myszy. Po wyłączeniu funkcji Powiększ do kursora odbywa się w oparciu o położenie obszaru roboczego." }, hotkeys: { title: "Ustawienia skrótów klawiszowych", profile: "Profil skrótów klawiszowych", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "bez skrótów", tool: "Narzędzie", hotkey: "Skrót", toolSelect: "Wybór", toolPan: "Przesuwanie", toolAddItem: "Dodaj element", toolRectangle: "Prostokąt", toolConnector: "Połączenia", toolText: "Tekst", note: "Uwaga: Skróty klawiszowe działają, gdy nie wpisujesz tekstu w polach tekstowych." }, pan: { title: "Ustawienia przesuwania", mousePanOptions: "Opcje przesuwania myszą", emptyAreaClickPan: "Kliknij i przesuń obszar", middleClickPan: "Kliknij środkowym przyciskiem myszy i przeciągnij", rightClickPan: "Kliknij prawym przyciskiem myszy i przeciągnij", ctrlClickPan: "Ctrl + kliknij i przeciągnij", altClickPan: "Alt + kliknij i przeciągnij", keyboardPanOptions: "Opcje przesuwania klawiaturą", arrowKeys: "Klawisze strzałek", wasdKeys: "Klawisze WASD", ijklKeys: "Klawisze IJKL", keyboardPanSpeed: "Szybkość przesuwu klawiatury", note: "Uwaga: Opcje przesuwania działają dodatkowo w stosunku do dedykowanego narzędzia przesuwania." }, connector: { title: "Ustawienia połączeń", connectionMode: "Tryb tworzenia połączenia", clickMode: "Tryb kliknięcia (zalecany)", clickModeDesc: "Kliknij pierwszy węzeł, a następnie kliknij drugi węzeł, aby utworzyć połączenie.", dragMode: "Tryb przeciągania", dragModeDesc: "Kliknij i przeciągnij od pierwszego węzła do drugiego węzła.", note: "Uwaga: To ustawienie można zmienić w dowolnym momencie. Wybrany tryb będzie używany, gdy narzędzie Połączeń jest aktywne.." }, iconPacks: { title: "Zarządzanie pakietami ikon", lazyLoading: "Włącz opóźnione ładowanie", lazyLoadingDesc: "Wczytuj pakiety ikon na żądanie, aby przyspieszyć uruchamianie", availablePacks: "Dostępne pakiety ikon", coreIsoflow: "Core Isoflow (Zawsze wczytane)", alwaysEnabled: "Zawsze włączone", awsPack: "AWS Icons", gcpPack: "Google Cloud Icons", azurePack: "Azure Icons", kubernetesPack: "Kubernetes Icons", loading: "Wczytywanie...", loaded: "Wczytane", notLoaded: "Niewczytane", iconCount: "{count} icon", lazyLoadingDisabledNote: "Opóźnione ładowanie jest wyłączone. Wszystkie pakiety ikon są ładowane podczas uruchamiania.", note: "Pakiety ikon można włączać lub wyłączać w zależności od potrzeb. Wyłączone pakiety zmniejszają zużycie pamięci i poprawiają wydajność." } }, lazyLoadingWelcome: { title: "Nowa funkcja: Opóźnione ładowanie!", message: "Hej! W odpowiedzi na liczne prośby wprowadziliśmy funkcję opóźnionego ładowania ikon, więc teraz, jeśli chcesz włączyć niestandardowe pakiety ikon, możesz to zrobić w sekcji „Ustawienia”.", configPath: "Kliknij ikonę manu.", configPath2: "w lewym górnym rogu, aby uzyskać dostęp do ustawień.", canDisable: "Jeśli chcesz, możesz wyłączyć tę funkcję..", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/pt-BR.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Este é um texto de exemplo" }, mainMenu: { undo: "Desfazer", redo: "Refazer", open: "Abrir", exportJson: "Exportar como JSON", exportCompactJson: "Exportar como JSON compacto", exportImage: "Exportar como imagem", clearCanvas: "Limpar a tela", settings: "Configurações", gitHub: "GitHub" }, helpDialog: { title: "Atalhos de teclado e ajuda", close: "Fechar", keyboardShortcuts: "Atalhos de teclado", mouseInteractions: "Interações do mouse", action: "Ação", shortcut: "Atalho", method: "Método", description: "Descrição", note: "Nota:", noteContent: "Os atalhos de teclado são desabilitados ao digitar em campos de entrada, áreas de texto ou elementos editáveis para evitar conflitos.", // Keyboard shortcuts undoAction: "Desfazer", undoDescription: "Desfazer a última ação", redoAction: "Refazer", redoDescription: "Refazer a última ação desfeita", redoAltAction: "Refazer (Alternativo)", redoAltDescription: "Atalho alternativo para refazer", helpAction: "Ajuda", helpDescription: "Abrir diálogo de ajuda com atalhos de teclado", zoomInAction: "Aumentar zoom", zoomInShortcut: "Roda do mouse para cima", zoomInDescription: "Aumentar o zoom na tela", zoomOutAction: "Diminuir zoom", zoomOutShortcut: "Roda do mouse para baixo", zoomOutDescription: "Diminuir o zoom da tela", panCanvasAction: "Mover tela", panCanvasShortcut: "Clique esquerdo + Arrastar", panCanvasDescription: "Mover a tela no modo de movimentação", contextMenuAction: "Menu de contexto", contextMenuShortcut: "Clique direito", contextMenuDescription: "Abrir menu de contexto para itens ou espaço vazio", // Mouse interactions selectToolAction: "Ferramenta de seleção", selectToolShortcut: "Clique no botão Selecionar", selectToolDescription: "Mudar para o modo de seleção", panToolAction: "Ferramenta de movimentação", panToolShortcut: "Clique no botão Mover", panToolDescription: "Mudar para o modo de movimentação da tela", addItemAction: "Adicionar item", addItemShortcut: "Clique no botão Adicionar item", addItemDescription: "Abrir seletor de ícones para adicionar novos itens", drawRectangleAction: "Desenhar retângulo", drawRectangleShortcut: "Clique no botão Retângulo", drawRectangleDescription: "Mudar para o modo de desenho de retângulos", createConnectorAction: "Criar conector", createConnectorShortcut: "Clique no botão Conector", createConnectorDescription: "Mudar para o modo de conector", addTextAction: "Adicionar texto", addTextShortcut: "Clique no botão Texto", addTextDescription: "Criar uma nova caixa de texto" }, connectorHintTooltip: { tipCreatingConnectors: "Dica: Criar conectores", tipConnectorTools: "Dica: Ferramentas de conectores", clickInstructionStart: "Clique", clickInstructionMiddle: "no primeiro nó ou ponto, depois", clickInstructionEnd: "no segundo nó ou ponto para criar uma conexão.", nowClickTarget: "Agora clique no alvo para completar a conexão.", dragStart: "Arraste", dragEnd: "do primeiro nó ao segundo nó para criar uma conexão.", rerouteStart: "Para redirecionar um conector,", rerouteMiddle: "clique com o botão esquerdo", rerouteEnd: "em qualquer ponto ao longo da linha do conector e arraste para criar ou mover pontos de ancoragem." }, lassoHintTooltip: { tipLasso: "Dica: Seleção com laço", tipFreehandLasso: "Dica: Seleção com laço livre", lassoDragStart: "Clique e arraste", lassoDragEnd: "para desenhar uma caixa de seleção retangular ao redor dos itens que você deseja selecionar.", freehandDragStart: "Clique e arraste", freehandDragMiddle: "para desenhar uma", freehandDragEnd: "forma livre", freehandComplete: "ao redor dos itens. Solte para selecionar todos os itens dentro da forma.", moveStart: "Uma vez selecionados,", moveMiddle: "clique dentro da seleção", moveEnd: "e arraste para mover todos os itens selecionados juntos." }, importHintTooltip: { title: "Importar diagramas", instructionStart: "Para importar diagramas, clique no", menuButton: "botão de menu", instructionMiddle: "(☰) no canto superior esquerdo, depois selecione", openButton: "\"Abrir\"", instructionEnd: "para carregar seus arquivos de diagrama." }, connectorRerouteTooltip: { title: "Dica: Redirecionar conectores", instructionStart: "Uma vez que seus conectores estejam posicionados, você pode redirecioná-los como desejar.", instructionSelect: "Selecione o conector", instructionMiddle: "primeiro, depois", instructionClick: "clique no caminho do conector", instructionAnd: "e", instructionDrag: "arraste", instructionEnd: "para alterá-lo!" }, connectorEmptySpaceTooltip: { message: "Para conectar este conector a um nó,", instruction: "clique com o botão esquerdo na extremidade do conector e arraste-o para o nó desejado." }, settings: { zoom: { description: "Configurar o comportamento do zoom ao usar a roda do mouse.", zoomToCursor: "Zoom no cursor", zoomToCursorDesc: "Quando habilitado, o zoom é centralizado na posição do cursor do mouse. Quando desabilitado, o zoom é centralizado na tela." }, hotkeys: { title: "Configurações de atalhos", profile: "Perfil de atalhos", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Sem atalhos", tool: "Ferramenta", hotkey: "Atalho", toolSelect: "Selecionar", toolPan: "Mover", toolAddItem: "Adicionar item", toolRectangle: "Retângulo", toolConnector: "Conector", toolText: "Texto", note: "Nota: Os atalhos funcionam quando você não está digitando em campos de texto" }, pan: { title: "Configurações de movimentação", mousePanOptions: "Opções de movimentação com mouse", emptyAreaClickPan: "Clicar e arrastar em área vazia", middleClickPan: "Clicar com o botão do meio e arrastar", rightClickPan: "Clicar com o botão direito e arrastar", ctrlClickPan: "Ctrl + clicar e arrastar", altClickPan: "Alt + clicar e arrastar", keyboardPanOptions: "Opções de movimentação com teclado", arrowKeys: "Teclas de seta", wasdKeys: "Teclas WASD", ijklKeys: "Teclas IJKL", keyboardPanSpeed: "Velocidade de movimentação com teclado", note: "Nota: As opções de movimentação funcionam além da ferramenta de movimentação dedicada" }, connector: { title: "Configurações de conectores", connectionMode: "Modo de criação de conexão", clickMode: "Modo clique (Recomendado)", clickModeDesc: "Clique no primeiro nó, depois clique no segundo nó para criar uma conexão", dragMode: "Modo arrastar", dragModeDesc: "Clique e arraste do primeiro nó ao segundo nó", note: "Nota: Você pode alterar esta configuração a qualquer momento. O modo selecionado será usado quando a ferramenta de conector estiver ativa." }, iconPacks: { title: "Gerenciamento de Pacotes de Ícones", lazyLoading: "Ativar Carregamento Sob Demanda", lazyLoadingDesc: "Carregar pacotes de ícones sob demanda para inicialização mais rápida", availablePacks: "Pacotes de Ícones Disponíveis", coreIsoflow: "Core Isoflow (Sempre Carregado)", alwaysEnabled: "Sempre ativado", awsPack: "Ícones AWS", gcpPack: "Ícones Google Cloud", azurePack: "Ícones Azure", kubernetesPack: "Ícones Kubernetes", loading: "Carregando...", loaded: "Carregado", notLoaded: "Não carregado", iconCount: "{count} ícones", lazyLoadingDisabledNote: "O carregamento sob demanda está desativado. Todos os pacotes de ícones são carregados na inicialização.", note: "Os pacotes de ícones podem ser ativados ou desativados conforme suas necessidades. Pacotes desativados reduzirão o uso de memória e melhorarão o desempenho." } }, lazyLoadingWelcome: { title: "Novo Recurso: Carregamento Sob Demanda!", message: "Ei! Após demanda popular, implementamos o Carregamento Sob Demanda de ícones, então agora se você quiser ativar pacotes de ícones não padrão, você pode ativá-los na seção 'Configuração'.", configPath: "Clique no ícone do Menu", configPath2: "no canto superior esquerdo para acessar a Configuração.", canDisable: "Você pode desativar esse comportamento se desejar.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/ru-RU.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Это пример текста" }, mainMenu: { undo: "Отменить", redo: "Повторить", open: "Открыть", exportJson: "Экспортировать как JSON", exportCompactJson: "Экспортировать как компактный JSON", exportImage: "Экспортировать как изображение", clearCanvas: "Очистить холст", settings: "Настройки", gitHub: "GitHub" }, helpDialog: { title: "Горячие клавиши и справка", close: "Закрыть", keyboardShortcuts: "Горячие клавиши", mouseInteractions: "Взаимодействие с мышью", action: "Действие", shortcut: "Горячая клавиша", method: "Метод", description: "Описание", note: "Примечание:", noteContent: "Горячие клавиши отключены при вводе в полях ввода, текстовых областях или редактируемых элементах во избежание конфликтов.", // Keyboard shortcuts undoAction: "Отменить", undoDescription: "Отменить последнее действие", redoAction: "Повторить", redoDescription: "Повторить последнее отмененное действие", redoAltAction: "Повторить (альтернатива)", redoAltDescription: "Альтернативная горячая клавиша для повтора", helpAction: "Справка", helpDescription: "Открыть диалог справки с горячими клавишами", zoomInAction: "Увеличить", zoomInShortcut: "Колесико мыши вверх", zoomInDescription: "Увеличить масштаб холста", zoomOutAction: "Уменьшить", zoomOutShortcut: "Колесико мыши вниз", zoomOutDescription: "Уменьшить масштаб холста", panCanvasAction: "Переместить холст", panCanvasShortcut: "Левая кнопка + перетаскивание", panCanvasDescription: "Переместить холст в режиме перемещения", contextMenuAction: "Контекстное меню", contextMenuShortcut: "Правая кнопка мыши", contextMenuDescription: "Открыть контекстное меню для элементов или пустого пространства", // Mouse interactions selectToolAction: "Инструмент выделения", selectToolShortcut: "Нажать кнопку Выделить", selectToolDescription: "Переключиться в режим выделения", panToolAction: "Инструмент перемещения", panToolShortcut: "Нажать кнопку Переместить", panToolDescription: "Переключиться в режим перемещения холста", addItemAction: "Добавить элемент", addItemShortcut: "Нажать кнопку Добавить элемент", addItemDescription: "Открыть выбор иконок для добавления новых элементов", drawRectangleAction: "Нарисовать прямоугольник", drawRectangleShortcut: "Нажать кнопку Прямоугольник", drawRectangleDescription: "Переключиться в режим рисования прямоугольников", createConnectorAction: "Создать соединитель", createConnectorShortcut: "Нажать кнопку Соединитель", createConnectorDescription: "Переключиться в режим соединителя", addTextAction: "Добавить текст", addTextShortcut: "Нажать кнопку Текст", addTextDescription: "Создать новое текстовое поле" }, connectorHintTooltip: { tipCreatingConnectors: "Совет: Создание соединителей", tipConnectorTools: "Совет: Инструменты соединителей", clickInstructionStart: "Нажмите", clickInstructionMiddle: "на первый узел или точку, затем", clickInstructionEnd: "на второй узел или точку, чтобы создать соединение.", nowClickTarget: "Теперь нажмите на цель, чтобы завершить соединение.", dragStart: "Перетащите", dragEnd: "от первого узла ко второму узлу, чтобы создать соединение.", rerouteStart: "Чтобы изменить маршрут соединителя,", rerouteMiddle: "нажмите левой кнопкой", rerouteEnd: "на любую точку вдоль линии соединителя и перетащите, чтобы создать или переместить опорные точки." }, lassoHintTooltip: { tipLasso: "Совет: Выделение лассо", tipFreehandLasso: "Совет: Свободное выделение лассо", lassoDragStart: "Нажмите и перетащите", lassoDragEnd: "чтобы нарисовать прямоугольную область выделения вокруг элементов, которые вы хотите выбрать.", freehandDragStart: "Нажмите и перетащите", freehandDragMiddle: "чтобы нарисовать", freehandDragEnd: "произвольную форму", freehandComplete: "вокруг элементов. Отпустите, чтобы выбрать все элементы внутри формы.", moveStart: "После выделения", moveMiddle: "нажмите внутри выделения", moveEnd: "и перетащите, чтобы переместить все выделенные элементы вместе." }, importHintTooltip: { title: "Импорт диаграмм", instructionStart: "Чтобы импортировать диаграммы, нажмите", menuButton: "кнопку меню", instructionMiddle: "(☰) в верхнем левом углу, затем выберите", openButton: "\"Открыть\"", instructionEnd: "чтобы загрузить файлы диаграмм." }, connectorRerouteTooltip: { title: "Совет: Изменение маршрута соединителей", instructionStart: "После размещения соединителей вы можете изменить их маршрут по своему усмотрению.", instructionSelect: "Выберите соединитель", instructionMiddle: "сначала, затем", instructionClick: "нажмите на путь соединителя", instructionAnd: "и", instructionDrag: "перетащите", instructionEnd: "чтобы изменить его!" }, connectorEmptySpaceTooltip: { message: "Чтобы подключить этот соединитель к узлу,", instruction: "щелкните левой кнопкой мыши на конце соединителя и перетащите его к нужному узлу." }, settings: { zoom: { description: "Настройте поведение масштабирования при использовании колесика мыши.", zoomToCursor: "Масштабировать к курсору", zoomToCursorDesc: "При включении масштабирование центрируется на позиции курсора мыши. При выключении масштабирование центрируется на холсте." }, hotkeys: { title: "Настройки горячих клавиш", profile: "Профиль горячих клавиш", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Без горячих клавиш", tool: "Инструмент", hotkey: "Горячая клавиша", toolSelect: "Выделить", toolPan: "Переместить", toolAddItem: "Добавить элемент", toolRectangle: "Прямоугольник", toolConnector: "Соединитель", toolText: "Текст", note: "Примечание: Горячие клавиши работают, когда вы не вводите текст в текстовых полях" }, pan: { title: "Настройки перемещения", mousePanOptions: "Параметры перемещения мышью", emptyAreaClickPan: "Нажать и перетащить на пустой области", middleClickPan: "Средняя кнопка и перетаскивание", rightClickPan: "Правая кнопка и перетаскивание", ctrlClickPan: "Ctrl + нажатие и перетаскивание", altClickPan: "Alt + нажатие и перетаскивание", keyboardPanOptions: "Параметры перемещения клавиатурой", arrowKeys: "Клавиши стрелок", wasdKeys: "Клавиши WASD", ijklKeys: "Клавиши IJKL", keyboardPanSpeed: "Скорость перемещения клавиатурой", note: "Примечание: Параметры перемещения работают в дополнение к специальному инструменту перемещения" }, connector: { title: "Настройки соединителя", connectionMode: "Режим создания соединения", clickMode: "Режим нажатия (рекомендуется)", clickModeDesc: "Нажмите на первый узел, затем нажмите на второй узел, чтобы создать соединение", dragMode: "Режим перетаскивания", dragModeDesc: "Нажмите и перетащите от первого узла ко второму узлу", note: "Примечание: Вы можете изменить эту настройку в любое время. Выбранный режим будет использоваться, когда инструмент соединителя активен." }, iconPacks: { title: "Управление Пакетами Иконок", lazyLoading: "Включить Ленивую Загрузку", lazyLoadingDesc: "Загружать пакеты иконок по требованию для более быстрого запуска", availablePacks: "Доступные Пакеты Иконок", coreIsoflow: "Core Isoflow (Всегда Загружен)", alwaysEnabled: "Всегда включено", awsPack: "Иконки AWS", gcpPack: "Иконки Google Cloud", azurePack: "Иконки Azure", kubernetesPack: "Иконки Kubernetes", loading: "Загрузка...", loaded: "Загружено", notLoaded: "Не загружено", iconCount: "{count} иконок", lazyLoadingDisabledNote: "Ленивая загрузка отключена. Все пакеты иконок загружаются при запуске.", note: "Пакеты иконок могут быть включены или отключены в зависимости от ваших потребностей. Отключенные пакеты уменьшат использование памяти и улучшат производительность." } }, lazyLoadingWelcome: { title: "Новая Функция: Ленивая Загрузка!", message: "Привет! По многочисленным просьбам мы реализовали Ленивую Загрузку иконок, поэтому теперь, если вы хотите включить нестандартные пакеты иконок, вы можете включить их в разделе 'Конфигурация'.", configPath: "Нажмите на иконку Гамбургер", configPath2: "в верхнем левом углу, чтобы получить доступ к Конфигурации.", canDisable: "Вы можете отключить это поведение, если хотите.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/tr-TR.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "Bu bir örnek metindir" }, mainMenu: { undo: "Geri Al", redo: "Yinele", open: "Aç", exportJson: "JSON olarak dışa aktar", exportCompactJson: "Kompakt JSON olarak dışa aktar", exportImage: "Görüntü olarak dışa aktar", clearCanvas: "Tuvali temizle", settings: "Ayarlar", gitHub: "GitHub" }, helpDialog: { title: "Klavye Kısayolları ve Yardım", close: "Kapat", keyboardShortcuts: "Klavye Kısayolları", mouseInteractions: "Fare Etkileşimleri", action: "Eylem", shortcut: "Kısayol", method: "Yöntem", description: "Açıklama", note: "Not:", noteContent: "Klavye kısayolları, çakışmaları önlemek için giriş alanlarında, metin alanlarında veya içerik düzenlenebilir öğelerde yazarken devre dışı bırakılır.", // Keyboard shortcuts undoAction: "Geri Al", undoDescription: "Son eylemi geri al", redoAction: "Yinele", redoDescription: "Son geri alınan eylemi yinele", redoAltAction: "Yinele (Alternatif)", redoAltDescription: "Alternatif yineleme kısayolu", helpAction: "Yardım", helpDescription: "Klavye kısayollarıyla yardım diyaloğunu aç", zoomInAction: "Yakınlaştır", zoomInShortcut: "Fare Tekerleği Yukarı", zoomInDescription: "Tuvalde yakınlaştır", zoomOutAction: "Uzaklaştır", zoomOutShortcut: "Fare Tekerleği Aşağı", zoomOutDescription: "Tuvalden uzaklaştır", panCanvasAction: "Tuvali Kaydır", panCanvasShortcut: "Sol tık + Sürükle", panCanvasDescription: "Kaydırma modundayken tuvali kaydır", contextMenuAction: "Bağlam Menüsü", contextMenuShortcut: "Sağ tık", contextMenuDescription: "Öğeler veya boş alan için bağlam menüsünü aç", // Mouse interactions selectToolAction: "Seçim Aracı", selectToolShortcut: "Seç butonuna tıkla", selectToolDescription: "Seçim moduna geç", panToolAction: "Kaydırma Aracı", panToolShortcut: "Kaydır butonuna tıkla", panToolDescription: "Tuvali hareket ettirmek için kaydırma moduna geç", addItemAction: "Öğe Ekle", addItemShortcut: "Öğe ekle butonuna tıkla", addItemDescription: "Yeni öğeler eklemek için simge seçiciyi aç", drawRectangleAction: "Dikdörtgen Çiz", drawRectangleShortcut: "Dikdörtgen butonuna tıkla", drawRectangleDescription: "Dikdörtgen çizim moduna geç", createConnectorAction: "Bağlayıcı Oluştur", createConnectorShortcut: "Bağlayıcı butonuna tıkla", createConnectorDescription: "Bağlayıcı moduna geç", addTextAction: "Metin Ekle", addTextShortcut: "Metin butonuna tıkla", addTextDescription: "Yeni bir metin kutusu oluştur" }, connectorHintTooltip: { tipCreatingConnectors: "İpucu: Bağlayıcı Oluşturma", tipConnectorTools: "İpucu: Bağlayıcı Araçları", clickInstructionStart: "İlk düğüme veya noktaya", clickInstructionMiddle: "tıklayın, ardından", clickInstructionEnd: "bir bağlantı oluşturmak için ikinci düğüme veya noktaya tıklayın.", nowClickTarget: "Bağlantıyı tamamlamak için şimdi hedefe tıklayın.", dragStart: "Bir bağlantı oluşturmak için", dragEnd: "ilk düğümden ikinci düğüme sürükleyin.", rerouteStart: "Bir bağlayıcıyı yeniden yönlendirmek için,", rerouteMiddle: "bağlayıcı çizgisi boyunca herhangi bir noktaya", rerouteEnd: "sol tıklayın ve çapa noktaları oluşturmak veya taşımak için sürükleyin." }, lassoHintTooltip: { tipLasso: "İpucu: Lasso Seçimi", tipFreehandLasso: "İpucu: Serbest El Lasso Seçimi", lassoDragStart: "Seçmek istediğiniz öğelerin etrafına", lassoDragEnd: "dikdörtgen bir seçim kutusu çizmek için tıklayın ve sürükleyin.", freehandDragStart: "Tıklayın ve sürükleyin", freehandDragMiddle: "bir", freehandDragEnd: "serbest form şekli", freehandComplete: "öğelerin etrafına çizin. Şeklin içindeki tüm öğeleri seçmek için bırakın.", moveStart: "Seçildikten sonra,", moveMiddle: "seçimin içine tıklayın", moveEnd: "ve tüm seçili öğeleri birlikte taşımak için sürükleyin." }, importHintTooltip: { title: "Diyagramları İçe Aktar", instructionStart: "Diyagramları içe aktarmak için, sol üst köşedeki", menuButton: "menü butonuna", instructionMiddle: "(☰) tıklayın, ardından", openButton: "\"Aç\"", instructionEnd: "seçerek diyagram dosyalarınızı yükleyin." }, connectorRerouteTooltip: { title: "İpucu: Bağlayıcıları Yeniden Yönlendir", instructionStart: "Bağlayıcılarınız yerleştirildikten sonra istediğiniz gibi yeniden yönlendirebilirsiniz.", instructionSelect: "Önce bağlayıcıyı seçin", instructionMiddle: ", ardından", instructionClick: "bağlayıcı yoluna tıklayın", instructionAnd: "ve", instructionDrag: "değiştirmek için sürükleyin", instructionEnd: "!" }, connectorEmptySpaceTooltip: { message: "Bu bağlayıcıyı bir düğüme bağlamak için,", instruction: "bağlayıcının ucuna sol tıklayın ve istediğiniz düğüme sürükleyin." }, settings: { zoom: { description: "Fare tekerleği kullanılırken yakınlaştırma davranışını yapılandırın.", zoomToCursor: "İmlece Yakınlaştır", zoomToCursorDesc: "Etkinleştirildiğinde, fare imleci konumunda merkezlenmiş olarak yakınlaştırır/uzaklaştırır. Devre dışı bırakıldığında, yakınlaştırma tuvalde merkezlenir." }, hotkeys: { title: "Kısayol Tuşu Ayarları", profile: "Kısayol Tuşu Profili", profileQwerty: "QWERTY (Q, W, E, R, T, Y)", profileSmnrct: "SMNRCT (S, M, N, R, C, T)", profileNone: "Kısayol Tuşu Yok", tool: "Araç", hotkey: "Kısayol Tuşu", toolSelect: "Seç", toolPan: "Kaydır", toolAddItem: "Öğe Ekle", toolRectangle: "Dikdörtgen", toolConnector: "Bağlayıcı", toolText: "Metin", note: "Not: Kısayol tuşları metin alanlarında yazarken çalışmaz" }, pan: { title: "Kaydırma Ayarları", mousePanOptions: "Fare Kaydırma Seçenekleri", emptyAreaClickPan: "Boş alanda tıkla ve sürükle", middleClickPan: "Orta tık ve sürükle", rightClickPan: "Sağ tık ve sürükle", ctrlClickPan: "Ctrl + tık ve sürükle", altClickPan: "Alt + tık ve sürükle", keyboardPanOptions: "Klavye Kaydırma Seçenekleri", arrowKeys: "Ok tuşları", wasdKeys: "WASD tuşları", ijklKeys: "IJKL tuşları", keyboardPanSpeed: "Klavye Kaydırma Hızı", note: "Not: Kaydırma seçenekleri özel Kaydırma aracına ek olarak çalışır" }, connector: { title: "Bağlayıcı Ayarları", connectionMode: "Bağlantı Oluşturma Modu", clickMode: "Tıklama Modu (Önerilen)", clickModeDesc: "Bir bağlantı oluşturmak için ilk düğüme tıklayın, ardından ikinci düğüme tıklayın", dragMode: "Sürükleme Modu", dragModeDesc: "İlk düğümden ikinci düğüme tıklayın ve sürükleyin", note: "Not: Bu ayarı istediğiniz zaman değiştirebilirsiniz. Seçilen mod, Bağlayıcı aracı etkin olduğunda kullanılacaktır." }, iconPacks: { title: "Simge Paketi Yönetimi", lazyLoading: "Tembel Yükleme Etkinleştir", lazyLoadingDesc: "Daha hızlı başlangıç için simge paketlerini isteğe bağlı yükle", availablePacks: "Mevcut Simge Paketleri", coreIsoflow: "Çekirdek Isoflow (Her Zaman Yüklenir)", alwaysEnabled: "Her zaman etkin", awsPack: "AWS Simgeleri", gcpPack: "Google Cloud Simgeleri", azurePack: "Azure Simgeleri", kubernetesPack: "Kubernetes Simgeleri", loading: "Yükleniyor...", loaded: "Yüklendi", notLoaded: "Yüklenmedi", iconCount: "{count} simge", lazyLoadingDisabledNote: "Tembel yükleme devre dışı. Tüm simge paketleri başlangıçta yüklenir.", note: "Simge paketleri ihtiyaçlarınıza göre etkinleştirilebilir veya devre dışı bırakılabilir. Devre dışı bırakılan paketler bellek kullanımını azaltır ve performansı artırır." } }, lazyLoadingWelcome: { title: "Yeni Özellik: Tembel Yükleme!", message: "Merhaba! Popüler talep üzerine, simgelerin Tembel Yüklenmesini uyguladık, bu yüzden artık standart olmayan simge paketlerini etkinleştirmek isterseniz bunları 'Yapılandırma' bölümünde etkinleştirebilirsiniz.", configPath: "Yapılandırmaya erişmek için", configPath2: "sol üstteki Hamburger simgesine tıklayın.", canDisable: "İsterseniz bu davranışı devre dışı bırakabilirsiniz.", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/i18n/zh-CN.ts ================================================ import { LocaleProps } from '../types/isoflowProps'; const locale: LocaleProps = { common: { exampleText: "这是一段示例文本" }, mainMenu: { undo: "撤销", redo: "重做", open: "打开", exportJson: "导出为 JSON", exportCompactJson: "导出为紧凑 JSON", exportImage: "导出为图片", clearCanvas: "清空画布", settings: "设置", gitHub: "GitHub" }, helpDialog: { title: "键盘快捷键和帮助", close: "关闭", keyboardShortcuts: "键盘快捷键", mouseInteractions: "鼠标交互", action: "操作", shortcut: "快捷键", method: "方法", description: "描述", note: "注意:", noteContent: "在输入框、文本区域或可编辑内容元素中键入时,键盘快捷键会被禁用,以防止冲突。", // Keyboard shortcuts undoAction: "撤销", undoDescription: "撤销上一个操作", redoAction: "重做", redoDescription: "重做上一个撤销的操作", redoAltAction: "重做(备选)", redoAltDescription: "备选重做快捷键", helpAction: "帮助", helpDescription: "打开包含键盘快捷键的帮助对话框", zoomInAction: "放大", zoomInShortcut: "鼠标滚轮向上", zoomInDescription: "放大画布", zoomOutAction: "缩小", zoomOutShortcut: "鼠标滚轮向下", zoomOutDescription: "缩小画布", panCanvasAction: "平移画布", panCanvasShortcut: "左键拖拽", panCanvasDescription: "在平移模式下移动画布", contextMenuAction: "上下文菜单", contextMenuShortcut: "右键点击", contextMenuDescription: "为项目或空白区域打开上下文菜单", // Mouse interactions selectToolAction: "选择工具", selectToolShortcut: "点击选择按钮", selectToolDescription: "切换到选择模式", panToolAction: "平移工具", panToolShortcut: "点击平移按钮", panToolDescription: "切换到平移模式以移动画布", addItemAction: "添加项目", addItemShortcut: "点击添加项目按钮", addItemDescription: "打开图标选择器以添加新项目", drawRectangleAction: "绘制矩形", drawRectangleShortcut: "点击矩形按钮", drawRectangleDescription: "切换到矩形绘制模式", createConnectorAction: "创建连接器", createConnectorShortcut: "点击连接器按钮", createConnectorDescription: "切换到连接器模式", addTextAction: "添加文本", addTextShortcut: "点击文本按钮", addTextDescription: "创建新的文本框" }, connectorHintTooltip: { tipCreatingConnectors: "提示:创建连接器", tipConnectorTools: "提示:连接器工具", clickInstructionStart: "点击", clickInstructionMiddle: "第一个节点或点,然后", clickInstructionEnd: "第二个节点或点来创建连接。", nowClickTarget: "现在点击目标以完成连接。", dragStart: "拖拽", dragEnd: "从第一个节点到第二个节点来创建连接。", rerouteStart: "要重新规划连接器线路,请", rerouteMiddle: "左键点击", rerouteEnd: "连接器线上的任何点并拖拽以创建或移动锚点。" }, lassoHintTooltip: { tipLasso: "提示:套索选择", tipFreehandLasso: "提示:自由套索选择", lassoDragStart: "点击并拖拽", lassoDragEnd: "以绘制矩形选择框来选中您想选择的项目。", freehandDragStart: "点击并拖拽", freehandDragMiddle: "以绘制", freehandDragEnd: "自由形状", freehandComplete: "围绕项目。释放以选择形状内的所有项目。", moveStart: "选择后,", moveMiddle: "在选择区域内点击", moveEnd: "并拖拽以一起移动所有选中的项目。" }, importHintTooltip: { title: "导入图表", instructionStart: "要导入图表,请点击左上角的", menuButton: "菜单按钮", instructionMiddle: "(☰),然后选择", openButton: "\"打开\"", instructionEnd: "来加载您的图表文件。" }, connectorRerouteTooltip: { title: "提示:重新规划连接器路径", instructionStart: "连接器放置后,您可以随意重新规划路径。", instructionSelect: "先选择连接器", instructionMiddle: ",然后", instructionClick: "点击连接器路径", instructionAnd: "并", instructionDrag: "拖拽", instructionEnd: "即可更改!" }, connectorEmptySpaceTooltip: { message: "要将此连接器连接到节点,", instruction: "左键单击连接器末端并将其拖动到所需节点。" }, settings: { zoom: { description: "配置使用鼠标滚轮时的缩放行为。", zoomToCursor: "光标缩放", zoomToCursorDesc: "启用时,以鼠标光标位置为中心进行缩放。禁用时,以画布中心进行缩放。" }, hotkeys: { title: "快捷键设置", profile: "快捷键配置", profileQwerty: "QWERTY(Q、W、E、R、T、Y)", profileSmnrct: "SMNRCT(S、M、N、R、C、T)", profileNone: "无快捷键", tool: "工具", hotkey: "快捷键", toolSelect: "选择", toolPan: "平移", toolAddItem: "添加项目", toolRectangle: "矩形", toolConnector: "连接器", toolText: "文本", note: "注意:在文本输入框中输入时快捷键不生效" }, pan: { title: "平移设置", mousePanOptions: "鼠标平移选项", emptyAreaClickPan: "点击并拖拽空白区域", middleClickPan: "中键点击并拖拽", rightClickPan: "右键点击并拖拽", ctrlClickPan: "Ctrl + 点击并拖拽", altClickPan: "Alt + 点击并拖拽", keyboardPanOptions: "键盘平移选项", arrowKeys: "方向键", wasdKeys: "WASD 键", ijklKeys: "IJKL 键", keyboardPanSpeed: "键盘平移速度", note: "注意:平移选项可与专用的平移工具一起使用" }, connector: { title: "连接器设置", connectionMode: "连接创建模式", clickMode: "点击模式(推荐)", clickModeDesc: "先点击第一个节点,然后点击第二个节点来创建连接", dragMode: "拖拽模式", dragModeDesc: "从第一个节点点击并拖拽到第二个节点", note: "注意:您可以随时更改此设置。所选模式将在连接器工具激活时使用。" }, iconPacks: { title: "图标包管理", lazyLoading: "启用延迟加载", lazyLoadingDesc: "按需加载图标包以加快启动速度", availablePacks: "可用图标包", coreIsoflow: "核心 Isoflow(始终加载)", alwaysEnabled: "始终启用", awsPack: "AWS 图标", gcpPack: "Google Cloud 图标", azurePack: "Azure 图标", kubernetesPack: "Kubernetes 图标", loading: "加载中...", loaded: "已加载", notLoaded: "未加载", iconCount: "{count} 个图标", lazyLoadingDisabledNote: "延迟加载已禁用。所有图标包将在启动时加载。", note: "可以根据需要启用或禁用图标包。禁用的图标包将减少内存使用并提高性能。" } }, lazyLoadingWelcome: { title: "新功能:延迟加载!", message: "嘿!应大家的要求,我们实现了图标的延迟加载功能,现在如果您想启用非标准图标包,可以在「配置」部分中启用它们。", configPath: "点击左上角的汉堡菜单图标", configPath2: "以访问配置。", canDisable: "如果您愿意,可以禁用此行为。", signature: "-Stan" } }; export default locale; ================================================ FILE: packages/fossflow-lib/src/index-docker.tsx ================================================ // This is an entry point for the Docker image build. import React from 'react'; import ReactDOM from 'react-dom/client'; import { Box } from '@mui/material'; import GlobalStyles from '@mui/material/GlobalStyles'; import Isoflow, { INITIAL_DATA } from 'src/Isoflow'; import { icons, colors } from './examples/initialData'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( ); ================================================ FILE: packages/fossflow-lib/src/index.html ================================================ Development | Isoflow
================================================ FILE: packages/fossflow-lib/src/index.ts ================================================ export { Isoflow, useIsoflow } from './Isoflow'; export * from './standaloneExports'; export { default } from './Isoflow'; ================================================ FILE: packages/fossflow-lib/src/index.tsx ================================================ // This is an entry point for running the app in dev mode. import React from 'react'; import ReactDOM from 'react-dom/client'; import GlobalStyles from '@mui/material/GlobalStyles'; import { ThemeProvider, createTheme } from '@mui/material'; import { Examples } from './examples'; import { themeConfig } from './styles/theme'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( ); ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Connector.ts ================================================ import { produce } from 'immer'; import { generateId, getItemAtTile, getItemByIdOrThrow, hasMovedTile, setWindowCursor } from 'src/utils'; import { ModeActions, Connector as ConnectorI } from 'src/types'; export const Connector: ModeActions = { entry: () => { setWindowCursor('crosshair'); }, exit: () => { setWindowCursor('default'); }, mousemove: ({ uiState, scene }) => { if ( uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id || !hasMovedTile(uiState.mouse) ) return; // TypeScript type guard - we know mode is CONNECTOR type here const connectorMode = uiState.mode; // Only update connector position in drag mode or when connecting in click mode if (uiState.connectorInteractionMode === 'drag' || connectorMode.isConnecting) { // Try to find the connector - it might not exist yet const connectorItem = (scene.currentView.connectors ?? []).find( c => c.id === connectorMode.id ); // If connector doesn't exist yet, return early if (!connectorItem) { return; } const itemAtTile = getItemAtTile({ tile: uiState.mouse.position.tile, scene }); if (itemAtTile?.type === 'ITEM') { const newConnector = produce(connectorItem, (draft) => { draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } }; }); scene.updateConnector(connectorMode.id!, newConnector); } else { const newConnector = produce(connectorItem, (draft) => { draft.anchors[1] = { id: generateId(), ref: { tile: uiState.mouse.position.tile } }; }); scene.updateConnector(connectorMode.id!, newConnector); } } }, mousedown: ({ uiState, scene, isRendererInteraction }) => { if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return; const itemAtTile = getItemAtTile({ tile: uiState.mouse.position.tile, scene }); if (uiState.connectorInteractionMode === 'click') { // Click mode: handle first and second clicks if (!uiState.mode.startAnchor) { // First click: store the start position const startAnchor = itemAtTile?.type === 'ITEM' ? { itemId: itemAtTile.id } : { tile: uiState.mouse.position.tile }; // Create a connector but don't finalize it yet const newConnector: ConnectorI = { id: generateId(), color: scene.colors[0].id, anchors: [] }; if (itemAtTile && itemAtTile.type === 'ITEM') { newConnector.anchors = [ { id: generateId(), ref: { item: itemAtTile.id } }, { id: generateId(), ref: { item: itemAtTile.id } } ]; } else { newConnector.anchors = [ { id: generateId(), ref: { tile: uiState.mouse.position.tile } }, { id: generateId(), ref: { tile: uiState.mouse.position.tile } } ]; } scene.createConnector(newConnector); uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: newConnector.id, startAnchor, isConnecting: true }); } else { // Second click: complete the connection // We already checked mode.type === 'CONNECTOR' above const currentMode = uiState.mode; if (currentMode.id) { // Try to find the connector - it might not exist const connector = (scene.currentView.connectors ?? []).find( c => c.id === currentMode.id ); // If connector doesn't exist, reset mode and return if (!connector) { uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: null, startAnchor: undefined, isConnecting: false }); return; } // Update the second anchor to the click position const newConnector = produce(connector, (draft) => { if (itemAtTile?.type === 'ITEM') { draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } }; } else { draft.anchors[1] = { id: generateId(), ref: { tile: uiState.mouse.position.tile } }; } }); scene.updateConnector(currentMode.id, newConnector); // Don't delete connectors to empty space - they're valid // Only validate minimum path length will be handled by the update // Reset for next connection uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: null, startAnchor: undefined, isConnecting: false }); } } } else { // Drag mode: original behavior const newConnector: ConnectorI = { id: generateId(), color: scene.colors[0].id, anchors: [] }; if (itemAtTile && itemAtTile.type === 'ITEM') { newConnector.anchors = [ { id: generateId(), ref: { item: itemAtTile.id } }, { id: generateId(), ref: { item: itemAtTile.id } } ]; } else { newConnector.anchors = [ { id: generateId(), ref: { tile: uiState.mouse.position.tile } }, { id: generateId(), ref: { tile: uiState.mouse.position.tile } } ]; } scene.createConnector(newConnector); uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: newConnector.id }); } }, mouseup: ({ uiState, scene }) => { if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return; // Only handle mouseup for drag mode if (uiState.connectorInteractionMode === 'drag') { // Don't delete connectors to empty space - they're valid // Validation is handled in the reducer layer uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: null }); } // Click mode handles completion in mousedown (second click) } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Cursor.ts ================================================ import { produce } from 'immer'; import { ConnectorAnchor, SceneConnector, ModeActions, ModeActionsAction, Coords, View } from 'src/types'; import { getItemAtTile, hasMovedTile, getAnchorAtTile, getItemByIdOrThrow, generateId, CoordsUtils, getAnchorTile, connectorPathTileToGlobal } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; const getAnchorOrdering = ( anchor: ConnectorAnchor, connector: SceneConnector, view: View ) => { const anchorTile = getAnchorTile(anchor, view); const index = connector.path.tiles.findIndex((pathTile) => { const globalTile = connectorPathTileToGlobal( pathTile, connector.path.rectangle.from ); return CoordsUtils.isEqual(globalTile, anchorTile); }); if (index === -1) { throw new Error( `Could not calculate ordering index of anchor [anchorId: ${anchor.id}]` ); } return index; }; const getAnchor = ( connectorId: string, tile: Coords, scene: ReturnType ) => { const connector = getItemByIdOrThrow(scene.connectors, connectorId).value; const anchor = getAnchorAtTile(tile, connector.anchors); if (!anchor) { const newAnchor: ConnectorAnchor = { id: generateId(), ref: { tile } }; const orderedAnchors = [...connector.anchors, newAnchor] .map((anch) => { return { ...anch, ordering: getAnchorOrdering(anch, connector, scene.currentView) }; }) .sort((a, b) => { return a.ordering - b.ordering; }); scene.updateConnector(connector.id, { anchors: orderedAnchors }); return newAnchor; } return anchor; }; const mousedown: ModeActionsAction = ({ uiState, scene, isRendererInteraction }) => { if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return; const itemAtTile = getItemAtTile({ tile: uiState.mouse.position.tile, scene }); if (itemAtTile) { uiState.actions.setMode( produce(uiState.mode, (draft) => { draft.mousedownItem = itemAtTile; }) ); } else { uiState.actions.setMode( produce(uiState.mode, (draft) => { draft.mousedownItem = null; }) ); uiState.actions.setItemControls(null); // Show context menu for empty space on left click uiState.actions.setContextMenu({ type: 'EMPTY', tile: uiState.mouse.position.tile }); } }; export const Cursor: ModeActions = { entry: (state) => { const { uiState } = state; if (uiState.mode.type !== 'CURSOR') return; if (uiState.mode.mousedownItem) { mousedown(state); } }, mousemove: ({ scene, uiState }) => { if (uiState.mode.type !== 'CURSOR' || !hasMovedTile(uiState.mouse)) return; let item = uiState.mode.mousedownItem; if (item?.type === 'CONNECTOR' && uiState.mouse.mousedown) { const anchor = getAnchor(item.id, uiState.mouse.mousedown.tile, scene); item = { type: 'CONNECTOR_ANCHOR', id: anchor.id }; } if (item) { uiState.actions.setMode({ type: 'DRAG_ITEMS', showCursor: true, items: [item], isInitialMovement: true }); } else { // If no item is being dragged and the mouse has moved, switch to PAN mode // Only do this if the drag started on empty space if (uiState.mouse.mousedown) { uiState.actions.setMode({ type: 'PAN', showCursor: false }); } } }, mousedown, mouseup: ({ uiState, isRendererInteraction }) => { if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return; const hasMoved = uiState.mouse.mousedown && hasMovedTile(uiState.mouse); if (uiState.mode.mousedownItem && !hasMoved) { if (uiState.mode.mousedownItem.type === 'ITEM') { uiState.actions.setItemControls({ type: 'ITEM', id: uiState.mode.mousedownItem.id }); } else if (uiState.mode.mousedownItem.type === 'RECTANGLE') { uiState.actions.setItemControls({ type: 'RECTANGLE', id: uiState.mode.mousedownItem.id }); } else if (uiState.mode.mousedownItem.type === 'CONNECTOR') { uiState.actions.setItemControls({ type: 'CONNECTOR', id: uiState.mode.mousedownItem.id }); } else if (uiState.mode.mousedownItem.type === 'TEXTBOX') { uiState.actions.setItemControls({ type: 'TEXTBOX', id: uiState.mode.mousedownItem.id }); } } else { uiState.actions.setItemControls(null); } uiState.actions.setMode( produce(uiState.mode, (draft) => { draft.mousedownItem = null; }) ); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/DragItems.ts ================================================ import { produce } from 'immer'; import { ModeActions, Coords, ItemReference } from 'src/types'; import { useScene } from 'src/hooks/useScene'; import type { State } from 'src/stores/reducers/types'; import { getItemByIdOrThrow, CoordsUtils, hasMovedTile, getAnchorParent, getItemAtTile, findNearestUnoccupiedTilesForGroup } from 'src/utils'; const dragItems = ( items: ItemReference[], tile: Coords, delta: Coords, scene: ReturnType ) => { // Separate all item types upfront const itemRefs = items.filter(item => item.type === 'ITEM'); const textBoxRefs = items.filter(item => item.type === 'TEXTBOX'); const rectangleRefs = items.filter(item => item.type === 'RECTANGLE'); const anchorRefs = items.filter(item => item.type === 'CONNECTOR_ANCHOR'); // Calculate node targets if any nodes are selected let newTiles: Coords[] | null = null; if (itemRefs.length > 0) { const itemsWithTargets = itemRefs.map(item => { const node = getItemByIdOrThrow(scene.items, item.id).value; return { id: item.id, targetTile: CoordsUtils.add(node.tile, delta) }; }); newTiles = findNearestUnoccupiedTilesForGroup( itemsWithTargets, scene, itemRefs.map(item => item.id) ); // If nodes can't find valid positions, abort the entire drag operation if (!newTiles) { return; } } // Check if there's anything to update const hasUpdates = newTiles || textBoxRefs.length > 0 || rectangleRefs.length > 0; if (hasUpdates) { // Wrap ALL updates in a single transaction with state chaining // This ensures each update builds on the previous one's state scene.transaction(() => { let currentState: State | undefined; // 1. Update nodes if (newTiles) { itemRefs.forEach((item, index) => { currentState = scene.updateViewItem(item.id, { tile: newTiles[index] }, currentState); }); } // 2. Update textboxes (chained from node state) textBoxRefs.forEach((item) => { const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value; currentState = scene.updateTextBox(item.id, { tile: CoordsUtils.add(textBox.tile, delta) }, currentState); }); // 3. Update rectangles (chained from textbox state) rectangleRefs.forEach((item) => { const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value; currentState = scene.updateRectangle(item.id, { from: CoordsUtils.add(rectangle.from, delta), to: CoordsUtils.add(rectangle.to, delta) }, currentState); }); }); } // Handle connector anchors separately (they have different update logic) anchorRefs.forEach((item) => { const connector = getAnchorParent(item.id, scene.connectors); const newConnector = produce(connector, (draft) => { const anchor = getItemByIdOrThrow(connector.anchors, item.id); const itemAtTile = getItemAtTile({ tile, scene }); switch (itemAtTile?.type) { case 'ITEM': draft.anchors[anchor.index] = { ...anchor.value, ref: { item: itemAtTile.id } }; break; case 'CONNECTOR_ANCHOR': draft.anchors[anchor.index] = { ...anchor.value, ref: { anchor: itemAtTile.id } }; break; default: draft.anchors[anchor.index] = { ...anchor.value, ref: { tile } }; break; } }); scene.updateConnector(connector.id, newConnector); }); }; export const DragItems: ModeActions = { entry: ({ uiState, rendererRef }) => { if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return; const renderer = rendererRef; renderer.style.userSelect = 'none'; }, exit: ({ rendererRef }) => { const renderer = rendererRef; renderer.style.userSelect = 'auto'; }, mousemove: ({ uiState, scene }) => { if (uiState.mode.type !== 'DRAG_ITEMS' || !uiState.mouse.mousedown) return; if (uiState.mode.isInitialMovement) { const delta = CoordsUtils.subtract( uiState.mouse.position.tile, uiState.mouse.mousedown.tile ); dragItems(uiState.mode.items, uiState.mouse.position.tile, delta, scene); uiState.actions.setMode( produce(uiState.mode, (draft) => { draft.isInitialMovement = false; }) ); return; } if (!hasMovedTile(uiState.mouse) || !uiState.mouse.delta?.tile) return; const delta = uiState.mouse.delta.tile; dragItems(uiState.mode.items, uiState.mouse.position.tile, delta, scene); }, mouseup: ({ uiState }) => { uiState.actions.setItemControls(null); uiState.actions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/FreehandLasso.ts ================================================ import { produce } from 'immer'; import { ModeActions, ItemReference, Coords } from 'src/types'; import { screenToIso, isPointInPolygon } from 'src/utils'; // Helper to find all items whose centers are within the freehand polygon const getItemsInFreehandBounds = ( pathTiles: Coords[], scene: any ): ItemReference[] => { const items: ItemReference[] = []; if (pathTiles.length < 3) return items; // Check all nodes/items scene.items.forEach((item: any) => { if (isPointInPolygon(item.tile, pathTiles)) { items.push({ type: 'ITEM', id: item.id }); } }); // Check all rectangles - they must be FULLY enclosed (all 4 corners inside) scene.rectangles.forEach((rectangle: any) => { const corners = [ rectangle.from, { x: rectangle.to.x, y: rectangle.from.y }, rectangle.to, { x: rectangle.from.x, y: rectangle.to.y } ]; // Rectangle is only selected if ALL corners are inside the polygon const allCornersInside = corners.every(corner => isPointInPolygon(corner, pathTiles)); if (allCornersInside) { items.push({ type: 'RECTANGLE', id: rectangle.id }); } }); // Check all text boxes scene.textBoxes.forEach((textBox: any) => { if (isPointInPolygon(textBox.tile, pathTiles)) { items.push({ type: 'TEXTBOX', id: textBox.id }); } }); return items; }; export const FreehandLasso: ModeActions = { mousemove: ({ uiState, scene }) => { if (uiState.mode.type !== 'FREEHAND_LASSO' || !uiState.mouse.mousedown) return; // If user is dragging an existing selection, switch to DRAG_ITEMS mode if (uiState.mode.isDragging && uiState.mode.selection) { uiState.actions.setMode({ type: 'DRAG_ITEMS', showCursor: true, items: uiState.mode.selection.items, isInitialMovement: true }); return; } // User is drawing the freehand path - collect screen coordinates const newScreenPoint = uiState.mouse.position.screen; uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { // Add point to path if it's far enough from the last point (throttle) const lastPoint = draft.path[draft.path.length - 1]; if (!lastPoint || Math.abs(newScreenPoint.x - lastPoint.x) > 5 || Math.abs(newScreenPoint.y - lastPoint.y) > 5) { draft.path.push(newScreenPoint); } } }) ); }, mousedown: ({ uiState }) => { if (uiState.mode.type !== 'FREEHAND_LASSO') return; // If there's an existing selection, check if click is within it if (uiState.mode.selection) { // Convert click position to tile const clickTile = uiState.mouse.position.tile; const isWithinSelection = isPointInPolygon( clickTile, uiState.mode.selection.pathTiles ); if (isWithinSelection) { // Clicked within selection - prepare to drag uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { draft.isDragging = true; } }) ); return; } // Clicked outside selection - clear it and start new path uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { draft.path = [uiState.mouse.position.screen]; draft.selection = null; draft.isDragging = false; } }) ); return; } // Start a new path uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { draft.path = [uiState.mouse.position.screen]; draft.selection = null; draft.isDragging = false; } }) ); }, mouseup: ({ uiState, scene }) => { if (uiState.mode.type !== 'FREEHAND_LASSO') return; // If we've drawn a path, convert to tiles and find items if (uiState.mode.path.length >= 3 && !uiState.mode.selection) { const rendererSize = uiState.rendererEl?.getBoundingClientRect(); if (!rendererSize) return; // Convert screen path to tile coordinates const pathTiles = uiState.mode.path.map((screenPoint) => { return screenToIso({ mouse: screenPoint, zoom: uiState.zoom, scroll: uiState.scroll, rendererSize: { width: rendererSize.width, height: rendererSize.height } }); }); // Find all items within the freehand polygon const items = getItemsInFreehandBounds(pathTiles, scene); uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { draft.selection = { pathTiles, items }; draft.isDragging = false; } }) ); } else { // Reset dragging state but keep selection if it exists uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'FREEHAND_LASSO') { draft.isDragging = false; } }) ); } } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Lasso.ts ================================================ import { produce } from 'immer'; import { ModeActions, ItemReference } from 'src/types'; import { CoordsUtils, isWithinBounds, hasMovedTile } from 'src/utils'; // Helper to find all items within the lasso bounds const getItemsInBounds = ( startTile: { x: number; y: number }, endTile: { x: number; y: number }, scene: any ): ItemReference[] => { const items: ItemReference[] = []; // Check all nodes/items scene.items.forEach((item: any) => { if (isWithinBounds(item.tile, [startTile, endTile])) { items.push({ type: 'ITEM', id: item.id }); } }); // Check all rectangles - they must be FULLY enclosed (all 4 corners inside) scene.rectangles.forEach((rectangle: any) => { const corners = [ rectangle.from, { x: rectangle.to.x, y: rectangle.from.y }, rectangle.to, { x: rectangle.from.x, y: rectangle.to.y } ]; // Rectangle is only selected if ALL corners are inside the bounds const allCornersInside = corners.every(corner => isWithinBounds(corner, [startTile, endTile]) ); if (allCornersInside) { items.push({ type: 'RECTANGLE', id: rectangle.id }); } }); // Check all text boxes scene.textBoxes.forEach((textBox: any) => { if (isWithinBounds(textBox.tile, [startTile, endTile])) { items.push({ type: 'TEXTBOX', id: textBox.id }); } }); return items; }; export const Lasso: ModeActions = { mousemove: ({ uiState, scene }) => { if (uiState.mode.type !== 'LASSO' || !uiState.mouse.mousedown) return; if (!hasMovedTile(uiState.mouse)) return; if (uiState.mode.isDragging && uiState.mode.selection) { // User is dragging an existing selection - switch to DRAG_ITEMS mode uiState.actions.setMode({ type: 'DRAG_ITEMS', showCursor: true, items: uiState.mode.selection.items, isInitialMovement: true }); return; } // User is creating/updating the selection box const startTile = uiState.mouse.mousedown.tile; const endTile = uiState.mouse.position.tile; const items = getItemsInBounds(startTile, endTile, scene); uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'LASSO') { draft.selection = { startTile, endTile, items }; } }) ); }, mousedown: ({ uiState }) => { if (uiState.mode.type !== 'LASSO') return; // If there's an existing selection, check if click is within it if (uiState.mode.selection) { const isWithinSelection = isWithinBounds(uiState.mouse.position.tile, [ uiState.mode.selection.startTile, uiState.mode.selection.endTile ]); if (isWithinSelection) { // Clicked within selection - prepare to drag uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'LASSO') { draft.isDragging = true; } }) ); return; } // Clicked outside selection - clear it and stay in LASSO mode uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'LASSO') { draft.selection = null; draft.isDragging = false; } }) ); } }, mouseup: ({ uiState }) => { if (uiState.mode.type !== 'LASSO') return; // Reset dragging state but keep selection uiState.actions.setMode( produce(uiState.mode, (draft) => { if (draft.type === 'LASSO') { draft.isDragging = false; } }) ); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Pan.ts ================================================ import { produce } from 'immer'; import { CoordsUtils, setWindowCursor } from 'src/utils'; import { ModeActions } from 'src/types'; export const Pan: ModeActions = { entry: () => { setWindowCursor('grab'); }, exit: () => { setWindowCursor('default'); }, mousemove: ({ uiState }) => { if (uiState.mode.type !== 'PAN') return; if (uiState.mouse.mousedown !== null) { const newScroll = produce(uiState.scroll, (draft) => { draft.position = uiState.mouse.delta?.screen ? CoordsUtils.add(draft.position, uiState.mouse.delta.screen) : draft.position; }); uiState.actions.setScroll(newScroll); } }, mousedown: ({ uiState, isRendererInteraction }) => { if (uiState.mode.type !== 'PAN' || !isRendererInteraction) return; setWindowCursor('grabbing'); }, mouseup: ({ uiState }) => { if (uiState.mode.type !== 'PAN') return; setWindowCursor('grab'); // Note: Mode switching is now handled by usePanHandlers } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/PlaceIcon.ts ================================================ import { produce } from 'immer'; import { ModeActions } from 'src/types'; import { generateId, getItemAtTile, findNearestUnoccupiedTile } from 'src/utils'; import { VIEW_ITEM_DEFAULTS } from 'src/config'; export const PlaceIcon: ModeActions = { mousemove: () => {}, mousedown: ({ uiState, scene, isRendererInteraction }) => { if (uiState.mode.type !== 'PLACE_ICON' || !isRendererInteraction) return; if (!uiState.mode.id) { const itemAtTile = getItemAtTile({ tile: uiState.mouse.position.tile, scene }); uiState.actions.setMode({ type: 'CURSOR', mousedownItem: itemAtTile, showCursor: true }); uiState.actions.setItemControls(null); } }, mouseup: ({ uiState, scene }) => { if (uiState.mode.type !== 'PLACE_ICON') return; if (uiState.mode.id !== null) { // Find the nearest unoccupied tile to the target position const targetTile = findNearestUnoccupiedTile( uiState.mouse.position.tile, scene ); // Place the icon on the nearest unoccupied tile if (targetTile) { const modelItemId = generateId(); scene.placeIcon({ modelItem: { id: modelItemId, name: 'Untitled', icon: uiState.mode.id }, viewItem: { ...VIEW_ITEM_DEFAULTS, id: modelItemId, tile: targetTile } }); } } uiState.actions.setMode( produce(uiState.mode, (draft) => { draft.id = null; }) ); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Rectangle/DrawRectangle.ts ================================================ import { ModeActions } from 'src/types'; import { produce } from 'immer'; import { generateId, hasMovedTile, setWindowCursor } from 'src/utils'; export const DrawRectangle: ModeActions = { entry: () => { setWindowCursor('crosshair'); }, exit: () => { setWindowCursor('default'); }, mousemove: ({ uiState, scene }) => { if ( uiState.mode.type !== 'RECTANGLE.DRAW' || !hasMovedTile(uiState.mouse) || !uiState.mode.id || !uiState.mouse.mousedown ) return; scene.updateRectangle(uiState.mode.id, { to: uiState.mouse.position.tile }); }, mousedown: ({ uiState, scene, isRendererInteraction }) => { if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction) return; const newRectangleId = generateId(); scene.createRectangle({ id: newRectangleId, color: scene.colors[0].id, from: uiState.mouse.position.tile, to: uiState.mouse.position.tile }); const newMode = produce(uiState.mode, (draft) => { draft.id = newRectangleId; }); uiState.actions.setMode(newMode); }, mouseup: ({ uiState }) => { if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return; uiState.actions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/Rectangle/TransformRectangle.ts ================================================ import { getItemByIdOrThrow, getBoundingBox, convertBoundsToNamedAnchors, hasMovedTile } from 'src/utils'; import { ModeActions } from 'src/types'; export const TransformRectangle: ModeActions = { entry: () => {}, exit: () => {}, mousemove: ({ uiState, scene }) => { if ( uiState.mode.type !== 'RECTANGLE.TRANSFORM' || !hasMovedTile(uiState.mouse) ) return; if (uiState.mode.selectedAnchor) { // User is dragging an anchor const rectangle = getItemByIdOrThrow( scene.rectangles, uiState.mode.id ).value; const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]); const namedBounds = convertBoundsToNamedAnchors(rectangleBounds); if ( uiState.mode.selectedAnchor === 'BOTTOM_LEFT' || uiState.mode.selectedAnchor === 'TOP_RIGHT' ) { const nextBounds = getBoundingBox([ uiState.mode.selectedAnchor === 'BOTTOM_LEFT' ? namedBounds.TOP_RIGHT : namedBounds.BOTTOM_LEFT, uiState.mouse.position.tile ]); const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds); scene.updateRectangle(uiState.mode.id, { from: nextNamedBounds.TOP_RIGHT, to: nextNamedBounds.BOTTOM_LEFT }); } else if ( uiState.mode.selectedAnchor === 'BOTTOM_RIGHT' || uiState.mode.selectedAnchor === 'TOP_LEFT' ) { const nextBounds = getBoundingBox([ uiState.mode.selectedAnchor === 'BOTTOM_RIGHT' ? namedBounds.TOP_LEFT : namedBounds.BOTTOM_RIGHT, uiState.mouse.position.tile ]); const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds); scene.updateRectangle(uiState.mode.id, { from: nextNamedBounds.TOP_LEFT, to: nextNamedBounds.BOTTOM_RIGHT }); } } }, mousedown: () => { // MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`) }, mouseup: ({ uiState }) => { if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return; uiState.actions.setMode({ type: 'CURSOR', mousedownItem: null, showCursor: true }); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/modes/TextBox.ts ================================================ import { setWindowCursor } from 'src/utils'; import { ModeActions } from 'src/types'; export const TextBox: ModeActions = { entry: () => { setWindowCursor('crosshair'); }, exit: () => { setWindowCursor('default'); }, mousemove: ({ uiState, scene }) => { if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return; scene.updateTextBox(uiState.mode.id, { tile: uiState.mouse.position.tile }); }, mouseup: ({ uiState, scene, isRendererInteraction }) => { if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return; if (!isRendererInteraction) { scene.deleteTextBox(uiState.mode.id); } else { uiState.actions.setItemControls({ type: 'TEXTBOX', id: uiState.mode.id }); } uiState.actions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); } }; ================================================ FILE: packages/fossflow-lib/src/interaction/useInteractionManager.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import { useModelStoreApi } from 'src/stores/modelStore'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore'; import { ModeActions, State, SlimMouseEvent, Mouse } from 'src/types'; import { DialogTypeEnum } from 'src/types/ui'; import { getMouse, getItemAtTile, generateId, incrementZoom, decrementZoom } from 'src/utils'; import { useResizeObserver } from 'src/hooks/useResizeObserver'; import { useScene } from 'src/hooks/useScene'; import { useHistory } from 'src/hooks/useHistory'; import { HOTKEY_PROFILES } from 'src/config/hotkeys'; import { TEXTBOX_DEFAULTS } from 'src/config'; import { Cursor } from './modes/Cursor'; import { DragItems } from './modes/DragItems'; import { DrawRectangle } from './modes/Rectangle/DrawRectangle'; import { TransformRectangle } from './modes/Rectangle/TransformRectangle'; import { Connector } from './modes/Connector'; import { Pan } from './modes/Pan'; import { PlaceIcon } from './modes/PlaceIcon'; import { TextBox } from './modes/TextBox'; import { Lasso } from './modes/Lasso'; import { FreehandLasso } from './modes/FreehandLasso'; import { usePanHandlers } from './usePanHandlers'; interface PendingMouseUpdate { mouse: Mouse; event: SlimMouseEvent; } const useRAFThrottle = () => { const rafIdRef = useRef(null); const pendingUpdateRef = useRef(null); const callbackRef = useRef<((update: PendingMouseUpdate) => void) | null>(null); const scheduleUpdate = useCallback((mouse: Mouse, event: SlimMouseEvent, callback: (update: PendingMouseUpdate) => void) => { pendingUpdateRef.current = { mouse, event }; callbackRef.current = callback; if (rafIdRef.current === null) { rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = null; if (pendingUpdateRef.current && callbackRef.current) { callbackRef.current(pendingUpdateRef.current); pendingUpdateRef.current = null; } }); } }, []); const flushUpdate = useCallback(() => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } if (pendingUpdateRef.current && callbackRef.current) { callbackRef.current(pendingUpdateRef.current); pendingUpdateRef.current = null; } }, []); const cleanup = useCallback(() => { if (rafIdRef.current !== null) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } pendingUpdateRef.current = null; }, []); return { scheduleUpdate, flushUpdate, cleanup }; }; const modes: { [k in string]: ModeActions } = { CURSOR: Cursor, DRAG_ITEMS: DragItems, 'RECTANGLE.DRAW': DrawRectangle, 'RECTANGLE.TRANSFORM': TransformRectangle, CONNECTOR: Connector, PAN: Pan, PLACE_ICON: PlaceIcon, TEXTBOX: TextBox, LASSO: Lasso, FREEHAND_LASSO: FreehandLasso }; const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => { switch (e.type) { case 'mousemove': return mode.mousemove; case 'mousedown': return mode.mousedown; case 'mouseup': return mode.mouseup; default: return null; } }; export const useInteractionManager = () => { const rendererRef = useRef(undefined); const reducerTypeRef = useRef(undefined); const modeType = useUiStateStore((state) => state.mode.type); const rendererEl = useUiStateStore((state) => state.rendererEl); const editorMode = useUiStateStore((state) => state.editorMode); const uiStateApi = useUiStateStoreApi(); const modelStoreApi = useModelStoreApi(); const scene = useScene(); const { size: rendererSize } = useResizeObserver(rendererEl); const { undo, redo, canUndo, canRedo } = useHistory(); const { createTextBox } = scene; const { handleMouseDown: handlePanMouseDown, handleMouseUp: handlePanMouseUp } = usePanHandlers(); const { scheduleUpdate, flushUpdate, cleanup } = useRAFThrottle(); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const uiState = uiStateApi.getState(); if (e.key === 'Escape') { e.preventDefault(); if (uiState.itemControls) { uiState.actions.setItemControls(null); return; } if (uiState.mode.type === 'CONNECTOR') { const connectorMode = uiState.mode; const isConnectionInProgress = (uiState.connectorInteractionMode === 'click' && connectorMode.isConnecting) || (uiState.connectorInteractionMode === 'drag' && connectorMode.id !== null); if (isConnectionInProgress && connectorMode.id) { scene.deleteConnector(connectorMode.id); uiState.actions.setMode({ type: 'CONNECTOR', showCursor: true, id: null, startAnchor: undefined, isConnecting: false }); } } return; } const target = e.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.closest('.ql-editor') ) { return; } const isCtrlOrCmd = e.ctrlKey || e.metaKey; if (isCtrlOrCmd && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); if (canUndo) { undo(); } } if ( isCtrlOrCmd && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey)) ) { e.preventDefault(); if (canRedo) { redo(); } } if (e.key === 'F1') { e.preventDefault(); uiState.actions.setDialog(DialogTypeEnum.HELP); } const hotkeyMapping = HOTKEY_PROFILES[uiState.hotkeyProfile]; const key = e.key.toLowerCase(); if (key === 'i' && uiState.itemControls && 'id' in uiState.itemControls && uiState.itemControls.type === 'ITEM') { e.preventDefault(); const event = new CustomEvent('quickIconChange'); window.dispatchEvent(event); } if (hotkeyMapping.select && key === hotkeyMapping.select) { e.preventDefault(); uiState.actions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); } else if (hotkeyMapping.pan && key === hotkeyMapping.pan) { e.preventDefault(); uiState.actions.setMode({ type: 'PAN', showCursor: false }); uiState.actions.setItemControls(null); } else if (hotkeyMapping.addItem && key === hotkeyMapping.addItem) { e.preventDefault(); uiState.actions.setItemControls({ type: 'ADD_ITEM' }); uiState.actions.setMode({ type: 'PLACE_ICON', showCursor: true, id: null }); } else if (hotkeyMapping.rectangle && key === hotkeyMapping.rectangle) { e.preventDefault(); uiState.actions.setMode({ type: 'RECTANGLE.DRAW', showCursor: true, id: null }); } else if (hotkeyMapping.connector && key === hotkeyMapping.connector) { e.preventDefault(); uiState.actions.setMode({ type: 'CONNECTOR', id: null, showCursor: true }); } else if (hotkeyMapping.text && key === hotkeyMapping.text) { e.preventDefault(); const textBoxId = generateId(); createTextBox({ ...TEXTBOX_DEFAULTS, id: textBoxId, tile: uiState.mouse.position.tile }); uiState.actions.setMode({ type: 'TEXTBOX', showCursor: false, id: textBoxId }); } else if (hotkeyMapping.lasso && key === hotkeyMapping.lasso) { e.preventDefault(); uiState.actions.setMode({ type: 'LASSO', showCursor: true, selection: null, isDragging: false }); } else if (hotkeyMapping.freehandLasso && key === hotkeyMapping.freehandLasso) { e.preventDefault(); uiState.actions.setMode({ type: 'FREEHAND_LASSO', showCursor: true, path: [], selection: null, isDragging: false }); } }; window.addEventListener('keydown', handleKeyDown); return () => { return window.removeEventListener('keydown', handleKeyDown); }; }, [undo, redo, canUndo, canRedo, uiStateApi, createTextBox, scene]); const processMouseUpdate = useCallback( (nextMouse: Mouse, e: SlimMouseEvent) => { if (!rendererRef.current) return; const uiState = uiStateApi.getState(); const model = modelStoreApi.getState(); const mode = modes[uiState.mode.type]; const modeFunction = getModeFunction(mode, e); if (!modeFunction) return; uiState.actions.setMouse(nextMouse); const baseState: State = { model, scene, uiState, rendererRef: rendererRef.current, rendererSize, isRendererInteraction: rendererRef.current === e.target }; if (reducerTypeRef.current !== uiState.mode.type) { const prevReducer = reducerTypeRef.current ? modes[reducerTypeRef.current] : null; if (prevReducer && prevReducer.exit) { prevReducer.exit(baseState); } if (mode.entry) { mode.entry(baseState); } } modeFunction(baseState); reducerTypeRef.current = uiState.mode.type; }, [uiStateApi, modelStoreApi, scene, rendererSize] ); const onMouseEvent = useCallback( (e: SlimMouseEvent) => { if (!rendererRef.current) return; if (e.type === 'mousedown' && handlePanMouseDown(e)) { return; } if (e.type === 'mouseup' && handlePanMouseUp(e)) { return; } const uiState = uiStateApi.getState(); const nextMouse = getMouse({ interactiveElement: rendererRef.current, zoom: uiState.zoom, scroll: uiState.scroll, lastMouse: uiState.mouse, mouseEvent: e, rendererSize }); if (e.type === 'mousemove') { scheduleUpdate(nextMouse, e, (update) => { processMouseUpdate(update.mouse, update.event); }); } else { flushUpdate(); processMouseUpdate(nextMouse, e); } }, [uiStateApi, rendererSize, handlePanMouseDown, handlePanMouseUp, scheduleUpdate, flushUpdate, processMouseUpdate] ); const onContextMenu = useCallback( (e: SlimMouseEvent) => { e.preventDefault(); const uiState = uiStateApi.getState(); if (uiState.panSettings.rightClickPan) { return; } const itemAtTile = getItemAtTile({ tile: uiState.mouse.position.tile, scene }); if (itemAtTile) { uiState.actions.setContextMenu({ type: 'ITEM', item: itemAtTile, tile: uiState.mouse.position.tile }); } else { uiState.actions.setContextMenu({ type: 'EMPTY', tile: uiState.mouse.position.tile }); } }, [uiStateApi, scene] ); useEffect(() => { if (modeType === 'INTERACTIONS_DISABLED') return; const el = window; const onTouchStart = (e: TouchEvent) => { onMouseEvent({ ...e, clientX: Math.floor(e.touches[0].clientX), clientY: Math.floor(e.touches[0].clientY), type: 'mousedown', button: 0 }); }; const onTouchMove = (e: TouchEvent) => { onMouseEvent({ ...e, clientX: Math.floor(e.touches[0].clientX), clientY: Math.floor(e.touches[0].clientY), type: 'mousemove', button: 0 }); }; const onTouchEnd = (e: TouchEvent) => { onMouseEvent({ ...e, clientX: 0, clientY: 0, type: 'mouseup', button: 0 }); }; const onScroll = (e: WheelEvent) => { const uiState = uiStateApi.getState(); const zoomToCursor = uiState.zoomSettings.zoomToCursor; const oldZoom = uiState.zoom; let newZoom: number; if (e.deltaY > 0) { newZoom = decrementZoom(oldZoom); } else { newZoom = incrementZoom(oldZoom); } if (newZoom === oldZoom) { return; } if (zoomToCursor && rendererRef.current && rendererSize) { const rect = rendererRef.current.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const mouseRelativeToCenterX = mouseX - rendererSize.width / 2; const mouseRelativeToCenterY = mouseY - rendererSize.height / 2; const worldX = (mouseRelativeToCenterX - uiState.scroll.position.x) / oldZoom; const worldY = (mouseRelativeToCenterY - uiState.scroll.position.y) / oldZoom; const newScrollX = mouseRelativeToCenterX - worldX * newZoom; const newScrollY = mouseRelativeToCenterY - worldY * newZoom; uiState.actions.setZoom(newZoom); uiState.actions.setScroll({ position: { x: newScrollX, y: newScrollY }, offset: uiState.scroll.offset }); } else { uiState.actions.setZoom(newZoom); } }; el.addEventListener('mousemove', onMouseEvent); el.addEventListener('mousedown', onMouseEvent); el.addEventListener('mouseup', onMouseEvent); el.addEventListener('contextmenu', onContextMenu); el.addEventListener('touchstart', onTouchStart); el.addEventListener('touchmove', onTouchMove); el.addEventListener('touchend', onTouchEnd); rendererEl?.addEventListener('wheel', onScroll, { passive: true }); return () => { el.removeEventListener('mousemove', onMouseEvent); el.removeEventListener('mousedown', onMouseEvent); el.removeEventListener('mouseup', onMouseEvent); el.removeEventListener('contextmenu', onContextMenu); el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); el.removeEventListener('touchend', onTouchEnd); rendererEl?.removeEventListener('wheel', onScroll); cleanup(); }; }, [ editorMode, modeType, onMouseEvent, onContextMenu, rendererEl, rendererSize, uiStateApi, cleanup ]); const setInteractionsElement = useCallback((element: HTMLElement) => { rendererRef.current = element; }, []); return { setInteractionsElement }; }; ================================================ FILE: packages/fossflow-lib/src/interaction/usePanHandlers.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import { useUiStateStore, useUiStateStoreApi } from 'src/stores/uiStateStore'; import { CoordsUtils, getItemAtTile } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; import { SlimMouseEvent } from 'src/types'; export const usePanHandlers = () => { const modeType = useUiStateStore((state) => state.mode.type); const actions = useUiStateStore((state) => state.actions); const panSettings = useUiStateStore((state) => state.panSettings); const rendererEl = useUiStateStore((state) => state.rendererEl); const mouseTile = useUiStateStore((state) => state.mouse.position.tile); const uiStateApi = useUiStateStoreApi(); const scene = useScene(); const isPanningRef = useRef(false); const panMethodRef = useRef(null); const startPan = useCallback((method: string) => { if (modeType !== 'PAN') { isPanningRef.current = true; panMethodRef.current = method; actions.setMode({ type: 'PAN', showCursor: false }); } }, [modeType, actions]); const endPan = useCallback(() => { if (isPanningRef.current) { isPanningRef.current = false; panMethodRef.current = null; actions.setMode({ type: 'CURSOR', showCursor: true, mousedownItem: null }); } }, [actions]); const isEmptyArea = useCallback((e: SlimMouseEvent): boolean => { if (!rendererEl || e.target !== rendererEl) return false; const itemAtTile = getItemAtTile({ tile: mouseTile, scene }); return !itemAtTile; }, [rendererEl, mouseTile, scene]); const handleMouseDown = useCallback((e: SlimMouseEvent): boolean => { if (e.button === 1 && panSettings.middleClickPan) { e.preventDefault(); startPan('middle'); return true; } if (e.button === 2 && panSettings.rightClickPan) { e.preventDefault(); startPan('right'); return true; } if (e.button === 0) { if (panSettings.ctrlClickPan && e.ctrlKey) { e.preventDefault(); startPan('ctrl'); return true; } if (panSettings.altClickPan && e.altKey) { e.preventDefault(); startPan('alt'); return true; } if (panSettings.emptyAreaClickPan && isEmptyArea(e)) { startPan('empty'); return true; } } return false; }, [panSettings, startPan, isEmptyArea]); const handleMouseUp = useCallback((e: SlimMouseEvent): boolean => { if (isPanningRef.current) { endPan(); return true; } return false; }, [endPan]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' || target.closest('.ql-editor') ) { return; } const currentState = uiStateApi.getState(); const currentPanSettings = currentState.panSettings; const speed = currentPanSettings.keyboardPanSpeed; let dx = 0; let dy = 0; if (currentPanSettings.arrowKeysPan) { if (e.key === 'ArrowUp') { dy = speed; e.preventDefault(); } else if (e.key === 'ArrowDown') { dy = -speed; e.preventDefault(); } else if (e.key === 'ArrowLeft') { dx = speed; e.preventDefault(); } else if (e.key === 'ArrowRight') { dx = -speed; e.preventDefault(); } } if (currentPanSettings.wasdPan) { const key = e.key.toLowerCase(); if (key === 'w') { dy = speed; e.preventDefault(); } else if (key === 's') { dy = -speed; e.preventDefault(); } else if (key === 'a') { dx = speed; e.preventDefault(); } else if (key === 'd') { dx = -speed; e.preventDefault(); } } if (currentPanSettings.ijklPan) { const key = e.key.toLowerCase(); if (key === 'i') { dy = speed; e.preventDefault(); } else if (key === 'k') { dy = -speed; e.preventDefault(); } else if (key === 'j') { dx = speed; e.preventDefault(); } else if (key === 'l') { dx = -speed; e.preventDefault(); } } if (dx !== 0 || dy !== 0) { const currentScroll = currentState.scroll; const newPosition = CoordsUtils.add( currentScroll.position, { x: dx, y: dy } ); currentState.actions.setScroll({ position: newPosition, offset: currentScroll.offset }); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [uiStateApi]); return { handleMouseDown, handleMouseUp, isPanning: isPanningRef.current }; }; ================================================ FILE: packages/fossflow-lib/src/module.d.ts ================================================ declare module '*.svg' { const content: React.FunctionComponent>; export default content; } ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/colors.test.ts ================================================ import { colorSchema, colorsSchema } from '../colors'; describe('colorSchema', () => { it('validates a correct color', () => { const valid = { id: 'color1', value: '#123456' }; expect(colorSchema.safeParse(valid).success).toBe(true); }); it('fails if value is too long', () => { const invalid = { id: 'color1', value: '#1234567A' }; const result = colorSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('value'); }) ).toBe(true); } }); }); describe('colorsSchema', () => { it('validates an array of colors', () => { const valid = [ { id: 'color1', value: '#000000' }, { id: 'color2', value: '#ffffff' } ]; expect(colorsSchema.safeParse(valid).success).toBe(true); }); it('fails if any color is invalid', () => { const invalid = [ { id: 'color1', value: '#000000' }, { id: 'color2', value: '#1234567A' } ]; const result = colorsSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('value'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/connector.test.ts ================================================ import { anchorSchema, connectorSchema } from '../connector'; describe('anchorSchema', () => { it('validates a correct anchor', () => { const valid = { id: 'a1', ref: { item: 'item1' } }; expect(anchorSchema.safeParse(valid).success).toBe(true); }); it('fails if id is missing', () => { const invalid = { ref: { item: 'item1' } }; const result = anchorSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); describe('connectorSchema', () => { it('validates a correct connector', () => { const valid = { id: 'c1', anchors: [{ id: 'a1', ref: { item: 'item1' } }] }; expect(connectorSchema.safeParse(valid).success).toBe(true); }); it('fails if anchors is missing', () => { const invalid = { id: 'c1' }; const result = connectorSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('anchors'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/icons.test.ts ================================================ import { iconSchema, iconsSchema } from '../icons'; describe('iconSchema', () => { it('validates a correct icon', () => { const valid = { id: 'icon1', name: 'Icon', url: 'http://test.com' }; expect(iconSchema.safeParse(valid).success).toBe(true); }); it('fails if required fields are missing', () => { const invalid = { name: 'Icon' }; const result = iconSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); describe('iconsSchema', () => { it('validates an array of icons', () => { const valid = [ { id: 'icon1', name: 'Icon', url: 'http://test.com' }, { id: 'icon2', name: 'Icon2', url: 'http://test2.com' } ]; expect(iconsSchema.safeParse(valid).success).toBe(true); }); it('fails if any icon is invalid', () => { const invalid = [ { id: 'icon1', name: 'Icon', url: 'http://test.com' }, { name: 'MissingId' } ]; const result = iconsSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/modelItems.test.ts ================================================ import { modelItemSchema, modelItemsSchema } from '../modelItems'; describe('modelItemSchema', () => { it('validates a correct model item', () => { const valid = { id: 'item1', name: 'Test', icon: 'icon1', description: 'desc' }; expect(modelItemSchema.safeParse(valid).success).toBe(true); }); it('fails if required fields are missing', () => { const invalid = { name: 'Test' }; const result = modelItemSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); describe('modelItemsSchema', () => { it('validates an array of model items', () => { const valid = [ { id: 'item1', name: 'Test1' }, { id: 'item2', name: 'Test2', icon: 'icon2' } ]; expect(modelItemsSchema.safeParse(valid).success).toBe(true); }); it('fails if any item is invalid', () => { const invalid = [{ id: 'item1', name: 'Test1' }, { name: 'MissingId' }]; const result = modelItemsSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/rectangle.test.ts ================================================ import { rectangleSchema } from '../rectangle'; describe('rectangleSchema', () => { it('validates a correct rectangle', () => { const valid = { id: 'rect1', from: { x: 0, y: 0 }, to: { x: 1, y: 1 } }; expect(rectangleSchema.safeParse(valid).success).toBe(true); }); it('fails if from is missing', () => { const invalid = { id: 'rect1', to: { x: 1, y: 1 } }; const result = rectangleSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('from'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/textBox.test.ts ================================================ import { textBoxSchema } from '../textBox'; describe('textBoxSchema', () => { it('validates a correct text box', () => { const valid = { id: 'tb1', tile: { x: 0, y: 0 }, content: 'Text' }; expect(textBoxSchema.safeParse(valid).success).toBe(true); }); it('fails if content is missing', () => { const invalid = { id: 'tb1', tile: { x: 0, y: 0 } }; const result = textBoxSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('content'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/validation.test.ts ================================================ import { produce } from 'immer'; import { Connector, ViewItem } from 'src/types'; import { model as modelFixture } from '../../fixtures/model'; import { validateModel } from '../validation'; describe('Model validation works correctly', () => { test('Model fixture is valid', () => { const issues = validateModel(modelFixture); expect(issues.length).toStrictEqual(0); }); test('Connector with anchor that references an invalid item fails validation', () => { const invalidConnector: Connector = { id: 'invalidConnector', color: 'color1', anchors: [ { id: 'testAnch', ref: { item: 'node1' } }, { id: 'testAnch2', ref: { item: 'invalidItem' } } ] }; const model = produce(modelFixture, (draft) => { draft.views[0].connectors?.push(invalidConnector); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('INVALID_ANCHOR_TO_VIEW_ITEM_REF'); }); test('Connector with less than two anchors fails validation', () => { const invalidConnector: Connector = { id: 'invalidConnector', color: 'color1', anchors: [] }; const model = produce(modelFixture, (draft) => { draft.views[0].connectors?.push(invalidConnector); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('CONNECTOR_TOO_FEW_ANCHORS'); }); test('Connector with anchor that references an invalid anchor fails validation', () => { const invalidConnector: Connector = { id: 'invalidConnector', color: 'color1', anchors: [ { id: 'testAnch1', ref: { anchor: 'invalidAnchor' } }, { id: 'testAnch2', ref: { anchor: 'anchor1' } } ] }; const model = produce(modelFixture, (draft) => { draft.views[0].connectors?.push(invalidConnector); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('INVALID_ANCHOR_TO_ANCHOR_REF'); }); test('An invalid view item fails validation', () => { const invalidItem: ViewItem = { id: 'invalidItem', tile: { x: 0, y: 0 } }; const model = produce(modelFixture, (draft) => { draft.views[0].items.push(invalidItem); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('INVALID_VIEW_ITEM_TO_MODEL_ITEM_REF'); }); test('A connector with an invalid color fails validation', () => { const invalidConnector: Connector = { id: 'invalidConnector', color: 'invalidColor', anchors: [] }; const model = produce(modelFixture, (draft) => { draft.views[0].connectors?.push(invalidConnector); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('INVALID_CONNECTOR_COLOR_REF'); }); test('A rectangle with an invalid color fails validation', () => { const invalidRectangle = { id: 'invalidRectangle', color: 'invalidColor', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }; const model = produce(modelFixture, (draft) => { draft.views[0].rectangles?.push(invalidRectangle); }); const issues = validateModel(model); expect(issues[0].type).toStrictEqual('INVALID_RECTANGLE_COLOR_REF'); }); }); describe('modelSchema Zod validation', () => { const { model } = require('../../fixtures/model'); test('Valid model passes modelSchema validation', () => { const result = require('../model').modelSchema.safeParse(model); expect(result.success).toBe(true); }); test('Model missing required title fails modelSchema validation', () => { const { ...invalidModel } = model; delete invalidModel.title; const result = require('../model').modelSchema.safeParse(invalidModel); expect(result.success).toBe(false); expect( result.error.issues.some((issue: any) => { return issue.path.includes('title'); }) ).toBe(true); }); test('Model with invalid color reference fails modelSchema validation', () => { const { ...invalidModel } = model; // Add a rectangle with an invalid color to the first view invalidModel.views = invalidModel.views.map((view: any, i: number) => { return i === 0 ? { ...view, rectangles: [ ...(view.rectangles || []), { id: 'rect-invalid', color: 'notAColor', from: { x: 0, y: 0 }, to: { x: 1, y: 1 } } ] } : view; }); const result = require('../model').modelSchema.safeParse(invalidModel); expect(result.success).toBe(false); if (!result.success) { // Print all issues for debugging console.log('Zod issues:', result.error.issues); expect( result.error.issues.some((issue: any) => { return ( issue.message === 'Rectangle references a color that does not exist in the model.' && issue.params && issue.params.rectangle === 'rect-invalid' ); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/__tests__/views.test.ts ================================================ import { viewItemSchema, viewSchema, viewsSchema } from '../views'; describe('viewItemSchema', () => { it('validates a correct view item', () => { const valid = { id: 'item1', tile: { x: 1, y: 2 } }; expect(viewItemSchema.safeParse(valid).success).toBe(true); }); it('fails if required fields are missing', () => { const invalid = { tile: { x: 1, y: 2 } }; const result = viewItemSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('id'); }) ).toBe(true); } }); }); describe('viewSchema', () => { it('validates a correct view', () => { const valid = { id: 'view1', name: 'View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }] }; expect(viewSchema.safeParse(valid).success).toBe(true); }); it('fails if items is missing', () => { const invalid = { id: 'view1', name: 'View' }; const result = viewSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('items'); }) ).toBe(true); } }); }); describe('viewsSchema', () => { it('validates an array of views', () => { const valid = [ { id: 'view1', name: 'View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }] } ]; expect(viewsSchema.safeParse(valid).success).toBe(true); }); it('fails if any view is invalid', () => { const invalid = [ { id: 'view1', name: 'View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }] }, { id: 'view2', name: 'View2' } ]; const result = viewsSchema.safeParse(invalid); expect(result.success).toBe(false); if (!result.success) { expect( result.error.issues.some((issue: any) => { return issue.path.includes('items'); }) ).toBe(true); } }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/colors.ts ================================================ import { z } from 'zod'; import { id } from './common'; export const colorSchema = z.object({ id, value: z.string().max(7) }); export const colorsSchema = z.array(colorSchema); ================================================ FILE: packages/fossflow-lib/src/schemas/common.ts ================================================ import { z } from 'zod'; export const coords = z.object({ x: z.number(), y: z.number() }); export const id = z.string(); export const color = z.string(); export const constrainedStrings = { name: z.string().max(100), description: z.string().max(1000) }; ================================================ FILE: packages/fossflow-lib/src/schemas/connector.ts ================================================ import { z } from 'zod'; import { coords, id, constrainedStrings } from './common'; export const connectorStyleOptions = ['SOLID', 'DOTTED', 'DASHED'] as const; export const connectorLineTypeOptions = ['SINGLE', 'DOUBLE', 'DOUBLE_WITH_CIRCLE'] as const; export const connectorLabelSchema = z.object({ id, text: constrainedStrings.description, position: z.number().min(0).max(100), // Percentage along the path (0-100) height: z.number().optional(), // Vertical offset line: z.enum(['1', '2']).optional(), // Which line for double line types (defaults to '1') showLine: z.boolean().optional() // Show the dotted line connecting label to connector (defaults to true) }); export const anchorSchema = z.object({ id, ref: z .object({ item: id, anchor: id, tile: coords }) .partial() }); export const connectorSchema = z.object({ id, // Legacy label fields (for backward compatibility) description: constrainedStrings.description.optional(), startLabel: constrainedStrings.description.optional(), endLabel: constrainedStrings.description.optional(), startLabelHeight: z.number().optional(), centerLabelHeight: z.number().optional(), endLabelHeight: z.number().optional(), // New flexible labels array labels: z.array(connectorLabelSchema).max(256).optional(), color: id.optional(), customColor: z.string().optional(), // For custom RGB colors width: z.number().optional(), style: z.enum(connectorStyleOptions).optional(), lineType: z.enum(connectorLineTypeOptions).optional(), showArrow: z.boolean().optional(), anchors: z.array(anchorSchema) }); ================================================ FILE: packages/fossflow-lib/src/schemas/icons.ts ================================================ import { z } from 'zod'; import { id, constrainedStrings } from './common'; export const iconSchema = z.object({ id, name: constrainedStrings.name, url: z.string(), collection: constrainedStrings.name.optional(), isIsometric: z.boolean().optional(), scale: z.number().min(0.1).max(3).optional() }); export const iconsSchema = z.array(iconSchema); ================================================ FILE: packages/fossflow-lib/src/schemas/index.ts ================================================ export * from './model'; export * from './colors'; export * from './icons'; export * from './modelItems'; export * from './views'; export * from './connector'; export * from './rectangle'; export * from './textBox'; ================================================ FILE: packages/fossflow-lib/src/schemas/model.ts ================================================ import { z } from 'zod'; import { INITIAL_DATA } from '../config'; import { constrainedStrings } from './common'; import { modelItemsSchema } from './modelItems'; import { viewsSchema } from './views'; import { validateModel } from './validation'; import { iconsSchema } from './icons'; import { colorsSchema } from './colors'; export const modelSchema = z .object({ version: z.string().max(10).optional(), title: constrainedStrings.name, description: constrainedStrings.description.optional(), items: modelItemsSchema, views: viewsSchema, icons: iconsSchema, colors: colorsSchema }) .superRefine((model, ctx) => { const issues = validateModel({ ...INITIAL_DATA, ...model }); issues.forEach((issue) => { ctx.addIssue({ code: z.ZodIssueCode.custom, params: issue.params, message: issue.message }); }); }); ================================================ FILE: packages/fossflow-lib/src/schemas/modelItems.ts ================================================ import { z } from 'zod'; import { id, constrainedStrings } from './common'; export const modelItemSchema = z.object({ id, name: constrainedStrings.name, description: constrainedStrings.description.optional(), icon: id.optional() }); export const modelItemsSchema = z.array(modelItemSchema); ================================================ FILE: packages/fossflow-lib/src/schemas/rectangle.ts ================================================ import { z } from 'zod'; import { id, coords } from './common'; export const rectangleSchema = z.object({ id, color: id.optional(), customColor: z.string().optional(), // For custom RGB colors from: coords, to: coords }); ================================================ FILE: packages/fossflow-lib/src/schemas/textBox.ts ================================================ import { z } from 'zod'; import { ProjectionOrientationEnum } from 'src/types/common'; import { id, coords, constrainedStrings } from './common'; export const textBoxSchema = z.object({ id, tile: coords, content: constrainedStrings.name, fontSize: z.number().optional(), orientation: z .union([ z.literal(ProjectionOrientationEnum.X), z.literal(ProjectionOrientationEnum.Y) ]) .optional() }); ================================================ FILE: packages/fossflow-lib/src/schemas/validation.ts ================================================ import type { Model, ModelItem, Connector, ConnectorAnchor, View, Rectangle } from 'src/types'; import { getAllAnchors, getItemByIdOrThrow } from 'src/utils'; type IssueType = | { type: 'INVALID_ANCHOR_TO_VIEW_ITEM_REF'; params: { anchor: string; viewItem: string; view: string; connector: string; }; } | { type: 'INVALID_CONNECTOR_COLOR_REF'; params: { connector: string; view: string; color: string; }; } | { type: 'INVALID_RECTANGLE_COLOR_REF'; params: { rectangle: string; view: string; color: string; }; } | { type: 'INVALID_ANCHOR_TO_ANCHOR_REF'; params: { srcAnchor: string; destAnchor: string; view: string; connector: string; }; } | { type: 'INVALID_VIEW_ITEM_TO_MODEL_ITEM_REF'; params: { view: string; modelItem: string; }; } | { type: 'INVALID_ANCHOR_REF'; params: { anchor: string; view: string; connector: string; }; } | { type: 'INVALID_MODEL_TO_ICON_REF'; params: { modelItem: string; icon: string; }; } | { type: 'CONNECTOR_TOO_FEW_ANCHORS'; params: { connector: string; view: string; }; }; type Issue = IssueType & { message: string; }; export const validateConnectorAnchor = ( anchor: ConnectorAnchor, ctx: { view: View; connector: Connector; allAnchors: ConnectorAnchor[]; } ): Issue[] => { const issues: Issue[] = []; if (Object.keys(anchor.ref).length !== 1) { issues.push({ type: 'INVALID_ANCHOR_REF', params: { anchor: anchor.id, view: ctx.view.id, connector: ctx.connector.id }, message: 'Connector includes an anchor that references more than one item. An anchor can only reference one item.' }); } if (anchor.ref.item) { try { getItemByIdOrThrow(ctx.view.items, anchor.ref.item); } catch (e) { issues.push({ type: 'INVALID_ANCHOR_TO_VIEW_ITEM_REF', params: { anchor: anchor.id, viewItem: anchor.ref.item, view: ctx.view.id, connector: ctx.connector.id }, message: 'Connector includes an anchor that references an item that does not exist in this view.' }); } } if (anchor.ref.anchor) { const targetAnchorId = ctx.allAnchors .map(({ id }) => { return id; }) .includes(anchor.ref.anchor); if (!targetAnchorId) { issues.push({ type: 'INVALID_ANCHOR_TO_ANCHOR_REF', params: { destAnchor: anchor.id, srcAnchor: anchor.ref.anchor, view: ctx.view.id, connector: ctx.connector.id }, message: 'Connector includes an anchor that references another connector anchor that does not exist in this view.' }); } } return issues; }; export const validateConnector = ( connector: Connector, ctx: { view: View; model: Model; allAnchors: ConnectorAnchor[]; } ): Issue[] => { const issues: Issue[] = []; if (connector.color) { try { getItemByIdOrThrow(ctx.model.colors, connector.color); } catch (e) { issues.push({ type: 'INVALID_CONNECTOR_COLOR_REF', params: { connector: connector.id, view: ctx.view.id, color: connector.color }, message: 'Connector references a color that does not exist in the model.' }); } } if (connector.anchors.length < 2) { issues.push({ type: 'CONNECTOR_TOO_FEW_ANCHORS', params: { connector: connector.id, view: ctx.view.id }, message: 'Connector must have at least two anchors. One for the source and one for the target.' }); } const { anchors } = connector; anchors.forEach((anchor) => { const anchorIssues = validateConnectorAnchor(anchor, { view: ctx.view, connector, allAnchors: ctx.allAnchors }); issues.push(...anchorIssues); }); return issues; }; export const validateRectangle = ( rectangle: Rectangle, ctx: { view: View; model: Model } ): Issue[] => { const issues: Issue[] = []; if (rectangle.color) { try { getItemByIdOrThrow(ctx.model.colors, rectangle.color); } catch (e) { issues.push({ type: 'INVALID_RECTANGLE_COLOR_REF', params: { rectangle: rectangle.id, view: ctx.view.id, color: rectangle.color }, message: 'Rectangle references a color that does not exist in the model.' }); } } return issues; }; export const validateView = (view: View, ctx: { model: Model }): Issue[] => { const issues: Issue[] = []; if (view.connectors) { const allAnchors = getAllAnchors(view.connectors); view.connectors.forEach((connector) => { issues.push( ...validateConnector(connector, { view, model: ctx.model, allAnchors }) ); }); } if (view.rectangles) { view.rectangles.forEach((rectangle) => { issues.push( ...validateRectangle(rectangle, { view, model: ctx.model }) ); }); } view.items.forEach((viewItem) => { try { getItemByIdOrThrow(ctx.model.items, viewItem.id); } catch (e) { issues.push({ type: 'INVALID_VIEW_ITEM_TO_MODEL_ITEM_REF', params: { modelItem: viewItem.id, view: view.id }, message: 'Invalid item in view. The item references a non-existant item in the model.' }); } }); return issues; }; export const validateModelItem = ( modelItem: ModelItem, ctx: { model: Model; } ): Issue[] => { const issues: Issue[] = []; if (!modelItem.icon) return issues; try { getItemByIdOrThrow(ctx.model.icons, modelItem.icon); } catch (e) { issues.push({ type: 'INVALID_MODEL_TO_ICON_REF', params: { modelItem: modelItem.id, icon: modelItem.icon }, message: 'Invalid item found in the model. The item references an icon that does not exist.' }); } return issues; }; export const validateModel = (model: Model): Issue[] => { const issues: Issue[] = []; model.items.forEach((modelItem) => { issues.push(...validateModelItem(modelItem, { model })); }); model.views.forEach((view) => { issues.push(...validateView(view, { model })); }); return issues; }; ================================================ FILE: packages/fossflow-lib/src/schemas/views.ts ================================================ import { z } from 'zod'; import { id, constrainedStrings, coords } from './common'; import { rectangleSchema } from './rectangle'; import { connectorSchema } from './connector'; import { textBoxSchema } from './textBox'; export const viewItemSchema = z.object({ id, tile: coords, labelHeight: z.number().optional() }); export const viewSchema = z.object({ id, lastUpdated: z.string().datetime().optional(), name: constrainedStrings.name, description: constrainedStrings.description.optional(), items: z.array(viewItemSchema), rectangles: z.array(rectangleSchema).optional(), connectors: z.array(connectorSchema).optional(), textBoxes: z.array(textBoxSchema).optional() }); export const viewsSchema = z.array(viewSchema); ================================================ FILE: packages/fossflow-lib/src/standaloneExports.ts ================================================ // This file will be exported as it's own bundle (separate to the main bundle). This is because the main // bundle requires `window` to be present and so can't be imported into a Node environment. export const version = PACKAGE_VERSION; export * as reducers from 'src/stores/reducers'; export { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config'; export * from 'src/schemas'; export type { IsoflowProps, InitialData } from 'src/types'; export * from 'src/types/model'; // Export i18n locales export { default as enUS } from 'src/i18n/en-US'; export { default as zhCN } from 'src/i18n/zh-CN'; export { default as allLocales } from 'src/i18n'; ================================================ FILE: packages/fossflow-lib/src/stores/localeStore.tsx ================================================ import React, { createContext, useContext, ReactNode } from 'react'; import { LocaleProps } from '../types/isoflowProps'; import enUS from '../i18n/en-US'; const LocaleContext = createContext(enUS); interface LocaleProviderProps { locale: LocaleProps; children: ReactNode; } export const LocaleProvider: React.FC = ({ locale, children }) => { return ( {children} ); }; export const useLocale = (): LocaleProps => { const context = useContext(LocaleContext); if (!context) { throw new Error('useLocale must be used within a LocaleProvider'); } return context; }; // Generic type helper for nested object access type NestedKeyOf = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object ? `${Key}.${NestedKeyOf}` : `${Key}`; }[keyof ObjectType & (string | number)]; // Overloaded useTranslation function export function useTranslation(): { t: (key: NestedKeyOf) => string; }; export function useTranslation( namespace: K ): { t: (key: keyof LocaleProps[K]) => string; }; export function useTranslation(namespace?: K) { const locale = useLocale(); if (namespace) { // Return scoped translation function for specific namespace const namespaceData = locale[namespace]; const t = (key: keyof LocaleProps[K]): string => { const value = namespaceData[key]; return typeof value === 'string' ? value : String(key); }; return { t }; } else { // Return global translation function with dot notation const t = (key: NestedKeyOf): string => { const parts = key.split('.'); let current: any = locale; for (const part of parts) { if (current && typeof current === 'object' && part in current) { current = current[part]; } else { return key; // Return key if path not found } } return typeof current === 'string' ? current : key; }; return { t }; } } ================================================ FILE: packages/fossflow-lib/src/stores/modelStore.tsx ================================================ import React, { createContext, useRef, useContext } from 'react'; import { createStore, useStore } from 'zustand'; import { ModelStore, Model } from 'src/types'; import { INITIAL_DATA } from 'src/config'; export interface HistoryState { past: Model[]; present: Model; future: Model[]; maxHistorySize: number; } export interface ModelStoreWithHistory extends Omit { history: HistoryState; actions: { get: () => ModelStoreWithHistory; set: (model: Partial, skipHistory?: boolean) => void; undo: () => boolean; redo: () => boolean; canUndo: () => boolean; canRedo: () => boolean; saveToHistory: () => void; clearHistory: () => void; }; } const MAX_HISTORY_SIZE = 50; const createHistoryState = (initialModel: Model): HistoryState => { return { past: [], present: initialModel, future: [], maxHistorySize: MAX_HISTORY_SIZE }; }; const extractModelData = (state: ModelStoreWithHistory): Model => { return { version: state.version, title: state.title, description: state.description, colors: state.colors, icons: state.icons, items: state.items, views: state.views }; }; const initialState = () => { return createStore((set, get) => { const initialModel = { ...INITIAL_DATA }; const saveToHistory = () => { set((state) => { const currentModel = extractModelData(state); const newPast = [...state.history.past, currentModel]; // Limit history size to prevent memory issues if (newPast.length > state.history.maxHistorySize) { newPast.shift(); } return { ...state, history: { ...state.history, past: newPast, present: currentModel, future: [] // Clear future when new action is performed } }; }); }; const undo = (): boolean => { const { history } = get(); if (history.past.length === 0) return false; const previous = history.past[history.past.length - 1]; const newPast = history.past.slice(0, history.past.length - 1); set((state) => { // Capture the actual live state (not stale history.present) const currentModel = extractModelData(state); return { ...previous, history: { ...state.history, past: newPast, present: previous, future: [currentModel, ...state.history.future] } }; }); return true; }; const redo = (): boolean => { const { history } = get(); if (history.future.length === 0) return false; const next = history.future[0]; const newFuture = history.future.slice(1); set((state) => { // Capture the actual live state (not stale history.present) const currentModel = extractModelData(state); return { ...next, history: { ...state.history, past: [...state.history.past, currentModel], present: next, future: newFuture } }; }); return true; }; const canUndo = () => { return get().history.past.length > 0; }; const canRedo = () => { return get().history.future.length > 0; }; const clearHistory = () => { const currentState = get(); const currentModel = extractModelData(currentState); set((state) => { return { ...state, history: createHistoryState(currentModel) }; }); }; return { ...initialModel, history: createHistoryState(initialModel), actions: { get, set: (updates: Partial, skipHistory = false) => { if (!skipHistory) { saveToHistory(); } set((state) => { return { ...state, ...updates }; }); }, undo, redo, canUndo, canRedo, saveToHistory, clearHistory } }; }); }; const ModelContext = createContext | null>( null ); interface ProviderProps { children: React.ReactNode; } export const ModelProvider = ({ children }: ProviderProps) => { const storeRef = useRef | undefined>(undefined); if (!storeRef.current) { storeRef.current = initialState(); } return ( {children} ); }; export function useModelStore( selector: (state: ModelStoreWithHistory) => T, equalityFn?: (left: T, right: T) => boolean ) { const store = useContext(ModelContext); if (store === null) { throw new Error('Missing provider in the tree'); } const value = useStore(store, selector, equalityFn); return value; } export function useModelStoreApi() { const store = useContext(ModelContext); if (store === null) { throw new Error('Missing provider in the tree'); } return store; } ================================================ FILE: packages/fossflow-lib/src/stores/reducers/__tests__/connector.test.ts ================================================ import { deleteConnector, syncConnector, updateConnector, createConnector } from '../connector'; import { State, ViewReducerContext } from '../types'; import { Connector, View, Model, Scene } from 'src/types'; // Mock the utility functions jest.mock('src/utils', () => ({ getItemByIdOrThrow: jest.fn((items: any[], id: string) => { const index = items.findIndex((item: any) => (typeof item === 'object' && item.id === id) || item === id ); if (index === -1) { throw new Error(`Item with id ${id} not found`); } return { value: items[index], index }; }), getConnectorPath: jest.fn(({ anchors }) => ({ tiles: [{ x: 0, y: 0 }, { x: 1, y: 1 }], rectangle: { from: { x: anchors.from.x || 0, y: anchors.from.y || 0 }, to: { x: anchors.to.x || 1, y: anchors.to.y || 1 } } })) })); describe('connector reducer', () => { let mockState: State; let mockContext: ViewReducerContext; let mockConnector: Connector; let mockView: View; beforeEach(() => { jest.clearAllMocks(); mockConnector = { id: 'connector1', anchors: { from: { id: 'item1', face: 'right', x: 0, y: 0 }, to: { id: 'item2', face: 'left', x: 2, y: 0 } }, label: 'Test Connection', lineType: 'solid', color: 'color1' }; mockView = { id: 'view1', name: 'Test View', items: [], connectors: [mockConnector], rectangles: [], textBoxes: [] }; mockState = { model: { version: '1.0', title: 'Test Model', description: '', colors: [], icons: [], items: [], views: [mockView] }, scene: { viewId: 'view1', viewport: { x: 0, y: 0, zoom: 1 }, grid: { enabled: true, size: 10, style: 'dots' }, connectors: { 'connector1': { path: { tiles: [], rectangle: { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } } } } }, viewItems: {}, rectangles: {}, textBoxes: {} } }; mockContext = { viewId: 'view1', state: mockState }; }); describe('deleteConnector', () => { it('should delete a connector from both model and scene', () => { const result = deleteConnector('connector1', mockContext); // Check connector is removed from model expect(result.model.views[0].connectors).toHaveLength(0); // Check connector is removed from scene by ID expect(result.scene.connectors['connector1']).toBeUndefined(); }); it('should throw error when connector does not exist', () => { expect(() => { deleteConnector('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; expect(() => { deleteConnector('connector1', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle empty connectors array gracefully', () => { mockState.model.views[0].connectors = []; mockState.scene.connectors = {}; expect(() => { deleteConnector('connector1', mockContext); }).toThrow('Item with id connector1 not found'); }); it('should not affect other connectors when deleting one', () => { const connector2: Connector = { id: 'connector2', anchors: { from: { id: 'item3', face: 'top' }, to: { id: 'item4', face: 'bottom' } } }; mockState.model.views[0].connectors = [mockConnector, connector2]; mockState.scene.connectors['connector2'] = { path: { tiles: [], rectangle: { from: { x: 1, y: 1 }, to: { x: 2, y: 2 } } } }; const result = deleteConnector('connector1', mockContext); expect(result.model.views[0].connectors).toHaveLength(1); expect(result.model.views[0].connectors![0].id).toBe('connector2'); expect(result.scene.connectors['connector2']).toBeDefined(); expect(result.scene.connectors['connector1']).toBeUndefined(); }); }); describe('syncConnector', () => { it('should sync connector path successfully', () => { const getConnectorPath = require('src/utils').getConnectorPath; // Clear previous calls and set up fresh mock getConnectorPath.mockClear(); getConnectorPath.mockReturnValue({ tiles: [{ x: 0, y: 0 }, { x: 1, y: 1 }], rectangle: { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } } }); const result = syncConnector('connector1', mockContext); expect(getConnectorPath).toHaveBeenCalled(); expect(result.scene.connectors['connector1'].path).toEqual({ tiles: [{ x: 0, y: 0 }, { x: 1, y: 1 }], rectangle: { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } } }); }); it('should handle path calculation errors gracefully', () => { const getConnectorPath = require('src/utils').getConnectorPath; getConnectorPath.mockImplementationOnce(() => { throw new Error('Path calculation failed'); }); const result = syncConnector('connector1', mockContext); // Should create empty path on error expect(result.scene.connectors['connector1'].path).toEqual({ tiles: [], rectangle: { from: { x: 0, y: 0 }, to: { x: 0, y: 0 } } }); }); it('should throw error when connector does not exist', () => { expect(() => { syncConnector('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle connectors with partial anchor data', () => { mockConnector.anchors = { from: { id: 'item1', face: 'right' }, to: { id: 'item2', face: 'left' } }; const result = syncConnector('connector1', mockContext); expect(result.scene.connectors['connector1'].path).toBeDefined(); }); }); describe('updateConnector', () => { it('should update connector properties', () => { const updates = { id: 'connector1', label: 'Updated Connection', color: 'color2', lineType: 'dashed' as const }; const result = updateConnector(updates, mockContext); expect(result.model.views[0].connectors![0].label).toBe('Updated Connection'); expect(result.model.views[0].connectors![0].color).toBe('color2'); expect(result.model.views[0].connectors![0].lineType).toBe('dashed'); }); it('should sync connector when anchors are updated', () => { const updates = { id: 'connector1', anchors: { from: { id: 'item3', face: 'bottom' as const }, to: { id: 'item4', face: 'top' as const } } }; const result = updateConnector(updates, mockContext); expect(result.model.views[0].connectors![0].anchors).toEqual(updates.anchors); // Verify sync was called by checking the path was updated expect(result.scene.connectors['connector1'].path).toBeDefined(); }); it('should not sync when anchors are not updated', () => { const getConnectorPath = require('src/utils').getConnectorPath; getConnectorPath.mockClear(); const updates = { id: 'connector1', label: 'Just a label update' }; updateConnector(updates, mockContext); // getConnectorPath should not be called when anchors aren't updated expect(getConnectorPath).not.toHaveBeenCalled(); }); it('should throw error when connector does not exist', () => { expect(() => { updateConnector({ id: 'nonexistent', label: 'test' }, mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle empty connectors array', () => { mockState.model.views[0].connectors = undefined; const result = updateConnector({ id: 'connector1', label: 'test' }, mockContext); // Should return state unchanged when connectors is undefined expect(result).toEqual(mockState); }); it('should preserve other connector properties when partially updating', () => { const updates = { id: 'connector1', label: 'Partial Update' }; const result = updateConnector(updates, mockContext); // Original properties should be preserved expect(result.model.views[0].connectors![0].anchors).toEqual(mockConnector.anchors); expect(result.model.views[0].connectors![0].color).toBe(mockConnector.color); expect(result.model.views[0].connectors![0].lineType).toBe(mockConnector.lineType); // Updated property expect(result.model.views[0].connectors![0].label).toBe('Partial Update'); }); }); describe('createConnector', () => { it('should create a new connector', () => { const newConnector: Connector = { id: 'connector2', anchors: { from: { id: 'item5', face: 'right' }, to: { id: 'item6', face: 'left' } }, label: 'New Connection' }; const result = createConnector(newConnector, mockContext); // Should be added at the beginning (unshift) expect(result.model.views[0].connectors).toHaveLength(2); expect(result.model.views[0].connectors![0].id).toBe('connector2'); expect(result.model.views[0].connectors![1].id).toBe('connector1'); // Should sync the new connector expect(result.scene.connectors['connector2']).toBeDefined(); expect(result.scene.connectors['connector2'].path).toBeDefined(); }); it('should initialize connectors array if undefined', () => { mockState.model.views[0].connectors = undefined; const newConnector: Connector = { id: 'connector2', anchors: { from: { id: 'item5', face: 'right' }, to: { id: 'item6', face: 'left' } } }; const result = createConnector(newConnector, mockContext); expect(result.model.views[0].connectors).toHaveLength(1); expect(result.model.views[0].connectors![0].id).toBe('connector2'); }); it('should handle sync errors when creating connector', () => { const getConnectorPath = require('src/utils').getConnectorPath; getConnectorPath.mockImplementationOnce(() => { throw new Error('Path calculation failed'); }); const newConnector: Connector = { id: 'connector2', anchors: { from: { id: 'item5', face: 'right' }, to: { id: 'item6', face: 'left' } } }; const result = createConnector(newConnector, mockContext); // Connector should still be created expect(result.model.views[0].connectors).toHaveLength(2); // But with empty path expect(result.scene.connectors['connector2'].path).toEqual({ tiles: [], rectangle: { from: { x: 0, y: 0 }, to: { x: 0, y: 0 } } }); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; const newConnector: Connector = { id: 'connector2', anchors: { from: { id: 'item5', face: 'right' }, to: { id: 'item6', face: 'left' } } }; expect(() => { createConnector(newConnector, mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should create connector with all optional properties', () => { const newConnector: Connector = { id: 'connector2', anchors: { from: { id: 'item5', face: 'right' }, to: { id: 'item6', face: 'left' } }, label: 'Full Connector', lineType: 'dotted', color: 'color3', labels: ['Label1', 'Label2'] }; const result = createConnector(newConnector, mockContext); const created = result.model.views[0].connectors![0]; expect(created.label).toBe('Full Connector'); expect(created.lineType).toBe('dotted'); expect(created.color).toBe('color3'); expect(created.labels).toEqual(['Label1', 'Label2']); }); }); describe('edge cases and state immutability', () => { it('should not mutate the original state', () => { const originalState = JSON.parse(JSON.stringify(mockState)); deleteConnector('connector1', mockContext); expect(mockState).toEqual(originalState); }); it('should handle multiple operations in sequence', () => { // Create let result = createConnector({ id: 'connector2', anchors: { from: { id: 'item3', face: 'top' }, to: { id: 'item4', face: 'bottom' } } }, { ...mockContext, state: mockState }); // Update result = updateConnector({ id: 'connector2', label: 'Updated' }, { ...mockContext, state: result }); // Delete original result = deleteConnector('connector1', { ...mockContext, state: result }); expect(result.model.views[0].connectors).toHaveLength(1); expect(result.model.views[0].connectors![0].id).toBe('connector2'); expect(result.model.views[0].connectors![0].label).toBe('Updated'); }); it('should handle view with multiple connectors', () => { const connectors: Connector[] = Array.from({ length: 5 }, (_, i) => ({ id: `connector${i}`, anchors: { from: { id: `item${i}`, face: 'right' }, to: { id: `item${i + 1}`, face: 'left' } } })); mockState.model.views[0].connectors = connectors; const result = deleteConnector('connector2', mockContext); expect(result.model.views[0].connectors).toHaveLength(4); expect(result.model.views[0].connectors!.find(c => c.id === 'connector2')).toBeUndefined(); }); }); }); ================================================ FILE: packages/fossflow-lib/src/stores/reducers/__tests__/modelItem.test.ts ================================================ import { model as modelFixture } from 'src/fixtures/model'; import { ModelItem } from 'src/types'; import { getItemByIdOrThrow } from 'src/utils'; import { createModelItem, updateModelItem, deleteModelItem } from '../modelItem'; const scene = { connectors: {}, textBoxes: {} }; describe('Model item reducers works correctly', () => { test('Item is added to model correctly', () => { const newItem: ModelItem = { id: 'newItem', name: 'newItem' }; const newState = createModelItem(newItem, { model: modelFixture, scene }); expect(newState.model.items[newState.model.items.length - 1]).toStrictEqual( newItem ); }); test('Item is updated correctly', () => { const nodeId = 'node1'; const updates: Partial = { name: 'test' }; const newState = updateModelItem(nodeId, updates, { model: modelFixture, scene }); const updatedItem = getItemByIdOrThrow(newState.model.items, nodeId); expect(updatedItem.value.name).toBe(updates.name); }); test('Item is deleted correctly', () => { const nodeId = 'node1'; const newState = deleteModelItem(nodeId, { model: modelFixture, scene }); const deletedItem = () => { getItemByIdOrThrow(newState.model.items, nodeId); }; expect(deletedItem).toThrow(); }); }); ================================================ FILE: packages/fossflow-lib/src/stores/reducers/__tests__/rectangle.test.ts ================================================ import { createRectangle, updateRectangle, deleteRectangle } from '../rectangle'; import { State, ViewReducerContext } from '../types'; import { Rectangle, View } from 'src/types'; // Mock the utility functions jest.mock('src/utils', () => ({ getItemByIdOrThrow: jest.fn((items: any[], id: string) => { const index = items.findIndex((item: any) => (typeof item === 'object' && item.id === id) || item === id ); if (index === -1) { throw new Error(`Item with id ${id} not found`); } return { value: items[index], index }; }) })); describe('rectangle reducer', () => { let mockState: State; let mockContext: ViewReducerContext; let mockRectangle: Rectangle; let mockView: View; beforeEach(() => { jest.clearAllMocks(); mockRectangle = { id: 'rect1', position: { x: 0, y: 0 }, size: { width: 100, height: 50 }, color: 'color1', borderColor: 'color2', borderWidth: 2, borderStyle: 'solid', opacity: 1, cornerRadius: 5 }; mockView = { id: 'view1', name: 'Test View', items: [], connectors: [], rectangles: [mockRectangle], textBoxes: [] }; mockState = { model: { version: '1.0', title: 'Test Model', description: '', colors: [], icons: [], items: [], views: [mockView] }, scene: { viewId: 'view1', viewport: { x: 0, y: 0, zoom: 1 }, grid: { enabled: true, size: 10, style: 'dots' }, connectors: {}, viewItems: {}, textBoxes: {} } }; mockContext = { viewId: 'view1', state: mockState }; }); describe('updateRectangle', () => { it('should update rectangle properties', () => { const updates = { id: 'rect1', size: { width: 200, height: 100 }, color: 'color3', opacity: 0.5 }; const result = updateRectangle(updates, mockContext); expect(result.model.views[0].rectangles![0].size).toEqual({ width: 200, height: 100 }); expect(result.model.views[0].rectangles![0].color).toBe('color3'); expect(result.model.views[0].rectangles![0].opacity).toBe(0.5); }); it('should preserve other properties when partially updating', () => { const updates = { id: 'rect1', color: 'color4' }; const result = updateRectangle(updates, mockContext); // Original properties should be preserved expect(result.model.views[0].rectangles![0].position).toEqual(mockRectangle.position); expect(result.model.views[0].rectangles![0].size).toEqual(mockRectangle.size); expect(result.model.views[0].rectangles![0].borderColor).toBe(mockRectangle.borderColor); expect(result.model.views[0].rectangles![0].cornerRadius).toBe(mockRectangle.cornerRadius); // Updated property expect(result.model.views[0].rectangles![0].color).toBe('color4'); }); it('should handle undefined rectangles array', () => { mockState.model.views[0].rectangles = undefined; const result = updateRectangle({ id: 'rect1', color: 'test' }, mockContext); // Should return state unchanged expect(result).toEqual(mockState); }); it('should throw error when rectangle does not exist', () => { expect(() => { updateRectangle({ id: 'nonexistent', color: 'test' }, mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; expect(() => { updateRectangle({ id: 'rect1', color: 'test' }, mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should update border properties', () => { const updates = { id: 'rect1', borderColor: 'color5', borderWidth: 4, borderStyle: 'dashed' as const }; const result = updateRectangle(updates, mockContext); expect(result.model.views[0].rectangles![0].borderColor).toBe('color5'); expect(result.model.views[0].rectangles![0].borderWidth).toBe(4); expect(result.model.views[0].rectangles![0].borderStyle).toBe('dashed'); }); }); describe('createRectangle', () => { it('should create a new rectangle', () => { const newRectangle: Rectangle = { id: 'rect2', position: { x: 50, y: 50 }, size: { width: 150, height: 75 }, color: 'color3' }; const result = createRectangle(newRectangle, mockContext); // Should be added at the beginning (unshift) expect(result.model.views[0].rectangles).toHaveLength(2); expect(result.model.views[0].rectangles![0].id).toBe('rect2'); expect(result.model.views[0].rectangles![1].id).toBe('rect1'); }); it('should initialize rectangles array if undefined', () => { mockState.model.views[0].rectangles = undefined; const newRectangle: Rectangle = { id: 'rect2', position: { x: 50, y: 50 }, size: { width: 150, height: 75 } }; const result = createRectangle(newRectangle, mockContext); expect(result.model.views[0].rectangles).toHaveLength(1); expect(result.model.views[0].rectangles![0].id).toBe('rect2'); }); it('should create rectangle with all properties', () => { const newRectangle: Rectangle = { id: 'rect2', position: { x: 50, y: 50 }, size: { width: 150, height: 75 }, color: 'color6', borderColor: 'color7', borderWidth: 3, borderStyle: 'dotted', opacity: 0.8, cornerRadius: 10, shadow: { offsetX: 2, offsetY: 2, blur: 4, color: 'color8' } }; const result = createRectangle(newRectangle, mockContext); const created = result.model.views[0].rectangles![0]; expect(created.color).toBe('color6'); expect(created.borderColor).toBe('color7'); expect(created.borderWidth).toBe(3); expect(created.borderStyle).toBe('dotted'); expect(created.opacity).toBe(0.8); expect(created.cornerRadius).toBe(10); expect(created.shadow).toEqual({ offsetX: 2, offsetY: 2, blur: 4, color: 'color8' }); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; const newRectangle: Rectangle = { id: 'rect2', position: { x: 50, y: 50 }, size: { width: 150, height: 75 } }; expect(() => { createRectangle(newRectangle, mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should call updateRectangle after creation', () => { // This tests that createRectangle calls updateRectangle at the end // which ensures any necessary syncing happens const newRectangle: Rectangle = { id: 'rect2', position: { x: 50, y: 50 }, size: { width: 150, height: 75 } }; const result = createRectangle(newRectangle, mockContext); // The rectangle should have all properties set expect(result.model.views[0].rectangles![0]).toMatchObject(newRectangle); }); }); describe('deleteRectangle', () => { it('should delete a rectangle from model', () => { const result = deleteRectangle('rect1', mockContext); // Check rectangle is removed from model expect(result.model.views[0].rectangles).toHaveLength(0); // Rectangles don't have scene data - only stored in model }); it('should throw error when rectangle does not exist', () => { expect(() => { deleteRectangle('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; expect(() => { deleteRectangle('rect1', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle empty rectangles array', () => { mockState.model.views[0].rectangles = []; expect(() => { deleteRectangle('rect1', mockContext); }).toThrow('Item with id rect1 not found'); }); it('should not affect other rectangles when deleting one', () => { const rect2: Rectangle = { id: 'rect2', position: { x: 100, y: 100 }, size: { width: 80, height: 40 } }; mockState.model.views[0].rectangles = [mockRectangle, rect2]; const result = deleteRectangle('rect1', mockContext); expect(result.model.views[0].rectangles).toHaveLength(1); expect(result.model.views[0].rectangles![0].id).toBe('rect2'); // Rectangles don't have scene data - only verify model is updated }); }); describe('edge cases and state immutability', () => { it('should not mutate the original state', () => { const originalState = JSON.parse(JSON.stringify(mockState)); deleteRectangle('rect1', mockContext); expect(mockState).toEqual(originalState); }); it('should handle multiple operations in sequence', () => { // Create let result = createRectangle({ id: 'rect2', position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }, { ...mockContext, state: mockState }); // Update result = updateRectangle({ id: 'rect2', color: 'updatedColor', opacity: 0.7 }, { ...mockContext, state: result }); // Delete original result = deleteRectangle('rect1', { ...mockContext, state: result }); expect(result.model.views[0].rectangles).toHaveLength(1); expect(result.model.views[0].rectangles![0].id).toBe('rect2'); expect(result.model.views[0].rectangles![0].color).toBe('updatedColor'); expect(result.model.views[0].rectangles![0].opacity).toBe(0.7); }); it('should handle view with multiple rectangles', () => { const rectangles: Rectangle[] = Array.from({ length: 5 }, (_, i) => ({ id: `rect${i}`, position: { x: i * 20, y: i * 20 }, size: { width: 100, height: 50 } })); mockState.model.views[0].rectangles = rectangles; const result = deleteRectangle('rect2', mockContext); expect(result.model.views[0].rectangles).toHaveLength(4); expect(result.model.views[0].rectangles!.find(r => r.id === 'rect2')).toBeUndefined(); }); it('should handle rectangles with complex nested properties', () => { const complexRect: Rectangle = { id: 'rect2', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, shadow: { offsetX: 5, offsetY: 5, blur: 10, color: 'shadowColor' }, gradient: { type: 'linear', angle: 45, stops: [ { offset: 0, color: 'color1' }, { offset: 1, color: 'color2' } ] } }; const result = createRectangle(complexRect, mockContext); const created = result.model.views[0].rectangles![0]; expect(created.shadow).toEqual(complexRect.shadow); expect(created.gradient).toEqual(complexRect.gradient); }); }); }); ================================================ FILE: packages/fossflow-lib/src/stores/reducers/__tests__/textBox.test.ts ================================================ import { createTextBox, updateTextBox, deleteTextBox, syncTextBox } from '../textBox'; import { State, ViewReducerContext } from '../types'; import { TextBox, View } from 'src/types'; // Mock the utility functions jest.mock('src/utils', () => ({ getItemByIdOrThrow: jest.fn((items: any[], id: string) => { const index = items.findIndex((item: any) => (typeof item === 'object' && item.id === id) || item === id ); if (index === -1) { throw new Error(`Item with id ${id} not found`); } return { value: items[index], index }; }), getTextBoxDimensions: jest.fn((textBox: TextBox) => ({ width: (textBox.content?.length || 0) * 10, height: textBox.fontSize || 16 })) })); describe('textBox reducer', () => { let mockState: State; let mockContext: ViewReducerContext; let mockTextBox: TextBox; let mockView: View; beforeEach(() => { jest.clearAllMocks(); mockTextBox = { id: 'textbox1', position: { x: 10, y: 20 }, content: 'Test Content', fontSize: 14, color: 'color1', backgroundColor: 'color2', borderColor: 'color3', alignment: 'left' }; mockView = { id: 'view1', name: 'Test View', items: [], connectors: [], rectangles: [], textBoxes: [mockTextBox] }; mockState = { model: { version: '1.0', title: 'Test Model', description: '', colors: [], icons: [], items: [], views: [mockView] }, scene: { viewId: 'view1', viewport: { x: 0, y: 0, zoom: 1 }, grid: { enabled: true, size: 10, style: 'dots' }, connectors: {}, viewItems: {}, rectangles: {}, textBoxes: { 'textbox1': { size: { width: 120, height: 14 } } } } }; mockContext = { viewId: 'view1', state: mockState }; }); describe('syncTextBox', () => { it('should sync text box dimensions to scene', () => { const getTextBoxDimensions = require('src/utils').getTextBoxDimensions; getTextBoxDimensions.mockClear(); getTextBoxDimensions.mockReturnValue({ width: 150, height: 20 }); const result = syncTextBox('textbox1', mockContext); expect(getTextBoxDimensions).toHaveBeenCalled(); expect(result.scene.textBoxes['textbox1'].size).toEqual({ width: 150, height: 20 }); }); it('should throw error when text box does not exist', () => { expect(() => { syncTextBox('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle empty textBoxes array', () => { mockState.model.views[0].textBoxes = []; expect(() => { syncTextBox('textbox1', mockContext); }).toThrow('Item with id textbox1 not found'); }); it('should create scene entry if it doesn\'t exist', () => { delete mockState.scene.textBoxes['textbox1']; const result = syncTextBox('textbox1', mockContext); expect(result.scene.textBoxes['textbox1']).toBeDefined(); expect(result.scene.textBoxes['textbox1'].size).toBeDefined(); }); }); describe('updateTextBox', () => { it('should update text box properties', () => { const updates = { id: 'textbox1', content: 'Updated Content', fontSize: 18, color: 'color4' }; const result = updateTextBox(updates, mockContext); expect(result.model.views[0].textBoxes![0].content).toBe('Updated Content'); expect(result.model.views[0].textBoxes![0].fontSize).toBe(18); expect(result.model.views[0].textBoxes![0].color).toBe('color4'); }); it('should sync when content is updated', () => { const getTextBoxDimensions = require('src/utils').getTextBoxDimensions; getTextBoxDimensions.mockClear(); const updates = { id: 'textbox1', content: 'New Content That Is Longer' }; updateTextBox(updates, mockContext); // Should trigger sync because content changed expect(getTextBoxDimensions).toHaveBeenCalled(); }); it('should sync when fontSize is updated', () => { const getTextBoxDimensions = require('src/utils').getTextBoxDimensions; getTextBoxDimensions.mockClear(); const updates = { id: 'textbox1', fontSize: 24 }; updateTextBox(updates, mockContext); // Should trigger sync because fontSize changed expect(getTextBoxDimensions).toHaveBeenCalled(); }); it('should not sync when other properties are updated', () => { const getTextBoxDimensions = require('src/utils').getTextBoxDimensions; getTextBoxDimensions.mockClear(); const updates = { id: 'textbox1', color: 'color5', backgroundColor: 'color6' }; updateTextBox(updates, mockContext); // Should NOT trigger sync for non-size-affecting properties expect(getTextBoxDimensions).not.toHaveBeenCalled(); }); it('should handle undefined textBoxes array', () => { mockState.model.views[0].textBoxes = undefined; const result = updateTextBox({ id: 'textbox1', content: 'test' }, mockContext); // Should return state unchanged expect(result).toEqual(mockState); }); it('should preserve other properties when partially updating', () => { const updates = { id: 'textbox1', content: 'Partial Update' }; const result = updateTextBox(updates, mockContext); // Original properties should be preserved expect(result.model.views[0].textBoxes![0].fontSize).toBe(mockTextBox.fontSize); expect(result.model.views[0].textBoxes![0].color).toBe(mockTextBox.color); expect(result.model.views[0].textBoxes![0].position).toEqual(mockTextBox.position); // Updated property expect(result.model.views[0].textBoxes![0].content).toBe('Partial Update'); }); it('should throw error when text box does not exist', () => { expect(() => { updateTextBox({ id: 'nonexistent', content: 'test' }, mockContext); }).toThrow('Item with id nonexistent not found'); }); }); describe('createTextBox', () => { it('should create a new text box', () => { const newTextBox: TextBox = { id: 'textbox2', position: { x: 30, y: 40 }, content: 'New Text Box', fontSize: 16 }; const result = createTextBox(newTextBox, mockContext); // Should be added at the beginning (unshift) expect(result.model.views[0].textBoxes).toHaveLength(2); expect(result.model.views[0].textBoxes![0].id).toBe('textbox2'); expect(result.model.views[0].textBoxes![1].id).toBe('textbox1'); // Should sync the new text box expect(result.scene.textBoxes['textbox2']).toBeDefined(); expect(result.scene.textBoxes['textbox2'].size).toBeDefined(); }); it('should initialize textBoxes array if undefined', () => { mockState.model.views[0].textBoxes = undefined; const newTextBox: TextBox = { id: 'textbox2', position: { x: 30, y: 40 }, content: 'New Text Box' }; const result = createTextBox(newTextBox, mockContext); expect(result.model.views[0].textBoxes).toHaveLength(1); expect(result.model.views[0].textBoxes![0].id).toBe('textbox2'); }); it('should create text box with all properties', () => { const newTextBox: TextBox = { id: 'textbox2', position: { x: 30, y: 40 }, content: 'Full Text Box', fontSize: 20, color: 'color7', backgroundColor: 'color8', borderColor: 'color9', alignment: 'center', bold: true, italic: true }; const result = createTextBox(newTextBox, mockContext); const created = result.model.views[0].textBoxes![0]; expect(created.content).toBe('Full Text Box'); expect(created.fontSize).toBe(20); expect(created.color).toBe('color7'); expect(created.backgroundColor).toBe('color8'); expect(created.borderColor).toBe('color9'); expect(created.alignment).toBe('center'); expect(created.bold).toBe(true); expect(created.italic).toBe(true); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; const newTextBox: TextBox = { id: 'textbox2', position: { x: 30, y: 40 }, content: 'New Text Box' }; expect(() => { createTextBox(newTextBox, mockContext); }).toThrow('Item with id nonexistent not found'); }); }); describe('deleteTextBox', () => { it('should delete a text box from both model and scene', () => { const result = deleteTextBox('textbox1', mockContext); // Check text box is removed from model expect(result.model.views[0].textBoxes).toHaveLength(0); // Check text box is removed from scene expect(result.scene.textBoxes['textbox1']).toBeUndefined(); }); it('should throw error when text box does not exist', () => { expect(() => { deleteTextBox('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; expect(() => { deleteTextBox('textbox1', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle empty textBoxes array', () => { mockState.model.views[0].textBoxes = []; expect(() => { deleteTextBox('textbox1', mockContext); }).toThrow('Item with id textbox1 not found'); }); it('should not affect other text boxes when deleting one', () => { const textBox2: TextBox = { id: 'textbox2', position: { x: 50, y: 60 }, content: 'Second Text Box' }; mockState.model.views[0].textBoxes = [mockTextBox, textBox2]; mockState.scene.textBoxes['textbox2'] = { size: { width: 150, height: 16 } }; const result = deleteTextBox('textbox1', mockContext); expect(result.model.views[0].textBoxes).toHaveLength(1); expect(result.model.views[0].textBoxes![0].id).toBe('textbox2'); // Verify proper scene cleanup expect(result.scene.textBoxes['textbox1']).toBeUndefined(); expect(result.scene.textBoxes['textbox2']).toBeDefined(); }); }); describe('edge cases and state immutability', () => { it('should not mutate the original state', () => { const originalState = JSON.parse(JSON.stringify(mockState)); deleteTextBox('textbox1', mockContext); expect(mockState).toEqual(originalState); }); it('should handle multiple operations in sequence', () => { // Create let result = createTextBox({ id: 'textbox2', position: { x: 100, y: 100 }, content: 'New Box' }, { ...mockContext, state: mockState }); // Update result = updateTextBox({ id: 'textbox2', content: 'Updated Box', fontSize: 24 }, { ...mockContext, state: result }); // Delete original result = deleteTextBox('textbox1', { ...mockContext, state: result }); expect(result.model.views[0].textBoxes).toHaveLength(1); expect(result.model.views[0].textBoxes![0].id).toBe('textbox2'); expect(result.model.views[0].textBoxes![0].content).toBe('Updated Box'); expect(result.model.views[0].textBoxes![0].fontSize).toBe(24); }); it('should handle view with multiple text boxes', () => { const textBoxes: TextBox[] = Array.from({ length: 5 }, (_, i) => ({ id: `textbox${i}`, position: { x: i * 10, y: i * 10 }, content: `Text ${i}` })); mockState.model.views[0].textBoxes = textBoxes; const result = deleteTextBox('textbox2', mockContext); expect(result.model.views[0].textBoxes).toHaveLength(4); expect(result.model.views[0].textBoxes!.find(t => t.id === 'textbox2')).toBeUndefined(); }); }); }); ================================================ FILE: packages/fossflow-lib/src/stores/reducers/__tests__/viewItem.test.ts ================================================ import { deleteViewItem, updateViewItem, createViewItem } from '../viewItem'; import { State, ViewReducerContext } from '../types'; import { ViewItem, View, Connector } from 'src/types'; // Mock the utility functions and reducers jest.mock('src/utils', () => ({ getItemByIdOrThrow: jest.fn((items: any[], id: string) => { const index = items.findIndex((item: any) => (typeof item === 'object' && item.id === id) || item === id ); if (index === -1) { throw new Error(`Item with id ${id} not found`); } return { value: items[index], index }; }), getConnectorsByViewItem: jest.fn((viewItemId: string, connectors: Connector[]) => { return connectors.filter(connector => connector.anchors.some((anchor: any) => anchor.ref?.item === viewItemId ) ); }) })); jest.mock('src/schemas/validation', () => ({ validateView: jest.fn(() => []) })); jest.mock('../view', () => ({ view: jest.fn((params: any) => params.ctx.state) })); describe('viewItem reducer', () => { let mockState: State; let mockContext: ViewReducerContext; let mockViewItem: ViewItem; let mockView: View; let mockConnector: Connector; beforeEach(() => { jest.clearAllMocks(); mockViewItem = { id: 'item1', tile: { x: 0, y: 0 }, size: { width: 100, height: 100 } }; mockConnector = { id: 'connector1', anchors: [ { id: 'anchor1', ref: { item: 'item1' }, face: 'right', offset: 0 }, { id: 'anchor2', ref: { item: 'item2' }, face: 'left', offset: 0 } ] }; mockView = { id: 'view1', name: 'Test View', items: [mockViewItem, { id: 'item2', tile: { x: 1, y: 0 } }], connectors: [mockConnector], rectangles: [], textBoxes: [] }; mockState = { model: { version: '1.0', title: 'Test Model', description: '', colors: [], icons: [], items: [], views: [mockView] }, scene: { viewId: 'view1', viewport: { x: 0, y: 0, zoom: 1 }, grid: { enabled: true, size: 10, style: 'dots' }, connectors: { 'connector1': { path: { tiles: [], rectangle: { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } } } } }, viewItems: {}, rectangles: {}, textBoxes: {} } }; mockContext = { viewId: 'view1', state: mockState }; }); describe('deleteViewItem', () => { it('should delete a view item and its associated connectors', () => { const result = deleteViewItem('item1', mockContext); // Check item is removed from model expect(result.model.views[0].items).toHaveLength(1); expect(result.model.views[0].items.find(item => item.id === 'item1')).toBeUndefined(); // Check connectors referencing the item are removed expect(result.model.views[0].connectors).toHaveLength(0); expect(result.scene.connectors['connector1']).toBeUndefined(); }); it('should only remove connectors that reference the deleted item', () => { const connector2: Connector = { id: 'connector2', anchors: [ { id: 'anchor3', ref: { item: 'item2' }, face: 'top' }, { id: 'anchor4', ref: { item: 'item3' }, face: 'bottom' } ] }; mockState.model.views[0].connectors = [mockConnector, connector2]; mockState.scene.connectors['connector2'] = { path: { tiles: [], rectangle: { from: { x: 1, y: 1 }, to: { x: 2, y: 2 } } } }; const result = deleteViewItem('item1', mockContext); // Check only connector1 is removed expect(result.model.views[0].connectors).toHaveLength(1); expect(result.model.views[0].connectors![0].id).toBe('connector2'); expect(result.scene.connectors['connector1']).toBeUndefined(); expect(result.scene.connectors['connector2']).toBeDefined(); }); it('should handle deletion when no connectors reference the item', () => { // Create a connector that doesn't reference item1 mockState.model.views[0].connectors = [{ id: 'connector2', anchors: [ { id: 'anchor3', ref: { item: 'item2' }, face: 'top' }, { id: 'anchor4', ref: { item: 'item3' }, face: 'bottom' } ] }]; const result = deleteViewItem('item1', mockContext); // Item should be removed but connector should remain expect(result.model.views[0].items).toHaveLength(1); expect(result.model.views[0].connectors).toHaveLength(1); }); it('should handle deletion when view has no connectors', () => { mockState.model.views[0].connectors = undefined; const result = deleteViewItem('item1', mockContext); expect(result.model.views[0].items).toHaveLength(1); expect(result.model.views[0].connectors).toBeUndefined(); }); it('should throw error when item does not exist', () => { expect(() => { deleteViewItem('nonexistent', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; expect(() => { deleteViewItem('item1', mockContext); }).toThrow('Item with id nonexistent not found'); }); it('should handle connectors with multiple anchors referencing the same item', () => { const complexConnector: Connector = { id: 'connector3', anchors: [ { id: 'anchor5', ref: { item: 'item1' }, face: 'top' }, { id: 'anchor6', ref: { item: 'item1' }, face: 'bottom' }, { id: 'anchor7', ref: { item: 'item2' }, face: 'left' } ] }; mockState.model.views[0].connectors = [complexConnector]; const result = deleteViewItem('item1', mockContext); // Connector should be removed since it references the deleted item expect(result.model.views[0].connectors).toHaveLength(0); }); }); describe('updateViewItem', () => { it('should update view item properties', () => { const updates = { id: 'item1', tile: { x: 2, y: 2 }, size: { width: 200, height: 200 } }; const result = updateViewItem(updates, mockContext); const updatedItem = result.model.views[0].items.find(item => item.id === 'item1'); expect(updatedItem?.tile).toEqual({ x: 2, y: 2 }); expect(updatedItem?.size).toEqual({ width: 200, height: 200 }); }); it('should update connectors when item tile position changes', () => { const updates = { id: 'item1', tile: { x: 5, y: 5 } }; const result = updateViewItem(updates, mockContext); // The item should be updated with new position const updatedItem = result.model.views[0].items.find(item => item.id === 'item1'); expect(updatedItem?.tile).toEqual({ x: 5, y: 5 }); // When tile changes, connectors that reference this item are updated // The mock implementation tracks that connectors referencing the item were found const getConnectorsByViewItem = require('src/utils').getConnectorsByViewItem; expect(getConnectorsByViewItem).toHaveBeenCalled(); }); it('should validate view after update', () => { const validateView = require('src/schemas/validation').validateView; validateView.mockReturnValueOnce([{ message: 'Validation error' }]); expect(() => { updateViewItem({ id: 'item1', tile: { x: 1, y: 1 } }, mockContext); }).toThrow('Validation error'); }); it('should not update connectors when tile position is not changed', () => { const view = require('../view').view; view.mockClear(); const updates = { id: 'item1', size: { width: 150, height: 150 } }; updateViewItem(updates, mockContext); // View reducer should not be called for connector updates expect(view).not.toHaveBeenCalled(); }); it('should throw error when item does not exist', () => { expect(() => { updateViewItem({ id: 'nonexistent', tile: { x: 1, y: 1 } }, mockContext); }).toThrow('Item with id nonexistent not found'); }); }); describe('createViewItem', () => { it('should create a new view item', () => { const newItem: ViewItem = { id: 'item3', tile: { x: 3, y: 3 }, size: { width: 100, height: 100 } }; const result = createViewItem(newItem, mockContext); // Should be added at the beginning (unshift) expect(result.model.views[0].items).toHaveLength(3); expect(result.model.views[0].items[0].id).toBe('item3'); }); it('should validate view after creation', () => { const validateView = require('src/schemas/validation').validateView; validateView.mockReturnValueOnce([{ message: 'Invalid item' }]); const newItem: ViewItem = { id: 'item3', tile: { x: 3, y: 3 } }; expect(() => { createViewItem(newItem, mockContext); }).toThrow('Invalid item'); }); it('should throw error when view does not exist', () => { mockContext.viewId = 'nonexistent'; const newItem: ViewItem = { id: 'item3', tile: { x: 3, y: 3 } }; expect(() => { createViewItem(newItem, mockContext); }).toThrow('Item with id nonexistent not found'); }); }); describe('edge cases and state immutability', () => { it('should not mutate the original state', () => { const originalState = JSON.parse(JSON.stringify(mockState)); deleteViewItem('item1', mockContext); expect(mockState).toEqual(originalState); }); it('should handle multiple operations in sequence', () => { // Create let result = createViewItem({ id: 'item3', tile: { x: 2, y: 2 } }, { ...mockContext, state: mockState }); // Update result = updateViewItem({ id: 'item3', size: { width: 150, height: 150 } }, { ...mockContext, state: result }); // Delete original result = deleteViewItem('item1', { ...mockContext, state: result }); expect(result.model.views[0].items.find(item => item.id === 'item3')).toBeDefined(); expect(result.model.views[0].items.find(item => item.id === 'item3')?.size).toEqual({ width: 150, height: 150 }); expect(result.model.views[0].items.find(item => item.id === 'item1')).toBeUndefined(); // Connector referencing item1 should be removed expect(result.model.views[0].connectors).toHaveLength(0); }); it('should handle deletion of all items with connectors', () => { // Delete all items one by one let result = deleteViewItem('item1', mockContext); result = deleteViewItem('item2', { ...mockContext, state: result }); expect(result.model.views[0].items).toHaveLength(0); expect(result.model.views[0].connectors).toHaveLength(0); }); }); }); ================================================ FILE: packages/fossflow-lib/src/stores/reducers/connector.ts ================================================ import { Connector } from 'src/types'; import { produce } from 'immer'; import { getItemByIdOrThrow, getConnectorPath } from 'src/utils'; import { State, ViewReducerContext } from './types'; export const deleteConnector = ( id: string, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const connector = getItemByIdOrThrow(view.value.connectors ?? [], id); const newState = produce(state, (draft) => { draft.model.views[view.index].connectors?.splice(connector.index, 1); delete draft.scene.connectors[id]; }); return newState; }; export const syncConnector = ( id: string, { viewId, state }: ViewReducerContext ) => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const connector = getItemByIdOrThrow(view.value.connectors ?? [], id); // Skip validation - allow all connectors regardless of position try { const path = getConnectorPath({ anchors: connector.value.anchors, view: view.value }); draft.scene.connectors[connector.value.id] = { path }; } catch (error) { // Even if we can't get the path, keep the connector with an empty path draft.scene.connectors[connector.value.id] = { path: { tiles: [], rectangle: { from: { x: 0, y: 0 }, to: { x: 0, y: 0 } } } }; } }); return newState; }; export const updateConnector = ( { id, ...updates }: { id: string } & Partial, { state, viewId }: ViewReducerContext ): State => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const { connectors } = draft.model.views[view.index]; if (!connectors) return; const connector = getItemByIdOrThrow(connectors, id); const newConnector = { ...connector.value, ...updates }; connectors[connector.index] = newConnector; if (updates.anchors) { const stateAfterSync = syncConnector(newConnector.id, { viewId, state: draft }); draft.model = stateAfterSync.model; draft.scene = stateAfterSync.scene; } }); return newState; }; export const createConnector = ( newConnector: Connector, { state, viewId }: ViewReducerContext ): State => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const { connectors } = draft.model.views[view.index]; if (!connectors) { draft.model.views[view.index].connectors = [newConnector]; } else { draft.model.views[view.index].connectors?.unshift(newConnector); } const stateAfterSync = syncConnector(newConnector.id, { viewId, state: draft }); draft.model = stateAfterSync.model; draft.scene = stateAfterSync.scene; }); return newState; }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/index.ts ================================================ export { view } from './view'; export * from './modelItem'; export { syncConnector } from './connector'; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/modelItem.ts ================================================ import { produce } from 'immer'; import { ModelItem } from 'src/types'; import { getItemByIdOrThrow } from 'src/utils'; import { State } from './types'; export const updateModelItem = ( id: string, updates: Partial, state: State ): State => { const modelItem = getItemByIdOrThrow(state.model.items, id); const newState = produce(state, (draft) => { draft.model.items[modelItem.index] = { ...modelItem.value, ...updates }; }); return newState; }; export const createModelItem = ( newModelItem: ModelItem, state: State ): State => { const newState = produce(state, (draft) => { draft.model.items.push(newModelItem); }); return updateModelItem(newModelItem.id, newModelItem, newState); }; export const deleteModelItem = (id: string, state: State): State => { const modelItem = getItemByIdOrThrow(state.model.items, id); const newState = produce(state, (draft) => { delete draft.model.items[modelItem.index]; }); return newState; }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/rectangle.ts ================================================ import { produce } from 'immer'; import { Rectangle } from 'src/types'; import { getItemByIdOrThrow } from 'src/utils'; import { State, ViewReducerContext } from './types'; export const updateRectangle = ( { id, ...updates }: { id: string } & Partial, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const newState = produce(state, (draft) => { const { rectangles } = draft.model.views[view.index]; if (!rectangles) return; const rectangle = getItemByIdOrThrow(rectangles, id); const newRectangle = { ...rectangle.value, ...updates }; rectangles[rectangle.index] = newRectangle; }); return newState; }; export const createRectangle = ( newRectangle: Rectangle, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const newState = produce(state, (draft) => { const { rectangles } = draft.model.views[view.index]; if (!rectangles) { draft.model.views[view.index].rectangles = [newRectangle]; } else { draft.model.views[view.index].rectangles?.unshift(newRectangle); } }); return updateRectangle(newRectangle, { viewId, state: newState }); }; export const deleteRectangle = ( id: string, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const rectangle = getItemByIdOrThrow(view.value.rectangles ?? [], id); const newState = produce(state, (draft) => { draft.model.views[view.index].rectangles?.splice(rectangle.index, 1); // Rectangles don't have scene data - they're only stored in the model }); return newState; }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/textBox.ts ================================================ import { produce } from 'immer'; import { TextBox } from 'src/types'; import { getItemByIdOrThrow, getTextBoxDimensions } from 'src/utils'; import { State, ViewReducerContext } from './types'; export const syncTextBox = ( id: string, { viewId, state }: ViewReducerContext ): State => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id); const textBoxSize = getTextBoxDimensions(textBox.value); draft.scene.textBoxes[textBox.value.id] = { size: textBoxSize }; }); return newState; }; export const updateTextBox = ( { id, ...updates }: { id: string } & Partial, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const newState = produce(state, (draft) => { const { textBoxes } = draft.model.views[view.index]; if (!textBoxes) return; const textBox = getItemByIdOrThrow(textBoxes, id); const newTextBox = { ...textBox.value, ...updates }; textBoxes[textBox.index] = newTextBox; if (updates.content !== undefined || updates.fontSize !== undefined) { const stateAfterSync = syncTextBox(newTextBox.id, { viewId, state: draft }); draft.model = stateAfterSync.model; draft.scene = stateAfterSync.scene; } }); return newState; }; export const createTextBox = ( newTextBox: TextBox, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const newState = produce(state, (draft) => { const { textBoxes } = draft.model.views[view.index]; if (!textBoxes) { draft.model.views[view.index].textBoxes = [newTextBox]; } else { draft.model.views[view.index].textBoxes?.unshift(newTextBox); } }); return updateTextBox(newTextBox, { viewId, state: newState }); }; export const deleteTextBox = ( id: string, { viewId, state }: ViewReducerContext ): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id); const newState = produce(state, (draft) => { draft.model.views[view.index].textBoxes?.splice(textBox.index, 1); delete draft.scene.textBoxes[id]; }); return newState; }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/types.ts ================================================ import { Model, Scene } from 'src/types'; import type * as viewReducers from './view'; import type * as viewItemReducers from './viewItem'; import type * as connectorReducers from './connector'; import type * as textBoxReducers from './textBox'; import type * as rectangleReducers from './rectangle'; export interface State { model: Model; scene: Scene; } export interface ViewReducerContext { viewId: string; state: State; } type ViewReducerAction = | { action: 'SYNC_SCENE'; payload: undefined; } | { action: 'CREATE_VIEW'; payload: Parameters[0]; } | { action: 'UPDATE_VIEW'; payload: Parameters[0]; } | { action: 'DELETE_VIEW'; payload: undefined; } | { action: 'CREATE_VIEWITEM'; payload: Parameters[0]; } | { action: 'UPDATE_VIEWITEM'; payload: Parameters[0]; } | { action: 'DELETE_VIEWITEM'; payload: Parameters[0]; } | { action: 'CREATE_CONNECTOR'; payload: Parameters[0]; } | { action: 'UPDATE_CONNECTOR'; payload: Parameters[0]; } | { action: 'DELETE_CONNECTOR'; payload: Parameters[0]; } | { action: 'SYNC_CONNECTOR'; payload: Parameters[0]; } | { action: 'CREATE_TEXTBOX'; payload: Parameters[0]; } | { action: 'UPDATE_TEXTBOX'; payload: Parameters[0]; } | { action: 'DELETE_TEXTBOX'; payload: Parameters[0]; } | { action: 'CREATE_RECTANGLE'; payload: Parameters[0]; } | { action: 'UPDATE_RECTANGLE'; payload: Parameters[0]; } | { action: 'DELETE_RECTANGLE'; payload: Parameters[0]; }; export type ViewReducerParams = ViewReducerAction & { ctx: ViewReducerContext }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/view.ts ================================================ import { produce } from 'immer'; import { View } from 'src/types'; import { getItemByIdOrThrow } from 'src/utils'; import { VIEW_DEFAULTS, INITIAL_SCENE_STATE } from 'src/config'; import type { ViewReducerContext, State, ViewReducerParams } from './types'; import { syncConnector } from './connector'; import { syncTextBox } from './textBox'; import * as viewItemReducers from './viewItem'; import * as connectorReducers from './connector'; import * as textBoxReducers from './textBox'; import * as rectangleReducers from './rectangle'; export const updateViewTimestamp = (ctx: ViewReducerContext): State => { const now = new Date().toISOString(); const newState = produce(ctx.state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, ctx.viewId); view.value.lastUpdated = now; }); return newState; }; export const syncScene = ({ viewId, state }: ViewReducerContext): State => { const view = getItemByIdOrThrow(state.model.views, viewId); const startingState: State = { model: state.model, scene: INITIAL_SCENE_STATE }; const stateAfterConnectorsSynced = [ ...(view.value.connectors ?? []) ].reduce((acc, connector) => { return syncConnector(connector.id, { viewId, state: acc }); }, startingState); const stateAfterTextBoxesSynced = [ ...(view.value.textBoxes ?? []) ].reduce((acc, textBox) => { return syncTextBox(textBox.id, { viewId, state: acc }); }, stateAfterConnectorsSynced); return stateAfterTextBoxesSynced; }; export const deleteView = (ctx: ViewReducerContext): State => { const newState = produce(ctx.state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, ctx.viewId); draft.model.views.splice(view.index, 1); }); return newState; }; export const updateView = ( updates: Partial>, ctx: ViewReducerContext ): State => { const newState = produce(ctx.state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, ctx.viewId); view.value = { ...view.value, ...updates }; }); return newState; }; export const createView = ( newView: Partial, ctx: ViewReducerContext ): State => { const newState = produce(ctx.state, (draft) => { draft.model.views.push({ ...VIEW_DEFAULTS, id: ctx.viewId, ...newView }); }); return newState; }; export const view = ({ action, payload, ctx }: ViewReducerParams) => { let newState: State; switch (action) { case 'SYNC_SCENE': newState = syncScene(ctx); break; case 'CREATE_VIEW': newState = createView(payload, ctx); break; case 'UPDATE_VIEW': newState = updateView(payload, ctx); break; case 'DELETE_VIEW': newState = deleteView(ctx); break; case 'CREATE_VIEWITEM': newState = viewItemReducers.createViewItem(payload, ctx); break; case 'UPDATE_VIEWITEM': newState = viewItemReducers.updateViewItem(payload, ctx); break; case 'DELETE_VIEWITEM': newState = viewItemReducers.deleteViewItem(payload, ctx); break; case 'CREATE_CONNECTOR': newState = connectorReducers.createConnector(payload, ctx); break; case 'UPDATE_CONNECTOR': newState = connectorReducers.updateConnector(payload, ctx); break; case 'SYNC_CONNECTOR': newState = connectorReducers.syncConnector(payload, ctx); break; case 'DELETE_CONNECTOR': newState = connectorReducers.deleteConnector(payload, ctx); break; case 'CREATE_TEXTBOX': newState = textBoxReducers.createTextBox(payload, ctx); break; case 'UPDATE_TEXTBOX': newState = textBoxReducers.updateTextBox(payload, ctx); break; case 'DELETE_TEXTBOX': newState = textBoxReducers.deleteTextBox(payload, ctx); break; case 'CREATE_RECTANGLE': newState = rectangleReducers.createRectangle(payload, ctx); break; case 'UPDATE_RECTANGLE': newState = rectangleReducers.updateRectangle(payload, ctx); break; case 'DELETE_RECTANGLE': newState = rectangleReducers.deleteRectangle(payload, ctx); break; default: throw new Error('Invalid action.'); } switch (action) { case 'SYNC_SCENE': case 'DELETE_VIEW': return newState; default: return updateViewTimestamp({ state: newState, viewId: ctx.viewId }); } }; ================================================ FILE: packages/fossflow-lib/src/stores/reducers/viewItem.ts ================================================ import { produce } from 'immer'; import { ViewItem } from 'src/types'; import { getItemByIdOrThrow, getConnectorsByViewItem } from 'src/utils'; import { validateView } from 'src/schemas/validation'; import { State, ViewReducerContext } from './types'; import * as reducers from './view'; export const updateViewItem = ( { id, ...updates }: { id: string } & Partial, { viewId, state }: ViewReducerContext ): State => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const { items } = view.value; if (!items) return; const viewItem = getItemByIdOrThrow(items, id); const newItem = { ...viewItem.value, ...updates }; items[viewItem.index] = newItem; if (updates.tile) { const connectorsToUpdate = getConnectorsByViewItem( viewItem.value.id, view.value.connectors ?? [] ); const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => { return reducers.view({ action: 'UPDATE_CONNECTOR', payload: connector, ctx: { viewId, state: acc } }); }, draft); draft.model.views[view.index].connectors = updatedConnectors.model.views[view.index].connectors; draft.scene.connectors = updatedConnectors.scene.connectors; } }); const newView = getItemByIdOrThrow(newState.model.views, viewId); const issues = validateView(newView.value, { model: newState.model }); if (issues.length > 0) { throw new Error(issues[0].message); } return newState; }; export const createViewItem = ( newViewItem: ViewItem, ctx: ViewReducerContext ): State => { const { state, viewId } = ctx; const view = getItemByIdOrThrow(state.model.views, viewId); const newState = produce(state, (draft) => { const { items } = draft.model.views[view.index]; items.unshift(newViewItem); }); return updateViewItem(newViewItem, { viewId, state: newState }); }; export const deleteViewItem = ( id: string, { state, viewId }: ViewReducerContext ): State => { const newState = produce(state, (draft) => { const view = getItemByIdOrThrow(draft.model.views, viewId); const viewItem = getItemByIdOrThrow(view.value.items, id); draft.model.views[view.index].items.splice(viewItem.index, 1); // Find connectors that reference this deleted item const connectorsToDelete = getConnectorsByViewItem( viewItem.value.id, view.value.connectors ?? [] ); // Remove connectors that reference the deleted item if (connectorsToDelete.length > 0 && draft.model.views[view.index].connectors) { draft.model.views[view.index].connectors = draft.model.views[view.index].connectors?.filter( connector => !connectorsToDelete.some(c => c.id === connector.id) ); // Also remove from scene connectorsToDelete.forEach(connector => { delete draft.scene.connectors[connector.id]; }); } }); return newState; }; ================================================ FILE: packages/fossflow-lib/src/stores/sceneStore.tsx ================================================ import React, { createContext, useRef, useContext } from 'react'; import { createStore, useStore } from 'zustand'; import { SceneStore, Scene } from 'src/types'; export interface SceneHistoryState { past: Scene[]; present: Scene; future: Scene[]; maxHistorySize: number; } export interface SceneStoreWithHistory extends Omit { history: SceneHistoryState; actions: { get: () => SceneStoreWithHistory; set: (scene: Partial, skipHistory?: boolean) => void; undo: () => boolean; redo: () => boolean; canUndo: () => boolean; canRedo: () => boolean; saveToHistory: () => void; clearHistory: () => void; }; } const MAX_HISTORY_SIZE = 50; const createSceneHistoryState = (initialScene: Scene): SceneHistoryState => { return { past: [], present: initialScene, future: [], maxHistorySize: MAX_HISTORY_SIZE }; }; const extractSceneData = (state: SceneStoreWithHistory): Scene => { return { connectors: state.connectors, textBoxes: state.textBoxes }; }; const initialState = () => { return createStore((set, get) => { const initialScene: Scene = { connectors: {}, textBoxes: {} }; const saveToHistory = () => { set((state) => { const currentScene = extractSceneData(state); const newPast = [...state.history.past, currentScene]; // Limit history size if (newPast.length > state.history.maxHistorySize) { newPast.shift(); } return { ...state, history: { ...state.history, past: newPast, present: currentScene, future: [] } }; }); }; const undo = (): boolean => { const { history } = get(); if (history.past.length === 0) return false; const previous = history.past[history.past.length - 1]; const newPast = history.past.slice(0, history.past.length - 1); set((state) => { // Capture the actual live state (not stale history.present) const currentScene = extractSceneData(state); return { ...previous, history: { ...state.history, past: newPast, present: previous, future: [currentScene, ...state.history.future] } }; }); return true; }; const redo = (): boolean => { const { history } = get(); if (history.future.length === 0) return false; const next = history.future[0]; const newFuture = history.future.slice(1); set((state) => { // Capture the actual live state (not stale history.present) const currentScene = extractSceneData(state); return { ...next, history: { ...state.history, past: [...state.history.past, currentScene], present: next, future: newFuture } }; }); return true; }; const canUndo = () => { return get().history.past.length > 0; }; const canRedo = () => { return get().history.future.length > 0; }; const clearHistory = () => { const currentState = get(); const currentScene = extractSceneData(currentState); set((state) => { return { ...state, history: createSceneHistoryState(currentScene) }; }); }; return { ...initialScene, history: createSceneHistoryState(initialScene), actions: { get, set: (updates: Partial, skipHistory = false) => { if (!skipHistory) { saveToHistory(); } set((state) => { return { ...state, ...updates }; }); }, undo, redo, canUndo, canRedo, saveToHistory, clearHistory } }; }); }; const SceneContext = createContext | null>( null ); interface ProviderProps { children: React.ReactNode; } export const SceneProvider = ({ children }: ProviderProps) => { const storeRef = useRef | undefined>(undefined); if (!storeRef.current) { storeRef.current = initialState(); } return ( {children} ); }; export function useSceneStore( selector: (state: SceneStoreWithHistory) => T, equalityFn?: (left: T, right: T) => boolean ) { const store = useContext(SceneContext); if (store === null) { throw new Error('Missing provider in the tree'); } const value = useStore(store, selector, equalityFn); return value; } export function useSceneStoreApi() { const store = useContext(SceneContext); if (store === null) { throw new Error('Missing provider in the tree'); } return store; } ================================================ FILE: packages/fossflow-lib/src/stores/uiStateStore.tsx ================================================ import React, { createContext, useContext, useRef } from 'react'; import { createStore, useStore } from 'zustand'; import { CoordsUtils, incrementZoom, decrementZoom, getStartingMode } from 'src/utils'; import { UiStateStore } from 'src/types'; import { INITIAL_UI_STATE } from 'src/config'; import { DEFAULT_HOTKEY_PROFILE, HotkeyProfile } from 'src/config/hotkeys'; import { DEFAULT_PAN_SETTINGS } from 'src/config/panSettings'; import { DEFAULT_ZOOM_SETTINGS } from 'src/config/zoomSettings'; import { DEFAULT_LABEL_SETTINGS } from 'src/config/labelSettings'; const initialState = () => { return createStore((set, get) => { return { zoom: INITIAL_UI_STATE.zoom, scroll: INITIAL_UI_STATE.scroll, view: '', mainMenuOptions: [], editorMode: 'EXPLORABLE_READONLY', mode: getStartingMode('EXPLORABLE_READONLY'), iconCategoriesState: [], isMainMenuOpen: false, dialog: null, rendererEl: null, contextMenu: null, mouse: { position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() }, mousedown: null, delta: null }, itemControls: null, enableDebugTools: false, hotkeyProfile: DEFAULT_HOTKEY_PROFILE, panSettings: DEFAULT_PAN_SETTINGS, zoomSettings: DEFAULT_ZOOM_SETTINGS, labelSettings: DEFAULT_LABEL_SETTINGS, connectorInteractionMode: 'click', // Default to click mode expandLabels: false, // Default to collapsed labels iconPackManager: null, // Will be set by Isoflow if provided actions: { setView: (view) => { set({ view }); }, setMainMenuOptions: (mainMenuOptions) => { set({ mainMenuOptions }); }, setEditorMode: (mode) => { set({ editorMode: mode, mode: getStartingMode(mode) }); }, setIconCategoriesState: (iconCategoriesState) => { set({ iconCategoriesState }); }, resetUiState: () => { set({ mode: getStartingMode(get().editorMode), scroll: { position: CoordsUtils.zero(), offset: CoordsUtils.zero() }, itemControls: null, zoom: 1 }); }, setMode: (mode) => { set({ mode }); }, setDialog: (dialog) => { set({ dialog }); }, setIsMainMenuOpen: (isMainMenuOpen) => { set({ isMainMenuOpen, itemControls: null }); }, incrementZoom: () => { const { zoom } = get(); set({ zoom: incrementZoom(zoom) }); }, decrementZoom: () => { const { zoom } = get(); set({ zoom: decrementZoom(zoom) }); }, setZoom: (zoom) => { set({ zoom }); }, setScroll: ({ position, offset }) => { set({ scroll: { position, offset: offset ?? get().scroll.offset } }); }, setItemControls: (itemControls) => { set({ itemControls }); }, setContextMenu: (contextMenu) => { set({ contextMenu }); }, setMouse: (mouse) => { set({ mouse }); }, setEnableDebugTools: (enableDebugTools) => { set({ enableDebugTools }); }, setRendererEl: (el: HTMLDivElement) => { set({ rendererEl: el }); }, setHotkeyProfile: (hotkeyProfile: HotkeyProfile) => { set({ hotkeyProfile }); }, setPanSettings: (panSettings) => { set({ panSettings }); }, setZoomSettings: (zoomSettings) => { set({ zoomSettings }); }, setLabelSettings: (labelSettings) => { set({ labelSettings }); }, setConnectorInteractionMode: (connectorInteractionMode) => { set({ connectorInteractionMode }); }, setExpandLabels: (expandLabels) => { set({ expandLabels }); }, setIconPackManager: (iconPackManager) => { set({ iconPackManager }); } } }; }); }; const UiStateContext = createContext | null>( null ); interface ProviderProps { children: React.ReactNode; } // TODO: Typings below are pretty gnarly due to the way Zustand works. // see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061 export const UiStateProvider = ({ children }: ProviderProps) => { const storeRef = useRef | undefined>(undefined); if (!storeRef.current) { storeRef.current = initialState(); } return ( {children} ); }; export function useUiStateStore( selector: (state: UiStateStore) => T, equalityFn?: (left: T, right: T) => boolean ) { const store = useContext(UiStateContext); if (store === null) { throw new Error('Missing provider in the tree'); } const value = useStore(store, selector, equalityFn); return value; } // Hook to get store API for imperative access (getState without subscribing) export function useUiStateStoreApi() { const store = useContext(UiStateContext); if (store === null) { throw new Error('Missing provider in the tree'); } return store; } ================================================ FILE: packages/fossflow-lib/src/styles/GlobalStyles.tsx ================================================ import React from 'react'; import { GlobalStyles as MUIGlobalStyles } from '@mui/material'; import 'react-quill-new/dist/quill.snow.css'; export const GlobalStyles = () => { return ( ); }; ================================================ FILE: packages/fossflow-lib/src/styles/theme.ts ================================================ import { createTheme, ThemeOptions } from '@mui/material'; interface CustomThemeVars { appPadding: { x: number; y: number; }; toolMenu: { height: number; }; customPalette: { [key in string]: string; }; } declare module '@mui/material/styles' { interface Theme { customVars: CustomThemeVars; } interface ThemeOptions { customVars: CustomThemeVars; } } export const customVars: CustomThemeVars = { appPadding: { x: 40, y: 40 }, toolMenu: { height: 40 }, customPalette: { diagramBg: '#f6faff', defaultColor: '#a5b8f3' } }; const createShadows = () => { const shadows = Array(25) .fill('none') .map((shadow, i) => { if (i === 0) return 'none'; return `0px 10px 20px ${i - 10}px rgba(0,0,0,0.25)`; }) as Required['shadows']; return shadows; }; export const themeConfig: ThemeOptions = { customVars, shadows: createShadows(), transitions: { duration: { shortest: 50, shorter: 100, short: 150, standard: 200, complex: 250, enteringScreen: 150, leavingScreen: 100 } }, typography: { h2: { fontSize: '4em', fontStyle: 'bold', lineHeight: 1.2 }, h5: { fontSize: '1.3em', lineHeight: 1.2 }, body1: { fontSize: '0.85em', lineHeight: 1.2 }, body2: { fontSize: '0.75em', lineHeight: 1.2 } }, palette: { secondary: { main: '#df004c' } }, components: { MuiCard: { defaultProps: { elevation: 0, variant: 'outlined' } }, MuiToolbar: { styleOverrides: { root: { backgroundColor: 'white' } } }, MuiButtonBase: { defaultProps: { disableRipple: true, disableTouchRipple: true } }, MuiButton: { defaultProps: { disableElevation: true, variant: 'contained', disableRipple: true, disableTouchRipple: true }, styleOverrides: { root: { textTransform: 'none' } } }, MuiSvgIcon: { defaultProps: { color: 'action' }, styleOverrides: { root: { width: 17, height: 17 } } }, MuiTextField: { defaultProps: { variant: 'outlined' }, styleOverrides: { root: { '.MuiInputBase-input': {} } } } } }; export const theme = createTheme(themeConfig); ================================================ FILE: packages/fossflow-lib/src/types/common.ts ================================================ export interface Coords { x: number; y: number; } export interface Size { width: number; height: number; } export interface Rect { from: Coords; to: Coords; } export const ProjectionOrientationEnum = { X: 'X', Y: 'Y' } as const; export type BoundingBox = [Coords, Coords, Coords, Coords]; export type SlimMouseEvent = Pick< MouseEvent, 'clientX' | 'clientY' | 'target' | 'type' | 'preventDefault' | 'button' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' >; export const EditorModeEnum = { NON_INTERACTIVE: 'NON_INTERACTIVE', EXPLORABLE_READONLY: 'EXPLORABLE_READONLY', EDITABLE: 'EDITABLE' } as const; export const MainMenuOptionsEnum = { 'ACTION.OPEN': 'ACTION.OPEN', 'EXPORT.JSON': 'EXPORT.JSON', 'EXPORT.PNG': 'EXPORT.PNG', 'ACTION.CLEAR_CANVAS': 'ACTION.CLEAR_CANVAS', 'LINK.GITHUB': 'LINK.GITHUB', 'LINK.DISCORD': 'LINK.DISCORD', VERSION: 'VERSION' } as const; export type MainMenuOptions = (keyof typeof MainMenuOptionsEnum)[]; ================================================ FILE: packages/fossflow-lib/src/types/dom-to-image-more.d.ts ================================================ declare module 'dom-to-image-more' { export interface Options { filter?: (node: Node) => boolean; bgcolor?: string; width?: number; height?: number; style?: any; quality?: number; cacheBust?: boolean; copyDefaultStyles?: boolean; preferredFontFormat?: string; fontEmbedCSS?: string | null; skipAutoScale?: boolean; } export function toPng(node: Node, options?: Options): Promise; export function toJpeg(node: Node, options?: Options): Promise; export function toSvg(node: Node, options?: Options): Promise; export function toBlob(node: Node, options?: Options): Promise; export function toPixelData(node: Node, options?: Options): Promise; const domtoimage: { toPng: typeof toPng; toJpeg: typeof toJpeg; toSvg: typeof toSvg; toBlob: typeof toBlob; toPixelData: typeof toPixelData; }; export default domtoimage; } ================================================ FILE: packages/fossflow-lib/src/types/index.ts ================================================ export * from './common'; export * from './model'; export * from './scene'; export * from './ui'; export * from './interactions'; export * from './isoflowProps'; ================================================ FILE: packages/fossflow-lib/src/types/interactions.ts ================================================ import { ModelStore, UiStateStore, Size } from 'src/types'; import { useScene } from 'src/hooks/useScene'; export interface State { model: ModelStore; scene: ReturnType; uiState: UiStateStore; rendererRef: HTMLElement; rendererSize: Size; isRendererInteraction: boolean; } export type ModeActionsAction = (state: State) => void; export type ModeActions = { entry?: ModeActionsAction; exit?: ModeActionsAction; mousemove?: ModeActionsAction; mousedown?: ModeActionsAction; mouseup?: ModeActionsAction; }; ================================================ FILE: packages/fossflow-lib/src/types/isoflowProps.ts ================================================ import type { EditorModeEnum, MainMenuOptions } from './common'; import type { Model } from './model'; import type { RendererProps } from './rendererProps'; export type InitialData = Model & { fitToView?: boolean; view?: string; }; export interface LocaleProps { common: { exampleText: string; }; mainMenu: { undo: string; redo: string; open: string; exportJson: string; exportCompactJson: string; exportImage: string; clearCanvas: string; settings: string; gitHub: string; }; helpDialog: { title: string; close: string; keyboardShortcuts: string; mouseInteractions: string; action: string; shortcut: string; method: string; description: string; note: string; noteContent: string; // Keyboard shortcuts undoAction: string; undoDescription: string; redoAction: string; redoDescription: string; redoAltAction: string; redoAltDescription: string; helpAction: string; helpDescription: string; zoomInAction: string; zoomInShortcut: string; zoomInDescription: string; zoomOutAction: string; zoomOutShortcut: string; zoomOutDescription: string; panCanvasAction: string; panCanvasShortcut: string; panCanvasDescription: string; contextMenuAction: string; contextMenuShortcut: string; contextMenuDescription: string; // Mouse interactions selectToolAction: string; selectToolShortcut: string; selectToolDescription: string; panToolAction: string; panToolShortcut: string; panToolDescription: string; addItemAction: string; addItemShortcut: string; addItemDescription: string; drawRectangleAction: string; drawRectangleShortcut: string; drawRectangleDescription: string; createConnectorAction: string; createConnectorShortcut: string; createConnectorDescription: string; addTextAction: string; addTextShortcut: string; addTextDescription: string; }; connectorHintTooltip: { tipCreatingConnectors: string; tipConnectorTools: string; clickInstructionStart: string; clickInstructionMiddle: string; clickInstructionEnd: string; nowClickTarget: string; dragStart: string; dragEnd: string; rerouteStart: string; rerouteMiddle: string; rerouteEnd: string; }; lassoHintTooltip: { tipLasso: string; tipFreehandLasso: string; lassoDragStart: string; lassoDragEnd: string; freehandDragStart: string; freehandDragMiddle: string; freehandDragEnd: string; freehandComplete: string; moveStart: string; moveMiddle: string; moveEnd: string; }; importHintTooltip: { title: string; instructionStart: string; menuButton: string; instructionMiddle: string; openButton: string; instructionEnd: string; }; connectorRerouteTooltip: { title: string; instructionStart: string; instructionSelect: string; instructionMiddle: string; instructionClick: string; instructionAnd: string; instructionDrag: string; instructionEnd: string; }; connectorEmptySpaceTooltip: { message: string; instruction: string; }; settings: { zoom: { description: string; zoomToCursor: string; zoomToCursorDesc: string; }; hotkeys: { title: string; profile: string; profileQwerty: string; profileSmnrct: string; profileNone: string; tool: string; hotkey: string; toolSelect: string; toolPan: string; toolAddItem: string; toolRectangle: string; toolConnector: string; toolText: string; note: string; }; pan: { title: string; mousePanOptions: string; emptyAreaClickPan: string; middleClickPan: string; rightClickPan: string; ctrlClickPan: string; altClickPan: string; keyboardPanOptions: string; arrowKeys: string; wasdKeys: string; ijklKeys: string; keyboardPanSpeed: string; note: string; }; connector: { title: string; connectionMode: string; clickMode: string; clickModeDesc: string; dragMode: string; dragModeDesc: string; note: string; }; iconPacks: { title: string; lazyLoading: string; lazyLoadingDesc: string; availablePacks: string; coreIsoflow: string; alwaysEnabled: string; awsPack: string; gcpPack: string; azurePack: string; kubernetesPack: string; loading: string; loaded: string; notLoaded: string; iconCount: string; lazyLoadingDisabledNote: string; note: string; }; }; lazyLoadingWelcome: { title: string; message: string; configPath: string; configPath2: string; canDisable: string; signature: string; }; // other namespaces can be added here } export interface IconPackManagerProps { lazyLoadingEnabled: boolean; onToggleLazyLoading: (enabled: boolean) => void; packInfo: Array<{ name: string; displayName: string; loaded: boolean; loading: boolean; error: string | null; iconCount: number; }>; enabledPacks: string[]; onTogglePack: (packName: string, enabled: boolean) => void; } export interface IsoflowProps { initialData?: InitialData; mainMenuOptions?: MainMenuOptions; onModelUpdated?: (Model: Model) => void; width?: number | string; height?: number | string; enableDebugTools?: boolean; editorMode?: keyof typeof EditorModeEnum; renderer?: RendererProps; locale?: LocaleProps; iconPackManager?: IconPackManagerProps; } ================================================ FILE: packages/fossflow-lib/src/types/model.ts ================================================ import z from 'zod'; import { iconSchema, modelSchema, modelItemSchema, modelItemsSchema, viewsSchema, viewSchema, viewItemSchema, connectorSchema, connectorLabelSchema, iconsSchema, colorsSchema, anchorSchema, textBoxSchema, rectangleSchema, connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas'; import { StoreApi } from 'zustand'; export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas'; export type Model = z.infer; export type ModelItems = z.infer; export type Icon = z.infer; export type Icons = z.infer; export type Colors = z.infer; export type ModelItem = z.infer; export type Views = z.infer; export type View = z.infer; export type ViewItem = z.infer; export type ConnectorStyle = keyof typeof connectorStyleOptions; export type ConnectorLineType = keyof typeof connectorLineTypeOptions; export type ConnectorAnchor = z.infer; export type ConnectorLabel = z.infer; export type Connector = z.infer; export type TextBox = z.infer; export type Rectangle = z.infer; export type ModelStore = Model & { actions: { get: StoreApi['getState']; set: StoreApi['setState']; }; }; export type { ModelStoreWithHistory, HistoryState as ModelHistoryState } from 'src/stores/modelStore'; export type { SceneStoreWithHistory, SceneHistoryState } from 'src/stores/sceneStore'; ================================================ FILE: packages/fossflow-lib/src/types/rendererProps.ts ================================================ export interface RendererProps { showGrid?: boolean; backgroundColor?: string; expandLabels?: boolean; } ================================================ FILE: packages/fossflow-lib/src/types/scene.ts ================================================ import { StoreApi } from 'zustand'; import type { Coords, Rect, Size } from './common'; export const tileOriginOptions = { CENTER: 'CENTER', TOP: 'TOP', BOTTOM: 'BOTTOM', LEFT: 'LEFT', RIGHT: 'RIGHT' } as const; export type TileOrigin = keyof typeof tileOriginOptions; export const ItemReferenceTypeOptions = { ITEM: 'ITEM', CONNECTOR: 'CONNECTOR', CONNECTOR_ANCHOR: 'CONNECTOR_ANCHOR', TEXTBOX: 'TEXTBOX', RECTANGLE: 'RECTANGLE' } as const; export type ItemReferenceType = keyof typeof ItemReferenceTypeOptions; export type ItemReference = { type: ItemReferenceType; id: string; }; export type ConnectorPath = { tiles: Coords[]; rectangle: Rect; }; export interface SceneConnector { path: ConnectorPath; } export interface SceneTextBox { size: Size; } export interface Scene { connectors: { [key: string]: SceneConnector; }; textBoxes: { [key: string]: SceneTextBox; }; } export type SceneStore = Scene & { actions: { get: StoreApi['getState']; set: StoreApi['setState']; }; }; ================================================ FILE: packages/fossflow-lib/src/types/ui.ts ================================================ import { Coords, EditorModeEnum, MainMenuOptions } from './common'; import { Icon } from './model'; import { ItemReference } from './scene'; import { HotkeyProfile } from 'src/config/hotkeys'; import { PanSettings } from 'src/config/panSettings'; import { ZoomSettings } from 'src/config/zoomSettings'; import { LabelSettings } from 'src/config/labelSettings'; import { IconPackManagerProps } from './isoflowProps'; interface AddItemControls { type: 'ADD_ITEM'; } export type ItemControls = ItemReference | AddItemControls; export interface Mouse { position: { screen: Coords; tile: Coords; }; mousedown: { screen: Coords; tile: Coords; } | null; delta: { screen: Coords; tile: Coords; } | null; } // Mode types export interface InteractionsDisabled { type: 'INTERACTIONS_DISABLED'; showCursor: boolean; } export interface CursorMode { type: 'CURSOR'; showCursor: boolean; mousedownItem: ItemReference | null; } export interface DragItemsMode { type: 'DRAG_ITEMS'; showCursor: boolean; items: ItemReference[]; isInitialMovement: Boolean; } export interface PanMode { type: 'PAN'; showCursor: boolean; } export interface PlaceIconMode { type: 'PLACE_ICON'; showCursor: boolean; id: string | null; } export interface ConnectorMode { type: 'CONNECTOR'; showCursor: boolean; id: string | null; // For click-based connection mode startAnchor?: { tile?: Coords; itemId?: string; }; isConnecting?: boolean; } export interface DrawRectangleMode { type: 'RECTANGLE.DRAW'; showCursor: boolean; id: string | null; } export const AnchorPositionOptions = { BOTTOM_LEFT: 'BOTTOM_LEFT', BOTTOM_RIGHT: 'BOTTOM_RIGHT', TOP_RIGHT: 'TOP_RIGHT', TOP_LEFT: 'TOP_LEFT' } as const; export type AnchorPosition = keyof typeof AnchorPositionOptions; export interface TransformRectangleMode { type: 'RECTANGLE.TRANSFORM'; showCursor: boolean; id: string; selectedAnchor: AnchorPosition | null; } export interface TextBoxMode { type: 'TEXTBOX'; showCursor: boolean; id: string | null; } export interface LassoMode { type: 'LASSO'; showCursor: boolean; selection: { startTile: Coords; endTile: Coords; items: ItemReference[]; } | null; isDragging: boolean; } export interface FreehandLassoMode { type: 'FREEHAND_LASSO'; showCursor: boolean; path: Coords[]; // Screen coordinates of the drawn path selection: { pathTiles: Coords[]; // Tile coordinates of the path points items: ItemReference[]; } | null; isDragging: boolean; } export type Mode = | InteractionsDisabled | CursorMode | PanMode | PlaceIconMode | ConnectorMode | DrawRectangleMode | TransformRectangleMode | DragItemsMode | TextBoxMode | LassoMode | FreehandLassoMode; // End mode types export interface Scroll { position: Coords; offset: Coords; } export interface IconCollectionState { id?: string; isExpanded: boolean; } export type IconCollectionStateWithIcons = IconCollectionState & { icons: Icon[]; }; export const DialogTypeEnum = { EXPORT_IMAGE: 'EXPORT_IMAGE', HELP: 'HELP', SETTINGS: 'SETTINGS' } as const; export interface ContextMenu { type: 'ITEM' | 'EMPTY'; item?: ItemReference; tile: Coords; } export type ConnectorInteractionMode = 'click' | 'drag'; export interface UiState { view: string; mainMenuOptions: MainMenuOptions; editorMode: keyof typeof EditorModeEnum; iconCategoriesState: IconCollectionState[]; mode: Mode; dialog: keyof typeof DialogTypeEnum | null; isMainMenuOpen: boolean; itemControls: ItemControls | null; contextMenu: ContextMenu | null; zoom: number; scroll: Scroll; mouse: Mouse; rendererEl: HTMLDivElement | null; enableDebugTools: boolean; hotkeyProfile: HotkeyProfile; panSettings: PanSettings; zoomSettings: ZoomSettings; labelSettings: LabelSettings; connectorInteractionMode: ConnectorInteractionMode; expandLabels: boolean; iconPackManager: IconPackManagerProps | null; } export interface UiStateActions { setView: (view: string) => void; setMainMenuOptions: (options: MainMenuOptions) => void; setEditorMode: (mode: keyof typeof EditorModeEnum) => void; setIconCategoriesState: (iconCategoriesState: IconCollectionState[]) => void; resetUiState: () => void; setMode: (mode: Mode) => void; incrementZoom: () => void; decrementZoom: () => void; setIsMainMenuOpen: (isOpen: boolean) => void; setDialog: (dialog: keyof typeof DialogTypeEnum | null) => void; setZoom: (zoom: number) => void; setScroll: (scroll: Scroll) => void; setItemControls: (itemControls: ItemControls | null) => void; setContextMenu: (contextMenu: ContextMenu | null) => void; setMouse: (mouse: Mouse) => void; setRendererEl: (el: HTMLDivElement) => void; setEnableDebugTools: (enabled: boolean) => void; setHotkeyProfile: (profile: HotkeyProfile) => void; setPanSettings: (settings: PanSettings) => void; setZoomSettings: (settings: ZoomSettings) => void; setLabelSettings: (settings: LabelSettings) => void; setConnectorInteractionMode: (mode: ConnectorInteractionMode) => void; setExpandLabels: (expand: boolean) => void; setIconPackManager: (iconPackManager: IconPackManagerProps | null) => void; } export type UiStateStore = UiState & { actions: UiStateActions; }; ================================================ FILE: packages/fossflow-lib/src/utils/CoordsUtils.ts ================================================ import { Coords } from 'src/types'; export class CoordsUtils { static isEqual(base: Coords, operand: Coords) { return base.x === operand.x && base.y === operand.y; } static subtract(base: Coords, operand: Coords): Coords { return { x: base.x - operand.x, y: base.y - operand.y }; } static add(base: Coords, operand: Coords): Coords { return { x: base.x + operand.x, y: base.y + operand.y }; } static multiply(base: Coords, operand: number): Coords { return { x: base.x * operand, y: base.y * operand }; } static toString(coords: Coords) { return `x: ${coords.x}, y: ${coords.y}`; } static sum(coords: Coords) { return coords.x + coords.y; } static zero() { return { x: 0, y: 0 }; } } ================================================ FILE: packages/fossflow-lib/src/utils/SizeUtils.ts ================================================ import { Size } from 'src/types'; export class SizeUtils { static isEqual(base: Size, operand: Size) { return base.width === operand.width && base.height === operand.height; } static subtract(base: Size, operand: Size): Size { return { width: base.width - operand.width, height: base.height - operand.height }; } static add(base: Size, operand: Size): Size { return { width: base.width + operand.width, height: base.height + operand.height }; } static multiply(base: Size, operand: number): Size { return { width: base.width * operand, height: base.height * operand }; } static toString(size: Size) { return `width: ${size.width}, height: ${size.height}`; } static zero() { return { width: 0, y: 0 }; } } ================================================ FILE: packages/fossflow-lib/src/utils/__tests__/common.test.ts ================================================ import { clamp } from '../common'; describe('Tests common utilities', () => { test('clamp() works correctly', () => { const clampNoChange = clamp(5, 0, 10); const clampMin = clamp(5, 6, 10); const clampMax = clamp(5, 0, 3); const clampDraw1 = clamp(5, 5, 10); const clampDraw2 = clamp(5, 0, 5); expect(clampNoChange).toBe(5); expect(clampMin).toBe(6); expect(clampMax).toBe(3); expect(clampDraw1).toBe(5); expect(clampDraw2).toBe(5); }); }); ================================================ FILE: packages/fossflow-lib/src/utils/__tests__/immer.test.ts ================================================ import { produce } from 'immer'; const createItem = (x: number, y: number) => { return { x, y }; }; // Although we don't normally test third party libraries, // this is useful to explore the behaviour of immer describe('Tests immer', () => { test('Array equivalence without immer', () => { const arr = [createItem(0, 0), createItem(1, 1)]; const newArr = [createItem(0, 0), createItem(2, 2)]; expect(arr[0]).not.toBe(newArr[0]); }); test('Array equivalence with immer', () => { const arr = [createItem(0, 0), createItem(1, 1)]; const newArr = produce(arr, (draft) => { draft[1] = createItem(2, 2); }); expect(arr[0]).toBe(newArr[0]); }); }); ================================================ FILE: packages/fossflow-lib/src/utils/__tests__/renderer.test.ts ================================================ import { Coords, Size, Scroll } from 'src/types'; import { CoordsUtils, SizeUtils } from 'src/utils'; import { PROJECTED_TILE_SIZE } from 'src/config'; import { getGridSubset, isWithinBounds, screenToIso } from '../renderer'; const getRendererSize = (tileSize: Size, zoom: number = 1): Size => { const projectedTileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom); return { width: projectedTileSize.width * tileSize.width, height: projectedTileSize.height * tileSize.height }; }; const getScroll = (coords: Coords): Scroll => { return { position: coords, offset: CoordsUtils.zero() }; }; describe('Tests renderer utils', () => { test('getGridSubset() works correctly', () => { const gridSubset = getGridSubset([ { x: 5, y: 5 }, { x: 7, y: 7 } ]); expect(gridSubset).toEqual([ { x: 5, y: 5 }, { x: 5, y: 6 }, { x: 5, y: 7 }, { x: 6, y: 5 }, { x: 6, y: 6 }, { x: 6, y: 7 }, { x: 7, y: 5 }, { x: 7, y: 6 }, { x: 7, y: 7 } ]); }); test('isWithinBounds() works correctly', () => { const bounds: Coords[] = [ { x: 4, y: 4 }, { x: 6, y: 6 } ]; const withinBounds = isWithinBounds({ x: 5, y: 5 }, bounds); const onBorder = isWithinBounds({ x: 4, y: 4 }, bounds); const outsideBounds = isWithinBounds({ x: 3, y: 3 }, bounds); expect(withinBounds).toBe(true); expect(onBorder).toBe(true); expect(outsideBounds).toBe(false); }); test('screenToIso() works correctly when mouse is at center of project', () => { const zoom = 1; const rendererSize = getRendererSize({ width: 10, height: 10 }, zoom); const scroll = getScroll({ x: 0, y: 0 }); const tile = screenToIso({ mouse: { x: rendererSize.width / 2, y: rendererSize.height / 2 }, zoom, scroll, rendererSize }); expect(tile).toEqual({ x: 0, y: -0 }); }); test('screenToIso() works correctly when mouse is at topLeft corner of project', () => { const zoom = 1; const rendererSize = getRendererSize({ width: 10, height: 10 }, zoom); const scroll = getScroll({ x: 0, y: 0 }); const tile = screenToIso({ mouse: { x: 0, y: 0 }, zoom, scroll, rendererSize }); expect(tile).toEqual({ x: 0, y: 10 }); }); test('screenToIso() works correctly when mouse is at topLeft corner of project and zoom is 0.5', () => { const zoom = 0.5; const rendererSize = getRendererSize({ width: 10, height: 10 }, zoom); const scroll = getScroll({ x: 0, y: 0 }); const tile = screenToIso({ mouse: { x: 0, y: 0 }, zoom, scroll, rendererSize }); expect(tile).toEqual({ x: 0, y: 10 }); }); test('screenToIso() works correctly when mouse is at center of project and zoom is 0.5 and screen is halfway scrolled', () => { const zoom = 1; const rendererSize = getRendererSize({ width: 10, height: 10 }, zoom); const scroll = getScroll({ x: rendererSize.width / 2, y: rendererSize.height / 2 }); const tile = screenToIso({ mouse: { x: rendererSize.width / 2, y: rendererSize.height / 2 }, zoom, scroll, rendererSize }); expect(tile).toEqual({ x: 0, y: 10 }); }); }); ================================================ FILE: packages/fossflow-lib/src/utils/common.ts ================================================ import chroma from 'chroma-js'; import { Icon, EditorModeEnum, Mode } from 'src/types'; import { v4 as uuid } from 'uuid'; export const generateId = () => { return uuid(); }; export const clamp = (num: number, min: number, max: number) => { return Math.max(Math.min(num, max), min); }; export const getRandom = (min: number, max: number) => { return Math.floor(Math.random() * (max - min) + min); }; export const roundToOneDecimalPlace = (num: number) => { return Math.round(num * 10) / 10; }; export const roundToTwoDecimalPlaces = (num: number) => { return Math.round(num * 100) / 100; }; interface GetColorVariantOpts { alpha?: number; grade?: number; } export const getColorVariant = ( color: string, variant: 'light' | 'dark', { alpha = 1, grade = 1 }: GetColorVariantOpts ) => { switch (variant) { case 'light': return chroma(color).brighten(grade).alpha(alpha).css(); case 'dark': return chroma(color).darken(grade).saturate(grade).alpha(alpha).css(); default: return chroma(color).alpha(alpha).css(); } }; export const setWindowCursor = (cursor: string) => { window.document.body.style.cursor = cursor; }; export const toPx = (value: number | string) => { return `${value}px`; }; export const categoriseIcons = (icons: Icon[]) => { const categories: { name?: string; icons: Icon[] }[] = []; icons.forEach((icon) => { const collection = categories.find((cat) => { return cat.name === icon.collection; }); if (!collection) { categories.push({ name: icon.collection, icons: [icon] }); } else { collection.icons.push(icon); } }); return categories; }; export const getStartingMode = ( editorMode: keyof typeof EditorModeEnum ): Mode => { switch (editorMode) { case 'EDITABLE': return { type: 'CURSOR', showCursor: true, mousedownItem: null }; case 'EXPLORABLE_READONLY': return { type: 'PAN', showCursor: false }; case 'NON_INTERACTIVE': return { type: 'INTERACTIONS_DISABLED', showCursor: false }; default: throw new Error('Invalid editor mode.'); } }; export function getItemByIdOrThrow( values: T[], id: string ): { value: T; index: number } { const index = values.findIndex((val) => { return val.id === id; }); if (index === -1) { throw new Error(`Item with id "${id}" not found.`); } return { value: values[index], index }; } export function getItemById( values: T[], id: string ): { value: T; index: number } | null { const index = values.findIndex((val) => { return val.id === id; }); if (index === -1) { return null; } return { value: values[index], index }; } export function getItemByIndexOrThrow(items: T[], index: number): T { const item = items[index]; if (!item) { throw new Error(`Item with index "${index}" not found.`); } return item; } ================================================ FILE: packages/fossflow-lib/src/utils/connectorLabels.ts ================================================ import { Connector, ConnectorLabel } from 'src/types'; import { generateId } from './common'; /** * Migrates legacy connector labels (description, startLabel, endLabel) * to the new flexible labels array format */ export const migrateLegacyLabels = (connector: Connector): ConnectorLabel[] => { const labels: ConnectorLabel[] = []; // Convert startLabel to 10% position if (connector.startLabel) { labels.push({ id: generateId(), text: connector.startLabel, position: 10, height: connector.startLabelHeight, line: '1' }); } // Convert description (center label) to 50% position if (connector.description) { labels.push({ id: generateId(), text: connector.description, position: 50, height: connector.centerLabelHeight, line: '1' }); } // Convert endLabel to 90% position if (connector.endLabel) { labels.push({ id: generateId(), text: connector.endLabel, position: 90, height: connector.endLabelHeight, line: '1' }); } return labels; }; /** * Gets all labels for a connector, migrating legacy labels if needed */ export const getConnectorLabels = (connector: Connector): ConnectorLabel[] => { // If connector already has new-style labels, use them if (connector.labels && connector.labels.length > 0) { return connector.labels; } // Otherwise, migrate legacy labels return migrateLegacyLabels(connector); }; /** * Calculates the actual tile position along the connector path for a given percentage */ export const getLabelTileIndex = ( pathLength: number, position: number ): number => { if (pathLength === 0) return 0; const index = Math.round((position / 100) * (pathLength - 1)); return Math.max(0, Math.min(index, pathLength - 1)); }; ================================================ FILE: packages/fossflow-lib/src/utils/exportOptions.ts ================================================ import domtoimage from 'dom-to-image-more'; import FileSaver from 'file-saver'; import { Model, Size } from '../types'; import { icons as availableIcons } from '../examples/initialData'; export const generateGenericFilename = (extension: string) => { return `fossflow-export-${new Date().toISOString()}.${extension}`; }; export const base64ToBlob = ( base64: string, contentType: string, sliceSize = 512 ) => { const byteCharacters = atob(base64); const byteArrays = []; for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { const slice = byteCharacters.slice(offset, offset + sliceSize); const byteNumbers = new Array(slice.length); for (let i = 0; i < slice.length; i += 1) { byteNumbers[i] = slice.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); byteArrays.push(byteArray); } const blob = new Blob(byteArrays, { type: contentType }); return blob; }; export const downloadFile = (data: Blob, filename: string) => { FileSaver.saveAs(data, filename); }; export const transformToCompactFormat = (model: Model) => { const { items, views, icons, title } = model; // Compact format: ultra-minimal for LLM generation const compactItems = items.map((item, index) => [ item.name.substring(0, 30), // Truncated name item.icon || 'block', // Icon reference only (no base64) item.description?.substring(0, 100) || '' // Truncated description ]); const compactViews = views.map((view) => { const positions = view.items.map((viewItem) => { const itemIndex = items.findIndex(item => item.id === viewItem.id); return [itemIndex, viewItem.tile.x, viewItem.tile.y]; }); const connections = view.connectors?.map((connector) => { const fromIndex = items.findIndex(item => item.id === connector.anchors[0]?.ref.item); const toIndex = items.findIndex(item => item.id === connector.anchors[connector.anchors.length - 1]?.ref.item); return [fromIndex, toIndex]; }).filter(conn => conn[0] !== -1 && conn[1] !== -1) || []; return [positions, connections]; }); return { t: title?.substring(0, 40) || 'Untitled', i: compactItems, v: compactViews, _: { f: 'compact', v: '1.0' } }; }; export const transformFromCompactFormat = (compactModel: any): Model => { const { t, i, v, _ } = compactModel; // Restore from compact format const fullItems = i.map((item: any, index: number) => ({ id: `item_${index}`, name: item[0], icon: item[1], description: item[2] || '' // Restore description if available })); // Resolve icons from the internal icon library const iconSet = new Set(); i.forEach((item: any) => { if (item[1]) iconSet.add(item[1]); }); const fullIcons = Array.from(iconSet).map(iconName => { // Find the icon in the available icons library const existingIcon = availableIcons.find(icon => icon.id === iconName || icon.name === iconName); if (existingIcon) { // Use the existing icon data with proper URL return { id: iconName, name: existingIcon.name, url: existingIcon.url, collection: existingIcon.collection, isIsometric: existingIcon.isIsometric ?? true }; } else { // Fallback for unknown icons return { id: iconName, name: iconName, url: '', // App will use default icon isIsometric: true }; } }); const fullViews = v.map((view: any, viewIndex: number) => { const [positions, connections] = view; const viewItems = positions.map((pos: any) => { const [itemIndex, x, y] = pos; return { id: `item_${itemIndex}`, tile: { x, y }, labelHeight: 80 }; }); const connectors = connections.map((conn: any, connIndex: number) => { const [fromIndex, toIndex] = conn; return { id: `conn_${viewIndex}_${connIndex}`, color: 'color1', anchors: [ { id: `a_${viewIndex}_${connIndex}_0`, ref: { item: `item_${fromIndex}` } }, { id: `a_${viewIndex}_${connIndex}_1`, ref: { item: `item_${toIndex}` } } ], width: 10, description: '', style: 'SOLID' }; }); return { id: `view_${viewIndex}`, name: `View ${viewIndex + 1}`, items: viewItems, connectors, rectangles: [], textBoxes: [] }; }); return { title: t, version: '1.0', items: fullItems, views: fullViews, icons: fullIcons, colors: [{ id: 'color1', value: '#a5b8f3' }] }; }; export const exportAsJSON = (model: Model) => { const data = new Blob([JSON.stringify(model)], { type: 'application/json;charset=utf-8' }); downloadFile(data, generateGenericFilename('json')); }; export const exportAsCompactJSON = (model: Model) => { const compactModel = transformToCompactFormat(model); const data = new Blob([JSON.stringify(compactModel)], { type: 'application/json;charset=utf-8' }); downloadFile(data, generateGenericFilename('compact.json')); }; export const exportAsImage = async ( el: HTMLDivElement, size?: Size, scale: number = 1, bgcolor: string = '#ffffff' ) => { // Calculate scaled dimensions const width = size ? size.width * scale : el.clientWidth * scale; const height = size ? size.height * scale : el.clientHeight * scale; // dom-to-image-more is a better maintained fork const options = { width, height, cacheBust: true, bgcolor, quality: 1.0, // Apply CSS transform for high-quality scaling style: scale !== 1 ? { transform: `scale(${scale})`, transformOrigin: 'top left' } : undefined }; try { const imageData = await domtoimage.toPng(el, options); return imageData; } catch (error) { console.error('Export failed, trying fallback method:', error); // Fallback: try with minimal options return await domtoimage.toPng(el, { width, height, cacheBust: true, bgcolor }); } }; export const exportAsSVG = async ( el: HTMLDivElement, size?: Size, bgcolor: string = '#ffffff' ) => { const width = size ? size.width : el.clientWidth; const height = size ? size.height : el.clientHeight; const options = { width, height, cacheBust: true, bgcolor, quality: 1.0 }; try { const svgData = await domtoimage.toSvg(el, options); return svgData; } catch (error) { console.error('SVG export failed, trying fallback method:', error); // Fallback: try with minimal options return await domtoimage.toSvg(el, { width, height, cacheBust: true, bgcolor }); } }; ================================================ FILE: packages/fossflow-lib/src/utils/findNearestUnoccupiedTile.ts ================================================ import { Coords } from 'src/types'; import { useScene } from 'src/hooks/useScene'; import { getItemAtTile } from './renderer'; /** * Finds the nearest unoccupied tile to the target tile using a spiral search pattern * @param targetTile - The desired tile position * @param scene - The current scene * @param maxDistance - Maximum search distance (default: 10) * @returns The nearest unoccupied tile, or null if none found within maxDistance */ export const findNearestUnoccupiedTile = ( targetTile: Coords, scene: ReturnType, maxDistance: number = 10 ): Coords | null => { // Check if the target tile itself is unoccupied const itemAtTarget = getItemAtTile({ tile: targetTile, scene }); if (!itemAtTarget || itemAtTarget.type !== 'ITEM') { return targetTile; } // Spiral search pattern: right, down, left, up const directions = [ { x: 1, y: 0 }, // right { x: 0, y: 1 }, // down { x: -1, y: 0 }, // left { x: 0, y: -1 } // up ]; // Search in expanding rings around the target for (let distance = 1; distance <= maxDistance; distance++) { // Start from the top-left of the ring let currentTile = { x: targetTile.x - distance, y: targetTile.y - distance }; // Check all tiles in this ring for (let side = 0; side < 4; side++) { const direction = directions[side]; const sideLength = distance * 2; for (let step = 0; step < sideLength; step++) { // Move to the next tile on this side of the ring currentTile = { x: currentTile.x + direction.x, y: currentTile.y + direction.y }; // Check if this tile is within bounds and unoccupied const itemAtTile = getItemAtTile({ tile: currentTile, scene }); if (!itemAtTile || itemAtTile.type !== 'ITEM') { return currentTile; } } } } // No unoccupied tile found within maxDistance return null; }; /** * Finds the nearest unoccupied tile for multiple items being placed/moved * Ensures all items can be placed without overlapping * @param items - Array of items with their target tiles * @param scene - The current scene * @param excludeIds - IDs of items to exclude from occupation check (e.g., items being moved) * @returns Array of nearest unoccupied tiles for each item, or null if cannot place all */ export const findNearestUnoccupiedTilesForGroup = ( items: { id: string; targetTile: Coords }[], scene: ReturnType, excludeIds: string[] = [] ): Coords[] | null => { const result: Coords[] = []; const occupiedTiles = new Set(); // Add existing items to occupied tiles (excluding the ones being moved) scene.items.forEach(item => { if (!excludeIds.includes(item.id)) { occupiedTiles.add(`${item.tile.x},${item.tile.y}`); } }); // Find unoccupied tiles for each item for (const item of items) { let foundTile: Coords | null = null; const targetKey = `${item.targetTile.x},${item.targetTile.y}`; // Check if target is available if (!occupiedTiles.has(targetKey)) { foundTile = item.targetTile; } else { // Search for nearest unoccupied tile for (let distance = 1; distance <= 10; distance++) { // Check tiles in a square ring at this distance for (let dx = -distance; dx <= distance; dx++) { for (let dy = -distance; dy <= distance; dy++) { // Only check tiles on the ring perimeter if (Math.abs(dx) === distance || Math.abs(dy) === distance) { const checkTile = { x: item.targetTile.x + dx, y: item.targetTile.y + dy }; const checkKey = `${checkTile.x},${checkTile.y}`; if (!occupiedTiles.has(checkKey)) { const itemAtTile = getItemAtTile({ tile: checkTile, scene }); if (!itemAtTile || itemAtTile.type !== 'ITEM' || excludeIds.includes(itemAtTile.id)) { foundTile = checkTile; break; } } } } if (foundTile) break; } if (foundTile) break; } } if (!foundTile) { return null; // Cannot place all items } result.push(foundTile); occupiedTiles.add(`${foundTile.x},${foundTile.y}`); } return result; }; ================================================ FILE: packages/fossflow-lib/src/utils/index.ts ================================================ export * from './CoordsUtils'; export * from './SizeUtils'; export * from './common'; export * from './pathfinder'; export * from './renderer'; export * from './exportOptions'; export * from './model'; export * from './findNearestUnoccupiedTile'; export * from './pointInPolygon'; export * from './connectorLabels'; ================================================ FILE: packages/fossflow-lib/src/utils/model.ts ================================================ import { produce } from 'immer'; import { Model, ModelStore } from 'src/types'; import { validateModel } from 'src/schemas/validation'; import { getItemByIdOrThrow } from './common'; export const fixModel = (model: Model): Model => { const issues = validateModel(model); return issues.reduce((acc, issue) => { if (issue.type === 'INVALID_MODEL_TO_ICON_REF') { return produce(acc, (draft) => { const { index: itemIndex } = getItemByIdOrThrow( draft.items, issue.params.modelItem ); draft.items[itemIndex].icon = undefined; }); } if (issue.type === 'CONNECTOR_TOO_FEW_ANCHORS') { return produce(acc, (draft) => { const view = getItemByIdOrThrow(draft.views, issue.params.view); const connector = getItemByIdOrThrow( view.value.connectors ?? [], issue.params.connector ); draft.views[view.index].connectors?.splice(connector.index, 1); }); } if (issue.type === 'INVALID_ANCHOR_TO_ANCHOR_REF') { return produce(acc, (draft) => { const view = getItemByIdOrThrow(draft.views, issue.params.view); const connector = getItemByIdOrThrow( view.value.connectors ?? [], issue.params.connector ); const anchor = getItemByIdOrThrow( connector.value.anchors, issue.params.srcAnchor ); connector.value.anchors.splice(anchor.index, 1); }); } return acc; }, model); }; export const modelFromModelStore = (modelStore: ModelStore): Model => { return { version: modelStore.version, title: modelStore.title, description: modelStore.description, colors: modelStore.colors, icons: modelStore.icons, items: modelStore.items, views: modelStore.views }; }; ================================================ FILE: packages/fossflow-lib/src/utils/pathfinder.ts ================================================ import PF from 'pathfinding'; import { Size, Coords } from 'src/types'; interface Args { gridSize: Size; from: Coords; to: Coords; } export const findPath = ({ gridSize, from, to }: Args): Coords[] => { const grid = new PF.Grid(gridSize.width, gridSize.height); const finder = new PF.AStarFinder({ heuristic: PF.Heuristic.manhattan, diagonalMovement: PF.DiagonalMovement.Always }); const path = finder.findPath(from.x, from.y, to.x, to.y, grid); const pathTiles = path.map((tile) => { return { x: tile[0], y: tile[1] }; }); return pathTiles; }; ================================================ FILE: packages/fossflow-lib/src/utils/pointInPolygon.ts ================================================ import { Coords } from 'src/types'; /** * Ray casting algorithm to determine if a point is inside a polygon * @param point - The point to check (tile coordinates) * @param polygon - Array of vertices defining the polygon (tile coordinates) * @returns true if the point is inside the polygon */ export const isPointInPolygon = (point: Coords, polygon: Coords[]): boolean => { if (polygon.length < 3) return false; let inside = false; const x = point.x; const y = point.y; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x; const yi = polygon[i].y; const xj = polygon[j].x; const yj = polygon[j].y; const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; }; /** * Convert an array of screen coordinates to tile coordinates using the screenToIso function */ export const screenPathToTilePath = ( screenPath: Coords[], screenToIsoFn: (coords: Coords) => Coords ): Coords[] => { return screenPath.map((point) => screenToIsoFn(point)); }; /** * Create a smooth SVG path from a series of points using quadratic curves * @param points - Array of screen coordinates * @returns SVG path string */ export const createSmoothPath = (points: Coords[]): string => { if (points.length < 2) return ''; let path = `M ${points[0].x},${points[0].y}`; // Use quadratic bezier curves for smooth lines for (let i = 1; i < points.length; i++) { const current = points[i]; const previous = points[i - 1]; // Calculate control point as midpoint const cpX = (previous.x + current.x) / 2; const cpY = (previous.y + current.y) / 2; if (i === 1) { // First segment - line to control point, then curve path += ` L ${cpX},${cpY}`; } else { // Subsequent segments - quadratic curve path += ` Q ${previous.x},${previous.y} ${cpX},${cpY}`; } } // Complete the curve to the last point const lastPoint = points[points.length - 1]; const secondLastPoint = points[points.length - 2]; path += ` Q ${secondLastPoint.x},${secondLastPoint.y} ${lastPoint.x},${lastPoint.y}`; // Close the path path += ' Z'; return path; }; ================================================ FILE: packages/fossflow-lib/src/utils/renderer.ts ================================================ import { produce } from 'immer'; import { UNPROJECTED_TILE_SIZE, PROJECTED_TILE_SIZE, ZOOM_INCREMENT, MAX_ZOOM, MIN_ZOOM, TEXTBOX_PADDING, CONNECTOR_SEARCH_OFFSET, DEFAULT_FONT_FAMILY, TEXTBOX_DEFAULTS, TEXTBOX_FONT_WEIGHT, PROJECT_BOUNDING_BOX_PADDING } from 'src/config'; import { Coords, TileOrigin, Connector, Size, Scroll, Mouse, ConnectorAnchor, ItemReference, Rect, ProjectionOrientationEnum, BoundingBox, TextBox, SlimMouseEvent, View, AnchorPosition } from 'src/types'; import { CoordsUtils, SizeUtils, clamp, roundToTwoDecimalPlaces, findPath, toPx, getItemByIdOrThrow } from 'src/utils'; import { useScene } from 'src/hooks/useScene'; interface ScreenToIso { mouse: Coords; zoom: number; scroll: Scroll; rendererSize: Size; } // converts a mouse position to a tile position export const screenToIso = ({ mouse, zoom, scroll, rendererSize }: ScreenToIso) => { const projectedTileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom); const halfW = projectedTileSize.width / 2; const halfH = projectedTileSize.height / 2; const projectPosition = { x: -rendererSize.width * 0.5 + mouse.x - scroll.position.x, y: -rendererSize.height * 0.5 + mouse.y - scroll.position.y }; const tile = { x: Math.floor( (projectPosition.x + halfW) / projectedTileSize.width - projectPosition.y / projectedTileSize.height ), y: -Math.floor( (projectPosition.y + halfH) / projectedTileSize.height + projectPosition.x / projectedTileSize.width ) }; return tile; }; interface GetTilePosition { tile: Coords; origin?: TileOrigin; } export const getTilePosition = ({ tile, origin = 'CENTER' }: GetTilePosition) => { const halfW = PROJECTED_TILE_SIZE.width / 2; const halfH = PROJECTED_TILE_SIZE.height / 2; const position: Coords = { x: halfW * tile.x - halfW * tile.y, y: -(halfH * tile.x + halfH * tile.y) }; switch (origin) { case 'TOP': return CoordsUtils.add(position, { x: 0, y: -halfH }); case 'BOTTOM': return CoordsUtils.add(position, { x: 0, y: halfH }); case 'LEFT': return CoordsUtils.add(position, { x: -halfW, y: 0 }); case 'RIGHT': return CoordsUtils.add(position, { x: halfW, y: 0 }); case 'CENTER': default: return position; } }; type IsoToScreen = GetTilePosition & { rendererSize: Size; }; export const isoToScreen = ({ tile, origin, rendererSize }: IsoToScreen) => { const position = getTilePosition({ tile, origin }); return { x: position.x + rendererSize.width / 2, y: position.y + rendererSize.height / 2 }; }; export const sortByPosition = (tiles: Coords[]) => { const xSorted = [...tiles]; const ySorted = [...tiles]; xSorted.sort((a, b) => { return a.x - b.x; }); ySorted.sort((a, b) => { return a.y - b.y; }); const highest = { byX: xSorted[xSorted.length - 1], byY: ySorted[ySorted.length - 1] }; const lowest = { byX: xSorted[0], byY: ySorted[0] }; const lowX = lowest.byX.x; const highX = highest.byX.x; const lowY = lowest.byY.y; const highY = highest.byY.y; return { byX: xSorted, byY: ySorted, highest, lowest, lowX, lowY, highX, highY }; }; // Returns a complete set of tiles that form a grid area (takes in any number of tiles to use points to encapsulate) export const getGridSubset = (tiles: Coords[]) => { const { lowX, lowY, highX, highY } = sortByPosition(tiles); const subset = []; for (let x = lowX; x < highX + 1; x += 1) { for (let y = lowY; y < highY + 1; y += 1) { subset.push({ x, y }); } } return subset; }; export const isWithinBounds = (tile: Coords, bounds: Coords[]) => { const { lowX, lowY, highX, highY } = sortByPosition(bounds); return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY; }; // Returns the four corners of a grid that encapsulates all tiles // passed in (at least 1 tile needed) export const getBoundingBox = ( tiles: Coords[], offset: Coords = CoordsUtils.zero() ): BoundingBox => { const { lowX, lowY, highX, highY } = sortByPosition(tiles); return [ { x: lowX - offset.x, y: lowY - offset.y }, { x: highX + offset.x, y: lowY - offset.y }, { x: highX + offset.x, y: highY + offset.y }, { x: lowX - offset.x, y: highY + offset.y } ]; }; export const getBoundingBoxSize = (boundingBox: Coords[]): Size => { const { lowX, lowY, highX, highY } = sortByPosition(boundingBox); return { width: highX - lowX + 1, height: highY - lowY + 1 }; }; const isoProjectionBaseValues = [0.707, -0.409, 0.707, 0.409, 0, -0.816]; export const getIsoMatrix = ( orientation?: keyof typeof ProjectionOrientationEnum ) => { switch (orientation) { case ProjectionOrientationEnum.Y: return produce(isoProjectionBaseValues, (draft) => { draft[1] = -draft[1]; draft[2] = -draft[2]; }); case ProjectionOrientationEnum.X: default: return isoProjectionBaseValues; } }; export const getIsoProjectionCss = ( orientation?: keyof typeof ProjectionOrientationEnum ) => { const matrixTransformValues = getIsoMatrix(orientation); return `matrix(${matrixTransformValues.join(', ')})`; }; export const getTranslateCSS = (translate: Coords = { x: 0, y: 0 }) => { return `translate(${translate.x}px, ${translate.y}px)`; }; export const incrementZoom = (zoom: number) => { const newZoom = clamp(zoom + ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM); return roundToTwoDecimalPlaces(newZoom); }; export const decrementZoom = (zoom: number) => { const newZoom = clamp(zoom - ZOOM_INCREMENT, MIN_ZOOM, MAX_ZOOM); return roundToTwoDecimalPlaces(newZoom); }; interface GetMouse { interactiveElement: HTMLElement; zoom: number; scroll: Scroll; lastMouse: Mouse; mouseEvent: SlimMouseEvent; rendererSize: Size; } export const getMouse = ({ interactiveElement, zoom, scroll, lastMouse, mouseEvent, rendererSize }: GetMouse): Mouse => { const componentOffset = interactiveElement.getBoundingClientRect(); const offset: Coords = { x: componentOffset?.left ?? 0, y: componentOffset?.top ?? 0 }; const { clientX, clientY } = mouseEvent; const mousePosition = { x: clientX - offset.x, y: clientY - offset.y }; const newPosition: Mouse['position'] = { screen: mousePosition, tile: screenToIso({ mouse: mousePosition, zoom, scroll, rendererSize }) }; const newDelta: Mouse['delta'] = { screen: CoordsUtils.subtract(newPosition.screen, lastMouse.position.screen), tile: CoordsUtils.subtract(newPosition.tile, lastMouse.position.tile) }; const getMousedown = (): Mouse['mousedown'] => { switch (mouseEvent.type) { case 'mousedown': return newPosition; case 'mousemove': return lastMouse.mousedown; default: return null; } }; const nextMouse: Mouse = { position: newPosition, delta: newDelta, mousedown: getMousedown() }; return nextMouse; }; export const getAllAnchors = (connectors: Connector[]) => { return connectors.reduce((acc, connector) => { return [...acc, ...connector.anchors]; }, [] as ConnectorAnchor[]); }; export const getAnchorTile = (anchor: ConnectorAnchor, view: View): Coords => { if (anchor.ref.item) { const viewItem = getItemByIdOrThrow(view.items, anchor.ref.item).value; return viewItem.tile; } if (anchor.ref.anchor) { const allAnchors = getAllAnchors(view.connectors ?? []); const nextAnchor = getItemByIdOrThrow(allAnchors, anchor.ref.anchor).value; return getAnchorTile(nextAnchor, view); } if (anchor.ref.tile) { return anchor.ref.tile; } throw new Error('Could not get anchor tile.'); }; interface NormalisePositionFromOrigin { position: Coords; origin: Coords; } export const normalisePositionFromOrigin = ({ position, origin }: NormalisePositionFromOrigin) => { return CoordsUtils.subtract(origin, position); }; interface GetConnectorPath { anchors: ConnectorAnchor[]; view: View; } export const getConnectorPath = ({ anchors, view }: GetConnectorPath): { tiles: Coords[]; rectangle: Rect; } => { if (anchors.length < 2) throw new Error( `Connector needs at least two anchors (receieved: ${anchors.length})` ); const anchorPosition = anchors.map((anchor) => { return getAnchorTile(anchor, view); }); const searchArea = getBoundingBox(anchorPosition, CONNECTOR_SEARCH_OFFSET); const sorted = sortByPosition(searchArea); const searchAreaSize = getBoundingBoxSize(searchArea); const rectangle = { from: { x: sorted.highX, y: sorted.highY }, to: { x: sorted.lowX, y: sorted.lowY } }; const positionsNormalisedFromSearchArea = anchorPosition.map((position) => { return normalisePositionFromOrigin({ position, origin: rectangle.from }); }); const tiles = positionsNormalisedFromSearchArea.reduce( (acc, position, i) => { if (i === 0) return acc; const prev = positionsNormalisedFromSearchArea[i - 1]; const path = findPath({ from: prev, to: position, gridSize: searchAreaSize }); return [...acc, ...path]; }, [] ); return { tiles, rectangle }; }; type GetRectangleFromSize = ( from: Coords, size: Size ) => { from: Coords; to: Coords }; export const getRectangleFromSize: GetRectangleFromSize = (from, size) => { return { from, to: { x: from.x + size.width, y: from.y + size.height } }; }; export const hasMovedTile = (mouse: Mouse) => { if (!mouse.delta) return false; return !CoordsUtils.isEqual(mouse.delta.tile, CoordsUtils.zero()); }; export const connectorPathTileToGlobal = ( tile: Coords, origin: Coords ): Coords => { return CoordsUtils.subtract( CoordsUtils.subtract(origin, CONNECTOR_SEARCH_OFFSET), CoordsUtils.subtract(tile, CONNECTOR_SEARCH_OFFSET) ); }; export const getTextBoxEndTile = (textBox: TextBox, size: Size) => { if (textBox.orientation === ProjectionOrientationEnum.X) { return CoordsUtils.add(textBox.tile, { x: size.width, y: 0 }); } return CoordsUtils.add(textBox.tile, { x: 0, y: -size.width }); }; interface GetItemAtTile { tile: Coords; scene: ReturnType; } export const getItemAtTile = ({ tile, scene }: GetItemAtTile): ItemReference | null => { const viewItem = scene.items.find((item) => { return CoordsUtils.isEqual(item.tile, tile); }); if (viewItem) { return { type: 'ITEM', id: viewItem.id }; } const textBox = scene.textBoxes.find((tb) => { const textBoxTo = getTextBoxEndTile(tb, tb.size); const textBoxBounds = getBoundingBox([ tb.tile, { x: Math.ceil(textBoxTo.x), y: tb.orientation === 'X' ? Math.ceil(textBoxTo.y) : Math.floor(textBoxTo.y) } ]); return isWithinBounds(tile, textBoxBounds); }); if (textBox) { return { type: 'TEXTBOX', id: textBox.id }; } const connector = scene.connectors.find((con) => { return con.path.tiles.find((pathTile) => { const globalPathTile = connectorPathTileToGlobal( pathTile, con.path.rectangle.from ); return CoordsUtils.isEqual(globalPathTile, tile); }); }); if (connector) { return { type: 'CONNECTOR', id: connector.id }; } const rectangle = scene.rectangles.find(({ from, to }) => { return isWithinBounds(tile, [from, to]); }); if (rectangle) { return { type: 'RECTANGLE', id: rectangle.id }; } return null; }; interface FontProps { fontWeight: number | string; fontSize: number; fontFamily: string; } export const getTextWidth = (text: string, fontProps: FontProps) => { if (!text) return 0; const paddingX = TEXTBOX_PADDING * UNPROJECTED_TILE_SIZE; const fontSizePx = toPx(fontProps.fontSize * UNPROJECTED_TILE_SIZE); const canvas: HTMLCanvasElement = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) { throw new Error('Could not get canvas context'); } context.font = `${fontProps.fontWeight} ${fontSizePx} ${fontProps.fontFamily}`; const metrics = context.measureText(text); canvas.remove(); return (metrics.width + paddingX * 2) / UNPROJECTED_TILE_SIZE - 0.8; }; export const getTextBoxDimensions = (textBox: TextBox): Size => { const width = getTextWidth(textBox.content, { fontSize: textBox.fontSize ?? TEXTBOX_DEFAULTS.fontSize, fontFamily: DEFAULT_FONT_FAMILY, fontWeight: TEXTBOX_FONT_WEIGHT }); const height = 1; return { width, height }; }; export const outermostCornerPositions: TileOrigin[] = [ 'BOTTOM', 'RIGHT', 'TOP', 'LEFT' ]; export const convertBoundsToNamedAnchors = ( boundingBox: BoundingBox ): { [key in AnchorPosition]: Coords; } => { return { BOTTOM_LEFT: boundingBox[0], BOTTOM_RIGHT: boundingBox[1], TOP_RIGHT: boundingBox[2], TOP_LEFT: boundingBox[3] }; }; export const getAnchorAtTile = (tile: Coords, anchors: ConnectorAnchor[]) => { return anchors.find((anchor) => { return Boolean( anchor.ref.tile && CoordsUtils.isEqual(anchor.ref.tile, tile) ); }); }; export const getAnchorParent = (anchorId: string, connectors: Connector[]) => { const connector = connectors.find((con) => { return con.anchors.find((anchor) => { return anchor.id === anchorId; }); }); if (!connector) { throw new Error(`Could not find connector with anchor id ${anchorId}`); } return connector; }; export const getTileScrollPosition = ( tile: Coords, origin?: TileOrigin ): Coords => { const tilePosition = getTilePosition({ tile, origin }); return { x: -tilePosition.x, y: -tilePosition.y }; }; export const getConnectorsByViewItem = ( viewItemId: string, connectors: Connector[] ) => { return connectors.filter((connector) => { return connector.anchors.find((anchor) => { return anchor.ref.item === viewItemId; }); }); }; export const getConnectorDirectionIcon = (connectorTiles: Coords[]) => { if (connectorTiles.length < 2) return null; const iconTile = connectorTiles[connectorTiles.length - 2]; const lastTile = connectorTiles[connectorTiles.length - 1]; let rotation; if (lastTile.x > iconTile.x) { if (lastTile.y > iconTile.y) { rotation = 135; } else if (lastTile.y < iconTile.y) { rotation = 45; } else { rotation = 90; } } if (lastTile.x < iconTile.x) { if (lastTile.y > iconTile.y) { rotation = -135; } else if (lastTile.y < iconTile.y) { rotation = -45; } else { rotation = -90; } } if (lastTile.x === iconTile.x) { if (lastTile.y > iconTile.y) { rotation = 180; } else if (lastTile.y < iconTile.y) { rotation = 0; } else { rotation = -90; } } return { x: iconTile.x * UNPROJECTED_TILE_SIZE + UNPROJECTED_TILE_SIZE / 2, y: iconTile.y * UNPROJECTED_TILE_SIZE + UNPROJECTED_TILE_SIZE / 2, rotation }; }; export const getProjectBounds = ( view: View, padding = PROJECT_BOUNDING_BOX_PADDING ): Coords[] => { const itemTiles = view.items.map((item) => { return item.tile; }); const connectors = view.connectors ?? []; const connectorTiles = connectors.reduce((acc, connector) => { const path = getConnectorPath({ anchors: connector.anchors, view }); return [...acc, path.rectangle.from, path.rectangle.to]; }, []); const rectangles = view.rectangles ?? []; const rectangleTiles = rectangles.reduce((acc, rectangle) => { return [...acc, rectangle.from, rectangle.to]; }, []); const textBoxes = view.textBoxes ?? []; const textBoxTiles = textBoxes.reduce((acc, textBox) => { const size = getTextBoxDimensions(textBox); return [ ...acc, textBox.tile, CoordsUtils.add(textBox.tile, { x: size.width, y: size.height }) ]; }, []); let allTiles = [ ...itemTiles, ...connectorTiles, ...rectangleTiles, ...textBoxTiles ]; if (allTiles.length === 0) { const centerTile = CoordsUtils.zero(); allTiles = [centerTile, centerTile, centerTile, centerTile]; } const corners = getBoundingBox(allTiles, { x: padding, y: padding }); return corners; }; export const getVisualBounds = (view: View, padding = 50) => { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; // Collect actual content positions and find extremes view.items.forEach((item) => { const pos = getTilePosition({ tile: item.tile }); const itemSize = 50; minX = Math.min(minX, pos.x - itemSize/2); maxX = Math.max(maxX, pos.x + itemSize/2); minY = Math.min(minY, pos.y - itemSize/2); maxY = Math.max(maxY, pos.y + itemSize/2); }); const connectors = view.connectors ?? []; connectors.forEach((connector) => { const path = getConnectorPath({ anchors: connector.anchors, view }); path.tiles.forEach((tile) => { const globalTile = connectorPathTileToGlobal(tile, path.rectangle.from); const pos = getTilePosition({ tile: globalTile }); minX = Math.min(minX, pos.x); maxX = Math.max(maxX, pos.x); minY = Math.min(minY, pos.y); maxY = Math.max(maxY, pos.y); }); }); const textBoxes = view.textBoxes ?? []; textBoxes.forEach((textBox) => { const pos = getTilePosition({ tile: textBox.tile }); const size = getTextBoxDimensions(textBox); const endPos = getTilePosition({ tile: getTextBoxEndTile(textBox, size) }); minX = Math.min(minX, pos.x, endPos.x); maxX = Math.max(maxX, pos.x, endPos.x); minY = Math.min(minY, pos.y, endPos.y); maxY = Math.max(maxY, pos.y, endPos.y); }); const rectangles = view.rectangles ?? []; rectangles.forEach((rectangle) => { const fromPos = getTilePosition({ tile: rectangle.from }); const toPos = getTilePosition({ tile: rectangle.to }); minX = Math.min(minX, fromPos.x, toPos.x); maxX = Math.max(maxX, fromPos.x, toPos.x); minY = Math.min(minY, fromPos.y, toPos.y); maxY = Math.max(maxY, fromPos.y, toPos.y); }); if (minX === Infinity) { return { x: 0, y: 0, width: 200, height: 200 }; } // Create tight bounds around actual content extremes return { x: minX - padding, y: minY - padding, width: (maxX - minX) + (padding * 2), height: (maxY - minY) + (padding * 2) }; }; export const getUnprojectedBounds = (view: View) => { const projectBounds = getProjectBounds(view); const cornerPositions = projectBounds.map((corner) => { return getTilePosition({ tile: corner }); }); const sortedCorners = sortByPosition(cornerPositions); const topLeft = { x: sortedCorners.lowX, y: sortedCorners.lowY }; const size = getBoundingBoxSize(cornerPositions); return { width: size.width, height: size.height, x: topLeft.x, y: topLeft.y }; }; export const getFitToViewParams = (view: View, viewportSize: Size) => { const projectBounds = getProjectBounds(view); const sortedCornerPositions = sortByPosition(projectBounds); const boundingBoxSize = getBoundingBoxSize(projectBounds); const unprojectedBounds = getUnprojectedBounds(view); const zoom = clamp( Math.min( viewportSize.width / unprojectedBounds.width, viewportSize.height / unprojectedBounds.height ), 0, MAX_ZOOM ); const scrollTarget: Coords = { x: (sortedCornerPositions.lowX + boundingBoxSize.width / 2) * zoom, y: (sortedCornerPositions.lowY + boundingBoxSize.height / 2) * zoom }; const scroll = getTileScrollPosition(scrollTarget); return { zoom, scroll }; }; ================================================ FILE: packages/fossflow-lib/tsconfig.declaration.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "emitDeclarationOnly": true, "declaration": true, "declarationMap": false, "noEmit": false, "outDir": "./dist" }, "exclude": ["node_modules", "./dist", "./docs", "**/*.test.ts", "**/*.test.tsx"], "include": [ "src/**/*.ts", "src/**/*.tsx", "src/global.d.ts" ] } ================================================ FILE: packages/fossflow-lib/tsconfig.dev.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": false, "emitDeclarationOnly": false } } ================================================ FILE: packages/fossflow-lib/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./src", "paths": { "src/*": ["./*"] }, "outDir": "./dist", "declaration": false, "declarationMap": false }, "exclude": ["node_modules", "./dist", "./docs"], "include": [ "src/**/*.ts", "src/**/*.tsx", "src/global.d.ts" ] } ================================================ FILE: scripts/update-version.js ================================================ #!/usr/bin/env node /** * Updates version numbers across all packages in the monorepo * Used by semantic-release to sync versions */ const fs = require('fs'); const path = require('path'); const version = process.argv[2]; if (!version) { console.error('Error: Version number required'); process.exit(1); } console.log(`Updating all packages to version ${version}...`); // List of package.json files to update const packageFiles = [ 'package.json', 'packages/fossflow-lib/package.json', 'packages/fossflow-app/package.json', 'packages/fossflow-backend/package.json' ]; packageFiles.forEach(file => { const filePath = path.join(process.cwd(), file); if (!fs.existsSync(filePath)) { console.warn(`Warning: ${file} not found, skipping...`); return; } try { const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8')); packageJson.version = version; fs.writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n'); console.log(`Updated ${file} to ${version}`); } catch (error) { console.error(`Error updating ${file}:`, error.message); process.exit(1); } }); console.log('Version update complete!'); ================================================ FILE: test-app.html ================================================ Test FossFLOW App

Testing FossFLOW App

================================================ FILE: test-base-paths.sh ================================================ #!/bin/bash # Test FossFLOW deployment at different base paths # This simulates how the app will be served on GitHub Pages or other platforms with subpaths set -e echo "Testing FossFLOW at multiple base paths..." # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Base paths to test BASE_PATHS=("/" "/fossflow" "/apps/fossflow" "/my-org/projects/fossflow") # Function to cleanup cleanup() { echo -e "\n${YELLOW}Cleaning up...${NC}" # Stop any running containers docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true docker stop selenium-test 2>/dev/null || true docker rm selenium-test 2>/dev/null || true # Kill any local servers if [ -f /tmp/server.pid ]; then kill $(cat /tmp/server.pid) 2>/dev/null || true rm /tmp/server.pid fi } # Set trap to cleanup on exit trap cleanup EXIT # Function to test a specific base path test_base_path() { local BASE_PATH=$1 echo -e "\n${YELLOW}Testing base path: ${BASE_PATH}${NC}" # Clean up any previous test docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true # Build the app with the specific PUBLIC_URL echo "Building app with PUBLIC_URL=${BASE_PATH}..." PUBLIC_URL="${BASE_PATH}" npm run build:app # Create nginx config for this base path if [ "$BASE_PATH" = "/" ]; then LOCATION_PATH="/" ALIAS_PATH="/usr/share/nginx/html/" else LOCATION_PATH="${BASE_PATH%/}/" ALIAS_PATH="/usr/share/nginx/html/" fi cat > /tmp/nginx.conf < /dev/null; then echo -e "${GREEN}✓ App accessible at http://localhost:3001${BASE_PATH}${NC}" else echo -e "${RED}✗ App NOT accessible at http://localhost:3001${BASE_PATH}${NC}" echo "Nginx logs:" docker logs nginx-test return 1 fi # Run E2E tests if Selenium is available if docker ps | grep selenium-test > /dev/null; then echo "Running E2E tests..." FOSSFLOW_TEST_URL="http://localhost:3001${BASE_PATH}" \ FOSSFLOW_BASE_PATH="${BASE_PATH}" \ WEBDRIVER_URL="http://localhost:4444" \ pytest tests/test_base_path_routing.py -v --tb=short || { echo -e "${RED}✗ E2E tests failed for base path: ${BASE_PATH}${NC}" return 1 } echo -e "${GREEN}✓ E2E tests passed for base path: ${BASE_PATH}${NC}" else echo -e "${YELLOW}Selenium not running, skipping E2E tests${NC}" echo "To run E2E tests, start Selenium first:" echo " docker run -d --name selenium-test --network host selenium/standalone-chrome:latest" fi # Clean up this test's nginx docker stop nginx-test 2>/dev/null || true docker rm nginx-test 2>/dev/null || true return 0 } # Main execution echo "Setting up test environment..." # Check if Selenium is running, offer to start it if ! docker ps | grep selenium > /dev/null; then echo -e "${YELLOW}Selenium is not running. Would you like to start it for E2E tests? (y/n)${NC}" read -r response if [[ "$response" == "y" ]]; then echo "Starting Selenium..." docker run -d \ --name selenium-test \ --network host \ --shm-size=2g \ selenium/standalone-chrome:latest echo "Waiting for Selenium to be ready..." timeout 30 bash -c 'until curl -sf http://localhost:4444/status > /dev/null 2>&1; do sleep 2; done' || { echo -e "${RED}Selenium failed to start${NC}" exit 1 } echo -e "${GREEN}✓ Selenium is ready${NC}" fi fi # Test each base path FAILED_PATHS=() for BASE_PATH in "${BASE_PATHS[@]}"; do if ! test_base_path "$BASE_PATH"; then FAILED_PATHS+=("$BASE_PATH") fi done # Summary echo -e "\n=========================================" echo "Test Summary:" echo "=========================================" if [ ${#FAILED_PATHS[@]} -eq 0 ]; then echo -e "${GREEN}✓ All base paths tested successfully!${NC}" echo "Tested paths: ${BASE_PATHS[*]}" else echo -e "${RED}✗ Some base paths failed:${NC}" for path in "${FAILED_PATHS[@]}"; do echo " - $path" done echo -e "\n${YELLOW}This indicates the app may not work correctly when deployed to GitHub Pages or other subpath deployments.${NC}" exit 1 fi ================================================ FILE: tsconfig.base.json ================================================ { "compilerOptions": { "target": "es6", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "declaration": true, "declarationMap": true, "sourceMap": true, "noImplicitAny": true, "useUnknownInCatchVariables": false }, "exclude": ["node_modules", "dist", "build"] }